Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
97044d9
add icon to widget build manifest
retrofox Jul 30, 2026
2394c05
add icon to widget type registry and REST
retrofox Jul 30, 2026
a0d796b
add icon resolver registry to widget primitives
retrofox Jul 30, 2026
a0aff5f
register dashboard icon resolver
retrofox Jul 30, 2026
cb554ef
register widget-owned icons server-side
retrofox Jul 30, 2026
ad4d4fc
move widget icons to widget.json
retrofox Jul 30, 2026
2f82ef1
remove dead metadata from welcome widget module
retrofox Jul 30, 2026
54c93e2
document icon resolution in widget-primitives
retrofox Jul 30, 2026
0685da1
shorten widget icon comments
retrofox Jul 30, 2026
157154b
update docs for declarative icon references
retrofox Jul 30, 2026
f7f559b
add icon reference story
retrofox Jul 30, 2026
19d7eef
link changelog entries to the PR
retrofox Jul 30, 2026
cb0f68a
Merge remote-tracking branch 'origin/trunk' into update/widget-declar…
retrofox Jul 30, 2026
8f32383
update package lock file
retrofox Jul 30, 2026
9cf4c2c
Merge branch 'trunk' into update/widget-declarative-icon
retrofox Jul 30, 2026
a4a5e35
narrow metadata icon back to an element
retrofox Jul 31, 2026
2f7e628
resolve icons off the loading gate
retrofox Jul 31, 2026
ef50796
restore currentColor on resolved icons
retrofox Jul 31, 2026
bb38b38
Merge remote-tracking branch 'origin/trunk' into update/widget-declar…
retrofox Jul 31, 2026
f9022d7
Merge remote-tracking branch 'origin/trunk' into update/widget-declar…
retrofox Jul 31, 2026
4ae8c4a
hold the icon slot while references resolve
retrofox Jul 31, 2026
fd1e5c4
register the icon resolver at dashboard init
retrofox Jul 31, 2026
44a41d4
Merge remote-tracking branch 'origin/trunk' into update/widget-declar…
retrofox Jul 31, 2026
f8a7e43
udpate pnpm lock file
retrofox Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions docs/explanations/architecture/dashboard-widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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`:

Expand All @@ -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`.
Expand All @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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' ),
Expand Down
10 changes: 10 additions & 0 deletions lib/experimental/dashboard-widgets/class-wp-widget-type.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions lib/experimental/dashboard-widgets/widget-icons.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php
/**
* Widget-owned icons.
*
* Icons the widgets reference declaratively but that are not public in
* the `core` collection, registered under a collection the widgets own.
*
* @package gutenberg
*/

/**
* Registers the dashboard widgets icon collection and its icons.
*
* Content is sourced from the `@wordpress/icons` library files.
*/
function gutenberg_register_dashboard_widget_icons() {
if ( ! function_exists( 'wp_register_icon_collection' ) ) {
return;
}

// Register widget-dashboard icon collection
wp_register_icon_collection(
'dashboard-widgets',
array(
'label' => __( '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' );
21 changes: 21 additions & 0 deletions lib/experimental/dashboard-widgets/widget-types.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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'] ?? ''
Expand Down
1 change: 1 addition & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
6 changes: 5 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/dashboard-init/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 5 additions & 1 deletion packages/dashboard-init/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
55 changes: 55 additions & 0 deletions packages/dashboard-init/src/icons/index.ts
Original file line number Diff line number Diff line change
@@ -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 `<svg>` makes `parse()` return an
* array; take the element.
*/
const parsed = parse( safeHTML( record.content.trim() ) );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found that <svg> element now doesn't have fill="currentColor" anymore. It's near-invisible on white (#1e1e1e vs#000000), but I believe the icons have stopped responding to the token entirely.

I think the underlying fix is to allow fill on <svg>.

Before:

Image

After:

Image

@retrofox retrofox Jul 31, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch.

The sanitizer only allows fill on path/polygon, and on WP >= 7.0 it's Core's copy (class_exists guard), so allowing it on <svg> takes a Core patch; worth raising with #75715.

Meanwhile, the resolver restores it client-side: parsed roots without fill are cloned with fill="currentColor" (ef50796).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#75715 (comment) and #75715 (comment) for more context

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 `<svg>`;
* restore inheritance so icons follow the surrounding color.
*/
return element.props.fill
? element
: cloneElement( element, { fill: 'currentColor' } );
} );
}
13 changes: 10 additions & 3 deletions packages/dashboard-init/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'.
*/
Expand All @@ -17,6 +22,8 @@ export async function init() {
return;
}

registerDashboardIconResolver();

dispatch( coreStore ).addEntities( [
{
name: 'widgetModule',
Expand Down
8 changes: 7 additions & 1 deletion packages/widget-primitives/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)).
Expand Down
6 changes: 5 additions & 1 deletion packages/widget-primitives/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 >`

Expand All @@ -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
Expand Down
Loading
Loading