diff --git a/docs/explanations/architecture/dashboard-widgets.md b/docs/explanations/architecture/dashboard-widgets.md index ca9e8577824c9e..b50bdfc4bb7a04 100644 --- a/docs/explanations/architecture/dashboard-widgets.md +++ b/docs/explanations/architecture/dashboard-widgets.md @@ -22,8 +22,8 @@ A widget is a directory under `widgets/`, discovered by convention; there is no ``` widgets/hello-world/ -├── widget.json static metadata (name, title, description, help, actions, keywords, category, presentation, textdomain) -├── widget.ts metadata module: default-exports icon, attributes, example +├── widget.json static metadata (name, title, description, help, icon, actions, keywords, category, presentation, textdomain) +├── widget.ts metadata module: default-exports attributes, example ├── render.tsx render module: default-exports the React component ├── style.module.css optional, injected at runtime by the build └── report.csv optional static asset linked from an action `href` @@ -35,7 +35,9 @@ Unlike the other translatable strings, `help` is an object: `content` plus optio `actions`: declarative verbs (`id`, `label`, plus exactly one fulfillment key). Today the only key is `href`, a link target, with optional `download` / `openInNewTab`. Hosts mount the primitive and place it; the dashboard uses a "More" menu. A relative `href` that exists under `widgets/{name}/` becomes a plugin URL at registration; missing relative non-admin files are dropped. `data:` and `javascript:` hrefs are rejected. Prefer absolute URLs for assets that must work in the plugin zip (which does not ship `widgets/`). -`widget.ts` is the live half of the metadata: values that only exist in JavaScript, such as the icon element or the `attributes` field schema (including optional `relevance` hints) that hosts feed into `DataForm`. +`icon`: a registered icon name (`collection/icon-name`), resolved client-side through the site's Icons API. Malformed names are dropped at registration. + +`widget.ts` is the live half of the metadata: values that only exist in JavaScript, such as the `attributes` field schema (including optional `relevance` hints) that hosts feed into `DataForm`. Its render component receives the widget's `attributes` and, optionally, `setAttributes`: @@ -54,13 +56,13 @@ export default function HelloWorld( { attributes } ) { ... } ## The server registry -`WP_Widget_Type_Registry` (`lib/experimental/dashboard-widgets/`) is a singleton, hydrated at `init` from the manifest. Each entry becomes a `WP_Widget_Type` with `name`, `render_module`, `widget_module`, `presentation`, `category`, and the translatable `title`, `description`, `help`, `actions`, and `keywords` (localized at registration time using the widget's `textdomain`). +`WP_Widget_Type_Registry` (`lib/experimental/dashboard-widgets/`) is a singleton, hydrated at `init` from the manifest. Each entry becomes a `WP_Widget_Type` with `name`, `render_module`, `widget_module`, `presentation`, `category`, `icon`, and the translatable `title`, `description`, `help`, `actions`, and `keywords` (localized at registration time using the widget's `textdomain`). The hydration is a deterministic copy, with no filters in between. The `widgets/` folder is the single source of widget authorship in this codebase. The registry is the server's authoritative list of widget types for the site. Two consumers read it: -- The REST controller (`WP_REST_Widget_Modules_Controller`) exposes it at `/wp/v2/widget-modules`, returning `{ name, render_module, widget_module, presentation, category, title, description, help, actions, keywords }` per record. +- The REST controller (`WP_REST_Widget_Modules_Controller`) exposes it at `/wp/v2/widget-modules`, returning `{ name, render_module, widget_module, presentation, category, title, description, help, icon, actions, keywords }` per record. - The dashboard page hooks its `dashboard-wp-admin_boot_dependencies` filter, a per-page instance of the generic `{page-slug}-wp-admin_boot_dependencies`, and adds every registered module to its import map as a `dynamic` dependency. A dynamic dependency is reachable by `import()` but never executed eagerly. Registration only makes the modules known to WordPress; loading them is a separate, per-host decision. Dynamic `import()` against the import map is how the dashboard loads widgets today. A host can load them another way: enqueue a module eagerly (`wp_enqueue_script_module()`), declare it as a `static` dependency of its own module, or, outside WordPress, skip the import map and resolve modules through its own `ResolveWidgetModule`. @@ -71,7 +73,7 @@ The registry exists as a class, rather than having REST read the manifest direct Everything after the REST record is the job of [`@wordpress/widget-primitives`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/widget-primitives), the contract both widget authors and hosts share. Its full surface (the contract types, the discovery hook, and the render component) is covered in the _Widget Primitives / Introduction_ story. In the pipeline it does two things. -`useWidgetTypes( records )` takes the host-supplied records, imports each record's `widget_module` for the live metadata, and merges it with the record into `WidgetType[]`. The record's `presentation`, `category`, `title`, `description`, `help`, `actions`, and `keywords`, all sourced from `widget.json` (with `title`, `description`, `help`, `actions`, and `keywords` localized server-side), win over the module's value. The hook reaches for no store or endpoint; a host such as the dashboard reads its own `widgetModule` core-data entity (backed by `/wp/v2/widget-modules`) and passes the records in. +`useWidgetTypes( records )` takes the host-supplied records, imports each record's `widget_module` for the live metadata, and merges it with the record into `WidgetType[]`. The record's `presentation`, `category`, `title`, `description`, `help`, `actions`, and `keywords`, all sourced from `widget.json` (with `title`, `description`, `help`, `actions`, and `keywords` localized server-side), win over the module's value. The record's `icon` is a registered icon name: the hook resolves it through the application-registered resolver (`registerIconResolver`), and the resolved element wins over a module's element. The hook reaches for no store or endpoint; a host such as the dashboard reads its own `widgetModule` core-data entity (backed by `/wp/v2/widget-modules`) and passes the records in. A module's `attributes` may also reference field types by name (`type: 'location'`). The application registers those definitions up front through `registerFieldType()` (the dashboard route registers its own on boot), and `useWidgetTypes` resolves every named reference through that registry while building each `WidgetType`: the registered definition supplies the field's behavior on top of its DataViews `baseType`, and hosts receive plain DataViews fields. The widget declaration stays serializable; resolution happens once, at this boundary. diff --git a/lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php b/lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php index 46deb1ac6d8ccd..0dae1d4b387426 100644 --- a/lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php +++ b/lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php @@ -211,6 +211,10 @@ public function prepare_item_for_response( $item, $request ) { $data['help'] = $widget_type->help; } + if ( rest_is_field_included( 'icon', $fields ) ) { + $data['icon'] = $widget_type->icon; + } + if ( rest_is_field_included( 'actions', $fields ) ) { $data['actions'] = $widget_type->actions; } @@ -315,6 +319,13 @@ public function get_item_schema() { 'readonly' => true, ), + 'icon' => array( + 'description' => __( 'Registered icon name identifying the widget type visually.', 'gutenberg' ), + 'type' => array( 'string', 'null' ), + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'actions' => array( 'description' => __( 'Declarative actions the widget type exposes. Labels are translatable.', 'gutenberg' ), 'type' => array( 'array', 'null' ), diff --git a/lib/experimental/dashboard-widgets/class-wp-widget-type.php b/lib/experimental/dashboard-widgets/class-wp-widget-type.php index d46ad31a740e3f..76dcf7327f9d08 100644 --- a/lib/experimental/dashboard-widgets/class-wp-widget-type.php +++ b/lib/experimental/dashboard-widgets/class-wp-widget-type.php @@ -107,6 +107,16 @@ class WP_Widget_Type { */ public $help = null; + /** + * Registered icon name (`collection/icon-name`), resolved by + * clients through the Icons API. + * + * Null when the widget did not declare the field. + * + * @var string|null + */ + public $icon = null; + /** * Declarative actions the widget exposes. Each entry carries `id`, * `label`, `href`, and optional `download`/`openInNewTab`. Labels are diff --git a/lib/experimental/dashboard-widgets/widget-icons.php b/lib/experimental/dashboard-widgets/widget-icons.php new file mode 100644 index 00000000000000..1a85dcb4f2c069 --- /dev/null +++ b/lib/experimental/dashboard-widgets/widget-icons.php @@ -0,0 +1,57 @@ + __( 'Dashboard Widgets', 'gutenberg' ), + 'description' => __( 'Icons owned by the dashboard widgets.', 'gutenberg' ), + ) + ); + + // Register temporary draft icon since it's still a core private icon + wp_register_icon( + 'dashboard-widgets/drafts', + array( + 'label' => __( 'Drafts', 'gutenberg' ), + 'file_path' => gutenberg_dir_path() . 'packages/icons/src/library/drafts.svg', + ) + ); + + // Register temporary WordPress icon since it's still a core private icon + wp_register_icon( + 'dashboard-widgets/wordpress', + array( + 'label' => __( 'WordPress', 'gutenberg' ), + 'file_path' => gutenberg_dir_path() . 'packages/icons/src/library/wordpress.svg', + ) + ); + + // Register temporary site logo icon since it's still a core private icon + wp_register_icon( + 'dashboard-widgets/site-logo', + array( + 'label' => __( 'Site Logo', 'gutenberg' ), + 'file_path' => gutenberg_dir_path() . 'packages/icons/src/library/site-logo.svg', + ) + ); +} +add_action( 'init', 'gutenberg_register_dashboard_widget_icons' ); diff --git a/lib/experimental/dashboard-widgets/widget-types.php b/lib/experimental/dashboard-widgets/widget-types.php index 2df54230af70cb..c8d7b5817666e6 100644 --- a/lib/experimental/dashboard-widgets/widget-types.php +++ b/lib/experimental/dashboard-widgets/widget-types.php @@ -240,6 +240,26 @@ function gutenberg_sanitize_widget_actions( $actions, $dir_name = '' ) { return $sanitized ? $sanitized : null; } +/** + * Constrains a widget icon reference to a registered icon name + * (`collection/icon-name`). Anything else drops silently, so authoring + * forms not accepted yet degrade to no icon rather than warn. + * + * @param string|null $icon Icon reference from the build manifest. + * @return string|null The icon name, or null when the shape does not match. + */ +function gutenberg_sanitize_widget_icon( $icon ) { + if ( ! is_string( $icon ) || '' === $icon ) { + return null; + } + + if ( ! preg_match( '#^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?/[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$#', $icon ) ) { + return null; + } + + return $icon; +} + /** * Hydrates the widget type registry from the build manifest. * @@ -273,6 +293,7 @@ function gutenberg_register_widget_types() { 'title' => $widget['title'] ?? null, 'description' => $widget['description'] ?? null, 'help' => gutenberg_sanitize_widget_help( $widget['help'] ?? null ), + 'icon' => gutenberg_sanitize_widget_icon( $widget['icon'] ?? null ), 'actions' => gutenberg_sanitize_widget_actions( $widget['actions'] ?? null, $widget['dir_name'] ?? '' diff --git a/lib/load.php b/lib/load.php index 9ef9e87d54988d..b22e9381f086dc 100644 --- a/lib/load.php +++ b/lib/load.php @@ -241,6 +241,7 @@ function gutenberg_is_experiment_enabled( $name ) { if ( gutenberg_is_experiment_enabled( 'gutenberg-dashboard-widgets' ) ) { require __DIR__ . '/experimental/dashboard-widgets/load.php'; require __DIR__ . '/experimental/dashboard-widgets/widget-types.php'; + require __DIR__ . '/experimental/dashboard-widgets/widget-icons.php'; require __DIR__ . '/experimental/dashboard-widgets/dashboard-layout.php'; require __DIR__ . '/experimental/dashboard-widgets/default-layout-seed.php'; } diff --git a/package-lock.json b/package-lock.json index 1c58756886b975..0a78f0b330e4dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51367,7 +51367,11 @@ "dependencies": { "@wordpress/core-data": "file:../core-data", "@wordpress/data": "file:../data", - "@wordpress/i18n": "file:../i18n" + "@wordpress/dom": "file:../dom", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/widget-primitives": "file:../widget-primitives", + "html-react-parser": "^5.2.11" }, "engines": { "node": ">=20.10.0", diff --git a/packages/dashboard-init/CHANGELOG.md b/packages/dashboard-init/CHANGELOG.md index 2e271ef03a604b..481d96810ecd52 100644 --- a/packages/dashboard-init/CHANGELOG.md +++ b/packages/dashboard-init/CHANGELOG.md @@ -2,4 +2,6 @@ ## Unreleased +- Register the dashboard's widget icon resolver at init, before the page + renders ([#80969](https://github.com/WordPress/gutenberg/pull/80969)). - Initial version of the package. diff --git a/packages/dashboard-init/package.json b/packages/dashboard-init/package.json index 803548455b6b6d..d188bce1cbcae7 100644 --- a/packages/dashboard-init/package.json +++ b/packages/dashboard-init/package.json @@ -34,7 +34,11 @@ "dependencies": { "@wordpress/core-data": "file:../core-data", "@wordpress/data": "file:../data", - "@wordpress/i18n": "file:../i18n" + "@wordpress/dom": "file:../dom", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/widget-primitives": "file:../widget-primitives", + "html-react-parser": "^5.2.11" }, "publishConfig": { "access": "public" diff --git a/packages/dashboard-init/src/icons/index.ts b/packages/dashboard-init/src/icons/index.ts new file mode 100644 index 00000000000000..ab61dc31bfdd26 --- /dev/null +++ b/packages/dashboard-init/src/icons/index.ts @@ -0,0 +1,55 @@ +/** + * External dependencies + */ +import parse from 'html-react-parser'; + +/** + * WordPress dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { resolveSelect } from '@wordpress/data'; +import { safeHTML } from '@wordpress/dom'; +import { cloneElement, isValidElement } from '@wordpress/element'; +import { registerIconResolver } from '@wordpress/widget-primitives'; +import type { WidgetIcon } from '@wordpress/widget-primitives'; + +/** + * Registers the dashboard's icon resolver: references resolve against + * the `icon` entity, and the record's SVG content becomes the element. + */ +export function registerDashboardIconResolver() { + registerIconResolver( async ( reference ) => { + const record = ( await resolveSelect( coreStore ).getEntityRecord( + 'root', + 'icon', + reference + ) ) as { content?: string } | undefined; + + if ( ! record?.content ) { + return null; + } + + /* + * Whitespace around the root `` makes `parse()` return an + * array; take the element. + */ + const parsed = parse( safeHTML( record.content.trim() ) ); + const found = Array.isArray( parsed ) + ? parsed.find( isValidElement ) + : parsed; + + if ( ! isValidElement( found ) ) { + return null; + } + + const element = found as WidgetIcon; + + /* + * The registry sanitizer strips `fill` from the root ``; + * restore inheritance so icons follow the surrounding color. + */ + return element.props.fill + ? element + : cloneElement( element, { fill: 'currentColor' } ); + } ); +} diff --git a/packages/dashboard-init/src/index.ts b/packages/dashboard-init/src/index.ts index 13497cac64c921..cf8eea106aa35d 100644 --- a/packages/dashboard-init/src/index.ts +++ b/packages/dashboard-init/src/index.ts @@ -6,9 +6,14 @@ import { store as coreStore } from '@wordpress/core-data'; import { __ } from '@wordpress/i18n'; /** - * Register the widget-modules discovery entity before the dashboard renders, - * so the stage's `getEntityRecords` read resolves and feeds the records to - * `useWidgetTypes`. + * Internal dependencies + */ +import { registerDashboardIconResolver } from './icons'; + +/** + * Register the widget-modules discovery entity and the icon resolver + * before the dashboard renders, so the stage's `getEntityRecords` read + * resolves and feeds the records to `useWidgetTypes`. * * This function is mandatory - all init modules must export 'init'. */ @@ -17,6 +22,8 @@ export async function init() { return; } + registerDashboardIconResolver(); + dispatch( coreStore ).addEntities( [ { name: 'widgetModule', diff --git a/packages/widget-primitives/CHANGELOG.md b/packages/widget-primitives/CHANGELOG.md index 4478eb9ea695bd..abf0b9e2db97c1 100644 --- a/packages/widget-primitives/CHANGELOG.md +++ b/packages/widget-primitives/CHANGELOG.md @@ -6,6 +6,11 @@ - `WidgetTypeMetadata`: add optional `actions`, a declarative list of user-triggerable links a widget exposes ([#80363](https://github.com/WordPress/gutenberg/pull/80363)). +- Widgets can reference their icon declaratively: `WidgetModuleRecord` + carries a registered icon name and `useWidgetTypes` resolves it through + the application-registered resolver (`registerIconResolver`), so + `WidgetType.icon` always reaches hosts renderable + ([#80969](https://github.com/WordPress/gutenberg/pull/80969)). ### Enhancements @@ -14,7 +19,8 @@ ### Documentation -- Describe actions as verbs with one fulfillment, not as links ([#80974](https://github.com/WordPress/gutenberg/pull/80974)). +- Add an Icons doc page and a `WithIconReference` story ([#80969](https://github.com/WordPress/gutenberg/pull/80969)). +- Describe actions as verbs ([#80974](https://github.com/WordPress/gutenberg/pull/80974)). - Prefer widget-local files over `data:` URLs for action downloads ([#80510](https://github.com/WordPress/gutenberg/pull/80510)). - Add an Actions doc page and a `WithActions` story, and cover `actions` in the widget anatomy doc ([#80363](https://github.com/WordPress/gutenberg/pull/80363)). diff --git a/packages/widget-primitives/README.md b/packages/widget-primitives/README.md index 7d5442fd442e43..f797f5e9270852 100644 --- a/packages/widget-primitives/README.md +++ b/packages/widget-primitives/README.md @@ -55,7 +55,7 @@ It takes host-supplied records (`WidgetModuleRecord[]`, or `null` while loading) ### Contract types -`WidgetType`, `WidgetName`, `WidgetIcon`, `WidgetRenderProps`, `ResolveWidgetModule`, and `WidgetModuleRecord`. `WidgetIcon` is a rendered SVG element that hosts the pass to its icon primitive as-is. +`WidgetType`, `WidgetName`, `WidgetIcon`, `WidgetRenderProps`, `ResolveWidgetModule`, and `WidgetModuleRecord`. `WidgetIcon` is a rendered SVG element that hosts pass to their icon primitive as-is; in `widget.json` a widget declares a registered icon name instead, resolved before it reaches hosts. ### `WidgetAttributeField< Item >` @@ -76,6 +76,10 @@ The widget names the intent and how it is fulfilled; the host mounts the primiti `useWidgetTypes` resolves those references into the plain per-field `Field` props DataViews understands, inheriting the rest from `baseType`. +### Icons + +`registerIconResolver( resolver )` registers how a registered icon name (`"icon": "core/calendar"` in `widget.json`) becomes a renderable element. The application registers it once; `useWidgetTypes` resolves references while assembling each `WidgetType`, so hosts only receive renderable icons. An unresolvable reference degrades to no icon. + ## Architecture For how the full pipeline fits together (authoring, build, server registry, and diff --git a/packages/widget-primitives/src/components/widget-render/stories/index.story.tsx b/packages/widget-primitives/src/components/widget-render/stories/index.story.tsx index e41525e291ee2a..1d98f4c243e256 100644 --- a/packages/widget-primitives/src/components/widget-render/stories/index.story.tsx +++ b/packages/widget-primitives/src/components/widget-render/stories/index.story.tsx @@ -14,7 +14,13 @@ import '@wordpress/components/build-style/style.css'; import '@wordpress/dataviews/build-style/style.css'; import { DataForm, useFormValidity } from '@wordpress/dataviews'; import type { DataFormControlProps, Field, Form } from '@wordpress/dataviews'; -import { Suspense, useId, useMemo, useState } from '@wordpress/element'; +import { + Suspense, + useEffect, + useId, + useMemo, + useState, +} from '@wordpress/element'; import { globe, starFilled } from '@wordpress/icons'; // `IconButton` is not on the recommended list yet. /* eslint-disable @wordpress/use-recommended-components */ @@ -26,9 +32,11 @@ import { Card, Icon, IconButton, Link, Stack } from '@wordpress/ui'; */ import { WidgetRender } from '..'; import { registerFieldType, resolveFields } from '../../../field-types'; +import { registerIconResolver, resolveIcon } from '../../../icon-resolver'; import type { WidgetAction, WidgetAttributeField, + WidgetIcon, WidgetRenderProps, WidgetType, } from '../../../types'; @@ -842,3 +850,91 @@ The widget names the intent and how it is fulfilled; the host mounts the primiti }, }, }; + +/* + * The application's icon vocabulary: one resolver, registered once. + * Records reference icons by name; the resolver returns elements. + */ +const STORY_ICONS: Record< string, WidgetIcon > = { + 'demo/planet': globe, +}; + +registerIconResolver( async ( reference ) => STORY_ICONS[ reference ] ?? null ); + +const referencedIconWidgetType: WidgetType< DemoAttributes > = { + ...demoWidgetType, + icon: undefined, +}; + +function WidgetWithIconReference() { + const titleId = useId(); + const [ attributes ] = useState< DemoAttributes >( { + ...referencedIconWidgetType.example?.attributes, + } ); + const [ icon, setIcon ] = useState< WidgetIcon | null >( null ); + + // Hosts get the resolved icon through `useWidgetTypes`; the story + // resolves the reference itself. + useEffect( () => { + let cancelled = false; + void resolveIcon( 'demo/planet' ).then( ( resolved ) => { + if ( ! cancelled ) { + setIcon( resolved ); + } + } ); + return () => { + cancelled = true; + }; + }, [] ); + + return ( +
+ } aria-labelledby={ titleId }> + + + { icon && ( + + ) } + } + style={ { flexGrow: 1 } } + > + { referencedIconWidgetType.title } + + + + + + + widgetType={ referencedIconWidgetType } + attributes={ attributes } + resolveWidgetModule={ resolveDemoModule } + /> + + + +
+ ); +} + +export const WithIconReference: StoryObj = { + render: () => , + parameters: { + docs: { + description: { + story: ` +The widget declares its icon as a **registered icon name** instead of a rendered element: + +1. The application registers one **icon resolver** (\`registerIconResolver\`), closing over its icon source. On WordPress that source is the \`icon\` REST entity; here, a local catalog. +2. The widget declares \`"icon": "demo/planet"\` in \`widget.json\`. Pure data: no imports, no elements. +3. \`useWidgetTypes\` resolves the reference while assembling each \`WidgetType\` (the story calls the resolver itself), so the host receives a renderable element and never sees the name. + +An unresolvable reference degrades to no icon. See the **Icons** doc for the full pipeline. +`, + }, + }, + }, +}; diff --git a/packages/widget-primitives/src/hooks/test/use-widget-types.test.tsx b/packages/widget-primitives/src/hooks/test/use-widget-types.test.tsx index f662f4703d4d4e..8d557daae21cc3 100644 --- a/packages/widget-primitives/src/hooks/test/use-widget-types.test.tsx +++ b/packages/widget-primitives/src/hooks/test/use-widget-types.test.tsx @@ -3,15 +3,28 @@ */ import { renderHook, waitFor } from '@testing-library/react'; +/** + * WordPress dependencies + */ +import { createElement, isValidElement } from '@wordpress/element'; + /** * Internal dependencies */ import { registerFieldType, unregisterFieldType } from '../../field-types'; +import { + registerIconResolver, + unregisterIconResolver, +} from '../../icon-resolver'; import { useWidgetTypes } from '../use-widget-types'; -import type { WidgetModuleRecord } from '../../types'; +import type { WidgetIcon, WidgetModuleRecord } from '../../types'; const LocationControl = () => null; +const mockModuleIcon = createElement( 'svg', { + viewBox: '0 0 24 24', +} ) as WidgetIcon; + jest.mock( 'test-widget/widget-module', () => ( { @@ -19,6 +32,7 @@ jest.mock( default: { apiVersion: 1, title: 'Store', + icon: mockModuleIcon, attributes: [ { id: 'location', @@ -32,6 +46,18 @@ jest.mock( { virtual: true } ); +jest.mock( + 'test-widget/string-icon-module', + () => ( { + __esModule: true, + default: { + title: 'String icon', + icon: 'wordpress', + }, + } ), + { virtual: true } +); + const records: WidgetModuleRecord[] = [ { name: 'test/store', @@ -40,9 +66,26 @@ const records: WidgetModuleRecord[] = [ }, ]; +const iconReferenceRecords: WidgetModuleRecord[] = [ + { ...records[ 0 ], icon: 'core/calendar' }, +]; + +const stringIconRecords: WidgetModuleRecord[] = [ + { + name: 'test/string-icon', + widget_module: 'test-widget/string-icon-module', + render_module: 'test-widget/render-module', + }, +]; + +const pendingIconRecords: WidgetModuleRecord[] = [ + { ...stringIconRecords[ 0 ], icon: 'core/pending' }, +]; + describe( 'useWidgetTypes', () => { afterEach( () => { unregisterFieldType( 'test/location' ); + unregisterIconResolver(); } ); it( 'resolves named field-type references while building widget types', async () => { @@ -70,4 +113,80 @@ describe( 'useWidgetTypes', () => { // …while plain DataViews fields pass through unchanged. expect( label ).toMatchObject( { id: 'label', type: 'text' } ); } ); + + it( 'prefers the resolved record icon over the module element', async () => { + const resolvedIcon = createElement( 'svg', { + viewBox: '0 0 32 32', + } ) as WidgetIcon; + registerIconResolver( async () => resolvedIcon ); + + const { result } = renderHook( () => + useWidgetTypes( iconReferenceRecords ) + ); + + await waitFor( () => expect( result.current[ 1 ] ).toBe( false ) ); + await waitFor( () => + expect( result.current[ 0 ][ 0 ].icon ).toBe( resolvedIcon ) + ); + } ); + + it( 'emits widget types before icon references settle', async () => { + registerIconResolver( + () => new Promise< WidgetIcon | null >( () => {} ) + ); + + const { result } = renderHook( () => + useWidgetTypes( iconReferenceRecords ) + ); + + await waitFor( () => expect( result.current[ 1 ] ).toBe( false ) ); + + expect( result.current[ 0 ] ).toHaveLength( 1 ); + expect( result.current[ 0 ][ 0 ].icon ).toBe( mockModuleIcon ); + } ); + + it( 'keeps the module element when the reference does not resolve', async () => { + const { result } = renderHook( () => + useWidgetTypes( iconReferenceRecords ) + ); + + await waitFor( () => expect( result.current[ 1 ] ).toBe( false ) ); + + expect( result.current[ 0 ][ 0 ].icon ).toBe( mockModuleIcon ); + } ); + + it( 'holds the icon slot with a stand-in while the reference resolves', async () => { + registerIconResolver( + () => new Promise< WidgetIcon | null >( () => {} ) + ); + + const { result } = renderHook( () => + useWidgetTypes( pendingIconRecords ) + ); + + await waitFor( () => expect( result.current[ 1 ] ).toBe( false ) ); + + expect( isValidElement( result.current[ 0 ][ 0 ].icon ) ).toBe( true ); + } ); + + it( 'clears the stand-in when the reference does not resolve', async () => { + const { result } = renderHook( () => + useWidgetTypes( pendingIconRecords ) + ); + + await waitFor( () => expect( result.current[ 1 ] ).toBe( false ) ); + await waitFor( () => + expect( result.current[ 0 ][ 0 ].icon ).toBeUndefined() + ); + } ); + + it( 'drops a module icon that is not an element', async () => { + const { result } = renderHook( () => + useWidgetTypes( stringIconRecords ) + ); + + await waitFor( () => expect( result.current[ 1 ] ).toBe( false ) ); + + expect( result.current[ 0 ][ 0 ].icon ).toBeUndefined(); + } ); } ); diff --git a/packages/widget-primitives/src/hooks/use-widget-types.ts b/packages/widget-primitives/src/hooks/use-widget-types.ts index dcdd0a7f03ed73..d023809eba2408 100644 --- a/packages/widget-primitives/src/hooks/use-widget-types.ts +++ b/packages/widget-primitives/src/hooks/use-widget-types.ts @@ -1,13 +1,32 @@ /** * WordPress dependencies */ -import { useEffect, useState } from '@wordpress/element'; +import { + createElement, + isValidElement, + useEffect, + useState, +} from '@wordpress/element'; /** * Internal dependencies */ import { resolveFields } from '../field-types'; -import type { WidgetModuleRecord, WidgetName, WidgetType } from '../types'; +import { resolveIcon } from '../icon-resolver'; +import type { + WidgetIcon, + WidgetModuleRecord, + WidgetName, + WidgetType, +} from '../types'; + +/* + * Transparent stand-in for an icon reference that has not resolved yet: + * it holds the icon slot so titles do not shift when the icon lands. + */ +const pendingIcon: WidgetIcon = createElement( 'svg', { + viewBox: '0 0 24 24', +} ); /* `true` while records or their metadata imports are still resolving; hosts must not treat a widget instance as missing until it is `false`. */ @@ -19,7 +38,10 @@ type UseWidgetTypesResult = readonly [ WidgetType[], boolean ]; * For each record it dynamically imports `widget_module` and merges the * module's default export with the runtime fields (`name`, `renderModule`). * Attribute schemas pass through `resolveFields`, so attributes referencing - * registered field types reach hosts as plain DataViews fields. + * registered field types reach hosts as plain DataViews fields. Icon + * references resolve through the registered icon resolver, off the loading + * flag: widget types emit as soon as their modules land, and each resolved + * icon patches in afterwards. * Pass `null`/`undefined` while records are still loading. * * @param records Host-supplied records, or `null`/`undefined` while loading. @@ -63,6 +85,17 @@ export function useWidgetTypes( const metadata = module.default as Partial< WidgetType >; + /* + * Only a renderable element may enter; a pending + * reference holds the slot with the stand-in until + * it resolves after the gate. + */ + const moduleIcon = isValidElement( metadata.icon ) + ? metadata.icon + : undefined; + const icon = + moduleIcon ?? ( record.icon ? pendingIcon : undefined ); + return { ...metadata, ...( metadata.attributes @@ -74,6 +107,7 @@ export function useWidgetTypes( : {} ), name: record.name as WidgetName, renderModule: record.render_module ?? '', + icon, /* * `title` is required: * - Server-side title wins @@ -111,6 +145,39 @@ export function useWidgetTypes( results.filter( ( t ): t is WidgetType => t !== null ) ); setIsResolvingWidgetTypes( false ); + + /* + * Icons resolve off the loading gate. The resolved reference + * wins; when it does not resolve, the module's element + * stands and the stand-in clears. + */ + for ( const record of records ) { + if ( ! record.icon ) { + continue; + } + + void resolveIcon( record.icon ).then( ( resolved ) => { + if ( cancelled ) { + return; + } + + setWidgetTypes( ( prev ) => + prev.map( ( widgetType ) => { + if ( widgetType.name !== record.name ) { + return widgetType; + } + + if ( resolved ) { + return { ...widgetType, icon: resolved }; + } + + return widgetType.icon === pendingIcon + ? { ...widgetType, icon: undefined } + : widgetType; + } ) + ); + } ); + } } ); return () => { diff --git a/packages/widget-primitives/src/icon-resolver/icon-resolver.ts b/packages/widget-primitives/src/icon-resolver/icon-resolver.ts new file mode 100644 index 00000000000000..d07c770431719d --- /dev/null +++ b/packages/widget-primitives/src/icon-resolver/icon-resolver.ts @@ -0,0 +1,72 @@ +/** + * Icon resolver registry. + * + * The application registers a single resolver that turns a registered + * icon name (`collection/icon-name`) into a renderable `WidgetIcon`; + * `useWidgetTypes` invokes it while assembling each `WidgetType`. + */ + +/** + * Internal dependencies + */ +import type { WidgetIcon, WidgetIconReference } from '../types'; + +/** + * Resolves an icon reference into a renderable icon, or `null` when the + * reference does not resolve. + */ +export type WidgetIconResolver = ( + reference: WidgetIconReference +) => Promise< WidgetIcon | null >; + +let iconResolver: WidgetIconResolver | undefined; + +/** + * Registers the icon resolver. First registration wins. + * + * @param resolver Resolver turning a reference into a renderable icon. + * @return The registered resolver, or `undefined` when ignored. + */ +export function registerIconResolver( + resolver: WidgetIconResolver +): WidgetIconResolver | undefined { + if ( iconResolver ) { + return undefined; + } + + iconResolver = resolver; + return resolver; +} + +/** + * Unregisters the icon resolver. + * + * @return The removed resolver, or `undefined` when none was registered. + */ +export function unregisterIconResolver(): WidgetIconResolver | undefined { + const resolver = iconResolver; + iconResolver = undefined; + + return resolver; +} + +/** + * Resolves an icon reference through the registered resolver. An + * unresolvable reference degrades to `null` rather than breaking. + * + * @param reference Icon reference to resolve. + * @return The renderable icon, or `null`. + */ +export async function resolveIcon( + reference: WidgetIconReference +): Promise< WidgetIcon | null > { + if ( ! iconResolver ) { + return null; + } + + try { + return await iconResolver( reference ); + } catch { + return null; + } +} diff --git a/packages/widget-primitives/src/icon-resolver/index.ts b/packages/widget-primitives/src/icon-resolver/index.ts new file mode 100644 index 00000000000000..68cc8416bfc0a9 --- /dev/null +++ b/packages/widget-primitives/src/icon-resolver/index.ts @@ -0,0 +1,7 @@ +export { + registerIconResolver, + resolveIcon, + unregisterIconResolver, +} from './icon-resolver'; + +export type { WidgetIconResolver } from './icon-resolver'; diff --git a/packages/widget-primitives/src/icon-resolver/test/icon-resolver.ts b/packages/widget-primitives/src/icon-resolver/test/icon-resolver.ts new file mode 100644 index 00000000000000..484951aac3f9f6 --- /dev/null +++ b/packages/widget-primitives/src/icon-resolver/test/icon-resolver.ts @@ -0,0 +1,61 @@ +/** + * WordPress dependencies + */ +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { + registerIconResolver, + resolveIcon, + unregisterIconResolver, +} from '../icon-resolver'; +import type { WidgetIcon } from '../../types'; + +const icon = createElement( 'svg', { viewBox: '0 0 24 24' } ) as WidgetIcon; + +describe( 'icon resolver registry', () => { + afterEach( () => { + unregisterIconResolver(); + } ); + + it( 'resolves to null when no resolver is registered', async () => { + await expect( resolveIcon( 'core/calendar' ) ).resolves.toBeNull(); + } ); + + it( 'resolves references through the registered resolver', async () => { + const resolver = jest.fn( async () => icon ); + expect( registerIconResolver( resolver ) ).toBe( resolver ); + + await expect( resolveIcon( 'core/calendar' ) ).resolves.toBe( icon ); + expect( resolver ).toHaveBeenCalledWith( 'core/calendar' ); + } ); + + it( 'keeps the first registered resolver', async () => { + const first = jest.fn( async () => icon ); + const second = jest.fn( async () => null ); + + registerIconResolver( first ); + expect( registerIconResolver( second ) ).toBeUndefined(); + + await expect( resolveIcon( 'core/calendar' ) ).resolves.toBe( icon ); + expect( second ).not.toHaveBeenCalled(); + } ); + + it( 'degrades to null when the resolver rejects', async () => { + registerIconResolver( () => Promise.reject( new Error( 'nope' ) ) ); + + await expect( resolveIcon( 'core/calendar' ) ).resolves.toBeNull(); + } ); + + it( 'unregisters the resolver', async () => { + const resolver = jest.fn( async () => icon ); + registerIconResolver( resolver ); + + expect( unregisterIconResolver() ).toBe( resolver ); + expect( unregisterIconResolver() ).toBeUndefined(); + + await expect( resolveIcon( 'core/calendar' ) ).resolves.toBeNull(); + } ); +} ); diff --git a/packages/widget-primitives/src/index.ts b/packages/widget-primitives/src/index.ts index e7e42bbcf56414..287d8270ae57cc 100644 --- a/packages/widget-primitives/src/index.ts +++ b/packages/widget-primitives/src/index.ts @@ -13,6 +13,11 @@ export { useWidgetTypes } from './hooks'; */ export { registerFieldType } from './field-types'; +/** + * Icon resolution + */ +export { registerIconResolver } from './icon-resolver'; + /** * Types */ diff --git a/packages/widget-primitives/src/stories/icons.md b/packages/widget-primitives/src/stories/icons.md new file mode 100644 index 00000000000000..53d17383e9508d --- /dev/null +++ b/packages/widget-primitives/src/stories/icons.md @@ -0,0 +1,38 @@ +# Icons + +
+This package is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes. +
+ +An icon authored as a React element can only travel inside its metadata module, so any surface that wants to draw it has to import and execute the module first. The icon reference makes the declaration travel as data instead: `widget.json` names an icon registered in the site's icon registry, and the name flows through the build manifest, the server registry, and the REST record like every other identity field. + +## Referencing + +One nullable string field. A registered icon name is `collection/icon-name`: + +```json +{ "icon": "core/calendar" } +``` + +A metadata module may still declare a rendered element; the resolved reference wins over it, and the element stands when the record carries none. The widgets in this repository all declare references. + +## Registering the resolver + +The reference is data behind a REST entity, so what the application registers is a resolver rather than a value. It registers one resolver, before anything renders, closing over whatever data layer it owns: + +```ts +registerIconResolver( async ( reference ) => { + const icon = await fetchIconRecord( reference ); + return icon ? toElement( icon.content ) : null; +} ); +``` + +The resolver returns a renderable element or `null`. First registration wins. + +## Resolution + +`useWidgetTypes` resolves references while it assembles each `WidgetType`, at the same boundary that resolves field-type names. Hosts receive a renderable `icon` and never see a reference; anything that is not an element is dropped at the boundary. The _WidgetRender / With Icon Reference_ story shows the seam in isolation. + +## When the reference does not resolve + +An unregistered name, a missing resolver, or a resolver failure degrades to no icon; the metadata module's element stands when there is one. A widget referencing an icon its application cannot resolve degrades; it does not break. diff --git a/packages/widget-primitives/src/stories/icons.mdx b/packages/widget-primitives/src/stories/icons.mdx new file mode 100644 index 00000000000000..3d6e5efa3296d9 --- /dev/null +++ b/packages/widget-primitives/src/stories/icons.mdx @@ -0,0 +1,6 @@ +import { Meta, Markdown } from '@storybook/addon-docs/blocks'; +import IconsDoc from './icons.md?raw'; + + + +{ IconsDoc } diff --git a/packages/widget-primitives/src/types.ts b/packages/widget-primitives/src/types.ts index 66e217e0c45731..de9397cf8fd35c 100644 --- a/packages/widget-primitives/src/types.ts +++ b/packages/widget-primitives/src/types.ts @@ -31,6 +31,12 @@ export type WidgetName = `${ string }/${ string }`; */ export type WidgetIcon = ReactElement< ComponentProps< 'svg' > >; +/** + * Registered icon name (`collection/icon-name`), resolved into a + * `WidgetIcon` by the application's resolver (see `registerIconResolver`). + */ +export type WidgetIconReference = string; + /** * A link in a widget's help note. */ @@ -323,4 +329,10 @@ export interface WidgetModuleRecord extends WidgetModuleRecordOverrides { * Script-module id dynamically imported for the widget's live metadata. */ widget_module?: string | null; + + /** + * Registered icon name (`collection/icon-name`); never an element. + * `null`/absent means the module's icon stands. + */ + icon?: WidgetIconReference | null; } diff --git a/packages/wp-build/CHANGELOG.md b/packages/wp-build/CHANGELOG.md index 8514bb3d5f8396..88a6d2ed3b9c45 100644 --- a/packages/wp-build/CHANGELOG.md +++ b/packages/wp-build/CHANGELOG.md @@ -6,6 +6,8 @@ - Widgets: carry a widget's declarative `actions` from `widget.json` into the generated PHP registry ([#80363](https://github.com/WordPress/gutenberg/pull/80363)). +- Widgets: carry a widget's declarative `icon` reference from `widget.json` + into the generated PHP registry ([#80969](https://github.com/WordPress/gutenberg/pull/80969)). ### Bug Fixes @@ -14,7 +16,6 @@ - Sync the page template `/?_fields=` preload path with the `_fields` list in `@wordpress/core-data` so the preload is consumed instead of the page issuing a duplicate request ([#80648](https://github.com/WordPress/gutenberg/pull/80648)). - Scope the generated page template's critical styles to `body.js` so the no-JS notice stays visible ([#80628](https://github.com/WordPress/gutenberg/pull/80628)). - ## 0.19.0 (2026-07-14) ### Enhancements diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index 83abe1aece3729..62d2ddb2969829 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -2021,7 +2021,7 @@ async function buildAllWidgets() { * Discover all widgets and collect their registry-facing data. * Widgets without a valid widget.json are skipped. * - * @return {Array<{ name: string, dirName: string, title: string | null, description: string | null, help: import('./widget-utils.mjs').WidgetHelpMetadata | null, actions: import('./widget-utils.mjs').WidgetActionMetadata[] | null, hasRender: boolean, hasWidget: boolean, presentation: string | null, category: string | null, keywords: string[] | null, textdomain: string | null }>} Array of widget objects. + * @return {Array<{ name: string, dirName: string, title: string | null, description: string | null, help: import('./widget-utils.mjs').WidgetHelpMetadata | null, icon: string | null, actions: import('./widget-utils.mjs').WidgetActionMetadata[] | null, hasRender: boolean, hasWidget: boolean, presentation: string | null, category: string | null, keywords: string[] | null, textdomain: string | null }>} Array of widget objects. */ function collectWidgets() { return getAllWidgets( ROOT_DIR ).flatMap( ( widgetName ) => { @@ -2043,6 +2043,7 @@ function collectWidgets() { title: metadata.title ?? null, description: metadata.description ?? null, help: metadata.help ?? null, + icon: metadata.icon ?? null, actions: metadata.actions ?? null, hasRender: widgetFiles.hasRender, hasWidget: widgetFiles.hasWidget, @@ -2199,6 +2200,7 @@ async function generateWidgetRegistry( widgets, replacements ) { const titleStr = toPhpStringLiteral( widget.title ); const descriptionStr = toPhpStringLiteral( widget.description ); const helpStr = toPhpHelpLiteral( widget.help ); + const iconStr = toPhpStringLiteral( widget.icon ); const actionsStr = toPhpActionsLiteral( widget.actions ); const keywordsStr = toPhpStringArrayLiteral( widget.keywords ); const textdomainStr = toPhpStringLiteral( widget.textdomain ); @@ -2208,6 +2210,7 @@ async function generateWidgetRegistry( widgets, replacements ) { 'title' => ${ titleStr }, 'description' => ${ descriptionStr }, 'help' => ${ helpStr }, + 'icon' => ${ iconStr }, 'actions' => ${ actionsStr }, 'has_render' => ${ hasRenderStr }, 'has_widget' => ${ hasWidgetStr }, diff --git a/packages/wp-build/lib/widget-utils.mjs b/packages/wp-build/lib/widget-utils.mjs index f67814b3f42ab3..afa85950cb93f2 100644 --- a/packages/wp-build/lib/widget-utils.mjs +++ b/packages/wp-build/lib/widget-utils.mjs @@ -50,6 +50,7 @@ export function getAllWidgets( rootDir ) { * @property {string} [title] Human-readable title. * @property {string} [description] Short description. * @property {WidgetHelpMetadata} [help] Contextual help note for compact surfaces. + * @property {string} [icon] Registered icon name (`collection/icon-name`). * @property {WidgetActionMetadata[]} [actions] Declarative actions the widget exposes. * @property {string} [category] Grouping category. * @property {'framed' | 'content-bleed' | 'full-bleed'} [presentation] Authoring intent about how the widget wants to render. diff --git a/phpunit/experimental/class-wp-rest-widget-modules-controller-test.php b/phpunit/experimental/class-wp-rest-widget-modules-controller-test.php index b431a25ad56304..b30bc03a3bf7b7 100644 --- a/phpunit/experimental/class-wp-rest-widget-modules-controller-test.php +++ b/phpunit/experimental/class-wp-rest-widget-modules-controller-test.php @@ -71,6 +71,7 @@ private function register_sample_widgets() { ), ), ), + 'icon' => 'core/calendar', 'actions' => array( array( 'id' => 'open-settings', @@ -159,6 +160,7 @@ public function test_get_item_returns_widget_by_name() { ), $data['help'] ); + $this->assertSame( 'core/calendar', $data['icon'] ); $this->assertSame( array( array( @@ -219,6 +221,7 @@ public function test_schema_exposes_expected_properties() { $this->assertArrayHasKey( 'title', $properties ); $this->assertArrayHasKey( 'description', $properties ); $this->assertArrayHasKey( 'help', $properties ); + $this->assertArrayHasKey( 'icon', $properties ); $this->assertArrayHasKey( 'actions', $properties ); $this->assertArrayHasKey( 'keywords', $properties ); $this->assertSame( 'string', $properties['name']['type'] ); @@ -228,6 +231,7 @@ public function test_schema_exposes_expected_properties() { $this->assertSame( array( 'string', 'null' ), $properties['title']['type'] ); $this->assertSame( array( 'string', 'null' ), $properties['description']['type'] ); $this->assertSame( array( 'object', 'null' ), $properties['help']['type'] ); + $this->assertSame( array( 'string', 'null' ), $properties['icon']['type'] ); $this->assertSame( array( 'array', 'null' ), $properties['actions']['type'] ); $this->assertSame( array( 'array', 'null' ), $properties['keywords']['type'] ); } diff --git a/phpunit/experimental/widget-types-test.php b/phpunit/experimental/widget-types-test.php index 8ebd0a4f60bbca..9b52668d078717 100644 --- a/phpunit/experimental/widget-types-test.php +++ b/phpunit/experimental/widget-types-test.php @@ -7,6 +7,7 @@ * @covers ::gutenberg_translate_widget_metadata * @covers ::gutenberg_get_widget_metadata_i18n_schema * @covers ::gutenberg_sanitize_widget_help + * @covers ::gutenberg_sanitize_widget_icon * @covers ::gutenberg_sanitize_widget_actions * @covers ::gutenberg_resolve_widget_action_href */ @@ -112,6 +113,25 @@ public function test_sanitize_widget_help_requires_content() { $this->assertNull( gutenberg_sanitize_widget_help( array( 'links' => array() ) ) ); } + /** + * Only names shaped `collection/icon-name` pass; markup, files, and + * anything malformed normalize to null. + */ + public function test_sanitize_widget_icon_requires_a_registered_name_shape() { + $this->assertSame( 'core/calendar', gutenberg_sanitize_widget_icon( 'core/calendar' ) ); + $this->assertSame( 'my-plugin/arrow-left', gutenberg_sanitize_widget_icon( 'my-plugin/arrow-left' ) ); + + $this->assertNull( gutenberg_sanitize_widget_icon( null ) ); + $this->assertNull( gutenberg_sanitize_widget_icon( '' ) ); + $this->assertNull( gutenberg_sanitize_widget_icon( 'calendar' ) ); + $this->assertNull( gutenberg_sanitize_widget_icon( 'core/a/b' ) ); + $this->assertNull( gutenberg_sanitize_widget_icon( 'Core/Calendar' ) ); + $this->assertNull( gutenberg_sanitize_widget_icon( 'core/-calendar' ) ); + $this->assertNull( gutenberg_sanitize_widget_icon( 'core/calendar-' ) ); + $this->assertNull( gutenberg_sanitize_widget_icon( 'icon.svg' ) ); + $this->assertNull( gutenberg_sanitize_widget_icon( '' ) ); + } + /** * Drops unsafe or malformed actions; sanitizes download filenames. */ diff --git a/widgets/events/widget.json b/widgets/events/widget.json index 7c117c2aa79126..c7fbd176484850 100644 --- a/widgets/events/widget.json +++ b/widgets/events/widget.json @@ -2,6 +2,7 @@ "name": "core/events", "title": "WordPress events", "description": "Displays upcoming WordPress community events.", + "icon": "core/calendar", "category": "dashboard", "presentation": "content-bleed", "textdomain": "default" diff --git a/widgets/events/widget.ts b/widgets/events/widget.ts index 98e1d4e278394a..ebc7a2989d34bd 100644 --- a/widgets/events/widget.ts +++ b/widgets/events/widget.ts @@ -2,7 +2,6 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { calendar } from '@wordpress/icons'; import type { WidgetAttributeField } from '@wordpress/widget-primitives'; /** @@ -12,7 +11,6 @@ import type { EventsWidgetAttributes } from './types'; export default { name: 'core/events', - icon: calendar, attributes: [ { id: 'location', diff --git a/widgets/hello-dolly/widget.json b/widgets/hello-dolly/widget.json index e8a11d60f81c8b..3cd432b6098ab5 100644 --- a/widgets/hello-dolly/widget.json +++ b/widgets/hello-dolly/widget.json @@ -2,6 +2,7 @@ "name": "core/hello-dolly", "title": "Hello Dolly", "description": "This is not just a widget, it symbolizes the hope and enthusiasm of an entire generation summed up in two words sung most famously by Louis Armstrong: Hello, Dolly. When activated you will randomly see a lyric from Hello, Dolly in the dashboard.", + "icon": "core/audio", "category": "dashboard", "presentation": "full-bleed", "actions": [ diff --git a/widgets/hello-dolly/widget.ts b/widgets/hello-dolly/widget.ts index 3d11ac3807f4c5..492b05c064c5b8 100644 --- a/widgets/hello-dolly/widget.ts +++ b/widgets/hello-dolly/widget.ts @@ -1,12 +1,6 @@ -/** - * WordPress dependencies - */ -import { audio } from '@wordpress/icons'; - -/** +/* * Widget type definition */ export default { name: 'core/hello-dolly', - icon: audio, }; diff --git a/widgets/hello-world/widget.json b/widgets/hello-world/widget.json index 81993cc158612a..43474a74a8871b 100644 --- a/widgets/hello-world/widget.json +++ b/widgets/hello-world/widget.json @@ -2,6 +2,7 @@ "name": "core/hello-world", "title": "Hello World", "description": "A minimal example widget.", + "icon": "dashboard-widgets/wordpress", "category": "demo", "presentation": "full-bleed", "textdomain": "default" diff --git a/widgets/hello-world/widget.ts b/widgets/hello-world/widget.ts index 93e207e4bde98e..953e4c0bd13f61 100644 --- a/widgets/hello-world/widget.ts +++ b/widgets/hello-world/widget.ts @@ -1,7 +1,6 @@ /** * WordPress dependencies */ -import { wordpress } from '@wordpress/icons'; import type { WidgetAttributeField } from '@wordpress/widget-primitives'; type HelloWorldWidgetAttributes = { @@ -13,7 +12,6 @@ type HelloWorldWidgetAttributes = { */ export default { name: 'core/hello-world', - icon: wordpress, attributes: [ { id: 'message', diff --git a/widgets/quick-draft/widget.json b/widgets/quick-draft/widget.json index 8e7f923df2bb30..4b112453f9a753 100644 --- a/widgets/quick-draft/widget.json +++ b/widgets/quick-draft/widget.json @@ -5,6 +5,7 @@ "help": { "content": "Drafts saved here are private until you publish them." }, + "icon": "dashboard-widgets/drafts", "category": "dashboard", "presentation": "content-bleed", "textdomain": "default" diff --git a/widgets/quick-draft/widget.ts b/widgets/quick-draft/widget.ts index 0eccea109c2323..e10ca465de256f 100644 --- a/widgets/quick-draft/widget.ts +++ b/widgets/quick-draft/widget.ts @@ -1,12 +1,6 @@ -/** - * WordPress dependencies - */ -import { drafts } from '@wordpress/icons'; - /* * Widget type definition */ export default { name: 'core/quick-draft', - icon: drafts, }; diff --git a/widgets/site-health/widget.json b/widgets/site-health/widget.json index 9146dba6b7e206..123582abddcba6 100644 --- a/widgets/site-health/widget.json +++ b/widgets/site-health/widget.json @@ -6,6 +6,7 @@ "content": "Results come from your site's latest health checks.", "links": [ { "label": "Visit Site Health", "href": "site-health.php" } ] }, + "icon": "core/shield", "category": "site", "presentation": "framed", "textdomain": "default" diff --git a/widgets/site-health/widget.ts b/widgets/site-health/widget.ts index 1ccfddbcf3c92a..1179243f422cb2 100644 --- a/widgets/site-health/widget.ts +++ b/widgets/site-health/widget.ts @@ -1,9 +1,3 @@ -/** - * WordPress dependencies - */ -import { shield } from '@wordpress/icons'; - export default { name: 'core/site-health', - icon: shield, }; diff --git a/widgets/site-preview/widget.json b/widgets/site-preview/widget.json index 046a798fc1a94c..b7e08e6a80df72 100644 --- a/widgets/site-preview/widget.json +++ b/widgets/site-preview/widget.json @@ -2,6 +2,7 @@ "name": "core/site-preview", "title": "Site preview", "description": "Shows preview of the site homepage.", + "icon": "dashboard-widgets/site-logo", "category": "dashboard", "presentation": "full-bleed", "textdomain": "default" diff --git a/widgets/site-preview/widget.ts b/widgets/site-preview/widget.ts index a78c424f5c3d1e..bcbc7bd3595fb2 100644 --- a/widgets/site-preview/widget.ts +++ b/widgets/site-preview/widget.ts @@ -1,9 +1,3 @@ -/** - * WordPress dependencies - */ -import { siteLogo } from '@wordpress/icons'; - export default { name: 'core/site-preview', - icon: siteLogo, }; diff --git a/widgets/welcome/widget.json b/widgets/welcome/widget.json index a6500a4bc50caa..15310c2d5d74fc 100644 --- a/widgets/welcome/widget.json +++ b/widgets/welcome/widget.json @@ -2,6 +2,7 @@ "name": "core/welcome", "title": "Welcome", "description": "Displays a welcome panel to introduce users to WordPress.", + "icon": "dashboard-widgets/wordpress", "category": "dashboard", "presentation": "full-bleed", "textdomain": "default" diff --git a/widgets/welcome/widget.ts b/widgets/welcome/widget.ts index ffa403078b06fd..246160c2ddf573 100644 --- a/widgets/welcome/widget.ts +++ b/widgets/welcome/widget.ts @@ -1,8 +1,3 @@ -const widget = { - apiVersion: 1, +export default { name: 'core/welcome', - icon: 'wordpress', - category: 'dashboard', }; - -export default widget;