Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/register-icons/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion components/icon-picker/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
51 changes: 27 additions & 24 deletions hooks/use-icons/index.ts
Original file line number Diff line number Diff line change
@@ -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<typeof transformIcons>;

const useIcons = (iconSet = '') => {
const [icons, setIcons] = useState<ExtendedIcons>([]);
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<FlattenedIcon[]>(
(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],
);
Expand Down
149 changes: 149 additions & 0 deletions hooks/use-icons/map-core-icons.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<svg>smiley</svg>',
collection: 'example',
};

expect(mapCoreIconRecord(record)).toEqual({
source: '<svg>smiley</svg>',
name: 'smiley',
label: 'Smiley',
iconSet: 'example',
});
});
});

describe('mergeIcons', () => {
test('appends core icons to the internal icons', () => {
const internalIcons: FlattenedIcon[] = [
{ source: '<svg>a</svg>', name: 'a', label: 'A', iconSet: 'theme' },
];
const coreIcons: FlattenedIcon[] = [
{ source: '<svg>b</svg>', name: 'b', label: 'B', iconSet: 'example' },
];

expect(mergeIcons(internalIcons, coreIcons)).toEqual([
{ source: '<svg>a</svg>', name: 'a', label: 'A', iconSet: 'theme' },
{ source: '<svg>b</svg>', name: 'b', label: 'B', iconSet: 'example' },
]);
});

test('dedupes by iconSet + name, keeping the internal icon', () => {
const internalIcons: FlattenedIcon[] = [
{
source: '<svg>internal</svg>',
name: 'smiley',
label: 'Internal',
iconSet: 'example',
},
];
const coreIcons: FlattenedIcon[] = [
{ source: '<svg>core</svg>', name: 'smiley', label: 'Core', iconSet: 'example' },
];

const merged = mergeIcons(internalIcons, coreIcons);

expect(merged).toHaveLength(1);
expect(merged[0].source).toBe('<svg>internal</svg>');
expect(merged[0].label).toBe('Internal');
});

test('keeps icons that share a name across different sets', () => {
const internalIcons: FlattenedIcon[] = [
{ source: '<svg>a</svg>', name: 'smiley', label: 'A', iconSet: 'theme' },
];
const coreIcons: FlattenedIcon[] = [
{ source: '<svg>b</svg>', name: 'smiley', label: 'B', iconSet: 'example' },
];

expect(mergeIcons(internalIcons, coreIcons)).toHaveLength(2);
});
});

describe('getCoreIcons', () => {
const records: CoreIconRecord[] = [
{
name: 'example/smiley',
label: 'Smiley',
content: '<svg>1</svg>',
collection: 'example',
},
{
name: 'example/heart',
label: 'Heart',
content: '<svg>2</svg>',
collection: 'example',
},
{ name: 'other/star', label: 'Star', content: '<svg>3</svg>', 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([]);
});
});
});
104 changes: 104 additions & 0 deletions hooks/use-icons/map-core-icons.ts
Original file line number Diff line number Diff line change
@@ -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 `<collection>/` 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;
}
6 changes: 6 additions & 0 deletions hooks/use-icons/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<collection>/` 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.
2 changes: 2 additions & 0 deletions stores/icons/readme.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 12 additions & 0 deletions stores/icons/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Loading