Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d577b9d
add widget footer for high-relevance actions
retrofox Aug 13, 2026
3a9b742
hold action icon slot while references resolve
retrofox Aug 13, 2026
f35ec5c
fix new-tab hint on icon-only footer actions
retrofox Aug 13, 2026
aaa5af5
share the footer/menu action split in one helper
retrofox Aug 13, 2026
4724e96
document the footer surface mapping
retrofox Aug 13, 2026
43c325a
add medium tier to widget relevance scale
retrofox Aug 13, 2026
bee0fda
tighten comments and docs
retrofox Aug 13, 2026
afc9e74
update stale relevance docs to the three-tier scale
retrofox Aug 13, 2026
73d82b2
add the footer to the widget chrome anatomy story
retrofox Aug 13, 2026
1854a34
wire footer diagram into the chrome story
retrofox Aug 13, 2026
5739aa9
update relevance union in wp-build widget typedef
retrofox Aug 13, 2026
9415ce9
Merge branch 'trunk' into update/widget-chrome-footer
retrofox Aug 13, 2026
1e74f15
add metadata-to-tile overview to the chrome story
retrofox Aug 13, 2026
c7434cb
fix header mapping in the tile overview
retrofox Aug 13, 2026
1c24955
render icon prefix on high footer actions
retrofox Aug 13, 2026
6814811
declare review items action in site health
retrofox Aug 13, 2026
089a763
Revert "declare review items action in site health"
retrofox Aug 13, 2026
eed64a0
demo icon prefix on the story's high action
retrofox Aug 13, 2026
63101f3
Merge remote-tracking branch 'origin/trunk' into update/widget-chrome…
retrofox Aug 13, 2026
b69baeb
Merge remote-tracking branch 'origin/trunk' into update/widget-chrome…
retrofox Aug 13, 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
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 verbs (`id`, `label`, optional `icon` and `relevance`, 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/`).
`actions`: declarative verbs (`id`, `label`, optional `icon` and `relevance`, 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 routes by `relevance`: `'high'` and `'medium'` to a persistent footer, the rest to 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/`).

`icon`: a registered icon name (`collection/icon-name`), resolved client-side through the site's Icons API. Malformed names are dropped at registration.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ public function get_item_schema() {
'icon' => array( 'type' => 'string' ),
'relevance' => array(
'type' => 'string',
'enum' => array( 'high', 'low' ),
'enum' => array( 'high', 'medium', 'low' ),
),
'href' => array( 'type' => 'string' ),
'download' => array( 'type' => array( 'string', 'boolean' ) ),
Expand Down
2 changes: 1 addition & 1 deletion lib/experimental/dashboard-widgets/widget-types.php
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ function gutenberg_sanitize_widget_actions( $actions, $dir_name = '' ) {
}
}

