diff --git a/api/register-icons/readme.md b/api/register-icons/readme.md index 73c7eb9f..b01421a2 100644 --- a/api/register-icons/readme.md +++ b/api/register-icons/readme.md @@ -2,6 +2,8 @@ The `registerIcons` function allows you to add icons to the global icons store so they become available in the [`IconPicker`](../../components/icon-picker/) component. +> **Legacy:** On WordPress 7.1 and above, prefer registering icons on the server with [`wp_register_icon()`](https://make.wordpress.org/core/2026/07/24/registering-and-rendering-svg-icons-in-wordpress-7-1/). Server registration gives you kses sanitization and REST exposure, and the [`IconPicker`](../../components/icon-picker/) reads those icons from the core icon store automatically (see the [`useIcons`](../../hooks/use-icons/) hook). `registerIcons` remains supported for back-compat and for WordPress below 7.1; icons it registers win over a core-registered icon of the same name. + ## Usage ```js diff --git a/components/icon-picker/readme.md b/components/icon-picker/readme.md index a443bb3a..2a9bbafa 100644 --- a/components/icon-picker/readme.md +++ b/components/icon-picker/readme.md @@ -51,6 +51,13 @@ _The recommended approach for adding an icon picker to your custom block is usin In order to add icons to become available to the icon picker you need to use the [`registerIcons`](../../api/register-icons/) function. +On WordPress 7.1 and above the picker also lists icons from the core icon store +(icons registered with `wp_register_icon`) alongside any registered with +`registerIcons`, with the internal set winning on a name collision. On WordPress +below 7.1 only the `registerIcons` sets are shown. See the +[`useIcons`](../../hooks/use-icons/) hook for details. + You can optionally include the `iconSet` property to the `IconPicker`, `InlineIconPicker`, and `IconPickerToolbarButton` to limit the icons available -in the picker if necessary. +in the picker if necessary. For a core icon set the `iconSet` is its collection +slug. diff --git a/hooks/use-icons/index.ts b/hooks/use-icons/index.ts index 97a1a166..93a29b31 100644 --- a/hooks/use-icons/index.ts +++ b/hooks/use-icons/index.ts @@ -1,49 +1,52 @@ import { useSelect } from '@wordpress/data'; -import { useState, useEffect } from '@wordpress/element'; import { iconStore } from '../../stores'; import { IconSet } from '../../stores/icons/types'; +import { getCoreIcons, mergeIcons, FlattenedIcon } from './map-core-icons'; -function transformIcons(iconSet: IconSet) { +function transformIcons(iconSet: IconSet): FlattenedIcon[] { return iconSet.icons.map((icon) => ({ ...icon, iconSet: iconSet.name })); } -type ExtendedIcons = ReturnType; - const useIcons = (iconSet = '') => { - const [icons, setIcons] = useState([]); - const rawIcons = useSelect( + return useSelect( (select) => { const { getIconSet, getIconSets } = select(iconStore); + let internalIcons: FlattenedIcon[]; if (iconSet) { - return getIconSet(iconSet); + const rawIconSet = getIconSet(iconSet); + // `getIconSet` returns an empty array when the set is unknown to the + // internal store, e.g. a collection registered only in the core store. + internalIcons = Array.isArray(rawIconSet) ? [] : transformIcons(rawIconSet); + } else { + internalIcons = getIconSets().reduce( + (icons, set) => [...icons, ...transformIcons(set)], + [], + ); } - return getIconSets(); + const coreIcons = getCoreIcons(select, iconSet); + + return mergeIcons(internalIcons, coreIcons); }, [iconSet], ); - - useEffect(() => { - if (iconSet) { - setIcons(transformIcons(rawIcons as IconSet)); - } else { - setIcons( - Object.values(rawIcons).reduce( - (rawIcons, iconSet) => [...rawIcons, ...transformIcons(iconSet)], - [], - ), - ); - } - }, [rawIcons, iconSet]); - - return icons; }; const useIcon = (iconSet: string, name: string) => { return useSelect( (select) => { - return select(iconStore).getIcon(iconSet, name); + const internalIcon = select(iconStore).getIcon(iconSet, name); + + const isInternalMatch = !!internalIcon && !Array.isArray(internalIcon); + if (isInternalMatch) { + return internalIcon; + } + + const coreIcons = getCoreIcons(select, iconSet); + const coreIcon = coreIcons.find((icon) => icon.name === name); + + return coreIcon ?? internalIcon; }, [iconSet, name], ); diff --git a/hooks/use-icons/map-core-icons.test.ts b/hooks/use-icons/map-core-icons.test.ts new file mode 100644 index 00000000..1b2e7317 --- /dev/null +++ b/hooks/use-icons/map-core-icons.test.ts @@ -0,0 +1,149 @@ +import { + stripCollectionPrefix, + mapCoreIconRecord, + mergeIcons, + getCoreIcons, + FlattenedIcon, +} from './map-core-icons'; +import { CoreIconRecord } from '../../stores/icons/types'; + +// The core store is only handed to the (faked) `select` in these tests, so a +// stub keeps Jest from loading the real `@wordpress/core-data` module graph. +jest.mock('@wordpress/core-data', () => ({ store: {} })); + +// Fake `select` for WordPress > 7.1, where the `root/icon` entity is +// registered and returns the given records. +function createCoreSelect(records: CoreIconRecord[] | null) { + const selectors = { + getEntityConfig: () => ({ name: 'icon', kind: 'root' }), + getEntityRecords: () => records, + }; + return () => selectors; +} + +// Fake `select` for WordPress < 7.1, where the `root/icon` entity is +// not registered. +function createLegacyCoreSelect() { + const selectors = { + getEntityConfig: () => undefined, + getEntityRecords: () => null, + }; + return () => selectors; +} + +describe('use-icons | map-core-icons', () => { + describe('stripCollectionPrefix', () => { + test('removes the collection prefix', () => { + expect(stripCollectionPrefix('example/smiley', 'example')).toBe('smiley'); + }); + + test('leaves an unprefixed name untouched', () => { + expect(stripCollectionPrefix('smiley', 'example')).toBe('smiley'); + }); + }); + + describe('mapCoreIconRecord', () => { + test('maps a core record onto the flattened picker shape', () => { + const record: CoreIconRecord = { + name: 'example/smiley', + label: 'Smiley', + content: 'smiley', + collection: 'example', + }; + + expect(mapCoreIconRecord(record)).toEqual({ + source: 'smiley', + name: 'smiley', + label: 'Smiley', + iconSet: 'example', + }); + }); + }); + + describe('mergeIcons', () => { + test('appends core icons to the internal icons', () => { + const internalIcons: FlattenedIcon[] = [ + { source: 'a', name: 'a', label: 'A', iconSet: 'theme' }, + ]; + const coreIcons: FlattenedIcon[] = [ + { source: 'b', name: 'b', label: 'B', iconSet: 'example' }, + ]; + + expect(mergeIcons(internalIcons, coreIcons)).toEqual([ + { source: 'a', name: 'a', label: 'A', iconSet: 'theme' }, + { source: 'b', name: 'b', label: 'B', iconSet: 'example' }, + ]); + }); + + test('dedupes by iconSet + name, keeping the internal icon', () => { + const internalIcons: FlattenedIcon[] = [ + { + source: 'internal', + name: 'smiley', + label: 'Internal', + iconSet: 'example', + }, + ]; + const coreIcons: FlattenedIcon[] = [ + { source: 'core', name: 'smiley', label: 'Core', iconSet: 'example' }, + ]; + + const merged = mergeIcons(internalIcons, coreIcons); + + expect(merged).toHaveLength(1); + expect(merged[0].source).toBe('internal'); + expect(merged[0].label).toBe('Internal'); + }); + + test('keeps icons that share a name across different sets', () => { + const internalIcons: FlattenedIcon[] = [ + { source: 'a', name: 'smiley', label: 'A', iconSet: 'theme' }, + ]; + const coreIcons: FlattenedIcon[] = [ + { source: 'b', name: 'smiley', label: 'B', iconSet: 'example' }, + ]; + + expect(mergeIcons(internalIcons, coreIcons)).toHaveLength(2); + }); + }); + + describe('getCoreIcons', () => { + const records: CoreIconRecord[] = [ + { + name: 'example/smiley', + label: 'Smiley', + content: '1', + collection: 'example', + }, + { + name: 'example/heart', + label: 'Heart', + content: '2', + collection: 'example', + }, + { name: 'other/star', label: 'Star', content: '3', collection: 'other' }, + ]; + + test('maps every record when no icon set is requested', () => { + const icons = getCoreIcons(createCoreSelect(records)); + + expect(icons).toHaveLength(3); + expect(icons.map((icon) => icon.name)).toEqual(['smiley', 'heart', 'star']); + }); + + test('filters by the requested icon set', () => { + const icons = getCoreIcons(createCoreSelect(records), 'example'); + + expect(icons).toHaveLength(2); + expect(icons.every((icon) => icon.iconSet === 'example')).toBe(true); + }); + + test('returns an empty array when the icon entity is absent (WordPress < 7.1)', () => { + expect(getCoreIcons(createLegacyCoreSelect())).toEqual([]); + }); + + test('returns an empty array while records are still resolving (null)', () => { + expect(getCoreIcons(createCoreSelect(null))).toEqual([]); + }); + }); +}); diff --git a/hooks/use-icons/map-core-icons.ts b/hooks/use-icons/map-core-icons.ts new file mode 100644 index 00000000..a2efc5a0 --- /dev/null +++ b/hooks/use-icons/map-core-icons.ts @@ -0,0 +1,104 @@ +import { store as coreStore } from '@wordpress/core-data'; + +import { CoreIconRecord, Icon } from '../../stores/icons/types'; + +/** + * An icon flattened into the shape the picker consumes, tagged with the + * icon set it belongs to. + */ +export type FlattenedIcon = Icon & { iconSet: string }; + +/** + * The `select` function passed to a `useSelect` callback. + */ +type WPSelect = (store: any) => any; + +/** + * Strip the leading `/` prefix from a core icon name so it lines + * up with the bare names used by the internal icon store. + * + * @param {string} name Icon name, e.g. `example/smiley`. + * @param {string} collection Collection slug the icon belongs to. + * + * @returns {string} The icon name without its collection prefix. + */ +export function stripCollectionPrefix(name: string, collection: string): string { + const prefix = `${collection}/`; + return name.startsWith(prefix) ? name.slice(prefix.length) : name; +} + +/** + * Map a core icon record onto the flattened shape the picker consumes. + * + * @param {CoreIconRecord} record A record from `getEntityRecords('root','icon')`. + * + * @returns {FlattenedIcon} The mapped icon. + */ +export function mapCoreIconRecord(record: CoreIconRecord): FlattenedIcon { + return { + source: record.content, + name: stripCollectionPrefix(record.name, record.collection), + label: record.label, + iconSet: record.collection, + }; +} + +/** + * Merge core icons into the internal icons, deduping by `iconSet` + `name`. + * Internal icons win on collision, so an explicit `registerIcons` override + * beats a core-registered icon of the same name. + * + * @param {FlattenedIcon[]} internalIcons Icons from the internal `registerIcons` store. + * @param {FlattenedIcon[]} coreIcons Icons mapped from the core icon store. + * + * @returns {FlattenedIcon[]} The merged, deduped list. + */ +export function mergeIcons( + internalIcons: FlattenedIcon[], + coreIcons: FlattenedIcon[], +): FlattenedIcon[] { + const seen = new Set(internalIcons.map((icon) => `${icon.iconSet}/${icon.name}`)); + const merged = [...internalIcons]; + + coreIcons.forEach((icon) => { + const key = `${icon.iconSet}/${icon.name}`; + if (!seen.has(key)) { + seen.add(key); + merged.push(icon); + } + }); + + return merged; +} + +/** + * Read the WordPress 7.1+ core icon store and map its records onto the + * flattened picker shape. + * + * Feature-detected by entity presence: on WordPress < 7.1 the `root/icon` + * entity is not registered, so this returns an empty array and the internal + * `registerIcons` store remains the sole source of icons. + * + * @param {WPSelect} select The `select` function from a `useSelect` callback. + * @param {string} iconSet Optionally limit the result to a single collection. + * + * @returns {FlattenedIcon[]} The mapped core icons. + */ +export function getCoreIcons(select: WPSelect, iconSet = ''): FlattenedIcon[] { + // @ts-ignore-next-line - The type definitions for the core store are incomplete. + const { getEntityConfig, getEntityRecords } = select(coreStore); + + const hasIconEntity = getEntityConfig?.('root', 'icon'); + if (!hasIconEntity) { + return []; + } + + const records: CoreIconRecord[] = getEntityRecords('root', 'icon') ?? []; + const icons = records.map(mapCoreIconRecord); + + if (iconSet) { + return icons.filter((icon) => icon.iconSet === iconSet); + } + + return icons; +} diff --git a/hooks/use-icons/readme.md b/hooks/use-icons/readme.md index 5a7b2409..34a4d66b 100644 --- a/hooks/use-icons/readme.md +++ b/hooks/use-icons/readme.md @@ -22,3 +22,9 @@ function BlockEdit(props) { ``` _Note: Instead of using the `useIcon` hook it is recommended to use the [`Icon`](../../components/icon-picker/) Component which uses the `useIcon` hook under the hood._ + +## Core icon store + +On WordPress 7.1 and above both hooks also read the core icon store (the `root/icon` REST entity registered by `wp_register_icon`) and merge those icons on top of the ones registered with [`registerIcons`](../../api/register-icons/). Each core record is flattened to the same `{ source, name, label, iconSet }` shape, with its `/` prefix stripped from the name and its collection used as the `iconSet`. + +Merging is deduped by `iconSet` + `name`, and the internal store wins on a collision, so an explicit `registerIcons` entry overrides a core-registered icon of the same name. On WordPress below 7.1 the `root/icon` entity is absent, so the hooks fall back to the internal store alone. No configuration is required. diff --git a/stores/icons/readme.md b/stores/icons/readme.md index 14e782af..4d5fac64 100644 --- a/stores/icons/readme.md +++ b/stores/icons/readme.md @@ -1,5 +1,7 @@ # `tenup/icons` data store +This store holds the icons registered through [`registerIcons`](../../api/register-icons/). On WordPress 7.1 and above the [`useIcons`](../../hooks/use-icons/) and [`useIcon`](../../hooks/use-icons/) hooks additionally read the core icon store (the `root/icon` REST entity) and merge those icons on top of this one; the icons here win on a name collision. This store itself is unchanged by that merge and remains the source for `registerIcons` icons. + The data store for `tenup/icons` stores icons in this shape: ```js diff --git a/stores/icons/types.ts b/stores/icons/types.ts index b9f7f98b..f1f5adc5 100644 --- a/stores/icons/types.ts +++ b/stores/icons/types.ts @@ -9,3 +9,15 @@ export type IconSet = { icons: Icon[]; label: string; }; + +/** + * A single icon record from the WordPress 7.1+ core icon store + * (`getEntityRecords('root','icon')`). `name` is prefixed with the + * collection slug (e.g. `example/smiley`) and `content` holds the raw SVG. + */ +export type CoreIconRecord = { + name: string; + label: string; + content: string; + collection: string; +};