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
55 changes: 55 additions & 0 deletions .changeset/board-selection-and-group-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
'@cube-dev/ui-kit': minor
---

Add widget selection and rigid group movement to `Board`.

Set `selectionMode="single" | "multiple"` and read the selection with
`selectedKeys` / `defaultSelectedKeys` / `onSelectionChange` (keys are layout item
ids, always returned in layout order).

Pressing a widget selects it on pointer-down and arms a drag of the selection —
selecting and grabbing are one gesture, so move the pointer and it drags, stay
still and it was only a selection. <kbd>Shift</kbd> (or <kbd>Cmd</kbd>/
<kbd>Ctrl</kbd>) toggles membership, dragging from empty canvas lassos
(`allowMarqueeSelection`), <kbd>Space</kbd> toggles the focused widget, and
<kbd>Escape</kbd> clears.

Selection behaves like focus: it tracks what the user is working with and moves on
as soon as they touch something else — pressing another widget makes that the
selection, and pressing an interactive control inside a widget or moving focus off
the board drops it entirely.

With `"multiple"`, dragging any selected widget moves the whole selection as a
rigid block that reflows by the board's own rules — the same compaction a single
widget gets, so a group can never be parked in empty space on a `vertical` board
and the widgets around it close the gap in the same frame. Every widget travels by the same delta, the group clamps against the
grid edge as a unit instead of collapsing into it, a frame that cannot be placed
is rejected outright rather than partially applied, and the move commits through a
single `onLayoutChange`. Arrow keys move the group too. `BoardInteractionInfo`
gains `items`, `oldItems` and `placeholders` describing the whole gesture; the
existing `item` / `oldItem` / `placeholder` fields are unchanged, and a board with
no selection behaves exactly as before.

The `selectionCancel` selector (board- or widget-level, defaulting to the exported
`BOARD_SELECTION_CANCEL`) marks interactive descendants; `[data-no-select]` opts
out a custom control. On a selectable board it also gates dragging, which fixes a
long-standing trap: `useMove`'s pointer-down calls `preventDefault()`, so without
a `dragCancel` an `input` inside a widget could not be focused or typed into.
Selected widgets are drawn with a `#primary-border` border and a `#primary` ring —
an edge treatment rather than a fill, since selection reads as a focus-like state;
`outline` stays reserved for the real focus ring. Widgets get a `selected`
modifier you can restyle through `widgetProps.styles`.

`onWidgetsDelete` reports a <kbd>Delete</kbd>/<kbd>Backspace</kbd> press with a
non-empty selection. Board never mutates the layout itself, so removal stays
yours to implement and to make undoable.

Accessibility: widget hosts are now `role="group"` with an accessible name from
the new `Board.Widget` `aria-label` prop (falling back to `qa`, then the layout
id). `aria-roledescription` is now localized rather than hardcoded English — it
was previously also invalid, sitting on a role-less element. Selected widgets are
described as "Selected", and selection changes are announced through a polite live
region.

Widget hosts also expose `data-board-widget-id` and `data-selected`.
17 changes: 11 additions & 6 deletions .size-limit.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,20 @@ module.exports = [
}),
);
},
// 460.16 kB at the time of writing. Raised from 460 kB for Tasty v3, which
// it exceeded by 161 B — its new dev diagnostics ship in every bundle,
// because `isDevEnv()` is evaluated at runtime so one build serves dev and
// production. Headroom is deliberately small so real bloat still trips the
// budget.
// 464.27 kB at the time of writing. Raised from 462 kB for Board selection
// and group movement: ~3.5 kB of engine (a rigid multi-item move primitive,
// selection state, marquee hit-testing, a live region) plus ~0.5 kB for the
// six `board.*` strings across twelve locales, which are all registered
// eagerly. Measured by building with and without the locale keys.
//
// The Button budget below is unchanged, which is the check that matters:
// none of this reaches a consumer who does not import `Board`.
//
// Headroom is deliberately small so real bloat still trips the budget.
//
// Note when checking locally: `size-limit` bundles the built `./dist`, it
// does not build. Run `pnpm build` first or you will measure a stale bundle.
limit: '462kB',
limit: '466kB',
},
{
name: 'Tree shaking (just a Button)',
Expand Down
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,28 @@ See `src/stories/CreateComponent.docs.mdx` (Storybook → **Getting Started / Cr

See `src/stories/Usage.docs.mdx` (Storybook → **Getting Started / Usage**) for units, base/spacing/size/shadow/layout tokens, color tokens, typography presets, themes, recipes, modifiers, state syntax, icons, and the form system.

## i18n

Full rules in [`src/i18n/README.md`](src/i18n/README.md). The short version:

- **Scope: strings a component renders.** Anything the component itself puts in
front of a user — visible text, `aria-label`, `aria-roledescription`, live-region
announcements, `title` — goes through `useI18n()`:
`t('component.key', 'English default')`. The inline English stays as a
belt-and-braces fallback.
- **Not for stories, docs, or tests.** Storybook stories, `.docs.mdx`, and specs are
demo and fixture copy, not product UI. Use plain literals there — a locale key
that exists only to feed a story is noise in twelve files, and a test that reads
its expectation from the bundle asserts nothing about the string.
- **Component props that expose a label stay overrides** that win over the
translated default: `emptyLabel = t('...', 'No items')`.
- **All 12 locales, every time.** `en-US` is the source of truth;
`locale-parity.test.ts` fails CI if any locale's key set or `{{interpolation}}`
tokens diverge. Interpolation is `{{double}}` braces with no ICU, so plurals need
separate keys rather than a plural rule.
- **If a string doubles as a DOM selector**, build the selector from the same
`t(...)` value so the two cannot drift when the language changes.

## TypeScript & Exports

- **Module augmentation:** `src/tasty-augment.d.ts` extends `@tenphi/tasty` with project-specific color tokens, preset names, and theme names.
Expand Down
Loading
Loading