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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { WidgetAttributeControls } from './widget-attribute-controls';
Original file line number Diff line number Diff line change
@@ -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
* settings trigger that opens the full settings drawer when needed.
* Inline controls appear only for the high-relevance fields.
* Inline edits publish immediately, so they need no Save affordance.
Comment thread
Copilot marked this conversation as resolved.
*
* @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 } );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was testing this and noticed the inline edits publish through the shared staging layer, which can persist more than the one attribute that was edited. commit() publishes the whole staged layout when anything differs (dashboard-context.tsx#L259-L263), and the settings drawer stages its edits in that same layer with the dashboard still interactive behind it since the drawer is modal={ false } (widget-settings.tsx#L141).

For example: open the Hello World widget's settings drawer and edit the message text but don't save, then edit the location inline in the Events widget's toolbar — the pendingCommit effect here publishes Hello World's unsaved draft along with the location edit. After that the drawer's Save button disables (hasUncommittedChanges is now false) and Cancel can't revert, since cancelStaging() resets to a committed layout that already contains the draft (widget-settings.tsx#L115-L120).

Maybe the context could expose an atomic write that publishes just the computed next layout (e.g. a commit/commitLayoutChange variant that takes the layout directly)? That would also remove the pendingCommit state/effect round-trip, which only exists to work around the stale closure.

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.

Awesome feedback, and something that is on the radar. Allowing on-the-fly attribute editing in inline Fields affects the current settings surface (drawer).

#79808 improves the auto-save process, but doesn't address your underlying point.

We can address it in a follow-up by implementing something as you suggested.

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 && (
<DataForm< WidgetAttributes >
data={ data }
fields={ fields }
form={ form }
onChange={ handleChange }
/>
) }

<WidgetSettingsTrigger
widget={ widget }
widgetType={ widgetType }
/>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down Expand Up @@ -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' } : {} ) }
>
<WidgetFrame
widget={ widget }
widgetType={ widgetType }
titleId={ titleId }
editMode={ editMode }
headerToolbar={ headerToolbar }
/>
</Card.Root>
</WidgetContextProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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;
Expand All @@ -101,14 +113,18 @@ export function WidgetFrame( {
showIdentity
widgetType={ widgetType }
titleId={ titleId }
/>
editMode={ editMode }
>
{ headerToolbar }
</WidgetHeader>
) }

<Card.Content
className={ clsx(
styles.content,
isBodyBleeding && styles.bleedContent
) }
{ ...( editMode ? { inert: 'true' } : {} ) }
>
{ isHeaderHidden && widgetType.title && (
<VisuallyHidden render={ <h2 id={ titleId } /> }>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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.
*/
Expand All @@ -54,6 +59,7 @@ export function WidgetHeader( {
titleId,
showIdentity = false,
overlay = false,
editMode = false,
children,
}: WidgetHeaderProps ): React.ReactNode {
return (
Expand All @@ -66,6 +72,7 @@ export function WidgetHeader( {
align="center"
gap="sm"
className={ styles.identity }
{ ...( editMode ? { inert: 'true' } : {} ) }
>
{ widgetType.icon && (
<span className={ styles.icon } aria-hidden="true">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/
import { useCallback } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { cog } from '@wordpress/icons';
import { moreVertical } from '@wordpress/icons';
// Dashboard is still experimental.
// eslint-disable-next-line @wordpress/use-recommended-components
import { IconButton } from '@wordpress/ui';
Expand Down Expand Up @@ -33,8 +33,8 @@ export interface WidgetSettingsTriggerProps {
* Per-instance gear that toggles the shared settings drawer by writing the
* instance `uuid` to the UI context; the single `WidgetSettings` at the root
* reacts to it. Clicking the gear of the instance whose drawer is already
* open closes it. Returns `null` when the type declares no attributes, so
* chrome can mount it unconditionally.
* open closes it. Returns `null` when no attribute needs the drawer (none, or
* all already promoted inline), so chrome can mount it unconditionally.
*
* @param {WidgetSettingsTriggerProps} props Component props.
*/
Expand All @@ -57,13 +57,19 @@ export function WidgetSettingsTrigger( {
setSettingsWidgetUuid( widget.uuid );
}, [ cancel, settingsWidgetUuid, setSettingsWidgetUuid, widget.uuid ] );

if ( ! widgetType.attributes?.length ) {
// Surface the drawer only when there are attributes not already promoted
// inline; if every attribute is high-relevance, the drawer would just
// repeat the toolbar.
const hasNonPromotedAttributes = widgetType.attributes?.some(
( attribute ) => attribute.relevance !== 'high'
);
if ( ! hasNonPromotedAttributes ) {
return null;
}

return (
<IconButton
icon={ cog }
icon={ moreVertical }
label={ __( 'Widget settings' ) }
variant="minimal"
tone="neutral"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,9 @@
/**
* Toolbar chip holding the active mode's controls.
*/
.widgetToolbar {
padding: var(--wpds-dimension-padding-xs);
border-radius: var(--wpds-border-radius-md);
background: var(--wpds-color-background-surface-neutral-strong);
}

/**
* Customize: solid, always visible.
*/
.solid {
background: var(--wpds-color-background-surface-neutral-strong);
.elevated {
border: 1px solid var(--wpds-color-stroke-surface-neutral-weak);
box-shadow: var(--wpds-elevation-xs);
}

/**
* Normal: faint, revealed on tile hover/focus. Opacity (not display) keeps it
* tab-reachable.
*/
.subtle {
background: color-mix(in srgb, var(--wpds-color-background-surface-neutral) 40%, transparent);
opacity: 0;
pointer-events: none;
}

:global([data-wp-grid-item-key]):hover .subtle,
:global([data-wp-grid-item-key]):focus-within .subtle {
opacity: 1;
pointer-events: auto;
}

@media not (prefers-reduced-motion) {
.subtle {
transition:
opacity var(--wpds-motion-duration-sm)
var(--wpds-motion-easing-subtle);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,20 @@ export interface WidgetToolbarProps {
children: ReactNode;

/**
* Subtle hover-revealed surface (normal) vs solid always-visible (customize).
* Lift the toolbar with a shadow while customizing.
*/
revealOnHover?: boolean;
editMode?: boolean;
}

/**
* The per-tile toolbar chip holding the active mode's controls. Solid and always
* visible while customizing; subtle and revealed on hover in normal mode.
* The per-tile toolbar chip holding the active mode's controls.
* Always visible; lifted with a shadow only while customizing.
*
* @param {WidgetToolbarProps} props Component props.
*/
export function WidgetToolbar( {
children,
revealOnHover = false,
editMode = false,
}: WidgetToolbarProps ): React.ReactNode {
return (
<Stack
Expand All @@ -43,7 +43,7 @@ export function WidgetToolbar( {
gap="xs"
className={ clsx(
styles.widgetToolbar,
revealOnHover ? styles.subtle : styles.solid
editMode && styles.elevated
) }
>
{ children }
Expand Down
Loading
Loading