From e531d9a34953f0ce0d8b22e1491d7e5dd9101c3e Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Mon, 13 Apr 2026 16:00:22 +0200 Subject: [PATCH 01/18] docs: add icon-picker-control refactor spec and implementation plan --- ...2026-04-13-icon-picker-control-refactor.md | 1088 +++++++++++++++++ ...-13-icon-picker-control-refactor-design.md | 235 ++++ 2 files changed, 1323 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-13-icon-picker-control-refactor.md create mode 100644 docs/superpowers/specs/2026-04-13-icon-picker-control-refactor-design.md diff --git a/docs/superpowers/plans/2026-04-13-icon-picker-control-refactor.md b/docs/superpowers/plans/2026-04-13-icon-picker-control-refactor.md new file mode 100644 index 0000000..41f7b1b --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-icon-picker-control-refactor.md @@ -0,0 +1,1088 @@ +# Icon Picker Control Refactor — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refactor `IconPickerControl` to auto-detect a new `/yard/icons` REST API and use it for set-based SVG icon picking, while keeping the existing FontAwesome path 100% intact for sites without the new API. + +**Architecture:** A `useIconSets` hook detects API availability on mount. The wrapper `IconPickerControl` renders either the untouched `FontAwesomeIconPicker` or the new `SvgIconPicker` based on that detection and whether `onChangeSVG` is provided. A module-level SVG cache prevents repeat fetches within a page session. + +**Tech Stack:** React (via `@wordpress/element`), `@wordpress/components` (SelectControl, SearchControl, Popover, Button), `@wordpress/data` + `@wordpress/notices` (snackbar errors), native `fetch` + `AbortController`, WordPress Jest preset for utility tests. + +**Spec:** `docs/superpowers/specs/2026-04-13-icon-picker-control-refactor-design.md` + +--- + +## File Map + +| Action | Path | Responsibility | +|---|---|---| +| Modify | `packages/components/src/icon-picker-control/utils/api.js` | Add `getIconSets`, `searchIcons`, `getIconSvg` | +| Create | `packages/components/src/icon-picker-control/utils/svg-cache.js` | Module-level SVG Map, shared across instances | +| Create | `packages/components/src/icon-picker-control/utils/svg-cache.test.js` | Unit tests for cache helpers | +| Create | `packages/components/src/icon-picker-control/hooks/use-icon-sets.js` | Fetch `/yard/icons` once on mount | +| Create | `packages/components/src/icon-picker-control/components/font-awesome-icon-picker.jsx` | Current FA picker logic extracted verbatim | +| Create | `packages/components/src/icon-picker-control/components/svg-icon-results.jsx` | SVG preview grid (max 10, skeleton placeholders) | +| Create | `packages/components/src/icon-picker-control/components/svg-icon-picker.jsx` | Set selector + debounced search + SVG results | +| Modify | `packages/components/src/icon-picker-control/index.jsx` | Wrapper: detection + routing + new prop API | +| Modify | `packages/components/src/icon-picker-control/editor.css` | Add SVG picker and skeleton styles | +| Modify | `packages/components/src/icon-picker-control/README.md` | Document new props and usage | + +--- + +## Task 1: Extend `utils/api.js` with new REST API functions + +**Files:** +- Modify: `packages/components/src/icon-picker-control/utils/api.js` + +- [ ] **Step 1: Open the file and append the three new exports after the existing `getFontAwesomeIcons` export** + + The existing export must remain untouched. Add these three functions at the bottom of `packages/components/src/icon-picker-control/utils/api.js`: + + ```js + /** + * Fetch all registered icon sets from the wp-icons REST API. + * + * @return {Promise} Map of set name to set metadata, e.g. { "fa-solid": { prefix: "fas" } } + */ + export const getIconSets = async () => { + const res = await fetch( '/yard/icons' ); + if ( ! res.ok ) { + throw new Error( `Failed to fetch icon sets: ${ res.status }` ); + } + return res.json(); + }; + + /** + * Search icons within a set via the wp-icons REST API. + * + * @param {string} set - The icon set identifier, e.g. "fa-solid". + * @param {string} query - The search query. + * @param {AbortSignal} signal - AbortController signal to cancel in-flight requests. + * + * @return {Promise} Array of icon objects: [{ name, set, prefix }, ...] + */ + export const searchIcons = async ( set, query, signal ) => { + const res = await fetch( + `/yard/icons/${ encodeURIComponent( set ) }?q=${ encodeURIComponent( query ) }`, + { signal } + ); + if ( ! res.ok ) { + throw new Error( `Failed to search icons: ${ res.status }` ); + } + return res.json(); + }; + + /** + * Fetch the raw SVG markup for a single icon. + * + * @param {string} set - The icon set identifier, e.g. "fa-solid". + * @param {string} name - The icon name, e.g. "house". + * + * @return {Promise} Raw SVG markup string. + */ + export const getIconSvg = async ( set, name ) => { + const res = await fetch( + `/yard/icons/${ encodeURIComponent( set ) }/${ name }` + ); + if ( ! res.ok ) { + throw new Error( `Failed to fetch SVG for ${ set }/${ name }: ${ res.status }` ); + } + return res.text(); + }; + ``` + +- [ ] **Step 2: Lint the file** + + Run: `npm run lint:js packages/components/src/icon-picker-control/utils/api.js` + + Expected: no errors. + +- [ ] **Step 3: Commit** + + ```bash + git add packages/components/src/icon-picker-control/utils/api.js + git commit -m "feat(icon-picker-control): add REST API helpers for wp-icons endpoints" + ``` + +--- + +## Task 2: Create the SVG cache utility + +**Files:** +- Create: `packages/components/src/icon-picker-control/utils/svg-cache.js` +- Create: `packages/components/src/icon-picker-control/utils/svg-cache.test.js` + +- [ ] **Step 1: Create `utils/svg-cache.js`** + + ```js + /** + * Module-level SVG cache shared across all IconPickerControl instances. + * Persists for the lifetime of the page session. + * + * @type {Map} + */ + const svgCache = new Map(); + + /** + * Retrieve a cached SVG string. + * + * @param {string} set - Icon set identifier. + * @param {string} name - Icon name. + * + * @return {string|undefined} Cached SVG string, or undefined if not cached. + */ + export const getCachedSvg = ( set, name ) => svgCache.get( `${ set }/${ name }` ); + + /** + * Store an SVG string in the cache. + * + * @param {string} set - Icon set identifier. + * @param {string} name - Icon name. + * @param {string} svg - Raw SVG markup. + */ + export const setCachedSvg = ( set, name, svg ) => + svgCache.set( `${ set }/${ name }`, svg ); + + /** + * Check whether an SVG is already cached. + * + * @param {string} set - Icon set identifier. + * @param {string} name - Icon name. + * + * @return {boolean} + */ + export const hasCachedSvg = ( set, name ) => svgCache.has( `${ set }/${ name }` ); + ``` + +- [ ] **Step 2: Create `utils/svg-cache.test.js`** + + ```js + import { getCachedSvg, setCachedSvg, hasCachedSvg } from './svg-cache'; + + describe( 'svg-cache', () => { + test( 'hasCachedSvg returns false for an uncached icon', () => { + expect( hasCachedSvg( 'fa-solid', 'missing-icon' ) ).toBe( false ); + } ); + + test( 'getCachedSvg returns undefined for an uncached icon', () => { + expect( getCachedSvg( 'fa-solid', 'missing-icon' ) ).toBeUndefined(); + } ); + + test( 'setCachedSvg stores an SVG and hasCachedSvg returns true', () => { + setCachedSvg( 'fa-solid', 'house', 'house' ); + expect( hasCachedSvg( 'fa-solid', 'house' ) ).toBe( true ); + } ); + + test( 'getCachedSvg retrieves the stored SVG string', () => { + setCachedSvg( 'fa-solid', 'star', 'star' ); + expect( getCachedSvg( 'fa-solid', 'star' ) ).toBe( 'star' ); + } ); + + test( 'cache is keyed by set/name, different sets are stored separately', () => { + setCachedSvg( 'fa-solid', 'heart', 'solid-heart' ); + setCachedSvg( 'fa-brands', 'heart', 'brands-heart' ); + expect( getCachedSvg( 'fa-solid', 'heart' ) ).toBe( 'solid-heart' ); + expect( getCachedSvg( 'fa-brands', 'heart' ) ).toBe( 'brands-heart' ); + } ); + } ); + ``` + +- [ ] **Step 3: Run the tests** + + Run: `npx jest packages/components/src/icon-picker-control/utils/svg-cache.test.js --no-coverage` + + Expected: 5 tests pass. + +- [ ] **Step 4: Commit** + + ```bash + git add packages/components/src/icon-picker-control/utils/svg-cache.js \ + packages/components/src/icon-picker-control/utils/svg-cache.test.js + git commit -m "feat(icon-picker-control): add module-level SVG cache utility" + ``` + +--- + +## Task 3: Create the `useIconSets` detection hook + +**Files:** +- Create: `packages/components/src/icon-picker-control/hooks/use-icon-sets.js` + +- [ ] **Step 1: Create the `hooks/` directory and `use-icon-sets.js`** + + ```js + /** + * WordPress dependencies + */ + import { useState, useEffect } from '@wordpress/element'; + + /** + * Internal dependencies + */ + import { getIconSets } from '../utils/api'; + + /** + * Detects whether the wp-icons REST API is available by fetching /yard/icons. + * + * Returns: + * - isLoading: true while the request is in flight (render nothing during this time) + * - isNewApiAvailable: true when /yard/icons returned at least one set + * - sets: the map of set name → metadata, e.g. { "fa-solid": { prefix: "fas" } } + * + * @return {{ sets: Object, isNewApiAvailable: boolean, isLoading: boolean }} + */ + const useIconSets = () => { + const [ sets, setSets ] = useState( {} ); + const [ isNewApiAvailable, setIsNewApiAvailable ] = useState( false ); + const [ isLoading, setIsLoading ] = useState( true ); + + useEffect( () => { + getIconSets() + .then( ( data ) => { + if ( data && Object.keys( data ).length > 0 ) { + setSets( data ); + setIsNewApiAvailable( true ); + } + } ) + .catch( () => { + // API not available — will fall back to FontAwesome picker silently. + } ) + .finally( () => { + setIsLoading( false ); + } ); + }, [] ); + + return { sets, isNewApiAvailable, isLoading }; + }; + + export default useIconSets; + ``` + +- [ ] **Step 2: Lint the file** + + Run: `npm run lint:js packages/components/src/icon-picker-control/hooks/use-icon-sets.js` + + Expected: no errors. + +- [ ] **Step 3: Commit** + + ```bash + git add packages/components/src/icon-picker-control/hooks/use-icon-sets.js + git commit -m "feat(icon-picker-control): add useIconSets hook for API detection" + ``` + +--- + +## Task 4: Extract `FontAwesomeIconPicker` + +**Files:** +- Create: `packages/components/src/icon-picker-control/components/font-awesome-icon-picker.jsx` +- Modify: `packages/components/src/icon-picker-control/index.jsx` (remove FA logic, keep exports) + +The FA picker is the **current** `IconPickerControl` function body. The only changes are import paths (the file moves from the root into `components/`). + +- [ ] **Step 1: Create `components/font-awesome-icon-picker.jsx`** + + ```jsx + /** + * WordPress dependencies + */ + import { + Popover, + SearchControl, + } from '@wordpress/components'; + import { useDispatch } from '@wordpress/data'; + import { useState } from '@wordpress/element'; + import { __ } from '@wordpress/i18n'; + import { applyFilters } from '@wordpress/hooks'; + import { store as noticesStore } from '@wordpress/notices'; + + /** + * Internal dependencies + */ + import DeleteIcon from './delete-icon.jsx'; + import IconResults from './icon-results.jsx'; + import { getFontAwesomeIcons } from '../utils/api'; + import { convertResponseToClassnames } from '../utils/helpers'; + + const FontAwesomeIconPicker = ( { + onChange, + icon, + displayIconPreview = true, + displayAsPopover = true, + displayDeleteIcon = false, + handleRemove, + } ) => { + const [ isOpen, setOpen ] = useState( false ); + const [ searchInput, setSearchInput ] = useState( '' ); + const [ searchResults, setSearchResults ] = useState( [] ); + const [ popoverAnchor, setPopoverAnchor ] = useState(); + + const { createNotice } = useDispatch( noticesStore ); + + const allowedFamilyStyles = applyFilters( + 'yard.fontawesome-family-styles', + [ + { family: 'classic', style: 'solid' }, + { family: 'classic', style: 'regular' }, + { family: 'classic', style: 'light' }, + { family: 'classic', style: 'thin' }, + { family: 'classic', style: 'brands' }, + { family: 'duotone', style: 'solid' }, + { family: 'sharp', style: 'solid' }, + { family: 'sharp', style: 'regular' }, + { family: 'sharp', style: 'light' }, + { family: 'sharp', style: 'thin' }, + ] + ); + + const searchFontAwesomeIcons = async ( searchValue ) => { + try { + const response = await getFontAwesomeIcons( searchValue ); + if ( ! response ) return; + + const result = response?.data?.search.reduce( + ( iconResults, iconData ) => { + convertResponseToClassnames( + iconData, + allowedFamilyStyles + ).forEach( ( value ) => { + iconResults.push( value ); + } ); + + return iconResults; + }, + [] + ); + if ( ! result ) return; + + setSearchResults( result ); + setOpen( true ); + } catch ( err ) { + return showErrorNotice(); + } + }; + + const showErrorNotice = () => { + createNotice( + 'error', + __( + 'Momenteel kunnen er geen iconen worden opgehaald, probeer het later nog een keer.' + ), + { + isDismissible: true, + type: 'snackbar', + id: 'icon-picker-control-error', + } + ); + }; + + const handleIconClick = ( clickedIcon ) => { + onChange( clickedIcon ); + setSearchInput( () => '' ); + setOpen( () => false ); + }; + + return ( + <> + { displayIconPreview && icon && ( + + ) } + + { + setSearchInput( searchValue ); + searchFontAwesomeIcons( searchValue ); + } } + ref={ setPopoverAnchor } + /> + + { displayAsPopover && searchInput && isOpen && ( + setOpen( false ) } + focusOnMount={ false } + > + + + ) } + + { ! displayAsPopover && searchInput && ( + + ) } + + { displayDeleteIcon && icon && ( + + ) } + + ); + }; + + export default FontAwesomeIconPicker; + ``` + +- [ ] **Step 2: Lint the new file** + + Run: `npm run lint:js packages/components/src/icon-picker-control/components/font-awesome-icon-picker.jsx` + + Expected: no errors. + +- [ ] **Step 3: Commit** + + ```bash + git add packages/components/src/icon-picker-control/components/font-awesome-icon-picker.jsx + git commit -m "feat(icon-picker-control): extract FontAwesomeIconPicker component" + ``` + +--- + +## Task 5: Create `SvgIconResults` + +**Files:** +- Create: `packages/components/src/icon-picker-control/components/svg-icon-results.jsx` + +- [ ] **Step 1: Create `components/svg-icon-results.jsx`** + + Each result object has shape `{ name: string, set: string, svg: string|null }`. When `svg` is `null` (still loading), a skeleton placeholder is rendered. When `svg` is `'ERROR'`, a `×` placeholder is shown. + + ```jsx + /** + * WordPress dependencies + */ + import { Button } from '@wordpress/components'; + import { __ } from '@wordpress/i18n'; + + const SvgIconResults = ( { results, onIconClick } ) => { + return ( +
+ { results.map( ( { name, set, svg }, key ) => ( +
+ +
+ ) ) } + + { ! results.length && ( +

{ __( 'Er zijn geen iconen gevonden' ) }

+ ) } +
+ ); + }; + + export default SvgIconResults; + ``` + +- [ ] **Step 2: Lint** + + Run: `npm run lint:js packages/components/src/icon-picker-control/components/svg-icon-results.jsx` + + Expected: no errors. + +- [ ] **Step 3: Commit** + + ```bash + git add packages/components/src/icon-picker-control/components/svg-icon-results.jsx + git commit -m "feat(icon-picker-control): add SvgIconResults grid component" + ``` + +--- + +## Task 6: Create `SvgIconPicker` + +**Files:** +- Create: `packages/components/src/icon-picker-control/components/svg-icon-picker.jsx` + +- [ ] **Step 1: Create `components/svg-icon-picker.jsx`** + + ```jsx + /** + * WordPress dependencies + */ + import { + SelectControl, + SearchControl, + Popover, + } from '@wordpress/components'; + import { useDispatch } from '@wordpress/data'; + import { useState, useRef } from '@wordpress/element'; + import { __ } from '@wordpress/i18n'; + import { store as noticesStore } from '@wordpress/notices'; + + /** + * Internal dependencies + */ + import DeleteIcon from './delete-icon.jsx'; + import SvgIconResults from './svg-icon-results.jsx'; + import { searchIcons, getIconSvg } from '../utils/api'; + import { getCachedSvg, setCachedSvg, hasCachedSvg } from '../utils/svg-cache'; + + const SEARCH_MIN_LENGTH = 3; + const SEARCH_DEBOUNCE_MS = 300; + const MAX_RESULTS = 10; + + const SvgIconPicker = ( { + sets, + onChangeSVG, + iconSVG, + displayIconPreview = true, + displayAsPopover = true, + displayDeleteIcon = false, + handleRemove, + } ) => { + const setOptions = Object.keys( sets ).map( ( key ) => ( { + label: key, + value: key, + } ) ); + + const [ selectedSet, setSelectedSet ] = useState( setOptions[ 0 ]?.value ?? '' ); + const [ searchInput, setSearchInput ] = useState( '' ); + const [ results, setResults ] = useState( [] ); + const [ isOpen, setIsOpen ] = useState( false ); + const [ popoverAnchor, setPopoverAnchor ] = useState(); + + const abortControllerRef = useRef( null ); + const debounceTimerRef = useRef( null ); + + const { createNotice } = useDispatch( noticesStore ); + + const showErrorNotice = () => { + createNotice( + 'error', + __( + 'Momenteel kunnen er geen iconen worden opgehaald, probeer het later nog een keer.' + ), + { + isDismissible: true, + type: 'snackbar', + id: 'icon-picker-control-error', + } + ); + }; + + /** + * Fetch SVGs for a list of icon objects, using the cache where possible. + * Returns the same list with a `svg` property added (string or 'ERROR'). + * + * @param {Array} iconList - Array of { name, set, ... } objects. + * @return {Promise} + */ + const fetchSvgsForResults = ( iconList ) => { + return Promise.all( + iconList.map( async ( iconData ) => { + const { name, set } = iconData; + if ( hasCachedSvg( set, name ) ) { + return { ...iconData, svg: getCachedSvg( set, name ) }; + } + try { + const svg = await getIconSvg( set, name ); + setCachedSvg( set, name, svg ); + return { ...iconData, svg }; + } catch { + return { ...iconData, svg: 'ERROR' }; + } + } ) + ); + }; + + const performSearch = async ( set, query, signal ) => { + try { + const iconList = await searchIcons( set, query, signal ); + const limited = iconList.slice( 0, MAX_RESULTS ); + + // Render immediately with skeleton placeholders. + setResults( limited.map( ( i ) => ( { ...i, svg: null } ) ) ); + setIsOpen( true ); + + // Then resolve SVGs and update. + const withSvgs = await fetchSvgsForResults( limited ); + setResults( withSvgs ); + } catch ( err ) { + if ( err.name === 'AbortError' ) return; + showErrorNotice(); + } + }; + + const handleSearchChange = ( value ) => { + setSearchInput( value ); + + if ( debounceTimerRef.current ) { + clearTimeout( debounceTimerRef.current ); + } + + if ( value.length < SEARCH_MIN_LENGTH ) { + setResults( [] ); + setIsOpen( false ); + return; + } + + debounceTimerRef.current = setTimeout( () => { + if ( abortControllerRef.current ) { + abortControllerRef.current.abort(); + } + abortControllerRef.current = new AbortController(); + performSearch( selectedSet, value, abortControllerRef.current.signal ); + }, SEARCH_DEBOUNCE_MS ); + }; + + const handleSetChange = ( value ) => { + setSelectedSet( value ); + setSearchInput( '' ); + setResults( [] ); + setIsOpen( false ); + }; + + const handleIconClick = ( svg ) => { + onChangeSVG( svg ); + setSearchInput( '' ); + setResults( [] ); + setIsOpen( false ); + }; + + return ( + <> + { displayIconPreview && iconSVG && ( + + ) } + + + + + + { displayAsPopover && searchInput && isOpen && ( + setIsOpen( false ) } + focusOnMount={ false } + > + + + ) } + + { ! displayAsPopover && searchInput && ( + + ) } + + { displayDeleteIcon && iconSVG && ( + + ) } + + ); + }; + + export default SvgIconPicker; + ``` + +- [ ] **Step 2: Lint** + + Run: `npm run lint:js packages/components/src/icon-picker-control/components/svg-icon-picker.jsx` + + Expected: no errors. + +- [ ] **Step 3: Commit** + + ```bash + git add packages/components/src/icon-picker-control/components/svg-icon-picker.jsx + git commit -m "feat(icon-picker-control): add SvgIconPicker component with debounced search and SVG cache" + ``` + +--- + +## Task 7: Rewrite `index.jsx` as the detection wrapper + +**Files:** +- Modify: `packages/components/src/icon-picker-control/index.jsx` + +This file now contains only the wrapper logic and the three public exports. The FA logic has moved to `font-awesome-icon-picker.jsx`. + +- [ ] **Step 1: Replace the full contents of `index.jsx`** + + ```jsx + /** + * WordPress dependencies + */ + import { Dropdown, ToolbarButton, ToolbarGroup } from '@wordpress/components'; + import { BlockControls } from '@wordpress/block-editor'; + import { __ } from '@wordpress/i18n'; + + /** + * Internal dependencies + */ + import FontAwesomeIconPicker from './components/font-awesome-icon-picker.jsx'; + import SvgIconPicker from './components/svg-icon-picker.jsx'; + import useIconSets from './hooks/use-icon-sets.js'; + import './editor.css'; + + /** + * Core icon picker control. Automatically selects between the FontAwesome + * picker and the new SVG-based picker depending on: + * 1. Whether /yard/icons is reachable (detected on mount). + * 2. Whether the consumer has provided an `onChangeSVG` callback. + * + * If either condition is false, the FontAwesome picker is rendered. + * + * @param {Object} props + * @param {Function} props.onChange Called with FA class string when FA picker is used. + * @param {string} props.icon Current FA class string (for legacy preview). + * @param {boolean} props.displayIconPreview Show icon preview above search. Default true. + * @param {boolean} props.displayAsPopover Show results in a popover. Default true. + * @param {boolean} props.displayDeleteIcon Show delete button. Default false. + * @param {Function} props.handleRemove Called when delete button is clicked. + * @param {Function} props.onChangeSVG Called with raw SVG string when SVG picker is used. + * @param {string} props.iconSVG Current SVG markup (for SVG preview). + */ + export const IconPickerControl = ( { + onChange, + icon, + displayIconPreview = true, + displayAsPopover = true, + displayDeleteIcon = false, + handleRemove, + onChangeSVG, + iconSVG, + } ) => { + const { sets, isNewApiAvailable, isLoading } = useIconSets(); + + if ( isLoading ) { + return null; + } + + if ( isNewApiAvailable && onChangeSVG ) { + return ( + + ); + } + + return ( + + ); + }; + + export const IconPickerControlInspector = ( { + icon, + onChange, + displayDeleteIcon = false, + handleRemove, + onChangeSVG, + iconSVG, + } ) => { + return ( + + ); + }; + + export const IconPickerControlToolbar = ( { + icon, + onChange, + onChangeSVG, + iconSVG, + } ) => { + return ( + + ( + + + { __( 'Kies icoon' ) } + + + ) } + renderContent={ () => ( + + ) } + /> + + ); + }; + ``` + +- [ ] **Step 2: Lint** + + Run: `npm run lint:js packages/components/src/icon-picker-control/index.jsx` + + Expected: no errors. + +- [ ] **Step 3: Commit** + + ```bash + git add packages/components/src/icon-picker-control/index.jsx + git commit -m "feat(icon-picker-control): replace index.jsx with detection wrapper, expose onChangeSVG/iconSVG props" + ``` + +--- + +## Task 8: Extend `editor.css` with SVG picker styles + +**Files:** +- Modify: `packages/components/src/icon-picker-control/editor.css` + +- [ ] **Step 1: Append new styles to the end of `editor.css`** + + ```css + /* SVG icon preview (replaces for SVG-mode selected icon) */ + .icon-picker-control-preview-icon svg { + width: 3rem; + height: 3rem; + display: block; + margin-bottom: 1rem; + } + + /* Skeleton placeholder for SVG cells that are still loading */ + .icon-picker-control-svg-skeleton { + display: block; + width: 2rem; + height: 2rem; + background-color: #e0e0e0; + border-radius: 2px; + animation: icon-picker-skeleton-pulse 1.2s ease-in-out infinite; + } + + @keyframes icon-picker-skeleton-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } + } + + /* Error placeholder for failed SVG fetches */ + .icon-picker-control-svg-error { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + font-size: 1.2rem; + color: #757575; + } + + /* Ensure SVGs inside result buttons scale correctly */ + .icon-picker-control-icon-btn-container svg { + width: 2rem; + height: 2rem; + display: block; + } + ``` + +- [ ] **Step 2: Lint CSS** + + Run: `npm run lint:scss packages/components/src/icon-picker-control/editor.css` + + Expected: no errors. + +- [ ] **Step 3: Commit** + + ```bash + git add packages/components/src/icon-picker-control/editor.css + git commit -m "feat(icon-picker-control): add SVG picker and skeleton styles" + ``` + +--- + +## Task 9: Update README + +**Files:** +- Modify: `packages/components/src/icon-picker-control/README.md` + +- [ ] **Step 1: Replace the full contents of `README.md`** + + ```markdown + # Icon Picker Control + + Picks an icon in the block editor. Supports two modes: + + - **FontAwesome mode** (default, legacy): searches the FontAwesome GraphQL API and returns a CSS class string (e.g. `fa-solid fa-house`). Stored in the `icon` block attribute. + - **SVG mode** (new): uses the `wp-icons` REST API (`/yard/icons`) to search sets of SVG icons and returns raw SVG markup. Stored in the `iconSVG` block attribute. + + Mode is selected automatically. If `/yard/icons` is reachable **and** the consumer provides an `onChangeSVG` callback, SVG mode is used. Otherwise FontAwesome mode is used. Existing consumers that only provide `onChange` are unaffected. + + ## Props + + | Prop | Type | Default | Description | + |---|---|---|---| + | `onChange` | `Function` | — | Called with FA class string (FontAwesome mode). | + | `icon` | `string` | — | Current FA class string, used for the legacy icon preview. | + | `displayIconPreview` | `boolean` | `true` | Show a preview of the selected icon above the search field. | + | `displayAsPopover` | `boolean` | `true` | Show search results in a popover (vs. inline). | + | `displayDeleteIcon` | `boolean` | `false` | Show a delete button below the search field. | + | `handleRemove` | `Function` | — | Called when the delete button is clicked. | + | `onChangeSVG` | `Function` | — | Called with raw SVG string (SVG mode). Opts the consumer into SVG mode when provided. | + | `iconSVG` | `string` | — | Current SVG markup, used for the SVG icon preview. | + + ## New consumer example + + ```jsx + setAttributes({ icon: v }) } + onChangeSVG={ ( v ) => setAttributes({ iconSVG: v }) } + /> + ``` + + ### Block attribute registration + + ```js + attributes: { + icon: { type: 'string', default: '' }, + iconSVG: { type: 'string', default: '' }, + } + ``` + + ### Frontend rendering (handle both old and new saved content) + + ```jsx + { iconSVG + ? + : icon && + } + ``` + + ## FontAwesome mode — filter + + To limit which FontAwesome family/style combinations appear, use this WordPress filter: + + ```js + import { addFilter } from '@wordpress/hooks'; + + addFilter( 'yard.fontawesome-family-styles', 'yard', () => [ + { family: 'classic', style: 'solid' }, + { family: 'classic', style: 'regular' }, + ] ); + ``` + + ## SVG mode — how it works + + 1. On mount, fetches `/yard/icons` to get available icon sets. + 2. Renders a set selector (`SelectControl`) populated with the returned sets. + 3. Search fires after 3 characters, debounced by 300 ms. + 4. Shows the first 10 results; fetches each icon's SVG eagerly (with skeleton placeholders while loading). + 5. SVGs are cached in a module-level `Map` for the page session — the same icon is never fetched twice. + 6. Selecting an icon calls `onChangeSVG` with the raw SVG string. + ``` + +- [ ] **Step 2: Commit** + + ```bash + git add packages/components/src/icon-picker-control/README.md + git commit -m "docs(icon-picker-control): update README for SVG mode and new props" + ``` + +--- + +## Task 10: Manual verification in the browser + +No automated component tests exist for this package. Verify the following manually in a WordPress site with the block editor open. + +- [ ] **Scenario A — Legacy site (no wp-icons API)** + + 1. Open a block that uses `IconPickerControlInspector` with only `onChange` wired up. + 2. Confirm the FontAwesome search field appears immediately. + 3. Type a search term. Confirm FA icon results appear in the popover. + 4. Click an icon. Confirm `onChange` is called and the `icon` attribute is saved. + +- [ ] **Scenario B — New site, consumer not opted in** + + 1. Ensure `/yard/icons` returns data on the site. + 2. Open a block that uses `IconPickerControlInspector` with only `onChange` wired up (no `onChangeSVG`). + 3. Confirm the FontAwesome picker appears (API available but consumer not opted in → FA path). + +- [ ] **Scenario C — New site, consumer opted in** + + 1. Ensure `/yard/icons` returns data on the site. + 2. Open a block that uses `IconPickerControlInspector` with both `onChange` and `onChangeSVG` wired up. + 3. Confirm the set selector dropdown appears populated with set names. + 4. Type fewer than 3 characters. Confirm no search fires. + 5. Type 3+ characters. Confirm skeleton placeholders appear, then SVG icons load in. + 6. Confirm only 10 results show at most. + 7. Click an icon. Confirm `onChangeSVG` is called, the popover closes, and the SVG preview appears. + 8. Confirm the delete button (if `displayDeleteIcon` is true and `iconSVG` is set) removes the icon. + +- [ ] **Scenario D — Network failure during SVG fetch** + + 1. In browser DevTools, throttle a specific `/yard/icons/{set}/{name}` request to fail. + 2. Confirm that cell shows a `×` placeholder and the rest of the grid loads correctly. + 3. Confirm no error notice appears (only search failures show the snackbar). + +- [ ] **Scenario E — Network failure on sets fetch** + + 1. In browser DevTools, block `/yard/icons` entirely. + 2. Confirm the component renders the FontAwesome picker (silent fallback, no error shown). diff --git a/docs/superpowers/specs/2026-04-13-icon-picker-control-refactor-design.md b/docs/superpowers/specs/2026-04-13-icon-picker-control-refactor-design.md new file mode 100644 index 0000000..261e86a --- /dev/null +++ b/docs/superpowers/specs/2026-04-13-icon-picker-control-refactor-design.md @@ -0,0 +1,235 @@ +# Icon Picker Control Refactor — Design Spec + +**Date:** 2026-04-13 +**Status:** Approved + +## Overview + +Refactor `IconPickerControl` to support a new WordPress REST API (`/yard/icons`) provided by the `wp-icons` Laravel/Acorn package (built on Blade Icons). The refactor must be **100% backwards compatible**: sites without the new package continue using the existing FontAwesome GraphQL API flow without any changes to consuming blocks. + +--- + +## Goals + +- Auto-detect the new `/yard/icons` REST API at runtime +- When available and opted into, use it for icon search + SVG retrieval +- Keep the existing FontAwesome path working exactly as-is for legacy sites +- Expose a clean new `onChangeSVG` / `iconSVG` API for new consumers +- Reduce unnecessary API load with debouncing, result capping, and a shared SVG cache + +--- + +## File Structure + +``` +packages/components/src/icon-picker-control/ + index.jsx ← updated wrapper + unchanged public exports + editor.css ← extended for SVG picker styles + README.md ← updated + components/ + delete-icon.jsx ← unchanged + icon-results.jsx ← unchanged (used by FontAwesomeIconPicker) + font-awesome-icon-picker.jsx ← NEW: current IconPickerControl logic extracted verbatim + svg-icon-picker.jsx ← NEW: set selector + debounced search + SVG grid + svg-icon-results.jsx ← NEW: SVG preview grid (max 10 results) + hooks/ + use-icon-sets.js ← NEW: detects /yard/icons on mount + utils/ + api.js ← extended: adds getIconSets, searchIcons, getIconSvg + helpers.js ← unchanged + svg-cache.js ← NEW: module-level Map shared across all instances +``` + +--- + +## Public API + +The three exported symbols are **unchanged**: `IconPickerControl`, `IconPickerControlInspector`, `IconPickerControlToolbar`. Existing consumers require zero changes. + +### Updated prop signature + +```jsx +IconPickerControl({ + // Existing props — all unchanged + onChange, // (classString) => void — FA path callback + icon, // string: FA class string for legacy preview + displayIconPreview, // bool, default true + displayAsPopover, // bool, default true + displayDeleteIcon, // bool, default false + handleRemove, // () => void + + // New optional props — SVG path + onChangeSVG, // (svgString) => void — opts consumer into SVG mode + iconSVG, // string: raw SVG markup for preview in SVG mode +}) +``` + +### Mode selection logic + +``` +useIconSets() resolves + ├─ fetch failed OR sets empty → render + └─ sets has data + ├─ onChangeSVG not provided → render + └─ onChangeSVG provided → render +``` + +Existing consumers providing only `onChange` always get `FontAwesomeIconPicker`, even on sites with the new API installed. + +### New consumer wiring + +```jsx + setAttributes({ icon: v }) } + onChangeSVG={ ( v ) => setAttributes({ iconSVG: v }) } +/> +``` + +### Block frontend template pattern + +```jsx +{ iconSVG + ? + : icon && +} +``` + +This handles both old saved content (`icon`) and new saved content (`iconSVG`) transparently. + +--- + +## Components + +### `FontAwesomeIconPicker` + +The current `IconPickerControl` code moved verbatim into `components/font-awesome-icon-picker.jsx`. No logic changes. Receives the same props as today's `IconPickerControl`. + +### `SvgIconPicker` + +New component. Props: `sets`, `onChange`, `onChangeSVG`, `icon`, `iconSVG`, `displayIconPreview`, `displayAsPopover`, `displayDeleteIcon`, `handleRemove`. + +UI structure: +``` +[ SelectControl: "Choose icon set" ] ← required, populated from sets prop +[ SearchControl: "Search icons..." ] ← 3-char minimum, 300ms debounce +[ Popover / inline results ] + └─ grid (max 10) + └─ Button > + └─ while SVG loading: grey skeleton placeholder +[ DeleteIcon button ] ← unchanged behaviour +[ Icon preview ] ← when iconSVG present +``` + +### `SvgIconResults` + +Renders a grid of up to 10 icon results. Each cell shows the fetched SVG or a skeleton placeholder while loading. On click, calls `onIconClick(svg)`. + +--- + +## Hooks + +### `useIconSets` + +```js +const { sets, isNewApiAvailable, isLoading } = useIconSets(); +``` + +- Fetches `GET /yard/icons` once on mount +- While in-flight: `isLoading = true` — wrapper renders nothing (avoids flash of wrong picker) +- On success with data: `isNewApiAvailable = true`, `sets = { "set-name": { prefix }, ... }` +- On failure or empty response: `isNewApiAvailable = false` + +--- + +## Utilities + +### `utils/api.js` additions + +Existing `getFontAwesomeIcons` is untouched. + +```js +// GET /yard/icons → { "set-name": { prefix }, ... } +export const getIconSets = async () => { ... }; + +// GET /yard/icons/{set}?q={query} → [{ name, set, prefix }, ...] +// Accepts AbortSignal for debounce/abort pattern +export const searchIcons = async ( set, query, signal ) => { ... }; + +// GET /yard/icons/{set}/{name} → SVG string +export const getIconSvg = async ( set, name ) => { ... }; +``` + +### `utils/svg-cache.js` + +Module-level `Map`, lives outside any component, shared across all instances and re-renders within a page session: + +```js +const svgCache = new Map(); // keyed by `${set}/${name}` +export const getCachedSvg = ( set, name ) => svgCache.get( `${set}/${name}` ); +export const setCachedSvg = ( set, name, svg ) => svgCache.set( `${set}/${name}`, svg ); +``` + +--- + +## Data Flow (SVG mode) + +``` +mount + └─ useIconSets fetches /yard/icons → populate set dropdown + +user picks set + └─ reset search input + results + +user types in search field + ├─ < 3 chars → do nothing + └─ ≥ 3 chars → wait 300ms (debounced) + └─ abort any in-flight request via AbortController + └─ GET /yard/icons/{set}?q={query} + └─ take first 10 results + └─ for each result: check svgCache + ├─ cache hit → render immediately + └─ cache miss → GET /yard/icons/{set}/{name} + └─ store in svgCache, render cell + +user clicks icon + └─ SVG already in cache (was fetched for preview) + └─ call onChangeSVG( svg ) + └─ clear search + close popover +``` + +--- + +## Load Reduction + +| Technique | Detail | +|---|---| +| 3-character minimum | Search does not fire until `searchInput.length >= 3` | +| 300ms debounce | Keystroke timer resets on each character; request fires only when user pauses | +| AbortController | Previous in-flight search request is cancelled when a new one starts | +| 10-result cap | Only first 10 results from the API are shown and SVGs fetched | +| Module-level SVG cache | Same SVG never fetched twice in a page session | +| Eager sets fetch on mount | `/yard/icons` fetched immediately; dropdown ready before user types | +| `Cache-Control: max-age=86400` | Both `/yard/icons` and SVG endpoints are cached by the browser for 24h | + +--- + +## Error Handling + +| Scenario | Behaviour | +|---|---| +| `/yard/icons` fetch fails on mount | `isNewApiAvailable = false` → silently renders `FontAwesomeIconPicker` | +| Search request fails | WordPress snackbar error notice (same pattern as current implementation) | +| Search request aborted | Silently ignored — not treated as an error | +| Individual SVG fetch fails | That grid cell renders a `×` placeholder; rest of grid unaffected | +| No results for query | "Er zijn geen iconen gevonden" — same copy as current | + +--- + +## Backwards Compatibility + +- All existing `onChange` / `icon` consumers work without any changes +- `FontAwesomeIconPicker` is the current code, not modified +- `onChangeSVG` is fully optional — omitting it locks the component into FA mode regardless of API availability +- Block frontend templates should check `iconSVG` first and fall back to `icon` — this handles all combinations of old/new saved content From 6db01e2407569bd41b764fb3277de3a8419f5514 Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Mon, 13 Apr 2026 16:03:38 +0200 Subject: [PATCH 02/18] feat(icon-picker-control): add REST API helpers for wp-icons endpoints --- .../src/icon-picker-control/utils/api.js | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/components/src/icon-picker-control/utils/api.js b/packages/components/src/icon-picker-control/utils/api.js index 14c936a..eeea163 100644 --- a/packages/components/src/icon-picker-control/utils/api.js +++ b/packages/components/src/icon-picker-control/utils/api.js @@ -41,3 +41,58 @@ export const getFontAwesomeIcons = async ( search ) => { throw new Error( error ); } }; + +/** + * Fetch all registered icon sets from the wp-icons REST API. + * + * @return {Promise} Map of set name to set metadata, e.g. { "fa-solid": { prefix: "fas" } } + */ +export const getIconSets = async () => { + const res = await fetch( '/yard/icons' ); + if ( ! res.ok ) { + throw new Error( `Failed to fetch icon sets: ${ res.status }` ); + } + return res.json(); +}; + +/** + * Search icons within a set via the wp-icons REST API. + * + * @param {string} set - The icon set identifier, e.g. "fa-solid". + * @param {string} query - The search query. + * @param {AbortSignal} signal - AbortController signal to cancel in-flight requests. + * + * @return {Promise} Array of icon objects: [{ name, set, prefix }, ...] + */ +export const searchIcons = async ( set, query, signal ) => { + const res = await fetch( + `/yard/icons/${ encodeURIComponent( set ) }?q=${ encodeURIComponent( + query + ) }`, + { signal } + ); + if ( ! res.ok ) { + throw new Error( `Failed to search icons: ${ res.status }` ); + } + return res.json(); +}; + +/** + * Fetch the raw SVG markup for a single icon. + * + * @param {string} set - The icon set identifier, e.g. "fa-solid". + * @param {string} name - The icon name, e.g. "house". + * + * @return {Promise} Raw SVG markup string. + */ +export const getIconSvg = async ( set, name ) => { + const res = await fetch( + `/yard/icons/${ encodeURIComponent( set ) }/${ name }` + ); + if ( ! res.ok ) { + throw new Error( + `Failed to fetch SVG for ${ set }/${ name }: ${ res.status }` + ); + } + return res.text(); +}; From 1fb934fedab3260992bbf23290774b15c0602d5e Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Mon, 13 Apr 2026 16:14:02 +0200 Subject: [PATCH 03/18] fix(icon-picker-control): url-encode name param in getIconSvg, mark signal as optional --- .../components/src/icon-picker-control/utils/api.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/components/src/icon-picker-control/utils/api.js b/packages/components/src/icon-picker-control/utils/api.js index eeea163..38492a5 100644 --- a/packages/components/src/icon-picker-control/utils/api.js +++ b/packages/components/src/icon-picker-control/utils/api.js @@ -58,9 +58,9 @@ export const getIconSets = async () => { /** * Search icons within a set via the wp-icons REST API. * - * @param {string} set - The icon set identifier, e.g. "fa-solid". - * @param {string} query - The search query. - * @param {AbortSignal} signal - AbortController signal to cancel in-flight requests. + * @param {string} set - The icon set identifier, e.g. "fa-solid". + * @param {string} query - The search query. + * @param {AbortSignal} [signal] - AbortController signal to cancel in-flight requests. * * @return {Promise} Array of icon objects: [{ name, set, prefix }, ...] */ @@ -87,7 +87,9 @@ export const searchIcons = async ( set, query, signal ) => { */ export const getIconSvg = async ( set, name ) => { const res = await fetch( - `/yard/icons/${ encodeURIComponent( set ) }/${ name }` + `/yard/icons/${ encodeURIComponent( set ) }/${ encodeURIComponent( + name + ) }` ); if ( ! res.ok ) { throw new Error( From 3c7afeaa3773c821de1f01e9df97a3251e18a779 Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Mon, 13 Apr 2026 16:15:06 +0200 Subject: [PATCH 04/18] feat(icon-picker-control): add module-level SVG cache utility --- .../icon-picker-control/utils/svg-cache.js | 37 +++++++++++++++++++ .../utils/svg-cache.test.js | 28 ++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 packages/components/src/icon-picker-control/utils/svg-cache.js create mode 100644 packages/components/src/icon-picker-control/utils/svg-cache.test.js diff --git a/packages/components/src/icon-picker-control/utils/svg-cache.js b/packages/components/src/icon-picker-control/utils/svg-cache.js new file mode 100644 index 0000000..60932ee --- /dev/null +++ b/packages/components/src/icon-picker-control/utils/svg-cache.js @@ -0,0 +1,37 @@ +/** + * Module-level SVG cache shared across all IconPickerControl instances. + * Persists for the lifetime of the page session. + * + * @type {Map} + */ +const svgCache = new Map(); + +/** + * Retrieve a cached SVG string. + * + * @param {string} set - Icon set identifier. + * @param {string} name - Icon name. + * + * @return {string|undefined} Cached SVG string, or undefined if not cached. + */ +export const getCachedSvg = ( set, name ) => svgCache.get( `${ set }/${ name }` ); + +/** + * Store an SVG string in the cache. + * + * @param {string} set - Icon set identifier. + * @param {string} name - Icon name. + * @param {string} svg - Raw SVG markup. + */ +export const setCachedSvg = ( set, name, svg ) => + svgCache.set( `${ set }/${ name }`, svg ); + +/** + * Check whether an SVG is already cached. + * + * @param {string} set - Icon set identifier. + * @param {string} name - Icon name. + * + * @return {boolean} + */ +export const hasCachedSvg = ( set, name ) => svgCache.has( `${ set }/${ name }` ); diff --git a/packages/components/src/icon-picker-control/utils/svg-cache.test.js b/packages/components/src/icon-picker-control/utils/svg-cache.test.js new file mode 100644 index 0000000..dbf87f9 --- /dev/null +++ b/packages/components/src/icon-picker-control/utils/svg-cache.test.js @@ -0,0 +1,28 @@ +import { getCachedSvg, setCachedSvg, hasCachedSvg } from './svg-cache'; + +describe( 'svg-cache', () => { + test( 'hasCachedSvg returns false for an uncached icon', () => { + expect( hasCachedSvg( 'fa-solid', 'missing-icon' ) ).toBe( false ); + } ); + + test( 'getCachedSvg returns undefined for an uncached icon', () => { + expect( getCachedSvg( 'fa-solid', 'missing-icon' ) ).toBeUndefined(); + } ); + + test( 'setCachedSvg stores an SVG and hasCachedSvg returns true', () => { + setCachedSvg( 'fa-solid', 'house', 'house' ); + expect( hasCachedSvg( 'fa-solid', 'house' ) ).toBe( true ); + } ); + + test( 'getCachedSvg retrieves the stored SVG string', () => { + setCachedSvg( 'fa-solid', 'star', 'star' ); + expect( getCachedSvg( 'fa-solid', 'star' ) ).toBe( 'star' ); + } ); + + test( 'cache is keyed by set/name, different sets are stored separately', () => { + setCachedSvg( 'fa-solid', 'heart', 'solid-heart' ); + setCachedSvg( 'fa-brands', 'heart', 'brands-heart' ); + expect( getCachedSvg( 'fa-solid', 'heart' ) ).toBe( 'solid-heart' ); + expect( getCachedSvg( 'fa-brands', 'heart' ) ).toBe( 'brands-heart' ); + } ); +} ); From 3c86439d0e46000ee36ec245cbf10e8fbda8e6d9 Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Mon, 13 Apr 2026 16:21:57 +0200 Subject: [PATCH 05/18] fix(icon-picker-control): add clearCache export and fix test isolation in svg-cache --- .../icon-picker-control/utils/svg-cache.js | 18 +++++++++++--- .../utils/svg-cache.test.js | 24 +++++++++++++++---- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/components/src/icon-picker-control/utils/svg-cache.js b/packages/components/src/icon-picker-control/utils/svg-cache.js index 60932ee..0ebe134 100644 --- a/packages/components/src/icon-picker-control/utils/svg-cache.js +++ b/packages/components/src/icon-picker-control/utils/svg-cache.js @@ -14,7 +14,8 @@ const svgCache = new Map(); * * @return {string|undefined} Cached SVG string, or undefined if not cached. */ -export const getCachedSvg = ( set, name ) => svgCache.get( `${ set }/${ name }` ); +export const getCachedSvg = ( set, name ) => + svgCache.get( `${ set }/${ name }` ); /** * Store an SVG string in the cache. @@ -22,6 +23,8 @@ export const getCachedSvg = ( set, name ) => svgCache.get( `${ set }/${ name }` * @param {string} set - Icon set identifier. * @param {string} name - Icon name. * @param {string} svg - Raw SVG markup. + * + * @return {void} */ export const setCachedSvg = ( set, name, svg ) => svgCache.set( `${ set }/${ name }`, svg ); @@ -32,6 +35,15 @@ export const setCachedSvg = ( set, name, svg ) => * @param {string} set - Icon set identifier. * @param {string} name - Icon name. * - * @return {boolean} + * @return {boolean} Whether the SVG is cached. + */ +export const hasCachedSvg = ( set, name ) => + svgCache.has( `${ set }/${ name }` ); + +/** + * Clear all entries from the cache. + * Intended for use in tests only. + * + * @return {void} */ -export const hasCachedSvg = ( set, name ) => svgCache.has( `${ set }/${ name }` ); +export const clearCache = () => svgCache.clear(); diff --git a/packages/components/src/icon-picker-control/utils/svg-cache.test.js b/packages/components/src/icon-picker-control/utils/svg-cache.test.js index dbf87f9..6170b32 100644 --- a/packages/components/src/icon-picker-control/utils/svg-cache.test.js +++ b/packages/components/src/icon-picker-control/utils/svg-cache.test.js @@ -1,12 +1,22 @@ -import { getCachedSvg, setCachedSvg, hasCachedSvg } from './svg-cache'; +/** + * Internal dependencies + */ +import { + getCachedSvg, + setCachedSvg, + hasCachedSvg, + clearCache, +} from './svg-cache'; describe( 'svg-cache', () => { + beforeEach( () => clearCache() ); + test( 'hasCachedSvg returns false for an uncached icon', () => { - expect( hasCachedSvg( 'fa-solid', 'missing-icon' ) ).toBe( false ); + expect( hasCachedSvg( 'fa-solid', 'house' ) ).toBe( false ); } ); test( 'getCachedSvg returns undefined for an uncached icon', () => { - expect( getCachedSvg( 'fa-solid', 'missing-icon' ) ).toBeUndefined(); + expect( getCachedSvg( 'fa-solid', 'house' ) ).toBeUndefined(); } ); test( 'setCachedSvg stores an SVG and hasCachedSvg returns true', () => { @@ -22,7 +32,11 @@ describe( 'svg-cache', () => { test( 'cache is keyed by set/name, different sets are stored separately', () => { setCachedSvg( 'fa-solid', 'heart', 'solid-heart' ); setCachedSvg( 'fa-brands', 'heart', 'brands-heart' ); - expect( getCachedSvg( 'fa-solid', 'heart' ) ).toBe( 'solid-heart' ); - expect( getCachedSvg( 'fa-brands', 'heart' ) ).toBe( 'brands-heart' ); + expect( getCachedSvg( 'fa-solid', 'heart' ) ).toBe( + 'solid-heart' + ); + expect( getCachedSvg( 'fa-brands', 'heart' ) ).toBe( + 'brands-heart' + ); } ); } ); From ca45c431a77473d1a6e81fe83651c1a6288c6c76 Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Mon, 13 Apr 2026 16:24:04 +0200 Subject: [PATCH 06/18] feat(icon-picker-control): add useIconSets hook for API detection --- .../hooks/use-icon-sets.js | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 packages/components/src/icon-picker-control/hooks/use-icon-sets.js diff --git a/packages/components/src/icon-picker-control/hooks/use-icon-sets.js b/packages/components/src/icon-picker-control/hooks/use-icon-sets.js new file mode 100644 index 0000000..dbe4e77 --- /dev/null +++ b/packages/components/src/icon-picker-control/hooks/use-icon-sets.js @@ -0,0 +1,45 @@ +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { getIconSets } from '../utils/api'; + +/** + * Detects whether the wp-icons REST API is available by fetching /yard/icons. + * + * Returns: + * - isLoading: true while the request is in flight (render nothing during this time) + * - isNewApiAvailable: true when /yard/icons returned at least one set + * - sets: the map of set name → metadata, e.g. { "fa-solid": { prefix: "fas" } } + * + * @return {{ sets: Object, isNewApiAvailable: boolean, isLoading: boolean }} The icon sets data and API availability status. + */ +const useIconSets = () => { + const [ sets, setSets ] = useState( {} ); + const [ isNewApiAvailable, setIsNewApiAvailable ] = useState( false ); + const [ isLoading, setIsLoading ] = useState( true ); + + useEffect( () => { + getIconSets() + .then( ( data ) => { + if ( data && Object.keys( data ).length > 0 ) { + setSets( data ); + setIsNewApiAvailable( true ); + } + } ) + .catch( () => { + // API not available — will fall back to FontAwesome picker silently. + } ) + .finally( () => { + setIsLoading( false ); + } ); + }, [] ); + + return { sets, isNewApiAvailable, isLoading }; +}; + +export default useIconSets; From 20135a3a3f97b23fee463805bd28603484a0b77f Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Mon, 13 Apr 2026 16:27:20 +0200 Subject: [PATCH 07/18] fix(icon-picker-control): add AbortController cleanup to useIconSets, add signal to getIconSets --- .../src/icon-picker-control/hooks/use-icon-sets.js | 12 +++++++++--- .../components/src/icon-picker-control/utils/api.js | 6 ++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/components/src/icon-picker-control/hooks/use-icon-sets.js b/packages/components/src/icon-picker-control/hooks/use-icon-sets.js index dbe4e77..315a7b6 100644 --- a/packages/components/src/icon-picker-control/hooks/use-icon-sets.js +++ b/packages/components/src/icon-picker-control/hooks/use-icon-sets.js @@ -24,19 +24,25 @@ const useIconSets = () => { const [ isLoading, setIsLoading ] = useState( true ); useEffect( () => { - getIconSets() + const controller = new AbortController(); + + getIconSets( controller.signal ) .then( ( data ) => { if ( data && Object.keys( data ).length > 0 ) { setSets( data ); setIsNewApiAvailable( true ); } } ) - .catch( () => { - // API not available — will fall back to FontAwesome picker silently. + .catch( ( err ) => { + if ( err.name !== 'AbortError' ) { + // API not available — will fall back to FontAwesome picker silently. + } } ) .finally( () => { setIsLoading( false ); } ); + + return () => controller.abort(); }, [] ); return { sets, isNewApiAvailable, isLoading }; diff --git a/packages/components/src/icon-picker-control/utils/api.js b/packages/components/src/icon-picker-control/utils/api.js index 38492a5..9e57607 100644 --- a/packages/components/src/icon-picker-control/utils/api.js +++ b/packages/components/src/icon-picker-control/utils/api.js @@ -45,10 +45,12 @@ export const getFontAwesomeIcons = async ( search ) => { /** * Fetch all registered icon sets from the wp-icons REST API. * + * @param {AbortSignal} [signal] - AbortController signal to cancel the request. + * * @return {Promise} Map of set name to set metadata, e.g. { "fa-solid": { prefix: "fas" } } */ -export const getIconSets = async () => { - const res = await fetch( '/yard/icons' ); +export const getIconSets = async ( signal ) => { + const res = await fetch( '/yard/icons', { signal } ); if ( ! res.ok ) { throw new Error( `Failed to fetch icon sets: ${ res.status }` ); } From 3c4cf43b4d03237ea25f4bd794f5d231486d55ac Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Mon, 13 Apr 2026 16:29:00 +0200 Subject: [PATCH 08/18] fix(icon-picker-control): guard useIconSets state updates with isMounted flag --- .../icon-picker-control/hooks/use-icon-sets.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/components/src/icon-picker-control/hooks/use-icon-sets.js b/packages/components/src/icon-picker-control/hooks/use-icon-sets.js index 315a7b6..7d9ad6d 100644 --- a/packages/components/src/icon-picker-control/hooks/use-icon-sets.js +++ b/packages/components/src/icon-picker-control/hooks/use-icon-sets.js @@ -24,25 +24,33 @@ const useIconSets = () => { const [ isLoading, setIsLoading ] = useState( true ); useEffect( () => { + let isMounted = true; const controller = new AbortController(); getIconSets( controller.signal ) .then( ( data ) => { + // eslint-disable-next-line no-useless-return + if ( ! isMounted ) return; if ( data && Object.keys( data ).length > 0 ) { setSets( data ); setIsNewApiAvailable( true ); } } ) .catch( ( err ) => { - if ( err.name !== 'AbortError' ) { - // API not available — will fall back to FontAwesome picker silently. - } + // eslint-disable-next-line no-useless-return + if ( ! isMounted || err.name === 'AbortError' ) return; + // API not available — will fall back to FontAwesome picker silently. } ) .finally( () => { + // eslint-disable-next-line no-useless-return + if ( ! isMounted ) return; setIsLoading( false ); } ); - return () => controller.abort(); + return () => { + isMounted = false; + controller.abort(); + }; }, [] ); return { sets, isNewApiAvailable, isLoading }; From de5bc6c0fcdef1d25a873dc36fe5a3fa04466a70 Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Mon, 13 Apr 2026 16:30:41 +0200 Subject: [PATCH 09/18] feat(icon-picker-control): extract FontAwesomeIconPicker component --- .../components/font-awesome-icon-picker.jsx | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 packages/components/src/icon-picker-control/components/font-awesome-icon-picker.jsx diff --git a/packages/components/src/icon-picker-control/components/font-awesome-icon-picker.jsx b/packages/components/src/icon-picker-control/components/font-awesome-icon-picker.jsx new file mode 100644 index 0000000..dad3c4e --- /dev/null +++ b/packages/components/src/icon-picker-control/components/font-awesome-icon-picker.jsx @@ -0,0 +1,142 @@ +/** + * WordPress dependencies + */ +import { Popover, SearchControl } from '@wordpress/components'; +import { useDispatch } from '@wordpress/data'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { applyFilters } from '@wordpress/hooks'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import DeleteIcon from './delete-icon.jsx'; +import IconResults from './icon-results.jsx'; +import { getFontAwesomeIcons } from '../utils/api'; +import { convertResponseToClassnames } from '../utils/helpers'; + +const FontAwesomeIconPicker = ( { + onChange, + icon, + displayIconPreview = true, + displayAsPopover = true, + displayDeleteIcon = false, + handleRemove, +} ) => { + const [ isOpen, setOpen ] = useState( false ); + const [ searchInput, setSearchInput ] = useState( '' ); + const [ searchResults, setSearchResults ] = useState( [] ); + const [ popoverAnchor, setPopoverAnchor ] = useState(); + + const { createNotice } = useDispatch( noticesStore ); + + const allowedFamilyStyles = applyFilters( + 'yard.fontawesome-family-styles', + [ + { family: 'classic', style: 'solid' }, + { family: 'classic', style: 'regular' }, + { family: 'classic', style: 'light' }, + { family: 'classic', style: 'thin' }, + { family: 'classic', style: 'brands' }, + { family: 'duotone', style: 'solid' }, + { family: 'sharp', style: 'solid' }, + { family: 'sharp', style: 'regular' }, + { family: 'sharp', style: 'light' }, + { family: 'sharp', style: 'thin' }, + ] + ); + + const searchFontAwesomeIcons = async ( searchValue ) => { + try { + const response = await getFontAwesomeIcons( searchValue ); + if ( ! response ) return; + + const result = response?.data?.search.reduce( + ( iconResults, iconData ) => { + convertResponseToClassnames( + iconData, + allowedFamilyStyles + ).forEach( ( value ) => { + iconResults.push( value ); + } ); + + return iconResults; + }, + [] + ); + if ( ! result ) return; + + setSearchResults( result ); + setOpen( true ); + } catch ( err ) { + return showErrorNotice(); + } + }; + + const showErrorNotice = () => { + createNotice( + 'error', + __( + 'Momenteel kunnen er geen iconen worden opgehaald, probeer het later nog een keer.' + ), + { + isDismissible: true, + type: 'snackbar', + id: 'icon-picker-control-error', + } + ); + }; + + const handleIconClick = ( clickedIcon ) => { + onChange( clickedIcon ); + setSearchInput( () => '' ); + setOpen( () => false ); + }; + + return ( + <> + { displayIconPreview && icon && ( + + ) } + + { + setSearchInput( searchValue ); + searchFontAwesomeIcons( searchValue ); + } } + ref={ setPopoverAnchor } + /> + + { displayAsPopover && searchInput && isOpen && ( + setOpen( false ) } + focusOnMount={ false } + > + + + ) } + + { ! displayAsPopover && searchInput && ( + + ) } + + { displayDeleteIcon && icon && ( + + ) } + + ); +}; + +export default FontAwesomeIconPicker; From 665803e8634b5e47e8e8aca70ee59997891bc358 Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Mon, 13 Apr 2026 16:46:42 +0200 Subject: [PATCH 10/18] feat(icon-picker-control): add SvgIconResults grid component --- .../components/svg-icon-results.jsx | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 packages/components/src/icon-picker-control/components/svg-icon-results.jsx diff --git a/packages/components/src/icon-picker-control/components/svg-icon-results.jsx b/packages/components/src/icon-picker-control/components/svg-icon-results.jsx new file mode 100644 index 0000000..4e3f6df --- /dev/null +++ b/packages/components/src/icon-picker-control/components/svg-icon-results.jsx @@ -0,0 +1,50 @@ +/** + * WordPress dependencies + */ +import { Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +const SvgIconResults = ( { results, onIconClick } ) => { + return ( +
+ { results.map( ( { name, svg }, key ) => ( +
+ +
+ ) ) } + + { ! results.length && ( +

{ __( 'Er zijn geen iconen gevonden' ) }

+ ) } +
+ ); +}; + +export default SvgIconResults; From cb72a0ed6fd2512f82966835d5203a88dcdf196f Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Mon, 13 Apr 2026 16:50:42 +0200 Subject: [PATCH 11/18] fix(icon-picker-control): sanitize SVG content, use stable result key, simplify onClick --- .../components/svg-icon-results.jsx | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/components/src/icon-picker-control/components/svg-icon-results.jsx b/packages/components/src/icon-picker-control/components/svg-icon-results.jsx index 4e3f6df..c3be8fc 100644 --- a/packages/components/src/icon-picker-control/components/svg-icon-results.jsx +++ b/packages/components/src/icon-picker-control/components/svg-icon-results.jsx @@ -4,18 +4,30 @@ import { Button } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; +/** + * Basic SVG sanitization — strips script tags and event handler attributes. + * TODO: Replace with DOMPurify when available as a project dependency. + * + * @param {string} svg - Raw SVG string from the REST API. + * @return {string} Sanitized SVG string. + */ +const sanitizeSvg = ( svg ) => { + return svg + .replace( //gi, '' ) + .replace( /\son\w+="[^"]*"/gi, '' ) + .replace( /\son\w+='[^']*'/gi, '' ); +}; + const SvgIconResults = ( { results, onIconClick } ) => { return (
- { results.map( ( { name, svg }, key ) => ( + { results.map( ( { name, set, svg } ) => (
From 70057bfa7dadedaea25bf533adfaba457ec26f33 Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Mon, 13 Apr 2026 16:53:09 +0200 Subject: [PATCH 12/18] feat(icon-picker-control): add SvgIconPicker component with debounced search and SVG cache --- .../components/svg-icon-picker.jsx | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 packages/components/src/icon-picker-control/components/svg-icon-picker.jsx diff --git a/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx b/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx new file mode 100644 index 0000000..850ff36 --- /dev/null +++ b/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx @@ -0,0 +1,198 @@ +/** + * WordPress dependencies + */ +import { SelectControl, SearchControl, Popover } from '@wordpress/components'; +import { useDispatch } from '@wordpress/data'; +import { useState, useRef } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import DeleteIcon from './delete-icon.jsx'; +import SvgIconResults from './svg-icon-results.jsx'; +import { searchIcons, getIconSvg } from '../utils/api'; +import { getCachedSvg, setCachedSvg, hasCachedSvg } from '../utils/svg-cache'; + +const SEARCH_MIN_LENGTH = 3; +const SEARCH_DEBOUNCE_MS = 300; +const MAX_RESULTS = 10; + +const SvgIconPicker = ( { + sets, + onChangeSVG, + iconSVG, + displayIconPreview = true, + displayAsPopover = true, + displayDeleteIcon = false, + handleRemove, +} ) => { + const setOptions = Object.keys( sets ).map( ( key ) => ( { + label: key, + value: key, + } ) ); + + const [ selectedSet, setSelectedSet ] = useState( + setOptions[ 0 ]?.value ?? '' + ); + const [ searchInput, setSearchInput ] = useState( '' ); + const [ results, setResults ] = useState( [] ); + const [ isOpen, setIsOpen ] = useState( false ); + const [ popoverAnchor, setPopoverAnchor ] = useState(); + + const abortControllerRef = useRef( null ); + const debounceTimerRef = useRef( null ); + + const { createNotice } = useDispatch( noticesStore ); + + const showErrorNotice = () => { + createNotice( + 'error', + __( + 'Momenteel kunnen er geen iconen worden opgehaald, probeer het later nog een keer.' + ), + { + isDismissible: true, + type: 'snackbar', + id: 'icon-picker-control-error', + } + ); + }; + + /** + * Fetch SVGs for a list of icon objects, using the cache where possible. + * Returns the same list with a `svg` property added (string or 'ERROR'). + * + * @param {Array} iconList - Array of { name, set, ... } objects. + * @return {Promise} The icon list with resolved SVG strings. + */ + const fetchSvgsForResults = ( iconList ) => { + return Promise.all( + iconList.map( async ( iconData ) => { + const { name, set } = iconData; + if ( hasCachedSvg( set, name ) ) { + return { ...iconData, svg: getCachedSvg( set, name ) }; + } + try { + const svg = await getIconSvg( set, name ); + setCachedSvg( set, name, svg ); + return { ...iconData, svg }; + } catch { + return { ...iconData, svg: 'ERROR' }; + } + } ) + ); + }; + + const performSearch = async ( set, query, signal ) => { + try { + const iconList = await searchIcons( set, query, signal ); + const limited = iconList.slice( 0, MAX_RESULTS ); + + // Render immediately with skeleton placeholders. + setResults( limited.map( ( i ) => ( { ...i, svg: null } ) ) ); + setIsOpen( true ); + + // Then resolve SVGs and update. + const withSvgs = await fetchSvgsForResults( limited ); + setResults( withSvgs ); + } catch ( err ) { + if ( err.name === 'AbortError' ) return; + showErrorNotice(); + } + }; + + const handleSearchChange = ( value ) => { + setSearchInput( value ); + + if ( debounceTimerRef.current ) { + clearTimeout( debounceTimerRef.current ); + } + + if ( value.length < SEARCH_MIN_LENGTH ) { + setResults( [] ); + setIsOpen( false ); + return; + } + + debounceTimerRef.current = setTimeout( () => { + if ( abortControllerRef.current ) { + abortControllerRef.current.abort(); + } + abortControllerRef.current = new AbortController(); + performSearch( + selectedSet, + value, + abortControllerRef.current.signal + ); + }, SEARCH_DEBOUNCE_MS ); + }; + + const handleSetChange = ( value ) => { + setSelectedSet( value ); + setSearchInput( '' ); + setResults( [] ); + setIsOpen( false ); + }; + + const handleIconClick = ( svg ) => { + onChangeSVG( svg ); + setSearchInput( '' ); + setResults( [] ); + setIsOpen( false ); + }; + + return ( + <> + { displayIconPreview && iconSVG && ( + + ) } + + + + + + { displayAsPopover && searchInput && isOpen && ( + setIsOpen( false ) } + focusOnMount={ false } + > + + + ) } + + { ! displayAsPopover && searchInput && ( + + ) } + + { displayDeleteIcon && iconSVG && ( + + ) } + + ); +}; + +export default SvgIconPicker; From f147590b9427918f26db133ad3d0a51ee7bc7174 Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Mon, 13 Apr 2026 17:04:13 +0200 Subject: [PATCH 13/18] fix(icon-picker-control): extract sanitizeSvg utility, fix race condition, add unmount cleanup, abort on set change --- .../components/svg-icon-picker.jsx | 29 +++++++++++++++++-- .../components/svg-icon-results.jsx | 13 ++------- .../icon-picker-control/utils/sanitize-svg.js | 15 ++++++++++ 3 files changed, 44 insertions(+), 13 deletions(-) create mode 100644 packages/components/src/icon-picker-control/utils/sanitize-svg.js diff --git a/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx b/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx index 850ff36..554d0d0 100644 --- a/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx +++ b/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx @@ -3,7 +3,7 @@ */ import { SelectControl, SearchControl, Popover } from '@wordpress/components'; import { useDispatch } from '@wordpress/data'; -import { useState, useRef } from '@wordpress/element'; +import { useState, useRef, useEffect } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; @@ -14,6 +14,7 @@ import DeleteIcon from './delete-icon.jsx'; import SvgIconResults from './svg-icon-results.jsx'; import { searchIcons, getIconSvg } from '../utils/api'; import { getCachedSvg, setCachedSvg, hasCachedSvg } from '../utils/svg-cache'; +import sanitizeSvg from '../utils/sanitize-svg'; const SEARCH_MIN_LENGTH = 3; const SEARCH_DEBOUNCE_MS = 300; @@ -43,9 +44,21 @@ const SvgIconPicker = ( { const abortControllerRef = useRef( null ); const debounceTimerRef = useRef( null ); + const searchGenerationRef = useRef( 0 ); const { createNotice } = useDispatch( noticesStore ); + useEffect( () => { + return () => { + if ( debounceTimerRef.current ) { + clearTimeout( debounceTimerRef.current ); + } + if ( abortControllerRef.current ) { + abortControllerRef.current.abort(); + } + }; + }, [] ); + const showErrorNotice = () => { createNotice( 'error', @@ -86,8 +99,10 @@ const SvgIconPicker = ( { }; const performSearch = async ( set, query, signal ) => { + const generation = ++searchGenerationRef.current; try { const iconList = await searchIcons( set, query, signal ); + if ( generation !== searchGenerationRef.current ) return; const limited = iconList.slice( 0, MAX_RESULTS ); // Render immediately with skeleton placeholders. @@ -96,6 +111,7 @@ const SvgIconPicker = ( { // Then resolve SVGs and update. const withSvgs = await fetchSvgsForResults( limited ); + if ( generation !== searchGenerationRef.current ) return; setResults( withSvgs ); } catch ( err ) { if ( err.name === 'AbortError' ) return; @@ -130,6 +146,13 @@ const SvgIconPicker = ( { }; const handleSetChange = ( value ) => { + if ( debounceTimerRef.current ) { + clearTimeout( debounceTimerRef.current ); + } + if ( abortControllerRef.current ) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } setSelectedSet( value ); setSearchInput( '' ); setResults( [] ); @@ -148,7 +171,9 @@ const SvgIconPicker = ( { { displayIconPreview && iconSVG && ( ) } diff --git a/packages/components/src/icon-picker-control/components/svg-icon-results.jsx b/packages/components/src/icon-picker-control/components/svg-icon-results.jsx index c3be8fc..6b0af60 100644 --- a/packages/components/src/icon-picker-control/components/svg-icon-results.jsx +++ b/packages/components/src/icon-picker-control/components/svg-icon-results.jsx @@ -5,18 +5,9 @@ import { Button } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; /** - * Basic SVG sanitization — strips script tags and event handler attributes. - * TODO: Replace with DOMPurify when available as a project dependency. - * - * @param {string} svg - Raw SVG string from the REST API. - * @return {string} Sanitized SVG string. + * Internal dependencies */ -const sanitizeSvg = ( svg ) => { - return svg - .replace( //gi, '' ) - .replace( /\son\w+="[^"]*"/gi, '' ) - .replace( /\son\w+='[^']*'/gi, '' ); -}; +import sanitizeSvg from '../utils/sanitize-svg'; const SvgIconResults = ( { results, onIconClick } ) => { return ( diff --git a/packages/components/src/icon-picker-control/utils/sanitize-svg.js b/packages/components/src/icon-picker-control/utils/sanitize-svg.js new file mode 100644 index 0000000..b95924a --- /dev/null +++ b/packages/components/src/icon-picker-control/utils/sanitize-svg.js @@ -0,0 +1,15 @@ +/** + * Basic SVG sanitization — strips script tags and event handler attributes. + * TODO: Replace with DOMPurify when available as a project dependency. + * + * @param {string} svg - Raw SVG string. + * @return {string} Sanitized SVG string. + */ +const sanitizeSvg = ( svg ) => { + return svg + .replace( //gi, '' ) + .replace( /\son\w+="[^"]*"/gi, '' ) + .replace( /\son\w+='[^']*'/gi, '' ); +}; + +export default sanitizeSvg; From bf541d98232b2de355c9df5f7a59927c22d64fbf Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Mon, 13 Apr 2026 17:06:44 +0200 Subject: [PATCH 14/18] feat(icon-picker-control): replace index.jsx with detection wrapper, expose onChangeSVG/iconSVG props --- .../src/icon-picker-control/index.jsx | 187 ++++++------------ 1 file changed, 62 insertions(+), 125 deletions(-) diff --git a/packages/components/src/icon-picker-control/index.jsx b/packages/components/src/icon-picker-control/index.jsx index 927d6ec..b922567 100644 --- a/packages/components/src/icon-picker-control/index.jsx +++ b/packages/components/src/icon-picker-control/index.jsx @@ -1,29 +1,36 @@ /** * WordPress dependencies */ -import { - Dropdown, - Popover, - SearchControl, - ToolbarButton, - ToolbarGroup, -} from '@wordpress/components'; +import { Dropdown, ToolbarButton, ToolbarGroup } from '@wordpress/components'; import { BlockControls } from '@wordpress/block-editor'; -import { useDispatch } from '@wordpress/data'; -import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { applyFilters } from '@wordpress/hooks'; -import { store as noticesStore } from '@wordpress/notices'; /** * Internal dependencies */ -import DeleteIcon from './components/delete-icon.jsx'; -import IconResults from './components/icon-results.jsx'; -import { getFontAwesomeIcons } from './utils/api'; -import { convertResponseToClassnames } from './utils/helpers'; +import FontAwesomeIconPicker from './components/font-awesome-icon-picker.jsx'; +import SvgIconPicker from './components/svg-icon-picker.jsx'; +import useIconSets from './hooks/use-icon-sets.js'; import './editor.css'; +/** + * Core icon picker control. Automatically selects between the FontAwesome + * picker and the new SVG-based picker depending on: + * 1. Whether /yard/icons is reachable (detected on mount). + * 2. Whether the consumer has provided an `onChangeSVG` callback. + * + * If either condition is false, the FontAwesome picker is rendered. + * + * @param {Object} props + * @param {Function} props.onChange Called with FA class string when FA picker is used. + * @param {string} props.icon Current FA class string (for legacy preview). + * @param {boolean} props.displayIconPreview Show icon preview above search. Default true. + * @param {boolean} props.displayAsPopover Show results in a popover. Default true. + * @param {boolean} props.displayDeleteIcon Show delete button. Default false. + * @param {Function} props.handleRemove Called when delete button is clicked. + * @param {Function} props.onChangeSVG Called with raw SVG string when SVG picker is used. + * @param {string} props.iconSVG Current SVG markup (for SVG preview). + */ export const IconPickerControl = ( { onChange, icon, @@ -31,119 +38,38 @@ export const IconPickerControl = ( { displayAsPopover = true, displayDeleteIcon = false, handleRemove, + onChangeSVG, + iconSVG, } ) => { - const [ isOpen, setOpen ] = useState( false ); - const [ searchInput, setSearchInput ] = useState( '' ); - const [ searchResults, setSearchResults ] = useState( [] ); - const [ popoverAnchor, setPopoverAnchor ] = useState(); - - const { createNotice } = useDispatch( noticesStore ); - - const allowedFamilyStyles = applyFilters( - 'yard.fontawesome-family-styles', - [ - { family: 'classic', style: 'solid' }, - { family: 'classic', style: 'regular' }, - { family: 'classic', style: 'light' }, - { family: 'classic', style: 'thin' }, - { family: 'classic', style: 'brands' }, - { family: 'duotone', style: 'solid' }, - { family: 'sharp', style: 'solid' }, - { family: 'sharp', style: 'regular' }, - { family: 'sharp', style: 'light' }, - { family: 'sharp', style: 'thin' }, - ] - ); - - const searchFontAwesomeIcons = async ( searchValue ) => { - try { - const response = await getFontAwesomeIcons( searchValue ); - if ( ! response ) return; - - const result = response?.data?.search.reduce( - ( iconResults, iconData ) => { - convertResponseToClassnames( - iconData, - allowedFamilyStyles - ).forEach( ( value ) => { - iconResults.push( value ); - } ); - - return iconResults; - }, - [] - ); - if ( ! result ) return; - - setSearchResults( result ); - setOpen( true ); - } catch ( err ) { - return showErrorNotice(); - } - }; - - const showErrorNotice = () => { - createNotice( - 'error', - __( - 'Momenteel kunnen er geen iconen worden opgehaald, probeer het later nog een keer.' - ), - { - isDismissible: true, - type: 'snackbar', - id: 'icon-picker-control-error', - } + const { sets, isNewApiAvailable, isLoading } = useIconSets(); + + if ( isLoading ) { + return null; + } + + if ( isNewApiAvailable && onChangeSVG ) { + return ( + ); - }; - - const handleIconClick = ( clickedIcon ) => { - onChange( clickedIcon ); - setSearchInput( () => '' ); - setOpen( () => false ); - }; + } return ( - <> - { displayIconPreview && icon && ( - - ) } - - { - setSearchInput( searchValue ); - searchFontAwesomeIcons( searchValue ); - } } - ref={ setPopoverAnchor } - /> - - { displayAsPopover && searchInput && isOpen && ( - setOpen( false ) } - focusOnMount={ false } - > - - - ) } - - { ! displayAsPopover && searchInput && ( - - ) } - - { displayDeleteIcon && icon && ( - - ) } - + ); }; @@ -152,6 +78,8 @@ export const IconPickerControlInspector = ( { onChange, displayDeleteIcon = false, handleRemove, + onChangeSVG, + iconSVG, } ) => { return ( ); }; -export const IconPickerControlToolbar = ( { icon, onChange } ) => { +export const IconPickerControlToolbar = ( { + icon, + onChange, + onChangeSVG, + iconSVG, +} ) => { return ( { onChange={ onChange } displayIconPreview={ false } displayAsPopover={ false } + onChangeSVG={ onChangeSVG } + iconSVG={ iconSVG } /> ) } /> From 9e20f0172769b9e90a5ffe432d04082e63b41db4 Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Mon, 13 Apr 2026 17:19:46 +0200 Subject: [PATCH 15/18] feat(icon-picker-control): add SVG picker styles and update README --- .../src/icon-picker-control/README.md | 82 +++++++++++++++---- .../src/icon-picker-control/editor.css | 41 ++++++++++ 2 files changed, 106 insertions(+), 17 deletions(-) diff --git a/packages/components/src/icon-picker-control/README.md b/packages/components/src/icon-picker-control/README.md index bf1e873..e46e5c4 100644 --- a/packages/components/src/icon-picker-control/README.md +++ b/packages/components/src/icon-picker-control/README.md @@ -1,24 +1,72 @@ -# Icon picker control +# Icon Picker Control -The IconPickerControl use the Font Awesome API to search for icons. More information found here: . +Picks an icon in the block editor. Supports two modes: -## Hooks +- **FontAwesome mode** (default, legacy): searches the FontAwesome GraphQL API and returns a CSS class string (e.g. `fa-solid fa-house`). Stored in the `icon` block attribute. +- **SVG mode** (new): uses the `wp-icons` REST API (`/yard/icons`) to search sets of SVG icons and returns raw SVG markup. Stored in the `iconSVG` block attribute. -By default all the styles from Font Awesome are loaded. Use this filter to change which styles are needed. In this example all styles are loaded: +Mode is selected automatically. If `/yard/icons` is reachable **and** the consumer provides an `onChangeSVG` callback, SVG mode is used. Otherwise FontAwesome mode is used. Existing consumers that only provide `onChange` are unaffected. -```JS +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `onChange` | `Function` | — | Called with FA class string (FontAwesome mode). | +| `icon` | `string` | — | Current FA class string, used for the legacy icon preview. | +| `displayIconPreview` | `boolean` | `true` | Show a preview of the selected icon above the search field. | +| `displayAsPopover` | `boolean` | `true` | Show search results in a popover (vs. inline). | +| `displayDeleteIcon` | `boolean` | `false` | Show a delete button below the search field. | +| `handleRemove` | `Function` | — | Called when the delete button is clicked. | +| `onChangeSVG` | `Function` | — | Called with raw SVG string (SVG mode). Opts the consumer into SVG mode when provided. | +| `iconSVG` | `string` | — | Current SVG markup, used for the SVG icon preview. | + +## New consumer example + +```jsx + setAttributes({ icon: v }) } + onChangeSVG={ ( v ) => setAttributes({ iconSVG: v }) } +/> +``` + +### Block attribute registration + +```js +attributes: { + icon: { type: 'string', default: '' }, + iconSVG: { type: 'string', default: '' }, +} +``` + +### Frontend rendering (handle both old and new saved content) + +```jsx +{ iconSVG + ? + : icon && +} +``` + +## FontAwesome mode — filter + +To limit which FontAwesome family/style combinations appear, use this WordPress filter: + +```js import { addFilter } from '@wordpress/hooks'; -addFilter('yard.fontawesome-family-styles', 'yard', () => [ - { family: 'classic', style: 'solid' }, - { family: 'classic', style: 'regular' }, - { family: 'classic', style: 'light' }, - { family: 'classic', style: 'thin' }, - { family: 'classic', style: 'brands' }, - { family: 'duotone', style: 'solid' }, - { family: 'sharp', style: 'solid' }, - { family: 'sharp', style: 'regular' }, - { family: 'sharp', style: 'light' }, - { family: 'sharp', style: 'thin' }, -]); +addFilter( 'yard.fontawesome-family-styles', 'yard', () => [ + { family: 'classic', style: 'solid' }, + { family: 'classic', style: 'regular' }, +] ); ``` + +## SVG mode — how it works + +1. On mount, fetches `/yard/icons` to get available icon sets. +2. Renders a set selector (`SelectControl`) populated with the returned sets. +3. Search fires after 3 characters, debounced by 300 ms. +4. Shows the first 10 results; fetches each icon's SVG eagerly (with skeleton placeholders while loading). +5. SVGs are cached in a module-level `Map` for the page session — the same icon is never fetched twice. +6. Selecting an icon calls `onChangeSVG` with the raw SVG string. diff --git a/packages/components/src/icon-picker-control/editor.css b/packages/components/src/icon-picker-control/editor.css index 4f5b942..8e67cbe 100644 --- a/packages/components/src/icon-picker-control/editor.css +++ b/packages/components/src/icon-picker-control/editor.css @@ -38,3 +38,44 @@ .icon-picker-control-delete-icon-btn { margin-bottom: 1.5rem; } + +/* SVG icon preview (replaces for SVG-mode selected icon) */ +.icon-picker-control-preview-icon svg { + width: 3rem; + height: 3rem; + display: block; + margin-bottom: 1rem; +} + +/* Skeleton placeholder for SVG cells that are still loading */ +.icon-picker-control-svg-skeleton { + display: block; + width: 2rem; + height: 2rem; + background-color: #e0e0e0; + border-radius: 2px; + animation: icon-picker-skeleton-pulse 1.2s ease-in-out infinite; +} + +@keyframes icon-picker-skeleton-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Error placeholder for failed SVG fetches */ +.icon-picker-control-svg-error { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + font-size: 1.2rem; + color: #757575; +} + +/* Ensure SVGs inside result buttons scale correctly */ +.icon-picker-control-icon-btn-container svg { + width: 2rem; + height: 2rem; + display: block; +} From 4327bf2d973b73140766995dee0c94b35ed3ebb1 Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Mon, 13 Apr 2026 17:28:45 +0200 Subject: [PATCH 16/18] fix(icon-picker-control): harden SVG sanitizer, add detection timeout, fix set-change generation guard --- .../icon-picker-control/components/svg-icon-picker.jsx | 1 + .../src/icon-picker-control/hooks/use-icon-sets.js | 10 +++++----- .../src/icon-picker-control/utils/sanitize-svg.js | 8 +++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx b/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx index 554d0d0..0ba18f9 100644 --- a/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx +++ b/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx @@ -153,6 +153,7 @@ const SvgIconPicker = ( { abortControllerRef.current.abort(); abortControllerRef.current = null; } + searchGenerationRef.current++; setSelectedSet( value ); setSearchInput( '' ); setResults( [] ); diff --git a/packages/components/src/icon-picker-control/hooks/use-icon-sets.js b/packages/components/src/icon-picker-control/hooks/use-icon-sets.js index 7d9ad6d..483d8fd 100644 --- a/packages/components/src/icon-picker-control/hooks/use-icon-sets.js +++ b/packages/components/src/icon-picker-control/hooks/use-icon-sets.js @@ -26,10 +26,10 @@ const useIconSets = () => { useEffect( () => { let isMounted = true; const controller = new AbortController(); + const timeoutId = setTimeout( () => controller.abort(), 5000 ); getIconSets( controller.signal ) .then( ( data ) => { - // eslint-disable-next-line no-useless-return if ( ! isMounted ) return; if ( data && Object.keys( data ).length > 0 ) { setSets( data ); @@ -37,17 +37,17 @@ const useIconSets = () => { } } ) .catch( ( err ) => { - // eslint-disable-next-line no-useless-return - if ( ! isMounted || err.name === 'AbortError' ) return; - // API not available — will fall back to FontAwesome picker silently. + if ( isMounted && err.name !== 'AbortError' ) { + // API not available — will fall back to FontAwesome picker silently. + } } ) .finally( () => { - // eslint-disable-next-line no-useless-return if ( ! isMounted ) return; setIsLoading( false ); } ); return () => { + clearTimeout( timeoutId ); isMounted = false; controller.abort(); }; diff --git a/packages/components/src/icon-picker-control/utils/sanitize-svg.js b/packages/components/src/icon-picker-control/utils/sanitize-svg.js index b95924a..a9d7893 100644 --- a/packages/components/src/icon-picker-control/utils/sanitize-svg.js +++ b/packages/components/src/icon-picker-control/utils/sanitize-svg.js @@ -1,6 +1,8 @@ /** - * Basic SVG sanitization — strips script tags and event handler attributes. + * Basic SVG sanitization — strips script tags, event handler attributes, + * and javascript: URI values. * TODO: Replace with DOMPurify when available as a project dependency. + * Note: Does not cover external resource references (). * * @param {string} svg - Raw SVG string. * @return {string} Sanitized SVG string. @@ -8,8 +10,8 @@ const sanitizeSvg = ( svg ) => { return svg .replace( //gi, '' ) - .replace( /\son\w+="[^"]*"/gi, '' ) - .replace( /\son\w+='[^']*'/gi, '' ); + .replace( /\son\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '' ) + .replace( /\bhref\s*=\s*["']?\s*javascript:[^"'\s>]*/gi, '' ); }; export default sanitizeSvg; From 0aa9ef0cf80f1ee00aae4f4a82af32d3175532d2 Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Mon, 13 Apr 2026 17:37:55 +0200 Subject: [PATCH 17/18] chore: remove superpower docs --- ...2026-04-13-icon-picker-control-refactor.md | 1088 ----------------- ...-13-icon-picker-control-refactor-design.md | 235 ---- 2 files changed, 1323 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-13-icon-picker-control-refactor.md delete mode 100644 docs/superpowers/specs/2026-04-13-icon-picker-control-refactor-design.md diff --git a/docs/superpowers/plans/2026-04-13-icon-picker-control-refactor.md b/docs/superpowers/plans/2026-04-13-icon-picker-control-refactor.md deleted file mode 100644 index 41f7b1b..0000000 --- a/docs/superpowers/plans/2026-04-13-icon-picker-control-refactor.md +++ /dev/null @@ -1,1088 +0,0 @@ -# Icon Picker Control Refactor — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Refactor `IconPickerControl` to auto-detect a new `/yard/icons` REST API and use it for set-based SVG icon picking, while keeping the existing FontAwesome path 100% intact for sites without the new API. - -**Architecture:** A `useIconSets` hook detects API availability on mount. The wrapper `IconPickerControl` renders either the untouched `FontAwesomeIconPicker` or the new `SvgIconPicker` based on that detection and whether `onChangeSVG` is provided. A module-level SVG cache prevents repeat fetches within a page session. - -**Tech Stack:** React (via `@wordpress/element`), `@wordpress/components` (SelectControl, SearchControl, Popover, Button), `@wordpress/data` + `@wordpress/notices` (snackbar errors), native `fetch` + `AbortController`, WordPress Jest preset for utility tests. - -**Spec:** `docs/superpowers/specs/2026-04-13-icon-picker-control-refactor-design.md` - ---- - -## File Map - -| Action | Path | Responsibility | -|---|---|---| -| Modify | `packages/components/src/icon-picker-control/utils/api.js` | Add `getIconSets`, `searchIcons`, `getIconSvg` | -| Create | `packages/components/src/icon-picker-control/utils/svg-cache.js` | Module-level SVG Map, shared across instances | -| Create | `packages/components/src/icon-picker-control/utils/svg-cache.test.js` | Unit tests for cache helpers | -| Create | `packages/components/src/icon-picker-control/hooks/use-icon-sets.js` | Fetch `/yard/icons` once on mount | -| Create | `packages/components/src/icon-picker-control/components/font-awesome-icon-picker.jsx` | Current FA picker logic extracted verbatim | -| Create | `packages/components/src/icon-picker-control/components/svg-icon-results.jsx` | SVG preview grid (max 10, skeleton placeholders) | -| Create | `packages/components/src/icon-picker-control/components/svg-icon-picker.jsx` | Set selector + debounced search + SVG results | -| Modify | `packages/components/src/icon-picker-control/index.jsx` | Wrapper: detection + routing + new prop API | -| Modify | `packages/components/src/icon-picker-control/editor.css` | Add SVG picker and skeleton styles | -| Modify | `packages/components/src/icon-picker-control/README.md` | Document new props and usage | - ---- - -## Task 1: Extend `utils/api.js` with new REST API functions - -**Files:** -- Modify: `packages/components/src/icon-picker-control/utils/api.js` - -- [ ] **Step 1: Open the file and append the three new exports after the existing `getFontAwesomeIcons` export** - - The existing export must remain untouched. Add these three functions at the bottom of `packages/components/src/icon-picker-control/utils/api.js`: - - ```js - /** - * Fetch all registered icon sets from the wp-icons REST API. - * - * @return {Promise} Map of set name to set metadata, e.g. { "fa-solid": { prefix: "fas" } } - */ - export const getIconSets = async () => { - const res = await fetch( '/yard/icons' ); - if ( ! res.ok ) { - throw new Error( `Failed to fetch icon sets: ${ res.status }` ); - } - return res.json(); - }; - - /** - * Search icons within a set via the wp-icons REST API. - * - * @param {string} set - The icon set identifier, e.g. "fa-solid". - * @param {string} query - The search query. - * @param {AbortSignal} signal - AbortController signal to cancel in-flight requests. - * - * @return {Promise} Array of icon objects: [{ name, set, prefix }, ...] - */ - export const searchIcons = async ( set, query, signal ) => { - const res = await fetch( - `/yard/icons/${ encodeURIComponent( set ) }?q=${ encodeURIComponent( query ) }`, - { signal } - ); - if ( ! res.ok ) { - throw new Error( `Failed to search icons: ${ res.status }` ); - } - return res.json(); - }; - - /** - * Fetch the raw SVG markup for a single icon. - * - * @param {string} set - The icon set identifier, e.g. "fa-solid". - * @param {string} name - The icon name, e.g. "house". - * - * @return {Promise} Raw SVG markup string. - */ - export const getIconSvg = async ( set, name ) => { - const res = await fetch( - `/yard/icons/${ encodeURIComponent( set ) }/${ name }` - ); - if ( ! res.ok ) { - throw new Error( `Failed to fetch SVG for ${ set }/${ name }: ${ res.status }` ); - } - return res.text(); - }; - ``` - -- [ ] **Step 2: Lint the file** - - Run: `npm run lint:js packages/components/src/icon-picker-control/utils/api.js` - - Expected: no errors. - -- [ ] **Step 3: Commit** - - ```bash - git add packages/components/src/icon-picker-control/utils/api.js - git commit -m "feat(icon-picker-control): add REST API helpers for wp-icons endpoints" - ``` - ---- - -## Task 2: Create the SVG cache utility - -**Files:** -- Create: `packages/components/src/icon-picker-control/utils/svg-cache.js` -- Create: `packages/components/src/icon-picker-control/utils/svg-cache.test.js` - -- [ ] **Step 1: Create `utils/svg-cache.js`** - - ```js - /** - * Module-level SVG cache shared across all IconPickerControl instances. - * Persists for the lifetime of the page session. - * - * @type {Map} - */ - const svgCache = new Map(); - - /** - * Retrieve a cached SVG string. - * - * @param {string} set - Icon set identifier. - * @param {string} name - Icon name. - * - * @return {string|undefined} Cached SVG string, or undefined if not cached. - */ - export const getCachedSvg = ( set, name ) => svgCache.get( `${ set }/${ name }` ); - - /** - * Store an SVG string in the cache. - * - * @param {string} set - Icon set identifier. - * @param {string} name - Icon name. - * @param {string} svg - Raw SVG markup. - */ - export const setCachedSvg = ( set, name, svg ) => - svgCache.set( `${ set }/${ name }`, svg ); - - /** - * Check whether an SVG is already cached. - * - * @param {string} set - Icon set identifier. - * @param {string} name - Icon name. - * - * @return {boolean} - */ - export const hasCachedSvg = ( set, name ) => svgCache.has( `${ set }/${ name }` ); - ``` - -- [ ] **Step 2: Create `utils/svg-cache.test.js`** - - ```js - import { getCachedSvg, setCachedSvg, hasCachedSvg } from './svg-cache'; - - describe( 'svg-cache', () => { - test( 'hasCachedSvg returns false for an uncached icon', () => { - expect( hasCachedSvg( 'fa-solid', 'missing-icon' ) ).toBe( false ); - } ); - - test( 'getCachedSvg returns undefined for an uncached icon', () => { - expect( getCachedSvg( 'fa-solid', 'missing-icon' ) ).toBeUndefined(); - } ); - - test( 'setCachedSvg stores an SVG and hasCachedSvg returns true', () => { - setCachedSvg( 'fa-solid', 'house', 'house' ); - expect( hasCachedSvg( 'fa-solid', 'house' ) ).toBe( true ); - } ); - - test( 'getCachedSvg retrieves the stored SVG string', () => { - setCachedSvg( 'fa-solid', 'star', 'star' ); - expect( getCachedSvg( 'fa-solid', 'star' ) ).toBe( 'star' ); - } ); - - test( 'cache is keyed by set/name, different sets are stored separately', () => { - setCachedSvg( 'fa-solid', 'heart', 'solid-heart' ); - setCachedSvg( 'fa-brands', 'heart', 'brands-heart' ); - expect( getCachedSvg( 'fa-solid', 'heart' ) ).toBe( 'solid-heart' ); - expect( getCachedSvg( 'fa-brands', 'heart' ) ).toBe( 'brands-heart' ); - } ); - } ); - ``` - -- [ ] **Step 3: Run the tests** - - Run: `npx jest packages/components/src/icon-picker-control/utils/svg-cache.test.js --no-coverage` - - Expected: 5 tests pass. - -- [ ] **Step 4: Commit** - - ```bash - git add packages/components/src/icon-picker-control/utils/svg-cache.js \ - packages/components/src/icon-picker-control/utils/svg-cache.test.js - git commit -m "feat(icon-picker-control): add module-level SVG cache utility" - ``` - ---- - -## Task 3: Create the `useIconSets` detection hook - -**Files:** -- Create: `packages/components/src/icon-picker-control/hooks/use-icon-sets.js` - -- [ ] **Step 1: Create the `hooks/` directory and `use-icon-sets.js`** - - ```js - /** - * WordPress dependencies - */ - import { useState, useEffect } from '@wordpress/element'; - - /** - * Internal dependencies - */ - import { getIconSets } from '../utils/api'; - - /** - * Detects whether the wp-icons REST API is available by fetching /yard/icons. - * - * Returns: - * - isLoading: true while the request is in flight (render nothing during this time) - * - isNewApiAvailable: true when /yard/icons returned at least one set - * - sets: the map of set name → metadata, e.g. { "fa-solid": { prefix: "fas" } } - * - * @return {{ sets: Object, isNewApiAvailable: boolean, isLoading: boolean }} - */ - const useIconSets = () => { - const [ sets, setSets ] = useState( {} ); - const [ isNewApiAvailable, setIsNewApiAvailable ] = useState( false ); - const [ isLoading, setIsLoading ] = useState( true ); - - useEffect( () => { - getIconSets() - .then( ( data ) => { - if ( data && Object.keys( data ).length > 0 ) { - setSets( data ); - setIsNewApiAvailable( true ); - } - } ) - .catch( () => { - // API not available — will fall back to FontAwesome picker silently. - } ) - .finally( () => { - setIsLoading( false ); - } ); - }, [] ); - - return { sets, isNewApiAvailable, isLoading }; - }; - - export default useIconSets; - ``` - -- [ ] **Step 2: Lint the file** - - Run: `npm run lint:js packages/components/src/icon-picker-control/hooks/use-icon-sets.js` - - Expected: no errors. - -- [ ] **Step 3: Commit** - - ```bash - git add packages/components/src/icon-picker-control/hooks/use-icon-sets.js - git commit -m "feat(icon-picker-control): add useIconSets hook for API detection" - ``` - ---- - -## Task 4: Extract `FontAwesomeIconPicker` - -**Files:** -- Create: `packages/components/src/icon-picker-control/components/font-awesome-icon-picker.jsx` -- Modify: `packages/components/src/icon-picker-control/index.jsx` (remove FA logic, keep exports) - -The FA picker is the **current** `IconPickerControl` function body. The only changes are import paths (the file moves from the root into `components/`). - -- [ ] **Step 1: Create `components/font-awesome-icon-picker.jsx`** - - ```jsx - /** - * WordPress dependencies - */ - import { - Popover, - SearchControl, - } from '@wordpress/components'; - import { useDispatch } from '@wordpress/data'; - import { useState } from '@wordpress/element'; - import { __ } from '@wordpress/i18n'; - import { applyFilters } from '@wordpress/hooks'; - import { store as noticesStore } from '@wordpress/notices'; - - /** - * Internal dependencies - */ - import DeleteIcon from './delete-icon.jsx'; - import IconResults from './icon-results.jsx'; - import { getFontAwesomeIcons } from '../utils/api'; - import { convertResponseToClassnames } from '../utils/helpers'; - - const FontAwesomeIconPicker = ( { - onChange, - icon, - displayIconPreview = true, - displayAsPopover = true, - displayDeleteIcon = false, - handleRemove, - } ) => { - const [ isOpen, setOpen ] = useState( false ); - const [ searchInput, setSearchInput ] = useState( '' ); - const [ searchResults, setSearchResults ] = useState( [] ); - const [ popoverAnchor, setPopoverAnchor ] = useState(); - - const { createNotice } = useDispatch( noticesStore ); - - const allowedFamilyStyles = applyFilters( - 'yard.fontawesome-family-styles', - [ - { family: 'classic', style: 'solid' }, - { family: 'classic', style: 'regular' }, - { family: 'classic', style: 'light' }, - { family: 'classic', style: 'thin' }, - { family: 'classic', style: 'brands' }, - { family: 'duotone', style: 'solid' }, - { family: 'sharp', style: 'solid' }, - { family: 'sharp', style: 'regular' }, - { family: 'sharp', style: 'light' }, - { family: 'sharp', style: 'thin' }, - ] - ); - - const searchFontAwesomeIcons = async ( searchValue ) => { - try { - const response = await getFontAwesomeIcons( searchValue ); - if ( ! response ) return; - - const result = response?.data?.search.reduce( - ( iconResults, iconData ) => { - convertResponseToClassnames( - iconData, - allowedFamilyStyles - ).forEach( ( value ) => { - iconResults.push( value ); - } ); - - return iconResults; - }, - [] - ); - if ( ! result ) return; - - setSearchResults( result ); - setOpen( true ); - } catch ( err ) { - return showErrorNotice(); - } - }; - - const showErrorNotice = () => { - createNotice( - 'error', - __( - 'Momenteel kunnen er geen iconen worden opgehaald, probeer het later nog een keer.' - ), - { - isDismissible: true, - type: 'snackbar', - id: 'icon-picker-control-error', - } - ); - }; - - const handleIconClick = ( clickedIcon ) => { - onChange( clickedIcon ); - setSearchInput( () => '' ); - setOpen( () => false ); - }; - - return ( - <> - { displayIconPreview && icon && ( - - ) } - - { - setSearchInput( searchValue ); - searchFontAwesomeIcons( searchValue ); - } } - ref={ setPopoverAnchor } - /> - - { displayAsPopover && searchInput && isOpen && ( - setOpen( false ) } - focusOnMount={ false } - > - - - ) } - - { ! displayAsPopover && searchInput && ( - - ) } - - { displayDeleteIcon && icon && ( - - ) } - - ); - }; - - export default FontAwesomeIconPicker; - ``` - -- [ ] **Step 2: Lint the new file** - - Run: `npm run lint:js packages/components/src/icon-picker-control/components/font-awesome-icon-picker.jsx` - - Expected: no errors. - -- [ ] **Step 3: Commit** - - ```bash - git add packages/components/src/icon-picker-control/components/font-awesome-icon-picker.jsx - git commit -m "feat(icon-picker-control): extract FontAwesomeIconPicker component" - ``` - ---- - -## Task 5: Create `SvgIconResults` - -**Files:** -- Create: `packages/components/src/icon-picker-control/components/svg-icon-results.jsx` - -- [ ] **Step 1: Create `components/svg-icon-results.jsx`** - - Each result object has shape `{ name: string, set: string, svg: string|null }`. When `svg` is `null` (still loading), a skeleton placeholder is rendered. When `svg` is `'ERROR'`, a `×` placeholder is shown. - - ```jsx - /** - * WordPress dependencies - */ - import { Button } from '@wordpress/components'; - import { __ } from '@wordpress/i18n'; - - const SvgIconResults = ( { results, onIconClick } ) => { - return ( -
- { results.map( ( { name, set, svg }, key ) => ( -
- -
- ) ) } - - { ! results.length && ( -

{ __( 'Er zijn geen iconen gevonden' ) }

- ) } -
- ); - }; - - export default SvgIconResults; - ``` - -- [ ] **Step 2: Lint** - - Run: `npm run lint:js packages/components/src/icon-picker-control/components/svg-icon-results.jsx` - - Expected: no errors. - -- [ ] **Step 3: Commit** - - ```bash - git add packages/components/src/icon-picker-control/components/svg-icon-results.jsx - git commit -m "feat(icon-picker-control): add SvgIconResults grid component" - ``` - ---- - -## Task 6: Create `SvgIconPicker` - -**Files:** -- Create: `packages/components/src/icon-picker-control/components/svg-icon-picker.jsx` - -- [ ] **Step 1: Create `components/svg-icon-picker.jsx`** - - ```jsx - /** - * WordPress dependencies - */ - import { - SelectControl, - SearchControl, - Popover, - } from '@wordpress/components'; - import { useDispatch } from '@wordpress/data'; - import { useState, useRef } from '@wordpress/element'; - import { __ } from '@wordpress/i18n'; - import { store as noticesStore } from '@wordpress/notices'; - - /** - * Internal dependencies - */ - import DeleteIcon from './delete-icon.jsx'; - import SvgIconResults from './svg-icon-results.jsx'; - import { searchIcons, getIconSvg } from '../utils/api'; - import { getCachedSvg, setCachedSvg, hasCachedSvg } from '../utils/svg-cache'; - - const SEARCH_MIN_LENGTH = 3; - const SEARCH_DEBOUNCE_MS = 300; - const MAX_RESULTS = 10; - - const SvgIconPicker = ( { - sets, - onChangeSVG, - iconSVG, - displayIconPreview = true, - displayAsPopover = true, - displayDeleteIcon = false, - handleRemove, - } ) => { - const setOptions = Object.keys( sets ).map( ( key ) => ( { - label: key, - value: key, - } ) ); - - const [ selectedSet, setSelectedSet ] = useState( setOptions[ 0 ]?.value ?? '' ); - const [ searchInput, setSearchInput ] = useState( '' ); - const [ results, setResults ] = useState( [] ); - const [ isOpen, setIsOpen ] = useState( false ); - const [ popoverAnchor, setPopoverAnchor ] = useState(); - - const abortControllerRef = useRef( null ); - const debounceTimerRef = useRef( null ); - - const { createNotice } = useDispatch( noticesStore ); - - const showErrorNotice = () => { - createNotice( - 'error', - __( - 'Momenteel kunnen er geen iconen worden opgehaald, probeer het later nog een keer.' - ), - { - isDismissible: true, - type: 'snackbar', - id: 'icon-picker-control-error', - } - ); - }; - - /** - * Fetch SVGs for a list of icon objects, using the cache where possible. - * Returns the same list with a `svg` property added (string or 'ERROR'). - * - * @param {Array} iconList - Array of { name, set, ... } objects. - * @return {Promise} - */ - const fetchSvgsForResults = ( iconList ) => { - return Promise.all( - iconList.map( async ( iconData ) => { - const { name, set } = iconData; - if ( hasCachedSvg( set, name ) ) { - return { ...iconData, svg: getCachedSvg( set, name ) }; - } - try { - const svg = await getIconSvg( set, name ); - setCachedSvg( set, name, svg ); - return { ...iconData, svg }; - } catch { - return { ...iconData, svg: 'ERROR' }; - } - } ) - ); - }; - - const performSearch = async ( set, query, signal ) => { - try { - const iconList = await searchIcons( set, query, signal ); - const limited = iconList.slice( 0, MAX_RESULTS ); - - // Render immediately with skeleton placeholders. - setResults( limited.map( ( i ) => ( { ...i, svg: null } ) ) ); - setIsOpen( true ); - - // Then resolve SVGs and update. - const withSvgs = await fetchSvgsForResults( limited ); - setResults( withSvgs ); - } catch ( err ) { - if ( err.name === 'AbortError' ) return; - showErrorNotice(); - } - }; - - const handleSearchChange = ( value ) => { - setSearchInput( value ); - - if ( debounceTimerRef.current ) { - clearTimeout( debounceTimerRef.current ); - } - - if ( value.length < SEARCH_MIN_LENGTH ) { - setResults( [] ); - setIsOpen( false ); - return; - } - - debounceTimerRef.current = setTimeout( () => { - if ( abortControllerRef.current ) { - abortControllerRef.current.abort(); - } - abortControllerRef.current = new AbortController(); - performSearch( selectedSet, value, abortControllerRef.current.signal ); - }, SEARCH_DEBOUNCE_MS ); - }; - - const handleSetChange = ( value ) => { - setSelectedSet( value ); - setSearchInput( '' ); - setResults( [] ); - setIsOpen( false ); - }; - - const handleIconClick = ( svg ) => { - onChangeSVG( svg ); - setSearchInput( '' ); - setResults( [] ); - setIsOpen( false ); - }; - - return ( - <> - { displayIconPreview && iconSVG && ( - - ) } - - - - - - { displayAsPopover && searchInput && isOpen && ( - setIsOpen( false ) } - focusOnMount={ false } - > - - - ) } - - { ! displayAsPopover && searchInput && ( - - ) } - - { displayDeleteIcon && iconSVG && ( - - ) } - - ); - }; - - export default SvgIconPicker; - ``` - -- [ ] **Step 2: Lint** - - Run: `npm run lint:js packages/components/src/icon-picker-control/components/svg-icon-picker.jsx` - - Expected: no errors. - -- [ ] **Step 3: Commit** - - ```bash - git add packages/components/src/icon-picker-control/components/svg-icon-picker.jsx - git commit -m "feat(icon-picker-control): add SvgIconPicker component with debounced search and SVG cache" - ``` - ---- - -## Task 7: Rewrite `index.jsx` as the detection wrapper - -**Files:** -- Modify: `packages/components/src/icon-picker-control/index.jsx` - -This file now contains only the wrapper logic and the three public exports. The FA logic has moved to `font-awesome-icon-picker.jsx`. - -- [ ] **Step 1: Replace the full contents of `index.jsx`** - - ```jsx - /** - * WordPress dependencies - */ - import { Dropdown, ToolbarButton, ToolbarGroup } from '@wordpress/components'; - import { BlockControls } from '@wordpress/block-editor'; - import { __ } from '@wordpress/i18n'; - - /** - * Internal dependencies - */ - import FontAwesomeIconPicker from './components/font-awesome-icon-picker.jsx'; - import SvgIconPicker from './components/svg-icon-picker.jsx'; - import useIconSets from './hooks/use-icon-sets.js'; - import './editor.css'; - - /** - * Core icon picker control. Automatically selects between the FontAwesome - * picker and the new SVG-based picker depending on: - * 1. Whether /yard/icons is reachable (detected on mount). - * 2. Whether the consumer has provided an `onChangeSVG` callback. - * - * If either condition is false, the FontAwesome picker is rendered. - * - * @param {Object} props - * @param {Function} props.onChange Called with FA class string when FA picker is used. - * @param {string} props.icon Current FA class string (for legacy preview). - * @param {boolean} props.displayIconPreview Show icon preview above search. Default true. - * @param {boolean} props.displayAsPopover Show results in a popover. Default true. - * @param {boolean} props.displayDeleteIcon Show delete button. Default false. - * @param {Function} props.handleRemove Called when delete button is clicked. - * @param {Function} props.onChangeSVG Called with raw SVG string when SVG picker is used. - * @param {string} props.iconSVG Current SVG markup (for SVG preview). - */ - export const IconPickerControl = ( { - onChange, - icon, - displayIconPreview = true, - displayAsPopover = true, - displayDeleteIcon = false, - handleRemove, - onChangeSVG, - iconSVG, - } ) => { - const { sets, isNewApiAvailable, isLoading } = useIconSets(); - - if ( isLoading ) { - return null; - } - - if ( isNewApiAvailable && onChangeSVG ) { - return ( - - ); - } - - return ( - - ); - }; - - export const IconPickerControlInspector = ( { - icon, - onChange, - displayDeleteIcon = false, - handleRemove, - onChangeSVG, - iconSVG, - } ) => { - return ( - - ); - }; - - export const IconPickerControlToolbar = ( { - icon, - onChange, - onChangeSVG, - iconSVG, - } ) => { - return ( - - ( - - - { __( 'Kies icoon' ) } - - - ) } - renderContent={ () => ( - - ) } - /> - - ); - }; - ``` - -- [ ] **Step 2: Lint** - - Run: `npm run lint:js packages/components/src/icon-picker-control/index.jsx` - - Expected: no errors. - -- [ ] **Step 3: Commit** - - ```bash - git add packages/components/src/icon-picker-control/index.jsx - git commit -m "feat(icon-picker-control): replace index.jsx with detection wrapper, expose onChangeSVG/iconSVG props" - ``` - ---- - -## Task 8: Extend `editor.css` with SVG picker styles - -**Files:** -- Modify: `packages/components/src/icon-picker-control/editor.css` - -- [ ] **Step 1: Append new styles to the end of `editor.css`** - - ```css - /* SVG icon preview (replaces for SVG-mode selected icon) */ - .icon-picker-control-preview-icon svg { - width: 3rem; - height: 3rem; - display: block; - margin-bottom: 1rem; - } - - /* Skeleton placeholder for SVG cells that are still loading */ - .icon-picker-control-svg-skeleton { - display: block; - width: 2rem; - height: 2rem; - background-color: #e0e0e0; - border-radius: 2px; - animation: icon-picker-skeleton-pulse 1.2s ease-in-out infinite; - } - - @keyframes icon-picker-skeleton-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } - } - - /* Error placeholder for failed SVG fetches */ - .icon-picker-control-svg-error { - display: flex; - align-items: center; - justify-content: center; - width: 2rem; - height: 2rem; - font-size: 1.2rem; - color: #757575; - } - - /* Ensure SVGs inside result buttons scale correctly */ - .icon-picker-control-icon-btn-container svg { - width: 2rem; - height: 2rem; - display: block; - } - ``` - -- [ ] **Step 2: Lint CSS** - - Run: `npm run lint:scss packages/components/src/icon-picker-control/editor.css` - - Expected: no errors. - -- [ ] **Step 3: Commit** - - ```bash - git add packages/components/src/icon-picker-control/editor.css - git commit -m "feat(icon-picker-control): add SVG picker and skeleton styles" - ``` - ---- - -## Task 9: Update README - -**Files:** -- Modify: `packages/components/src/icon-picker-control/README.md` - -- [ ] **Step 1: Replace the full contents of `README.md`** - - ```markdown - # Icon Picker Control - - Picks an icon in the block editor. Supports two modes: - - - **FontAwesome mode** (default, legacy): searches the FontAwesome GraphQL API and returns a CSS class string (e.g. `fa-solid fa-house`). Stored in the `icon` block attribute. - - **SVG mode** (new): uses the `wp-icons` REST API (`/yard/icons`) to search sets of SVG icons and returns raw SVG markup. Stored in the `iconSVG` block attribute. - - Mode is selected automatically. If `/yard/icons` is reachable **and** the consumer provides an `onChangeSVG` callback, SVG mode is used. Otherwise FontAwesome mode is used. Existing consumers that only provide `onChange` are unaffected. - - ## Props - - | Prop | Type | Default | Description | - |---|---|---|---| - | `onChange` | `Function` | — | Called with FA class string (FontAwesome mode). | - | `icon` | `string` | — | Current FA class string, used for the legacy icon preview. | - | `displayIconPreview` | `boolean` | `true` | Show a preview of the selected icon above the search field. | - | `displayAsPopover` | `boolean` | `true` | Show search results in a popover (vs. inline). | - | `displayDeleteIcon` | `boolean` | `false` | Show a delete button below the search field. | - | `handleRemove` | `Function` | — | Called when the delete button is clicked. | - | `onChangeSVG` | `Function` | — | Called with raw SVG string (SVG mode). Opts the consumer into SVG mode when provided. | - | `iconSVG` | `string` | — | Current SVG markup, used for the SVG icon preview. | - - ## New consumer example - - ```jsx - setAttributes({ icon: v }) } - onChangeSVG={ ( v ) => setAttributes({ iconSVG: v }) } - /> - ``` - - ### Block attribute registration - - ```js - attributes: { - icon: { type: 'string', default: '' }, - iconSVG: { type: 'string', default: '' }, - } - ``` - - ### Frontend rendering (handle both old and new saved content) - - ```jsx - { iconSVG - ? - : icon && - } - ``` - - ## FontAwesome mode — filter - - To limit which FontAwesome family/style combinations appear, use this WordPress filter: - - ```js - import { addFilter } from '@wordpress/hooks'; - - addFilter( 'yard.fontawesome-family-styles', 'yard', () => [ - { family: 'classic', style: 'solid' }, - { family: 'classic', style: 'regular' }, - ] ); - ``` - - ## SVG mode — how it works - - 1. On mount, fetches `/yard/icons` to get available icon sets. - 2. Renders a set selector (`SelectControl`) populated with the returned sets. - 3. Search fires after 3 characters, debounced by 300 ms. - 4. Shows the first 10 results; fetches each icon's SVG eagerly (with skeleton placeholders while loading). - 5. SVGs are cached in a module-level `Map` for the page session — the same icon is never fetched twice. - 6. Selecting an icon calls `onChangeSVG` with the raw SVG string. - ``` - -- [ ] **Step 2: Commit** - - ```bash - git add packages/components/src/icon-picker-control/README.md - git commit -m "docs(icon-picker-control): update README for SVG mode and new props" - ``` - ---- - -## Task 10: Manual verification in the browser - -No automated component tests exist for this package. Verify the following manually in a WordPress site with the block editor open. - -- [ ] **Scenario A — Legacy site (no wp-icons API)** - - 1. Open a block that uses `IconPickerControlInspector` with only `onChange` wired up. - 2. Confirm the FontAwesome search field appears immediately. - 3. Type a search term. Confirm FA icon results appear in the popover. - 4. Click an icon. Confirm `onChange` is called and the `icon` attribute is saved. - -- [ ] **Scenario B — New site, consumer not opted in** - - 1. Ensure `/yard/icons` returns data on the site. - 2. Open a block that uses `IconPickerControlInspector` with only `onChange` wired up (no `onChangeSVG`). - 3. Confirm the FontAwesome picker appears (API available but consumer not opted in → FA path). - -- [ ] **Scenario C — New site, consumer opted in** - - 1. Ensure `/yard/icons` returns data on the site. - 2. Open a block that uses `IconPickerControlInspector` with both `onChange` and `onChangeSVG` wired up. - 3. Confirm the set selector dropdown appears populated with set names. - 4. Type fewer than 3 characters. Confirm no search fires. - 5. Type 3+ characters. Confirm skeleton placeholders appear, then SVG icons load in. - 6. Confirm only 10 results show at most. - 7. Click an icon. Confirm `onChangeSVG` is called, the popover closes, and the SVG preview appears. - 8. Confirm the delete button (if `displayDeleteIcon` is true and `iconSVG` is set) removes the icon. - -- [ ] **Scenario D — Network failure during SVG fetch** - - 1. In browser DevTools, throttle a specific `/yard/icons/{set}/{name}` request to fail. - 2. Confirm that cell shows a `×` placeholder and the rest of the grid loads correctly. - 3. Confirm no error notice appears (only search failures show the snackbar). - -- [ ] **Scenario E — Network failure on sets fetch** - - 1. In browser DevTools, block `/yard/icons` entirely. - 2. Confirm the component renders the FontAwesome picker (silent fallback, no error shown). diff --git a/docs/superpowers/specs/2026-04-13-icon-picker-control-refactor-design.md b/docs/superpowers/specs/2026-04-13-icon-picker-control-refactor-design.md deleted file mode 100644 index 261e86a..0000000 --- a/docs/superpowers/specs/2026-04-13-icon-picker-control-refactor-design.md +++ /dev/null @@ -1,235 +0,0 @@ -# Icon Picker Control Refactor — Design Spec - -**Date:** 2026-04-13 -**Status:** Approved - -## Overview - -Refactor `IconPickerControl` to support a new WordPress REST API (`/yard/icons`) provided by the `wp-icons` Laravel/Acorn package (built on Blade Icons). The refactor must be **100% backwards compatible**: sites without the new package continue using the existing FontAwesome GraphQL API flow without any changes to consuming blocks. - ---- - -## Goals - -- Auto-detect the new `/yard/icons` REST API at runtime -- When available and opted into, use it for icon search + SVG retrieval -- Keep the existing FontAwesome path working exactly as-is for legacy sites -- Expose a clean new `onChangeSVG` / `iconSVG` API for new consumers -- Reduce unnecessary API load with debouncing, result capping, and a shared SVG cache - ---- - -## File Structure - -``` -packages/components/src/icon-picker-control/ - index.jsx ← updated wrapper + unchanged public exports - editor.css ← extended for SVG picker styles - README.md ← updated - components/ - delete-icon.jsx ← unchanged - icon-results.jsx ← unchanged (used by FontAwesomeIconPicker) - font-awesome-icon-picker.jsx ← NEW: current IconPickerControl logic extracted verbatim - svg-icon-picker.jsx ← NEW: set selector + debounced search + SVG grid - svg-icon-results.jsx ← NEW: SVG preview grid (max 10 results) - hooks/ - use-icon-sets.js ← NEW: detects /yard/icons on mount - utils/ - api.js ← extended: adds getIconSets, searchIcons, getIconSvg - helpers.js ← unchanged - svg-cache.js ← NEW: module-level Map shared across all instances -``` - ---- - -## Public API - -The three exported symbols are **unchanged**: `IconPickerControl`, `IconPickerControlInspector`, `IconPickerControlToolbar`. Existing consumers require zero changes. - -### Updated prop signature - -```jsx -IconPickerControl({ - // Existing props — all unchanged - onChange, // (classString) => void — FA path callback - icon, // string: FA class string for legacy preview - displayIconPreview, // bool, default true - displayAsPopover, // bool, default true - displayDeleteIcon, // bool, default false - handleRemove, // () => void - - // New optional props — SVG path - onChangeSVG, // (svgString) => void — opts consumer into SVG mode - iconSVG, // string: raw SVG markup for preview in SVG mode -}) -``` - -### Mode selection logic - -``` -useIconSets() resolves - ├─ fetch failed OR sets empty → render - └─ sets has data - ├─ onChangeSVG not provided → render - └─ onChangeSVG provided → render -``` - -Existing consumers providing only `onChange` always get `FontAwesomeIconPicker`, even on sites with the new API installed. - -### New consumer wiring - -```jsx - setAttributes({ icon: v }) } - onChangeSVG={ ( v ) => setAttributes({ iconSVG: v }) } -/> -``` - -### Block frontend template pattern - -```jsx -{ iconSVG - ? - : icon && -} -``` - -This handles both old saved content (`icon`) and new saved content (`iconSVG`) transparently. - ---- - -## Components - -### `FontAwesomeIconPicker` - -The current `IconPickerControl` code moved verbatim into `components/font-awesome-icon-picker.jsx`. No logic changes. Receives the same props as today's `IconPickerControl`. - -### `SvgIconPicker` - -New component. Props: `sets`, `onChange`, `onChangeSVG`, `icon`, `iconSVG`, `displayIconPreview`, `displayAsPopover`, `displayDeleteIcon`, `handleRemove`. - -UI structure: -``` -[ SelectControl: "Choose icon set" ] ← required, populated from sets prop -[ SearchControl: "Search icons..." ] ← 3-char minimum, 300ms debounce -[ Popover / inline results ] - └─ grid (max 10) - └─ Button > - └─ while SVG loading: grey skeleton placeholder -[ DeleteIcon button ] ← unchanged behaviour -[ Icon preview ] ← when iconSVG present -``` - -### `SvgIconResults` - -Renders a grid of up to 10 icon results. Each cell shows the fetched SVG or a skeleton placeholder while loading. On click, calls `onIconClick(svg)`. - ---- - -## Hooks - -### `useIconSets` - -```js -const { sets, isNewApiAvailable, isLoading } = useIconSets(); -``` - -- Fetches `GET /yard/icons` once on mount -- While in-flight: `isLoading = true` — wrapper renders nothing (avoids flash of wrong picker) -- On success with data: `isNewApiAvailable = true`, `sets = { "set-name": { prefix }, ... }` -- On failure or empty response: `isNewApiAvailable = false` - ---- - -## Utilities - -### `utils/api.js` additions - -Existing `getFontAwesomeIcons` is untouched. - -```js -// GET /yard/icons → { "set-name": { prefix }, ... } -export const getIconSets = async () => { ... }; - -// GET /yard/icons/{set}?q={query} → [{ name, set, prefix }, ...] -// Accepts AbortSignal for debounce/abort pattern -export const searchIcons = async ( set, query, signal ) => { ... }; - -// GET /yard/icons/{set}/{name} → SVG string -export const getIconSvg = async ( set, name ) => { ... }; -``` - -### `utils/svg-cache.js` - -Module-level `Map`, lives outside any component, shared across all instances and re-renders within a page session: - -```js -const svgCache = new Map(); // keyed by `${set}/${name}` -export const getCachedSvg = ( set, name ) => svgCache.get( `${set}/${name}` ); -export const setCachedSvg = ( set, name, svg ) => svgCache.set( `${set}/${name}`, svg ); -``` - ---- - -## Data Flow (SVG mode) - -``` -mount - └─ useIconSets fetches /yard/icons → populate set dropdown - -user picks set - └─ reset search input + results - -user types in search field - ├─ < 3 chars → do nothing - └─ ≥ 3 chars → wait 300ms (debounced) - └─ abort any in-flight request via AbortController - └─ GET /yard/icons/{set}?q={query} - └─ take first 10 results - └─ for each result: check svgCache - ├─ cache hit → render immediately - └─ cache miss → GET /yard/icons/{set}/{name} - └─ store in svgCache, render cell - -user clicks icon - └─ SVG already in cache (was fetched for preview) - └─ call onChangeSVG( svg ) - └─ clear search + close popover -``` - ---- - -## Load Reduction - -| Technique | Detail | -|---|---| -| 3-character minimum | Search does not fire until `searchInput.length >= 3` | -| 300ms debounce | Keystroke timer resets on each character; request fires only when user pauses | -| AbortController | Previous in-flight search request is cancelled when a new one starts | -| 10-result cap | Only first 10 results from the API are shown and SVGs fetched | -| Module-level SVG cache | Same SVG never fetched twice in a page session | -| Eager sets fetch on mount | `/yard/icons` fetched immediately; dropdown ready before user types | -| `Cache-Control: max-age=86400` | Both `/yard/icons` and SVG endpoints are cached by the browser for 24h | - ---- - -## Error Handling - -| Scenario | Behaviour | -|---|---| -| `/yard/icons` fetch fails on mount | `isNewApiAvailable = false` → silently renders `FontAwesomeIconPicker` | -| Search request fails | WordPress snackbar error notice (same pattern as current implementation) | -| Search request aborted | Silently ignored — not treated as an error | -| Individual SVG fetch fails | That grid cell renders a `×` placeholder; rest of grid unaffected | -| No results for query | "Er zijn geen iconen gevonden" — same copy as current | - ---- - -## Backwards Compatibility - -- All existing `onChange` / `icon` consumers work without any changes -- `FontAwesomeIconPicker` is the current code, not modified -- `onChangeSVG` is fully optional — omitting it locks the component into FA mode regardless of API availability -- Block frontend templates should check `iconSVG` first and fall back to `icon` — this handles all combinations of old/new saved content From 63afe333a82ba7b81665a1610cc0ec20c4117fb6 Mon Sep 17 00:00:00 2001 From: YvetteNikolov Date: Tue, 14 Apr 2026 10:36:24 +0200 Subject: [PATCH 18/18] chore: update icon component with svg and svg-icon-picker values --- .../components/svg-icon-picker.jsx | 6 +++--- packages/components/src/icon/index.jsx | 13 ++++++++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx b/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx index 0ba18f9..3f7d389 100644 --- a/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx +++ b/packages/components/src/icon-picker-control/components/svg-icon-picker.jsx @@ -16,9 +16,9 @@ import { searchIcons, getIconSvg } from '../utils/api'; import { getCachedSvg, setCachedSvg, hasCachedSvg } from '../utils/svg-cache'; import sanitizeSvg from '../utils/sanitize-svg'; -const SEARCH_MIN_LENGTH = 3; -const SEARCH_DEBOUNCE_MS = 300; -const MAX_RESULTS = 10; +const SEARCH_MIN_LENGTH = 2; +const SEARCH_DEBOUNCE_MS = 100; +const MAX_RESULTS = 50; const SvgIconPicker = ( { sets, diff --git a/packages/components/src/icon/index.jsx b/packages/components/src/icon/index.jsx index caa5396..0f17026 100644 --- a/packages/components/src/icon/index.jsx +++ b/packages/components/src/icon/index.jsx @@ -1,6 +1,17 @@ export const Icon = ( props ) => { const { attributes } = props; - const { icon, iconAltText } = attributes; + const { icon, iconSVG, iconAltText } = attributes; + + if ( iconSVG ) { + return ( +