Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e675c92
add info property to widget type metadata
retrofox Jul 2, 2026
0a575ae
surface widget info as header tooltip
retrofox Jul 2, 2026
64d088f
add info notes to bundled widgets
retrofox Jul 2, 2026
8b85a9b
derive widget record overrides from metadata
retrofox Jul 2, 2026
16e7cd4
replace info note button with plain tooltip
retrofox Jul 2, 2026
6d029f2
reimplement info note as an infotip popover
retrofox Jul 3, 2026
c48cd69
adjust infotip position and stacking
retrofox Jul 3, 2026
f721622
open infotip on click, anchor bottom-start
retrofox Jul 3, 2026
086da25
allow renderable info in widget metadata
retrofox Jul 3, 2026
3b49706
link site health from its info note
retrofox Jul 3, 2026
9728d3a
keep tab focus inside the info popover
retrofox Jul 3, 2026
8df9c67
replace info with a declarative help schema
retrofox Jul 3, 2026
9e3d39a
render the help note in the header infotip
retrofox Jul 3, 2026
a36b0d5
declare widget help in widget.json
retrofox Jul 3, 2026
daecbea
render help emphasis via createInterpolateElement
retrofox Jul 3, 2026
e3242ec
mute the help icon and drop the z-index override
retrofox Jul 3, 2026
5e311d3
rename help actions to links
retrofox Jul 3, 2026
de9d394
Merge branch 'trunk' into update/widget-info-property
retrofox Jul 3, 2026
4232d9a
rename help text to content
retrofox Jul 3, 2026
a8aedd9
extract the header infotip into a component
retrofox Jul 3, 2026
4f16520
divide adjacent infotip links
retrofox Jul 3, 2026
5c67135
trim help types from the public API
retrofox Jul 3, 2026
53c811d
remove testing widget link
retrofox Jul 3, 2026
cf24dcd
fix help section for health widget
retrofox Jul 3, 2026
42b3a2a
fix tooltip max width and position
retrofox Jul 3, 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
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ public function prepare_item_for_response( $item, $request ) {
$data['description'] = $widget_type->description;
}

if ( rest_is_field_included( 'help', $fields ) ) {
$data['help'] = $widget_type->help;
}

if ( rest_is_field_included( 'keywords', $fields ) ) {
$data['keywords'] = $widget_type->keywords;
}
Expand Down Expand Up @@ -283,6 +287,30 @@ public function get_item_schema() {
'readonly' => true,
),

