diff --git a/.changeset/board-widget-chrome-and-intent.md b/.changeset/board-widget-chrome-and-intent.md new file mode 100644 index 000000000..813039d4e --- /dev/null +++ b/.changeset/board-widget-chrome-and-intent.md @@ -0,0 +1,15 @@ +--- +'@cube-dev/ui-kit': minor +--- + +`Board`: corner-anchored widget chrome, app-defined widget modifiers, a reason on `onLayoutChange`, and a real `dragCancel` default. + +- **`dragCancel` now defaults to `BOARD_SELECTION_CANCEL`** instead of no cancel at all. A control inside a widget has to keep its own press whether or not the board happens to support selection — previously that only worked on a _selectable_ board, where `selectionCancel` incidentally doubled as the drag guard, so every other board had to hand-write a selector or a pointer-down on a child's button would drag the widget instead. Pass your own selector to narrow or widen it, or `''` for the old behaviour. + +- **`cornerChrome`** (with **`cornerChromePlacement`**, default `'ne'`) puts a control on a widget's corner, centred on it. It renders in the same layer as the corner resize grips, which is the layer that escapes the widget's own `overflow: hidden` — chrome hung off the corner from inside a widget is cropped in half by that clip, or by an ancestor's scroll container when the widget sits in the first row. Being outside the widget host, it is also outside the drag gesture, so pressing it can never start a drag and it needs no `dragCancel` entry. + +- **`mods`** on `Board.Widget` (and board-wide via `widgetProps`) merges app-defined modifiers into the ones the board sets, so a `styles` map can match on app state (`mods={{ editing: true }}` with `styles={{ shadow: { editing: '…' } }}`) instead of the app swapping whole style objects per state. Board's own modifiers are applied last, so a custom one can never shadow `selected`, `drag` and the rest, which the board's styling and its accessibility wiring both depend on. + +- **`onLayoutChange` now reports why the layout changed** — `'drag'`, `'resize'`, `'transfer'`, or `'normalize'` for the commits no gesture caused (a reflow for a changed column count, an `isAutoHeight` widget growing). An app that persisted every commit had no way to tell a user's edit from the board fitting itself to a constraint that moved, so it wrote the reflow back as an edit and marked a document dirty nobody touched. The argument is additive: existing one-parameter handlers keep working. + +The docs also now explain that widget style maps are **merged**: a map with no `''` entry extends the defaults, while one that sets `''` replaces them and needs `'@inherit'` to keep any. Reaching for `'': false` to switch a single state off is the easy mistake — it silently takes `selected`, `pre-selected` and the drag lift with it. diff --git a/src/components/layout/Board/Board.docs.mdx b/src/components/layout/Board/Board.docs.mdx index bd3f833b9..1144f26ab 100644 --- a/src/components/layout/Board/Board.docs.mdx +++ b/src/components/layout/Board/Board.docs.mdx @@ -37,7 +37,7 @@ Dragging and resizing are powered by React Aria's `useMove`, so they work with m - **`layout`** `LayoutItem[]` — Controlled layout. - **`defaultLayout`** `LayoutItem[]` — Initial layout for uncontrolled usage. -- **`onLayoutChange`** `(layout: LayoutItem[]) => void` — Called when a drag or resize is committed. +- **`onLayoutChange`** `(layout: LayoutItem[], info: { reason }) => void` — Called when the layout is committed. `reason` is `'drag'`, `'resize'`, `'transfer'` (a widget arrived from or left for another board) or `'normalize'` (no gesture — a reflow for a changed column count, or an auto-height widget growing). See [Knowing why the layout changed](#knowing-why-the-layout-changed). - **`onDragStart` / `onDrag` / `onDragStop`** `(info: BoardInteractionInfo) => void` — Drag lifecycle callbacks. `onDrag` fires on every step; `onDragStop` fires after the layout is committed. - **`onResizeStart` / `onResize` / `onResizeStop`** `(info: BoardInteractionInfo) => void` — Resize lifecycle callbacks, mirroring the drag ones. - **`cols`** `number` (default: `12`) — Number of columns. @@ -56,7 +56,7 @@ Dragging and resizing are powered by React Aria's `useMove`, so they work with m - **`isDroppable`** `boolean` (default: `true`) — Whether the board accepts widgets dropped from other boards. - **`resizeHandles`** `ResizeHandleAxis[]` (default: `['se']`) — Which resize handles to show. Corner handles (`ne`/`nw`/`se`/`sw`) show an angle grip; edge handles (`n`/`s`/`e`/`w`) show a dotted grip. Both are revealed on hover/focus/resize. A single edge (e.g. `['e']` or `['s']`) gives a horizontally- or vertically-only resizable widget. - **`resizeGripPlacement`** `'inside' | 'corner'` (default: `'inside'`) — Where the corner grips sit. `'inside'` tucks each one into the widget box; `'corner'` centres it on the widget's corner, so it lines up with a control centred on the opposite corner. Only affects corner handles — a dotted edge grip always stays inside. A corner-placed grip is drawn _outside_ the widget (a widget clips its own content, which would otherwise cut the grip in half), so it needs the board to have at least half a grip's worth of `containerPadding` to show in full against the board's edge. Its hit-zone moves out with it and overhangs by the same half-grip, so the whole visible grip is grabbable and hovering it keeps it revealed — the trade is a ~5px band just outside each corner where a press starts a resize rather than a marquee. Can be overridden per widget. -- **`dragCancel`** `string` — CSS selector for elements that must not start a pointer drag (e.g. form controls inside a widget: `"input,textarea,button,a,.no-drag"`). Does not affect keyboard moves — those only run when the widget host itself is focused. Can be overridden per widget. +- **`dragCancel`** `string` (default: `BOARD_SELECTION_CANCEL`) — CSS selector for elements that must not start a pointer drag. Defaults to the same set `selectionCancel` uses, because a control inside a widget has to keep its own press whether or not the board supports selection — a board hosting interactive content needs no configuration for buttons, inputs, links and the common ARIA widget roles to stay usable. Pass your own selector to narrow or widen it, or `''` to let a drag start from anywhere. Does not affect keyboard moves. Can be overridden per widget. - **`dragHandle`** `string` — CSS selector for the only elements from which a pointer drag may start. Can be overridden per widget. - **`showGridLines`** `boolean | 'drag' | 'any-drag'` (default: `false`) — Show grid lines behind the widgets. `true` always; `false` never; `'drag'` only while _this_ board is part of the active gesture — it owns the drag (as its source, or as the board the widget is currently over) or one of its own widgets is being resized; `'any-drag'` while any drag is in flight anywhere under a shared `Board.Provider`, so every board advertises itself as somewhere the widget could land. Under one `Board.Provider`, `'drag'` keeps boards a widget cannot land in quiet, and `'any-drag'` is the opt-in for the noisier, more informative version. A nested board that does not set this inherits an enabled ancestor's setting, at the ancestor's own scope. - **`isAligned`** `boolean` (default: `false`) — Align a nested board with its ancestor `Board`'s layout. Only takes effect when the board is nested inside another `Board`'s widget. When set, every cell matches the parent's cell size exactly: the board inherits the parent's column pitch (deriving its own column count from its measured width so cells stay parent-sized as the container is resized) and uses the parent's row height verbatim. It never shrinks rows to fit — pair it with an `isAutoHeight` container so the widget grows to fit its rows at that height. `cols`/`rowHeight` then act as fallbacks used only until the parent metrics resolve. @@ -76,6 +76,9 @@ Dragging and resizing are powered by React Aria's `useMove`, so they work with m ### Board.Widget - **`id`** `string` (required) — Must match the `i` of a layout item. +- **`cornerChrome`** `ReactNode` — A control anchored to one corner of the widget and centred on it — a settings button, a badge, a remove affordance. Drawn in the same layer as the corner resize grips, which is the layer that escapes the widget's own clip: chrome hung off the corner from _inside_ a widget is cropped in half by that clip, or by an ancestor's scroll container when the widget sits in the first row. It is also outside the drag gesture, so a press on it can never start a drag and needs no `dragCancel` entry. +- **`cornerChromePlacement`** `'ne' | 'nw' | 'se' | 'sw'` (default: `'ne'`) — Which corner the chrome sits on. Pair it with a `resizeGripPlacement="corner"` grip on the opposite corner and the two line up. +- **`mods`** `Record` — App-defined modifiers for this widget, merged into the ones the board sets, so a `styles` map can match on app state: `mods={{ editing: true }}` with `styles={{ shadow: { editing: '0 0 0 1bw #primary' } }}`. Board's own modifiers always win, so a custom one can never shadow `selected`, `drag` and the rest. Board-level `widgetProps.mods` merge underneath, so a shared default can be set once and overridden per widget — and every modifier rides along on the clone that floats during a drag. - **`hoverRing`** `boolean` (default: `true`) — Whether the widget shows the resting ring on hover — the affordance that says it can be picked up. Turn it off for a widget that is scenery rather than a thing to grab (a chromeless layout container, a spacer), where the ring advertises an interaction the widget does not really offer. Selection and drag treatments are unaffected. Can be set for the whole board via `widgetProps`. - **`isCard`** `boolean` (default: `false`) — Render this widget as a card by adding a border. Widgets are always filled (`#surface-2`) and rounded (`1cr`); `isCard` adds the border on top. Defaults to `false` (borderless) unless the owning `Board`'s `widgetProps.isCard` opts in; set it per widget to override that default. - **`isDraggable`** `boolean` — Disable dragging for this widget. @@ -223,6 +226,12 @@ Show grid lines behind the widgets while dragging or resizing (or always). With +### Corner chrome + +A control centred on a widget's corner with `cornerChrome`, drawn outside the widget's clip so nothing crops it. With a `resizeGripPlacement="corner"` grip on the opposite corner, the two line up. + + + ### Corner resize grip Centre the resize grip on the widget's corner with `resizeGripPlacement="corner"`, so it lines up with a control centred on the opposite corner. @@ -299,6 +308,21 @@ A group reflows like a single widget — it is compacted by the same rules, so o +### Knowing why the layout changed + +`onLayoutChange` reports a `reason` alongside the layout, because a board commits for two different kinds of reason and an app usually needs to tell them apart. `'drag'`, `'resize'` and `'transfer'` are the user arranging their board. `'normalize'` is the board fitting an existing arrangement to a constraint that moved underneath it — the column count changed, or an `isAutoHeight` widget grew to fit its content. + +An app that persists every commit without looking writes those normalizations back as if they were edits, which marks a document dirty that nobody touched and can overwrite saved positions with ones the board derived. Persist the gestures and let a normalization pass: + +```jsx + { + if (reason === 'normalize') return; + save(layout); + }} +/> +``` + ### Restyling the selection diff --git a/src/components/layout/Board/Board.stories.tsx b/src/components/layout/Board/Board.stories.tsx index e787700eb..1607f4396 100644 --- a/src/components/layout/Board/Board.stories.tsx +++ b/src/components/layout/Board/Board.stories.tsx @@ -1,6 +1,7 @@ import { Meta, StoryFn } from '@storybook/react-vite'; import { ReactNode, useState } from 'react'; +import { SettingsIcon } from '../../../icons'; import { Button } from '../../actions/Button'; import { Text } from '../../content/Text'; import { Title } from '../../content/Title'; @@ -317,6 +318,46 @@ const FixedMatrixTemplate: StoryFn = (args) => ( ); +const CornerChromeTemplate: StoryFn = (args) => ( + + {defaultLayout.map((item) => ( + } + aria-label={`Configure ${item.i}`} + onPress={() => {}} + /> + } + > + + + ))} + +); + +export const CornerChrome = CornerChromeTemplate.bind({}); +CornerChrome.args = {}; +CornerChrome.parameters = { + docs: { + description: { + story: + "`cornerChrome` centres a control on the widget's corner, drawn in the same layer as the corner resize grips - the layer that escapes the widget's own `overflow: hidden`. Chrome hung off the corner from inside a widget gets cropped in half by that clip, or by an ancestor's scroll container in the first row. It also sits outside the drag gesture, so pressing it never starts a drag. Here every widget pairs it with a `corner` resize grip on the opposite corner.", + }, + }, +}; + export const FixedMatrix = FixedMatrixTemplate.bind({}); FixedMatrix.args = {}; FixedMatrix.parameters = { diff --git a/src/components/layout/Board/Board.test.tsx b/src/components/layout/Board/Board.test.tsx index 91b436c96..1f84fda57 100644 --- a/src/components/layout/Board/Board.test.tsx +++ b/src/components/layout/Board/Board.test.tsx @@ -2601,7 +2601,11 @@ describe('Board', () => { expect(committed.find((it) => it.i === 'a')).toEqual( expect.objectContaining({ x: 4, y: 0, w: 1, h: 1 }), ); - expect(onChildLayoutChange).toHaveBeenCalledWith([]); + // The child board empties, and says why: the widget left for another + // board rather than the user rearranging this one. + expect(onChildLayoutChange).toHaveBeenCalledWith([], { + reason: 'transfer', + }); expect(onWidgetTransfer).toHaveBeenCalledWith( expect.objectContaining({ widgetId: 'a', @@ -3748,4 +3752,250 @@ describe('Board', () => { }); }); }); + + describe('corner chrome', () => { + const pointerEvent = (type: string, pageX: number, pageY: number) => { + const event = new PointerEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + pointerId: 1, + pointerType: 'mouse', + }); + Object.defineProperty(event, 'pageX', { get: () => pageX }); + Object.defineProperty(event, 'pageY', { get: () => pageY }); + return event; + }; + + it('renders chrome outside the widget, so its own clip cannot crop it', () => { + render( + + Settings} + > + A + + , + ); + + const chrome = screen.getByRole('button', { name: 'Settings' }); + expect(chrome).toBeInTheDocument(); + // The widget host clips its content (`overflow: hidden`), which is the + // whole reason this slot exists — so the chrome must NOT be inside it. + expect(screen.getByTestId('WidgetA').contains(chrome)).toBe(false); + }); + + it('keeps chrome reachable by assistive tech', () => { + // The grip layer is `aria-hidden` for the grips' sake. Chrome is real UI, + // so a layer holding chrome must not inherit that. + render( + + Settings} + > + A + + , + ); + + expect( + screen + .getByRole('button', { name: 'Settings' }) + .closest('[aria-hidden="true"]'), + ).toBeNull(); + }); + + it('does not start a drag when the chrome is pressed', () => { + const onLayoutChange = vi.fn(); + render( + + Settings} + > + A + + , + ); + + const chrome = screen.getByRole('button', { name: 'Settings' }); + fireEvent(chrome, pointerEvent('pointerdown', 0, 0)); + fireEvent(window, pointerEvent('pointermove', 200, 200)); + fireEvent(window, pointerEvent('pointerup', 200, 200)); + + // Chrome lives outside the widget host, so `useMove` is not even attached + // to it — no `dragCancel` entry needed to protect it. + expect(onLayoutChange).not.toHaveBeenCalled(); + }); + }); + + describe('custom widget modifiers', () => { + it('resolves a style map against an app-defined modifier', () => { + render( + + + A + + , + ); + + expect(screen.getByTestId('WidgetA')).toHaveAttribute('data-editing'); + }); + + it('accepts board-wide modifiers via widgetProps, letting a widget override', () => { + render( + + + A + + + B + + , + ); + + // The board-level default reaches a widget that sets nothing... + expect(screen.getByTestId('WidgetA')).toHaveAttribute('data-compact'); + // ...and a widget's own value wins over it. + expect(screen.getByTestId('WidgetB')).not.toHaveAttribute('data-compact'); + }); + + it('accepts corner chrome via widgetProps', () => { + render( + Shared, + cornerChromePlacement: 'sw', + }} + > + A + , + ); + + // Typed as a widget default, so it has to behave like one — being silently + // dropped is worse than not accepting it at all. + expect( + screen.getAllByRole('button', { name: 'Shared' }), + ).not.toHaveLength(0); + }); + + it('keeps app modifiers on the clone that floats during a pointer drag', () => { + const pointerEvent = (type: string, pageX: number, pageY: number) => { + const event = new PointerEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + pointerId: 1, + pointerType: 'mouse', + }); + Object.defineProperty(event, 'pageX', { get: () => pageX }); + Object.defineProperty(event, 'pageY', { get: () => pageY }); + return event; + }; + + render( + + + A + + , + ); + + fireEvent( + screen.getByTestId('WidgetA'), + pointerEvent('pointerdown', 0, 0), + ); + fireEvent(window, pointerEvent('pointermove', 120, 0)); + + // While a pointer drag is in flight the clone IS the widget — the in-grid + // host is hidden — so a custom state must not blink off for the gesture. + const floating = document.querySelector('[data-floating]'); + expect(floating).not.toBeNull(); + expect(floating).toHaveAttribute('data-editing'); + + fireEvent(window, pointerEvent('pointerup', 120, 0)); + }); + + it('never lets an app modifier shadow one of the board own states', () => { + render( + + + A + + , + ); + + // Nothing is selected, so the board's own `selected: false` has to win over + // the app's claim — the selection styling and the a11y wiring both read it. + expect(screen.getByTestId('WidgetA')).not.toHaveAttribute( + 'data-selected', + ); + }); + }); + + describe('layout change reason', () => { + const pointerEvent = (type: string, pageX: number, pageY: number) => { + const event = new PointerEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + pointerId: 1, + pointerType: 'mouse', + }); + Object.defineProperty(event, 'pageX', { get: () => pageX }); + Object.defineProperty(event, 'pageY', { get: () => pageY }); + return event; + }; + + it('reports a resize as a gesture, not a normalization', () => { + const onLayoutChange = vi.fn(); + render( + + + A + + , + ); + + const handle = document.querySelector( + '[data-qa="BoardResizeHandle"]', + ) as HTMLElement; + fireEvent(handle, pointerEvent('pointerdown', 0, 0)); + fireEvent(window, pointerEvent('pointermove', 200, 0)); + fireEvent(window, pointerEvent('pointerup', 200, 0)); + + expect(onLayoutChange).toHaveBeenCalled(); + expect(onLayoutChange.mock.lastCall![1]).toEqual({ reason: 'resize' }); + }); + }); }); diff --git a/src/components/layout/Board/Board.tsx b/src/components/layout/Board/Board.tsx index 8ac7309d7..4ec6c44a6 100644 --- a/src/components/layout/Board/Board.tsx +++ b/src/components/layout/Board/Board.tsx @@ -63,6 +63,7 @@ import { useBoardSelectModifierKey } from './use-board-select-modifier-key'; import { BoardSelectionMode, useBoardSelection } from './use-board-selection'; import { ResizePhase, WidgetHost } from './WidgetHost'; +import type { BoardLayoutChangeInfo } from './use-board-layout'; import type { BoardResizeGripPlacement, CubeBoardWidgetProps } from './Widget'; const BoardElement = tasty({ @@ -229,7 +230,7 @@ export interface CubeBoardProps layout?: LayoutItem[]; /** Initial layout for uncontrolled usage. */ defaultLayout?: LayoutItem[]; - onLayoutChange?: (layout: LayoutItem[]) => void; + onLayoutChange?: (layout: LayoutItem[], info: BoardLayoutChangeInfo) => void; /** Called when a drag gesture starts. */ onDragStart?: (info: BoardInteractionInfo) => void; /** Called on every step of a drag gesture. */ @@ -334,10 +335,15 @@ export interface CubeBoardProps */ resizeGripPlacement?: BoardResizeGripPlacement; /** - * CSS selector for elements that must not start a pointer drag (e.g. form - * controls inside a widget: `"input,textarea,button,a,.no-drag"`). Does not - * affect keyboard moves — those only run when the widget host itself is - * focused. Can be overridden per widget on `Board.Widget`. + * CSS selector for elements that must not start a pointer drag. + * + * Defaults to {@link BOARD_SELECTION_CANCEL} — the same set `selectionCancel` + * uses — because a control inside a widget has to keep its own press whether + * or not the board happens to support selection. Pass a selector of your own + * to narrow or widen it, or `''` to let a drag start from anywhere. + * + * Does not affect keyboard moves — those only run when the widget host itself + * is focused. Can be overridden per widget on `Board.Widget`. */ dragCancel?: string; /** @@ -489,7 +495,7 @@ function BoardInner( isDroppable = true, resizeHandles = ['se'], resizeGripPlacement = 'inside', - dragCancel, + dragCancel = BOARD_SELECTION_CANCEL, dragHandle, showGridLines, isAligned = false, @@ -811,7 +817,8 @@ function BoardInner( // ref rather than the rendered value. getSelectedKeys: () => selectedKeysRef.current.size > 0 ? selectedKeysRef.current : null, - applyLayout: (next, commit) => applyLayoutEvent(next, commit), + applyLayout: (next, commit, reason) => + applyLayoutEvent(next, commit, reason), setPlaceholders: (items) => setPlaceholdersEvent(items), isDroppable: () => liveRef.current.isDroppable, }; @@ -837,7 +844,7 @@ function BoardInner( const compacted = [ ...liveRef.current.compactor.compact(corrected, nextCols), ]; - applyLayoutEvent(compacted, true); + applyLayoutEvent(compacted, true, 'normalize'); }); // `null` until the first *measured* aligned column count is established. This // avoids treating the initial zero-width -> measured-width transition as a @@ -961,7 +968,7 @@ function BoardInner( if (phase === 'end') { const finalLayout = [...layoutRef.current]; - applyLayout(finalLayout, true); + applyLayout(finalLayout, true, 'resize'); setPlaceholders([]); const resizedItem = getLayoutItem(finalLayout, id) ?? rs.item; onResizeStop?.({ @@ -1087,7 +1094,7 @@ function BoardInner( h: neededRows, }); const compacted = [...liveRef.current.compactor.compact(working, pp.cols)]; - applyLayout(compacted, true); + applyLayout(compacted, true, 'normalize'); }); // The dragged item captured at gesture start, so drag callbacks can report the @@ -1571,6 +1578,14 @@ function BoardInner( registration?.isCard ?? widgetProps?.isCard ?? false; const widgetHoverRing = registration?.hoverRing ?? widgetProps?.hoverRing ?? true; + // Board-level mods merge UNDER the per-widget ones, mirroring + // how `widgetProps.styles` merges under a widget's own styles: + // a shared default every widget carries, which any one widget + // can override for itself. Only allocate when both exist. + const widgetMods = + registration?.mods && widgetProps?.mods + ? { ...widgetProps.mods, ...registration.mods } + : registration?.mods ?? widgetProps?.mods; // Per-widget `isAutoHeight`/`qa` fall back to the board-level // `widgetProps` defaults (mirroring the other widget props). const widgetIsAutoHeight = @@ -1604,6 +1619,14 @@ function BoardInner( registration={registration} isCard={widgetIsCard} hoverRing={widgetHoverRing} + cornerChrome={ + registration?.cornerChrome ?? widgetProps?.cornerChrome + } + cornerChromePlacement={ + registration?.cornerChromePlacement ?? + widgetProps?.cornerChromePlacement + } + mods={widgetMods} styles={widgetStyles as Styles} isDraggable={widgetDraggable} isResizable={widgetResizable} diff --git a/src/components/layout/Board/BoardResponsive.tsx b/src/components/layout/Board/BoardResponsive.tsx index 2fe3e0ac3..a934117db 100644 --- a/src/components/layout/Board/BoardResponsive.tsx +++ b/src/components/layout/Board/BoardResponsive.tsx @@ -16,6 +16,7 @@ import { } from './responsive-utils'; import type { BoardCompactType, CubeBoardProps } from './Board'; +import type { BoardLayoutChangeInfo } from './use-board-layout'; export interface CubeBoardResponsiveProps extends Omit< @@ -36,12 +37,15 @@ export interface CubeBoardResponsiveProps /** Initial per-breakpoint layouts for uncontrolled usage. */ defaultLayouts?: ResponsiveLayouts; /** - * Called when a drag or resize is committed. Receives the active breakpoint's - * layout and the full map of all breakpoint layouts. + * Called when the layout is committed. Receives the active breakpoint's + * layout, the full map of all breakpoint layouts, and why it changed — see + * {@link BoardLayoutChangeInfo}, which is what separates a user's gesture + * from the board reflowing itself. */ onLayoutChange?: ( currentLayout: LayoutItem[], allLayouts: ResponsiveLayouts, + info: BoardLayoutChangeInfo, ) => void; /** Called when the active breakpoint changes. */ onBreakpointChange?: (breakpoint: string, cols: number) => void; @@ -171,12 +175,14 @@ function BoardResponsiveInner( // eslint-disable-next-line react-hooks/exhaustive-deps }, [measuredWidth, activeCols]); - const handleLayoutChange = useEvent((next: LayoutItem[]) => { - const merged = { ...layoutsRef.current, [activeBreakpoint]: next }; - layoutsRef.current = merged; - if (!isControlled) setInternalLayouts(merged); - onLayoutChange?.(next, merged); - }); + const handleLayoutChange = useEvent( + (next: LayoutItem[], info: BoardLayoutChangeInfo) => { + const merged = { ...layoutsRef.current, [activeBreakpoint]: next }; + layoutsRef.current = merged; + if (!isControlled) setInternalLayouts(merged); + onLayoutChange?.(next, merged, info); + }, + ); return ( ; /** Minimum width in grid columns (used when the layout item omits `minW`). */ minW?: number; /** Maximum width in grid columns (used when the layout item omits `maxW`). */ @@ -140,6 +169,9 @@ export function Widget(props: CubeBoardWidgetProps) { resizeGripPlacement, isCard, hoverRing, + cornerChrome, + cornerChromePlacement, + mods, minW, maxW, minH, @@ -191,6 +223,9 @@ export function Widget(props: CubeBoardWidgetProps) { resizeGripPlacement, isCard, hoverRing, + cornerChrome, + cornerChromePlacement, + mods, minW, maxW, minH, diff --git a/src/components/layout/Board/WidgetHost.tsx b/src/components/layout/Board/WidgetHost.tsx index fda1f69d8..b57ec206b 100644 --- a/src/components/layout/Board/WidgetHost.tsx +++ b/src/components/layout/Board/WidgetHost.tsx @@ -1,5 +1,5 @@ import { Styles, tasty } from '@tenphi/tasty'; -import { CSSProperties, useMemo, useRef, useState } from 'react'; +import { CSSProperties, ReactNode, useMemo, useRef, useState } from 'react'; import { useFocusRing, useFocusWithin, useHover, useMove } from 'react-aria'; import { createPortal } from 'react-dom'; @@ -24,7 +24,7 @@ import { } from './grid-core'; import { BoardSelectModifierKey } from './use-board-select-modifier-key'; -import type { BoardResizeGripPlacement } from './Widget'; +import type { BoardCornerPlacement, BoardResizeGripPlacement } from './Widget'; export type ResizePhase = 'start' | 'move' | 'end'; @@ -291,6 +291,53 @@ const GripLayerElement = tasty({ }, }); +/** + * A control anchored to one corner of a widget, centred on it. + * + * Lives in the same layer as the corner resize grips, which is the layer that + * exists precisely because a widget clips its own content: a control an app + * hangs off the corner itself is cropped in half by that clip, or by an + * ancestor's scroll container when the widget sits in the first row. Here it is + * a sibling of the widget rather than a descendant, so neither can reach it. + * + * Being outside the widget host also means `useMove` is not attached, so a press + * on the chrome cannot start a drag — no `dragCancel` entry required. + */ +const CornerChromeElement = tasty({ + qa: 'BoardWidgetCornerChrome', + styles: { + position: 'absolute', + // The layer takes no pointer events; chrome is interactive, so it opts back in. + pointerEvents: 'auto', + zIndex: 1, + top: { + '': 'auto', + 'corner=ne | corner=nw': 0, + }, + bottom: { + '': 'auto', + 'corner=se | corner=sw': 0, + }, + left: { + '': 'auto', + 'corner=nw | corner=sw': 0, + }, + right: { + '': 'auto', + 'corner=ne | corner=se': 0, + }, + // Centre it on the corner: half of its own size in each direction. The + // default matches `ne`, which is also the default placement, so chrome is + // still centred rather than hanging off-centre if no corner mod matches. + transform: { + '': 'translate(50%, -50%)', + 'corner=nw': 'translate(-50%, -50%)', + 'corner=se': 'translate(50%, 50%)', + 'corner=sw': 'translate(-50%, 50%)', + }, + }, +}); + // Edge axes (n/s/e/w) get a dotted grip affordance, revealed on // hover/focus/resize. The dots line up along the edge (a vertical column for the // e/w handles, a horizontal row for n/s), matching the design-system pane grip @@ -422,6 +469,12 @@ export interface WidgetHostProps { * `widgetProps.hoverRing` default. */ hoverRing: boolean; + /** Corner-anchored chrome, drawn outside the widget's clip. */ + cornerChrome?: ReactNode; + /** Which corner {@link cornerChrome} is centred on. */ + cornerChromePlacement?: BoardCornerPlacement; + /** App-defined modifiers merged into the host's own, for style maps to match. */ + mods?: Record; /** * Resolved style overrides for the rendered widget element (per-widget * `styles` falling back to the board-level `widgetProps.styles`). @@ -522,6 +575,9 @@ export function WidgetHost(props: WidgetHostProps) { registration, isCard, hoverRing, + cornerChrome, + cornerChromePlacement = 'ne', + mods: customMods, styles: widgetStyles, isDraggable, isResizable, @@ -1004,7 +1060,15 @@ export function WidgetHost(props: WidgetHostProps) { // The floating clone carries the "drag" affordance (raised shadow/z-index); // keep the hidden host flat. Keyboard drags (which never float) still // highlight the in-grid host, so only suppress `drag` when floating. - const hostMods = { ...mods, drag: isActiveDrag && !floatInOverlay, settled }; + // App mods go UNDER the board's own: a custom mod must never be able to + // shadow `selected`, `drag` and friends, which the board's own style map and + // its accessibility wiring both depend on. + const hostMods = { + ...customMods, + ...mods, + drag: isActiveDrag && !floatInOverlay, + settled, + }; // The host is always rendered first, with a stable element shape, so React // reuses the same DOM node across the drag transition (never remounts it). @@ -1053,7 +1117,11 @@ export function WidgetHost(props: WidgetHostProps) { height: `${floatRect!.height}px`, pointerEvents: 'none', }} - mods={{ ...mods, drag: true, floating: true }} + // `hostMods` rather than `mods`, so the app's own modifiers survive the + // gesture: the clone IS the widget while a pointer drag is in flight + // (the in-grid host is hidden), and a custom state blinking off for the + // duration of the drag is exactly when it would be most noticeable. + mods={{ ...hostMods, drag: true, floating: true }} styles={widgetStyles as Styles} aria-hidden="true" > @@ -1073,8 +1141,10 @@ export function WidgetHost(props: WidgetHostProps) { // Suppressed while the widget floats in the drag overlay: the layer mirrors the // widget's *grid* rect, so leaving it behind would park a live hit-zone on a // cell the widget has visually left. + // The layer is also what carries corner chrome, so it renders when either the + // grips or the chrome need it. const gripLayer = - cornerAxes.length && !floatInOverlay ? ( + (cornerAxes.length || cornerChrome) && !floatInOverlay ? ( ) : null; diff --git a/src/components/layout/Board/board-context.tsx b/src/components/layout/Board/board-context.tsx index 910201c0e..a0486273d 100644 --- a/src/components/layout/Board/board-context.tsx +++ b/src/components/layout/Board/board-context.tsx @@ -8,6 +8,7 @@ import type { LayoutItem, PositionParams, } from './grid-core'; +import type { BoardLayoutChangeReason } from './use-board-layout'; /** A rectangle in viewport (client) coordinates. */ export interface ViewportRect { @@ -47,7 +48,11 @@ export interface BoardEntry { */ getSelectedKeys: () => ReadonlySet | null; /** Update the board layout. `commit` fires `onLayoutChange`. */ - applyLayout: (layout: LayoutItem[], commit: boolean) => void; + applyLayout: ( + layout: LayoutItem[], + commit: boolean, + reason?: BoardLayoutChangeReason, + ) => void; /** Replace every drop-slot preview. Pass `[]` to clear. */ setPlaceholders: (items: LayoutItem[]) => void; isDroppable: () => boolean; diff --git a/src/components/layout/Board/board-store.ts b/src/components/layout/Board/board-store.ts index 4c9e5977c..1f1a22d84 100644 --- a/src/components/layout/Board/board-store.ts +++ b/src/components/layout/Board/board-store.ts @@ -1,7 +1,7 @@ import type { Styles } from '@tenphi/tasty'; import type { ReactNode } from 'react'; import type { LayoutConstraint, ResizeHandleAxis } from './grid-core'; -import type { BoardResizeGripPlacement } from './Widget'; +import type { BoardCornerPlacement, BoardResizeGripPlacement } from './Widget'; /** * Declarative registration for a single widget. @@ -29,6 +29,12 @@ export interface WidgetRegistration { * board's `widgetProps.hoverRing` when unset here. */ hoverRing?: boolean; + /** Corner-anchored chrome for this widget. */ + cornerChrome?: ReactNode; + /** Which corner the chrome sits on. */ + cornerChromePlacement?: BoardCornerPlacement; + /** App-defined modifiers merged into the host's own. */ + mods?: Record; /** Minimum width in grid columns (fallback when the layout item omits `minW`). */ minW?: number; /** Maximum width in grid columns (fallback when the layout item omits `maxW`). */ @@ -93,6 +99,9 @@ export class BoardWidgetStore { prev.resizeGripPlacement !== reg.resizeGripPlacement || prev.isCard !== reg.isCard || prev.hoverRing !== reg.hoverRing || + prev.cornerChrome !== reg.cornerChrome || + prev.cornerChromePlacement !== reg.cornerChromePlacement || + prev.mods !== reg.mods || prev.minW !== reg.minW || prev.maxW !== reg.maxW || prev.minH !== reg.minH || diff --git a/src/components/layout/Board/index.tsx b/src/components/layout/Board/index.tsx index ac03053fd..f99fa6033 100644 --- a/src/components/layout/Board/index.tsx +++ b/src/components/layout/Board/index.tsx @@ -23,7 +23,15 @@ export type { } from './Board'; export type { BoardSelectionMode } from './use-board-selection'; export type { CubeBoardResponsiveProps } from './BoardResponsive'; -export type { CubeBoardWidgetProps, BoardResizeGripPlacement } from './Widget'; +export type { + CubeBoardWidgetProps, + BoardResizeGripPlacement, + BoardCornerPlacement, +} from './Widget'; +export type { + BoardLayoutChangeInfo, + BoardLayoutChangeReason, +} from './use-board-layout'; export type { CubeBoardProviderProps } from './BoardProvider'; export type { WidgetTransferInfo } from './board-context'; export type { diff --git a/src/components/layout/Board/use-board-layout.ts b/src/components/layout/Board/use-board-layout.ts index c7f1ae8ca..0b3b9733a 100644 --- a/src/components/layout/Board/use-board-layout.ts +++ b/src/components/layout/Board/use-board-layout.ts @@ -4,12 +4,39 @@ import { useEvent } from '../../../_internal/hooks'; import { cloneLayout, LayoutItem } from './grid-core'; +/** + * Why the layout changed. + * + * A board commits for two different kinds of reason, and an app usually cares + * which: a gesture is the user arranging their board, while a normalization is + * the board fitting an existing arrangement to a constraint that moved + * underneath it. Without this, an app that persists `onLayoutChange` has no way + * to tell "the user dragged a widget" from "the column count changed and the + * board reflowed", so it writes the reflow back as if it were an edit — marking + * a document dirty nobody touched. + * + * - `drag` / `resize` — a pointer or keyboard gesture on this board. + * - `transfer` — a widget arrived from, or left for, another board. + * - `normalize` — no gesture: a reflow for a changed column count, or an + * auto-height widget growing to fit its content. + */ +export type BoardLayoutChangeReason = + | 'drag' + | 'resize' + | 'transfer' + | 'normalize'; + +/** What `onLayoutChange` reports alongside the new layout. */ +export interface BoardLayoutChangeInfo { + reason: BoardLayoutChangeReason; +} + export interface UseBoardLayoutOptions { /** Controlled layout. */ layout?: LayoutItem[]; /** Initial layout for uncontrolled usage. */ defaultLayout?: LayoutItem[]; - onLayoutChange?: (layout: LayoutItem[]) => void; + onLayoutChange?: (layout: LayoutItem[], info: BoardLayoutChangeInfo) => void; } export interface UseBoardLayoutResult { @@ -35,8 +62,16 @@ export interface UseBoardLayoutResult { placeholdersRef: React.MutableRefObject; placeholderRef: React.MutableRefObject; setPlaceholders: (items: LayoutItem[]) => void; - /** Update the layout. `commit` fires `onLayoutChange`. */ - applyLayout: (layout: LayoutItem[], commit: boolean) => void; + /** + * Update the layout. `commit` fires `onLayoutChange` with `reason`, which is + * required on a commit so a new commit path cannot forget to say why it + * fired. + */ + applyLayout: ( + layout: LayoutItem[], + commit: boolean, + reason?: BoardLayoutChangeReason, + ) => void; } /** @@ -69,8 +104,9 @@ export function useBoardLayout( setPlaceholdersState(items); }, []); - const onLayoutChangeEvent = useEvent((next: LayoutItem[]) => - onLayoutChange?.(next), + const onLayoutChangeEvent = useEvent( + (next: LayoutItem[], reason: BoardLayoutChangeReason) => + onLayoutChange?.(next, { reason }), ); // Sync controlled prop into local state when it changes by reference. @@ -85,11 +121,15 @@ export function useBoardLayout( }, [controlledLayout, isControlled]); const applyLayout = useCallback( - (next: LayoutItem[], commit: boolean) => { + ( + next: LayoutItem[], + commit: boolean, + reason: BoardLayoutChangeReason = 'normalize', + ) => { layoutRef.current = next; setLayout(next); if (commit) { - onLayoutChangeEvent(next); + onLayoutChangeEvent(next, reason); } }, [onLayoutChangeEvent], diff --git a/src/components/layout/Board/use-board-registry.ts b/src/components/layout/Board/use-board-registry.ts index df00e9bb4..901689d91 100644 --- a/src/components/layout/Board/use-board-registry.ts +++ b/src/components/layout/Board/use-board-registry.ts @@ -1136,7 +1136,11 @@ export function useBoardRegistry( if (source) { const sp = source.getPositionParams(); const sc = source.getCompactor(); - source.applyLayout([...sc.compact(source.getLayout(), sp.cols)], true); + source.applyLayout( + [...sc.compact(source.getLayout(), sp.cols)], + true, + 'drag', + ); } } else { const landing = lastLandingRef.current ?? { x: ds.item.x, y: ds.item.y }; @@ -1180,7 +1184,11 @@ export function useBoardRegistry( const sp = source.getPositionParams(); const sc = source.getCompactor(); const remaining = source.getLayout().filter((l) => l.i !== ds.itemId); - source.applyLayout([...sc.compact(remaining, sp.cols)], true); + source.applyLayout( + [...sc.compact(remaining, sp.cols)], + true, + 'transfer', + ); } let finalLayout: LayoutItem[]; @@ -1259,7 +1267,7 @@ export function useBoardRegistry( ), ]; } - target!.applyLayout(finalLayout, true); + target!.applyLayout(finalLayout, true, 'transfer'); // Signal the transfer so a controlled app can move the widget's // declaration into the destination container (positions are already diff --git a/src/eslint-plugin/defaults.generated.ts b/src/eslint-plugin/defaults.generated.ts index 9ab15e6f8..a5687d62c 100644 --- a/src/eslint-plugin/defaults.generated.ts +++ b/src/eslint-plugin/defaults.generated.ts @@ -73,6 +73,8 @@ export const DEFAULTS: DefaultsRegistry = { collisionMode: { kind: 'default', value: 'revert' }, cols: { kind: 'default', value: 12 }, compact: { kind: 'default', value: 'vertical' }, + cornerChromePlacement: { kind: 'default', value: 'ne' }, + dragCancel: { kind: 'default', value: 'BOARD_SELECTION_CANCEL' }, extraRows: { kind: 'default', value: 0 }, hoverRing: { kind: 'default', value: true }, isAligned: { kind: 'default', value: false }, diff --git a/src/index.ts b/src/index.ts index 85e4a9208..ef81f59b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -90,6 +90,9 @@ export type { LayoutItem as BoardLayoutItem, ResizeHandleAxis as BoardResizeHandleAxis, BoardResizeGripPlacement, + BoardCornerPlacement, + BoardLayoutChangeInfo, + BoardLayoutChangeReason, CollisionMode as BoardCollisionMode, Compactor as BoardCompactor, LayoutConstraint as BoardLayoutConstraint,