From b2c65064e05e0e00bf3209e2069e40a5fe55ac6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 30 Jun 2026 11:02:53 +0100 Subject: [PATCH 01/17] add relevance hint to widget attribute schema --- packages/widget-primitives/CHANGELOG.md | 5 +++++ packages/widget-primitives/src/types.ts | 28 +++++++++++++++++-------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/widget-primitives/CHANGELOG.md b/packages/widget-primitives/CHANGELOG.md index 2b9e1d7f6ab06b..69b499b7cbd9be 100644 --- a/packages/widget-primitives/CHANGELOG.md +++ b/packages/widget-primitives/CHANGELOG.md @@ -6,6 +6,11 @@ - `WidgetAttributeField< Item >`: authoring helper narrowing a DataViews `Field.id` to the widget's attribute keys. + +### Enhancements + +- `WidgetAttributeField`: add optional `relevance` hint (`'high' | 'low'`) + marking attributes a host may promote to a prominent surface. - `WidgetModuleRecord`: add optional `category`, overlaid onto the metadata module's value. - `WidgetModuleRecord`: add optional `title`, `description`, and diff --git a/packages/widget-primitives/src/types.ts b/packages/widget-primitives/src/types.ts index 1ed90a4c44d152..2a101013676e58 100644 --- a/packages/widget-primitives/src/types.ts +++ b/packages/widget-primitives/src/types.ts @@ -28,14 +28,23 @@ 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`). + * How relevant an attribute is. Hosts may promote `'high'` to a prominent + * surface; `'low'` (the default) is not. The widget declares importance, + * not a surface. */ -export type WidgetAttributeField< Item > = Field< Item > & { - /* - * `& string` drops the number/symbol keys `keyof` can yield; `Field.id` - * is a string. - */ +type WidgetAttributeRelevance = 'high' | 'low'; + +/** A DataViews `Field` plus the widget-layer `relevance` hint; what hosts read. */ +type WidgetAttribute< Item = unknown > = Field< Item > & { + relevance?: WidgetAttributeRelevance; +}; + +/** + * Authoring helper: a `WidgetAttribute` with `id` narrowed to the widget's + * attribute keys (`Item`). + */ +export type WidgetAttributeField< Item > = WidgetAttribute< Item > & { + // `& string` drops number/symbol keys; `Field.id` is a string. id: keyof Item & string; }; @@ -117,9 +126,10 @@ export interface WidgetTypeMetadata< Item = unknown > { /** * Declarative attribute schema, bound to the widget's attribute * object via `Item`. Hosts render forms straight from this list - * via `DataForm`, with no per-widget form wiring. + * via `DataForm`, with no per-widget form wiring. Entries may carry + * a `relevance` hint. */ - attributes?: Field< Item >[]; + attributes?: WidgetAttribute< Item >[]; /** * Structured example data hosts use for previews, and the default From e11016ce64b5ea31d0da27b119cf377a0d44d2dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 30 Jun 2026 12:14:04 +0100 Subject: [PATCH 02/17] render high-relevance attributes inline --- .../widget-attribute-controls/index.ts | 1 + .../widget-attribute-controls.tsx | 117 ++++++++++++++++++ .../src/components/widgets/widgets.tsx | 16 ++- 3 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 packages/widget-dashboard/src/components/widget-attribute-controls/index.ts create mode 100644 packages/widget-dashboard/src/components/widget-attribute-controls/widget-attribute-controls.tsx diff --git a/packages/widget-dashboard/src/components/widget-attribute-controls/index.ts b/packages/widget-dashboard/src/components/widget-attribute-controls/index.ts new file mode 100644 index 00000000000000..6054461736c734 --- /dev/null +++ b/packages/widget-dashboard/src/components/widget-attribute-controls/index.ts @@ -0,0 +1 @@ +export { WidgetAttributeControls } from './widget-attribute-controls'; diff --git a/packages/widget-dashboard/src/components/widget-attribute-controls/widget-attribute-controls.tsx b/packages/widget-dashboard/src/components/widget-attribute-controls/widget-attribute-controls.tsx new file mode 100644 index 00000000000000..97f00cf1af9244 --- /dev/null +++ b/packages/widget-dashboard/src/components/widget-attribute-controls/widget-attribute-controls.tsx @@ -0,0 +1,117 @@ +/** + * WordPress dependencies + */ +import { DataForm } from '@wordpress/dataviews'; +import type { Field, Form } from '@wordpress/dataviews'; +import { useCallback, useEffect, useMemo, useState } from '@wordpress/element'; +import type { WidgetType } from '@wordpress/widget-primitives'; + +/** + * Internal dependencies + */ +import { useDashboardInternalContext } from '../../context/dashboard-context'; +import { WidgetSettingsTrigger } from '../widget-settings'; +import type { DashboardWidget } from '../../types'; + +type WidgetAttributes = Record< string, unknown >; + +type WidgetAttributeControlsProps = { + /** + * The instance whose attributes these controls edit. + */ + widget: DashboardWidget< unknown >; + + /** + * The instance's widget type, source of the attribute schema. + */ + widgetType: WidgetType; +}; + +/** + * Normal-mode controls: the `relevance: 'high'` attributes inline, plus the + * gear that opens the full settings drawer. The gear shows whenever the type + * declares attributes; the inline controls appear only for the high ones. + * Inline edits publish immediately, so they need no Save affordance. + * + * @param {WidgetAttributeControlsProps} props Component props. + */ +export function WidgetAttributeControls( { + widget, + widgetType, +}: WidgetAttributeControlsProps ): React.ReactNode { + const { layout, onLayoutChange, commit } = useDashboardInternalContext(); + + const fields = useMemo< Field< WidgetAttributes >[] >( + () => + ( widgetType.attributes ?? [] ).filter( + ( attribute ) => attribute.relevance === 'high' + ) as Field< WidgetAttributes >[], + [ widgetType.attributes ] + ); + + // A `row` top-level layout lays the controls out horizontally; each field + // carries `labelPosition: 'none'` so the control renders bare. + const form = useMemo< Form >( + () => ( { + layout: { type: 'row', alignment: 'center' }, + fields: fields.map( ( field ) => ( { + id: field.id, + layout: { type: 'regular', labelPosition: 'none' }, + } ) ), + } ), + [ fields ] + ); + + // Publish once staging has advanced, so `commit` reads the fresh layout + // instead of the stale closure captured at edit time. + const [ pendingCommit, setPendingCommit ] = useState( false ); + useEffect( () => { + if ( pendingCommit ) { + commit( { exitEditMode: false } ); + setPendingCommit( false ); + } + }, [ pendingCommit, commit ] ); + + const handleChange = useCallback( + ( edits: Record< string, unknown > ) => { + onLayoutChange( + layout.map( ( instance ) => + instance.uuid === widget.uuid + ? { + ...instance, + attributes: { + ...( instance.attributes as object ), + ...edits, + }, + } + : instance + ) + ); + + setPendingCommit( true ); + }, + [ layout, onLayoutChange, widget.uuid ] + ); + + const data = ( widget.attributes ?? + widgetType.example?.attributes ?? + {} ) as WidgetAttributes; + + return ( + <> + { fields.length > 0 && ( + + data={ data } + fields={ fields } + form={ form } + onChange={ handleChange } + /> + ) } + + + + ); +} diff --git a/packages/widget-dashboard/src/components/widgets/widgets.tsx b/packages/widget-dashboard/src/components/widgets/widgets.tsx index 2bcf07ac57b632..ce83c281ae127f 100644 --- a/packages/widget-dashboard/src/components/widgets/widgets.tsx +++ b/packages/widget-dashboard/src/components/widgets/widgets.tsx @@ -21,10 +21,10 @@ import type { WidgetName } from '@wordpress/widget-primitives'; */ import { useDashboardInternalContext } from '../../context/dashboard-context'; import { useDashboardContainerColumnCount } from '../../hooks/use-dashboard-container-column-count'; +import { WidgetAttributeControls } from '../widget-attribute-controls'; import { WidgetChrome } from '../widget-chrome'; import { WidgetHeader } from '../widget-header'; import { WidgetLayoutControls } from '../widget-layout-controls'; -import { WidgetSettingsTrigger } from '../widget-settings'; import { WidgetToolbar } from '../widget-toolbar'; import { WidgetResizeHandle } from './widget-resize-handle'; import styles from './widgets.module.css'; @@ -134,15 +134,19 @@ export const Widgets = forwardRef< HTMLDivElement, WidgetsProps >( ( type ) => type.name === widget.type ); const hasSettings = !! widgetType?.attributes?.length; + const hasHighRelevanceAttributes = !! widgetType?.attributes?.some( + ( attribute ) => attribute.relevance === 'high' + ); // Grid-slot overlay (outside the card's `inert`) with the active - // mode's controls: layout while customizing, the gear otherwise. + // mode's controls: layout while customizing, the attribute + // controls (high-relevance fields plus the gear) otherwise. let controls: React.ReactNode; if ( editMode ) { controls = ; } else if ( hasSettings && widgetType ) { controls = ( - @@ -151,7 +155,11 @@ export const Widgets = forwardRef< HTMLDivElement, WidgetsProps >( const actionableArea = controls ? ( - + { controls } From 9b98d5997b06d04c4f88d0daaaf1e2f1d96980c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 30 Jun 2026 12:14:08 +0100 Subject: [PATCH 03/17] mark events location as high relevance --- widgets/events/widget.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/widgets/events/widget.ts b/widgets/events/widget.ts index 43ad21816ec9fa..3e302d5acebe8f 100644 --- a/widgets/events/widget.ts +++ b/widgets/events/widget.ts @@ -24,6 +24,7 @@ export default { description: __( 'City or region for nearby events. Edits apply when you save this panel.' ), + relevance: 'high', Edit: LocationSettingControl, }, ] satisfies WidgetAttributeField< EventsWidgetAttributes >[], From d14a9d72e1cf0c461472d70b20d65e945f7f49a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 30 Jun 2026 15:23:41 +0100 Subject: [PATCH 04/17] unify tile header into one row Identity and toolbar share a flex row; inert narrows to the body. --- .../widget-chrome/widget-chrome.tsx | 15 +++++--- .../components/widget-frame/widget-frame.tsx | 18 +++++++++- .../widget-header/widget-header.tsx | 11 ++++-- .../widget-toolbar/widget-toolbar.module.css | 33 +++-------------- .../widget-toolbar/widget-toolbar.tsx | 12 +++---- .../src/components/widgets/widgets.tsx | 36 ++++++++++--------- 6 files changed, 67 insertions(+), 58 deletions(-) diff --git a/packages/widget-dashboard/src/components/widget-chrome/widget-chrome.tsx b/packages/widget-dashboard/src/components/widget-chrome/widget-chrome.tsx index 17c31b86c3f886..b19210a53cb54b 100644 --- a/packages/widget-dashboard/src/components/widget-chrome/widget-chrome.tsx +++ b/packages/widget-dashboard/src/components/widget-chrome/widget-chrome.tsx @@ -56,19 +56,23 @@ export interface WidgetChromeProps { widget: DashboardWidget< unknown >; index: number; /** - * Lifted by `@wordpress/grid` into a sibling slot outside `Card.Root`, so - * it stays interactive while edit mode makes the chrome `inert`. + * Lifted by `@wordpress/grid` into a sibling slot outside `Card.Root`, + * for full-bleed widgets with no in-card header to host the toolbar. */ actionableArea?: ReactNode; + /** + * Toolbar rendered in the in-card header (framed and content-bleed). + */ + headerToolbar?: ReactNode; className?: string; } /** * Dashboard chrome: wraps `WidgetFrame` in the grid tile's `Card.Root`, owning - * identity, edit-mode `inert`, and the missing/resolving states. + * the missing and resolving states. */ export const WidgetChrome = forwardRef< HTMLDivElement, WidgetChromeProps >( - function WidgetChrome( { widget, index, className }, ref ) { + function WidgetChrome( { widget, index, className, headerToolbar }, ref ) { const { widgetTypes, isResolvingWidgetTypes, editMode } = useDashboardInternalContext(); const widgetType = widgetTypes.find( ( t ) => t.name === widget.type ); @@ -125,12 +129,13 @@ export const WidgetChrome = forwardRef< HTMLDivElement, WidgetChromeProps >( ref={ ref } className={ clsx( styles.widgetChrome, className ) } aria-labelledby={ widgetType.title ? titleId : undefined } - { ...( editMode ? { inert: 'true' } : {} ) } > diff --git a/packages/widget-dashboard/src/components/widget-frame/widget-frame.tsx b/packages/widget-dashboard/src/components/widget-frame/widget-frame.tsx index 83d9ca5d91a51e..eb3c2b8c7af16a 100644 --- a/packages/widget-dashboard/src/components/widget-frame/widget-frame.tsx +++ b/packages/widget-dashboard/src/components/widget-frame/widget-frame.tsx @@ -67,6 +67,16 @@ export interface WidgetFrameProps { widget: DashboardWidget< unknown >; widgetType: WidgetType; titleId: string; + + /** + * Inert the body and identity while customizing. + */ + editMode?: boolean; + + /** + * Toolbar shown beside the identity in the in-card header. + */ + headerToolbar?: ReactNode; } /** @@ -79,6 +89,8 @@ export function WidgetFrame( { widget, widgetType, titleId, + editMode = false, + headerToolbar, }: WidgetFrameProps ) { // full-bleed hides the header; full-bleed and content-bleed bleed the body. const { presentation } = widgetType; @@ -101,7 +113,10 @@ export function WidgetFrame( { showIdentity widgetType={ widgetType } titleId={ titleId } - /> + editMode={ editMode } + > + { headerToolbar } + ) } { isHeaderHidden && widgetType.title && ( }> diff --git a/packages/widget-dashboard/src/components/widget-header/widget-header.tsx b/packages/widget-dashboard/src/components/widget-header/widget-header.tsx index 6cef037471c40e..0f0334ccc8f024 100644 --- a/packages/widget-dashboard/src/components/widget-header/widget-header.tsx +++ b/packages/widget-dashboard/src/components/widget-header/widget-header.tsx @@ -36,6 +36,11 @@ export interface WidgetHeaderProps { */ overlay?: boolean; + /** + * Inert the identity while customizing, so it does not capture interaction. + */ + editMode?: boolean; + /** * Toolbar placed on the trailing edge. */ @@ -44,8 +49,8 @@ export interface WidgetHeaderProps { /** * Tile header row: identity (icon + title) and a toolbar on one line. Rendered - * as a `base` in the card flow and as an `overlay` in the grid slot (outside the - * card's `inert`) so the toolbar stays interactive. + * in the card flow, or as an `overlay` in the grid slot for full-bleed widgets + * that have no in-card header. * * @param {WidgetHeaderProps} props Component props. */ @@ -54,6 +59,7 @@ export function WidgetHeader( { titleId, showIdentity = false, overlay = false, + editMode = false, children, }: WidgetHeaderProps ): React.ReactNode { return ( @@ -66,6 +72,7 @@ export function WidgetHeader( { align="center" gap="sm" className={ styles.identity } + { ...( editMode ? { inert: 'true' } : {} ) } > { widgetType.icon && (