@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.
- package/CHANGELOG.md +258 -0
- package/CONVENTIONS.md +342 -385
- package/README.md +33 -18
- package/components/display/record-details/RecordDetails.vue +61 -0
- package/components/display/record-details/record-details.css +37 -0
- package/components/display/record-details/types.ts +8 -0
- package/components/feedback/toast/toast.css +1 -1
- package/components/form/block-editor/BlockEditor.vue +454 -0
- package/components/form/block-editor/block-editor.css +149 -0
- package/components/form/block-editor/types.ts +15 -0
- package/components/form/combobox/Combobox.vue +42 -72
- package/components/form/combobox/combobox.css +1 -1
- package/components/form/form/Form.vue +1 -2
- package/components/form/label/label.css +1 -1
- package/components/form/number-field/NumberField.vue +1 -2
- package/components/form/number-field/number-field.css +1 -1
- package/components/form/radio-group/RadioGroup.vue +2 -5
- package/components/form/record-form/RecordFields.vue +128 -0
- package/components/form/record-form/RecordForm.vue +116 -0
- package/components/form/record-form/fields.ts +20 -0
- package/components/form/record-form/record-form.css +15 -0
- package/components/form/record-form/types.ts +28 -0
- package/components/form/slider/slider.css +2 -3
- package/components/form/tags-input/tags-input.css +1 -2
- package/components/form/text-input/text-input.css +2 -0
- package/components/form/textarea/textarea.css +1 -1
- package/components/layout/accordion/Accordion.vue +6 -27
- package/components/layout/collapsible/Collapsible.vue +9 -19
- package/components/layout/index/Index.vue +353 -0
- package/components/layout/index/index.css +114 -0
- package/components/layout/index/useIndex.ts +390 -0
- package/components/layout/table/table.css +5 -4
- package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
- package/components/navigation/breadcrumb/breadcrumb.css +15 -0
- package/components/navigation/sidebar/Sidebar.vue +329 -84
- package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
- package/components/navigation/sidebar/context.ts +21 -0
- package/components/navigation/sidebar/sidebar.css +346 -78
- package/components/navigation/sidebar/types.ts +13 -1
- package/components/navigation/tabs/Tabs.vue +6 -0
- package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
- package/components/overlay/command/Command.vue +10 -38
- package/components/overlay/command/command.css +1 -1
- package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
- package/components/overlay/modal/Modal.vue +7 -28
- package/components/overlay/popover/Popover.vue +9 -31
- package/components/overlay/sheet/Sheet.vue +7 -28
- package/components/overlay/tooltip/Tooltip.vue +14 -19
- package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
- package/composables/useDisclosure.ts +113 -0
- package/composables/useEventListener.ts +16 -0
- package/composables/useMenu.ts +212 -0
- package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
- package/package.json +16 -40
- package/styles/0-settings/colors.css +10 -0
- package/styles/0-settings/layout.css +18 -0
- package/styles/0-settings/motion.css +2 -2
- package/styles/0-settings/spacing.css +3 -1
- package/utils/a11y/focus.ts +9 -3
- package/utils/cms/index.ts +283 -0
- package/utils/cms/schema.json +126 -0
- package/utils/navigation/sidebar.ts +97 -0
- package/utils/validation/form.ts +6 -9
- package/components/action/button/Button.astro +0 -95
- package/components/action/button/IconButton.astro +0 -86
- package/components/display/avatar/Avatar.astro +0 -17
- package/components/display/avatar/AvatarStack.astro +0 -9
- package/components/display/badge/Badge.astro +0 -15
- package/components/display/empty/Empty.astro +0 -9
- package/components/display/icon/Icon.astro +0 -52
- package/components/feedback/alert/Alert.astro +0 -52
- package/components/feedback/progress/Progress.astro +0 -68
- package/components/feedback/skeleton/Skeleton.astro +0 -32
- package/components/feedback/spinner/Spinner.astro +0 -25
- package/components/feedback/toast/Toast.astro +0 -50
- package/components/form/checkbox/Checkbox.astro +0 -79
- package/components/form/date-input/DateInput.astro +0 -105
- package/components/form/form/Form.astro +0 -106
- package/components/form/input-otp/InputOTP.astro +0 -147
- package/components/form/label/Label.astro +0 -13
- package/components/form/number-field/NumberField.astro +0 -142
- package/components/form/radio-group/RadioGroup.astro +0 -105
- package/components/form/select/Select.astro +0 -105
- package/components/form/slider/Slider.astro +0 -205
- package/components/form/switch/Switch.astro +0 -75
- package/components/form/tags-input/TagsInput.astro +0 -153
- package/components/form/text-input/TextInput.astro +0 -84
- package/components/form/textarea/Textarea.astro +0 -86
- package/components/layout/card/Card.astro +0 -13
- package/components/layout/separator/Separator.astro +0 -31
- package/components/layout/table/Table.astro +0 -116
- package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
- package/components/navigation/navbar/Navbar.astro +0 -62
- package/components/navigation/pagination/PaginationFooter.astro +0 -24
- package/components/navigation/sidebar/Sidebar.astro +0 -132
- package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
- package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
- package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
- package/components/overlay/modal/Modal.astro +0 -66
- package/utils/controllers/disclosure.ts +0 -117
- package/utils/controllers/form.ts +0 -524
- package/utils/controllers/index.ts +0 -39
- package/utils/controllers/menu.ts +0 -255
- package/utils/controllers/number-field.ts +0 -103
- package/utils/controllers/otp.ts +0 -252
- package/utils/controllers/sidebar.ts +0 -610
- package/utils/controllers/slider.ts +0 -336
- package/utils/controllers/tags-input.ts +0 -255
- 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
|
|
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
|
|
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
|
|
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
|
|
190
|
-
|
|
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
|
|
264
|
-
|
|
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
|
|
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
|
|
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
|
-
- [ ]
|
|
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.
|
|
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
|
-
|
|
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.
|
|
412
|
-
typically an internal recursive `<
|
|
413
|
-
that is **not exported** (private to the parent
|
|
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
|
|
419
|
-
a
|
|
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`
|
|
423
|
-
|
|
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 `
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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** —
|
|
432
|
+
**Layered split** — two layers, locked per design D13:
|
|
436
433
|
|
|
437
|
-
1. **Shared primitives** (
|
|
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. **
|
|
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
|
|
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
|
|
444
|
+
**Consumer responsibility**:
|
|
449
445
|
|
|
450
|
-
| Concern |
|
|
451
|
-
| --------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
452
|
-
| Per-field error display | Consumer wires `:errors` on each form-primitive child from `props.fieldErrors[name]` |
|
|
453
|
-
| `aria-busy` during submit | Consumer flips `:busy` prop on `<Form>` after async submit completes |
|
|
454
|
-
| Status message | Consumer sets `:status-message` prop on `<Form>` |
|
|
455
|
-
| Server `errors` (top-level) | Consumer sets `:errors` prop on `<Form>` |
|
|
456
|
-
| Throw vs `{ ok: false }` | Consumer's submit handler runs in parent; throwing is the consumer's exception-handling concern |
|
|
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.
|
|
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`. (
|
|
468
|
-
3. If validation passes: clear errors, set `aria-busy="true"` on form, disable submit button,
|
|
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
|
-
**
|
|
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 `#
|
|
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
|
-
##
|
|
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)
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
`packages/ui/
|
|
627
|
-
|
|
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
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
|
686
|
+
export function use<Name>(
|
|
687
|
+
elRef: Ref<HTMLElement | null>,
|
|
688
|
+
options?: Use<Name>Options,
|
|
689
|
+
): void;
|
|
638
690
|
```
|
|
639
691
|
|
|
640
|
-
|
|
641
|
-
|
|
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 —
|
|
695
|
+
### Stateful-component signature — handle
|
|
646
696
|
|
|
647
|
-
For stateful components with imperative open/close (or show/hide
|
|
648
|
-
methods
|
|
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
|
|
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
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
):
|
|
707
|
+
export function useDialog(
|
|
708
|
+
elRef: Ref<HTMLDialogElement | null>,
|
|
709
|
+
options?: UseDialogOptions,
|
|
710
|
+
): DialogHandle;
|
|
664
711
|
```
|
|
665
712
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
|
678
|
-
|
|
679
|
-
prop
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
|
691
|
-
with one global ARIA live region), the module MAY additionally export
|
|
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
|
|
750
|
+
### When to share a composable vs write a new one
|
|
696
751
|
|
|
697
|
-
|
|
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
|
|
700
|
-
`
|
|
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
|
-
|
|
758
|
+
Composables are SEPARATE when components differ in **geometry, focus rules,
|
|
704
759
|
or trigger semantics**:
|
|
705
760
|
|
|
706
|
-
- Popover, Tooltip, DropdownMenu →
|
|
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
|
-
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
|
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
|
-
- `
|
|
724
|
-
native APIs where applicable" below — Sheet's slide-side
|
|
725
|
-
CSS concern, the
|
|
726
|
-
- `
|
|
727
|
-
Popover-API-based
|
|
728
|
-
- `
|
|
729
|
-
- `
|
|
730
|
-
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
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
|
|
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>`
|
|
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 `
|
|
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 (`
|
|
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
|
|
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 `
|
|
873
|
+
The `useDialog` / `usePopover` composables stay as
|
|
837
874
|
the public surface — they own the manual pieces above and standardize
|
|
838
|
-
the
|
|
839
|
-
on the native APIs rather than rebuilding them. `
|
|
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
|
-
- [ ]
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
|
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
|
-
- [ ]
|
|
880
|
-
`
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
- [ ]
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
owns runtime
|
|
888
|
-
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
`
|
|
892
|
-
`
|
|
893
|
-
- [ ]
|
|
894
|
-
|
|
895
|
-
|
|
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
|
|
898
|
-
from the browser. Popover-API-based overlays (Popover,
|
|
899
|
-
DropdownMenu, Combobox listbox) get top-layer + light
|
|
900
|
-
The
|
|
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
|
-
|
|
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
|
-
- [ ]
|
|
916
|
-
|
|
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
|
|
919
|
-
|
|
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
|
|
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
|
-
|
|
941
|
-
- **`data-state` lifecycle**: `closed` (static initial) → `open` (
|
|
942
|
-
flips on `open()`) → `closed` (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
967
|
-
the close intent through `onClose` so the
|
|
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/
|
|
997
|
+
`packages/ui/composables/useDialog.ts` for the actual code.
|
|
983
998
|
|
|
984
999
|
```ts
|
|
985
|
-
// packages/ui/
|
|
986
|
-
export interface
|
|
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
|
|
1007
|
+
export interface UseDialogOptions {
|
|
994
1008
|
role?: 'dialog' | 'alertdialog'; // default: 'dialog' (browser default for <dialog>)
|
|
995
|
-
persistent?: boolean
|
|
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
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
):
|
|
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
|
|
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
|
-
|
|
1054
|
-
|
|
1055
|
-
(
|
|
1056
|
-
)
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
|
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"`
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
`
|
|
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 —
|
|
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'"
|
|
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
|
|
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
|
-
**
|
|
1255
|
-
mirrored to ARIA semantics where applicable). Each
|
|
1256
|
-
entry documents which ARIA attributes
|
|
1257
|
-
|
|
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
|
|
1263
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|