Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 15 additions & 0 deletions .changeset/board-widget-chrome-and-intent.md
Original file line number Diff line number Diff line change
@@ -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` 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.
28 changes: 26 additions & 2 deletions src/components/layout/Board/Board.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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<string, boolean | string | undefined>` — 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.
- **`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.
Expand Down Expand Up @@ -223,6 +226,12 @@ Show grid lines behind the widgets while dragging or resizing (or always). With

<Story of={BoardStories.GridLines} />

### 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.

<Story of={BoardStories.CornerChrome} />

### 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.
Expand Down Expand Up @@ -299,6 +308,21 @@ A group reflows like a single widget — it is compacted by the same rules, so o

<Story of={BoardStories.ControlledSelection} />

### 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
<Board
onLayoutChange={(layout, { reason }) => {
if (reason === 'normalize') return;
save(layout);
}}
/>
```

### Restyling the selection

<Story of={BoardStories.RestyledSelection} />
Expand Down
41 changes: 41 additions & 0 deletions src/components/layout/Board/Board.stories.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -317,6 +318,46 @@ const FixedMatrixTemplate: StoryFn<CubeBoardProps> = (args) => (
</div>
);

const CornerChromeTemplate: StoryFn<CubeBoardProps> = (args) => (
<Board
fill="#light"
padding="1x"
radius="1r"
widgetProps={{ isCard: true, resizeGripPlacement: 'corner' }}
defaultLayout={defaultLayout}
{...args}
>
{defaultLayout.map((item) => (
<Board.Widget
key={item.i}
id={item.i}
aria-label={`Widget ${item.i}`}
cornerChrome={
<Button
size="small"
icon={<SettingsIcon />}
aria-label={`Configure ${item.i}`}
onPress={() => {}}
/>
}
>
<WidgetBody title={`Widget ${item.i}`} />
</Board.Widget>
))}
</Board>
);

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 = {
Expand Down
172 changes: 171 additions & 1 deletion src/components/layout/Board/Board.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -3748,4 +3752,170 @@ 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(
<Board width={1200} defaultLayout={baseLayout}>
<Board.Widget
id="a"
qa="WidgetA"
cornerChrome={<button type="button">Settings</button>}
>
A
</Board.Widget>
</Board>,
);

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(
<Board width={1200} defaultLayout={baseLayout}>
<Board.Widget
id="a"
cornerChrome={<button type="button">Settings</button>}
>
A
</Board.Widget>
</Board>,
);

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(
<Board
width={1200}
defaultLayout={baseLayout}
onLayoutChange={onLayoutChange}
>
<Board.Widget
id="a"
cornerChrome={<button type="button">Settings</button>}
>
A
</Board.Widget>
</Board>,
);

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(
<Board width={1200} defaultLayout={baseLayout}>
<Board.Widget
id="a"
qa="WidgetA"
mods={{ editing: true }}
// Deliberately no `''` entry: that is what keeps this in tasty's
// EXTEND mode, so the board's own `selected` / `drag` treatments
// survive. The rule cannot see that a widget `styles` prop is always
// merged onto a parent map.
// oxlint-disable-next-line tasty/require-default-state
styles={{ shadow: { editing: '0 0 0 1bw #primary' } }}
>
A
</Board.Widget>
</Board>,
);

expect(screen.getByTestId('WidgetA')).toHaveAttribute('data-editing');
});

it('never lets an app modifier shadow one of the board own states', () => {
render(
<Board width={1200} defaultLayout={baseLayout} selectionMode="single">
<Board.Widget id="a" qa="WidgetA" mods={{ selected: true }}>
A
</Board.Widget>
</Board>,
);

// 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(
<Board
width={1200}
rowHeight={100}
margin={[0, 0]}
containerPadding={[0, 0]}
defaultLayout={[{ i: 'a', x: 0, y: 0, w: 2, h: 2 }]}
onLayoutChange={onLayoutChange}
>
<Board.Widget id="a" qa="WidgetA">
A
</Board.Widget>
</Board>,
);

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' });
});
});
});
Loading
Loading