diff --git a/src/controls/dynamicForm/dynamicField/IDynamicFieldProps.ts b/src/controls/dynamicForm/dynamicField/IDynamicFieldProps.ts
index 33b62c40c..44208562d 100644
--- a/src/controls/dynamicForm/dynamicField/IDynamicFieldProps.ts
+++ b/src/controls/dynamicForm/dynamicField/IDynamicFieldProps.ts
@@ -2,6 +2,7 @@ import { BaseComponentContext } from '@microsoft/sp-component-base';
import { IDropdownOption } from "@fluentui/react/lib/Dropdown";
import { IStyle, IStyleFunctionOrObject, Theme } from '@fluentui/react';
import { IFilePickerResult } from '../../filePicker';
+import { ChoiceFieldFormatType } from '@pnp/sp/fields';
export type DateFormat = 'DateTime' | 'DateOnly';
export type FieldChangeAdditionalData = IFilePickerResult;
@@ -96,6 +97,7 @@ export interface IDynamicFieldProps {
itemsQueryCountLimit?: number;
customIcon?: string;
orderBy?: string;
+ choiceType?: ChoiceFieldFormatType;
/** Used for customize component styling */
styles?:IStyleFunctionOrObject;
}
diff --git a/src/controls/filePicker/FilePicker.tsx b/src/controls/filePicker/FilePicker.tsx
index 32b78215e..0859c53e4 100644
--- a/src/controls/filePicker/FilePicker.tsx
+++ b/src/controls/filePicker/FilePicker.tsx
@@ -36,6 +36,7 @@ import { StockImages } from "./StockImagesTab/StockImages";
import UploadFilePickerTab from "./UploadFilePickerTab/UploadFilePickerTab";
import MultipleUploadFilePickerTab from "./MultipleUploadFilePickerTab/MultipleUploadFilePickerTab";
import WebSearchTab from "./WebSearchTab/WebSearchTab";
+import { FilePickerTab } from "./FilePickerTab";
export class FilePicker extends React.Component<
@@ -89,7 +90,7 @@ export class FilePicker extends React.Component<
this.setState({
organisationAssetsEnabled: orgAssetsEnabled,
- selectedTab: this.getDefaultSelectedTabKey(this.props, orgAssetsEnabled),
+ selectedTab: this._getDefaultSelectedTabKey(this.props, orgAssetsEnabled),
});
if (!!this.props.context && !!this.props.webAbsoluteUrl) {
const { title, id } = await this.fileBrowserService.getSiteTitleAndId();
@@ -190,7 +191,7 @@ export class FilePicker extends React.Component<
/>
- {this.state.selectedTab === "keyLink" && (
+ {this.state.selectedTab === FilePickerTab.Link && (
)}
- {this.state.selectedTab === "keyUpload" && (
+ {this.state.selectedTab === FilePickerTab.Upload && (
)}
- {this.state.selectedTab === "keyMultipleUpload" && (
+ {this.state.selectedTab === FilePickerTab.MultipleUpload && (
)}
- {this.state.selectedTab === "keySite" && (
+ {this.state.selectedTab === FilePickerTab.Site && (
)}
- {this.state.selectedTab === "keyOrgAssets" && (
+ {this.state.selectedTab === FilePickerTab.OrgAssets && (
)}
- {this.state.selectedTab === "keyWeb" && (
+ {this.state.selectedTab === FilePickerTab.Web && (
)}
- {this.state.selectedTab === "keyOneDrive" && (
+ {this.state.selectedTab === FilePickerTab.OneDrive && (
)}
- {this.state.selectedTab === "keyRecent" && (
+ {this.state.selectedTab === FilePickerTab.Recent && (
)}
- {this.state.selectedTab === "keyStockImages" && (
+ {this.state.selectedTab === FilePickerTab.StockImages && (
{
this.setState({
panelOpen: true,
- selectedTab: this.getDefaultSelectedTabKey(
+ selectedTab: this._getDefaultSelectedTabKey(
this.props,
this.state.organisationAssetsEnabled
),
@@ -344,21 +345,21 @@ export class FilePicker extends React.Component<
*/
private _getNavPanelOptions = (): INavLinkGroup[] => {
const addUrl = this.props.storeLastActiveTab !== false;
- const links = [];
+ let links = [];
if (!this.props.hideRecentTab) {
links.push({
name: strings.RecentLinkLabel,
url: addUrl ? "#recent" : undefined,
icon: "Recent",
- key: "keyRecent",
+ key: FilePickerTab.Recent,
});
}
if (!this.props.hideStockImages) {
links.push({
name: strings.StockImagesLinkLabel,
url: addUrl ? "#stockImages" : undefined,
- key: "keyStockImages",
+ key: FilePickerTab.StockImages,
icon: "ImageSearch",
});
}
@@ -366,7 +367,7 @@ export class FilePicker extends React.Component<
links.push({
name: strings.WebSearchLinkLabel,
url: addUrl ? "#search" : undefined,
- key: "keyWeb",
+ key: FilePickerTab.Web,
icon: "Search",
});
}
@@ -378,14 +379,14 @@ export class FilePicker extends React.Component<
name: strings.OrgAssetsLinkLabel,
url: addUrl ? "#orgAssets" : undefined,
icon: "FabricFolderConfirm",
- key: "keyOrgAssets",
+ key: FilePickerTab.OrgAssets,
});
}
if (!this.props.hideOneDriveTab) {
links.push({
name: "OneDrive",
url: addUrl ? "#onedrive" : undefined,
- key: "keyOneDrive",
+ key: FilePickerTab.OneDrive,
icon: "OneDrive",
});
}
@@ -393,7 +394,7 @@ export class FilePicker extends React.Component<
links.push({
name: strings.SiteLinkLabel,
url: addUrl ? "#globe" : undefined,
- key: "keySite",
+ key: FilePickerTab.Site,
icon: "Globe",
});
}
@@ -401,7 +402,7 @@ export class FilePicker extends React.Component<
links.push({
name: strings.UploadLinkLabel,
url: addUrl ? "#upload" : undefined,
- key: "keyUpload",
+ key: FilePickerTab.Upload,
icon: "System",
});
}
@@ -409,7 +410,7 @@ export class FilePicker extends React.Component<
links.push({
name: strings.UploadLinkLabel + " " + strings.OneDriveRootFolderName,
url: addUrl ? "#Multipleupload" : undefined,
- key: "keyMultipleUpload",
+ key: FilePickerTab.MultipleUpload,
icon: "BulkUpload",
});
}
@@ -417,46 +418,68 @@ export class FilePicker extends React.Component<
links.push({
name: strings.FromLinkLinkLabel,
url: addUrl ? "#link" : undefined,
- key: "keyLink",
+ key: FilePickerTab.Link,
icon: "Link",
});
}
+ if(this.props.tabOrder) {
+ links = this._getTabOrder(links);
+ }
+
const groups: INavLinkGroup[] = [{ links }];
return groups;
}
- private getDefaultSelectedTabKey = (
+ /**
+ * Sorts navigation tabs based on the tabOrder prop
+ */
+ private _getTabOrder = (links): INavLink[] => {
+ const sortedKeys = [
+ ...this.props.tabOrder,
+ ...links.map(l => l.key).filter(key => !this.props.tabOrder.includes(key)),
+ ];
+
+ links.sort((a, b) => {
+ return sortedKeys.indexOf(a.key) - sortedKeys.indexOf(b.key);
+ });
+
+ return links;
+ };
+
+ /**
+ * Returns the default selected tab key
+ */
+ private _getDefaultSelectedTabKey = (
props: IFilePickerProps,
orgAssetsEnabled: boolean
): string => {
- if (!props.hideRecentTab) {
- return "keyRecent";
- }
- if (!props.hideStockImages) {
- return "keyStockImages";
- }
- if (props.bingAPIKey && !props.hideWebSearchTab) {
- return "keyWeb";
- }
- if (!props.hideOrganisationalAssetTab && orgAssetsEnabled) {
- return "keyOrgAssets";
- }
- if (!props.hideOneDriveTab) {
- return "keyOneDrive";
- }
- if (!props.hideSiteFilesTab) {
- return "keySite";
- }
- if (!props.hideLocalUploadTab) {
- return "keyUpload";
- }
- if (!props.hideLinkUploadTab) {
- return "keyLink";
- }
- if (!props.hideLocalMultipleUploadTab) {
- return "keyMultipleUpload";
+ const tabsConfig = [
+ { isTabVisible: !props.hideRecentTab, tabKey: FilePickerTab.Recent },
+ { isTabVisible: !props.hideStockImages, tabKey: FilePickerTab.StockImages },
+ { isTabVisible: props.bingAPIKey && !props.hideWebSearchTab, tabKey: FilePickerTab.Web },
+ { isTabVisible: !props.hideOrganisationalAssetTab && orgAssetsEnabled, tabKey: FilePickerTab.OrgAssets },
+ { isTabVisible: !props.hideOneDriveTab, tabKey: FilePickerTab.OneDrive },
+ { isTabVisible: !props.hideSiteFilesTab, tabKey: FilePickerTab.Site },
+ { isTabVisible: !props.hideLocalUploadTab, tabKey: FilePickerTab.Upload },
+ { isTabVisible: !props.hideLinkUploadTab, tabKey: FilePickerTab.Link },
+ { isTabVisible: !props.hideLocalMultipleUploadTab, tabKey: FilePickerTab.MultipleUpload }
+ ];
+
+ const visibleTabs = tabsConfig.filter(tab => tab.isTabVisible);
+ const visibleTabKeys = visibleTabs.map(tab => tab.tabKey);
+
+ // If defaultSelectedTab is provided and is visible, then return tabKey
+ if(this.props.defaultSelectedTab && visibleTabKeys.includes(this.props.defaultSelectedTab)) {
+ return this.props.defaultSelectedTab;
}
+ // If no valid default tab is provided, find the first visible tab in the order
+ if (this.props.tabOrder) {
+ const visibleTabSet = new Set(visibleTabKeys);
+ return this.props.tabOrder.find(key => visibleTabSet.has(key));
+ } else {
+ return visibleTabKeys[0]; // first visible tab from default order
+ }
}
}
diff --git a/src/controls/filePicker/FilePickerTab.ts b/src/controls/filePicker/FilePickerTab.ts
new file mode 100644
index 000000000..9094e9bbf
--- /dev/null
+++ b/src/controls/filePicker/FilePickerTab.ts
@@ -0,0 +1,11 @@
+export enum FilePickerTab {
+ Recent = "keyRecent",
+ StockImages = "keyStockImages",
+ Web = "keyWeb",
+ OrgAssets = "keyOrgAssets",
+ OneDrive = "keyOneDrive",
+ Site = "keySite",
+ Upload = "keyUpload",
+ Link = "keyLink",
+ MultipleUpload = "keyMultipleUpload"
+}
diff --git a/src/controls/filePicker/IFilePickerProps.ts b/src/controls/filePicker/IFilePickerProps.ts
index 903c850fc..64a567f4d 100644
--- a/src/controls/filePicker/IFilePickerProps.ts
+++ b/src/controls/filePicker/IFilePickerProps.ts
@@ -3,6 +3,7 @@ import { IIconProps } from "@fluentui/react/lib/Icon";
import { BaseComponentContext } from '@microsoft/sp-component-base';
import { IFilePickerResult } from "./FilePicker.types";
+import { FilePickerTab } from "./FilePickerTab";
export interface IFilePickerProps {
/**
@@ -175,4 +176,14 @@ export interface IFilePickerProps {
* Specifies if file check should be done
*/
checkIfFileExists?: boolean;
+ /**
+ * Specifies tab order
+ * Default [FilePickerTab.Recent, FilePickerTab.StockImages, FilePickerTab.Web, FilePickerTab.OrgAssets, FilePickerTab.OneDrive, FilePickerTab.Site, FilePickerTab.Upload, FilePickerTab.Link, FilePickerTab.MultipleUpload]
+ */
+ tabOrder?: FilePickerTab[];
+ /**
+ * Specifies default selected tab
+ * One of the values from the FilePickerTab enum: Recent, StockImages, Web, OrgAssets, OneDrive, Site, Upload, Link, or MultipleUpload.
+ */
+ defaultSelectedTab?: FilePickerTab;
}
diff --git a/src/controls/filePicker/index.ts b/src/controls/filePicker/index.ts
index 64d58e192..b484b2be8 100644
--- a/src/controls/filePicker/index.ts
+++ b/src/controls/filePicker/index.ts
@@ -2,3 +2,4 @@ export * from "./FilePicker";
export * from "./FilePicker.types";
export * from "./IFilePickerProps";
export * from "./IFilePickerState";
+export * from "./FilePickerTab";
diff --git a/src/controls/fileTypeIcon/FileTypeIcon.test.tsx b/src/controls/fileTypeIcon/FileTypeIcon.test.tsx
index 9070548f2..797e0588e 100644
--- a/src/controls/fileTypeIcon/FileTypeIcon.test.tsx
+++ b/src/controls/fileTypeIcon/FileTypeIcon.test.tsx
@@ -183,14 +183,14 @@ describe('', () => {
});
it('Image icon size test with unkown size', (done) => {
- fileTypeIcon = mount();
+ fileTypeIcon = mount();
expect(fileTypeIcon.find('div.ms-BrandIcon--icon16')).to.have.length(1);
expect(fileTypeIcon.find('i')).to.have.length(0);
done();
});
it('Image icon size test with unkown size for generic icon', (done) => {
- fileTypeIcon = mount();
+ fileTypeIcon = mount();
expect(fileTypeIcon.find('div img')).to.have.length(1);
expect(fileTypeIcon.find('div.ms-BrandIcon--icon16')).to.have.length(0);
expect(fileTypeIcon.find('i')).to.have.length(0);
@@ -198,7 +198,7 @@ describe('', () => {
});
it('Image icon test with unkown application', (done) => {
- fileTypeIcon = mount();
+ fileTypeIcon = mount();
expect(fileTypeIcon.find('div img')).to.have.length(1);
expect(fileTypeIcon.find('div.ms-BrandIcon--icon16')).to.have.length(0);
expect(fileTypeIcon.find('i')).to.have.length(0);
diff --git a/src/controls/listItemComments/common/ECommentAction.ts b/src/controls/listItemComments/common/ECommentAction.ts
index 1fd9bde23..50ea34b2e 100644
--- a/src/controls/listItemComments/common/ECommentAction.ts
+++ b/src/controls/listItemComments/common/ECommentAction.ts
@@ -1,4 +1,6 @@
export enum ECommentAction {
- "ADD" = "ADD",
- "DELETE" = "DELETE"
+ 'ADD' = 'ADD',
+ 'DELETE' = 'DELETE',
+ 'LIKE' = 'LIKE',
+ 'UNLIKE' = 'UNLIKE',
}
diff --git a/src/controls/listItemComments/common/constants.ts b/src/controls/listItemComments/common/constants.ts
index c7eacd957..3261121fa 100644
--- a/src/controls/listItemComments/common/constants.ts
+++ b/src/controls/listItemComments/common/constants.ts
@@ -1,2 +1,3 @@
export const PHOTO_URL = "/_layouts/15/userphoto.aspx?size=M&accountname=";
export const TILE_HEIGHT: number = 70;
+export const URL_REGEX = /(https?:\/\/(?:[-\w.])+(?::[0-9]+)?(?:\/[^\s]*)?)/g;
diff --git a/src/controls/listItemComments/components/Comments/CommentText.tsx b/src/controls/listItemComments/components/Comments/CommentText.tsx
index 4b46d239d..21742ed6a 100644
--- a/src/controls/listItemComments/components/Comments/CommentText.tsx
+++ b/src/controls/listItemComments/components/Comments/CommentText.tsx
@@ -3,11 +3,12 @@ import { useContext, useEffect, useState } from "react";
import { Mention } from "./IComment";
import { Text } from "@fluentui/react/lib/Text";
import { LivePersona } from "../../../LivePersona";
-import { AppContext } from "../../common";
-import regexifyString from "regexify-string";
-import { Stack } from "@fluentui/react/lib/Stack";
-import { isArray, isObject } from "lodash";
+import { AppContext, URL_REGEX } from '../../common';
+import regexifyString from 'regexify-string';
+import { Stack } from '@fluentui/react/lib/Stack';
+import { isArray, isObject } from 'lodash';
import he from 'he';
+import { Link } from '@fluentui/react';
export interface ICommentTextProps {
text: string;
mentions: Mention[];
@@ -46,26 +47,55 @@ export const CommentText: React.FunctionComponent = (
setCommentText(result);
}, []);
+ const convertTextToLinksAndText = (
+ text: string
+ ): (string | JSX.Element)[] => {
+ const parts = text.split(URL_REGEX);
+ return parts.map((part, index) => {
+ if (part.match(URL_REGEX)) {
+ return (
+
+ {part}
+
+ );
+ }
+ return part;
+ });
+ };
+
return (
<>
{isArray(commentText) ? (
- (commentText as any[]).map((el, i) => { // eslint-disable-line @typescript-eslint/no-explicit-any
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (commentText as any[]).map((el, i) => {
if (isObject(el)) {
- return {el};
+ return (
+
+ {el}
+
+ );
} else {
const _el: string = el.trim();
if (_el.length) {
return (
- {he.decode(_el)}
+ {convertTextToLinksAndText(he.decode(_el))}
);
}
}
})
) : (
- {he.decode(commentText)}
+
+ {convertTextToLinksAndText(he.decode(commentText))}
+
)}
>
diff --git a/src/controls/listItemComments/components/Comments/CommentsList.tsx b/src/controls/listItemComments/components/Comments/CommentsList.tsx
index 89bab65cd..1952b7c0c 100644
--- a/src/controls/listItemComments/components/Comments/CommentsList.tsx
+++ b/src/controls/listItemComments/components/Comments/CommentsList.tsx
@@ -20,8 +20,22 @@ import { RenderComments } from "./RenderComments";
export const CommentsList: React.FunctionComponent = () => {
const { listItemCommentsState, setlistItemCommentsState } = useContext(ListItemCommentsStateContext);
const { configurationListClasses } = useListItemCommentsStyles();
- const { getListItemComments, getNextPageOfComments, addComment, deleteComment } = useSpAPI();
- const { comments, isScrolling, pageInfo, commentAction, commentToAdd, selectedComment } = listItemCommentsState;
+ const {
+ getListItemComments,
+ getNextPageOfComments,
+ addComment,
+ deleteComment,
+ likeComment,
+ unlikeComment,
+ } = useSpAPI();
+ const {
+ comments,
+ isScrolling,
+ pageInfo,
+ commentAction,
+ commentToAdd,
+ selectedComment,
+ } = listItemCommentsState;
const { hasMore, nextLink } = pageInfo;
const scrollPanelRef = useRef();
const { errorInfo } = listItemCommentsState;
@@ -32,16 +46,23 @@ export const CommentsList: React.FunctionComponent = () => {
type: EListItemCommentsStateTypes.SET_IS_LOADING,
payload: true,
});
- const _commentsResults: IlistItemCommentsResults = await getListItemComments();
+ const _commentsResults: IlistItemCommentsResults =
+ await getListItemComments();
setlistItemCommentsState({
type: EListItemCommentsStateTypes.SET_LIST_ITEM_COMMENTS,
payload: _commentsResults.comments,
});
setlistItemCommentsState({
type: EListItemCommentsStateTypes.SET_DATA_PAGE_INFO,
- payload: { hasMore: _commentsResults.hasMore, nextLink: _commentsResults.nextLink } as IPageInfo,
+ payload: {
+ hasMore: _commentsResults.hasMore,
+ nextLink: _commentsResults.nextLink,
+ } as IPageInfo,
+ });
+ setlistItemCommentsState({
+ type: EListItemCommentsStateTypes.SET_COMMENT_ACTION,
+ payload: undefined,
});
- setlistItemCommentsState({ type: EListItemCommentsStateTypes.SET_COMMENT_ACTION, payload: undefined });
setlistItemCommentsState({
type: EListItemCommentsStateTypes.SET_IS_LOADING,
payload: false,
@@ -99,25 +120,110 @@ export const CommentsList: React.FunctionComponent = () => {
[setlistItemCommentsState, _loadComments]
);
+ const _onCommentLike = useCallback(
+ async (commentId: number) => {
+ try {
+ const _errorInfo: IErrorInfo = { showError: false, error: undefined };
+ setlistItemCommentsState({
+ type: EListItemCommentsStateTypes.SET_ERROR_INFO,
+ payload: _errorInfo,
+ });
+ await likeComment(commentId);
+ await _loadComments();
+ } catch (error) {
+ const _errorInfo: IErrorInfo = { showError: true, error: error };
+ setlistItemCommentsState({
+ type: EListItemCommentsStateTypes.SET_ERROR_INFO,
+ payload: _errorInfo,
+ });
+ }
+ },
+ [setlistItemCommentsState, _loadComments]
+ );
+ const _onCommentUnlike = useCallback(
+ async (commentId: number) => {
+ try {
+ const _errorInfo: IErrorInfo = { showError: false, error: undefined };
+ setlistItemCommentsState({
+ type: EListItemCommentsStateTypes.SET_ERROR_INFO,
+ payload: _errorInfo,
+ });
+ await unlikeComment(commentId);
+ await _loadComments();
+ } catch (error) {
+ const _errorInfo: IErrorInfo = { showError: true, error: error };
+ setlistItemCommentsState({
+ type: EListItemCommentsStateTypes.SET_ERROR_INFO,
+ payload: _errorInfo,
+ });
+ }
+ },
+ [setlistItemCommentsState, _loadComments]
+ );
+
useEffect(() => {
switch (commentAction) {
case ECommentAction.ADD:
(async () => {
// Add new comment
await _onAddComment(commentToAdd);
- })().then(() => { /* no-op; */}).catch(() => { /* no-op; */ });
+ })()
+ .then(() => {
+ /* no-op; */
+ })
+ .catch(() => {
+ /* no-op; */
+ });
+ break;
+ case ECommentAction.LIKE:
+ (async () => {
+ // Add new comment
+ const commentId = Number(selectedComment.id);
+ await _onCommentLike(commentId);
+ })()
+ .then(() => {
+ /* no-op; */
+ })
+ .catch(() => {
+ /* no-op; */
+ });
+ break;
+ case ECommentAction.UNLIKE:
+ (async () => {
+ // Add new comment
+ const commentId = Number(selectedComment.id);
+ await _onCommentUnlike(commentId);
+ })()
+ .then(() => {
+ /* no-op; */
+ })
+ .catch(() => {
+ /* no-op; */
+ });
break;
case ECommentAction.DELETE:
(async () => {
// delete comment
const commentId = Number(selectedComment.id);
await _onADeleteComment(commentId);
- })().then(() => { /* no-op; */}).catch(() => { /* no-op; */ });
+ })()
+ .then(() => {
+ /* no-op; */
+ })
+ .catch(() => {
+ /* no-op; */
+ });
break;
default:
break;
}
- }, [commentAction, selectedComment, commentToAdd, _onAddComment, _onADeleteComment]);
+ }, [
+ commentAction,
+ selectedComment,
+ commentToAdd,
+ _onAddComment,
+ _onADeleteComment,
+ ]);
useEffect(() => {
(async () => {
diff --git a/src/controls/listItemComments/components/Comments/LikedUserList.tsx b/src/controls/listItemComments/components/Comments/LikedUserList.tsx
new file mode 100644
index 000000000..848d2cdfe
--- /dev/null
+++ b/src/controls/listItemComments/components/Comments/LikedUserList.tsx
@@ -0,0 +1,72 @@
+import * as React from 'react';
+import {
+ IconButton,
+ IIconProps,
+ Modal,
+ Persona,
+ PersonaSize,
+ Stack,
+} from '@fluentui/react';
+import { useListItemCommentsStyles } from './useListItemCommentsStyles';
+
+interface ILikedUserListProps {
+ isDialogOpen: boolean;
+ setShowDialog: React.Dispatch>;
+ likedBy: any; // eslint-disable-line @typescript-eslint/no-explicit-any
+}
+
+const cancelIcon: IIconProps = { iconName: 'Cancel' };
+
+export const LikedUserList = ({
+ isDialogOpen,
+ setShowDialog,
+ likedBy,
+}: ILikedUserListProps): JSX.Element => {
+ const { iconButtonStyles, contentStyles } = useListItemCommentsStyles();
+
+ const PHOTO_URL = '/_layouts/15/userphoto.aspx?size=M&accountname=';
+
+ return (
+ setShowDialog(false)}
+ styles={{ main: { width: '480px' } }}
+ >
+
+
Liked by
+ setShowDialog(false)}
+ />
+
+
+ {likedBy.map(
+ (
+ user: any, // eslint-disable-line @typescript-eslint/no-explicit-any
+ index: number
+ ) => (
+ <>
+
+ >
+ )
+ )}
+
+
+ );
+};
diff --git a/src/controls/listItemComments/components/Comments/RenderComments.tsx b/src/controls/listItemComments/components/Comments/RenderComments.tsx
index 1c3bc3293..2e63ca331 100644
--- a/src/controls/listItemComments/components/Comments/RenderComments.tsx
+++ b/src/controls/listItemComments/components/Comments/RenderComments.tsx
@@ -1,36 +1,112 @@
-import { IconButton } from "@fluentui/react/lib/Button";
-import { DocumentCard, DocumentCardDetails } from "@fluentui/react/lib/DocumentCard";
-import { Stack } from "@fluentui/react/lib/Stack";
-import * as React from "react";
-import { useCallback } from "react";
-import { useContext } from "react";
-import { ConfirmDelete } from "../ConfirmDelete/ConfirmDelete";
-import { EListItemCommentsStateTypes, ListItemCommentsStateContext } from "../ListItemCommentsStateProvider";
-import { CommentItem } from "./CommentItem";
-import { IComment } from "./IComment";
-import { RenderSpinner } from "./RenderSpinner";
-import { useListItemCommentsStyles } from "./useListItemCommentsStyles";
-import { useBoolean } from "@fluentui/react-hooks";
-import { List } from "@fluentui/react/lib/List";
-import { AppContext, ECommentAction } from "../..";
+import { IconButton } from '@fluentui/react/lib/Button';
+import {
+ DocumentCard,
+ DocumentCardDetails,
+} from '@fluentui/react/lib/DocumentCard';
+import { Stack } from '@fluentui/react/lib/Stack';
+import * as React from 'react';
+import { useCallback, useState } from 'react';
+import { useContext } from 'react';
+import { ConfirmDelete } from '../ConfirmDelete/ConfirmDelete';
+import {
+ EListItemCommentsStateTypes,
+ ListItemCommentsStateContext,
+} from '../ListItemCommentsStateProvider';
+import { CommentItem } from './CommentItem';
+import { IComment } from './IComment';
+import { RenderSpinner } from './RenderSpinner';
+import { useListItemCommentsStyles } from './useListItemCommentsStyles';
+import { useBoolean } from '@fluentui/react-hooks';
+import { Link, List, Text } from '@fluentui/react';
+import { AppContext, ECommentAction } from '../..';
+import { LikedUserList } from './LikedUserList';
-export interface IRenderCommentsProps { }
+export interface IRenderCommentsProps {}
-export const RenderComments: React.FunctionComponent = () => {
+export const RenderComments: React.FunctionComponent<
+ IRenderCommentsProps
+> = () => {
const { highlightedCommentId } = useContext(AppContext);
- const { listItemCommentsState, setlistItemCommentsState } = useContext(ListItemCommentsStateContext);
- const { documentCardStyles,documentCardHighlightedStyles, itemContainerStyles, deleteButtonContainerStyles } = useListItemCommentsStyles();
+ const { listItemCommentsState, setlistItemCommentsState } = useContext(
+ ListItemCommentsStateContext
+ );
+ const {
+ documentCardStyles,
+ documentCardHighlightedStyles,
+ itemContainerStyles,
+ buttonsContainerStyles,
+ } = useListItemCommentsStyles();
const { comments, isLoading } = listItemCommentsState;
const [hideDialog, { toggle: setHideDialog }] = useBoolean(true);
+ const [showDialog, setShowDialog] = useState(false);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const [selectedLikedBy, setSelectedLikedBy] = useState([]);
+
+ const _likeComment = useCallback(() => {
+ setlistItemCommentsState({
+ type: EListItemCommentsStateTypes.SET_COMMENT_ACTION,
+ payload: ECommentAction.LIKE,
+ });
+ }, []);
+
+ const _unLikeComment = useCallback(() => {
+ setlistItemCommentsState({
+ type: EListItemCommentsStateTypes.SET_COMMENT_ACTION,
+ payload: ECommentAction.UNLIKE,
+ });
+ }, []);
const onRenderCell = useCallback(
(comment: IComment, index: number): JSX.Element => {
return (
-
-
+
+
+
+ {comment.likeCount > 0 ? (
+ {
+ setSelectedLikedBy(comment.likedBy);
+ setShowDialog(true);
+ }}
+ >
+ {comment.likeCount}
+
+ ) : (
+ {comment.likeCount}
+ )}
+
+ {
+ setlistItemCommentsState({
+ type: EListItemCommentsStateTypes.SET_SELECTED_COMMENT,
+ payload: comment,
+ });
+ if (!comment.isLikedByUser) {
+ _likeComment();
+ } else {
+ _unLikeComment();
+ }
+ }}
+ />
+
{
setlistItemCommentsState({
@@ -57,10 +133,13 @@ export const RenderComments: React.FunctionComponent = ()
},
[comments]
);
-
return (
<>
- {isLoading ? :
}
+ {isLoading ? (
+
+ ) : (
+
+ )}
{
@@ -73,6 +152,11 @@ export const RenderComments: React.FunctionComponent = ()
setHideDialog();
}}
/>
+
>
);
};
diff --git a/src/controls/listItemComments/components/Comments/useListItemCommentsStyles.ts b/src/controls/listItemComments/components/Comments/useListItemCommentsStyles.ts
index beefed387..73891ad20 100644
--- a/src/controls/listItemComments/components/Comments/useListItemCommentsStyles.ts
+++ b/src/controls/listItemComments/components/Comments/useListItemCommentsStyles.ts
@@ -2,16 +2,19 @@ import * as React from "react";
import { IDocumentCardStyles } from "@fluentui/react/lib/DocumentCard";
import { IStackStyles } from "@fluentui/react/lib/Stack";
import {
+ FontWeights,
+ getTheme,
IStyle,
mergeStyles,
mergeStyleSets,
-} from "@fluentui/react/lib/Styling";
-import { AppContext } from "../../common";
-import { TILE_HEIGHT } from "../../common/constants";
+} from '@fluentui/react/lib/Styling';
+import { AppContext } from '../../common';
+import { TILE_HEIGHT } from '../../common/constants';
+import { IButtonStyles } from '@fluentui/react';
interface returnObjectStyles {
itemContainerStyles: IStackStyles;
- deleteButtonContainerStyles: Partial;
+ buttonsContainerStyles: Partial;
userListContainerStyles: Partial;
renderUserContainerStyles: Partial;
documentCardStyles: Partial;
@@ -19,22 +22,31 @@ interface returnObjectStyles {
documentCardHighlightedStyles: Partial;
documentCardUserStyles: Partial;
configurationListClasses: any; // eslint-disable-line @typescript-eslint/no-explicit-any
+ contentStyles: any; // eslint-disable-line @typescript-eslint/no-explicit-any
+ iconButtonStyles: Partial;
}
export const useListItemCommentsStyles = (): returnObjectStyles => {
const { theme, numberCommentsPerPage } = React.useContext(AppContext);
+ const fluentTheme = getTheme();
+
// Calc Height List tiles Container Based on number Items per Page
const tilesHeight: number = numberCommentsPerPage
? (numberCommentsPerPage < 5 ? 5 : numberCommentsPerPage) * TILE_HEIGHT + 35
: 7 * TILE_HEIGHT;
const itemContainerStyles: IStackStyles = {
- root: { paddingTop: 0, paddingLeft: 20, paddingRight: 20, paddingBottom: 20 } as IStyle,
+ root: {
+ paddingTop: 0,
+ paddingLeft: 20,
+ paddingRight: 20,
+ paddingBottom: 20,
+ } as IStyle,
};
- const deleteButtonContainerStyles: Partial = {
+ const buttonsContainerStyles: Partial = {
root: {
- position: "absolute",
+ position: 'absolute',
top: 0,
right: 0,
},
@@ -45,15 +57,20 @@ export const useListItemCommentsStyles = (): returnObjectStyles => {
};
const renderUserContainerStyles: Partial = {
- root: { paddingTop: 5, paddingBottom: 5, paddingLeft: 10, paddingRight: 10 },
+ root: {
+ paddingTop: 5,
+ paddingBottom: 5,
+ paddingLeft: 10,
+ paddingRight: 10,
+ },
};
const documentCardStyles: Partial = {
root: {
marginBottom: 7,
width: 322,
backgroundColor: theme.neutralLighterAlt,
- userSelect: "text",
- ":hover": {
+ userSelect: 'text',
+ ':hover': {
borderColor: theme.themePrimary,
borderWidth: 1,
} as IStyle,
@@ -65,9 +82,9 @@ export const useListItemCommentsStyles = (): returnObjectStyles => {
marginBottom: 7,
width: 322,
backgroundColor: theme.themeLighter,
- userSelect: "text",
- border: "solid 3px "+theme.themePrimary,
- ":hover": {
+ userSelect: 'text',
+ border: 'solid 3px ' + theme.themePrimary,
+ ':hover': {
borderColor: theme.themePrimary,
borderWidth: 1,
} as IStyle,
@@ -78,7 +95,7 @@ export const useListItemCommentsStyles = (): returnObjectStyles => {
root: {
marginBottom: 5,
backgroundColor: theme.neutralLighterAlt,
- ":hover": {
+ ':hover': {
borderColor: theme.themePrimary,
borderWidth: 1,
} as IStyle,
@@ -89,9 +106,9 @@ export const useListItemCommentsStyles = (): returnObjectStyles => {
root: {
marginTop: 2,
backgroundColor: theme?.white,
- boxShadow: "0 5px 15px rgba(50, 50, 90, .1)",
+ boxShadow: '0 5px 15px rgba(50, 50, 90, .1)',
- ":hover": {
+ ':hover': {
borderColor: theme.themePrimary,
backgroundColor: theme.neutralLighterAlt,
borderWidth: 1,
@@ -113,26 +130,75 @@ export const useListItemCommentsStyles = (): returnObjectStyles => {
color: theme.themePrimary,
}),
divContainer: {
- display: "block",
+ display: 'block',
} as IStyle,
titlesContainer: {
height: tilesHeight,
marginBottom: 10,
- display: "flex",
+ display: 'flex',
marginTop: 15,
- overflow: "auto",
- "&::-webkit-scrollbar-thumb": {
+ overflow: 'auto',
+ '&::-webkit-scrollbar-thumb': {
backgroundColor: theme.neutralLighter,
},
- "&::-webkit-scrollbar": {
+ '&::-webkit-scrollbar': {
width: 5,
},
} as IStyle,
});
+ const contentStyles = mergeStyleSets({
+ container: {
+ display: 'flex',
+ flexFlow: 'column nowrap',
+ alignItems: 'stretch',
+ },
+ header: [
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ fluentTheme.fonts.xLargePlus,
+ {
+ flex: '1 1 auto',
+ borderTop: `4px solid ${theme.themePrimary}`,
+ color: theme.neutralPrimary,
+ display: 'flex',
+ alignItems: 'center',
+ fontWeight: FontWeights.semibold,
+ padding: '12px 12px 14px 24px',
+ },
+ ],
+ heading: {
+ color: theme.neutralPrimary,
+ fontWeight: FontWeights.semibold,
+ fontSize: 'inherit',
+ margin: '0',
+ },
+ body: {
+ flex: '4 4 auto',
+ padding: '0 24px 24px 24px',
+ overflowY: 'hidden',
+ selectors: {
+ p: { margin: '14px 0' },
+ 'p:first-child': { marginTop: 0 },
+ 'p:last-child': { marginBottom: 0 },
+ },
+ },
+ });
+
+ const iconButtonStyles: Partial = {
+ root: {
+ color: theme.neutralPrimary,
+ marginLeft: 'auto',
+ marginTop: '4px',
+ marginRight: '2px',
+ },
+ rootHovered: {
+ color: theme.neutralDark,
+ },
+ };
+
return {
itemContainerStyles,
- deleteButtonContainerStyles,
+ buttonsContainerStyles,
userListContainerStyles,
renderUserContainerStyles,
documentCardStyles,
@@ -140,5 +206,7 @@ export const useListItemCommentsStyles = (): returnObjectStyles => {
documentCardHighlightedStyles,
documentCardUserStyles,
configurationListClasses,
+ contentStyles,
+ iconButtonStyles,
};
};
diff --git a/src/controls/listItemComments/hooks/useSpAPI.ts b/src/controls/listItemComments/hooks/useSpAPI.ts
index f848068a1..498d6939b 100644
--- a/src/controls/listItemComments/hooks/useSpAPI.ts
+++ b/src/controls/listItemComments/hooks/useSpAPI.ts
@@ -7,14 +7,19 @@ import { IComment } from "../components/Comments/IComment";
import { PageContext } from "@microsoft/sp-page-context";
interface returnObject {
getListItemComments: () => Promise;
- getNextPageOfComments: (nextLink: string) => Promise;
+ getNextPageOfComments: (
+ nextLink: string
+ ) => Promise;
addComment: (comment: IAddCommentPayload) => Promise;
deleteComment: (commentId: number) => Promise;
+ likeComment: (commentId: number) => Promise;
+ unlikeComment: (commentId: number) => Promise;
}
export const useSpAPI = (): returnObject => {
- const { serviceScope, webUrl, listId, itemId, numberCommentsPerPage } = useContext(AppContext);
- let _webUrl: string = "";
+ const { serviceScope, webUrl, listId, itemId, numberCommentsPerPage } =
+ useContext(AppContext);
+ let _webUrl: string = '';
serviceScope.whenFinished(async () => {
_webUrl = serviceScope.consume(PageContext.serviceKey).web.absoluteUrl;
});
@@ -28,7 +33,7 @@ export const useSpAPI = (): returnObject => {
webUrl ?? _webUrl
}/_api/web/lists(@a1)/GetItemById(@a2)/Comments(@a3)?@a1='${listId}'&@a2='${itemId}'&@a3='${commentId}'`;
const spOpts: ISPHttpClientOptions = {
- method: "DELETE",
+ method: 'DELETE',
};
await spHttpClient.fetch(
`${_endPointUrl}`,
@@ -48,7 +53,9 @@ export const useSpAPI = (): returnObject => {
webUrl ?? _webUrl
}/_api/web/lists(@a1)/GetItemById(@a2)/Comments()?@a1='${listId}'&@a2='${itemId}'`;
const spOpts: ISPHttpClientOptions = {
- body: `{ "text": "${comment.text}", "mentions": ${JSON.stringify(comment.mentions)}}`,
+ body: `{ "text": "${comment.text}", "mentions": ${JSON.stringify(
+ comment.mentions
+ )}}`,
};
const _listResults: SPHttpClientResponse = await spHttpClient.post(
`${_endPointUrl}`,
@@ -61,26 +68,71 @@ export const useSpAPI = (): returnObject => {
[serviceScope]
);
- const getListItemComments = useCallback(async (): Promise => {
- const spHttpClient = serviceScope.consume(SPHttpClient.serviceKey);
- if (!spHttpClient) return;
- const _endPointUrl = `${
- webUrl ?? _webUrl
- }/_api/web/lists(@a1)/GetItemById(@a2)/GetComments()?@a1='${listId}'&@a2='${itemId}'&$top=${
- numberCommentsPerPage ?? 10
- }`;
- const _listResults: SPHttpClientResponse = await spHttpClient.get(
- `${_endPointUrl}`,
- SPHttpClient.configurations.v1
- );
- const _commentsResults = (await _listResults.json()) as any; // eslint-disable-line @typescript-eslint/no-explicit-any
- const _returnComments: IlistItemCommentsResults = {
- comments: _commentsResults.value,
- hasMore: _commentsResults["@odata.nextLink"] ? true : false,
- nextLink: _commentsResults["@odata.nextLink"] ?? undefined,
- };
- return _returnComments;
- }, [serviceScope]);
+ const likeComment = useCallback(
+ async (commentId: number): Promise => {
+ const spHttpClient = serviceScope.consume(SPHttpClient.serviceKey);
+ if (!spHttpClient) return;
+ const _endPointUrl = `${
+ webUrl ?? _webUrl
+ }/_api/web/lists(@a1)/GetItemById(@a2)/Comments(@a3)/like?@a1='${listId}'&@a2='${itemId}'&@a3='${commentId}'`;
+
+ const spOpts: ISPHttpClientOptions = {
+ headers: {
+ Accept: 'application/json;odata=nometadata',
+ },
+ };
+ await spHttpClient.post(
+ `${_endPointUrl}`,
+ SPHttpClient.configurations.v1,
+ spOpts
+ );
+ },
+ [serviceScope, webUrl, _webUrl, listId, itemId]
+ );
+
+ const unlikeComment = useCallback(
+ async (commentId: number): Promise => {
+ const spHttpClient = serviceScope.consume(SPHttpClient.serviceKey);
+ if (!spHttpClient) return;
+ const _endPointUrl = `${
+ webUrl ?? _webUrl
+ }/_api/web/lists(@a1)/GetItemById(@a2)/Comments(@a3)/unlike?@a1='${listId}'&@a2='${itemId}'&@a3='${commentId}'`;
+
+ const spOpts: ISPHttpClientOptions = {
+ headers: {
+ Accept: 'application/json;odata=nometadata',
+ },
+ };
+ await spHttpClient.post(
+ `${_endPointUrl}`,
+ SPHttpClient.configurations.v1,
+ spOpts
+ );
+ },
+ [serviceScope, webUrl, _webUrl, listId, itemId]
+ );
+
+ const getListItemComments =
+ useCallback(async (): Promise => {
+ const spHttpClient = serviceScope.consume(SPHttpClient.serviceKey);
+ if (!spHttpClient) return;
+ const _endPointUrl = `${
+ webUrl ?? _webUrl
+ }/_api/web/lists(@a1)/GetItemById(@a2)/GetComments()?@a1='${listId}'&@a2='${itemId}'&$top=${
+ numberCommentsPerPage ?? 10
+ }&$expand=likedBy`;
+ const _listResults: SPHttpClientResponse = await spHttpClient.get(
+ `${_endPointUrl}`,
+ SPHttpClient.configurations.v1
+ );
+ const _commentsResults = (await _listResults.json()) as any; // eslint-disable-line @typescript-eslint/no-explicit-any
+ const _returnComments: IlistItemCommentsResults = {
+ comments: _commentsResults.value,
+ hasMore: _commentsResults['@odata.nextLink'] ? true : false,
+ nextLink: _commentsResults['@odata.nextLink'] ?? undefined,
+ };
+ return _returnComments;
+ }, [serviceScope]);
const getNextPageOfComments = useCallback(
async (nextLink: string): Promise => {
@@ -94,13 +146,20 @@ export const useSpAPI = (): returnObject => {
const _commentsResults = (await _listResults.json()) as any; // eslint-disable-line @typescript-eslint/no-explicit-any
const _returnComments: IlistItemCommentsResults = {
comments: _commentsResults.value,
- hasMore: _commentsResults["@odata.nextLink"] ? true : false,
- nextLink: _commentsResults["@odata.nextLink"] ?? undefined,
+ hasMore: _commentsResults['@odata.nextLink'] ? true : false,
+ nextLink: _commentsResults['@odata.nextLink'] ?? undefined,
};
return _returnComments;
},
[serviceScope]
);
- return { getListItemComments, getNextPageOfComments, addComment, deleteComment };
+ return {
+ getListItemComments,
+ getNextPageOfComments,
+ addComment,
+ deleteComment,
+ likeComment,
+ unlikeComment,
+ };
};
diff --git a/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx b/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx
index 667808a0e..4d6800d65 100644
--- a/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx
+++ b/src/controls/modernTaxonomyPicker/taxonomyTree/TaxonomyTree.tsx
@@ -251,6 +251,10 @@ export function TaxonomyTree(
React.useEffect(() => {
let termRootName = "";
+ if (!props.anchorTermInfo && !props.termSetInfo) {
+ return;
+ }
+
if (props.anchorTermInfo) {
let anchorTermNames = props.anchorTermInfo.labels.filter(
(name) => name.languageTag === props.languageTag && name.isDefault
@@ -264,15 +268,15 @@ export function TaxonomyTree(
}
termRootName = anchorTermNames[0].name;
} else {
- let termSetNames = props.termSetInfo.localizedNames.filter(
+ let termSetNames = props.termSetInfo?.localizedNames.filter(
(name) => name.languageTag === props.languageTag
- );
+ ) || [];
if (termSetNames.length === 0) {
termSetNames = props.termSetInfo.localizedNames.filter(
(name) => name.languageTag === props.termStoreInfo.defaultLanguageTag
- );
+ ) || [];
}
- termRootName = termSetNames[0].name;
+ termRootName = termSetNames[0].name || '';
}
const rootGroup: IGroup = {
name: termRootName,
@@ -905,6 +909,10 @@ export function TaxonomyTree(
return ev.which === getRTLSafeKeyCode(KeyCodes.right);
};
+ if (!props.termSetInfo && !props.termStoreInfo) {
+ return <>>;
+ }
+
return (
{
className={
css(
styles.richtext && this.state.editing ? 'ql-active' : null,
+ CONTAINER_CLASS,
this.props.className || null
) || null
}
diff --git a/src/controls/worldMap/IData.ts b/src/controls/worldMap/IData.ts
new file mode 100644
index 000000000..a3765b5e0
--- /dev/null
+++ b/src/controls/worldMap/IData.ts
@@ -0,0 +1,40 @@
+
+/**
+ * Data structure for map location items.
+ * Represents a location point with metadata for display on the world map.
+ */
+export interface IData {
+ /**
+ * Unique identifier for the location.
+ * @example "nyc-001" or "location-1"
+ */
+ id: string;
+
+ /**
+ * Display name of the location.
+ * Used for search functionality and marker tooltips.
+ * @example "New York City" or "Eiffel Tower"
+ */
+ name: string;
+
+ /**
+ * URL to an image representing this location.
+ * Used for marker display or in tooltips.
+ * @example "https://example.com/nyc-skyline.jpg"
+ */
+ imageUrl: string;
+
+ /**
+ * URL link associated with this location.
+ * Can be used for navigation when marker is clicked.
+ * @example "https://example.com/locations/new-york" or "/details/nyc"
+ */
+ link: string;
+
+ /**
+ * Geographic coordinates as [longitude, latitude] tuple.
+ * Used to position the marker on the map.
+ * @example [-74.006, 40.7128] // New York City coordinates
+ */
+ coordinates: [number, number];
+}
diff --git a/src/controls/worldMap/IMaplibreWorldMapProps.tsx b/src/controls/worldMap/IMaplibreWorldMapProps.tsx
new file mode 100644
index 000000000..19cf96616
--- /dev/null
+++ b/src/controls/worldMap/IMaplibreWorldMapProps.tsx
@@ -0,0 +1,241 @@
+import { IData } from "./IData";
+import React from "react";
+import { Theme } from "@fluentui/react-components";
+
+/** Props for the world map component. */
+export interface IMaplibreWorldMapProps {
+ /**
+ * Array of location data to display on the map. Each item must include coordinates as [longitude, latitude].
+ * @example
+ * ```tsx
+ * const data = [
+ * { id: '1', name: 'New York', coordinates: [-74.006, 40.7128], imageUrl: '...', link: '...' }
+ * ];
+ * ```
+ */
+ data: IData[];
+
+ /**
+ * Callback function triggered when a marker is clicked.
+ * @param c - The data item associated with the clicked marker
+ * @example
+ * ```tsx
+ * onClick={(location) => console.log('Clicked:', location.name)}
+ * ```
+ */
+ onClick?: (c: IData) => void;
+
+ /**
+ * Custom map style URL. If provided with mapKey, the key will be automatically appended.
+ * If provided without mapKey, the URL will be used as-is.
+ * @example
+ * ```tsx
+ * mapStyleUrl="https://api.maptiler.com/maps/satellite/style.json"
+ * ```
+ */
+ mapStyleUrl?: string;
+
+ /**
+ * MapTiler API key for accessing premium map styles.
+ * If provided alone, uses MapTiler streets style by default.
+ * If not provided, falls back to free demo map.
+ * @example
+ * ```tsx
+ * mapKey="your-maptiler-api-key-here"
+ * ```
+ */
+ mapKey?: string;
+
+ /**
+ * Custom CSS styles for the map container.
+ * @example
+ * ```tsx
+ * style={{ width: '100%', height: '500px', border: '1px solid #ccc' }}
+ * ```
+ */
+ style?: React.CSSProperties;
+
+ /**
+ * Padding (in pixels) around the map when fitting bounds to show all markers.
+ * @default 20
+ * @example
+ * ```tsx
+ * fitPadding={50} // Adds 50px padding around markers when auto-fitting
+ * ```
+ */
+ fitPadding?: number;
+
+ /**
+ * Title displayed above the map. Can be a string or React element.
+ * @default 'World Map'
+ * @example
+ * ```tsx
+ * title="My Custom Map"
+ * // or
+ * title={Interactive Location Map
}
+ * ```
+ */
+ title?: string | React.ReactNode;
+
+ /**
+ * Description text displayed below the title (not currently implemented in UI).
+ * @example
+ * ```tsx
+ * description="Click on markers to view location details"
+ * ```
+ */
+ description?: string | React.ReactNode;
+
+ /**
+ * CSS class name applied to the root container.
+ * @example
+ * ```tsx
+ * className="my-custom-map-class"
+ * ```
+ */
+ className?: string;
+
+ /**
+ * Configuration options for map markers appearance and behavior.
+ */
+ marker?: {
+ /**
+ * CSS class name applied to marker elements.
+ * @example "custom-marker-style"
+ */
+ markerClassName?: string;
+
+ /**
+ * Custom CSS styles applied to marker elements.
+ * @example {{ backgroundColor: 'red', borderRadius: '50%' }}
+ */
+ markerStyle?: React.CSSProperties;
+
+ /**
+ * Size of marker images in pixels.
+ * @default 40
+ * @example 60
+ */
+ imageSize?: number;
+
+ /**
+ * Custom function to render tooltip content for markers.
+ * @param c - The data item for the marker
+ * @returns React element to display in tooltip
+ * @example
+ * ```tsx
+ * renderToolTip={(item) => {item.name}
{item.description}
}
+ * ```
+ */
+ renderToolTip?: (c: IData) => React.ReactNode;
+
+ /**
+ * CSS class name applied to tooltip elements.
+ * @example "custom-tooltip-style"
+ */
+ tooltipClassName?: string;
+
+ /**
+ * Custom CSS styles applied to tooltip elements.
+ * @example {{ backgroundColor: 'black', color: 'white', padding: '8px' }}
+ */
+ tooltipStyle?: React.CSSProperties;
+ }
+
+ /**
+ * Configuration options for the search functionality.
+ */
+ search?: {
+ /**
+ * Enable or disable the search feature.
+ * @default true
+ * @example
+ * ```tsx
+ * search={{ enabled: false }} // Disables search
+ * ```
+ */
+ enabled?: boolean;
+
+ /**
+ * Placeholder text displayed in the search input.
+ * @default "Search locations..."
+ * @example
+ * ```tsx
+ * search={{ placeholder: "Find a city or landmark..." }}
+ * ```
+ */
+ placeholder?: string;
+
+ /**
+ * Callback function triggered when search term changes or results are filtered.
+ * @param searchTerm - The current search term
+ * @param filteredData - Array of data items matching the search
+ * @example
+ * ```tsx
+ * onSearchChange={(term, results) => {
+ * console.log(`Search: "${term}" found ${results.length} results`);
+ * }}
+ * ```
+ */
+ onSearchChange?: (searchTerm: string, filteredData: IData[]) => void;
+
+ /**
+ * Field to search on or a custom function to extract the search term from data items.
+ * @default "name"
+ * @example
+ * ```tsx
+ * // Search by specific field
+ * searchField: "id"
+ *
+ * // Custom search function
+ * searchField: (item) => `${item.name} ${item.description}`
+ * ```
+ */
+ searchField?: keyof IData | ((item: IData) => string);
+
+ /**
+ * Zoom level to use when focusing on search results.
+ * @default 8
+ * @example
+ * ```tsx
+ * search={{ zoomLevel: 12 }} // Closer zoom for search results
+ * ```
+ */
+ zoomLevel?: number;
+
+ /**
+ * Position of the search overlay on the map.
+ * @default { top: '10px', left: '10px' }
+ * @example
+ * ```tsx
+ * // Top-right position
+ * search={{ position: { top: '10px', right: '10px' } }}
+ *
+ * // Bottom-left position
+ * search={{ position: { bottom: '20px', left: '20px' } }}
+ * ```
+ */
+ position?: {
+ /** Distance from the top edge of the map */
+ top?: string;
+ /** Distance from the left edge of the map */
+ left?: string;
+ /** Distance from the right edge of the map */
+ right?: string;
+ /** Distance from the bottom edge of the map */
+ bottom?: string;
+ };
+ }
+
+ /**
+ * Fluent UI theme object for consistent styling.
+ * @example
+ * ```tsx
+ * import { useTheme } from '@fluentui/react-components';
+ *
+ * const theme = useTheme();
+ *
+ * ```
+ */
+ theme?: Theme
+}
diff --git a/src/controls/worldMap/IMarker.ts b/src/controls/worldMap/IMarker.ts
new file mode 100644
index 000000000..11afe8c3e
--- /dev/null
+++ b/src/controls/worldMap/IMarker.ts
@@ -0,0 +1,10 @@
+import { IData } from "./IData";
+import React from "react";
+
+export interface IMarker {
+ markerClassName?: string;
+ markerStyle?: React.CSSProperties;
+ renderToolTip?: (c: IData) => React.ReactNode;
+ tooltipClassName?: string;
+ tooltipStyle?: React.CSSProperties;
+}
diff --git a/src/controls/worldMap/IMarkerProps.tsx b/src/controls/worldMap/IMarkerProps.tsx
new file mode 100644
index 000000000..bc947e995
--- /dev/null
+++ b/src/controls/worldMap/IMarkerProps.tsx
@@ -0,0 +1,7 @@
+import { IData } from './IData';
+import { IMarker } from './IMarker';
+
+export interface IMarkerProps extends IMarker {
+ data: IData;
+ onClick?: (data: IData) => void;
+}
diff --git a/src/controls/worldMap/IWorldMapProps.ts b/src/controls/worldMap/IWorldMapProps.ts
new file mode 100644
index 000000000..43a5a1524
--- /dev/null
+++ b/src/controls/worldMap/IWorldMapProps.ts
@@ -0,0 +1,13 @@
+import { Theme } from "@fluentui/react-components";
+
+export interface IWorldMapProps {
+ description: string;
+ isDarkTheme: boolean;
+ hasTeamsContext: boolean;
+ title: string;
+ theme?: Theme
+ styles?: React.CSSProperties;
+ className?: string;
+ mapStyleUrl?: string;
+ fitPadding?: number;
+}
diff --git a/src/controls/worldMap/MapNavigation.tsx b/src/controls/worldMap/MapNavigation.tsx
new file mode 100644
index 000000000..71442472a
--- /dev/null
+++ b/src/controls/worldMap/MapNavigation.tsx
@@ -0,0 +1,99 @@
+import * as React from 'react';
+
+import {
+ ArrowReset24Regular,
+ ZoomIn24Regular,
+ ZoomOut24Regular,
+} from '@fluentui/react-icons';
+import {
+ Button,
+ Tooltip,
+ shorthands,
+} from '@fluentui/react-components';
+
+import { MapRef } from 'react-map-gl/maplibre';
+import { css } from '@emotion/css';
+import strings from 'ControlStrings';
+
+export interface MapNavigationProps {
+ mapRef: React.RefObject;
+ initialViewState?: { longitude: number; latitude: number; zoom: number };
+ vertical?: boolean;
+}
+
+const navStyle = css`
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ z-index: 2;
+ background: tokens.colorNeutralBackground1;
+ border-radius: 8px;
+ box-shadow: tokens.shadow4;
+ padding: 4px;
+ display: flex;
+ gap: 8px;
+`;
+
+const navStyleVertical = css`
+ flex-direction: column;
+`;
+
+const buttonStyle = css`
+ min-width: 32px;
+ min-height: 32px;
+ ${shorthands.padding('4px')}
+`;
+
+export const MapNavigation: React.FC = ({
+ mapRef,
+ initialViewState = { longitude: 0, latitude: 20, zoom: 1 },
+ vertical = true,
+}) => {
+ const handleZoomIn = React.useCallback((): void => {
+ mapRef.current?.getMap().zoomIn();
+ }, [mapRef]);
+ const handleZoomOut = React.useCallback((): void => {
+ mapRef.current?.getMap().zoomOut();
+ }, [mapRef]);
+ const handleReset = React.useCallback((): void => {
+ mapRef.current?.flyTo({
+ center: [initialViewState.longitude, initialViewState.latitude],
+ zoom: initialViewState.zoom,
+ essential: true,
+ });
+ }, [mapRef, initialViewState]);
+
+ return (
+
+
+ }
+ aria-label={strings.worldMapZoomIn}
+ className={buttonStyle}
+ onClick={handleZoomIn}
+ />
+
+
+ }
+ aria-label={strings.worldMapZoomOut}
+ className={buttonStyle}
+ onClick={handleZoomOut}
+ />
+
+
+ }
+ aria-label={strings.worldMapReset}
+ className={buttonStyle}
+ onClick={handleReset}
+ />
+
+
+ );
+};
+
+export default MapNavigation;
diff --git a/src/controls/worldMap/Marker.tsx b/src/controls/worldMap/Marker.tsx
new file mode 100644
index 000000000..5b062b8c7
--- /dev/null
+++ b/src/controls/worldMap/Marker.tsx
@@ -0,0 +1,69 @@
+import * as React from 'react';
+
+import { Tooltip, tokens } from '@fluentui/react-components';
+
+import { IMarkerProps } from './IMarkerProps';
+import { Marker as MapMarker } from 'react-map-gl/maplibre';
+import TooltipContent from './TooltipContent';
+import { css } from '@emotion/css';
+import strings from 'ControlStrings';
+
+const useStyles = (): { flag: string; tooltipContent: string } => {
+ return {
+ flag: css`
+ width: 22px;
+ height: 10px;
+ border-radius: 4px;
+ box-shadow: '${tokens.shadow4};
+ border: 1px solid ${tokens.colorNeutralStroke2};
+ cursor: pointer;
+ display: block;
+ margin: 0 auto;
+ `,
+ tooltipContent: css`
+ background-color: ${tokens.colorBrandBackground2};
+ `,
+ };
+};
+
+export const Marker: React.FC = ({
+ data,
+ onClick,
+ markerClassName,
+ markerStyle,
+
+ renderToolTip,
+ tooltipClassName,
+ tooltipStyle,
+}) => {
+ const styles = useStyles();
+
+ return (
+ onClick && onClick(data)}
+ className={markerClassName ?? styles.flag}
+ style={markerStyle ?? undefined}
+ >
+ ,
+ style: { ...tooltipStyle },
+ className: tooltipClassName ?? styles.tooltipContent,
+ }}
+ relationship="label"
+ >
+
(e.currentTarget.style.transform = 'scale(1.1)')}
+ onMouseLeave={(e) => (e.currentTarget.style.transform = '')}
+ style={{ cursor: 'pointer' }}
+ />
+
+
+ );
+};
+export default Marker;
diff --git a/src/controls/worldMap/TooltipContent.tsx b/src/controls/worldMap/TooltipContent.tsx
new file mode 100644
index 000000000..e7e40b585
--- /dev/null
+++ b/src/controls/worldMap/TooltipContent.tsx
@@ -0,0 +1,74 @@
+import * as React from "react";
+
+import {
+ Text,
+ tokens,
+} from "@fluentui/react-components";
+
+import { IData } from "./IData";
+import { css } from "@emotion/css";
+import strings from "ControlStrings";
+
+export interface CountryTooltipContentProps {
+ data: IData;
+}
+
+const stackStyles = css`
+ min-width: 160px;
+
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ align-items: flex-start;
+ background-color: ${tokens.colorBrandBackground2};
+ padding: 0;
+ min-width: 100px;
+`;
+
+const rowStyles = css`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+`;
+
+const imageStyles = css`
+ width: 32px;
+ height: 20px;
+ display: block;
+ border-radius: 4px;
+ box-shadow: '${tokens.shadow4};
+`;
+
+const titleStyles = css`
+ font-weight: 600;
+ font-size: 1rem;
+`;
+
+const subTitleStyles = css`
+ color: '${tokens.colorNeutralForeground2};
+ font-size: 0.92rem;
+`;
+
+export const TooltipContent: React.FC = ({
+ data,
+}) => {
+ return (
+
+
+

+
{data.name}
+
+
+ {strings.worldMapCoord}
+ {data.coordinates[1].toFixed(2)}{strings.worldMapN}
+ {data.coordinates[0].toFixed(2)}{strings.worldMapE}
+
+
+ );
+};
+
+export default TooltipContent;
diff --git a/src/controls/worldMap/WorldMap.tsx b/src/controls/worldMap/WorldMap.tsx
new file mode 100644
index 000000000..a8e92b85f
--- /dev/null
+++ b/src/controls/worldMap/WorldMap.tsx
@@ -0,0 +1,111 @@
+import { IData } from "./IData";
+import { IWorldMapProps } from "./IWorldMapProps";
+import MaplibreWorldMap from "./WorldMapControl";
+import React from "react";
+
+const WorldMap: React.FC = (props) => {
+
+ const countries: IData[] = React.useMemo(
+ () => [
+ { id:"us", name:"Microsoft – USA", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/en-us", coordinates:[-95.7129,37.0902] },
+ { id:"canada", name:"Microsoft – Canada", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/en-ca", coordinates:[-79.3832,43.6532] },
+ { id:"mexico", name:"Microsoft – Mexico", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/es-mx", coordinates:[-99.1332,19.4326] },
+ { id:"brazil", name:"Microsoft – Brazil", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/pt-br", coordinates:[-46.6333,-23.5505] },
+ // Europe
+ { id:"portugal", name:"Microsoft – Portugal", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/pt-pt", coordinates:[-9.1393,38.7223] },
+ { id:"uk", name:"Microsoft – United Kingdom", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/en-gb", coordinates:[-0.1276,51.5074] },
+ { id:"ireland", name:"Microsoft – Ireland", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/en-ie", coordinates:[-6.2603,53.3498] },
+ { id:"france", name:"Microsoft – France", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/fr-fr", coordinates:[2.3522,48.8566] },
+ { id:"germany", name:"Microsoft – Germany", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/de-de", coordinates:[11.5820,48.1351] },
+ { id:"italy", name:"Microsoft – Italy", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/it-it", coordinates:[12.4964,41.9028] },
+ { id:"spain", name:"Microsoft – Spain", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/es-es", coordinates:[-3.7038,40.4168] },
+ { id:"sweden", name:"Microsoft – Sweden", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/sv-se", coordinates:[18.0686,59.3293] },
+ { id:"switzerland", name:"Microsoft – Switzerland", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/de-ch", coordinates:[7.4474,46.9480] },
+ { id:"norway", name:"Microsoft – Norway", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/nb-no", coordinates:[10.7522,59.9139] },
+ { id:"finland", name:"Microsoft – Finland", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/fi-fi", coordinates:[24.9384,60.1699] },
+ { id:"denmark", name:"Microsoft – Denmark", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/da-dk", coordinates:[12.5683,55.6761] },
+ { id:"netherlands", name:"Microsoft – Netherlands", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/nl-nl", coordinates:[4.8952,52.3702] },
+ { id:"belgium", name:"Microsoft – Belgium", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/nl-be", coordinates:[4.3517,50.8503] },
+ { id:"luxembourg", name:"Microsoft – Luxembourg", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/fr-lu", coordinates:[6.1319,49.6116] },
+ { id:"austria", name:"Microsoft – Austria", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/de-at", coordinates:[16.3738,48.2082] },
+ { id:"poland", name:"Microsoft – Poland", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/pl-pl", coordinates:[21.0122,52.2297] },
+ { id:"czech_republic", name:"Microsoft – Czech Republic", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/cs-cz", coordinates:[14.4208,50.0880] },
+ { id:"slovakia", name:"Microsoft – Slovakia", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/sk-sk", coordinates:[17.1077,48.1486] },
+ { id:"hungary", name:"Microsoft – Hungary", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/hu-hu", coordinates:[19.0402,47.4979] },
+ { id:"slovenia", name:"Microsoft – Slovenia", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/sl-si", coordinates:[14.5058,46.0569] },
+ { id:"croatia", name:"Microsoft – Croatia", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/hr-hr", coordinates:[15.9819,45.8150] },
+ { id:"serbia", name:"Microsoft – Serbia", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/sr-latn-rs", coordinates:[20.4573,44.7872] },
+ { id:"bosnia", name:"Microsoft – Bosnia and Herzegovina", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/bs-ba", coordinates:[18.4131,43.8563] },
+ { id:"montenegro", name:"Microsoft – Montenegro", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/sr-latn-me", coordinates:[19.2629,42.4304] },
+ { id:"albania", name:"Microsoft – Albania", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/sq-al", coordinates:[19.8187,41.3275] },
+ { id:"macedonia", name:"Microsoft – North Macedonia", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/mk-mk", coordinates:[21.4316,41.9981] },
+ { id:"greece", name:"Microsoft – Greece", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/el-gr", coordinates:[23.7275,37.9838] },
+ { id:"bulgaria", name:"Microsoft – Bulgaria", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/bg-bg", coordinates:[23.3219,42.6977] },
+ { id:"romania", name:"Microsoft – Romania", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/ro-ro", coordinates:[26.1025,44.4268] },
+ { id:"moldova", name:"Microsoft – Moldova", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/ro-md", coordinates:[28.8638,47.0105] },
+ { id:"ukraine", name:"Microsoft – Ukraine", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/uk-ua", coordinates:[30.5234,50.4501] },
+ { id:"belarus", name:"Microsoft – Belarus", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/be-by", coordinates:[27.5615,53.9045] },
+ { id:"estonia", name:"Microsoft – Estonia", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/et-ee", coordinates:[24.7536,59.4370] },
+ { id:"latvia", name:"Microsoft – Latvia", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/lv-lv", coordinates:[24.1052,56.9496] },
+ { id:"lithuania", name:"Microsoft – Lithuania", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/lt-lt", coordinates:[25.2797,54.6872] },
+ // Rest of the world (original list)
+ { id:"russia", name:"Microsoft – Russia", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/ru-ru", coordinates:[37.6173,55.7558] },
+ { id:"south_africa", name:"Microsoft – South Africa", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/en-za", coordinates:[28.0473,-26.2041] },
+ { id:"uae", name:"Microsoft – UAE", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/en-ae", coordinates:[55.2708,25.2048] },
+ { id:"india", name:"Microsoft – India", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/en-in", coordinates:[78.9629,20.5937] },
+ { id:"china", name:"Microsoft – China", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/zh-cn", coordinates:[116.383,39.917] },
+ { id:"hong_kong", name:"Microsoft – Hong Kong", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/zh-hk", coordinates:[114.1095,22.3964] },
+ { id:"japan", name:"Microsoft – Japan", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/ja-jp", coordinates:[139.6917,35.6895] },
+ { id:"south_korea", name:"Microsoft – South Korea", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/ko-kr", coordinates:[126.9780,37.5665] },
+ { id:"australia", name:"Microsoft – Australia", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/en-au", coordinates:[151.2093,-33.8688] },
+ { id:"new_zealand", name:"Microsoft – New Zealand", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/en-nz", coordinates:[174.88597,-40.90056] },
+ { id:"singapore", name:"Microsoft – Singapore", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/en-sg", coordinates:[103.8198,1.3521] },
+ { id:"malaysia", name:"Microsoft – Malaysia", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/en-my", coordinates:[101.6869,3.1390] },
+ { id:"philippines", name:"Microsoft – Philippines", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/en-ph", coordinates:[121.7740,12.8797] },
+ { id:"thailand", name:"Microsoft – Thailand", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/th-th", coordinates:[100.5018,13.7563] },
+ { id:"indonesia", name:"Microsoft – Indonesia", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/id-id", coordinates:[106.8456,-6.2088] },
+ { id:"vietnam", name:"Microsoft – Vietnam", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/vi-vn", coordinates:[105.8442,21.0278] },
+ { id:"bangladesh", name:"Microsoft – Bangladesh", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/bn-bd", coordinates:[90.4125,23.8103] },
+ { id:"pakistan", name:"Microsoft – Pakistan", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/ur-pk", coordinates:[67.0011,24.8607] },
+ { id:"egypt", name:"Microsoft – Egypt", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/ar-eg", coordinates:[31.2357,30.0444] },
+ { id:"morocco", name:"Microsoft – Morocco", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/fr-ma", coordinates:[-7.5898,33.5731] },
+ { id:"kenya", name:"Microsoft – Kenya", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/en-ke", coordinates:[36.8219,-1.2921] },
+ { id:"ghana", name:"Microsoft – Ghana", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/en-gh", coordinates:[-0.1869,5.6037] },
+ { id:"ivory_coast", name:"Microsoft – Côte d'Ivoire", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/fr-ci", coordinates:[-4.0083,5.3453] },
+ { id:"algeria", name:"Microsoft – Algeria", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/fr-dz", coordinates:[3.0588,36.7538] },
+ { id:"nigeria", name:"Microsoft – Nigeria", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/en-ng", coordinates:[3.3792,6.5244] },
+ { id:"tunisia", name:"Microsoft – Tunisia", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/fr-tn", coordinates:[10.1815,36.8065] },
+ { id:"saudi_arabia", name:"Microsoft – Saudi Arabia", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/ar-sa", coordinates:[46.6753,24.7136] },
+ { id:"qatar", name:"Microsoft – Qatar", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/ar-qa", coordinates:[51.5310,25.2854] },
+ { id:"kuwait", name:"Microsoft – Kuwait", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/ar-kw", coordinates:[47.9783,29.3759] },
+ { id:"oman", name:"Microsoft – Oman", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/ar-om", coordinates:[58.3829,23.5880] },
+ { id:"bahrain", name:"Microsoft – Bahrain", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/ar-bh", coordinates:[50.5832,26.0667] },
+ { id:"lebanon", name:"Microsoft – Lebanon", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/ar-lb", coordinates:[35.5018,33.8938] },
+ { id:"jordan", name:"Microsoft – Jordan", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/ar-jo", coordinates:[35.9106,31.9632] },
+ { id:"israel", name:"Microsoft – Israel", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/he-il", coordinates:[34.7818,32.0853] },
+ { id:"kazakhstan", name:"Microsoft – Kazakhstan", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/kk-kz", coordinates:[76.8860,43.2389] },
+ { id:"uzbekistan", name:"Microsoft – Uzbekistan", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/uz-uz", coordinates:[69.2401,41.2995] },
+ { id:"azerbaijan", name:"Microsoft – Azerbaijan", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/az-latn-az", coordinates:[49.8671,40.4093] },
+ { id:"georgia", name:"Microsoft – Georgia", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/ka-ge", coordinates:[44.7930,41.7151] },
+ { id:"armenia", name:"Microsoft – Armenia", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/hy-am", coordinates:[44.5090,40.1792] },
+ { id:"turkmenistan", name:"Microsoft – Turkmenistan", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/tm-tm", coordinates:[58.3833,37.9500] },
+ { id:"mongolia", name:"Microsoft – Mongolia", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/mn-mn", coordinates:[106.9155,47.8864] },
+ { id:"sri_lanka", name:"Microsoft – Sri Lanka", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/en-lk", coordinates:[79.8612,6.9271] },
+ { id:"nepal", name:"Microsoft – Nepal", imageUrl:"https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg", link:"https://www.microsoft.com/ne-np", coordinates:[85.3240,27.7172] }
+ ],
+ []
+ );
+
+ return (
+
+ console.log('Clicked', c)}
+ title="Microsoft Locations Worldwide"
+ />
+
+ );
+};
+
+export default WorldMap;
diff --git a/src/controls/worldMap/WorldMapControl.tsx b/src/controls/worldMap/WorldMapControl.tsx
new file mode 100644
index 000000000..6771b207c
--- /dev/null
+++ b/src/controls/worldMap/WorldMapControl.tsx
@@ -0,0 +1,293 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+/* eslint-disable @typescript-eslint/no-floating-promises */
+import 'maplibre-gl/dist/maplibre-gl.css';
+
+import Map, { MapRef, StyleSpecification } from 'react-map-gl/maplibre';
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { SearchBox, Subtitle1, Text } from '@fluentui/react-components';
+
+import { IData } from './IData';
+import { IMaplibreWorldMapProps } from './IMaplibreWorldMapProps';
+import MapNavigation from './MapNavigation';
+import Marker from './Marker';
+import { css } from '@emotion/css';
+import strings from 'ControlStrings';
+import { useCleanMapStyle } from './useCleanMapStyle';
+
+const MULTI_STYLE_URLS = {
+ satellite: (key: string) =>
+ `https://api.maptiler.com/maps/satellite/style.json?key=${key}`,
+ streets: (key: string) =>
+ `https://api.maptiler.com/maps/streets/style.json?key=${key}`,
+ topo: (key: string) =>
+ `https://api.maptiler.com/maps/topo-v2/style.json?key=${key}`,
+ demo: `https://demotiles.maplibre.org/style.json`, // Free demo style (no key required)
+};
+
+const useStyles = () => ({
+ container: css({
+ padding: '20px',
+ }),
+ mapContainer: css({
+ position: 'relative',
+ marginTop: '20px',
+ }),
+ searchOverlay: css({
+ position: 'absolute',
+ zIndex: 1000,
+ maxWidth: '300px',
+ padding: '8px',
+ }),
+ searchResults: css({
+ marginTop: '4px',
+ fontSize: '12px',
+ color: '#666',
+ }),
+});
+
+/**
+ * Main Maplibre world map component.
+ * expects each data to already include a `coordinates: [lon, lat]` tuple.
+ */
+export const MaplibreWorldMap: React.FC = ({
+ data,
+ onClick,
+ mapStyleUrl,
+ mapKey,
+ style,
+ fitPadding = 20,
+ theme,
+ marker,
+ title,
+ search,
+}) => {
+ const mapRef = useRef(null);
+ const styles = useStyles();
+
+ // Determine the final map style URL based on provided props
+ const finalMapStyleUrl = useMemo(() => {
+ // If user provides both mapKey and mapStyleUrl, use the mapStyleUrl as-is (assuming it already includes the key)
+ if (mapKey && mapStyleUrl) {
+ // Check if the URL already contains a key parameter
+ if (mapStyleUrl.includes('?key=') || mapStyleUrl.includes('&key=')) {
+ return mapStyleUrl;
+ } else {
+ // Add the key to the URL
+ const separator = mapStyleUrl.includes('?') ? '&' : '?';
+ return `${mapStyleUrl}${separator}key=${mapKey}`;
+ }
+ }
+
+ // If only mapKey is provided, use the default streets style with the user's key
+ if (mapKey && !mapStyleUrl) {
+ return MULTI_STYLE_URLS.streets(mapKey);
+ }
+
+ // If only mapStyleUrl is provided, use it as-is
+ if (!mapKey && mapStyleUrl) {
+ return mapStyleUrl;
+ }
+
+ // If neither is provided, use the demo style (no key required)
+ return MULTI_STYLE_URLS.demo;
+ }, [mapKey, mapStyleUrl]);
+
+ const cleanStyle = useCleanMapStyle(finalMapStyleUrl);
+
+ const [searchTerm, setSearchTerm] = useState('');
+ const [filteredData, setFilteredData] = useState(data);
+ const [initialViewState] = useState({ longitude: 0, latitude: 20, zoom: 1 });
+
+ // Search configuration with defaults
+ const searchConfig = useMemo(
+ () => ({
+ enabled: search?.enabled ?? true,
+ placeholder: search?.placeholder ?? strings.worldMapSearchLocations,
+ searchField: search?.searchField ?? strings.worldMapSearchField,
+ zoomLevel: search?.zoomLevel ?? 8,
+ position: {
+ top: '10px',
+ left: '10px',
+ ...search?.position,
+ },
+ ...search,
+ }),
+ [search]
+ );
+
+ // Reset to initial view when search is cleared
+ const resetToInitialView = useCallback(() => {
+ if (mapRef.current) {
+ if (data.length > 0) {
+ // Fit to all data
+ const lons = data.map((c) => c.coordinates[0]);
+ const lats = data.map((c) => c.coordinates[1]);
+ mapRef.current.getMap().fitBounds(
+ [
+ [Math.min(...lons), Math.min(...lats)],
+ [Math.max(...lons), Math.max(...lats)],
+ ],
+ { padding: fitPadding, duration: 1000 }
+ );
+ } else {
+ // Reset to initial view state
+ mapRef.current.getMap().flyTo({
+ center: [initialViewState.longitude, initialViewState.latitude],
+ zoom: initialViewState.zoom,
+ duration: 1000,
+ });
+ }
+ }
+ }, [data, fitPadding, initialViewState]);
+
+ // Filter data based on search term
+ const handleSearch = useCallback(
+ (term: string) => {
+ setSearchTerm(term);
+
+ if (!term.trim()) {
+ setFilteredData(data);
+ search?.onSearchChange?.(term, data);
+ resetToInitialView();
+ return;
+ }
+
+ const filtered = data.filter((item) => {
+ const searchValue =
+ typeof searchConfig.searchField === 'function'
+ ? searchConfig.searchField(item)
+ : (item[searchConfig.searchField as keyof IData] as string);
+
+ return searchValue?.toLowerCase().includes(term.toLowerCase());
+ });
+
+ setFilteredData(filtered);
+ search?.onSearchChange?.(term, filtered);
+
+ // Auto-zoom to first result
+ if (filtered.length > 0 && mapRef.current) {
+ const firstResult = filtered[0];
+ mapRef.current.getMap().flyTo({
+ center: firstResult.coordinates,
+ zoom: searchConfig.zoomLevel,
+ duration: 1000,
+ });
+ }
+ },
+ [data, search, searchConfig, resetToInitialView]
+ );
+
+ // Handle search clear
+ const handleSearchClear = useCallback(() => {
+ setSearchTerm('');
+ setFilteredData(data);
+ search?.onSearchChange?.('', data);
+ resetToInitialView();
+ }, [data, search, resetToInitialView]);
+
+ // Update filtered data when data prop changes
+ useEffect(() => {
+ if (!searchTerm.trim()) {
+ setFilteredData(data);
+ } else {
+ handleSearch(searchTerm);
+ }
+ }, [data, searchTerm, handleSearch]);
+
+ const defaultMapStyles: React.CSSProperties = useMemo(
+ () => ({
+ width: '100%',
+ height: '600px',
+ fontFamily: theme?.fontFamilyBase,
+ paddingTop: '20px',
+ paddingBottom: '20px',
+ }),
+ [theme]
+ );
+
+ // Fit map to loaded markers
+ useEffect(() => {
+ if (!mapRef.current || filteredData.length === 0) return;
+ const lons = filteredData.map((c) => c.coordinates[0]);
+ const lats = filteredData.map((c) => c.coordinates[1]);
+ mapRef.current.getMap().fitBounds(
+ [
+ [Math.min(...lons), Math.min(...lats)],
+ [Math.max(...lons), Math.max(...lats)],
+ ],
+ { padding: fitPadding }
+ );
+ }, [filteredData, fitPadding]);
+
+ if (!cleanStyle) {
+ return {strings.worldMapLoadintText};
+ }
+
+ return (
+
+
{title ?? strings.worldMapTitle}
+
+
+
+ {searchConfig.enabled && (
+
+
handleSearch(data?.value || '')}
+ dismiss={{
+ onClick: handleSearchClear,
+ }}
+ size="medium"
+ style={{ width: '100%', minWidth: '250px' }}
+ />
+ {searchTerm && (
+
+ {filteredData.length}{' '}
+ {filteredData.length === 1
+ ? strings.worldMapLocationLabel
+ : strings.worldMapLocationPluralLabel}{' '}
+ {strings.worldMapFoundLabel}
+
+ )}
+
+ )}
+
+
+ );
+};
+
+export default MaplibreWorldMap;
diff --git a/src/controls/worldMap/examples.tsx b/src/controls/worldMap/examples.tsx
new file mode 100644
index 000000000..99d3f04c4
--- /dev/null
+++ b/src/controls/worldMap/examples.tsx
@@ -0,0 +1,217 @@
+// Example usage of WorldMapControl with search functionality
+
+import { IData } from './IData';
+import { MaplibreWorldMap } from './WorldMapControl';
+import React from 'react';
+
+const ExampleWithSearch: React.FC = () => {
+ const mapData: IData[] = [
+ {
+ id: '1',
+ name: 'New York',
+ imageUrl: 'https://example.com/ny.jpg',
+ link: 'https://example.com/ny',
+ coordinates: [-74.006, 40.7128]
+ },
+ {
+ id: '2',
+ name: 'London',
+ imageUrl: 'https://example.com/london.jpg',
+ link: 'https://example.com/london',
+ coordinates: [-0.1276, 51.5074]
+ },
+ {
+ id: '3',
+ name: 'Tokyo',
+ imageUrl: 'https://example.com/tokyo.jpg',
+ link: 'https://example.com/tokyo',
+ coordinates: [139.6917, 35.6895]
+ }
+ ];
+
+ const handleLocationClick = (location: IData): void => {
+ console.log('Location clicked:', location.name);
+ };
+
+ const handleSearchChange = (searchTerm: string, filteredData: IData[]): void => {
+ console.log('Search term:', searchTerm);
+ console.log('Filtered results:', filteredData);
+ if (!searchTerm) {
+ console.log('Search cleared - map reset to initial view');
+ }
+ };
+
+ return (
+ item.name
+ position: {
+ top: '10px',
+ left: '10px'
+ }
+ }}
+ style={{
+ width: '100%',
+ height: '500px'
+ }}
+ />
+ );
+};
+
+// Example with custom positioning (top-right)
+const ExampleWithTopRightSearch: React.FC = () => {
+ const mapData: IData[] = [
+ {
+ id: '1',
+ name: 'New York',
+ imageUrl: 'https://example.com/ny.jpg',
+ link: 'https://example.com/ny',
+ coordinates: [-74.006, 40.7128]
+ }
+ ];
+
+ return (
+
+ );
+};
+
+// Example with custom MapTiler API key
+const ExampleWithCustomMapKey: React.FC = () => {
+ const mapData: IData[] = [
+ {
+ id: '1',
+ name: 'New York',
+ imageUrl: 'https://example.com/ny.jpg',
+ link: 'https://example.com/ny',
+ coordinates: [-74.006, 40.7128]
+ }
+ ];
+
+ return (
+
+ );
+};
+
+// Example with custom MapTiler API key and specific style
+const ExampleWithCustomKeyAndStyle: React.FC = () => {
+ const mapData: IData[] = [
+ {
+ id: '1',
+ name: 'New York',
+ imageUrl: 'https://example.com/ny.jpg',
+ link: 'https://example.com/ny',
+ coordinates: [-74.006, 40.7128]
+ }
+ ];
+
+ return (
+
+ );
+};
+
+// Example using demo map (no key required)
+const ExampleWithDemoMap: React.FC = () => {
+ const mapData: IData[] = [
+ {
+ id: '1',
+ name: 'New York',
+ imageUrl: 'https://example.com/ny.jpg',
+ link: 'https://example.com/ny',
+ coordinates: [-74.006, 40.7128]
+ }
+ ];
+
+ return (
+
+ );
+};
+
+// Example with search disabled
+const ExampleWithoutSearch: React.FC = () => {
+ const mapData: IData[] = [
+ // ... your data
+ ];
+
+ return (
+
+ );
+};
+
+// Example with custom search field
+const ExampleWithCustomSearch: React.FC = () => {
+ const mapData: IData[] = [
+ {
+ id: '1',
+ name: 'New York',
+ imageUrl: 'https://example.com/ny.jpg',
+ link: 'https://example.com/ny',
+ coordinates: [-74.006, 40.7128]
+ }
+ ];
+
+ return (
+ `${item.name} ${item.link}`,
+ }}
+ />
+ );
+};
+
+export {
+ ExampleWithSearch,
+ ExampleWithCustomSearch,
+ ExampleWithoutSearch,
+ ExampleWithTopRightSearch,
+ ExampleWithCustomMapKey,
+ ExampleWithCustomKeyAndStyle,
+ ExampleWithDemoMap
+};
diff --git a/src/controls/worldMap/index.ts b/src/controls/worldMap/index.ts
new file mode 100644
index 000000000..ac71f825f
--- /dev/null
+++ b/src/controls/worldMap/index.ts
@@ -0,0 +1,9 @@
+export * from './IData';
+export * from './IWorldMapProps';
+export * from './IMaplibreWorldMapProps';
+export * from './WorldMap';
+export * from './WorldMapControl';
+
+
+
+
diff --git a/src/controls/worldMap/useCleanMapStyle.tsx b/src/controls/worldMap/useCleanMapStyle.tsx
new file mode 100644
index 000000000..2137827ec
--- /dev/null
+++ b/src/controls/worldMap/useCleanMapStyle.tsx
@@ -0,0 +1,28 @@
+/* eslint-disable @typescript-eslint/no-floating-promises */
+import { useEffect, useState } from "react";
+
+import type { Style } from "maplibre-gl";
+
+/**
+ * Fetches and cleans a MapLibre style JSON, removing any graticule layer.
+ */
+export const useCleanMapStyle = (url: string): Style | undefined => {
+ const [styleJson, setStyleJson] = useState