if ( isset( $action['relevance'] ) && in_array( $action['relevance'], array( 'high', 'low' ), true ) ) {
if ( isset( $action['relevance'] ) && in_array( $action['relevance'], array( 'high', 'medium', 'low' ), true ) ) {
$entry['relevance'] = $action['relevance'];
}

Expand Down
9 changes: 9 additions & 0 deletions packages/widget-dashboard/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## Unreleased

### New Features

- Widget chrome footer: `relevance: 'high'` actions mount as leading text
links (declared icon as prefix) in a persistent strip under the widget
body, `'medium'` as trailing compact affordances (icon-only with a
declared icon); the "More" menu keeps the rest, and full-bleed widgets
keep every action in the menu
([#81556](https://github.com/WordPress/gutenberg/pull/81556)).

### Internal

- Point tsconfig references at split dependencies' build projects. ([#81509](https://github.com/WordPress/gutenberg/pull/81509))
Expand Down
14 changes: 12 additions & 2 deletions packages/widget-dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ The tile chrome is a `Card` at the Card's default density. Hosts can tighten or

```css
:root {
--wp-widget-dashboard-tile-padding: var(--wpds-dimension-padding-lg);
--wp-widget-dashboard-tile-padding: var( --wpds-dimension-padding-lg );
}
```

Expand All @@ -223,7 +223,7 @@ When a widget type declares `help`, the tile chrome surfaces its `content` and o

### Attribute editing

A widget declares importance per attribute through `relevance` (`'high' | 'low'`, absent means `'low'`). The declaration states importance, not placement; this host maps it to two surfaces:
A widget declares importance per attribute through `relevance` (`'high' | 'medium' | 'low'`, absent means `'low'`). The declaration states importance, not placement; this host maps it to two surfaces:

- **Prominent surface**: `relevance: 'high'` fields render as bare inline controls in the tile header, for in-context edits.
- **Settings surface**: the full schema, opened from the settings trigger. The trigger shows only when some attribute is not promoted; otherwise it would repeat the prominent surface.
Expand All @@ -232,6 +232,16 @@ The prominent surface holds only while it fits. The header measures the space it

Edits on any surface stage through the engine's internal layer and reach `onLayoutChange` on commit. Prominent-surface and dropdown edits publish on a shared auto-save debounce; the settings surface publishes on Save.

### Actions

Actions carry the same `relevance` scale, mapped to surfaces of decreasing prominence:

- **Footer, leading**: `relevance: 'high'` actions render as text links in a persistent strip under the widget body, a declared icon riding as prefix.
- **Footer, trailing**: `relevance: 'medium'` actions render as compact affordances: icon-only links when the action declares an icon, text links otherwise.
- **More menu**: the rest collapse into the three-dots menu in the tile header.

Every affordance is a real anchor. Full-bleed widgets have no footer, so all of their actions stay in the menu.

## Authoring widgets

Widget render modules receive only what they need to render and edit:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { __ } from '@wordpress/i18n';
import { moreVertical } from '@wordpress/icons';
// eslint-disable-next-line @wordpress/use-recommended-components
import { Icon, IconButton, Link } from '@wordpress/ui';
import type { WidgetType } from '@wordpress/widget-primitives';
import type { WidgetAction } from '@wordpress/widget-primitives';
import { useReserveHeaderSpace } from '../widget-header/widget-header-fit';
import styles from './widget-actions.module.css';
import { unlock } from '../../lock-unlock';
Expand All @@ -12,26 +12,27 @@ const { Menu } = unlock( componentsPrivateApis );

type WidgetActionsProps = {
/**
* The widget type whose declared actions render here.
* The actions this menu materializes. The host routes by relevance:
* the footer takes `'high'` and `'medium'`, this menu the rest, and
* every action for full-bleed widgets, which have no footer.
*/
widgetType: WidgetType;
actions: WidgetAction[];
};

/**
* Materializes a widget type's declared `actions` as a "more" menu in the
* 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.
* Materializes widget actions as a "more" menu in the chrome: a three-dots
* trigger surfacing each given 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.
*
* @param {WidgetActionsProps} props Component props.
*/
export function WidgetActions( {
widgetType,
actions,
}: WidgetActionsProps ): React.ReactNode {
const actions = widgetType.actions ?? [];
const reserveRef = useReserveHeaderSpace< HTMLSpanElement >( 'actions' );

if ( actions.length === 0 ) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { WidgetFooter } from './widget-footer';
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Full-width divider; content aligns to the card padding. The body above
* has no bottom padding, so scrolled content runs flush to the divider.
*/
.widget-footer {
padding-block: var(--wpds-dimension-padding-md);
padding-inline: var(--wp-ui-card-padding);
border-block-start-style: solid;
border-block-start-width: var(--wpds-border-width-xs);
border-block-start-color: var(--wpds-color-stroke-surface-neutral-weak);
}

.compact-actions {
margin-inline-start: auto;
}

/* High action with a declared icon: the icon rides as a prefix. */
.prefixed-action {
display: inline-flex;
align-items: center;
gap: var(--wpds-dimension-gap-xs);
}

/*
* Square the icon-only link via the Button override hooks for icon
* buttons; breathing room comes from the size, not from padding.
*/
.icon-action {
--wp-ui-button-aspect-ratio: 1;
--wp-ui-button-padding-inline: 0px;
--wp-ui-button-min-width: unset;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { __, sprintf } from '@wordpress/i18n';
// eslint-disable-next-line @wordpress/use-recommended-components
import { Icon, Link, LinkButton, Stack, Tooltip } from '@wordpress/ui';
import type { WidgetAction, WidgetIcon } from '@wordpress/widget-primitives';
import styles from './widget-footer.module.css';

type IconActionProps = {
/**
* The action to materialize.
*/
action: WidgetAction & { icon: WidgetIcon };
};

/**
* Icon-only link. `openInNewTab` mounts the target on the anchor rather
* than through `Link`, whose external glyph would double the icon; the
* new-tab hint joins the accessible name.
*
* @param {IconActionProps} props Component props.
*/
function IconAction( { action }: IconActionProps ): React.ReactNode {
const label = action.openInNewTab
? sprintf(
/* translators: %s: action label. */
__( '%s (opens in a new tab)' ),
action.label
)
: action.label;

return (
<Tooltip.Root>
<Tooltip.Trigger
render={
<LinkButton
variant="minimal"
tone="neutral"
size="compact"
className={ styles[ 'icon-action' ] }
aria-label={ label }
href={ action.href }
download={ action.download }
render={
action.openInNewTab ? (
/* href and content merge in at runtime. */
// eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/anchor-is-valid
<a target="_blank" rel="noopener noreferrer" />
) : undefined
}
/>
}
>
<LinkButton.Icon icon={ action.icon } />
</Tooltip.Trigger>
<Tooltip.Popup>{ action.label }</Tooltip.Popup>
</Tooltip.Root>
);
}

type WidgetFooterProps = {
/**
* The promoted actions (`relevance: 'high'` and `'medium'`).
*/
actions: WidgetAction[];

/**
* Inert the footer while customizing.
*/
editMode?: boolean;
};

/**
* Persistent strip under the widget body. `'high'` actions mount as leading
* text links, a declared icon riding as prefix; `'medium'` actions as
* trailing compact affordances, icon-only when they declare an icon. Every
* affordance is a real anchor.
*
* @param {WidgetFooterProps} props Component props.
*/
export function WidgetFooter( {
actions,
editMode = false,
}: WidgetFooterProps ): React.ReactNode {
if ( actions.length === 0 ) {
return null;
}

const highActions = actions.filter(
( action ) => action.relevance === 'high'
);
const mediumActions = actions.filter(
( action ) => action.relevance === 'medium'
);

return (
<Stack
direction="row"
align="center"
gap="lg"
className={ styles[ 'widget-footer' ] }
{ ...( editMode ? { inert: 'true' } : {} ) }
>
{ highActions.length > 0 && (
<Stack direction="row" align="center" gap="lg" wrap="wrap">
{ highActions.map( ( action ) => (
<Link
key={ action.id }
href={ action.href }
download={ action.download }
openInNewTab={ action.openInNewTab }
className={
action.icon
? styles[ 'prefixed-action' ]
: undefined
}
>
{ action.icon && <Icon icon={ action.icon } /> }
{ action.label }
</Link>
) ) }
</Stack>
) }

{ mediumActions.length > 0 && (
<Stack
direction="row"
align="center"
gap="xs"
className={ styles[ 'compact-actions' ] }
>
<Tooltip.Provider>
{ mediumActions.map( ( action ) =>
action.icon ? (
<IconAction
key={ action.id }
action={ {
...action,
icon: action.icon,
} }
/>
) : (
<Link
key={ action.id }
href={ action.href }
download={ action.download }
openInNewTab={ action.openInNewTab }
>
{ action.label }
</Link>
)
) }
</Tooltip.Provider>
</Stack>
) }
</Stack>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { __ } from '@wordpress/i18n';
// eslint-disable-next-line @wordpress/use-recommended-components
import { Card, Notice, Stack, VisuallyHidden } from '@wordpress/ui';
import type { WidgetType } from '@wordpress/widget-primitives';
import { splitWidgetActions } from '../../utils/split-widget-actions';
import { WidgetFooter } from '../widget-footer';
import { WidgetHeader } from '../widget-header';
import { WidgetRender } from '../widget-render';
import styles from './widget-frame.module.css';
Expand Down Expand Up @@ -69,8 +71,9 @@ export interface WidgetFrameProps {
}

/**
* Shared framing: `presentation` into header + content, with the error/loading
* boundaries. Hosts supply the `Card.Root` and their own concerns.
* Shared framing: `presentation` into header, content, and the actions footer,
* with the error/loading boundaries. Hosts supply the `Card.Root` and their
* own concerns.
*
* @param {WidgetFrameProps} props Component props.
*/
Expand All @@ -87,6 +90,8 @@ export function WidgetFrame( {
const isBodyBleeding =
presentation === 'full-bleed' || presentation === 'content-bleed';

const { footer: footerActions } = splitWidgetActions( widgetType );

const body = (
<WidgetErrorBoundary>
<Suspense fallback={ <LoadingOverlay /> }>
Expand Down Expand Up @@ -122,6 +127,10 @@ export function WidgetFrame( {
) }
{ body }
</Card.Content>

{ footerActions.length > 0 && (
<WidgetFooter actions={ footerActions } editMode={ editMode } />
) }
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
import type { WidgetName } from '@wordpress/widget-primitives';
import { useDashboardInternalContext } from '../../context/dashboard-context';
import { useDashboardContainerColumnCount } from '../../hooks/use-dashboard-container-column-count';
import { splitWidgetActions } from '../../utils/split-widget-actions';
import { WidgetActions } from '../widget-actions';
import { WidgetAttributes } from '../widget-attributes';
import { WidgetChrome } from '../widget-chrome';
Expand Down Expand Up @@ -124,14 +125,16 @@ 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';

const { menu: menuActions } = splitWidgetActions( widgetType );
const hasActions = menuActions.length > 0;

// 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) and the
// declared actions otherwise.
// menu actions otherwise.
let controls: React.ReactNode;
if ( editMode ) {
controls = <WidgetLayoutControls widget={ widget } />;
Expand All @@ -146,7 +149,7 @@ export const Widgets = forwardRef< HTMLDivElement, WidgetsProps >(
) }

{ hasActions && (
<WidgetActions widgetType={ widgetType } />
<WidgetActions actions={ menuActions } />
) }
</>
);
Expand Down
Loading
Loading