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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions package-lock.json

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

5 changes: 5 additions & 0 deletions packages/widget-primitives/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

### New Features

- `WidgetAttributeField< Item >`: authoring helper narrowing a DataViews
`Field.id` to the widget's attribute keys.

## 0.1.0 (2026-06-24)

### New Features
Expand Down
19 changes: 12 additions & 7 deletions packages/widget-primitives/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,18 @@ endpoint, exposed while the `gutenberg-dashboard-widgets` experiment is
enabled. The dashboard reads it through a `@wordpress/core-data` entity and
passes the records to the hook.

With no records, or an empty list, `useWidgetTypes()` returns an empty list.
An empty list of records resolves to an empty `widgetTypes` with
`isResolvingWidgetTypes` set to `false`. Passing `null` (or `undefined`) keeps
the hook in its loading state: `widgetTypes` is empty and
`isResolvingWidgetTypes` stays `true`.

## Public API

- `<WidgetRender>`: canonical entry point for any host that mounts a widget.
Resolves the widget's render module via a host-provided `resolveWidgetModule`
and mounts the resulting component with the standard `attributes` plus
`setAttributes` render contract. Error handling and chrome are host
concerns; because the component is mounted lazily, the host must also
wrap it in a `Suspense` boundary.
- `<WidgetRender>`: 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;
Expand All @@ -54,6 +56,9 @@ With no records, or an empty list, `useWidgetTypes()` returns an empty list.
`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.

## Architecture

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ export const WithSettings: StoryObj = {
docs: {
description: {
story: `
Where Default lets the widget ask for changes, here the host edits the values itself. A widget type declares its settings as a dataviews \`Field[]\` under \`attributes\`, and that single declaration is enough for a host to build a settings UI:
Where Default lets the widget ask for changes, here the host edits the values itself. A widget type declares its settings as a DataViews \`Field[]\` under \`attributes\`, and that single declaration is enough for a host to build a settings UI:

- The \`DataForm\` on the right is mounted straight from the schema, with no per-widget form wiring.
- Validation comes from the same source: the \`greeting\` field is marked as required, and \`useFormValidity\` surfaces the result in the form.
Expand Down
1 change: 1 addition & 0 deletions packages/widget-primitives/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type {
WidgetName,
WidgetIcon,
WidgetType,
WidgetAttributeField,
WidgetRenderProps,
ResolveWidgetModule,
WidgetModuleRecord,
Expand Down
6 changes: 2 additions & 4 deletions packages/widget-primitives/src/stories/anatomy.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ How the widget asks to sit in the host's frame. Today this layer holds one prope

`presentation` suggests how much chrome the widget wants around it.
The widget speaks in its own vocabulary ("render me without a frame"); the host decides how to materialize it, including whether to show the identity at all and which parts of it.
Painting the icon and title into a header is the conventional choice, not the only one: a host could just as well render the title in a footer. Its three values are one axis, from the most chrome to none.
Painting the icon and title into a header is the conventional choice, not the only one: a host could just as well render the title in a footer. The three values run from most chrome (`framed`) through `content-bleed` to none (`full-bleed`).

`framed` (the default): the host paints the header from identity and pads the content. Site Health renders inside that frame.

Expand Down Expand Up @@ -51,12 +51,10 @@ Either way the host never interprets the values. It mounts the form from the dec

![The attributes are a contract both sides write: the render module reads them to produce the output, the widget asks for changes through setAttributes, and the host edits them through a settings form. The meaning stays the widget's.](./assets/representation.svg)

The `WidgetRender` stories show these in motion: Default, where the widget asks; With Settings, where the host edits; and With Host Chrome, where the host frames it.

## 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.

A widget does not declare its own header, because the header is host chrome, not something the widget owns. A widget does not declare a width in pixels, because pixels belong to the host's translation of framing, not to the framing itself. Both questions look like they concern the widget, but each is answered by what the host owns: the header, and the pixel translation of framing.
A widget does not declare its own header, because the header is host chrome, not something the widget owns. A widget does not declare a width in pixels, because pixels belong to the host's translation of framing, not to the framing itself.

The same separation is what makes a widget portable. Only the framing layer is re-translated when the host changes; identity and representation are consumed the same way everywhere. A host is free to render a widget in a context its author never anticipated, as long as it honors the three layers for what they are.
7 changes: 5 additions & 2 deletions packages/widget-primitives/src/stories/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ A _host_ is any context that renders widgets: a dashboard, a sidebar, a plugin p

## What it exposes

**Contract types** describe what a widget is: `WidgetType`, `WidgetName`, `WidgetIcon`, `WidgetRenderProps`, `ResolveWidgetModule`, `WidgetModuleRecord`. They are the shapes a host reads to discover and render a widget, defined here and re-exported nowhere else. How a widget is authored (its folder, `widget.json`, `widget.ts`, `render.tsx`) is covered by **System Architecture**.
**Contract types** describe what a widget is: `WidgetType`, `WidgetName`, `WidgetIcon`, `WidgetRenderProps`, `ResolveWidgetModule`, `WidgetModuleRecord`. They are the shapes a host reads to discover and render a widget, defined here and re-exported nowhere else.
One further export, `WidgetAttributeField< Item >`, is an authoring helper rather than a host-read shape: it narrows a DataViews `Field.id` to the keys of the widget's attribute object, so a typo'd field `id` is caught while authoring. How a widget is authored (its folder, `widget.json`, `widget.ts`, `render.tsx`) is covered by **System Architecture**.

**Discovery** is `useWidgetTypes( records )`. It takes host-supplied widget-module records, imports each record's metadata module, and returns a `[ WidgetType[], isResolving ]` tuple, where `isResolving` is `true` while the records are still being imported. The hook reaches for no store or endpoint: the host fetches the records however it wants and passes them in.
**Discovery** is `useWidgetTypes( records )`. It takes host-supplied widget-module records, imports each record's metadata module, and returns a `[ WidgetType[], isResolvingWidgetTypes ]` tuple, where `isResolvingWidgetTypes` is `true` before records are supplied (`null` or `undefined`) and while their metadata modules are still being imported.

The hook reaches for no store or endpoint: the host fetches the records however it wants and passes them in.

**Rendering** is `<WidgetRender />`. It resolves a `WidgetType.renderModule` through a host-provided `ResolveWidgetModule` and mounts the component with the `attributes` / `setAttributes` contract. Error handling and chrome stay with the host, and because the module is mounted lazily, the host must wrap it in a Suspense boundary.

Expand Down
10 changes: 10 additions & 0 deletions packages/widget-primitives/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ export type WidgetName = `${ string }/${ string }`;
*/
export type WidgetIcon = ReactElement< ComponentProps< 'svg' > >;

/**
* Authoring helper for a widget's `attributes` schema: a DataViews `Field`
* whose `id` is narrowed to the keys of the widget's attribute object (`Item`).
*/
export type WidgetAttributeField< Item > = Field< Item > & {
// `& string` drops the number/symbol keys `keyof` can yield; `Field.id`
// is a string.
id: keyof Item & string;

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 was confused about why we need &string here but looks like it's because we want to exclude symbol/number? Would it make sense to add one-line note on & string to help future readers?

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.

Added a short note inline. keyof Item can be string | number | symbol, and Field.id is a string, so & string keeps only the string keys (which also lets the literal ids narrow cleanly).

Thanks for flagging it

};

/**
* Literal contents of a widget's `widget.json` metadata file.
*
Expand Down
3 changes: 2 additions & 1 deletion widgets/activity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"@wordpress/html-entities": "file:../../packages/html-entities",
"@wordpress/i18n": "file:../../packages/i18n",
"@wordpress/icons": "file:../../packages/icons",
"@wordpress/ui": "file:../../packages/ui"
"@wordpress/ui": "file:../../packages/ui",
"@wordpress/widget-primitives": "file:../../packages/widget-primitives"
},
"devDependencies": {
"@types/react": "^18.3.27"
Expand Down
10 changes: 9 additions & 1 deletion widgets/activity/widget.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import type { WidgetAttributeField } from '@wordpress/widget-primitives';

type ActivityWidgetAttributes = {
perPage?: number;
};

export default {
name: 'core/activity',
Expand All @@ -9,5 +17,5 @@ export default {
type: 'integer',
label: __( 'Items per page' ),
},
],
] satisfies WidgetAttributeField< ActivityWidgetAttributes >[],
};
3 changes: 2 additions & 1 deletion widgets/events/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"@wordpress/element": "file:../../packages/element",
"@wordpress/i18n": "file:../../packages/i18n",
"@wordpress/icons": "file:../../packages/icons",
"@wordpress/ui": "file:../../packages/ui"
"@wordpress/ui": "file:../../packages/ui",
"@wordpress/widget-primitives": "file:../../packages/widget-primitives"
},
"devDependencies": {
"@types/react": "^18.3.27"
Expand Down
4 changes: 2 additions & 2 deletions widgets/events/widget.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* WordPress dependencies
*/
import type { Field } from '@wordpress/dataviews';
import { __ } from '@wordpress/i18n';
import { calendar } from '@wordpress/icons';
import type { WidgetAttributeField } from '@wordpress/widget-primitives';

/**
* Internal dependencies
Expand All @@ -27,5 +27,5 @@ export default {
),
Edit: LocationSettingControl,
},
] satisfies Field< EventsWidgetAttributes >[],
] satisfies WidgetAttributeField< EventsWidgetAttributes >[],
};
1 change: 1 addition & 0 deletions widgets/hello-world/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"dependencies": {
"@wordpress/icons": "file:../../packages/icons",
"@wordpress/ui": "file:../../packages/ui",
"@wordpress/widget-primitives": "file:../../packages/widget-primitives",
"clsx": "^2.1.1"
},
"devDependencies": {
Expand Down
7 changes: 6 additions & 1 deletion widgets/hello-world/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
* WordPress dependencies
*/
import { wordpress } from '@wordpress/icons';
import type { WidgetAttributeField } from '@wordpress/widget-primitives';

type HelloWorldWidgetAttributes = {
message?: string;
};

/**
* Widget type definition
Expand All @@ -16,7 +21,7 @@ export default {
label: 'Message',
type: 'text',
},
],
] satisfies WidgetAttributeField< HelloWorldWidgetAttributes >[],
example: {
attributes: {
message: 'Hello World',
Expand Down
Loading