diff --git a/packages/react-components/react-icons-compat/README.md b/packages/react-components/react-icons-compat/README.md index c721130e37aa1..57ebf3d84dff6 100644 --- a/packages/react-components/react-icons-compat/README.md +++ b/packages/react-components/react-icons-compat/README.md @@ -1,5 +1,5 @@ # @fluentui/react-icons-compat -**React Icons components for [Fluent UI React](https://react.fluentui.dev/)** +**React Icons utility functions to help with migration from v8 to v9 [Fluent UI React](https://react.fluentui.dev/)** -These are not production-ready components and **should never be used in product**. This space is useful for testing new components whose APIs might change before final release. +It is recommended to use this package only if you still need to use functions such as `registerIcons` from v8 icons. Otherwise it is recommended that you use the v9 icons directly from `@fluentui/react-icons`. diff --git a/packages/react-components/react-icons-compat/etc/react-icons-compat.api.md b/packages/react-components/react-icons-compat/etc/react-icons-compat.api.md index ebb55659bcc0b..f595fd88a3689 100644 --- a/packages/react-components/react-icons-compat/etc/react-icons-compat.api.md +++ b/packages/react-components/react-icons-compat/etc/react-icons-compat.api.md @@ -4,6 +4,65 @@ ```ts +// @public +export function getIcon(name?: string): IconRecord | undefined; + +// @public (undocumented) +export interface IconOptions { + disableWarnings: boolean; + // @deprecated (undocumented) + warnOnMissingIcons?: boolean; +} + +// @public (undocumented) +export interface IconRecord { + // (undocumented) + code: string | undefined; + // (undocumented) + subset: IconSubsetRecord; +} + +// @public (undocumented) +export interface IconRecords { + // (undocumented) + [key: string]: IconRecord | {}; + // (undocumented) + __options: IconOptions; + // (undocumented) + __remapped: { + [key: string]: string; + }; +} + +// @public (undocumented) +export interface IconSubset { + // (undocumented) + icons: { + [key: string]: string | JSX.Element; + }; + mergeImageProps?: boolean; +} + +// @public (undocumented) +export interface IconSubsetRecord extends IconSubset { + // (undocumented) + className?: string; + // (undocumented) + isRegistered?: boolean; +} + +// @public +export function registerIconAlias(iconName: string, mappedToName: string): void; + +// @public +export function registerIcons(iconSubset: IconSubset, options?: Partial): void; + +// @public +export function setIconOptions(options: Partial): void; + +// @public +export function unregisterIcons(iconNames: string[]): void; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/react-components/react-icons-compat/src/GlobalSettings.ts b/packages/react-components/react-icons-compat/src/GlobalSettings.ts new file mode 100644 index 0000000000000..b160e787d3bba --- /dev/null +++ b/packages/react-components/react-icons-compat/src/GlobalSettings.ts @@ -0,0 +1,119 @@ +import { getWindow } from './getWindow'; + +/** + * Storing global state in local module variables has issues when more than one copy + * of the module gets loaded on the page (due to a bundling error or simply by consuming + * a prebundled script.) + * + * This file contains helpers to deal with the getting and setting local state, and allows + * callers to get called back when it mutates. + */ + +const GLOBAL_SETTINGS_PROP_NAME = '__globalSettings__'; +const CALLBACK_STATE_PROP_NAME = '__callbacks__'; + +let _counter = 0; + +/** + * Change description used for change callbacks in GlobalSettings. + * + * @public + */ +export interface ChangeDescription { + key: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + oldValue: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; +} + +/** + * Change event callback. + * + * @public + */ +export interface ChangeEventCallback { + __id__?: string; + (changeDescription?: ChangeDescription): void; +} + +/** + * Global settings helper, which stores settings in the global (window) namespace. + * If window is not provided, it will store settings in module scope. Provides a + * way to observe changes as well when their values change. + * + * @public + */ +export class GlobalSettings { + public static getValue(key: string, defaultValue?: T | (() => T)): T { + const globalSettings = _getGlobalSettings(); + + if (globalSettings[key] === undefined) { + globalSettings[key] = typeof defaultValue === 'function' ? (defaultValue as Function)() : defaultValue; + } + + return globalSettings[key]; + } + + public static setValue(key: string, value: T): T { + const globalSettings = _getGlobalSettings(); + const callbacks = globalSettings[CALLBACK_STATE_PROP_NAME]; + const oldValue = globalSettings[key]; + + if (value !== oldValue) { + globalSettings[key] = value; + + const changeDescription = { + oldValue, + value, + key, + }; + + for (const id in callbacks) { + if (callbacks.hasOwnProperty(id)) { + callbacks[id](changeDescription); + } + } + } + + return value; + } + + public static addChangeListener(cb: ChangeEventCallback): void { + // Note: we use generated ids on the callbacks to create a map of the callbacks, which optimizes removal. + // (It's faster to delete a key than it is to look up the index of an object and splice an array.) + let id = cb.__id__; + const callbacks = _getCallbacks(); + + if (!id) { + id = cb.__id__ = String(_counter++); + } + + callbacks[id] = cb; + } + + public static removeChangeListener(cb: ChangeEventCallback): void { + const callbacks = _getCallbacks(); + delete callbacks[cb.__id__ as string]; + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function _getGlobalSettings(): { [key: string]: any } { + const win = getWindow(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const globalObj: { [key: string]: any } = win || {}; + + if (!globalObj[GLOBAL_SETTINGS_PROP_NAME]) { + globalObj[GLOBAL_SETTINGS_PROP_NAME] = { + [CALLBACK_STATE_PROP_NAME]: {}, + }; + } + + return globalObj[GLOBAL_SETTINGS_PROP_NAME]; +} + +function _getCallbacks(): { [key: string]: () => void } { + const globalSettings = _getGlobalSettings(); + return globalSettings[CALLBACK_STATE_PROP_NAME]; +} diff --git a/packages/react-components/react-icons-compat/src/getWindow.ts b/packages/react-components/react-icons-compat/src/getWindow.ts new file mode 100644 index 0000000000000..91c1b6f5d43ab --- /dev/null +++ b/packages/react-components/react-icons-compat/src/getWindow.ts @@ -0,0 +1,31 @@ +import { canUseDOM } from '@fluentui/react-utilities'; + +let _window: Window | undefined = undefined; + +// Note: Accessing "window" in IE11 is somewhat expensive, and calling "typeof window" +// hits a memory leak, whereas aliasing it and calling "typeof _window" does not. +// Caching the window value at the file scope lets us minimize the impact. +try { + // eslint-disable-next-line no-restricted-globals + _window = window; +} catch (e) { + /* no-op */ +} + +/** + * Helper to get the window object. The helper will make sure to use a cached variable + * of "window", to avoid overhead and memory leaks in IE11. Note that in popup scenarios the + * window object won't match the "global" window object, and for these scenarios, you should + * pass in an element hosted within the popup. + * + * @public + */ +export function getWindow(rootElement?: Element | null): Window | undefined { + if (!canUseDOM() || typeof _window === 'undefined') { + return undefined; + } else { + const el = rootElement as Element; + + return el && el.ownerDocument && el.ownerDocument.defaultView ? el.ownerDocument.defaultView : _window; + } +} diff --git a/packages/react-components/react-icons-compat/src/icon.ts b/packages/react-components/react-icons-compat/src/icon.ts new file mode 100644 index 0000000000000..998e4429787b3 --- /dev/null +++ b/packages/react-components/react-icons-compat/src/icon.ts @@ -0,0 +1,224 @@ +import * as React from 'react'; +import { GlobalSettings } from './GlobalSettings'; + +export type T = React.ReactNode; + +export interface IconSubset { + icons: { + [key: string]: string | JSX.Element; + }; + /** + * Indicates to the icon renderer that it is safe to merge any props on the original `Icon` element + * onto the child content element registered for the icon which are valid for HTML images. + */ + mergeImageProps?: boolean; +} + +export interface IconSubsetRecord extends IconSubset { + isRegistered?: boolean; + className?: string; +} + +export interface IconRecord { + code: string | undefined; + subset: IconSubsetRecord; +} + +export interface IconOptions { + /** + * By default, registering the same set of icons will generate a console warning per duplicate icon + * registered, because this scenario can create unexpected consequences. + * + * Some scenarios include: + * + * Icon set was previously registered using a different base url. + * Icon set was previously registered but a different version was provided. + * Icons in a previous registered set overlap with a new set. + * + * To simply ignore previously registered icons, you can specify to disable warnings. This means + * that if an icon which was previous registered is registered again, it will be silently ignored. + * However, consider whether the problems listed above will cause issues. + **/ + disableWarnings: boolean; + + /** + * @deprecated Use `disableWarnings` instead. + */ + warnOnMissingIcons?: boolean; +} + +export interface IconRecords { + __options: IconOptions; + __remapped: { [key: string]: string }; + [key: string]: IconRecord | {}; +} + +const ICON_SETTING_NAME = 'icons'; + +const _iconSettings = GlobalSettings.getValue(ICON_SETTING_NAME, { + __options: { + disableWarnings: false, + warnOnMissingIcons: true, + }, + __remapped: {}, +}); + +/** + * Normalizes an icon name for consistent mapping. + * Current implementation is to convert the icon name to lower case. + * + * @param name - Icon name to normalize. + * @returns {string} Normalized icon name to use for indexing and mapping. + */ +const normalizeIconName = (name: string): string => name.toLowerCase(); + +/** + * Registers a given subset of icons. + * + * @param iconSubset - the icon subset definition. + */ +export function registerIcons(iconSubset: IconSubset, options?: Partial): void { + const subset = { + ...iconSubset, + isRegistered: false, + className: undefined, + }; + const { icons } = iconSubset; + + // Grab options, optionally mix user provided ones on top. + options = options ? { ..._iconSettings.__options, ...options } : _iconSettings.__options; + + for (const iconName in icons) { + if (icons.hasOwnProperty(iconName)) { + const code = icons[iconName]; + const normalizedIconName = normalizeIconName(iconName); + + if (_iconSettings[normalizedIconName]) { + _warnDuplicateIcon(iconName); + } else { + _iconSettings[normalizedIconName] = { + code, + subset, + } as IconRecord; + } + } + } +} + +/** + * Unregisters icons by name. + * + * @param iconNames - List of icons to unregister. + */ +export function unregisterIcons(iconNames: string[]): void { + const options = _iconSettings.__options; + + for (const iconName of iconNames) { + const normalizedIconName = normalizeIconName(iconName); + if (_iconSettings[normalizedIconName]) { + delete _iconSettings[normalizedIconName]; + } else { + // Warn that we are trying to delete an icon that doesn't exist + if (!options.disableWarnings) { + // eslint-disable-next-line no-console + console.warn(`The icon "${iconName}" tried to unregister but was not registered.`); + } + } + + // Delete any aliases for this iconName + if (_iconSettings.__remapped[normalizedIconName]) { + delete _iconSettings.__remapped[normalizedIconName]; + } + + // Delete any items that were an alias for this iconName + Object.keys(_iconSettings.__remapped).forEach((key: string) => { + if (_iconSettings.__remapped[key] === normalizedIconName) { + delete _iconSettings.__remapped[key]; + } + }); + } +} + +/** + * Remaps one icon name to another. + */ +export function registerIconAlias(iconName: string, mappedToName: string): void { + _iconSettings.__remapped[normalizeIconName(iconName)] = normalizeIconName(mappedToName); +} + +/** + * Gets an icon definition. If an icon is requested but the subset has yet to be registered, + * it will get registered immediately. + * + * @public + * @param name - Name of icon. + */ +export function getIcon(name?: string): IconRecord | undefined { + let icon: IconRecord | undefined = undefined; + const options = _iconSettings.__options; + + name = name ? normalizeIconName(name) : ''; + name = _iconSettings.__remapped[name] || name; + + if (name) { + icon = _iconSettings[name!] as IconRecord; + + if (icon) { + const { subset } = icon; + if (subset) { + if (!subset.isRegistered) { + subset.isRegistered = true; + } + } + } else { + // eslint-disable-next-line deprecation/deprecation + if (!options.disableWarnings && options.warnOnMissingIcons) { + // eslint-disable-next-line no-console + console.warn( + `The icon "${name}" was used but not registered. See https://github.com/microsoft/fluentui/wiki/Using-icons for more information.`, + ); + } + } + } + + return icon; +} + +/** + * Sets the icon options. + * + * @public + */ +export function setIconOptions(options: Partial): void { + _iconSettings.__options = { + ..._iconSettings.__options, + ...options, + }; +} + +let _missingIcons: string[] = []; +let _missingIconsTimer: ReturnType | undefined = undefined; + +function _warnDuplicateIcon(iconName: string): void { + const options = _iconSettings.__options; + const warningDelay = 2000; + const maxIconsInMessage = 10; + + if (!options.disableWarnings) { + _missingIcons.push(iconName); + if (_missingIconsTimer === undefined) { + _missingIconsTimer = setTimeout(() => { + // eslint-disable-next-line no-console + console.warn( + `Some icons were re-registered. Applications should only call registerIcons for any given ` + + `icon once. Redefining what an icon is may have unintended consequences. Duplicates ` + + `include: \n` + + _missingIcons.slice(0, maxIconsInMessage).join(', ') + + (_missingIcons.length > maxIconsInMessage ? ` (+ ${_missingIcons.length - maxIconsInMessage} more)` : ''), + ); + _missingIconsTimer = undefined; + _missingIcons = []; + }, warningDelay); + } + } +} diff --git a/packages/react-components/react-icons-compat/src/index.ts b/packages/react-components/react-icons-compat/src/index.ts index cb0ff5c3b541f..9749ac0ff1c79 100644 --- a/packages/react-components/react-icons-compat/src/index.ts +++ b/packages/react-components/react-icons-compat/src/index.ts @@ -1 +1,2 @@ -export {}; +export { registerIcons, registerIconAlias, unregisterIcons, getIcon, setIconOptions } from './icon'; +export type { IconRecord, IconOptions, IconSubset, IconSubsetRecord, IconRecords } from './icon';