diff --git a/docs/explanations/architecture/dashboard-widgets.md b/docs/explanations/architecture/dashboard-widgets.md
index 59e58d110fc03c..480479b49fdaed 100644
--- a/docs/explanations/architecture/dashboard-widgets.md
+++ b/docs/explanations/architecture/dashboard-widgets.md
@@ -22,7 +22,7 @@ A widget is a directory under `widgets/`, discovered by convention; there is no
```
widgets/hello-world/
-├── widget.json static metadata (name, title, description, help, keywords, category, presentation, textdomain)
+├── widget.json static metadata (name, title, description, help, actions, keywords, category, presentation, textdomain)
├── widget.ts metadata module: default-exports icon, attributes, example
├── render.tsx render module: default-exports the React component
└── style.module.css optional, injected at runtime by the build
@@ -32,6 +32,8 @@ The split between `widget.json` and `widget.ts` is deliberate. `widget.json` is
Unlike the other translatable strings, `help` is an object: `content` plus optional `links`, meant for compact surfaces such as tooltips.
+`actions` is a list of the declarative actions a widget exposes: each carries `id`, `label`, `href`, and optional `download` / `openInNewTab`, with the label translated. A host renders them as links and decides where; the dashboard surfaces them in a "More" menu. Because navigation and download are the browser's, the widget declares an intent and a target without knowing its surface.
+
`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`.
Its render component receives the widget's `attributes` and, optionally, `setAttributes`:
@@ -51,13 +53,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`, 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`, 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, 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, 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`.
@@ -68,7 +70,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`, and `keywords`, all sourced from `widget.json` (with `title`, `description`, `help`, 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 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 053a22b121d2a0..46deb1ac6d8ccd 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( 'actions', $fields ) ) {
+ $data['actions'] = $widget_type->actions;
+ }
+
if ( rest_is_field_included( 'keywords', $fields ) ) {
$data['keywords'] = $widget_type->keywords;
}
@@ -311,6 +315,23 @@ public function get_item_schema() {
'readonly' => true,
),
+ 'actions' => array(
+ 'description' => __( 'Declarative actions the widget type exposes. Labels are translatable.', 'gutenberg' ),
+ 'type' => array( 'array', 'null' ),
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'string' ),
+ 'label' => array( 'type' => 'string' ),
+ 'href' => array( 'type' => 'string' ),
+ 'download' => array( 'type' => array( 'string', 'boolean' ) ),
+ 'openInNewTab' => array( 'type' => 'boolean' ),
+ ),
+ ),
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+
'keywords' => array(
'description' => __( 'Alternative terms used to match the widget type when searching, e.g. "calendar" for an events widget. 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 046abf6fa4b4f7..d46ad31a740e3f 100644
--- a/lib/experimental/dashboard-widgets/class-wp-widget-type.php
+++ b/lib/experimental/dashboard-widgets/class-wp-widget-type.php
@@ -107,6 +107,17 @@ class WP_Widget_Type {
*/
public $help = null;
+ /**
+ * Declarative actions the widget exposes. Each entry carries `id`,
+ * `label`, `href`, and optional `download`/`openInNewTab`. Labels are
+ * translated at registration time using the widget's text domain.
+ *
+ * Null when the widget did not declare the field.
+ *
+ * @var array|null
+ */
+ public $actions = null;
+
/**
* Alternative terms used to match the widget type when searching,
* e.g. "calendar" for an events widget. Translated at registration
diff --git a/lib/experimental/dashboard-widgets/widget-i18n.json b/lib/experimental/dashboard-widgets/widget-i18n.json
index 859fa3ef5dc81d..a5ef9b522f6a5d 100644
--- a/lib/experimental/dashboard-widgets/widget-i18n.json
+++ b/lib/experimental/dashboard-widgets/widget-i18n.json
@@ -5,5 +5,6 @@
"content": "widget help content",
"links": [ { "label": "widget help link label" } ]
},
+ "actions": [ { "label": "widget action label" } ],
"keywords": [ "widget keyword" ]
}
diff --git a/lib/experimental/dashboard-widgets/widget-types.php b/lib/experimental/dashboard-widgets/widget-types.php
index 31e6673d9bc096..223091d83b69bc 100644
--- a/lib/experimental/dashboard-widgets/widget-types.php
+++ b/lib/experimental/dashboard-widgets/widget-types.php
@@ -40,8 +40,8 @@ function gutenberg_get_widget_metadata_i18n_schema() {
/**
* Translates a widget's user-facing metadata strings.
*
- * Runs `title`, `description`, `help`, and `keywords` through the widget
- * i18n schema using the widget's `textdomain`, leaving every other key
+ * Runs `title`, `description`, `help`, `actions`, and `keywords` through the
+ * widget i18n schema using the widget's `textdomain`, leaving every other key
* untouched. A no-op when the widget declares no `textdomain`.
*
* @param array $widget Widget data from the build manifest.
@@ -55,7 +55,7 @@ function gutenberg_translate_widget_metadata( $widget ) {
$i18n_schema = gutenberg_get_widget_metadata_i18n_schema();
- foreach ( array( 'title', 'description', 'help', 'keywords' ) as $field ) {
+ foreach ( array( 'title', 'description', 'help', 'actions', 'keywords' ) as $field ) {
if ( isset( $widget[ $field ], $i18n_schema->$field ) ) {
$widget[ $field ] = translate_settings_using_i18n_schema( $i18n_schema->$field, $widget[ $field ], $textdomain );
}
@@ -106,6 +106,52 @@ function gutenberg_sanitize_widget_help( $help ) {
return $sanitized;
}
+/**
+ * Constrains a widget's actions to their allowed shape: each entry keeps
+ * `id`, `label`, and `href`; entries missing any of those are dropped.
+ * Optional `download` and `openInNewTab` are copied when present.
+ *
+ * @param array|null $actions Actions from the build manifest.
+ * @return array|null Sanitized actions, or null when there are none.
+ */
+function gutenberg_sanitize_widget_actions( $actions ) {
+ if ( ! is_array( $actions ) ) {
+ return null;
+ }
+
+ $sanitized = array();
+ foreach ( $actions as $action ) {
+ if (
+ ! is_array( $action ) ||
+ empty( $action['id'] ) ||
+ empty( $action['label'] ) ||
+ empty( $action['href'] )
+ ) {
+ continue;
+ }
+
+ $entry = array(
+ 'id' => $action['id'],
+ 'label' => $action['label'],
+ 'href' => $action['href'],
+ );
+
+ if ( isset( $action['download'] ) ) {
+ $entry['download'] = is_bool( $action['download'] )
+ ? $action['download']
+ : (string) $action['download'];
+ }
+
+ if ( isset( $action['openInNewTab'] ) ) {
+ $entry['openInNewTab'] = (bool) $action['openInNewTab'];
+ }
+
+ $sanitized[] = $entry;
+ }
+
+ return $sanitized ? $sanitized : null;
+}
+
/**
* Hydrates the widget type registry from the build manifest.
*
@@ -139,6 +185,7 @@ function gutenberg_register_widget_types() {
'title' => $widget['title'] ?? null,
'description' => $widget['description'] ?? null,
'help' => gutenberg_sanitize_widget_help( $widget['help'] ?? null ),
+ 'actions' => gutenberg_sanitize_widget_actions( $widget['actions'] ?? null ),
'keywords' => $widget['keywords'] ?? null,
)
);
diff --git a/packages/widget-dashboard/CHANGELOG.md b/packages/widget-dashboard/CHANGELOG.md
index 2cc2bcda6bfede..4c0c86f2f8a581 100644
--- a/packages/widget-dashboard/CHANGELOG.md
+++ b/packages/widget-dashboard/CHANGELOG.md
@@ -2,6 +2,11 @@
## Unreleased
+### New Features
+
+- Surface a widget's declared `actions` in the tile chrome as a "More"
+ menu of links ([#80363](https://github.com/WordPress/gutenberg/pull/80363)).
+
### Enhancements
- Widget settings: use the `drawerRight` icon for the per-tile settings
diff --git a/packages/widget-dashboard/src/components/widget-actions/index.ts b/packages/widget-dashboard/src/components/widget-actions/index.ts
new file mode 100644
index 00000000000000..99b67d2b68855b
--- /dev/null
+++ b/packages/widget-dashboard/src/components/widget-actions/index.ts
@@ -0,0 +1 @@
+export { WidgetActions } from './widget-actions';
diff --git a/packages/widget-dashboard/src/components/widget-actions/widget-actions.module.css b/packages/widget-dashboard/src/components/widget-actions/widget-actions.module.css
new file mode 100644
index 00000000000000..a90dc1c24ba5b0
--- /dev/null
+++ b/packages/widget-dashboard/src/components/widget-actions/widget-actions.module.css
@@ -0,0 +1,3 @@
+.widget-action-items .widget-action-link {
+ display: inline-flex;
+}
diff --git a/packages/widget-dashboard/src/components/widget-actions/widget-actions.tsx b/packages/widget-dashboard/src/components/widget-actions/widget-actions.tsx
new file mode 100644
index 00000000000000..78149eddc055f8
--- /dev/null
+++ b/packages/widget-dashboard/src/components/widget-actions/widget-actions.tsx
@@ -0,0 +1,79 @@
+/**
+ * WordPress dependencies
+ */
+import { privateApis as componentsPrivateApis } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { moreVertical } from '@wordpress/icons';
+// eslint-disable-next-line @wordpress/use-recommended-components
+import { IconButton, Link } from '@wordpress/ui';
+import type { WidgetType } from '@wordpress/widget-primitives';
+
+/**
+ * Internal dependencies
+ */
+import styles from './widget-actions.module.css';
+
+import { unlock } from '../../lock-unlock';
+
+const { Menu } = unlock( componentsPrivateApis );
+
+type WidgetActionsProps = {
+ /**
+ * The widget type whose declared actions render here.
+ */
+ widgetType: WidgetType;
+};
+
+/**
+ * Materializes a widget type's declared `actions` as a "more" menu in the
+ * chrome: a three-dots trigger surfacing each action as a link. Each action
+ * is a declarative link target; the host renders it as an anchor and owns
+ * placement.
+ *
+ * @param {WidgetActionsProps} props Component props.
+ */
+export function WidgetActions( {
+ widgetType,
+}: WidgetActionsProps ): React.ReactNode {
+ const actions = widgetType.actions ?? [];
+
+ if ( actions.length === 0 ) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/widget-dashboard/src/components/widgets/widgets.tsx b/packages/widget-dashboard/src/components/widgets/widgets.tsx
index 9e0aa8c55c109a..01510462d340a3 100644
--- a/packages/widget-dashboard/src/components/widgets/widgets.tsx
+++ b/packages/widget-dashboard/src/components/widgets/widgets.tsx
@@ -21,6 +21,7 @@ import type { WidgetName } from '@wordpress/widget-primitives';
*/
import { useDashboardInternalContext } from '../../context/dashboard-context';
import { useDashboardContainerColumnCount } from '../../hooks/use-dashboard-container-column-count';
+import { WidgetActions } from '../widget-actions';
import { WidgetAttributeControls } from '../widget-attribute-controls';
import { WidgetChrome } from '../widget-chrome';
import { WidgetHeader } from '../widget-header';
@@ -134,21 +135,31 @@ export const Widgets = forwardRef< HTMLDivElement, WidgetsProps >(
( type ) => type.name === widget.type
);
const hasSettings = !! widgetType?.attributes?.length;
+ const hasActions = !! widgetType?.actions?.length;
+
const isFullBleed = widgetType?.presentation === 'full-bleed';
// The active mode's controls: layout while customizing, the
// attribute controls (high-relevance fields on the prominent
- // surface, plus a settings entry point when needed)
- // otherwise.
+ // surface, plus a settings entry point when needed) and the
+ // declared actions otherwise.
let controls: React.ReactNode;
if ( editMode ) {
controls = ;
- } else if ( hasSettings && widgetType ) {
+ } else if ( ( hasSettings || hasActions ) && widgetType ) {
controls = (
-
+ <>
+ { hasSettings && (
+
+ ) }
+
+ { hasActions && (
+
+ ) }
+ >
);
}
diff --git a/packages/widget-dashboard/src/stories/index.story.tsx b/packages/widget-dashboard/src/stories/index.story.tsx
index c65ca83ec05769..8d886290e84d6f 100644
--- a/packages/widget-dashboard/src/stories/index.story.tsx
+++ b/packages/widget-dashboard/src/stories/index.story.tsx
@@ -16,6 +16,7 @@ import { useState } from '@wordpress/element';
import { chartBar, trendingUp } from '@wordpress/icons';
import type {
ResolveWidgetModule,
+ WidgetAction,
WidgetAttributeField,
WidgetRenderProps,
WidgetType,
@@ -139,6 +140,10 @@ const trafficSnapshotWidgetType: WidgetType = {
title: 'Traffic Snapshot',
description:
'Sample metric widget used to exercise the inline attribute controls.',
+ help: {
+ content:
+ 'Three attributes. Metric and Period are high relevance — two fields in the header. Label is low, behind the settings button.',
+ },
icon: chartBar,
renderModule: 'demo/widgets/traffic-snapshot/render',
attributes: SNAPSHOT_FIELDS as WidgetType[ 'attributes' ],
@@ -256,14 +261,35 @@ const GOAL_FIELDS: WidgetAttributeField< GoalAttributes >[] = [
},
];
+// Two declarative actions: an external link and a client-side download.
+const GOAL_ACTIONS: WidgetAction[] = [
+ {
+ id: 'view-goal',
+ label: 'View goal details',
+ href: 'https://wordpress.org/',
+ openInNewTab: true,
+ },
+ {
+ id: 'export-progress',
+ label: 'Export progress',
+ href: 'data:text/csv;charset=utf-8,metric,target,current,percent%0Arevenue,5000,3600,72',
+ download: 'goal-progress.csv',
+ },
+];
+
const goalProgressWidgetType: WidgetType = {
apiVersion: 1,
name: 'demo/goal-progress',
title: 'Goal Progress',
description: 'Sample goal widget whose attributes are all promoted.',
+ help: {
+ content:
+ 'Two attributes, Goal metric and Target, both high relevance — two fields in the header, no settings button. Two actions in the More menu: a link and a download.',
+ },
icon: trendingUp,
renderModule: 'demo/widgets/goal-progress/render',
attributes: GOAL_FIELDS as WidgetType[ 'attributes' ],
+ actions: GOAL_ACTIONS,
example: {
attributes: { metric: 'revenue', target: '5000' },
},
@@ -360,9 +386,13 @@ Two demo types exercise that policy:
Their tiles compare the header presentations:
- The two-column tile fits the identity and both inline controls, so they stay in the header.
-- The one-column tiles do not: the promoted fields collapse into a dropdown holding them as a form, while the settings trigger stays in the toolbar beside it. The goal tile, with every attribute promoted, shows only the dropdown.
+- The one-column tiles do not: the promoted fields collapse into a dropdown holding them as a form, while the settings trigger stays in the toolbar beside it. The goal tile, with every attribute promoted, shows the dropdown next to its actions menu.
The widget only declares relevance; the fit is measured by the chrome, so the same declaration adapts to any tile width. Resize the canvas to watch the headers switch presentations.
+
+Beyond attributes, \`demo/goal-progress\` declares two \`actions\`: a "View goal details" link and an "Export progress" download. The host surfaces them in a "More" menu in the toolbar, so the widget declares each action as data and the host owns where it appears.
+
+Each type also carries a \`help\` note, opened from the info icon in the header, that describes its attributes and what they do.
`,
},
},
diff --git a/packages/widget-primitives/CHANGELOG.md b/packages/widget-primitives/CHANGELOG.md
index 37d1e66e207899..6f7097546f0577 100644
--- a/packages/widget-primitives/CHANGELOG.md
+++ b/packages/widget-primitives/CHANGELOG.md
@@ -2,6 +2,11 @@
## Unreleased
+### New Features
+
+- `WidgetTypeMetadata`: add optional `actions`, a declarative list of
+ user-triggerable links a widget exposes ([#80363](https://github.com/WordPress/gutenberg/pull/80363)).
+
### Enhancements
- Ship the package as a WordPress script module
@@ -9,6 +14,8 @@
### Documentation
+- 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)).
- Spell out the accepted field-type name syntax: lowercase kebab-case
segments, with at most one namespace level ([#80208](https://github.com/WordPress/gutenberg/pull/80208)).
diff --git a/packages/widget-primitives/README.md b/packages/widget-primitives/README.md
index b2070f363c079a..d336329905cf0e 100644
--- a/packages/widget-primitives/README.md
+++ b/packages/widget-primitives/README.md
@@ -43,30 +43,37 @@ the hook in its loading state: `widgetTypes` is empty and
## Public API
-- ``: entry point for any host that mounts a widget. It
- resolves the widget's render module via a host-provided
- `resolveWidgetModule` and mounts the resulting component with the
- `attributes` / `setAttributes` render contract. Error handling and chrome
- stay with the host, which wraps the lazy render in a `Suspense` boundary.
-- `useWidgetTypes( records )` → `[ widgetTypes, isResolvingWidgetTypes ]`:
- takes host-supplied records (`WidgetModuleRecord[]`, or `null` while
- loading) and imports each record's metadata module;
- `isResolvingWidgetTypes` stays `true` until they resolve.
-- Contract types: `WidgetType`, `WidgetName`, `WidgetIcon`,
- `WidgetRenderProps`, `ResolveWidgetModule`, `WidgetModuleRecord`.
- `WidgetIcon` is a rendered SVG element; hosts pass it to their icon
- primitive as is.
-- `WidgetAttributeField< Item >`: authoring helper. It is a DataViews
- `Field` whose `id` is narrowed to the keys of the widget's attribute
- object, with an optional `relevance` hint (`'high' | 'low'`) marking
- attributes a host may promote to a prominent surface.
-- Field types: `registerFieldType( definition )` names a reusable
- field type (`{ name: 'location', baseType: 'text', Edit, ... }`,
- typed by `FieldTypeDefinition`) that attributes can reference via
- `type`. `useWidgetTypes` resolves such references into the plain
- per-field `Field` props DataViews understands, inheriting everything
- else from `baseType`. Names that are not registered degrade exactly
- as unknown types do in DataViews.
+### ``
+
+It's the entry point for any host that mounts a widget. It resolves the render module via a host-provided `resolveWidgetModule` and mounts the component using the `attributes` / `setAttributes` contract.
+
+Error handling and chrome stay with the host, which wraps the lazy render in a `Suspense` boundary.
+
+### `useWidgetTypes( records )`
+
+It takes host-supplied records (`WidgetModuleRecord[]`, or `null` while loading) and imports each one's metadata module. It returns `[ widgetTypes, isResolvingWidgetTypes ]`; the flag stays `true` until they resolve.
+
+### 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.
+
+### `WidgetAttributeField< Item >`
+
+It's an authoring helper: a DataViews `Field` whose `id` is narrowed to the widget's attribute keys.
+Its optional `relevance` hint (`'high' | 'low'`) marks attributes a host may promote to a prominent surface.
+
+### `WidgetAction`
+
+It's a declarative action a widget type exposes.
+Each carries `id`, `label`, `href`, and optional `download` / `openInNewTab`.
+
+The widget names the intent and a link target; the host renders it as an anchor and owns placement. Only the `link` target exists today, and navigation and downloads are handled by the browser.
+
+### Field types
+
+`registerFieldType( definition )` names a reusable field type — `{ name: 'location', baseType: 'text', Edit, ... }`, typed by `FieldTypeDefinition`. Those attributes reference via `type`.
+
+`useWidgetTypes` resolves those references into the plain per-field `Field` props DataViews understands, inheriting the rest from `baseType`.
## Architecture
@@ -76,15 +83,8 @@ hosts), see the
## Contributing to this package
-This is an individual package that's part of the Gutenberg project.
-The project is organized as a monorepo. It's made up of multiple
-self-contained software packages, each with a specific purpose. The
-packages in this monorepo are published to [npm](https://www.npmjs.com/)
-and used by [WordPress](https://make.wordpress.org/core/) as well as
-other software projects.
+This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects.
-To find out more about contributing to this package or Gutenberg as a
-whole, please read the project's main
-[contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md).
+To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md).
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 b1bce0bf944275..71ef6d1691ec3c 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
@@ -18,7 +18,7 @@ import { Suspense, 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 */
-import { Card, Icon, IconButton, Stack } from '@wordpress/ui';
+import { Card, Icon, IconButton, Link, Stack } from '@wordpress/ui';
/* eslint-enable @wordpress/use-recommended-components */
/**
@@ -27,6 +27,7 @@ import { Card, Icon, IconButton, Stack } from '@wordpress/ui';
import { WidgetRender } from '..';
import { registerFieldType, resolveFields } from '../../../field-types';
import type {
+ WidgetAction,
WidgetAttributeField,
WidgetRenderProps,
WidgetType,
@@ -747,3 +748,97 @@ An unregistered name would degrade silently, exactly like an unknown type in Dat
},
},
};
+
+const actionsWidgetType: WidgetType< DemoAttributes > = {
+ ...demoWidgetType,
+ actions: [
+ {
+ id: 'open-docs',
+ label: 'Open docs',
+ href: 'https://developer.wordpress.org/',
+ openInNewTab: true,
+ },
+ {
+ id: 'export-greeting',
+ label: 'Export greeting',
+ href: 'data:text/plain;charset=utf-8,Hello%20World',
+ download: 'greeting.txt',
+ },
+ ] satisfies WidgetAction[],
+};
+
+function WidgetWithActions() {
+ const titleId = useId();
+ const actions = actionsWidgetType.actions ?? [];
+ const [ attributes ] = useState< DemoAttributes >( {
+ ...actionsWidgetType.example?.attributes,
+ } );
+
+ return (
+
+ );
+}
+
+export const WithActions: StoryObj = {
+ render: () => ,
+ parameters: {
+ docs: {
+ description: {
+ story: `
+Beyond its data, a widget can declare \`actions\`: verbs a user can trigger, like opening docs or downloading a file. The widget names each action (\`id\`, \`label\`, and a link \`href\`); the host decides where to put it and materializes it.
+
+**In this demo**
+
+- The widget declares two actions: an external link (\`Open docs\`) and a download (\`Export greeting\`).
+- The host renders them as links beside the title. Another host could surface them in a menu, a footer, or a command palette.
+
+**Takeaway**
+
+The widget names the intent and a link target; navigation and download are the browser's, so the widget never knows its surface.
+`,
+ },
+ },
+ },
+};
diff --git a/packages/widget-primitives/src/hooks/use-widget-types.ts b/packages/widget-primitives/src/hooks/use-widget-types.ts
index d687218c49f9a1..dcdd0a7f03ed73 100644
--- a/packages/widget-primitives/src/hooks/use-widget-types.ts
+++ b/packages/widget-primitives/src/hooks/use-widget-types.ts
@@ -94,6 +94,9 @@ export function useWidgetTypes(
...( record.keywords
? { keywords: record.keywords }
: {} ),
+ ...( record.actions
+ ? { actions: record.actions }
+ : {} ),
} as WidgetType;
} catch {
return null;
diff --git a/packages/widget-primitives/src/index.ts b/packages/widget-primitives/src/index.ts
index c1fcff43675ea1..e7e42bbcf56414 100644
--- a/packages/widget-primitives/src/index.ts
+++ b/packages/widget-primitives/src/index.ts
@@ -20,6 +20,7 @@ export type {
WidgetName,
WidgetIcon,
WidgetType,
+ WidgetAction,
WidgetAttributeField,
WidgetRenderProps,
ResolveWidgetModule,
diff --git a/packages/widget-primitives/src/stories/actions.md b/packages/widget-primitives/src/stories/actions.md
new file mode 100644
index 00000000000000..d89454b80e116f
--- /dev/null
+++ b/packages/widget-primitives/src/stories/actions.md
@@ -0,0 +1,44 @@
+# Actions
+
+
+This package is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
+
+
+A widget declares its data through `attributes`; it declares the verbs a user can trigger through `actions`.
+
+An action is something to _do_: open a report, download a file, etc. Like other widgets' props, it's declarative and serializable, so it can ride in `widget.json`.
+
+The widget names the intent and a target; the host decides where the action appears and how it materializes.
+
+## Envelope and fulfillment
+
+Every action carries an **envelope**: an `id` and a `label`, and one **fulfillment** that says what it triggers.
+Today, the only fulfillment is a `link`: a target the host renders as an anchor.
+
+```ts
+{
+ id: 'view-report',
+ label: __( 'View report' ),
+ href: 'admin.php?page=reports',
+}
+```
+
+`href` is a complete URL or path; external URLs and admin entry points load a full page. Two optional flags refine it:
+
+- `download`: turns the target into a file download; a string names the file.
+- `openInNewTab`: opens the target in a new browser tab.
+
+```ts
+{
+ id: 'export',
+ label: __( 'Export CSV' ),
+ href: reportCsvUrl,
+ download: 'report.csv',
+}
+```
+
+## Placement is the host's
+
+The widget lists its actions; it never specifies where they go. The host maps them to its surfaces: a dashboard might gather them in a "More" menu, a footer, or a command palette.
+
+This is the contract that the `relevance` attribute already uses: the widget declares intent, and the host owns the surface.
\ No newline at end of file
diff --git a/packages/widget-primitives/src/stories/actions.mdx b/packages/widget-primitives/src/stories/actions.mdx
new file mode 100644
index 00000000000000..a6af89fe3c66b4
--- /dev/null
+++ b/packages/widget-primitives/src/stories/actions.mdx
@@ -0,0 +1,6 @@
+import { Meta, Markdown } from '@storybook/addon-docs/blocks';
+import ActionsDoc from './actions.md?raw';
+
+
+
+{ ActionsDoc }
diff --git a/packages/widget-primitives/src/stories/anatomy.md b/packages/widget-primitives/src/stories/anatomy.md
index 41056bfcceaf46..152bcdcb4a6bbe 100644
--- a/packages/widget-primitives/src/stories/anatomy.md
+++ b/packages/widget-primitives/src/stories/anatomy.md
@@ -41,14 +41,14 @@ Framing is the layer the host translates, and the one place where the same decla
## Representation
-How the widget represents its data: `attributes`, `example`, and the render module.
+How the widget represents its data and the verbs it exposes: `attributes`, `actions`, `example`, and the render module.
The `attributes` are the contract between host and widget. The widget owns their shape and meaning; the host owns their values.
Each entry in `attributes` is a DataViews `Field` plus an optional `relevance` hint (`'high' | 'low'`). Its `type` may also reference a registered field type by name, resolved to plain `Field` props before any host reads the schema (see **Field Types**). The widget declares how important an attribute is; the host decides where, and whether, to expose it. When the hint is absent, treat it as `'low'`.
-- `'high'`: the host _may_ promote the field to a prominent surface near the instance, for quick in-context editing without opening a separate settings UI.
-- `'low'` (default): the field belongs in the host's settings surface, a form built from the full schema.
+- `'high'`: the host _may_ promote the field to a prominent surface near the instance, for quick in-context editing without opening a separate settings UI.
+- `'low'` (default): the field belongs in the host's settings surface, a form built from the full schema.
The widget names importance, not a surface. One host might promote `'high'` fields inline; another might fold everything into a single panel. Both honor the same contract.
@@ -60,6 +60,10 @@ Either way the host never interprets the values. It mounts the form from the dec

+Alongside its data, a widget can declare `actions`: the verbs a user triggers, such as opening a report or downloading a file. Each action names an `id`, a `label`, and a link target (`href`, with optional `download` / `openInNewTab`).
+
+The pattern matches attributes: the widget declares the intent, and the host decides the surface. It never says "put this in a toolbar"; it names the action, and the host materializes the affordance and places it. Today the only target is a link, so navigation and download are the browser's, and the host renders the anchor without interpreting what the action means.
+
## Why the split matters
Each layer is consumed by one verb: identity is _copied_, framing is _translated_, representation is _rendered_. Each verb is a boundary of ownership.
diff --git a/packages/widget-primitives/src/types.ts b/packages/widget-primitives/src/types.ts
index 6e57edd915a7d5..7bb31f04a84225 100644
--- a/packages/widget-primitives/src/types.ts
+++ b/packages/widget-primitives/src/types.ts
@@ -70,6 +70,41 @@ export interface WidgetHelp {
*/
type WidgetAttributeRelevance = 'high' | 'low';
+/**
+ * A user-triggerable action a widget type declares. The declaration is
+ * serializable data; the host materializes it as an affordance and owns
+ * placement. The action points at a `link` target the host renders as an
+ * anchor; `download` turns it into a file download.
+ */
+export interface WidgetAction {
+ /**
+ * Stable identifier, local to the widget type.
+ */
+ id: string;
+
+ /**
+ * Human-readable label naming the action. Translatable.
+ */
+ label: string;
+
+ /**
+ * Destination the action points at. External URLs and admin PHP entry
+ * points load a full page.
+ */
+ href: string;
+
+ /**
+ * When set, the browser downloads the destination instead of navigating.
+ * A string supplies the suggested filename.
+ */
+ download?: string | boolean;
+
+ /**
+ * Whether the destination opens in a new browser tab.
+ */
+ openInNewTab?: boolean;
+}
+
/**
* A DataViews `Field` plus the widget-layer `relevance` hint; what hosts
* read. Its `type` may also reference a registered field type by name
@@ -177,6 +212,12 @@ export interface WidgetTypeMetadata< Item = unknown > {
*/
attributes?: WidgetAttribute< Item >[];
+ /**
+ * Declarative actions the widget type exposes. Hosts materialize each
+ * one as an affordance and decide where to place it.
+ */
+ actions?: WidgetAction[];
+
/**
* Structured example data hosts use for previews, and the default
* attributes applied when a new instance is created without initial
@@ -253,6 +294,7 @@ type WidgetModuleRecordOverrides = {
| 'category'
| 'presentation'
| 'keywords'
+ | 'actions'
> ]?: WidgetTypeMetadata[ K ] | null;
};
diff --git a/packages/wp-build/CHANGELOG.md b/packages/wp-build/CHANGELOG.md
index b2b1a86856ea91..4f20b0573024d7 100644
--- a/packages/wp-build/CHANGELOG.md
+++ b/packages/wp-build/CHANGELOG.md
@@ -2,6 +2,11 @@
## Unreleased
+### Enhancements
+
+- Widgets: carry a widget's declarative `actions` from `widget.json` into
+ the generated PHP registry ([#80363](https://github.com/WordPress/gutenberg/pull/80363)).
+
## 0.19.0 (2026-07-14)
### Enhancements
diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs
index f079b9712712f0..780d4352df0de1 100755
--- a/packages/wp-build/lib/build.mjs
+++ b/packages/wp-build/lib/build.mjs
@@ -2016,7 +2016,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, 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, 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 ) => {
@@ -2038,6 +2038,7 @@ function collectWidgets() {
title: metadata.title ?? null,
description: metadata.description ?? null,
help: metadata.help ?? null,
+ actions: metadata.actions ?? null,
hasRender: widgetFiles.hasRender,
hasWidget: widgetFiles.hasWidget,
presentation: metadata.presentation ?? null,
@@ -2114,6 +2115,60 @@ function toPhpHelpLiteral( help ) {
return `array( ${ parts.join( ', ' ) } )`;
}
+/**
+ * Format a widget's actions as a PHP array literal. Returns the PHP literal
+ * `null` when there are no valid actions; entries missing `id`, `label`, or
+ * `href` are dropped.
+ *
+ * @param {import('./widget-utils.mjs').WidgetActionMetadata[]|null|undefined} actions Source value.
+ * @return {string} PHP array literal, or `null`.
+ */
+function toPhpActionsLiteral( actions ) {
+ if ( ! Array.isArray( actions ) ) {
+ return 'null';
+ }
+
+ const entries = actions
+ .filter(
+ ( action ) => action && action.id && action.label && action.href
+ )
+ .map( ( action ) => {
+ const parts = [
+ `'id' => ${ toPhpStringLiteral( action.id ) }`,
+ `'label' => ${ toPhpStringLiteral( action.label ) }`,
+ `'href' => ${ toPhpStringLiteral( action.href ) }`,
+ ];
+
+ if ( action.download !== undefined ) {
+ parts.push(
+ typeof action.download === 'boolean'
+ ? `'download' => ${
+ action.download ? 'true' : 'false'
+ }`
+ : `'download' => ${ toPhpStringLiteral(
+ action.download
+ ) }`
+ );
+ }
+
+ if ( action.openInNewTab !== undefined ) {
+ parts.push(
+ `'openInNewTab' => ${
+ action.openInNewTab ? 'true' : 'false'
+ }`
+ );
+ }
+
+ return `array( ${ parts.join( ', ' ) } )`;
+ } );
+
+ if ( entries.length === 0 ) {
+ return 'null';
+ }
+
+ return `array( ${ entries.join( ', ' ) } )`;
+}
+
/**
* Generate global widget registry file.
* Creates a single registry with all widgets including file availability.
@@ -2139,6 +2194,7 @@ async function generateWidgetRegistry( widgets, replacements ) {
const titleStr = toPhpStringLiteral( widget.title );
const descriptionStr = toPhpStringLiteral( widget.description );
const helpStr = toPhpHelpLiteral( widget.help );
+ const actionsStr = toPhpActionsLiteral( widget.actions );
const keywordsStr = toPhpStringArrayLiteral( widget.keywords );
const textdomainStr = toPhpStringLiteral( widget.textdomain );
return `\tarray(
@@ -2147,6 +2203,7 @@ async function generateWidgetRegistry( widgets, replacements ) {
'title' => ${ titleStr },
'description' => ${ descriptionStr },
'help' => ${ helpStr },
+ 'actions' => ${ actionsStr },
'has_render' => ${ hasRenderStr },
'has_widget' => ${ hasWidgetStr },
'presentation' => ${ presentationStr },
diff --git a/packages/wp-build/lib/widget-utils.mjs b/packages/wp-build/lib/widget-utils.mjs
index d22668c8508d5c..f67814b3f42ab3 100644
--- a/packages/wp-build/lib/widget-utils.mjs
+++ b/packages/wp-build/lib/widget-utils.mjs
@@ -35,12 +35,22 @@ export function getAllWidgets( rootDir ) {
* @property {WidgetHelpLinkMetadata[]} [links] Links contextual to the note.
*/
+/**
+ * @typedef {Object} WidgetActionMetadata
+ * @property {string} id Stable identifier, local to the widget type.
+ * @property {string} label Human-readable label. Translatable.
+ * @property {string} href Destination the action points at.
+ * @property {string|boolean} [download] Download the destination; a string sets the filename.
+ * @property {boolean} [openInNewTab] Open the destination in a new browser tab.
+ */
+
/**
* @typedef {Object} WidgetMetadata
* @property {string} name Widget namespaced identifier.
* @property {string} [title] Human-readable title.
* @property {string} [description] Short description.
* @property {WidgetHelpMetadata} [help] Contextual help note for compact surfaces.
+ * @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.
* @property {string[]} [keywords] Search aliases used to match the widget.
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 3a15c174742054..b431a25ad56304 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,13 @@ private function register_sample_widgets() {
),
),
),
+ 'actions' => array(
+ array(
+ 'id' => 'open-settings',
+ 'label' => 'Open settings',
+ 'href' => 'options-general.php',
+ ),
+ ),
'keywords' => array( 'alpha', 'first' ),
)
);
@@ -152,6 +159,16 @@ public function test_get_item_returns_widget_by_name() {
),
$data['help']
);
+ $this->assertSame(
+ array(
+ array(
+ 'id' => 'open-settings',
+ 'label' => 'Open settings',
+ 'href' => 'options-general.php',
+ ),
+ ),
+ $data['actions']
+ );
$this->assertSame( array( 'alpha', 'first' ), $data['keywords'] );
}
@@ -202,6 +219,7 @@ public function test_schema_exposes_expected_properties() {
$this->assertArrayHasKey( 'title', $properties );
$this->assertArrayHasKey( 'description', $properties );
$this->assertArrayHasKey( 'help', $properties );
+ $this->assertArrayHasKey( 'actions', $properties );
$this->assertArrayHasKey( 'keywords', $properties );
$this->assertSame( 'string', $properties['name']['type'] );
$this->assertSame( array( 'string', 'null' ), $properties['render_module']['type'] );
@@ -210,6 +228,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( 'array', 'null' ), $properties['actions']['type'] );
$this->assertSame( array( 'array', 'null' ), $properties['keywords']['type'] );
}
}
diff --git a/widgets/hello-dolly/widget.json b/widgets/hello-dolly/widget.json
index eae8bc7586e96a..4a63da4eec7209 100644
--- a/widgets/hello-dolly/widget.json
+++ b/widgets/hello-dolly/widget.json
@@ -4,5 +4,19 @@
"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.",
"category": "dashboard",
"presentation": "full-bleed",
+ "actions": [
+ {
+ "id": "plugin-page",
+ "label": "WordPress.org plugin page",
+ "href": "https://wordpress.org/plugins/hello-dolly/",
+ "openInNewTab": true
+ },
+ {
+ "id": "download-lyrics",
+ "label": "Download lyrics",
+ "href": "data:text/plain;charset=utf-8,Hello%2C%20Dolly%0AWell%2C%20hello%2C%20Dolly%0AIt's%20so%20nice%20to%20have%20you%20back%20where%20you%20belong%0AYou're%20lookin'%20swell%2C%20Dolly%0AI%20can%20tell%2C%20Dolly%0AYou're%20still%20glowin'%2C%20you're%20still%20crowin'%0AYou're%20still%20goin'%20strong%0AI%20feel%20the%20room%20swayin'%0AWhile%20the%20band's%20playin'%0AOne%20of%20our%20old%20favorite%20songs%20from%20way%20back%20when%0ASo%2C%20take%20her%20wrap%2C%20fellas%0ADolly%2C%20never%20go%20away%20again%0AHello%2C%20Dolly%0AWell%2C%20hello%2C%20Dolly%0AIt's%20so%20nice%20to%20have%20you%20back%20where%20you%20belong%0AYou're%20lookin'%20swell%2C%20Dolly%0AI%20can%20tell%2C%20Dolly%0AYou're%20still%20glowin'%2C%20you're%20still%20crowin'%0AYou're%20still%20goin'%20strong%0AI%20feel%20the%20room%20swayin'%0AWhile%20the%20band's%20playin'%0AOne%20of%20our%20old%20favorite%20songs%20from%20way%20back%20when%0ASo%2C%20golly%2C%20gee%2C%20fellas%0AHave%20a%20little%20faith%20in%20me%2C%20fellas%0ADolly%2C%20never%20go%20away%0APromise%2C%20you'll%20never%20go%20away%0ADolly'll%20never%20go%20away%20again",
+ "download": "hello-dolly-lyrics.txt"
+ }
+ ],
"textdomain": "default"
}