'help' => array(
'description' => __( 'Contextual help note for the widget type: content plus optional links. Translatable.', 'gutenberg' ),
'type' => array( 'object', 'null' ),
'properties' => array(
'content' => array(
'description' => __( 'Help content; may carry minimal inline emphasis.', 'gutenberg' ),
'type' => 'string',
),
'links' => array(
'description' => __( 'Optional links contextual to the help content.', 'gutenberg' ),
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'label' => array( 'type' => 'string' ),
'href' => array( 'type' => 'string' ),
),
),
),
),
'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' ),
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 @@ -97,6 +97,16 @@ class WP_Widget_Type {
*/
public $description = null;

/**
* Contextual help note: `content` plus optional `links`.
* Translated at registration time using the widget's text domain.
*
* Null when the widget did not declare the field.
*
* @var array|null
*/
public $help = null;

/**
* Alternative terms used to match the widget type when searching,
* e.g. "calendar" for an events widget. Translated at registration
Expand Down
4 changes: 4 additions & 0 deletions lib/experimental/dashboard-widgets/widget-i18n.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
{
"title": "widget title",
"description": "widget description",
"help": {
"content": "widget help content",
"links": [ { "label": "widget help link label" } ]
},
"keywords": [ "widget keyword" ]
}
64 changes: 55 additions & 9 deletions lib/experimental/dashboard-widgets/widget-types.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,18 @@
* translatable and the gettext context to use for each.
*
* Read once from widget-i18n.json and memoized for the rest of the request.
* Decoded as objects, not associative arrays: that is how
* `translate_settings_using_i18n_schema()` tells keyed maps apart from
* lists.
*
* @return array Map of translatable field name to gettext context.
* @return object Map of translatable field name to gettext context.
*/
function gutenberg_get_widget_metadata_i18n_schema() {
static $i18n_schema = null;

if ( null === $i18n_schema ) {
$schema = wp_json_file_decode( __DIR__ . '/widget-i18n.json', array( 'associative' => true ) );
$i18n_schema = is_array( $schema ) ? $schema : array();
$schema = wp_json_file_decode( __DIR__ . '/widget-i18n.json' );
$i18n_schema = is_object( $schema ) ? $schema : new stdClass();
}

return $i18n_schema;
Expand All @@ -37,9 +40,9 @@ function gutenberg_get_widget_metadata_i18n_schema() {
/**
* Translates a widget's user-facing metadata strings.
*
* Runs `title`, `description`, 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`.
* Runs `title`, `description`, `help`, 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.
* @return array Widget data with its translatable strings localized.
Expand All @@ -52,15 +55,57 @@ function gutenberg_translate_widget_metadata( $widget ) {

$i18n_schema = gutenberg_get_widget_metadata_i18n_schema();

foreach ( array( 'title', 'description', 'keywords' ) as $field ) {
if ( isset( $widget[ $field ], $i18n_schema[ $field ] ) ) {
$widget[ $field ] = translate_settings_using_i18n_schema( $i18n_schema[ $field ], $widget[ $field ], $textdomain );
foreach ( array( 'title', 'description', 'help', 'keywords' ) as $field ) {
if ( isset( $widget[ $field ], $i18n_schema->$field ) ) {
$widget[ $field ] = translate_settings_using_i18n_schema( $i18n_schema->$field, $widget[ $field ], $textdomain );
}
}

return $widget;
}

/**
* Constrains a widget help note to its allowed shape: `content` keeps
* only `em`/`strong` markup, and links missing a `label` or `href` are
* dropped.
*
* @param array|null $help Help note from the build manifest.
* @return array|null Sanitized help note, or null when there is no content.
*/
function gutenberg_sanitize_widget_help( $help ) {
if ( ! is_array( $help ) || empty( $help['content'] ) || ! is_string( $help['content'] ) ) {
return null;
}

$sanitized = array(
'content' => wp_kses(
$help['content'],
array(
'em' => array(),
'strong' => array(),
)
),
);

if ( ! empty( $help['links'] ) && is_array( $help['links'] ) ) {
$links = array();
foreach ( $help['links'] as $link ) {
if ( is_array( $link ) && ! empty( $link['label'] ) && ! empty( $link['href'] ) ) {
$links[] = array(
'label' => $link['label'],
'href' => $link['href'],
);
}
}

if ( $links ) {
$sanitized['links'] = $links;
}
}

return $sanitized;
}

/**
* Hydrates the widget type registry from the build manifest.
*
Expand Down Expand Up @@ -93,6 +138,7 @@ function gutenberg_register_widget_types() {
'category' => $widget['category'] ?? null,
'title' => $widget['title'] ?? null,
'description' => $widget['description'] ?? null,
'help' => gutenberg_sanitize_widget_help( $widget['help'] ?? null ),
'keywords' => $widget['keywords'] ?? null,
)
);
Expand Down
5 changes: 5 additions & 0 deletions packages/widget-dashboard/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

### Enhancements

- `WidgetHeader`: surface the widget type's `help` note as an infotip
beside the title: a click-open popover with the note and its links.

## 0.2.0 (2026-07-01)

## 0.1.0 (2026-06-24)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* WordPress dependencies
*/
import { createInterpolateElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { info } from '@wordpress/icons';
// eslint-disable-next-line @wordpress/use-recommended-components
import { Icon, Link, Popover, Stack, VisuallyHidden } from '@wordpress/ui';

/**
* Internal dependencies
*/
import styles from './widget-header.module.css';

export interface WidgetInfotipProps {
/**
* Help content to display; may carry `<em>`/`<strong>`.
*/
content: string;

/**
* Links rendered after the content.
*/
links?: {
label: string;
href: string;
}[];
}

/**
* Header infotip: a click-open popover holding the widget type's help note.
*
* @param {WidgetInfotipProps} props Component props.
*/
export function WidgetInfotip( {
content,
links,
}: WidgetInfotipProps ): React.ReactNode {
return (
<Popover.Root modal="trap-focus">
<Popover.Trigger
aria-label={ __( 'More information' ) }
className={ styles.help }
>
<Icon icon={ info } size={ 20 } />
</Popover.Trigger>

<Popover.Popup
className={ styles.popoverPopup }
positioner={ <Popover.Positioner side="top" align="start" /> }
>
<Popover.Arrow />
<VisuallyHidden render={ <Popover.Title /> }>
{ __( 'More information' ) }
</VisuallyHidden>

<Stack direction="column" align="start" gap="sm">
<Popover.Description>
{ createInterpolateElement( content, {
em: <em />,
strong: <strong />,
} ) }
</Popover.Description>

{ links && links.length > 0 && (
<Stack direction="row" align="start" gap="sm">
{ links.map( ( link ) => (
<Link
key={ link.href }
href={ link.href }
className={ styles.link }
>
{ link.label }
</Link>
) ) }
</Stack>
) }
</Stack>
</Popover.Popup>
</Popover.Root>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,31 @@
color: var(--wpds-color-foreground-content-neutral);
}

.popoverPopup {
max-width: 300px;
}

/**
* Infotip trigger: a bare icon button beside the title.
*/
.help {
display: inline-flex;
align-items: center;
padding: 0;
border: 0;
background: none;
color: var(--wpds-color-foreground-content-neutral-weak);
cursor: var(--wpds-cursor-control);
}

/**
* Divide adjacent links in the infotip.
*/
.link + .link {
border-inline-start: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral);
padding-inline-start: var(--wpds-dimension-gap-sm);
}

.toolbar {
margin-inline-start: auto;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import type { WidgetType } from '@wordpress/widget-primitives';
/**
* Internal dependencies
*/
import { WidgetInfotip } from './widget-header-infotip';
import styles from './widget-header.module.css';

export interface WidgetHeaderProps {
/**
* Widget type, source of the icon and title shown as identity.
* Widget type, source of the icon, title, and help note shown as
* identity.
*/
widgetType?: WidgetType;

Expand Down Expand Up @@ -83,8 +85,16 @@ export function WidgetHeader( {
<Card.Title id={ titleId } render={ <h2 /> }>
{ widgetType.title }
</Card.Title>

{ widgetType.help && (
<WidgetInfotip
content={ widgetType.help.content }
links={ widgetType.help.links }
/>
) }
</Stack>
) }

{ children && <div className={ styles.toolbar }>{ children }</div> }
</Card.Header>
);
Expand Down
24 changes: 24 additions & 0 deletions packages/widget-dashboard/src/test/widget-dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ const widgetTypes: WidgetType[] = [
apiVersion: 1,
name: 'test/greet',
title: 'Greet',
help: {
content: 'Greetings at a glance.',
links: [ { label: 'Learn more', href: 'options-general.php' } ],
},
renderModule: 'test-greet-module',
},
];
Expand Down Expand Up @@ -101,6 +105,26 @@ describe( 'WidgetDashboard', () => {
);
} );

it( 'surfaces the widget type help note in the header', async () => {
render( <Harness /> );

// The infotip trigger opens a popover holding the note.
await userEvent.click(
await screen.findByRole( 'button', {
name: 'More information',
} )
);

expect(
await screen.findByText( 'Greetings at a glance.' )
).toBeInTheDocument();

// The note's declared links render in the popover.
expect(
screen.getByRole( 'link', { name: 'Learn more' } )
).toBeInTheDocument();
} );

it( 'threads setAttributes into onLayoutChange on commit with merged attributes', async () => {
const onChange = jest.fn();
render( <Harness onLayoutChange={ onChange } /> );
Expand Down
4 changes: 4 additions & 0 deletions packages/widget-primitives/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
- `WidgetModuleRecord`: add optional `title`, `description`, and
`keywords`, overlaid onto the metadata module's values. Lets a host
supply metadata translated server-side.
- `WidgetTypeMetadata`: add optional `help`, a declarative contextual
note (`content` plus optional `links`) for compact surfaces such as
tooltips. Also carried by `WidgetModuleRecord`, overlaid onto the
metadata module's value.

## 0.2.0 (2026-07-01)

Expand Down
1 change: 1 addition & 0 deletions packages/widget-primitives/src/hooks/use-widget-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export function useWidgetTypes(
...( record.description
? { description: record.description }
: {} ),
...( record.help ? { help: record.help } : {} ),
...( record.keywords
? { keywords: record.keywords }
: {} ),
Expand Down
2 changes: 1 addition & 1 deletion packages/widget-primitives/src/stories/anatomy.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The three layers are what a widget _is_, how it _asks_ to be framed, and what it

## Identity

What the widget is: `name`, `title`, `description`, `icon`, `category`, `keywords`.
What the widget is: `name`, `title`, `description`, `help`, `icon`, `category`, `keywords`.

The host shows this almost verbatim, in pickers, headers, and help. It decides _where_ identity appears and _whether_ to show it, never _what it says_. Identity is copied, not interpreted: two hosts displaying the same widget show the same title.

Expand Down
Loading
Loading