@pienter/ui 0.3.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +258 -0
  2. package/CONVENTIONS.md +342 -385
  3. package/README.md +33 -18
  4. package/components/display/record-details/RecordDetails.vue +61 -0
  5. package/components/display/record-details/record-details.css +37 -0
  6. package/components/display/record-details/types.ts +8 -0
  7. package/components/feedback/toast/toast.css +1 -1
  8. package/components/form/block-editor/BlockEditor.vue +454 -0
  9. package/components/form/block-editor/block-editor.css +149 -0
  10. package/components/form/block-editor/types.ts +15 -0
  11. package/components/form/combobox/Combobox.vue +42 -72
  12. package/components/form/combobox/combobox.css +1 -1
  13. package/components/form/form/Form.vue +1 -2
  14. package/components/form/label/label.css +1 -1
  15. package/components/form/number-field/NumberField.vue +1 -2
  16. package/components/form/number-field/number-field.css +1 -1
  17. package/components/form/radio-group/RadioGroup.vue +2 -5
  18. package/components/form/record-form/RecordFields.vue +128 -0
  19. package/components/form/record-form/RecordForm.vue +116 -0
  20. package/components/form/record-form/fields.ts +20 -0
  21. package/components/form/record-form/record-form.css +15 -0
  22. package/components/form/record-form/types.ts +28 -0
  23. package/components/form/slider/slider.css +2 -3
  24. package/components/form/tags-input/tags-input.css +1 -2
  25. package/components/form/text-input/text-input.css +2 -0
  26. package/components/form/textarea/textarea.css +1 -1
  27. package/components/layout/accordion/Accordion.vue +6 -27
  28. package/components/layout/collapsible/Collapsible.vue +9 -19
  29. package/components/layout/index/Index.vue +353 -0
  30. package/components/layout/index/index.css +114 -0
  31. package/components/layout/index/useIndex.ts +390 -0
  32. package/components/layout/table/table.css +5 -4
  33. package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
  34. package/components/navigation/breadcrumb/breadcrumb.css +15 -0
  35. package/components/navigation/sidebar/Sidebar.vue +329 -84
  36. package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
  37. package/components/navigation/sidebar/context.ts +21 -0
  38. package/components/navigation/sidebar/sidebar.css +346 -78
  39. package/components/navigation/sidebar/types.ts +13 -1
  40. package/components/navigation/tabs/Tabs.vue +6 -0
  41. package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
  42. package/components/overlay/command/Command.vue +10 -38
  43. package/components/overlay/command/command.css +1 -1
  44. package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
  45. package/components/overlay/modal/Modal.vue +7 -28
  46. package/components/overlay/popover/Popover.vue +9 -31
  47. package/components/overlay/sheet/Sheet.vue +7 -28
  48. package/components/overlay/tooltip/Tooltip.vue +14 -19
  49. package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
  50. package/composables/useDisclosure.ts +113 -0
  51. package/composables/useEventListener.ts +16 -0
  52. package/composables/useMenu.ts +212 -0
  53. package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
  54. package/package.json +16 -40
  55. package/styles/0-settings/colors.css +10 -0
  56. package/styles/0-settings/layout.css +18 -0
  57. package/styles/0-settings/motion.css +2 -2
  58. package/styles/0-settings/spacing.css +3 -1
  59. package/utils/a11y/focus.ts +9 -3
  60. package/utils/cms/index.ts +283 -0
  61. package/utils/cms/schema.json +126 -0
  62. package/utils/navigation/sidebar.ts +97 -0
  63. package/utils/validation/form.ts +6 -9
  64. package/components/action/button/Button.astro +0 -95
  65. package/components/action/button/IconButton.astro +0 -86
  66. package/components/display/avatar/Avatar.astro +0 -17
  67. package/components/display/avatar/AvatarStack.astro +0 -9
  68. package/components/display/badge/Badge.astro +0 -15
  69. package/components/display/empty/Empty.astro +0 -9
  70. package/components/display/icon/Icon.astro +0 -52
  71. package/components/feedback/alert/Alert.astro +0 -52
  72. package/components/feedback/progress/Progress.astro +0 -68
  73. package/components/feedback/skeleton/Skeleton.astro +0 -32
  74. package/components/feedback/spinner/Spinner.astro +0 -25
  75. package/components/feedback/toast/Toast.astro +0 -50
  76. package/components/form/checkbox/Checkbox.astro +0 -79
  77. package/components/form/date-input/DateInput.astro +0 -105
  78. package/components/form/form/Form.astro +0 -106
  79. package/components/form/input-otp/InputOTP.astro +0 -147
  80. package/components/form/label/Label.astro +0 -13
  81. package/components/form/number-field/NumberField.astro +0 -142
  82. package/components/form/radio-group/RadioGroup.astro +0 -105
  83. package/components/form/select/Select.astro +0 -105
  84. package/components/form/slider/Slider.astro +0 -205
  85. package/components/form/switch/Switch.astro +0 -75
  86. package/components/form/tags-input/TagsInput.astro +0 -153
  87. package/components/form/text-input/TextInput.astro +0 -84
  88. package/components/form/textarea/Textarea.astro +0 -86
  89. package/components/layout/card/Card.astro +0 -13
  90. package/components/layout/separator/Separator.astro +0 -31
  91. package/components/layout/table/Table.astro +0 -116
  92. package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
  93. package/components/navigation/navbar/Navbar.astro +0 -62
  94. package/components/navigation/pagination/PaginationFooter.astro +0 -24
  95. package/components/navigation/sidebar/Sidebar.astro +0 -132
  96. package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
  97. package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
  98. package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
  99. package/components/overlay/modal/Modal.astro +0 -66
  100. package/utils/controllers/disclosure.ts +0 -117
  101. package/utils/controllers/form.ts +0 -524
  102. package/utils/controllers/index.ts +0 -39
  103. package/utils/controllers/menu.ts +0 -255
  104. package/utils/controllers/number-field.ts +0 -103
  105. package/utils/controllers/otp.ts +0 -252
  106. package/utils/controllers/sidebar.ts +0 -610
  107. package/utils/controllers/slider.ts +0 -336
  108. package/utils/controllers/tags-input.ts +0 -255
  109. package/utils/controllers/toast.ts +0 -426
package/CONVENTIONS.md CHANGED
@@ -47,8 +47,7 @@ components/<category>/<module>/
47
47
  The categories are `action`, `display`, `feedback`, `form`, `layout`,
48
48
  `navigation`, and `overlay`. Cross-module implementation imports use these
49
49
  categorized paths. Package consumers use the explicit flat exports instead,
50
- for example `@pienter/ui/components/Button.vue` or
51
- `@pienter/ui/components/Button.astro`. Category paths, colocated CSS, and
50
+ for example `@pienter/ui/components/Button.vue`. Category paths, colocated CSS, and
52
51
  private helpers are not public package entrypoints.
53
52
 
54
53
  ## Multi-component-per-directory exception
@@ -64,7 +63,6 @@ Current consumers:
64
63
  Rules:
65
64
 
66
65
  - The shared CSS file MUST be named after the shared visual concept (`avatar.css`, `select.css`)
67
- - All co-located components MUST be in the same Astro tier
68
66
  - Each component file has a separate flat public import (`@pienter/ui/components/Avatar.vue` and `@pienter/ui/components/AvatarStack.vue`)
69
67
  - Each component still has its own entry in the directory's `AUDIT.md`
70
68
  - New components SHOULD live in their own directory unless they meet the tightly-coupled-visual-sibling-family bar (i.e., this exception isn't a default — it's earned)
@@ -104,7 +102,7 @@ Variants and runtime states live on `data-*` attributes on the block element, ne
104
102
  | `data-animation` | Continuous decorative animation on the block | `spin` (Icon) |
105
103
  | `data-tone` | Semantic tone for non-variant components (Alert, Badge, Toast) | `brand`, `success`, `warning`, `danger` |
106
104
  | `data-block` | Block-level layout flag (boolean) | `'true'` or absent |
107
- | `data-state` | Runtime state controlled by code or controller | `loading`, `open`, `closed`, `checked`, `indeterminate` |
105
+ | `data-state` | Runtime state controlled by code or a behavior composable | `loading`, `open`, `closed`, `checked`, `indeterminate` |
108
106
  | `data-status` | Derived semantic status for form primitives (validation outcome) | `error`, `success` |
109
107
  | `data-layout` | Form-primitive scaffold layout selector — lives on `.pui-field`; flips between stacked label-above (default) and compact inline control-left + label-right | `stacked` (default; attribute may be omitted), `inline` |
110
108
  | `data-orientation` | Layout direction for grouped controls (RadioGroup items, future SegmentedControl, etc.) — lives on the group container | `vertical` (default for RadioGroup), `horizontal` |
@@ -114,7 +112,7 @@ The example values listed for each attribute are non-exhaustive — components a
114
112
  **`data-state` vs `data-status`** — both attributes describe how a component
115
113
  "is right now," but they have different sources:
116
114
 
117
- - `data-state` is **runtime UI state** — owned by code or a controller, driven
115
+ - `data-state` is **runtime UI state** — owned by code or a behavior composable, driven
118
116
  by interaction (`loading` while a request is in flight, `open`/`closed` for a
119
117
  disclosure, `checked`/`indeterminate` for a toggle). It usually changes in
120
118
  response to user input or async work.
@@ -186,9 +184,8 @@ A component that wraps a single native HTML element (`<button>`, `<input>`, `<a>
186
184
  - [ ] Loading state: emit `disabled` (or `aria-disabled` + `tabindex="-1"`
187
185
  for `<a>`) AND `aria-busy="true"` AND `data-state="loading"` AND a
188
186
  visually-rendered spinner element with `aria-hidden="true"`.
189
- - [ ] Forwards arbitrary attributes via `...rest` (Astro) / inheritAttrs
190
- (Vue) so consumers can attach `id`, `name`, event listeners, ARIA
191
- labels.
187
+ - [ ] Forwards arbitrary attributes via `inheritAttrs` fallthrough so
188
+ consumers can attach `id`, `name`, event listeners, ARIA labels.
192
189
 
193
190
  **Worked example — Button**: see `packages/ui/components/action/button/`. Polymorphic `<button>`/`<a>` via `href`, variants/size/block/state on `data-*`, loading state via `aria-busy` + `data-state="loading"` + `<span class="pui-btn__spinner">` element.
194
191
 
@@ -260,8 +257,8 @@ role="alert">` with one `<li>` per error message, only when errors are
260
257
  etc. so the same selector pattern works across every form primitive.
261
258
  `data-status` is derived: if a `status` prop is set, use it; otherwise
262
259
  derive `error` from a non-empty errors array.
263
- - [ ] Forwards remaining attributes onto the control element (Astro: `...rest`,
264
- Vue: `inheritAttrs` defaults), so consumers can attach `name`,
260
+ - [ ] Forwards remaining attributes onto the control element via
261
+ `v-bind="$attrs"`, so consumers can attach `name`,
265
262
  `autocomplete`, `inputmode`, event listeners, ARIA labels, etc.
266
263
 
267
264
  **Declared props are stripped from `$attrs`** — when a Vue component
@@ -329,7 +326,7 @@ of where the file lives.
329
326
  ### Pure-markup with slots
330
327
 
331
328
  Locked during Phase 1 alongside Card (2026-05-03). A component that renders
332
- styled markup with **no behavior, no controller, no native form semantics**.
329
+ styled markup with **no behavior, no composable wiring, no native form semantics**.
333
330
  Variants drive visual style via `data-variant` / `data-tone`; content flows
334
331
  in through slots (default and/or named). The component is essentially a
335
332
  named CSS chunk wrapped in a framework primitive so consumers don't have to
@@ -345,7 +342,7 @@ remember the wrapper class.
345
342
  see "Slot vs prop conventions" below.
346
343
  - [ ] Variants and tones expressed via `data-variant` or `data-tone` per the
347
344
  locked rules; no per-variant class names.
348
- - [ ] No JS — no controllers, no event listeners, no reactive state. If a
345
+ - [ ] No JS — no behavior composables, no event listeners, no reactive state. If a
349
346
  pure-markup component grows behavior (loading, dismiss, expand), it
350
347
  graduates to one of the other archetypes and the audit entry is updated.
351
348
  - [ ] No ARIA roles **unless the component carries semantic meaning the
@@ -356,15 +353,14 @@ remember the wrapper class.
356
353
  consumer use **inside** the slot content (Card's `pui-card__title`,
357
354
  `pui-card__eyebrow`, etc.), document those classes in the audit entry
358
355
  as **public API**. Renaming or removing them is a breaking change.
359
- - [ ] Vue uses the default `inheritAttrs: true` (the wrapper IS the only
356
+ - [ ] Uses the default `inheritAttrs: true` (the wrapper IS the only
360
357
  element) so consumer-supplied `id`, `class`, `aria-*`, and event
361
- listeners land on the wrapper. Astro spreads `...rest` on the wrapper
362
- to the same effect.
358
+ listeners land on the wrapper.
363
359
 
364
360
  **Worked example — Card**: see `packages/ui/components/layout/card/`. Renders a
365
361
  single `<div class="pui-card" data-variant>` with a default slot. Variants
366
362
  on `data-variant` (`bordered`, `brand`, `muted`); no ARIA role; no
367
- controller. Public child classes — `pui-card`, `pui-card__eyebrow`,
363
+ behavior wiring. Public child classes — `pui-card`, `pui-card__eyebrow`,
368
364
  `pui-card__title`, `pui-card__body`, `pui-card__meta`, `pui-card__footer` — are
369
365
  applied by consumers to their own markup inside the slot.
370
366
 
@@ -408,71 +404,69 @@ pattern. Sidebar is the v1 reference; future Menubar / NavigationMenu
408
404
  items.
409
405
  - [ ] Recursion (when items can nest, e.g. Sidebar groups) is handled
410
406
  inside the component — the consumer always passes a single flat
411
- `items` array, even when items are deep trees. In Vue this is
412
- typically an internal recursive `<SidebarItemRender>` component
413
- that is **not exported** (private to the parent file). In Astro
414
- it is a recursive `.astro` component (see worked Sidebar example
415
- below) — also private.
407
+ `items` array, even when items are deep trees. This is
408
+ typically an internal recursive `<SidebarMenuItem>` component
409
+ that is **not exported** (private to the parent module).
416
410
  - [ ] State that the consumer might want to control (collapsed/expanded
417
411
  shell, drawer open/closed, expanded group set) flows through
418
- props + `v-model` / `update:*` events; runtime state is owned by
419
- a controller (per the Stateful-component archetype).
412
+ props + `v-model` / `update:*` events; runtime side-effects are
413
+ owned by a behavior composable (per the Stateful-component
414
+ archetype).
420
415
 
421
416
  **Worked example — Sidebar**: see `packages/ui/components/navigation/sidebar/`.
422
- Hybrid: `:items` drives the menu (with the `link | group | section`
423
- discriminated union from `sidebar/types.ts`); `#brand` and `#footer`
417
+ Hybrid: `:top-items` and `:bottom-items` drive the two nav regions (with
418
+ the `link | group | section` discriminated union from
419
+ `sidebar/types.ts`); `#brand` and `#bottom`
424
420
  slots cover decorative regions. Active-state derived from
425
421
  `:active-href` via the hierarchical-match algorithm (below). The
426
- recursive item rendering lives in a private `SidebarItemRender.vue`
427
- (Vue) and `SidebarItemRender.astro` (Astro). Runtime state desktop
428
- collapse, mobile drawer, per-group expand owned by `mountSidebar`
429
- in `controllers/sidebar.ts`.
422
+ recursive item rendering lives in a private `SidebarMenuItem.vue`.
423
+ Runtime state desktop collapse, mobile drawer, per-group expand
424
+ follows the D13 layered split: shared primitives in
425
+ `utils/navigation/sidebar.ts`, reactive orchestration inside
426
+ `Sidebar.vue`.
430
427
 
431
428
  ### Form orchestration
432
429
 
433
430
  Locked during Phase 2 alongside Form (2026-05-03). Form is the **orchestrator**: it does not render a control of its own. Its job is to wire submit handling, run client-side validators, render error chrome (top-level summary + per-field), apply success state, and coordinate the submit busy lifecycle. The form-primitive wrappers (TextInput, Textarea, Switch, Checkbox, RadioGroup, Select, NumberField, DateInput) are the **fields**; Form orchestrates them.
434
431
 
435
- **Layered split** — three layers, locked per design D13:
432
+ **Layered split** — two layers, locked per design D13:
436
433
 
437
- 1. **Shared primitives** (used by both Vue and Astro):
434
+ 1. **Shared primitives** (framework-agnostic):
438
435
  - `utils/validation/rules.ts` — pure validators (`isRequired`, `isEmail`, `minLength`, …).
439
436
  - `utils/validation/form.ts:validateFormData(formData, validators)` — runs validators against `FormData`, returns `Record<string, string[]>`. Pure, framework-agnostic.
440
437
  - `utils/validation/form.ts:normalizeFieldErrors(map)` — normalizes the `string | string[]` per-field shape from `FormSubmitResponse` to `string[]`.
441
438
  - `utils/a11y/focus.ts:focusFirstInvalid(formEl)` — DOM-imperative focus helper.
442
439
  - `utils/a11y/live-region.ts:announce(text, priority)` — DOM-imperative announcement.
443
- 2. **Astro form controller** — `controllers/form.ts:mountForm(el, config) teardown`. Full imperative orchestration: intercept submit, run validators, render per-field + summary errors, set `aria-busy`, await `onSubmit`, map response to UI, focus first invalid + announce. Astro's only orchestration path.
444
- 3. **Vue Form orchestration** — lives **inside `Form.vue`** using Vue reactive idioms (refs, watch effects, computed). Calls the shared primitives directly. Does **NOT** wrap `mountForm` — wrapping an imperative DOM controller from Vue fights Vue's data flow (controller mutates the DOM, Vue patches it back, race conditions ensue).
440
+ 2. **Form orchestration** — lives **inside `Form.vue`** using Vue reactive idioms (refs, watch effects, computed). Calls the shared primitives directly. Does **NOT** wrap an imperative DOM controller that fights Vue's data flow (controller mutates the DOM, Vue patches it back, race conditions ensue).
445
441
 
446
- **Why two implementations**: forcing a single shared controller means either (a) Vue wraps imperative DOM (loses reactive benefits), or (b) primitives become so abstract they're not really doing the work. Layered split keeps each implementation idiomatic while sharing the expensive parts (validators, focus management, announcement) and the response contract.
442
+ **Why the split**: pushing the orchestration into a shared controller means Vue wraps imperative DOM and loses its reactive benefits; pushing everything into the SFC buries the expensive parts (validators, focus management, announcement) where they can't be tested or reused. The layered split keeps the orchestration idiomatic while the primitives stay pure.
447
443
 
448
- **Consumer responsibility — Vue vs Astro**:
444
+ **Consumer responsibility**:
449
445
 
450
- | Concern | Vue (`Form.vue`) | Astro (`mountForm`) |
451
- | --------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
452
- | Per-field error display | Consumer wires `:errors` on each form-primitive child from `props.fieldErrors[name]` | Controller writes per-field errors into the field's `pui-field__hint` element automatically |
453
- | `aria-busy` during submit | Consumer flips `:busy` prop on `<Form>` after async submit completes | Controller sets/removes `aria-busy` automatically |
454
- | Status message | Consumer sets `:status-message` prop on `<Form>` | Controller renders `statusMessage` from response automatically |
455
- | Server `errors` (top-level) | Consumer sets `:errors` prop on `<Form>` | Controller renders `response.errors` automatically |
456
- | Throw vs `{ ok: false }` | Consumer's submit handler runs in parent; throwing is the consumer's exception-handling concern | Controller catches `onSubmit` throws and translates to `{ ok: false, errors: ['Submit failed'] }` |
446
+ | Concern | Consumer responsibility (`Form.vue`) |
447
+ | --------------------------- | ----------------------------------------------------------------------------------------------- |
448
+ | Per-field error display | Consumer wires `:errors` on each form-primitive child from `props.fieldErrors[name]` |
449
+ | `aria-busy` during submit | Consumer flips `:busy` prop on `<Form>` after async submit completes |
450
+ | Status message | Consumer sets `:status-message` prop on `<Form>` |
451
+ | Server `errors` (top-level) | Consumer sets `:errors` prop on `<Form>` |
452
+ | Throw vs `{ ok: false }` | Consumer's submit handler runs in parent; throwing is the consumer's exception-handling concern |
457
453
 
458
454
  **Error display rules**:
459
455
 
460
- - Per-field errors land in the form-primitive's `pui-field__hint role="alert"` element, set `aria-invalid="true"` on the control, set `data-status="error"` on the wrapper. Vue: consumer wires `:errors` on each form-primitive child (typical Vue data flow). Astro: `mountForm` mutates the `pui-field` wrapper imperatively, marking the injected error list with `data-pui-form-errors="true"` so a future re-run can clear it without touching consumer-supplied static error markup.
456
+ - Per-field errors land in the form-primitive's `pui-field__hint role="alert"` element, set `aria-invalid="true"` on the control, set `data-status="error"` on the wrapper. The consumer wires `:errors` on each form-primitive child (typical Vue data flow).
461
457
  - Top-level errors (`response.errors[]` or consumer-supplied `:errors` prop) land in `.pui-form__error-summary` at the top of the form, with `role="alert"` and `aria-live="assertive"`.
462
458
  - Success: `statusMessage` renders inside `.pui-form__status` with `aria-live="polite"`, and is also announced via the global ARIA live region by the shared `announce()` primitive.
463
459
 
464
460
  **Submit lifecycle**:
465
461
 
466
462
  1. `submit` event → `event.preventDefault()`. Read `FormData`.
467
- 2. Run client-side validators. If any fail: render field errors, set `aria-invalid` on each invalid control, focus first invalid, announce error count assertively. Do NOT call `onSubmit`. (Vue emits `validation-error` for telemetry.)
468
- 3. If validation passes: clear errors, set `aria-busy="true"` on form, disable submit button, call `await onSubmit(data)` (Vue: emit `submit`; Astro: await `config.onSubmit`).
463
+ 2. Run client-side validators. If any fail: render field errors, set `aria-invalid` on each invalid control, focus first invalid, announce error count assertively. Do NOT call `onSubmit`. (Form emits `validation-error` for telemetry.)
464
+ 3. If validation passes: clear errors, set `aria-busy="true"` on form, disable submit button, hand off to the consumer's submit handler (emit `submit` with the data).
469
465
  4. After `onSubmit` returns: clear `aria-busy`, re-enable submit. Map response — `ok: false` → render server `fieldErrors` + top-level `errors`, focus first invalid, announce; `ok: true` → render `statusMessage`, announce politely.
470
466
 
471
467
  **Disable propagation**: when Form's `busy` is `true`, child form-primitives do NOT auto-disable. The Form-level busy state controls the submit button only. Disabling other fields/buttons inside the form (e.g. a "Cancel" button) is the consumer's call — a Form-wide busy state shouldn't block dismiss affordances.
472
468
 
473
- **Astro consumer pattern (locked)**: `Form.astro` renders chrome only. The consumer mounts `mountForm` from a `<script>` block colocated with the form markup. This is intentional: `validators` (functions) and `onSubmit` (async function) cannot be passed as Astro props. The consumer-writes-the-mount-script pattern is verbose but explicit, and matches how real product surfaces will wire up server-driven validation + redirect handling.
474
-
475
- **Worked example — Form**: see `packages/ui/components/form/form/` and `packages/ui/utils/controllers/form.ts`. Vue uses reactive refs + watch effects; Astro uses `mountForm` from a consumer `<script>` block. Both consume the placeholder `FormSubmitResponse` shape and call the shared `focusFirstInvalid`, `announce`, and `validateFormData` primitives.
469
+ **Worked example Form**: see `packages/ui/components/form/form/`. Reactive refs + watch effects consume the placeholder `FormSubmitResponse` shape and call the shared `focusFirstInvalid`, `announce`, and `validateFormData` primitives.
476
470
 
477
471
  ### Active-href hierarchical-match algorithm
478
472
 
@@ -544,7 +538,7 @@ because the component places each in a fixed DOM position. Examples:
544
538
  - Modal's `#footer` (placed below the body, inside the dialog content,
545
539
  styled as the action bar)
546
540
  - Nav's `#actions` (placed at the trailing edge of the navbar)
547
- - Sidebar's `#brand` and `#footer` (header / footer regions of the layout
541
+ - Sidebar's `#brand` and `#bottom` (header / foot regions of the layout
548
542
  shell, deferred to Sidebar)
549
543
  - AlertDialog's actions slot (deferred to Phase 4)
550
544
 
@@ -617,159 +611,202 @@ component.
617
611
 
618
612
  ---
619
613
 
620
- ## Controller mount-on-element pattern
614
+ ## Choosing an orchestration pattern
615
+
616
+ See ADR 0007 for the reasoning and the failure modes that produced this rule.
617
+
618
+ Two patterns, and the choice is not a matter of taste. What differs is who
619
+ owns the DOM.
620
+
621
+ **A — Shared behavior composable** (`packages/ui/composables/`, below).
622
+ One composable, called at setup time with the element ref; it wires its
623
+ own lifecycle (`onMounted` / `onBeforeUnmount`). The composable owns the
624
+ runtime side-effects; the component stays a thin trigger source.
625
+
626
+ **B — Layered split** (D13, see _Form orchestration_ above). Shared _pure_
627
+ primitives, and reactive orchestration inside the SFC that derives its
628
+ answers from them — one source of truth for the answers.
629
+
630
+ Reach for **B** when the component's runtime state is also _rendered_ state
631
+ — attributes, classes, visibility, inline geometry that the template would
632
+ otherwise want to bind. Under **A** those have two owners: the composable
633
+ writes them imperatively and Vue patches them back. That race is not
634
+ hypothetical; it is
635
+ what D13 was written about, and re-litigating it cost Sidebar a string of
636
+ bugs (a `focusin` handler silently rewriting every `tabindex`, `hidden`
637
+ fighting `visibility`, a flyout's state attribute set from two directions).
638
+
639
+ Stay with **A** when the composable owns things Vue never renders: focus
640
+ traps, scroll locks, top-layer/`<dialog>` mechanics, `popover` wiring,
641
+ event plumbing on `window` or `document`. A shared composable that never
642
+ touches rendered state cannot race with the framework.
643
+
644
+ Practical test: _would Vue want to `v-bind` this?_ If yes, it belongs in
645
+ the template, and the component wants **B**.
646
+
647
+ ### Current assignments
648
+
649
+ - **B (layered split)** — Form (D13, 2026-05-03), Sidebar (ADR 0007,
650
+ 2026-08-27).
651
+ - **A (shared composable)** — everything else listed below.
652
+
653
+ The rest have not been assessed against this rule, let alone migrated.
654
+ Several of them are legitimately **A** — the `<dialog>` and Popover-API
655
+ families barely touch rendered state. Treat the list below as the current
656
+ state of the codebase, not as a claim that each entry was chosen
657
+ deliberately.
658
+
659
+ ---
660
+
661
+ ## Composable behavior pattern
621
662
 
622
- Locked during Phase 1 alongside Modal (2026-05-03). Stateful components (Modal,
623
- AlertDialog, Sheet, Toast, Popover, Tooltip, DropdownMenu, Combobox, Command,
624
- Accordion, Collapsible, plus per-input controllers NumberField, TagsInput,
625
- InputOTP, Slider, Sidebar) are wired through a controller in
626
- `packages/ui/utils/controllers/`. The controller owns the runtime state
627
- machine; the framework (Vue or an Astro `<script>` block) acts as the trigger
628
- source.
663
+ Locked during Phase 1 alongside Modal (2026-05-03) as the controller
664
+ mount-on-element pattern; migrated to Vue composables in 2026-08. This is
665
+ pattern **A** above. Stateful components (Modal, AlertDialog, Sheet,
666
+ Popover, Tooltip, DropdownMenu, Combobox, Command, Accordion, Collapsible)
667
+ are wired through a behavior composable in `packages/ui/composables/`. The
668
+ composable owns the runtime side-effects; the Vue component acts as the
669
+ trigger source.
629
670
 
630
- ### Default signatureteardown-only
671
+ `packages/ui/composables/` holds shared behavior composables Vue code
672
+ that owns DOM side-effects for more than one component. A single
673
+ component's reactive orchestration is not a behavior composable and does
674
+ not live there — Sidebar's lives inside `Sidebar.vue`.
631
675
 
632
- For controllers whose lifecycle is mount → teardown without runtime
633
- imperative methods (e.g., `controllers/form.ts:mountForm`,
634
- per-input mounts), the canonical signature is:
676
+ ### Default signature void, self-cleaning
677
+
678
+ Every behavior composable takes the element ref plus an options object at
679
+ setup time, wires its listeners in `onMounted`, and removes them in
680
+ `onBeforeUnmount`. There are no teardown functions to store and no
681
+ control objects to null — Vue's lifecycle owns the cleanup. For behavior
682
+ with no imperative methods (`useDisclosure`, `useMenu`), the canonical
683
+ signature returns nothing:
635
684
 
636
685
  ```ts
637
- export function mount<Name>(element: HTMLElement, config?: <Config>): () => void
686
+ export function use<Name>(
687
+ elRef: Ref<HTMLElement | null>,
688
+ options?: Use<Name>Options,
689
+ ): void;
638
690
  ```
639
691
 
640
- The returned function tears down all listeners, observers, and DOM mutations
641
- introduced by the controller. Astro components invoke from a `<script>`
642
- block; Vue components invoke from `onMounted` and call the teardown from
643
- `onBeforeUnmount`.
692
+ Components call the composable directly in `<script setup>`; they never
693
+ touch `onMounted` / `onBeforeUnmount` for it.
644
694
 
645
- ### Stateful-component signature — control object
695
+ ### Stateful-component signature — handle
646
696
 
647
- For stateful components with imperative open/close (or show/hide / expand)
648
- methods — Modal, AlertDialog, Sheet, Toast, Popover, Tooltip, DropdownMenu,
649
- Combobox, Accordion, Collapsible — the controller returns a **control
650
- object** instead of a bare teardown function:
697
+ For stateful components with imperative open/close (or show/hide)
698
+ methods, the composable additionally returns a **handle**:
651
699
 
652
700
  ```ts
653
- interface DialogControl {
701
+ export interface DialogHandle {
654
702
  open(): void;
655
703
  close(): void;
656
704
  isOpen(): boolean;
657
- teardown(): void;
658
705
  }
659
706
 
660
- export function mountDialog(
661
- element: HTMLElement,
662
- config?: DialogConfig,
663
- ): DialogControl;
707
+ export function useDialog(
708
+ elRef: Ref<HTMLDialogElement | null>,
709
+ options?: UseDialogOptions,
710
+ ): DialogHandle;
664
711
  ```
665
712
 
666
- The control object collapses the dialog's state machine to a single source of
667
- truth. Frameworks call `control.open()` / `control.close()` and observe the
668
- controller-owned `data-state` attribute; they do not duplicate the state
669
- machine in reactive props. This avoids the dual-state-machine problem where
670
- Vue's `props.open` and a controller's internal state desync after a slow
671
- async transition.
672
-
673
- The framework's reactive prop is the **trigger source** it tells the
674
- controller "I want to be open now"; the controller is the **state machine** —
675
- it owns the focus trap, scroll lock, portal placement, and the rendered
713
+ (`usePopover` returns `{ show, hide, toggle, isOpen, reposition }`.)
714
+ There is no `teardown()` on the handle cleanup runs automatically in
715
+ `onBeforeUnmount`, which closes the overlay first if it is still open so
716
+ listeners and scroll locks are released.
717
+
718
+ The handle collapses the dialog's runtime side-effects to a single source
719
+ of truth. The DOM is the source of truth for whether the overlay is open
720
+ (`el.open` for `<dialog>`, `:popover-open` for popovers`isOpen()` reads
721
+ it); the composable does not duplicate that state in reactive refs. This
722
+ avoids the dual-state-machine problem where Vue's `props.open` and an
723
+ internal state flag desync after a slow async transition.
724
+
725
+ The component's reactive prop is the **trigger source** — the composable's
726
+ controlled `open` option is a getter (`open: () => props.open`) that is
727
+ applied on mount and watched thereafter (`true` opens, `false` closes,
728
+ `undefined` is ignored). The composable is the **state machine** — it owns
729
+ the focus handling, scroll lock, positioning, and the rendered
676
730
  `data-state`. The `onClose` callback closes the loop on user-initiated
677
- close (Escape, backdrop click): the controller invokes `onClose`, the
678
- framework reflects it back through its prop / `update:modelValue`, and the
679
- prop watcher then calls `control.close()` single source of truth in the
680
- framework's reactive layer, single source of truth in the controller's
681
- imperative layer, no duplication.
682
-
683
- `teardown()` is mandatory on every control objectit must release
684
- listeners, focus traps, scroll locks, restore portaled DOM, and null all
685
- internal references. Calling `teardown()` while `isOpen()` is `true` MUST
686
- internally call `close()` first so resources don't leak.
731
+ close (Escape, backdrop click, light-dismiss): the composable invokes
732
+ `onClose` to report the intent, the component reflects it back through its
733
+ prop / `update:modelValue`, and the watched `open` getter then performs
734
+ the actual close single source of truth in the component's reactive
735
+ layer, single source of truth in the DOM, no duplication.
736
+
737
+ Options that can change over the component's lifetime `persistent`,
738
+ `placement`, `offset`, `singleOpen` accept `MaybeRefOrGetter` and are
739
+ read reactively via `toValue()` at use time. `usePopover`'s `anchor` is a
740
+ `MaybeRefOrGetter<HTMLElement | null>` resolved lazily on every
741
+ reposition, so the trigger may appear after mount.
687
742
 
688
743
  ### Singleton-style exception
689
744
 
690
- When a controller manages a single global resource (e.g., the Toast queue
691
- with one global ARIA live region), the module MAY additionally export a
692
- module-level API (`toast.show()`, `toast.dismiss()`). Use sparingly and
745
+ When a behavior module manages a single global resource (e.g., the Toast
746
+ queue with one global ARIA live region), the module MAY additionally export
747
+ a module-level API (`toast.show()`, `toast.dismiss()`). Use sparingly and
693
748
  document the exception here when added.
694
749
 
695
- ### When to share a controller vs write a new one
750
+ ### When to share a composable vs write a new one
696
751
 
697
- Controllers are SHARED when component variants differ only in
752
+ Composables are SHARED when component variants differ only in
698
753
  **configuration** — role, persistent flag, initial focus target, ARIA
699
- attributes set via `setAttribute`. Modal and AlertDialog (Phase 4) share
700
- `mountDialog` because the difference is `{ role: 'alertdialog', persistent:
701
- true, initialFocus: 'cancel' }`.
754
+ attributes set via `setAttribute`. Modal and AlertDialog share
755
+ `useDialog` because the difference is `{ role: 'alertdialog', persistent:
756
+ () => true, initialFocus: 'cancel' }`.
702
757
 
703
- Controllers are SEPARATE when components differ in **geometry, focus rules,
758
+ Composables are SEPARATE when components differ in **geometry, focus rules,
704
759
  or trigger semantics**:
705
760
 
706
- - Popover, Tooltip, DropdownMenu → their own `mountPopover` (anchor-positioned,
761
+ - Popover, Tooltip, DropdownMenu → shared `usePopover` (anchor-positioned,
707
762
  no scroll lock, no focus trap unless a modal popover, click-outside
708
763
  dismisses by default)
709
- - Comboboxits own `mountCombobox` (input + listbox composition, typeahead,
710
- ARIA combobox pattern)
711
- - Accordion + Collapsible share a `mountDisclosure` (expand/collapse pair)
712
- - Sidebar drawer `mountSidebar` (the panel is `<nav>`, not `<dialog>`,
713
- so it can't share `mountDialog`; see "Use native APIs where applicable"
714
- below)
715
-
716
- Sheet is NOT in this list — it shares `mountDialog` with Modal +
764
+ - Accordion + Collapsible share `useDisclosure` (expand/collapse pair)
765
+ - DropdownMenu → additionally `useMenu` (menu keyboard navigation and
766
+ roving tabindex, composed alongside `usePopover`; each is independent
767
+ of the other and the component composes them)
768
+ - Sidebar drawer its own orchestration (the panel is `<nav>`, not
769
+ `<dialog>`, so it can't share `useDialog`; see "Use native APIs where
770
+ applicable" below). Not in `composables/` — see the D13 note below.
771
+
772
+ Sheet is NOT in this list — it shares `useDialog` with Modal +
717
773
  AlertDialog. All three render `<dialog>` and only differ in CSS
718
- geometry (Sheet anchors to a side instead of centering). The controller
774
+ geometry (Sheet anchors to a side instead of centering). The composable
719
775
  mechanics are identical.
720
776
 
777
+ There is no `useCombobox` — Combobox uses `usePopover` for its listbox;
778
+ the combobox-specific behavior (input + listbox composition, typeahead,
779
+ the ARIA combobox pattern) lives inside the component.
780
+
721
781
  Listed mapping (locked):
722
782
 
723
- - `mountDialog` → Modal, AlertDialog, Sheet (all `<dialog>`-based; see "Use
724
- native APIs where applicable" below — Sheet's slide-side geometry is a
725
- CSS concern, the controller mechanics are identical)
726
- - `mountPopover` → Popover, Tooltip, DropdownMenu, HoverCard (all
727
- Popover-API-based; deferred to Phase 4)
728
- - `mountCombobox` → Combobox, Command (search palette)
729
- - `mountDisclosure` → Accordion, Collapsible
730
- - `mountSidebar`Sidebar (its own controller desktop layout-shell
731
- collapse, mobile drawer overlay, per-group expand, and roving-tabindex
732
- keyboard nav are all Sidebar-specific geometry/focus-rule concerns; v1
733
- owns its group expand/collapse inline and may compose into
734
- `mountDisclosure` later)
735
- - `mountToast` Toast (with module-level singleton API per the
736
- singleton-exception rule)
737
- - `mountForm` → Form (Astro orchestration only; Vue uses reactive
738
- idioms inside `Form.vue` and does not wrap the controller per D13).
739
- Signature: `mountForm(formEl, { validators?, onSubmit?, onResult? })`
740
- → teardown. Owns submit interception, client-side validation
741
- kickoff, per-field + top-level error rendering, `aria-busy` lifecycle
742
- management, `focusFirstInvalid` + `announce` calls, and the
743
- `FormSubmitResponse` → UI mapping.
744
- - per-input controllers (each its own file, non-stateful
745
- teardown-only signature per the canonical NumberField pattern):
746
- - `mountNumberField` → NumberField
747
- (`controllers/number-field.ts`). Signature:
748
- `mountNumberField(wrapperEl, { onChange? })` → teardown.
749
- - `mountTagsInput` → TagsInput (`controllers/tags-input.ts`).
750
- Signature: `mountTagsInput(wrapperEl, { initialTags?,
751
- onChange?, maxTags?, separators? })` → teardown.
752
- - `mountInputOTP` → InputOTP (`controllers/otp.ts`). Signature:
753
- `mountInputOTP(wrapperEl, { pattern?, onChange? })` →
754
- teardown.
755
- - `mountSlider` → Slider (`controllers/slider.ts`). Signature:
756
- `mountSlider(trackEl, { min?, max?, step?, initialValue?,
757
- disabled?, onChange? })` → teardown. Note: mounts on the
758
- inner `.pui-slider` track element, not the outer `.pui-field`
759
- scaffold (matching NumberField's mount-on-inner pattern).
760
-
761
- **Form primitives with NO controller** (markup-only with native
783
+ - `useDialog` → Modal, AlertDialog, Sheet, Command (all `<dialog>`-based;
784
+ see "Use native APIs where applicable" below — Sheet's slide-side
785
+ geometry is a CSS concern, the composable mechanics are identical)
786
+ - `usePopover` → Popover, Tooltip, DropdownMenu, Combobox listbox (all
787
+ Popover-API-based)
788
+ - `useDisclosure` → Accordion, Collapsible
789
+ - `useMenu` → DropdownMenu (layered on `usePopover`)
790
+ - Sidebar**layered split per D13**, like Form. `composables/` is
791
+ for shared behavior composables, and Sidebar's orchestration is reactive
792
+ bindings inside `Sidebar.vue`, deriving its answers from
793
+ `utils/navigation/sidebar.ts` (href match, group-key slug,
794
+ flyout geometry, submenu predicate). Desktop layout-shell collapse,
795
+ mobile drawer overlay, per-group expand and keyboard nav are all
796
+ Sidebar-specific geometry/focus-rule concerns.
797
+
798
+ **Form primitives with NO behavior composable** (markup-only with native
762
799
  semantics): TextInput, Textarea (planned), Select (planned),
763
800
  **Switch**, **Checkbox**, **RadioGroup**. RadioGroup is the
764
801
  worked example of a grouped form primitive that does NOT need a
765
- controller — native `<input type="radio">` elements with a shared
802
+ behavior composable — native `<input type="radio">` elements with a shared
766
803
  `name` provide arrow-key keyboard nav, single-selection, and
767
804
  Tab-focuses-selected for free; Radix's roving-tabindex
768
805
  implementation exists because Radix uses
769
806
  `<button role="radio">` rather than the native input. See the
770
807
  RadioGroup audit (`components/form/radio-group/AUDIT.md`) for the full rationale.
771
808
 
772
- **Press-button family with NO controller** (markup-only,
809
+ **Press-button family with NO behavior composable** (markup-only,
773
810
  `<button aria-pressed>`-driven): **Toggle** (single press-button),
774
811
  **ToggleGroup** (multi-select press-button group), **Segmented**
775
812
  (single-select press-button group). Each native `<button>` is
@@ -785,14 +822,14 @@ independently tabbable" by design, mirroring how shadcn /
785
822
  ToggleGroup degrades to standard button focus when not wrapping
786
823
  Radix's primitive.
787
824
 
788
- **When two NON-`<dialog>` overlay controllers duplicate "modal mode"
825
+ **When two NON-`<dialog>` overlays duplicate "modal mode"
789
826
  mechanics — extract**: this rule applies only to overlays that don't use
790
827
  the native `<dialog>` element. With `<dialog>`, the browser already
791
828
  provides the modal mechanics (focus trap, Escape, top-layer rendering),
792
- so `mountDialog` (Modal, AlertDialog, Sheet) is structurally a thin
829
+ so `useDialog` (Modal, AlertDialog, Sheet, Command) is structurally a thin
793
830
  wrapper and there is no shared "modal mode" body to extract.
794
831
 
795
- Sidebar's drawer (`controllers/sidebar.ts`) implements its own
832
+ Sidebar's drawer (`Sidebar.vue`) implements its own
796
833
  modal-overlay mechanics manually because the panel is a `<nav>`
797
834
  landmark rather than a `<dialog>` (see "Use native APIs where
798
835
  applicable" below): focus trap on the panel, ref-counted scroll lock
@@ -811,12 +848,12 @@ One consumer is implementation; two is duplication; three is a pattern.
811
848
  Modern HTML provides native overlay primitives that reduce hand-rolled
812
849
  focus traps, portals, and escape-key handling. Use them where they fit:
813
850
 
814
- | Overlay archetype | Native API | Examples |
815
- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
816
- | Modal, alert dialog, sheet | `<dialog>` + `showModal()` / `close()` | Modal, AlertDialog, Sheet (slide-side variants of the dialog primitive) |
817
- | Anchored popover, tooltip, dropdown menu, combobox listbox | Popover API (`popover` attribute + `showPopover()` / `hidePopover()`) | Popover, Tooltip, DropdownMenu, Combobox |
818
- | Navigation drawer, sidebar | **Manual** | Sidebar drawer is `<nav>`, not `<dialog>` — keep manual `inert` / focus-trap mechanics. The semantic landmark is more important than the modal mechanics. |
819
- | Toast | **Manual** — `<ol class="pui-toast-region">` with `aria-live="polite"` and `aria-atomic="false"` | Toasts are non-modal status notifications. The visual layer is a fixed-position `<ol>` (lazy-created by `controllers/toast.ts`); auto-dismiss + queue are owned by the singleton. The Popover API was considered and rejected: it requires a single element, but the toast region holds N siblings whose lifetimes are independent. |
851
+ | Overlay archetype | Native API | Examples |
852
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
853
+ | Modal, alert dialog, sheet | `<dialog>` + `showModal()` / `close()` | Modal, AlertDialog, Sheet (slide-side variants of the dialog primitive) |
854
+ | Anchored popover, tooltip, dropdown menu, combobox listbox | Popover API (`popover` attribute + `showPopover()` / `hidePopover()`) | Popover, Tooltip, DropdownMenu, Combobox |
855
+ | Navigation drawer, sidebar | **Manual** | Sidebar drawer is `<nav>`, not `<dialog>` — keep manual `inert` / focus-trap mechanics. The semantic landmark is more important than the modal mechanics. |
856
+ | Toast | **Manual** — `<ol class="pui-toast-region">` with `aria-live="polite"` and `aria-atomic="false"` | Toasts are non-modal status notifications. The visual layer is a fixed-position `<ol>`; auto-dismiss + queue are owned by whoever renders the region. The Popover API was considered and rejected: it requires a single element, but the toast region holds N siblings whose lifetimes are independent. |
820
857
 
821
858
  **What native APIs give us (free)**: top-layer rendering (escapes any
822
859
  ancestor stacking context / overflow), focus trap (`Tab` cycles within
@@ -833,34 +870,13 @@ persistent variants (suppress the `cancel` event with
833
870
  via `setAttribute`), our `data-state="open|closed"` reflection
834
871
  convention (so CSS selectors continue working consistently).
835
872
 
836
- The `mountDialog` / `mountPopover` / `mountSheet` controllers stay as
873
+ The `useDialog` / `usePopover` composables stay as
837
874
  the public surface — they own the manual pieces above and standardize
838
- the controller archetype across components — but their internals lean
839
- on the native APIs rather than rebuilding them. `mountDialog` in
875
+ the composable archetype across components — but their internals lean
876
+ on the native APIs rather than rebuilding them. `useDialog` in
840
877
  particular is a thin wrapper over `<dialog>.showModal()` /
841
878
  `<dialog>.close()`.
842
879
 
843
- ### Astro: hoisted vs inline script
844
-
845
- Two patterns for post-render JS in Astro components:
846
-
847
- **Hoisted module script** (`<script>` block at bottom of `.astro`, no
848
- `is:inline`): Astro hoists, dedupes, and bundles. Runs once per page
849
- load. Use this when many instances of the component can render per
850
- page (NumberField is the v1 example — its `data-pui-number-field`
851
- selector mounts the controller on every instance) — the dedupe
852
- wins. The script uses `document.querySelectorAll([data-…])` to find
853
- all instances and applies behavior to each.
854
-
855
- **Inline script** (`<script>` block in or co-located with markup,
856
- typically `is:inline`): runs verbatim in DOM order. Use this when
857
- the component is essentially singleton per page (Modal, Sidebar) and
858
- needs per-instance state via the `_<controllerName>` expando pattern.
859
-
860
- Both patterns ARE compatible with the `_<controllerName>` expando rule
861
- when stateful behavior is needed; the choice is about deduplication and
862
- script-payload economy.
863
-
864
880
  ### Stateful-component archetype
865
881
 
866
882
  Parallel to native-element-wrapper and form-primitive wrapper, stateful
@@ -868,36 +884,42 @@ components share a common authoring shape.
868
884
 
869
885
  **Authoring checklist for stateful components**:
870
886
 
871
- - [ ] Controller in `packages/ui/utils/controllers/<name>.ts` returns a
872
- control object `{ open / close / isOpen / teardown }` (or the
873
- equivalent imperative pair for non-open/close components, e.g.
874
- Disclosure's `expand / collapse / isExpanded / teardown`).
875
- - [ ] Component template renders the wrapper element as the **mount point**
887
+ - [ ] Behavior composable in `packages/ui/composables/use<Name>.ts` takes
888
+ `(elRef, options)` and returns a handle — `{ open / close / isOpen }`
889
+ for `useDialog`, `{ show / hide / toggle / isOpen / reposition }`
890
+ for `usePopover` or `void` for wire-only behavior (`useDisclosure`,
891
+ `useMenu`).
892
+ - [ ] Component template renders the wrapper element behind the ref
876
893
  with `data-state="closed"` (or equivalent neutral state) as the
877
- static initial value. The controller flips `data-state` at runtime;
894
+ static initial value. The composable flips `data-state` at runtime;
878
895
  CSS targets `data-state` selectors only — no class-toggling for state.
879
- - [ ] Framework component captures a ref to the wrapper element and calls
880
- `mount<Name>(el, config)` in `onMounted` (Vue) or in a `<script>`
881
- block that runs after DOM is parsed (Astro). The control object is
882
- stored on the framework instance (Vue: `let control` in setup; Astro:
883
- `(el as any)._<name>` so consumer scripts can reach it).
884
- - [ ] Framework component watches its reactive `open` (or equivalent) prop
885
- and forwards changes to the controller via `control.open()` /
886
- `control.close()`. The framework prop is the trigger; the controller
887
- owns runtime side-effects.
888
- - [ ] Framework component supplies an `onClose` (or equivalent
889
- user-requested-state-change) callback to the controller. The callback
890
- reflects the user intent back into framework state via emit /
891
- `update:modelValue`; the framework's prop-watcher then calls
892
- `control.close()`.
893
- - [ ] `onBeforeUnmount` (Vue) / before navigation (Astro) calls
894
- `control.teardown()` so listeners, traps, scroll locks, and portaled
895
- DOM are released even if `close()` was never called.
896
+ - [ ] Component captures a ref to the wrapper element and calls
897
+ `use<Name>(elRef, options)` at setup time. The composable wires its
898
+ listeners in its own `onMounted` and cleans up in `onBeforeUnmount`
899
+ the component writes no lifecycle hooks and stores no control
900
+ object.
901
+ - [ ] Controlled open state flows through the `open` getter option
902
+ (`open: () => props.open`). The composable applies the initial value
903
+ on mount and watches for changes; `undefined` is ignored. The
904
+ component prop is the trigger; the composable owns runtime
905
+ side-effects.
906
+ - [ ] Options that can change over the component's lifetime (`persistent`,
907
+ `placement`, `offset`, `singleOpen`) are passed as getters or refs
908
+ (`persistent: () => props.persistent`); the composable reads them
909
+ via `toValue()` at use time.
910
+ - [ ] Component supplies an `onClose` (or equivalent
911
+ user-requested-state-change) callback. The callback
912
+ reflects the user intent back into component state via emit /
913
+ `update:modelValue`; the watched `open` getter then performs the
914
+ actual close.
915
+ - [ ] Unmounting while open closes first — the composable's
916
+ `onBeforeUnmount` releases listeners, traps, and scroll locks even
917
+ if the component never closed explicitly.
896
918
  - [ ] **Native APIs first**. `<dialog>`-based overlays (Modal, AlertDialog,
897
- Sheet) get top-layer rendering, focus trap, and Escape dismissal
898
- from the browser. Popover-API-based overlays (Popover, Tooltip,
899
- DropdownMenu, Combobox listbox) get top-layer + light dismiss.
900
- The controller layers on body scroll lock, backdrop-click
919
+ Sheet, Command) get top-layer rendering, focus trap, and Escape
920
+ dismissal from the browser. Popover-API-based overlays (Popover,
921
+ Tooltip, DropdownMenu, Combobox listbox) get top-layer + light
922
+ dismiss. The composable layers on body scroll lock, backdrop-click
901
923
  dismissal, persistent suppression, `data-state` reflection, and
902
924
  any role override (`role="alertdialog"`). See "Use native APIs
903
925
  where applicable" below for the full table.
@@ -908,15 +930,16 @@ components share a common authoring shape.
908
930
  currently because `<dialog>`-based overlays escape stacking via
909
931
  the native top-layer and the Sidebar drawer stays inline. Vue's
910
932
  `<Teleport>` is intentionally NOT used should portaling be added —
911
- both frameworks share one mechanism for one mental model.
933
+ the behavior layer owns DOM placement, one mechanism for one
934
+ mental model.
912
935
  - [ ] Components requiring body-scroll lock use `utils/dom/scroll-lock.ts`.
913
936
  The lock is reference-counted so nested overlays don't release each
914
937
  other prematurely.
915
- - [ ] Controllers managing manual focus traps (the non-`<dialog>` case)
916
- use `utils/a11y/focus.ts:createFocusTrap`. They are also
938
+ - [ ] Behavior code managing manual focus traps (the non-`<dialog>` case)
939
+ uses `utils/a11y/focus.ts:createFocusTrap`. It is also
917
940
  responsible for capturing the trigger before focus moves and
918
- restoring focus to it on close. `<dialog>`-based controllers
919
- delegate both to the browser; a backup focus restore runs only
941
+ restoring focus to it on close. `<dialog>`-based behavior
942
+ delegates both to the browser; a backup focus restore runs only
920
943
  when the browser fails to restore (rare — e.g. the trigger was
921
944
  removed from the DOM mid-open).
922
945
 
@@ -929,7 +952,7 @@ class="pui-modal">` element that is both the surface and the backdrop
929
952
  provides top-layer rendering, focus trap, Escape dismissal (via the
930
953
  `cancel` event), `[open]` reflection, and focus restoration to the
931
954
  trigger on close. AlertDialog (Phase 4) and Sheet (Phase 4) reuse
932
- the same controller — only their CSS geometry differs.
955
+ the same composable — only their CSS geometry differs.
933
956
  - **Portal target**: not applicable. `<dialog>.showModal()` renders
934
957
  in the native top-layer, which already escapes any ancestor stacking
935
958
  context or `overflow: hidden`. There is no portal helper involved.
@@ -937,11 +960,11 @@ class="pui-modal">` element that is both the surface and the backdrop
937
960
  `padding-right` is increased by the scrollbar width to prevent layout
938
961
  shift. The lock is reference-counted across all open overlays.
939
962
  Browsers do NOT lock body scroll for `<dialog>` automatically, so the
940
- controller still owns this.
941
- - **`data-state` lifecycle**: `closed` (static initial) → `open` (controller
942
- flips on `open()`) → `closed` (controller flips on `close()`). The
963
+ composable still owns this.
964
+ - **`data-state` lifecycle**: `closed` (static initial) → `open` (composable
965
+ flips on `open()`) → `closed` (composable flips on `close()`). The
943
966
  `[open]` attribute is set by the browser via `showModal()`/`close()`;
944
- `data-state` is set by the controller in parallel so CSS selectors
967
+ `data-state` is set by the composable in parallel so CSS selectors
945
968
  consistent with the locked convention keep working. Animated
946
969
  enter/exit transitions drive off the `data-state` selector pairs in
947
970
  CSS; the `opening` / `closing` intermediate states are deferred to
@@ -949,7 +972,7 @@ class="pui-modal">` element that is both the surface and the backdrop
949
972
  out-of-scope on `@starting-style`).
950
973
  - **Initial focus**: `<dialog>.showModal()` auto-focuses the first
951
974
  focusable child (or the element with `[autofocus]`) by default. On
952
- top of that, the controller's `initialFocus` config can override:
975
+ top of that, the composable's `initialFocus` option can override:
953
976
  `'cancel'` focuses the element marked `[data-dialog-cancel]`,
954
977
  `HTMLElement` focuses an explicit element, `'first'` and `null`
955
978
  defer to the browser. A `[data-dialog-initial-focus]` element
@@ -959,48 +982,41 @@ class="pui-modal">` element that is both the surface and the backdrop
959
982
  manual backup runs only when the browser fails to restore.
960
983
  - **Outside-click semantics**: clicks land on the `<dialog>` element
961
984
  itself when the user clicks the `::backdrop` (children clicks bubble
962
- through with `target` = the inner content). The controller checks
985
+ through with `target` = the inner content). The composable checks
963
986
  `event.target === el` to detect a backdrop click. Persistent dialogs
964
987
  (AlertDialog, future) skip this dismissal entirely.
965
988
  - **Escape key**: when not persistent, Escape fires the native `cancel`
966
- event whose default action is `el.close()`. The controller forwards
967
- the close intent through `onClose` so the framework's reactive prop
989
+ event whose default action is `el.close()`. The composable forwards
990
+ the close intent through `onClose` so the component's reactive prop
968
991
  stays authoritative. Persistent dialogs call `event.preventDefault()`
969
992
  on the `cancel` event to suppress the default close.
970
- - **Astro `_<controllerName>` expando**: the control object is exposed on
971
- the `<dialog>` element via a property named after the controller (not
972
- the component): `_dialog` for `mountDialog` (used by Modal,
973
- AlertDialog, and Sheet), `_popover` for `mountPopover`, etc.
974
- Consumers reach the API via
975
- `document.getElementById('<id>')._dialog.open()`. The TypeScript cast
976
- `(el as any)._dialog` is the v1 pattern; a typed helper
977
- `getController<T>(el, key)` may land in `utils/dom/` later.
978
993
 
979
994
  ### Worked example — Modal
980
995
 
981
996
  The full-fat reference. See `packages/ui/components/overlay/modal/` and
982
- `packages/ui/utils/controllers/dialog.ts` for the actual code.
997
+ `packages/ui/composables/useDialog.ts` for the actual code.
983
998
 
984
999
  ```ts
985
- // packages/ui/utils/controllers/dialog.ts
986
- export interface DialogControl {
1000
+ // packages/ui/composables/useDialog.ts
1001
+ export interface DialogHandle {
987
1002
  open(): void;
988
1003
  close(): void;
989
1004
  isOpen(): boolean;
990
- teardown(): void;
991
1005
  }
992
1006
 
993
- export interface DialogConfig {
1007
+ export interface UseDialogOptions {
994
1008
  role?: 'dialog' | 'alertdialog'; // default: 'dialog' (browser default for <dialog>)
995
- persistent?: boolean; // default: false; suppresses cancel default + backdrop click
1009
+ persistent?: MaybeRefOrGetter<boolean>; // default: false; suppresses cancel default + backdrop click
996
1010
  initialFocus?: 'first' | 'cancel' | HTMLElement | null; // default: 'first' (defers to native showModal())
1011
+ open?: () => boolean | undefined; // controlled state; applied on mount, watched after; undefined ignored
1012
+ onBeforeOpen?: () => void; // runs just before the dialog opens, on every open path
997
1013
  onClose?: () => void; // user-requested close (Escape or backdrop click)
998
1014
  }
999
1015
 
1000
- export function mountDialog(
1001
- el: HTMLDialogElement,
1002
- config?: DialogConfig,
1003
- ): DialogControl;
1016
+ export function useDialog(
1017
+ elRef: Ref<HTMLDialogElement | null>,
1018
+ options?: UseDialogOptions,
1019
+ ): DialogHandle;
1004
1020
  ```
1005
1021
 
1006
1022
  **Vue consumer** (full pattern):
@@ -1021,11 +1037,8 @@ export function mountDialog(
1021
1037
  </template>
1022
1038
 
1023
1039
  <script setup lang="ts">
1024
- import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
1025
- import {
1026
- mountDialog,
1027
- type DialogControl,
1028
- } from '@pienter/ui/utils/controllers/dialog.js';
1040
+ import { ref } from 'vue';
1041
+ import { useDialog } from '@pienter/ui/composables/useDialog';
1029
1042
 
1030
1043
  defineOptions({ inheritAttrs: false });
1031
1044
 
@@ -1037,78 +1050,23 @@ const props = defineProps<{
1037
1050
  const emit = defineEmits<{ 'update:open': [boolean]; close: [] }>();
1038
1051
 
1039
1052
  const rootRef = ref<HTMLDialogElement | null>(null);
1040
- let control: DialogControl | null = null;
1041
-
1042
- onMounted(() => {
1043
- control = mountDialog(rootRef.value!, {
1044
- persistent: props.persistent,
1045
- onClose: () => {
1046
- emit('update:open', false);
1047
- emit('close');
1048
- },
1049
- });
1050
- if (props.open) control.open();
1051
- });
1052
1053
 
1053
- watch(
1054
- () => props.open,
1055
- (v) => (v ? control?.open() : control?.close()),
1056
- );
1057
-
1058
- onBeforeUnmount(() => {
1059
- control?.teardown();
1060
- control = null;
1054
+ useDialog(rootRef, {
1055
+ role: 'dialog',
1056
+ persistent: () => props.persistent,
1057
+ open: () => props.open,
1058
+ onClose: () => {
1059
+ emit('update:open', false);
1060
+ emit('close');
1061
+ },
1061
1062
  });
1062
1063
  </script>
1063
1064
  ```
1064
1065
 
1065
- **Astro consumer** (open-by-default or trigger-driven via `_dialog`):
1066
-
1067
- ```astro
1068
- ---
1069
- const { id, open, persistent, title } = Astro.props;
1070
- ---
1071
- <dialog
1072
- id={id}
1073
- class="pui-modal"
1074
- data-state="closed"
1075
- data-pui-modal
1076
- data-persistent={persistent ? 'true' : undefined}
1077
- data-open-initially={open ? 'true' : undefined}
1078
- aria-labelledby={`${id}-title`}
1079
- >
1080
- <h2 id={`${id}-title`}>{title}</h2>
1081
- <slot />
1082
- </dialog>
1083
-
1084
- <script>
1085
- import { mountDialog } from '@pienter/ui/utils/controllers/dialog.js';
1086
- document.querySelectorAll<HTMLDialogElement>('dialog[data-pui-modal]').forEach((el) => {
1087
- if ((el as any)._dialog) return;
1088
- const control = mountDialog(el, {
1089
- persistent: el.dataset.persistent === 'true',
1090
- });
1091
- (el as any)._dialog = control;
1092
- if (el.dataset.openInitially === 'true') control.open();
1093
- });
1094
- </script>
1095
- ```
1096
-
1097
- **Astro consumer page — opening from a trigger button**:
1098
-
1099
- ```astro
1100
- <button onclick="document.getElementById('demo-modal')._dialog.open()">
1101
- Open
1102
- </button>
1103
- <Modal id="demo-modal" title="Confirm" persistent={false}>
1104
- <p>Body text.</p>
1105
- </Modal>
1106
- ```
1107
-
1108
- The `_dialog` consumer pattern is the v1 contract for Astro — each Modal
1109
- exposes its control object as a property on the wrapper element so consumer
1110
- scripts can reach it. A future iteration may add declarative
1111
- `data-modal-trigger="<id>"` button wiring; deferred for v1.
1066
+ No `watch`, no `onMounted` / `onBeforeUnmount`, no stored control object —
1067
+ the composable owns its lifecycle. The `open` getter carries both the
1068
+ initial state and every subsequent change; `onClose` reports user intent
1069
+ back to the reactive layer.
1112
1070
 
1113
1071
  ### Vue API decisions
1114
1072
 
@@ -1144,14 +1102,10 @@ binding. Examples:
1144
1102
  <div role="dialog" aria-modal="true">…</div>
1145
1103
  ```
1146
1104
 
1147
- ```astro
1148
- <ul role="list" aria-label="Breadcrumbs">…</ul>
1149
- ```
1150
-
1151
1105
  This is the common case for `role`, `aria-modal`, `aria-label` (when the
1152
1106
  label is a literal string), and `aria-orientation`.
1153
1107
 
1154
- ### Reactive ARIA bindings (Vue) — `:aria-*`
1108
+ ### Reactive ARIA bindings — `:aria-*`
1155
1109
 
1156
1110
  When the value depends on **props or runtime state**, use Vue's reactive
1157
1111
  binding syntax:
@@ -1173,26 +1127,6 @@ The pattern matches the **boolean-attr-only-when-truthy** rule above: emit
1173
1127
  so Vue drops the attribute entirely. Never emit the literal string
1174
1128
  `'false'`.
1175
1129
 
1176
- ### Astro ARIA — template expressions
1177
-
1178
- Astro doesn't have reactivity but its template-expression syntax handles
1179
- the same conditional shape:
1180
-
1181
- ```astro
1182
- <input
1183
- aria-invalid={hasErrors ? 'true' : undefined}
1184
- aria-describedby={describedBy}
1185
- />
1186
-
1187
- <div aria-labelledby={`${id}-title`}>…</div>
1188
- ```
1189
-
1190
- The only difference from Vue is the syntax sigil (`{}` vs `:`). The
1191
- `undefined`-drops-attribute behavior is identical. Consumer-supplied
1192
- dynamic ARIA values land via the `...rest` spread on the appropriate
1193
- element (the wrapper for pure-markup; the inner control for form
1194
- primitives).
1195
-
1196
1130
  ### Boolean ARIA values — strings, not literals
1197
1131
 
1198
1132
  Per the boolean-attr-only-when-truthy rule, emit `'true'` (the string)
@@ -1204,12 +1138,12 @@ state writes `[aria-busy='true']`, `[aria-invalid='true']`,
1204
1138
  **Disambiguation — native HTML booleans vs ARIA boolean strings**:
1205
1139
  native HTML boolean attributes (`disabled`, `readonly`, `required`,
1206
1140
  `checked`, `selected`) follow framework boolean-prop semantics — bind
1207
- the boolean directly (`:disabled="x"` in Vue, `disabled={x}` in Astro);
1208
- the framework renders the bare attribute when truthy and drops it when
1209
- falsy. ARIA boolean attributes (`aria-busy`, `aria-invalid`,
1210
- `aria-disabled`) take the _string_ `'true'` and the only-when-truthy
1141
+ the boolean directly (`:disabled="x"`); Vue renders the bare attribute
1142
+ when truthy and drops it when falsy. ARIA boolean attributes
1143
+ (`aria-busy`, `aria-invalid`, `aria-disabled`) take the _string_
1144
+ `'true'` and the only-when-truthy
1211
1145
  idiom (`x ? 'true' : undefined`). Do not pass a raw boolean to an
1212
- `aria-*` binding — Astro will stringify `false` to the literal
1146
+ `aria-*` binding — Vue stringifies `false` to the literal
1213
1147
  `"false"`, which fails the rule. For `aria-expanded`, `aria-checked`,
1214
1148
  `aria-pressed`, and `aria-selected`, see Always-emit ARIA exceptions
1215
1149
  below — they require explicit `'true'`/`'false'`.
@@ -1239,28 +1173,28 @@ literally; do NOT omit when falsy:
1239
1173
  selected".
1240
1174
 
1241
1175
  For these four, the binding shape is `:aria-<name>="x ? 'true' :
1242
- 'false'"` (Vue) or `aria-<name>={x ? 'true' : 'false'}` (Astro). Native
1176
+ 'false'"`. Native
1243
1177
  elements with intrinsic role semantics provide some of this for free
1244
1178
  (e.g. `<input type="checkbox">` carries implicit `checked` state) — but
1245
1179
  when the role is set explicitly (`role="switch"` on a checkbox-backed
1246
1180
  input), the matching ARIA attribute MUST also be set explicitly so AT
1247
1181
  reads the role-state pair consistently.
1248
1182
 
1249
- ### ARIA ownership — template vs controller
1183
+ ### ARIA ownership — template vs composable
1250
1184
 
1251
1185
  For stateful components (Modal, AlertDialog, Sheet, Popover, Disclosure),
1252
1186
  some ARIA attributes are owned by the **template** (template-time data —
1253
1187
  heading IDs, label strings, role choice when fixed) and some by the
1254
- **controller** (runtime state — `aria-modal` set during open, `data-state`
1255
- mirrored to ARIA semantics where applicable). Each controller's audit
1256
- entry documents which ARIA attributes it owns; everything else is the
1257
- framework template's responsibility. For Modal:
1188
+ **composable** (runtime state — `aria-modal` set during open, `data-state`
1189
+ mirrored to ARIA semantics where applicable). Each component's audit
1190
+ entry documents which ARIA attributes its composable owns; everything else
1191
+ is the template's responsibility. For Modal:
1258
1192
 
1259
1193
  - Template: `aria-labelledby` (heading ID is template-time).
1260
1194
  - Browser: `role="dialog"` (the implicit role for `<dialog>`) and
1261
1195
  `aria-modal` (set implicitly when `showModal()` is called) — no
1262
- template or controller wiring needed.
1263
- - Controller: `role` override only — `mountDialog` calls
1196
+ template or composable wiring needed.
1197
+ - Composable: `role` override only — `useDialog` calls
1264
1198
  `setAttribute('role', 'alertdialog')` for AlertDialog. Modal v1
1265
1199
  doesn't override and inherits the browser default.
1266
1200
 
@@ -1296,28 +1230,6 @@ Every interactive component renders a consistent keyboard-focus ring:
1296
1230
 
1297
1231
  ---
1298
1232
 
1299
- ## Astro tier mapping
1300
-
1301
- Components ship Astro implementations only when listed in this tier. SPA-only interactive components are Vue-only; Astro consumers needing them render the Vue version as an Astro island via `client:visible`.
1302
-
1303
- | Tier | Components | Astro? |
1304
- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
1305
- | Static markup | Card, Badge, Alert, Avatar, Avatar-stack, Breadcrumb, Empty, Icon, Skeleton, Spinner, Separator, Progress, Label | Yes — pure `.astro`, no JS |
1306
- | Form primitives | Button, TextInput, Textarea, Form, Checkbox, RadioGroup, Switch, Select, NumberField, DateInput, TagsInput, InputOTP, Slider | Yes — JS via controllers where needed |
1307
- | Marketing-needed interactive | Modal, AlertDialog, Toast | Yes — `controllers/dialog.ts`, `controllers/toast.ts` |
1308
- | Layout | Navbar, Sidebar, Table | Yes — Table's sort ships as links, so still no JS |
1309
- | SPA-only interactive | Tabs, Pagination, Segmented, Toggle (press), ToggleGroup, Popover, Tooltip, DropdownMenu, Combobox, Command, Sheet, Accordion, Collapsible, DataTable | **Vue-only** |
1310
-
1311
- Table sort is deliberately not in the Vue-only row. Sorting a table is a
1312
- navigation, not SPA state (ADR 0005), so the Astro tier renders each sortable
1313
- header as an `<a>` pointing at the URL that column would produce next — same
1314
- `aria-sort`, same chevron glyph, same asc → desc → unsorted cycle as the Vue
1315
- tier's `<button>`, and no JavaScript. What stays Vue-only is `DataTable`, the
1316
- data-driven wrapper: it renders rows from a reactive array and owns selection
1317
- state, neither of which a static page has.
1318
-
1319
- ---
1320
-
1321
1233
  ## shadcn / Radix commit pins
1322
1234
 
1323
1235
  _Pinned 2026-05-03. Update both together as a separate, intentional change._
@@ -1387,9 +1299,9 @@ follow the shape of an existing one.
1387
1299
 
1388
1300
  ## Form submit response contract
1389
1301
 
1390
- _Placeholder shape — subject to change when ts-sdk publishes the canonical response shape. The symmetry requirement (same shape consumed by Vue `Form.vue` and Astro `controllers/form.ts:mountForm`) is locked; the shape itself is provisional and will be migrated together when ts-sdk lands._
1302
+ _Placeholder shape — subject to change when ts-sdk publishes the canonical response shape. The shape is provisional and will be migrated in one change when ts-sdk lands._
1391
1303
 
1392
- Both the Vue `Form.vue` and the Astro `controllers/form.ts:mountForm` SHALL accept the same submit response shape, mapping server validation errors, success messages, and redirect signals through identical contracts. Drift between the two implementations is treated as a defect.
1304
+ `Form.vue` SHALL accept this submit response shape, mapping server validation errors, success messages, and redirect signals through one contract.
1393
1305
 
1394
1306
  ```ts
1395
1307
  // packages/ui/utils/validation/form.ts
@@ -1411,11 +1323,11 @@ export interface FormSubmitResponse {
1411
1323
  }
1412
1324
  ```
1413
1325
 
1414
- **Mapping rules — both frameworks**:
1326
+ **Mapping rules**:
1415
1327
 
1416
1328
  - `ok: true` clears all errors, renders `statusMessage` in `.pui-form__status`, and announces it politely.
1417
1329
  - `ok: false` renders `errors` in `.pui-form__error-summary`, applies `fieldErrors` to the matching form-primitive wrappers (per-field error list inside `pui-field__hint role="alert"` + `aria-invalid="true"` on the control), focuses the first invalid field via `focusFirstInvalid`, and announces the error count assertively.
1418
- - `redirect` is a passive value — orchestration does NOT navigate; consumers act on it inside their `onResult` / `@submit` callback.
1330
+ - `redirect` is a passive value — orchestration does NOT navigate; consumers act on it inside their `@submit` handler.
1419
1331
 
1420
1332
  **Field error normalization**: `fieldErrors[name]` MAY be a single string or an array of strings. Orchestration normalizes to `string[]` via the shared `normalizeFieldErrors` helper before applying to the DOM.
1421
1333
 
@@ -1497,3 +1409,48 @@ Pinned upstream SHAs used across the table:
1497
1409
  | toggle | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/toggle.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/toggle/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1498
1410
  | toggle-group | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/toggle-group.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/toggle-group/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1499
1411
  | tooltip | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/tooltip.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/tooltip/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1412
+
1413
+ ## CMS page composition
1414
+
1415
+ `Index`, `RecordForm`, and `RecordDetails` compose existing primitives. Consumers
1416
+ register routes and supply endpoint-specific data functions, rendering and actions.
1417
+
1418
+ Index accepts `load(query, { signal })`, `columns` and `queryOptions`. It owns
1419
+ query state, rows, metadata, loading, failures and retry. Search is debounced;
1420
+ search/filter/sort/page-size changes reset page to 1. New requests and unmount
1421
+ abort earlier work; stale responses are ignored even when loaders ignore the signal.
1422
+ `sync-query` opts into Vue Router query decoding and history synchronization.
1423
+ Without it, state stays local. Index imports the optional `vue-router` peer, but
1424
+ requires an installed router instance only for URL synchronization.
1425
+
1426
+ Index applies the existing default sort cycle; Table/DataTable still emit a column
1427
+ key as intent, per ADR 0005. A toolbar select shares that sort state and includes
1428
+ all allowed fields, including those without visible columns. Sortable columns
1429
+ must belong to the allowed sort vocabulary; labels may be overridden with
1430
+ `sortLabels`. Filter slots can update one key without replacing siblings. Action
1431
+ and selection slots receive loading state and an awaitable refresh callback.
1432
+ Structured backend query errors offer reset; other load failures offer retry.
1433
+ Use DataTable when the consumer needs controlled data
1434
+ and request state. Index's compact row spacing uses
1435
+ `--pui-table-cell-padding-block`, which Table otherwise defaults to `--space-xs`.
1436
+
1437
+ RecordForm emits detached JSON snapshots, preserving every supplied property.
1438
+ Consumers construct an explicit writable object from backend records. Typed path
1439
+ segments identify nested fields and array indices; fields display issues for
1440
+ their path and descendants. The form summary retains unmatched issues.
1441
+ `--pui-record-form-columns` customizes the field grid, defaulting to one column.
1442
+
1443
+ RecordForm's `fields` slot replaces its default RecordFields rendering for
1444
+ custom sidebar/tab layouts. RecordFields takes the same draft and renders a
1445
+ subset, with `--pui-record-fields-columns` as its grid seam. It never renders a
1446
+ form element. Callers expose the relevant tab when backend issues arrive.
1447
+
1448
+ BlockEditor owns an ordered array of stable `id`/`type` blocks and emits updates
1449
+ for edits, addition, removal and reordering. Creation factories and slot content
1450
+ stay caller-owned. Optional `v-model:collapsed` lets pages reveal invalid blocks.
1451
+ Its colocated audit records pointer, keyboard, focus and cancellation behavior.
1452
+
1453
+ The backend contract lives in `docs/cms/backend-contract.md` with a distributable
1454
+ JSON Schema at `utils/cms/schema.json`. It defines request/response data only;
1455
+ endpoint URLs, authentication, permissions and domain operations belong to the
1456
+ application.