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
2 changes: 1 addition & 1 deletion docs/explanations/architecture/dashboard-widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ 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`: declarative links (`id`, `label`, `href`, optional `download` / `openInNewTab`). Hosts place them; 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/`).
`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`.

Expand Down
1 change: 1 addition & 0 deletions packages/widget-dashboard/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

### Documentation

- Describe what the "More" menu mounts for a link fulfillment ([#80974](https://github.com/WordPress/gutenberg/pull/80974)).
- Prefer a static CSV asset over a `data:` URL in the goal-progress story ([#80510](https://github.com/WordPress/gutenberg/pull/80510)).
- Add/improve default story and documentation ([#80423](https://github.com/WordPress/gutenberg/pull/80423])).
- Document how attribute `relevance` maps to surfaces: the prominent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ type WidgetActionsProps = {

/**
* 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.
* chrome: a three-dots trigger surfacing each action. This host mounts a real
* anchor for the link fulfillment, so middle-click and copy address survive;
* the menu exposes it as a menu item rather than as a link.
*
* As a trailing header section it reserves its own footprint, so the
* collapsible controls beside it never plan for space it occupies.
Expand Down
1 change: 1 addition & 0 deletions packages/widget-primitives/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

### Documentation

- Describe actions as verbs with one fulfillment, not as links ([#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: 3 additions & 3 deletions packages/widget-primitives/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ Its optional `relevance` hint (`'high' | 'low'`) marks attributes a host may pro

### `WidgetAction`

It's a declarative action a widget type exposes.
Each carries `id`, `label`, `href`, and optional `download` / `openInNewTab`.
It's a declarative verb a widget type exposes: an envelope (`id`, `label`) plus exactly one fulfillment, named by the key carrying it.
Today the only key is `href`, a link target, with optional `download` / `openInNewTab`.
`data:` and `javascript:` hrefs are rejected at registration. Prefer a file next to the widget, an absolute URL, or `downloadBlob` for generated content.

The widget names the intent and a link target; the host renders it as an anchor and owns placement.
The widget names the intent and how it is fulfilled; the host mounts the primitive and owns placement.

### Field types

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -827,7 +827,7 @@ export const WithActions: StoryObj = {
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.
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 how it is fulfilled, through the key it writes (today a link \`href\`); the host mounts the primitive and decides where to put it.

**In this demo**

Expand All @@ -836,7 +836,7 @@ Beyond its data, a widget can declare \`actions\`: verbs a user can trigger, lik

**Takeaway**

The widget names the intent and a link target; navigation and download are the browser's, so the widget never knows its surface.
The widget names the intent and how it is fulfilled; the host mounts the primitive and places it, so the widget never knows its surface.
`,
},
},
Expand Down
29 changes: 24 additions & 5 deletions packages/widget-primitives/src/stories/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ A widget declares its data through `attributes`; it declares the verbs a user ca

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.
The widget names the intent and, through the key it writes, how the action is fulfilled. Two decisions are left, and both are the host's: which primitive to mount, and where to put it.

![One action declaration. The key the widget writes fixes the fulfillment, what triggering the action means. The host is left with two decisions: the materialization, which primitive it mounts, and the placement, where the affordance goes.](./assets/action-axes.svg)

## 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.
Every action carries an **envelope**, an `id` and a `label`, and exactly one **fulfillment**, which says what triggering it means.

The fulfillment is named by the key that carries it, not by a separate discriminator. Today the only key is `href`, so the only fulfillment is a link: triggering the action goes to a target.

```ts
{
Expand All @@ -29,6 +32,8 @@ next to the widget (resolved to a plugin URL on the server). `data:` and
files. Query strings on local filenames (e.g. `report.csv?v=2`) are not
resolved as widget files.

Two more keys belong to the link, not to the envelope, so they only mean something alongside an `href`:

- `download`: download instead of navigate; a string sets the filename.
- `openInNewTab`: open in a new tab.

Expand All @@ -41,8 +46,22 @@ resolved as widget files.
}
```

## Materialization is the host's

The fulfillment says what triggering means. It does not say which control the user clicks. The host picks that per surface, and the same declaration can end up as a menu item, a footer link, or a palette command.

![The same action declaring a link target, materialized by three surfaces: a More menu and a footer mount a real anchor and keep link semantics, while a command palette has no anchor to mount and degrades to a command.](./assets/action-surfaces.svg)

A link fulfillment carries one obligation: **where the surface allows a link primitive, the host must mount one**. A real link keeps middle-click and copy address, which routing the same target through a click handler destroys. That is why a link is a first-class fulfillment rather than a shorthand for one.

Its accessible role follows the surface rather than the element: mounted inside the dashboard's "More" menu, the anchor is exposed as a menu item, not as a link.

Which link primitive is also the host's call. The widget declares _where_ to go; the host decides _how to get there_. A target inside the host's own routes can use its router's link, which is still an anchor and keeps the same behaviors; anything else is a plain anchor and a full page load. The widget cannot make that call: whether a target is reachable in-page depends on the routes the host registered, which changes per host and over time.

Where no link primitive fits, as in a command palette, the host mounts what the surface offers and those semantics degrade. That is a real cost of reaching beyond the widget, not an oversight.

## 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.
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.
This is the contract that the `relevance` attribute already uses: the widget declares intent, and the host owns the surface.
9 changes: 8 additions & 1 deletion packages/widget-primitives/src/stories/actions.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { Meta, Markdown } from '@storybook/addon-docs/blocks';
import ActionsDoc from './actions.md?raw';
import actionAxes from './assets/action-axes.svg';
import actionSurfaces from './assets/action-surfaces.svg';

<Meta title="Widget Primitives/Actions" />

<Markdown>{ ActionsDoc }</Markdown>
<Markdown>
{ ActionsDoc.replace( './assets/action-axes.svg', actionAxes ).replace(
'./assets/action-surfaces.svg',
actionSurfaces
) }
</Markdown>
6 changes: 4 additions & 2 deletions packages/widget-primitives/src/stories/anatomy.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,11 @@ 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 surface. The meaning stays the widget's.](./assets/representation.svg)

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`).
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 exactly one fulfillment, the key that says what triggering it means. Today the only key is `href`, a link target (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.
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.

The host never interprets what an action _means_, only how to offer it: which primitive to mount, and where. For a link that means mounting a real link primitive wherever the surface allows one, and deciding how to reach the target, which the widget cannot know (see **Actions**).

## Why the split matters

Expand Down
44 changes: 44 additions & 0 deletions packages/widget-primitives/src/stories/assets/action-axes.svg

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.

This diagram says the host decide fulfillment. Is that correct? My understanding is the widget selects the fulfillment through its key, while the host decides materialization and placement.

Image

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.

Thanks, Chi. Updating the doc

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading