@pienter/ui 0.2.0
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 +64 -0
- package/CONVENTIONS.md +1499 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/components/action/button/Button.astro +95 -0
- package/components/action/button/Button.vue +92 -0
- package/components/action/button/IconButton.astro +86 -0
- package/components/action/button/IconButton.vue +85 -0
- package/components/action/button/button.css +194 -0
- package/components/action/toggle/Toggle.vue +38 -0
- package/components/action/toggle/toggle.css +77 -0
- package/components/action/toggle-group/ToggleGroup.vue +67 -0
- package/components/action/toggle-group/toggle-group.css +78 -0
- package/components/display/avatar/Avatar.astro +17 -0
- package/components/display/avatar/Avatar.vue +30 -0
- package/components/display/avatar/AvatarStack.astro +9 -0
- package/components/display/avatar/AvatarStack.vue +11 -0
- package/components/display/avatar/avatar.css +58 -0
- package/components/display/badge/Badge.astro +15 -0
- package/components/display/badge/Badge.vue +23 -0
- package/components/display/badge/badge.css +56 -0
- package/components/display/empty/Empty.astro +9 -0
- package/components/display/empty/Empty.vue +11 -0
- package/components/display/empty/empty.css +39 -0
- package/components/display/icon/Icon.astro +52 -0
- package/components/display/icon/Icon.vue +57 -0
- package/components/display/icon/icon.css +47 -0
- package/components/feedback/alert/Alert.astro +52 -0
- package/components/feedback/alert/Alert.vue +60 -0
- package/components/feedback/alert/alert.css +78 -0
- package/components/feedback/progress/Progress.astro +68 -0
- package/components/feedback/progress/Progress.vue +82 -0
- package/components/feedback/progress/progress.css +68 -0
- package/components/feedback/skeleton/Skeleton.astro +32 -0
- package/components/feedback/skeleton/Skeleton.vue +39 -0
- package/components/feedback/skeleton/skeleton.css +56 -0
- package/components/feedback/spinner/Spinner.astro +25 -0
- package/components/feedback/spinner/Spinner.vue +36 -0
- package/components/feedback/spinner/spinner.css +91 -0
- package/components/feedback/toast/Toast.astro +50 -0
- package/components/feedback/toast/Toast.vue +74 -0
- package/components/feedback/toast/toast.css +128 -0
- package/components/form/checkbox/Checkbox.astro +79 -0
- package/components/form/checkbox/Checkbox.vue +95 -0
- package/components/form/checkbox/checkbox.css +59 -0
- package/components/form/combobox/Combobox.vue +508 -0
- package/components/form/combobox/combobox.css +110 -0
- package/components/form/date-input/DateInput.astro +105 -0
- package/components/form/date-input/DateInput.vue +121 -0
- package/components/form/date-input/date-input.css +19 -0
- package/components/form/form/Form.astro +106 -0
- package/components/form/form/Form.vue +181 -0
- package/components/form/form/form.css +46 -0
- package/components/form/input-otp/InputOTP.astro +147 -0
- package/components/form/input-otp/InputOTP.vue +209 -0
- package/components/form/input-otp/input-otp.css +52 -0
- package/components/form/label/Label.astro +13 -0
- package/components/form/label/Label.vue +20 -0
- package/components/form/label/label.css +11 -0
- package/components/form/number-field/NumberField.astro +142 -0
- package/components/form/number-field/NumberField.vue +155 -0
- package/components/form/number-field/number-field.css +115 -0
- package/components/form/radio-group/RadioGroup.astro +105 -0
- package/components/form/radio-group/RadioGroup.vue +110 -0
- package/components/form/radio-group/radio-group.css +114 -0
- package/components/form/radio-group/types.ts +14 -0
- package/components/form/select/Segmented.vue +36 -0
- package/components/form/select/Select.astro +105 -0
- package/components/form/select/Select.vue +109 -0
- package/components/form/select/select.css +96 -0
- package/components/form/slider/Slider.astro +205 -0
- package/components/form/slider/Slider.vue +321 -0
- package/components/form/slider/slider.css +115 -0
- package/components/form/switch/Switch.astro +75 -0
- package/components/form/switch/Switch.vue +89 -0
- package/components/form/switch/switch.css +64 -0
- package/components/form/tags-input/TagsInput.astro +153 -0
- package/components/form/tags-input/TagsInput.vue +207 -0
- package/components/form/tags-input/tags-input.css +128 -0
- package/components/form/text-input/TextInput.astro +84 -0
- package/components/form/text-input/TextInput.vue +99 -0
- package/components/form/text-input/text-input.css +165 -0
- package/components/form/textarea/Textarea.astro +86 -0
- package/components/form/textarea/Textarea.vue +102 -0
- package/components/form/textarea/textarea.css +25 -0
- package/components/layout/accordion/Accordion.vue +59 -0
- package/components/layout/accordion/accordion.css +87 -0
- package/components/layout/card/Card.astro +13 -0
- package/components/layout/card/Card.vue +20 -0
- package/components/layout/card/card.css +55 -0
- package/components/layout/collapsible/Collapsible.vue +77 -0
- package/components/layout/collapsible/collapsible.css +76 -0
- package/components/layout/separator/Separator.astro +31 -0
- package/components/layout/separator/Separator.vue +33 -0
- package/components/layout/separator/separator.css +27 -0
- package/components/layout/table/DataTable.vue +127 -0
- package/components/layout/table/Table.astro +116 -0
- package/components/layout/table/Table.vue +146 -0
- package/components/layout/table/TableRow.vue +59 -0
- package/components/layout/table/table.css +201 -0
- package/components/layout/table/types.ts +35 -0
- package/components/layout/table/useTable.ts +7 -0
- package/components/navigation/breadcrumb/Breadcrumb.astro +36 -0
- package/components/navigation/breadcrumb/Breadcrumb.vue +36 -0
- package/components/navigation/breadcrumb/breadcrumb.css +37 -0
- package/components/navigation/navbar/Navbar.astro +62 -0
- package/components/navigation/navbar/Navbar.vue +50 -0
- package/components/navigation/navbar/navbar.css +77 -0
- package/components/navigation/pagination/Pagination.vue +107 -0
- package/components/navigation/pagination/pagination.css +53 -0
- package/components/navigation/sidebar/Sidebar.astro +132 -0
- package/components/navigation/sidebar/Sidebar.vue +174 -0
- package/components/navigation/sidebar/SidebarItemRender.astro +83 -0
- package/components/navigation/sidebar/SidebarItemRender.vue +98 -0
- package/components/navigation/sidebar/sidebar.css +303 -0
- package/components/navigation/sidebar/types.ts +72 -0
- package/components/navigation/tabs/Tabs.vue +84 -0
- package/components/navigation/tabs/tabs.css +39 -0
- package/components/overlay/alert-dialog/AlertDialog.astro +112 -0
- package/components/overlay/alert-dialog/AlertDialog.vue +117 -0
- package/components/overlay/alert-dialog/alert-dialog.css +57 -0
- package/components/overlay/command/Command.vue +356 -0
- package/components/overlay/command/command.css +179 -0
- package/components/overlay/dropdown-menu/DropdownMenu.vue +143 -0
- package/components/overlay/dropdown-menu/dropdown-menu.css +120 -0
- package/components/overlay/modal/Modal.astro +66 -0
- package/components/overlay/modal/Modal.vue +85 -0
- package/components/overlay/modal/modal.css +60 -0
- package/components/overlay/popover/Popover.vue +113 -0
- package/components/overlay/popover/popover.css +53 -0
- package/components/overlay/sheet/Sheet.vue +88 -0
- package/components/overlay/sheet/sheet.css +108 -0
- package/components/overlay/tooltip/Tooltip.vue +210 -0
- package/components/overlay/tooltip/tooltip.css +50 -0
- package/composables/useUrlSort.ts +48 -0
- package/icons/alert-triangle.ts +1 -0
- package/icons/arrow-down.ts +1 -0
- package/icons/arrow-up-down.ts +5 -0
- package/icons/arrow-up.ts +1 -0
- package/icons/bell.ts +1 -0
- package/icons/check.ts +1 -0
- package/icons/chevron-down.ts +1 -0
- package/icons/chevron-left.ts +1 -0
- package/icons/chevron-right.ts +1 -0
- package/icons/chevron-up-down.ts +5 -0
- package/icons/chevron-up.ts +1 -0
- package/icons/circle-alert.ts +1 -0
- package/icons/circle-check.ts +1 -0
- package/icons/clipboard.ts +1 -0
- package/icons/download.ts +1 -0
- package/icons/edit.ts +1 -0
- package/icons/external-link.ts +1 -0
- package/icons/eye.ts +1 -0
- package/icons/file.ts +1 -0
- package/icons/filter.ts +1 -0
- package/icons/folder.ts +1 -0
- package/icons/image.ts +1 -0
- package/icons/inbox.ts +1 -0
- package/icons/index.ts +91 -0
- package/icons/info.ts +1 -0
- package/icons/layers.ts +1 -0
- package/icons/link-2.ts +1 -0
- package/icons/list.ts +1 -0
- package/icons/loader.ts +3 -0
- package/icons/menu.ts +1 -0
- package/icons/more-horizontal.ts +1 -0
- package/icons/more-vertical.ts +1 -0
- package/icons/plus-circle.ts +1 -0
- package/icons/plus.ts +1 -0
- package/icons/save.ts +1 -0
- package/icons/search.ts +1 -0
- package/icons/send.ts +1 -0
- package/icons/settings.ts +1 -0
- package/icons/tool.ts +1 -0
- package/icons/trash-2.ts +1 -0
- package/icons/trash.ts +1 -0
- package/icons/upload-cloud.ts +1 -0
- package/icons/upload.ts +1 -0
- package/icons/x.ts +1 -0
- package/package.json +150 -0
- package/styles/0-settings/colors.css +241 -0
- package/styles/0-settings/index.css +5 -0
- package/styles/0-settings/layout.css +52 -0
- package/styles/0-settings/motion.css +11 -0
- package/styles/0-settings/spacing.css +15 -0
- package/styles/0-settings/typography.css +37 -0
- package/styles/0-utils/index.css +1 -0
- package/styles/1-reset/index.css +1 -0
- package/styles/1-reset/reset.css +26 -0
- package/styles/2-base/base.css +42 -0
- package/styles/2-base/forms.css +23 -0
- package/styles/2-base/index.css +2 -0
- package/styles/3-layout/container.css +57 -0
- package/styles/3-layout/index.css +2 -0
- package/styles/3-layout/section.css +17 -0
- package/styles/5-utilities/accessibility.css +13 -0
- package/styles/5-utilities/index.css +2 -0
- package/styles/5-utilities/text.css +5 -0
- package/styles/main.css +8 -0
- package/styles/styles.d.ts +6 -0
- package/utils/a11y/focus.ts +68 -0
- package/utils/a11y/id.ts +10 -0
- package/utils/a11y/index.ts +9 -0
- package/utils/a11y/keyboard.ts +32 -0
- package/utils/a11y/live-region.ts +36 -0
- package/utils/controllers/dialog.ts +205 -0
- package/utils/controllers/disclosure.ts +117 -0
- package/utils/controllers/form.ts +524 -0
- package/utils/controllers/index.ts +39 -0
- package/utils/controllers/menu.ts +255 -0
- package/utils/controllers/number-field.ts +103 -0
- package/utils/controllers/otp.ts +252 -0
- package/utils/controllers/popover.ts +434 -0
- package/utils/controllers/sidebar.ts +610 -0
- package/utils/controllers/slider.ts +336 -0
- package/utils/controllers/tags-input.ts +255 -0
- package/utils/controllers/toast.ts +426 -0
- package/utils/dom/index.ts +1 -0
- package/utils/dom/scroll-lock.ts +48 -0
- package/utils/index.ts +3 -0
- package/utils/sort/index.ts +3 -0
- package/utils/sort/serialize.ts +19 -0
- package/utils/sort/state.ts +11 -0
- package/utils/sort/types.ts +15 -0
- package/utils/validation/form.ts +93 -0
- package/utils/validation/index.ts +13 -0
- package/utils/validation/rules.ts +31 -0
package/CONVENTIONS.md
ADDED
|
@@ -0,0 +1,1499 @@
|
|
|
1
|
+
# `@pienter/ui` Authoring Conventions
|
|
2
|
+
|
|
3
|
+
This document codifies how to author components and utilities in `packages/ui/`. Read it before starting any component task. If a needed convention is missing, escalate to the maintainer rather than improvise.
|
|
4
|
+
|
|
5
|
+
The architectural decisions behind these conventions live in the OpenSpec change `component-library-shadcn-baseline` (see `openspec/changes/component-library-shadcn-baseline/design.md`). This file is the working reference for contributors; the design doc is the rationale.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## BEM block & element naming
|
|
10
|
+
|
|
11
|
+
Component CSS uses **BEM-lite**:
|
|
12
|
+
|
|
13
|
+
- **Block**: `pui-` prefix + kebab-case component name. One block per component, declared in the component's CSS file.
|
|
14
|
+
- **Element**: `<block>__<element>` — child parts within the block (separator `__`).
|
|
15
|
+
- **Modifiers**: do **not** use `--` BEM modifiers. Variants, sizes, and runtime states are expressed via `data-*` attributes on the block (see next section). Reasons: keeps the class list short, lets us toggle states from JS without touching `class`, and matches shadcn's pattern.
|
|
16
|
+
|
|
17
|
+
**Worked example — Button**:
|
|
18
|
+
|
|
19
|
+
```css
|
|
20
|
+
@layer components {
|
|
21
|
+
.pui-btn {
|
|
22
|
+
/* block */
|
|
23
|
+
}
|
|
24
|
+
.pui-btn__spinner {
|
|
25
|
+
/* element */
|
|
26
|
+
}
|
|
27
|
+
.pui-btn[data-variant='primary'] {
|
|
28
|
+
/* variant via data-attribute */
|
|
29
|
+
}
|
|
30
|
+
.pui-btn[data-state='loading'] {
|
|
31
|
+
/* runtime state via data-attribute */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Component module paths
|
|
39
|
+
|
|
40
|
+
Implementation modules live under a singular role category and retain a
|
|
41
|
+
colocated module folder:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
components/<category>/<module>/
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The categories are `action`, `display`, `feedback`, `form`, `layout`,
|
|
48
|
+
`navigation`, and `overlay`. Cross-module implementation imports use these
|
|
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
|
|
52
|
+
private helpers are not public package entrypoints.
|
|
53
|
+
|
|
54
|
+
## Multi-component-per-directory exception
|
|
55
|
+
|
|
56
|
+
Components may co-locate in a single directory when they form a **tightly-coupled visual sibling family** sharing one CSS file. The directory name reflects the shared visual concept, not a single component name.
|
|
57
|
+
|
|
58
|
+
Current consumers:
|
|
59
|
+
|
|
60
|
+
- `components/display/avatar/` — `Avatar` + `AvatarStack` (the stack is a visual layout for grouping avatars; stack styling lives in `avatar.css` alongside the avatar visual)
|
|
61
|
+
- `components/form/select/` — `Select` + `Segmented` (the segmented control is a tab-like single-select visual; shares form-primitive scaffolding with Select; will be retrofit in Phase 4)
|
|
62
|
+
- `components/action/button/` — `Button` + `IconButton` (the icon-only control is a square button. `button.css` shares the base/variant/size/state/focus rules across both via a `.pui-btn, .pui-icon-btn` selector group; a `.pui-btn`-only block holds the label, link variant, block layout, and loading fade that must not reach the icon button; a `.pui-icon-btn` rule adds the square box)
|
|
63
|
+
|
|
64
|
+
Rules:
|
|
65
|
+
|
|
66
|
+
- 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
|
+
- Each component file has a separate flat public import (`@pienter/ui/components/Avatar.vue` and `@pienter/ui/components/AvatarStack.vue`)
|
|
69
|
+
- Each component still has its own entry in the directory's `AUDIT.md`
|
|
70
|
+
- 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)
|
|
71
|
+
|
|
72
|
+
This exception is the directory-shape analogue to the form-primitive shared-scaffold exception (see "Form-primitive wrapper" archetype): both accept a single shared resource (CSS file or BEM block) across multiple component files because duplicating it would invite drift.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## CSS layer & token rules
|
|
77
|
+
|
|
78
|
+
All component CSS rules SHALL be wrapped in `@layer components { … }` so they
|
|
79
|
+
participate in the documented layer cascade (`reset, base, layout, components, utilities`).
|
|
80
|
+
|
|
81
|
+
All values referencing color, spacing, typography, radii, strokes, and shadows
|
|
82
|
+
SHALL use design tokens via `var(--…)` — no hardcoded hex, rgb, rem, or px
|
|
83
|
+
literals.
|
|
84
|
+
|
|
85
|
+
**Permitted literal escape hatches** (cases where no scale token applies):
|
|
86
|
+
|
|
87
|
+
- decorative widths on small visual elements (e.g., spinner ring border, accent
|
|
88
|
+
underlines) where the value is intrinsic to the element rather than scaled
|
|
89
|
+
- transition durations and timing functions
|
|
90
|
+
|
|
91
|
+
If a needed value isn't covered by a token _and_ isn't on this escape-hatch
|
|
92
|
+
list, add a token to `0-settings/` rather than hardcoding.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## `data-*` attribute conventions
|
|
97
|
+
|
|
98
|
+
Variants and runtime states live on `data-*` attributes on the block element, never on class names.
|
|
99
|
+
|
|
100
|
+
| Attribute | Purpose | Example values |
|
|
101
|
+
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
102
|
+
| `data-variant` | Visual variant (mutually exclusive within a component) | `primary`, `secondary`, `ghost`, `danger`, `link` |
|
|
103
|
+
| `data-size` | Sizing scale (mutually exclusive) | `sm`, `md` (Button/IconButton); `sm`/`md`/`lg` (Icon); other components add their own |
|
|
104
|
+
| `data-animation` | Continuous decorative animation on the block | `spin` (Icon) |
|
|
105
|
+
| `data-tone` | Semantic tone for non-variant components (Alert, Badge, Toast) | `brand`, `success`, `warning`, `danger` |
|
|
106
|
+
| `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` |
|
|
108
|
+
| `data-status` | Derived semantic status for form primitives (validation outcome) | `error`, `success` |
|
|
109
|
+
| `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
|
+
| `data-orientation` | Layout direction for grouped controls (RadioGroup items, future SegmentedControl, etc.) — lives on the group container | `vertical` (default for RadioGroup), `horizontal` |
|
|
111
|
+
|
|
112
|
+
The example values listed for each attribute are non-exhaustive — components add states as needed (e.g. `expanded`, `selected`, `active` for upcoming Disclosure, Tabs, Sidebar). New values follow the same kebab-case rule and live on the same attribute family.
|
|
113
|
+
|
|
114
|
+
**`data-state` vs `data-status`** — both attributes describe how a component
|
|
115
|
+
"is right now," but they have different sources:
|
|
116
|
+
|
|
117
|
+
- `data-state` is **runtime UI state** — owned by code or a controller, driven
|
|
118
|
+
by interaction (`loading` while a request is in flight, `open`/`closed` for a
|
|
119
|
+
disclosure, `checked`/`indeterminate` for a toggle). It usually changes in
|
|
120
|
+
response to user input or async work.
|
|
121
|
+
- `data-status` is **derived semantic status** — owned by validation /
|
|
122
|
+
form-orchestration logic, computed from props. `error` means "this field
|
|
123
|
+
failed validation" (typically derived from a non-empty `errors` array);
|
|
124
|
+
`success` means "this field passed and we want to surface that" (an explicit
|
|
125
|
+
prop, not auto-derived).
|
|
126
|
+
|
|
127
|
+
A single component MAY carry both: a TextInput can be `data-state="loading"`
|
|
128
|
+
(busy) AND `data-status="error"` (validation failed) at once.
|
|
129
|
+
|
|
130
|
+
### Boolean attributes — only-when-truthy
|
|
131
|
+
|
|
132
|
+
For any boolean prop emitted as a `data-*` or `aria-*` attribute, render the
|
|
133
|
+
attribute _only when the prop is truthy_, never with a literal `'false'`:
|
|
134
|
+
|
|
135
|
+
```vue
|
|
136
|
+
:data-block="block ? 'true' : undefined" :aria-busy="loading ? 'true' :
|
|
137
|
+
undefined"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
CSS targets the truthy presence (`[data-block='true']`, `[aria-busy='true']`).
|
|
141
|
+
Avoids `data-block="false"` polluting the DOM and matching unintended selectors.
|
|
142
|
+
|
|
143
|
+
**Conventions**:
|
|
144
|
+
|
|
145
|
+
- `data-state` is reserved for _runtime_ state (loading, open/closed, checked). Static visual choice belongs on `data-variant` or `data-size`.
|
|
146
|
+
- Values are kebab-case strings; never booleans or numbers.
|
|
147
|
+
|
|
148
|
+
**Worked example — Button in loading state**:
|
|
149
|
+
|
|
150
|
+
```html
|
|
151
|
+
<button
|
|
152
|
+
class="pui-btn"
|
|
153
|
+
data-variant="primary"
|
|
154
|
+
data-size="md"
|
|
155
|
+
data-state="loading"
|
|
156
|
+
aria-busy="true"
|
|
157
|
+
disabled
|
|
158
|
+
>
|
|
159
|
+
<span class="pui-btn__spinner" aria-hidden="true"></span>
|
|
160
|
+
Saving…
|
|
161
|
+
</button>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Component archetypes
|
|
167
|
+
|
|
168
|
+
Each component fits one of a small set of authoring archetypes. Archetypes are locked as they are encountered in Phase 1.
|
|
169
|
+
|
|
170
|
+
### Native-element-wrapper
|
|
171
|
+
|
|
172
|
+
A component that wraps a single native HTML element (`<button>`, `<input>`, `<a>`, `<select>`, etc.) with class + `data-*` + ARIA wiring. The wrapper adds variant/size/state mapping but does not synthesize behavior — keyboard and form semantics come from the underlying native element.
|
|
173
|
+
|
|
174
|
+
**Authoring checklist for native-element wrappers**:
|
|
175
|
+
|
|
176
|
+
- [ ] Renders the underlying native element directly (`<button>`, `<input>`,
|
|
177
|
+
`<select>`, `<a>`, etc.); no `<div role="…">` simulations.
|
|
178
|
+
- [ ] Variant/size/visual props map to `data-*` attributes on the element.
|
|
179
|
+
- [ ] `disabled` flows to the native attribute when supported. For elements
|
|
180
|
+
that don't support native `disabled` (`<a>`), use
|
|
181
|
+
`aria-disabled="true"` AND `tabindex="-1"` AND `pointer-events: none`
|
|
182
|
+
in CSS so the element is fully inert.
|
|
183
|
+
- [ ] If polymorphic via a single prop (e.g., Button's `href` toggles
|
|
184
|
+
`<button>`/`<a>`), conditionally drop attributes that don't apply
|
|
185
|
+
(`type`, `disabled`) and use ARIA equivalents on the alternate branch.
|
|
186
|
+
- [ ] Loading state: emit `disabled` (or `aria-disabled` + `tabindex="-1"`
|
|
187
|
+
for `<a>`) AND `aria-busy="true"` AND `data-state="loading"` AND a
|
|
188
|
+
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.
|
|
192
|
+
|
|
193
|
+
**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
|
+
|
|
195
|
+
### Form-primitive wrapper
|
|
196
|
+
|
|
197
|
+
A component that wraps a single native form control (`<input>`, `<textarea>`,
|
|
198
|
+
`<select>`, grouped `<input type="radio">`, etc.) plus the surrounding label,
|
|
199
|
+
hint, and error-list — i.e. the visible label, the optional helper text under
|
|
200
|
+
the field, and the validation messages that appear after submit. Form
|
|
201
|
+
primitives extend the native-element-wrapper archetype with **shared
|
|
202
|
+
label / hint / error scaffolding** and the ARIA wiring that ties them to the
|
|
203
|
+
control.
|
|
204
|
+
|
|
205
|
+
**Shared scaffold — explicit BEM exception**: form primitives wrap their
|
|
206
|
+
control in a `pui-field` element that hosts the label, hint, and error list. The
|
|
207
|
+
control itself uses a per-element block name (`pui-input`, `pui-textarea`,
|
|
208
|
+
`pui-select`, `pui-checkbox`, …). This is an **explicit exception** to the
|
|
209
|
+
"block matches component name" rule: the scaffold styles (label typography,
|
|
210
|
+
hint colour, status-driven recolour) are reused by every form primitive, so
|
|
211
|
+
duplicating them under per-component blocks (`v-text-input__label`,
|
|
212
|
+
`pui-textarea__label`, `pui-select__label`, …) would produce 10+ identical
|
|
213
|
+
declarations and invite drift. The trade-off — that the wrapper class doesn't
|
|
214
|
+
match the component file name — is accepted in exchange for one source of
|
|
215
|
+
truth for field chrome.
|
|
216
|
+
|
|
217
|
+
`<fieldset>` is the wrapper for grouped controls (RadioGroup is the
|
|
218
|
+
v1 reference; future Checkbox-group variants would follow the same
|
|
219
|
+
shape) where a single legend describes multiple inputs; everything
|
|
220
|
+
else (TextInput, Textarea, Select, Switch, Checkbox-singular, NumberField,
|
|
221
|
+
DateInput) uses `<div class="pui-field">` with a single `<label>`. See the
|
|
222
|
+
RadioGroup audit (`components/form/radio-group/AUDIT.md`) for the worked example — `<fieldset
|
|
223
|
+
class="pui-field" role="radiogroup">` with `<legend class="pui-field__label">`
|
|
224
|
+
above the rendered options, hint and errors below.
|
|
225
|
+
|
|
226
|
+
**Authoring checklist for form-primitive wrappers**:
|
|
227
|
+
|
|
228
|
+
- [ ] Wrapper renders `<div class="pui-field">` (or `<fieldset class="pui-field">`
|
|
229
|
+
with `<legend class="pui-field__label">` for grouped controls like
|
|
230
|
+
RadioGroup).
|
|
231
|
+
- [ ] Label uses `<label class="pui-field__label" for={inputId}>` (or `<legend>`
|
|
232
|
+
for a fieldset wrapper) with `for` matching the control's `id`.
|
|
233
|
+
- [ ] If `id` is not consumer-provided, generate via
|
|
234
|
+
`generateId('<prefix>')` from `@pienter/ui/utils/a11y/id`, where
|
|
235
|
+
`<prefix>` is the bare native-element name (`'input'`, `'textarea'`,
|
|
236
|
+
`'select'`, `'checkbox'`); for grouped controls use the role
|
|
237
|
+
(`'radiogroup'`). Derive `${inputId}-hint` and `${inputId}-errors`
|
|
238
|
+
from the same base.
|
|
239
|
+
- [ ] Hint renders as `<p class="pui-field__hint" id={hintId}>` only when the
|
|
240
|
+
`hint` prop is set. Omit the element entirely when absent.
|
|
241
|
+
- [ ] Errors render as `<ul class="pui-field__hint" id={errorsId}
|
|
242
|
+
role="alert">` with one `<li>` per error message, only when errors are
|
|
243
|
+
present. Omit the element entirely when the errors array is empty.
|
|
244
|
+
- `role="alert"` (which carries implicit `aria-live="assertive"` and
|
|
245
|
+
`aria-atomic="true"`) is appropriate for submit-time validation messages
|
|
246
|
+
that arrive as a batch. For live/as-you-type validation — out of scope
|
|
247
|
+
for v1 — use `role="status"` or `aria-live="polite"` instead, to avoid
|
|
248
|
+
interrupting the user mid-keystroke. When live validation lands, this
|
|
249
|
+
rule is reopened.
|
|
250
|
+
- [ ] Control element receives `aria-invalid="true"` (string, per the
|
|
251
|
+
boolean-attr-only-when-truthy rule) when errors are present; omit the
|
|
252
|
+
attribute when not.
|
|
253
|
+
- [ ] Control element receives `aria-describedby` listing hint and/or errors
|
|
254
|
+
IDs (space-separated when both); omit the attribute entirely when
|
|
255
|
+
neither exists. Order: hint first, errors second. Hint-first matches a
|
|
256
|
+
"context, then problem" reading flow for screen-reader users; do not
|
|
257
|
+
reorder per-component.
|
|
258
|
+
- [ ] Visual status lives on `data-status` on the wrapper (`pui-field`), not on
|
|
259
|
+
`data-state`. CSS targets `.pui-field[data-status='error'] .pui-input`
|
|
260
|
+
etc. so the same selector pattern works across every form primitive.
|
|
261
|
+
`data-status` is derived: if a `status` prop is set, use it; otherwise
|
|
262
|
+
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`,
|
|
265
|
+
`autocomplete`, `inputmode`, event listeners, ARIA labels, etc.
|
|
266
|
+
|
|
267
|
+
**Declared props are stripped from `$attrs`** — when a Vue component
|
|
268
|
+
declares a prop (e.g., `disabled`, `name`, `value`), Vue removes it from
|
|
269
|
+
`$attrs` before the `v-bind="$attrs"` spread. Re-bind explicitly on the
|
|
270
|
+
control element (`:disabled="disabled"`, `:name="name"`) to flow the
|
|
271
|
+
typed prop through. Order matters: bind `$attrs` first, then explicit
|
|
272
|
+
props — explicit bindings win, which is what we want.
|
|
273
|
+
|
|
274
|
+
- [ ] Compact form primitives (Switch, Checkbox, individual Radio items) use
|
|
275
|
+
`data-layout="inline"` on the `pui-field` wrapper to flip the layout to
|
|
276
|
+
control-left + label-right. Everything else (label association via
|
|
277
|
+
`for`/`id`, hint/error rendering, ARIA wiring, `aria-describedby`
|
|
278
|
+
joining, status-driven recolour) stays unchanged. The DOM source order
|
|
279
|
+
under `data-layout="inline"` is **control first, label second** so the
|
|
280
|
+
`<label for>` association is read in left-to-right order, and the
|
|
281
|
+
`pui-field__hint` elements remain after the label so a single CSS rule
|
|
282
|
+
(`grid-column: 1 / -1`) drops them onto a row below the control + label.
|
|
283
|
+
|
|
284
|
+
**Worked example — TextInput**: see `packages/ui/components/form/text-input/`. The
|
|
285
|
+
wrapper is `<div class="pui-field" data-status>`, the label is
|
|
286
|
+
`<label class="pui-field__label" for>`, the control is `<input class="pui-input">`,
|
|
287
|
+
hint and errors are `<p|ul class="pui-field__hint" id>`. `aria-invalid="true"`
|
|
288
|
+
emitted only on error; `aria-describedby` joins hint and error IDs with a
|
|
289
|
+
space when both exist.
|
|
290
|
+
|
|
291
|
+
**Worked example — Switch (inline layout)**: see
|
|
292
|
+
`packages/ui/components/form/switch/`. The wrapper is
|
|
293
|
+
`<div class="pui-field" data-layout="inline" data-status>`, the control is
|
|
294
|
+
`<input class="pui-switch" type="checkbox" role="switch">` rendered FIRST in
|
|
295
|
+
DOM order, the label is `<label class="pui-field__label" for>` rendered
|
|
296
|
+
second, and hint/errors render below the row spanning both columns. ARIA
|
|
297
|
+
wiring (`aria-checked`, `aria-invalid`, `aria-describedby`) follows the
|
|
298
|
+
same shape as TextInput.
|
|
299
|
+
|
|
300
|
+
**Worked example — RadioGroup (grouped controls)**: see
|
|
301
|
+
`packages/ui/components/form/radio-group/`. The wrapper is
|
|
302
|
+
`<fieldset class="pui-field" role="radiogroup" data-status>`, the label is
|
|
303
|
+
`<legend class="pui-field__label">`, the inner control region is a
|
|
304
|
+
`<div class="pui-radio-group" data-orientation>` containing one
|
|
305
|
+
`<label class="pui-radio-group__item">` per option (each label wraps its own
|
|
306
|
+
native `<input type="radio" class="pui-radio">`), and hint/errors render
|
|
307
|
+
inside the fieldset around the items. The outer field layout is stacked
|
|
308
|
+
(legend above, items below) — `data-layout="inline"` does NOT apply at
|
|
309
|
+
this level; per-item inline layout lives on `.pui-radio-group__item` via
|
|
310
|
+
`display: inline-flex`. Per-radio uses the **label-wrapping pattern**
|
|
311
|
+
(label wraps input) rather than `<label for>` association — the option's
|
|
312
|
+
short string label sits directly inside the wrapping label element. ARIA:
|
|
313
|
+
`aria-invalid` and `aria-describedby` land on the fieldset (group-level);
|
|
314
|
+
each input carries always-emit `aria-checked="true|false"`.
|
|
315
|
+
|
|
316
|
+
**pui-field scaffold CSS — co-located with TextInput for now**: the shared
|
|
317
|
+
`.pui-field`, `.pui-field__label`, `.pui-field__hint`, and
|
|
318
|
+
`.pui-field[data-layout='inline']` rules live in
|
|
319
|
+
`packages/ui/components/form/text-input/text-input.css` because TextInput was
|
|
320
|
+
the first form-primitive consumer. Per-control element rules
|
|
321
|
+
(`.pui-input`, `.pui-switch`, etc.) live in their own component CSS. **When
|
|
322
|
+
the fifth form-primitive ships** (Checkbox, RadioGroup, Select, Textarea,
|
|
323
|
+
NumberField, DateInput will push that count over), extract the
|
|
324
|
+
`pui-field*` rules to `packages/ui/styles/4-components/form-field.css` so
|
|
325
|
+
the scaffold isn't tied to one component's filename. The `data-layout`
|
|
326
|
+
documentation in this file points consumers at the scaffold regardless
|
|
327
|
+
of where the file lives.
|
|
328
|
+
|
|
329
|
+
### Pure-markup with slots
|
|
330
|
+
|
|
331
|
+
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**.
|
|
333
|
+
Variants drive visual style via `data-variant` / `data-tone`; content flows
|
|
334
|
+
in through slots (default and/or named). The component is essentially a
|
|
335
|
+
named CSS chunk wrapped in a framework primitive so consumers don't have to
|
|
336
|
+
remember the wrapper class.
|
|
337
|
+
|
|
338
|
+
**Authoring checklist for pure-markup components**:
|
|
339
|
+
|
|
340
|
+
- [ ] Renders a single block element (or a minimal nested structure when the
|
|
341
|
+
component has distinct semantic regions — e.g. an Empty illustration
|
|
342
|
+
slot above body content).
|
|
343
|
+
- [ ] Default slot for the primary content area. Named slots only when the
|
|
344
|
+
component has 2+ ordered regions whose layout the component owns —
|
|
345
|
+
see "Slot vs prop conventions" below.
|
|
346
|
+
- [ ] Variants and tones expressed via `data-variant` or `data-tone` per the
|
|
347
|
+
locked rules; no per-variant class names.
|
|
348
|
+
- [ ] No JS — no controllers, no event listeners, no reactive state. If a
|
|
349
|
+
pure-markup component grows behavior (loading, dismiss, expand), it
|
|
350
|
+
graduates to one of the other archetypes and the audit entry is updated.
|
|
351
|
+
- [ ] No ARIA roles **unless the component carries semantic meaning the
|
|
352
|
+
markup doesn't already convey**. Card has no role (it's a styled
|
|
353
|
+
`<div>`); Alert has `role="alert"` because its purpose is to surface
|
|
354
|
+
dynamic, attention-worthy messages. Don't add roles speculatively.
|
|
355
|
+
- [ ] If the CSS publishes element classes (`<block>__<element>`) for
|
|
356
|
+
consumer use **inside** the slot content (Card's `pui-card__title`,
|
|
357
|
+
`pui-card__eyebrow`, etc.), document those classes in the audit entry
|
|
358
|
+
as **public API**. Renaming or removing them is a breaking change.
|
|
359
|
+
- [ ] Vue uses the default `inheritAttrs: true` (the wrapper IS the only
|
|
360
|
+
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.
|
|
363
|
+
|
|
364
|
+
**Worked example — Card**: see `packages/ui/components/layout/card/`. Renders a
|
|
365
|
+
single `<div class="pui-card" data-variant>` with a default slot. Variants
|
|
366
|
+
on `data-variant` (`bordered`, `brand`, `muted`); no ARIA role; no
|
|
367
|
+
controller. Public child classes — `pui-card`, `pui-card__eyebrow`,
|
|
368
|
+
`pui-card__title`, `pui-card__body`, `pui-card__meta`, `pui-card__footer` — are
|
|
369
|
+
applied by consumers to their own markup inside the slot.
|
|
370
|
+
|
|
371
|
+
### Hybrid API (data + slots)
|
|
372
|
+
|
|
373
|
+
Locked during Phase 1 alongside Sidebar (2026-05-03). A component that
|
|
374
|
+
mixes a **data-driven, a11y-critical region** (e.g. Sidebar's navigation
|
|
375
|
+
menu) with **decorative or contextual regions** driven by named slots
|
|
376
|
+
(e.g. `#brand`, `#footer`). The a11y wiring — active-state derivation,
|
|
377
|
+
ARIA attributes, keyboard navigation, roving tabindex — lives once
|
|
378
|
+
inside the component and is not opt-out-able by consumers.
|
|
379
|
+
|
|
380
|
+
This is the canonical archetype for "data-driven menu surfaces" — any
|
|
381
|
+
future component that has a repetitive list of links/items where each
|
|
382
|
+
item must carry consistent ARIA + keyboard behavior follows this
|
|
383
|
+
pattern. Sidebar is the v1 reference; future Menubar / NavigationMenu
|
|
384
|
+
(deferred) would adopt the same shape.
|
|
385
|
+
|
|
386
|
+
**Authoring checklist for hybrid-API components**:
|
|
387
|
+
|
|
388
|
+
- [ ] One **driver-data prop** with a typed shape. When entries can vary
|
|
389
|
+
structurally (link vs group vs section heading), the type is a
|
|
390
|
+
**discriminated union** keyed on a `type` field. Place the union
|
|
391
|
+
in a colocated `types.ts` (`components/<category>/<module>/types.ts`) and
|
|
392
|
+
re-export it from the component file so consumers import one
|
|
393
|
+
symbol.
|
|
394
|
+
- [ ] **Named slots for non-driver regions** only — `#brand`, `#footer`,
|
|
395
|
+
`#actions`, etc. Each slot is a documented region with a fixed
|
|
396
|
+
DOM placement and appears in the component's audit entry. The
|
|
397
|
+
driver-data region's items can NOT be replaced via slot — doing
|
|
398
|
+
so would break the centralized a11y wiring.
|
|
399
|
+
- [ ] A11y wiring is owned by the component and consistent across every
|
|
400
|
+
data-driven entry: active-state derivation, `aria-current`,
|
|
401
|
+
`aria-expanded`, roving tabindex, keyboard arrow navigation,
|
|
402
|
+
`role` choice. Consumers cannot opt out of any of this — they
|
|
403
|
+
provide data, the component renders the right ARIA.
|
|
404
|
+
- [ ] Active-state is **derived**, not pre-marked on items. The
|
|
405
|
+
component takes an active-key prop (e.g. Sidebar's `:active-href`,
|
|
406
|
+
Tabs' `:model-value`) and applies the active styling/ARIA
|
|
407
|
+
internally. Consumers do not pass `active: true` on individual
|
|
408
|
+
items.
|
|
409
|
+
- [ ] Recursion (when items can nest, e.g. Sidebar groups) is handled
|
|
410
|
+
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.
|
|
416
|
+
- [ ] State that the consumer might want to control (collapsed/expanded
|
|
417
|
+
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).
|
|
420
|
+
|
|
421
|
+
**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`
|
|
424
|
+
slots cover decorative regions. Active-state derived from
|
|
425
|
+
`: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`.
|
|
430
|
+
|
|
431
|
+
### Form orchestration
|
|
432
|
+
|
|
433
|
+
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
|
+
|
|
435
|
+
**Layered split** — three layers, locked per design D13:
|
|
436
|
+
|
|
437
|
+
1. **Shared primitives** (used by both Vue and Astro):
|
|
438
|
+
- `utils/validation/rules.ts` — pure validators (`isRequired`, `isEmail`, `minLength`, …).
|
|
439
|
+
- `utils/validation/form.ts:validateFormData(formData, validators)` — runs validators against `FormData`, returns `Record<string, string[]>`. Pure, framework-agnostic.
|
|
440
|
+
- `utils/validation/form.ts:normalizeFieldErrors(map)` — normalizes the `string | string[]` per-field shape from `FormSubmitResponse` to `string[]`.
|
|
441
|
+
- `utils/a11y/focus.ts:focusFirstInvalid(formEl)` — DOM-imperative focus helper.
|
|
442
|
+
- `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).
|
|
445
|
+
|
|
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.
|
|
447
|
+
|
|
448
|
+
**Consumer responsibility — Vue vs Astro**:
|
|
449
|
+
|
|
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'] }` |
|
|
457
|
+
|
|
458
|
+
**Error display rules**:
|
|
459
|
+
|
|
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.
|
|
461
|
+
- 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
|
+
- 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
|
+
|
|
464
|
+
**Submit lifecycle**:
|
|
465
|
+
|
|
466
|
+
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`).
|
|
469
|
+
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
|
+
|
|
471
|
+
**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
|
+
|
|
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.
|
|
476
|
+
|
|
477
|
+
### Active-href hierarchical-match algorithm
|
|
478
|
+
|
|
479
|
+
Locked during Phase 1 alongside Sidebar (2026-05-03). The canonical
|
|
480
|
+
algorithm for deriving "is this nav item active?" from a current URL
|
|
481
|
+
path (`activeHref`) and an item's target path (`itemHref`).
|
|
482
|
+
|
|
483
|
+
```
|
|
484
|
+
isActive(activeHref, itemHref):
|
|
485
|
+
if activeHref is empty/null → false
|
|
486
|
+
if itemHref === '/' → return activeHref === '/' // root special-case
|
|
487
|
+
if activeHref === itemHref → true // exact match
|
|
488
|
+
if activeHref starts with (itemHref + '/') → true // hierarchical
|
|
489
|
+
otherwise → false
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
**Worked examples**:
|
|
493
|
+
|
|
494
|
+
| activeHref | itemHref | match? | rationale |
|
|
495
|
+
| -------------------- | ---------------- | ------ | ------------------------------------------ |
|
|
496
|
+
| `/admin/members` | `/admin/members` | yes | exact |
|
|
497
|
+
| `/admin/members/123` | `/admin/members` | yes | hierarchical (`/admin/members/` prefix) |
|
|
498
|
+
| `/admin/members-old` | `/admin/members` | no | path-boundary check rejects substring-only |
|
|
499
|
+
| `/admin/members` | `/` | no | root special-case |
|
|
500
|
+
| `/` | `/` | yes | root exact |
|
|
501
|
+
|
|
502
|
+
**Group active-state**: a group item is active when _any_ of its
|
|
503
|
+
descendant items (recursively) is active. Sidebar uses this to expand
|
|
504
|
+
the matching group on mount and to highlight the group header.
|
|
505
|
+
|
|
506
|
+
**Edge cases — out of scope for v1**, documented for future:
|
|
507
|
+
|
|
508
|
+
- Trailing slashes are NOT normalized (`/admin/members/` vs
|
|
509
|
+
`/admin/members` are treated as different). Consumers that may emit
|
|
510
|
+
trailing slashes should normalize before passing to `:active-href`.
|
|
511
|
+
- Query strings and hash fragments are NOT stripped. If `activeHref =
|
|
512
|
+
'/admin/members?filter=active'`, no item href without that query
|
|
513
|
+
matches. Strip in the consumer for now; revisit if real surfaces
|
|
514
|
+
start to need server-driven querystring-aware activation.
|
|
515
|
+
- Case sensitivity follows JS string comparison (case-sensitive).
|
|
516
|
+
|
|
517
|
+
This algorithm lives in the Sidebar component (not in `utils/`) for
|
|
518
|
+
v1 because it has no other consumer; promote to a shared util when
|
|
519
|
+
Breadcrumb / Tabs / Pagination / DropdownMenu need the same matching
|
|
520
|
+
(Phase 3+).
|
|
521
|
+
|
|
522
|
+
---
|
|
523
|
+
|
|
524
|
+
## Slot vs prop conventions
|
|
525
|
+
|
|
526
|
+
Locked during Phase 1 alongside Card (2026-05-03). Rules apply across every
|
|
527
|
+
component, not only the pure-markup tier.
|
|
528
|
+
|
|
529
|
+
### When to use a default slot
|
|
530
|
+
|
|
531
|
+
Use a single default slot for the primary content area when the slot's
|
|
532
|
+
content is "whatever the consumer wants" — rich markup, other components,
|
|
533
|
+
arbitrary structure. Examples: Card body, Modal body, Alert content,
|
|
534
|
+
AlertDialog body, Sheet body, Sidebar item content via `link.label` markup
|
|
535
|
+
(deferred to Sidebar audit).
|
|
536
|
+
|
|
537
|
+
### When to use named slots
|
|
538
|
+
|
|
539
|
+
Use named slots when the component has **2+ distinct semantic regions** AND
|
|
540
|
+
the component itself owns the **layout and ordering** of those regions —
|
|
541
|
+
i.e. the consumer can't simply concatenate them inside a default slot
|
|
542
|
+
because the component places each in a fixed DOM position. Examples:
|
|
543
|
+
|
|
544
|
+
- Modal's `#footer` (placed below the body, inside the dialog content,
|
|
545
|
+
styled as the action bar)
|
|
546
|
+
- Nav's `#actions` (placed at the trailing edge of the navbar)
|
|
547
|
+
- Sidebar's `#brand` and `#footer` (header / footer regions of the layout
|
|
548
|
+
shell, deferred to Sidebar)
|
|
549
|
+
- AlertDialog's actions slot (deferred to Phase 4)
|
|
550
|
+
|
|
551
|
+
Each named slot is a **documented region with its own DOM placement** and
|
|
552
|
+
appears in the component's audit entry alongside its purpose.
|
|
553
|
+
|
|
554
|
+
### When to use props for content
|
|
555
|
+
|
|
556
|
+
Use a prop when the content is a **single short string with no rich markup
|
|
557
|
+
in v1**. Examples: TextInput's `label`, `hint`, `placeholder`; Modal's
|
|
558
|
+
`title`, `subtitle`; AlertDialog's `title`, `description`, `confirmLabel`,
|
|
559
|
+
`cancelLabel`. Strings with no semantic structure → props. Anything that
|
|
560
|
+
might want a `<strong>` or a link inside it → slot.
|
|
561
|
+
|
|
562
|
+
### Don't mix prop + slot for the same region
|
|
563
|
+
|
|
564
|
+
Avoid pairing a `:title` prop AND a `#title` slot for the same logical
|
|
565
|
+
region. Pick one and commit to it. Convention:
|
|
566
|
+
|
|
567
|
+
- If the region is **always** a single string in observed consumer usage →
|
|
568
|
+
prop (`<Modal title="Confirm">`).
|
|
569
|
+
- If a real consumer needs rich markup → switch the prop to a slot in a
|
|
570
|
+
deliberate change. Don't ship the dual-API "prop falls back to slot"
|
|
571
|
+
pattern; it doubles the surface and obscures intent.
|
|
572
|
+
|
|
573
|
+
### Documented child classes — Card's pattern
|
|
574
|
+
|
|
575
|
+
When a pure-markup component's default slot accepts rich consumer markup
|
|
576
|
+
AND the component publishes element classes (`pui-card__title`,
|
|
577
|
+
`pui-card__eyebrow`, etc.) for consumers to apply within their own markup,
|
|
578
|
+
those classes are **public API**. Document them in the component's audit
|
|
579
|
+
entry next to the slot description. Consumers write
|
|
580
|
+
`<h3 class="pui-card__title">…</h3>` inside `<Card>`'s slot; the class names
|
|
581
|
+
are stable across versions and renaming them is a breaking change.
|
|
582
|
+
|
|
583
|
+
This pattern is the **flat-API equivalent** of shadcn-style compound
|
|
584
|
+
subcomponents. Where shadcn writes `<CardTitle>` (a separately-imported
|
|
585
|
+
React component), we write `<h3 class="pui-card__title">` inside a single
|
|
586
|
+
`<Card>` slot. The styling reach is the same; the import surface is one
|
|
587
|
+
component instead of seven.
|
|
588
|
+
|
|
589
|
+
### Slots are templating, not subcomponents
|
|
590
|
+
|
|
591
|
+
Named slots accept arbitrary markup or other components. They never require
|
|
592
|
+
importing separately-named subcomponents (no `<CardHeader>` siblings, no
|
|
593
|
+
`<DialogTrigger>` imports). This restates the flat-API rule from D2 and
|
|
594
|
+
applies it to slot wiring: consumer code only ever imports the parent
|
|
595
|
+
component.
|
|
596
|
+
|
|
597
|
+
### Vue `inheritAttrs` default by archetype
|
|
598
|
+
|
|
599
|
+
- **Pure-markup with slots** (Card, Empty, Skeleton, Alert, Badge): no
|
|
600
|
+
inner element to forward to. Use the default `inheritAttrs: true` so
|
|
601
|
+
consumer-supplied `id`, `class`, `aria-*`, and event listeners land on
|
|
602
|
+
the wrapper.
|
|
603
|
+
- **Native-element wrapper** (Button): the wrapper IS the native element.
|
|
604
|
+
Default `inheritAttrs: true` is correct — attrs land on the `<button>` /
|
|
605
|
+
`<a>` directly.
|
|
606
|
+
- **Form-primitive wrapper** (TextInput): the rendered tree has a wrapper
|
|
607
|
+
(`pui-field`) AND an inner control (`<input>`). Set `inheritAttrs: false`
|
|
608
|
+
and `v-bind="$attrs"` on the inner element so consumer attrs reach the
|
|
609
|
+
control, not the chrome wrapper.
|
|
610
|
+
- **Stateful component** (Modal): the rendered tree is a single mount
|
|
611
|
+
element (`<dialog class="pui-modal">`) that is both the wrapper and the
|
|
612
|
+
surface. Set `inheritAttrs: false` and `v-bind="$attrs"` on the
|
|
613
|
+
`<dialog>` so consumer-supplied `id`, `class`, `aria-*`, and event
|
|
614
|
+
listeners land on the dialog itself rather than on Vue's auto-merged
|
|
615
|
+
fallthrough target. The same pattern applies to AlertDialog and Sheet
|
|
616
|
+
when they land.
|
|
617
|
+
|
|
618
|
+
---
|
|
619
|
+
|
|
620
|
+
## Controller mount-on-element pattern
|
|
621
|
+
|
|
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.
|
|
629
|
+
|
|
630
|
+
### Default signature — teardown-only
|
|
631
|
+
|
|
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:
|
|
635
|
+
|
|
636
|
+
```ts
|
|
637
|
+
export function mount<Name>(element: HTMLElement, config?: <Config>): () => void
|
|
638
|
+
```
|
|
639
|
+
|
|
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`.
|
|
644
|
+
|
|
645
|
+
### Stateful-component signature — control object
|
|
646
|
+
|
|
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:
|
|
651
|
+
|
|
652
|
+
```ts
|
|
653
|
+
interface DialogControl {
|
|
654
|
+
open(): void;
|
|
655
|
+
close(): void;
|
|
656
|
+
isOpen(): boolean;
|
|
657
|
+
teardown(): void;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export function mountDialog(
|
|
661
|
+
element: HTMLElement,
|
|
662
|
+
config?: DialogConfig,
|
|
663
|
+
): DialogControl;
|
|
664
|
+
```
|
|
665
|
+
|
|
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
|
|
676
|
+
`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 object — it 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.
|
|
687
|
+
|
|
688
|
+
### Singleton-style exception
|
|
689
|
+
|
|
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
|
|
693
|
+
document the exception here when added.
|
|
694
|
+
|
|
695
|
+
### When to share a controller vs write a new one
|
|
696
|
+
|
|
697
|
+
Controllers are SHARED when component variants differ only in
|
|
698
|
+
**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' }`.
|
|
702
|
+
|
|
703
|
+
Controllers are SEPARATE when components differ in **geometry, focus rules,
|
|
704
|
+
or trigger semantics**:
|
|
705
|
+
|
|
706
|
+
- Popover, Tooltip, DropdownMenu → their own `mountPopover` (anchor-positioned,
|
|
707
|
+
no scroll lock, no focus trap unless a modal popover, click-outside
|
|
708
|
+
dismisses by default)
|
|
709
|
+
- Combobox → its 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 +
|
|
717
|
+
AlertDialog. All three render `<dialog>` and only differ in CSS
|
|
718
|
+
geometry (Sheet anchors to a side instead of centering). The controller
|
|
719
|
+
mechanics are identical.
|
|
720
|
+
|
|
721
|
+
Listed mapping (locked):
|
|
722
|
+
|
|
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
|
|
762
|
+
semantics): TextInput, Textarea (planned), Select (planned),
|
|
763
|
+
**Switch**, **Checkbox**, **RadioGroup**. RadioGroup is the
|
|
764
|
+
worked example of a grouped form primitive that does NOT need a
|
|
765
|
+
controller — native `<input type="radio">` elements with a shared
|
|
766
|
+
`name` provide arrow-key keyboard nav, single-selection, and
|
|
767
|
+
Tab-focuses-selected for free; Radix's roving-tabindex
|
|
768
|
+
implementation exists because Radix uses
|
|
769
|
+
`<button role="radio">` rather than the native input. See the
|
|
770
|
+
RadioGroup audit (`components/form/radio-group/AUDIT.md`) for the full rationale.
|
|
771
|
+
|
|
772
|
+
**Press-button family with NO controller** (markup-only,
|
|
773
|
+
`<button aria-pressed>`-driven): **Toggle** (single press-button),
|
|
774
|
+
**ToggleGroup** (multi-select press-button group), **Segmented**
|
|
775
|
+
(single-select press-button group). Each native `<button>` is
|
|
776
|
+
independently tabbable and independently activatable via
|
|
777
|
+
Enter / Space; pressed state is owned by the consumer (`v-model`)
|
|
778
|
+
and surfaced via always-emit `aria-pressed="true"|"false"`. None
|
|
779
|
+
of the three uses roving tabindex — the pattern is reserved for
|
|
780
|
+
single-focus-per-group controls (RadioGroup arrow-key navigation,
|
|
781
|
+
Tabs roving) where one item represents the entire group's focus
|
|
782
|
+
target. Multi-select press-button groups (ToggleGroup) and
|
|
783
|
+
Segmented's tab-like single-select are both "every button is
|
|
784
|
+
independently tabbable" by design, mirroring how shadcn /
|
|
785
|
+
ToggleGroup degrades to standard button focus when not wrapping
|
|
786
|
+
Radix's primitive.
|
|
787
|
+
|
|
788
|
+
**When two NON-`<dialog>` overlay controllers duplicate "modal mode"
|
|
789
|
+
mechanics — extract**: this rule applies only to overlays that don't use
|
|
790
|
+
the native `<dialog>` element. With `<dialog>`, the browser already
|
|
791
|
+
provides the modal mechanics (focus trap, Escape, top-layer rendering),
|
|
792
|
+
so `mountDialog` (Modal, AlertDialog, Sheet) is structurally a thin
|
|
793
|
+
wrapper and there is no shared "modal mode" body to extract.
|
|
794
|
+
|
|
795
|
+
Sidebar's drawer (`controllers/sidebar.ts`) implements its own
|
|
796
|
+
modal-overlay mechanics manually because the panel is a `<nav>`
|
|
797
|
+
landmark rather than a `<dialog>` (see "Use native APIs where
|
|
798
|
+
applicable" below): focus trap on the panel, ref-counted scroll lock
|
|
799
|
+
on body, capture trigger before mount + restore on dismiss,
|
|
800
|
+
ARIA-promotion (`role="dialog"` + `aria-modal="true"` + `aria-label`)
|
|
801
|
+
while open. Today this duplication exists in exactly one place
|
|
802
|
+
(Sidebar). When **a second non-`<dialog>` overlay** lands with the
|
|
803
|
+
same mechanics — Toast's auto-dismissing mode is a candidate, if it
|
|
804
|
+
ends up needing them — review whether to extract a shared
|
|
805
|
+
`utils/a11y/modal-mode.ts` primitive:
|
|
806
|
+
`enterModalMode(panel, { onEscape, restoreFocusTo, ariaLabel }) → exitModalMode()`.
|
|
807
|
+
One consumer is implementation; two is duplication; three is a pattern.
|
|
808
|
+
|
|
809
|
+
### Use native APIs where applicable
|
|
810
|
+
|
|
811
|
+
Modern HTML provides native overlay primitives that reduce hand-rolled
|
|
812
|
+
focus traps, portals, and escape-key handling. Use them where they fit:
|
|
813
|
+
|
|
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. |
|
|
820
|
+
|
|
821
|
+
**What native APIs give us (free)**: top-layer rendering (escapes any
|
|
822
|
+
ancestor stacking context / overflow), focus trap (`Tab` cycles within
|
|
823
|
+
the dialog while open), Escape dismiss (fires a `cancel` event whose
|
|
824
|
+
default closes the dialog), `::backdrop` overlay pseudo-element, `[open]`
|
|
825
|
+
state reflection, focus restoration to the trigger on close.
|
|
826
|
+
|
|
827
|
+
**What we still wire manually**: body scroll lock (browsers don't lock —
|
|
828
|
+
keep `utils/dom/scroll-lock.ts:lockScroll()`), backdrop click dismiss
|
|
829
|
+
for `<dialog>` (the browser doesn't auto-close — detect via
|
|
830
|
+
`event.target === el` since `<dialog>` IS the backdrop element),
|
|
831
|
+
persistent variants (suppress the `cancel` event with
|
|
832
|
+
`event.preventDefault()`), `role="alertdialog"` for AlertDialog (set
|
|
833
|
+
via `setAttribute`), our `data-state="open|closed"` reflection
|
|
834
|
+
convention (so CSS selectors continue working consistently).
|
|
835
|
+
|
|
836
|
+
The `mountDialog` / `mountPopover` / `mountSheet` controllers stay as
|
|
837
|
+
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
|
|
840
|
+
particular is a thin wrapper over `<dialog>.showModal()` /
|
|
841
|
+
`<dialog>.close()`.
|
|
842
|
+
|
|
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
|
+
### Stateful-component archetype
|
|
865
|
+
|
|
866
|
+
Parallel to native-element-wrapper and form-primitive wrapper, stateful
|
|
867
|
+
components share a common authoring shape.
|
|
868
|
+
|
|
869
|
+
**Authoring checklist for stateful components**:
|
|
870
|
+
|
|
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**
|
|
876
|
+
with `data-state="closed"` (or equivalent neutral state) as the
|
|
877
|
+
static initial value. The controller flips `data-state` at runtime;
|
|
878
|
+
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
|
+
- [ ] **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
|
|
901
|
+
dismissal, persistent suppression, `data-state` reflection, and
|
|
902
|
+
any role override (`role="alertdialog"`). See "Use native APIs
|
|
903
|
+
where applicable" below for the full table.
|
|
904
|
+
- [ ] Non-`<dialog>` overlays (Sidebar drawer — the panel is a `<nav>`
|
|
905
|
+
landmark) use `utils/a11y/focus.ts:createFocusTrap` for the focus
|
|
906
|
+
trap. If a future overlay needs to portal out of its parent's
|
|
907
|
+
stacking context, add a portal helper at that point — none ships
|
|
908
|
+
currently because `<dialog>`-based overlays escape stacking via
|
|
909
|
+
the native top-layer and the Sidebar drawer stays inline. Vue's
|
|
910
|
+
`<Teleport>` is intentionally NOT used should portaling be added —
|
|
911
|
+
both frameworks share one mechanism for one mental model.
|
|
912
|
+
- [ ] Components requiring body-scroll lock use `utils/dom/scroll-lock.ts`.
|
|
913
|
+
The lock is reference-counted so nested overlays don't release each
|
|
914
|
+
other prematurely.
|
|
915
|
+
- [ ] Controllers managing manual focus traps (the non-`<dialog>` case)
|
|
916
|
+
use `utils/a11y/focus.ts:createFocusTrap`. They are also
|
|
917
|
+
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
|
|
920
|
+
when the browser fails to restore (rare — e.g. the trigger was
|
|
921
|
+
removed from the DOM mid-open).
|
|
922
|
+
|
|
923
|
+
**Locked decisions (Modal, 2026-05-03)** — these apply to every stateful
|
|
924
|
+
overlay component that follows:
|
|
925
|
+
|
|
926
|
+
- **Native `<dialog>` element**: Modal renders a single `<dialog
|
|
927
|
+
class="pui-modal">` element that is both the surface and the backdrop
|
|
928
|
+
source (the backdrop is the `::backdrop` pseudo-element). The browser
|
|
929
|
+
provides top-layer rendering, focus trap, Escape dismissal (via the
|
|
930
|
+
`cancel` event), `[open]` reflection, and focus restoration to the
|
|
931
|
+
trigger on close. AlertDialog (Phase 4) and Sheet (Phase 4) reuse
|
|
932
|
+
the same controller — only their CSS geometry differs.
|
|
933
|
+
- **Portal target**: not applicable. `<dialog>.showModal()` renders
|
|
934
|
+
in the native top-layer, which already escapes any ancestor stacking
|
|
935
|
+
context or `overflow: hidden`. There is no portal helper involved.
|
|
936
|
+
- **Scroll-lock policy**: the body's `overflow` is set to `hidden` and
|
|
937
|
+
`padding-right` is increased by the scrollbar width to prevent layout
|
|
938
|
+
shift. The lock is reference-counted across all open overlays.
|
|
939
|
+
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
|
|
943
|
+
`[open]` attribute is set by the browser via `showModal()`/`close()`;
|
|
944
|
+
`data-state` is set by the controller in parallel so CSS selectors
|
|
945
|
+
consistent with the locked convention keep working. Animated
|
|
946
|
+
enter/exit transitions drive off the `data-state` selector pairs in
|
|
947
|
+
CSS; the `opening` / `closing` intermediate states are deferred to
|
|
948
|
+
per-component as needed (Modal v1 has none — see Modal audit
|
|
949
|
+
out-of-scope on `@starting-style`).
|
|
950
|
+
- **Initial focus**: `<dialog>.showModal()` auto-focuses the first
|
|
951
|
+
focusable child (or the element with `[autofocus]`) by default. On
|
|
952
|
+
top of that, the controller's `initialFocus` config can override:
|
|
953
|
+
`'cancel'` focuses the element marked `[data-dialog-cancel]`,
|
|
954
|
+
`HTMLElement` focuses an explicit element, `'first'` and `null`
|
|
955
|
+
defer to the browser. A `[data-dialog-initial-focus]` element
|
|
956
|
+
inside the dialog ALWAYS wins over the configured value (consumer
|
|
957
|
+
override). On `close()`, the browser restores focus to the trigger
|
|
958
|
+
(`document.activeElement` at the time `showModal()` was called); a
|
|
959
|
+
manual backup runs only when the browser fails to restore.
|
|
960
|
+
- **Outside-click semantics**: clicks land on the `<dialog>` element
|
|
961
|
+
itself when the user clicks the `::backdrop` (children clicks bubble
|
|
962
|
+
through with `target` = the inner content). The controller checks
|
|
963
|
+
`event.target === el` to detect a backdrop click. Persistent dialogs
|
|
964
|
+
(AlertDialog, future) skip this dismissal entirely.
|
|
965
|
+
- **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
|
|
968
|
+
stays authoritative. Persistent dialogs call `event.preventDefault()`
|
|
969
|
+
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
|
+
|
|
979
|
+
### Worked example — Modal
|
|
980
|
+
|
|
981
|
+
The full-fat reference. See `packages/ui/components/overlay/modal/` and
|
|
982
|
+
`packages/ui/utils/controllers/dialog.ts` for the actual code.
|
|
983
|
+
|
|
984
|
+
```ts
|
|
985
|
+
// packages/ui/utils/controllers/dialog.ts
|
|
986
|
+
export interface DialogControl {
|
|
987
|
+
open(): void;
|
|
988
|
+
close(): void;
|
|
989
|
+
isOpen(): boolean;
|
|
990
|
+
teardown(): void;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
export interface DialogConfig {
|
|
994
|
+
role?: 'dialog' | 'alertdialog'; // default: 'dialog' (browser default for <dialog>)
|
|
995
|
+
persistent?: boolean; // default: false; suppresses cancel default + backdrop click
|
|
996
|
+
initialFocus?: 'first' | 'cancel' | HTMLElement | null; // default: 'first' (defers to native showModal())
|
|
997
|
+
onClose?: () => void; // user-requested close (Escape or backdrop click)
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
export function mountDialog(
|
|
1001
|
+
el: HTMLDialogElement,
|
|
1002
|
+
config?: DialogConfig,
|
|
1003
|
+
): DialogControl;
|
|
1004
|
+
```
|
|
1005
|
+
|
|
1006
|
+
**Vue consumer** (full pattern):
|
|
1007
|
+
|
|
1008
|
+
```vue
|
|
1009
|
+
<template>
|
|
1010
|
+
<dialog
|
|
1011
|
+
ref="rootRef"
|
|
1012
|
+
v-bind="$attrs"
|
|
1013
|
+
class="pui-modal"
|
|
1014
|
+
data-state="closed"
|
|
1015
|
+
data-pui-modal
|
|
1016
|
+
:aria-labelledby="titleId"
|
|
1017
|
+
>
|
|
1018
|
+
<h2 :id="titleId">{{ title }}</h2>
|
|
1019
|
+
<slot />
|
|
1020
|
+
</dialog>
|
|
1021
|
+
</template>
|
|
1022
|
+
|
|
1023
|
+
<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';
|
|
1029
|
+
|
|
1030
|
+
defineOptions({ inheritAttrs: false });
|
|
1031
|
+
|
|
1032
|
+
const props = defineProps<{
|
|
1033
|
+
open: boolean;
|
|
1034
|
+
title: string;
|
|
1035
|
+
persistent?: boolean;
|
|
1036
|
+
}>();
|
|
1037
|
+
const emit = defineEmits<{ 'update:open': [boolean]; close: [] }>();
|
|
1038
|
+
|
|
1039
|
+
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
|
+
watch(
|
|
1054
|
+
() => props.open,
|
|
1055
|
+
(v) => (v ? control?.open() : control?.close()),
|
|
1056
|
+
);
|
|
1057
|
+
|
|
1058
|
+
onBeforeUnmount(() => {
|
|
1059
|
+
control?.teardown();
|
|
1060
|
+
control = null;
|
|
1061
|
+
});
|
|
1062
|
+
</script>
|
|
1063
|
+
```
|
|
1064
|
+
|
|
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.
|
|
1112
|
+
|
|
1113
|
+
### Vue API decisions
|
|
1114
|
+
|
|
1115
|
+
- **`v-model:open`** is the public surface (Vue 3.4+). Implementations may
|
|
1116
|
+
use `defineModel<boolean>('open')` (Vue 3.6+) or the explicit
|
|
1117
|
+
`props: { open } + emit('update:open', boolean)` pair — both produce the
|
|
1118
|
+
same v-model semantics. The library currently uses the explicit
|
|
1119
|
+
`props + emit` pair for consistency with the existing Phase 1 components
|
|
1120
|
+
(Choice, TextInput, Switch, etc.); migrate when a future change refreshes
|
|
1121
|
+
all components together.
|
|
1122
|
+
- A separate `@close` event is emitted alongside `update:open(false)` so
|
|
1123
|
+
consumers can hook close-specific side effects without watching the
|
|
1124
|
+
v-model.
|
|
1125
|
+
- `inheritAttrs: false` on the wrapper, `v-bind="$attrs"` on the dialog
|
|
1126
|
+
content, mirroring TextInput.
|
|
1127
|
+
|
|
1128
|
+
---
|
|
1129
|
+
|
|
1130
|
+
## ARIA attribute conventions
|
|
1131
|
+
|
|
1132
|
+
Locked during Phase 1 alongside Modal + Card (2026-05-03). When to use
|
|
1133
|
+
static ARIA vs reactive bindings, and how each framework expresses the two
|
|
1134
|
+
shapes.
|
|
1135
|
+
|
|
1136
|
+
### Static ARIA — write the attribute literally
|
|
1137
|
+
|
|
1138
|
+
When the value is **template-time constant** — known at render and never
|
|
1139
|
+
changes — write the attribute statically on the element. No reactive
|
|
1140
|
+
binding. Examples:
|
|
1141
|
+
|
|
1142
|
+
```vue
|
|
1143
|
+
<!-- Modal dialog content -->
|
|
1144
|
+
<div role="dialog" aria-modal="true">…</div>
|
|
1145
|
+
```
|
|
1146
|
+
|
|
1147
|
+
```astro
|
|
1148
|
+
<ul role="list" aria-label="Breadcrumbs">…</ul>
|
|
1149
|
+
```
|
|
1150
|
+
|
|
1151
|
+
This is the common case for `role`, `aria-modal`, `aria-label` (when the
|
|
1152
|
+
label is a literal string), and `aria-orientation`.
|
|
1153
|
+
|
|
1154
|
+
### Reactive ARIA bindings (Vue) — `:aria-*`
|
|
1155
|
+
|
|
1156
|
+
When the value depends on **props or runtime state**, use Vue's reactive
|
|
1157
|
+
binding syntax:
|
|
1158
|
+
|
|
1159
|
+
```vue
|
|
1160
|
+
<input
|
|
1161
|
+
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
1162
|
+
:aria-describedby="describedBy"
|
|
1163
|
+
:aria-busy="loading ? 'true' : undefined"
|
|
1164
|
+
/>
|
|
1165
|
+
|
|
1166
|
+
<button :aria-disabled="isDisabled ? 'true' : undefined">…</button>
|
|
1167
|
+
|
|
1168
|
+
<div :aria-labelledby="titleId">…</div>
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
The pattern matches the **boolean-attr-only-when-truthy** rule above: emit
|
|
1172
|
+
`'true'` (string) when the prop / state is truthy, otherwise `undefined`
|
|
1173
|
+
so Vue drops the attribute entirely. Never emit the literal string
|
|
1174
|
+
`'false'`.
|
|
1175
|
+
|
|
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
|
+
### Boolean ARIA values — strings, not literals
|
|
1197
|
+
|
|
1198
|
+
Per the boolean-attr-only-when-truthy rule, emit `'true'` (the string)
|
|
1199
|
+
only when the underlying boolean prop is truthy; **omit the attribute
|
|
1200
|
+
entirely** otherwise. Never emit `'false'`. CSS that targets ARIA boolean
|
|
1201
|
+
state writes `[aria-busy='true']`, `[aria-invalid='true']`,
|
|
1202
|
+
`[aria-disabled='true']`, `[aria-expanded='true']`, etc.
|
|
1203
|
+
|
|
1204
|
+
**Disambiguation — native HTML booleans vs ARIA boolean strings**:
|
|
1205
|
+
native HTML boolean attributes (`disabled`, `readonly`, `required`,
|
|
1206
|
+
`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
|
|
1211
|
+
idiom (`x ? 'true' : undefined`). Do not pass a raw boolean to an
|
|
1212
|
+
`aria-*` binding — Astro will stringify `false` to the literal
|
|
1213
|
+
`"false"`, which fails the rule. For `aria-expanded`, `aria-checked`,
|
|
1214
|
+
`aria-pressed`, and `aria-selected`, see Always-emit ARIA exceptions
|
|
1215
|
+
below — they require explicit `'true'`/`'false'`.
|
|
1216
|
+
|
|
1217
|
+
**Always-emit exceptions** — a small set of ARIA attributes carry
|
|
1218
|
+
meaning in BOTH their `'true'` and `'false'` states (the absent state
|
|
1219
|
+
means something different — typically "not applicable" rather than
|
|
1220
|
+
"explicitly false"). For these, emit the string `'true'` or `'false'`
|
|
1221
|
+
literally; do NOT omit when falsy:
|
|
1222
|
+
|
|
1223
|
+
- `aria-expanded` on a disclosure trigger / group toggle — absent means
|
|
1224
|
+
"not a disclosure"; `'false'` means "is a disclosure, currently
|
|
1225
|
+
collapsed". Used by Sidebar group toggles and any future
|
|
1226
|
+
Disclosure-pattern component.
|
|
1227
|
+
- `aria-checked` on `role="switch"` or `role="checkbox"` /
|
|
1228
|
+
`role="radio"` controls — absent means "not a check-style control";
|
|
1229
|
+
`'false'` means "is a check-style control, currently unchecked".
|
|
1230
|
+
Used by Switch, Checkbox, and RadioGroup items. v1 Checkbox emits
|
|
1231
|
+
the binary `'true'` / `'false'` only; the tri-state `'mixed'`
|
|
1232
|
+
branch is reserved for a future tri-state consumer (e.g., a
|
|
1233
|
+
"select all" data-table header) and re-enters the rule unchanged.
|
|
1234
|
+
- `aria-pressed` on a toggle button (`role="button"` with toggled
|
|
1235
|
+
state) — same shape; `'false'` means "is a toggle button, currently
|
|
1236
|
+
not pressed". Used by the future press-button Toggle (Phase 4).
|
|
1237
|
+
- `aria-selected` on listbox / option / tab items — absent means "not
|
|
1238
|
+
a selectable item"; `'false'` means "is selectable, currently not
|
|
1239
|
+
selected".
|
|
1240
|
+
|
|
1241
|
+
For these four, the binding shape is `:aria-<name>="x ? 'true' :
|
|
1242
|
+
'false'"` (Vue) or `aria-<name>={x ? 'true' : 'false'}` (Astro). Native
|
|
1243
|
+
elements with intrinsic role semantics provide some of this for free
|
|
1244
|
+
(e.g. `<input type="checkbox">` carries implicit `checked` state) — but
|
|
1245
|
+
when the role is set explicitly (`role="switch"` on a checkbox-backed
|
|
1246
|
+
input), the matching ARIA attribute MUST also be set explicitly so AT
|
|
1247
|
+
reads the role-state pair consistently.
|
|
1248
|
+
|
|
1249
|
+
### ARIA ownership — template vs controller
|
|
1250
|
+
|
|
1251
|
+
For stateful components (Modal, AlertDialog, Sheet, Popover, Disclosure),
|
|
1252
|
+
some ARIA attributes are owned by the **template** (template-time data —
|
|
1253
|
+
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:
|
|
1258
|
+
|
|
1259
|
+
- Template: `aria-labelledby` (heading ID is template-time).
|
|
1260
|
+
- Browser: `role="dialog"` (the implicit role for `<dialog>`) and
|
|
1261
|
+
`aria-modal` (set implicitly when `showModal()` is called) — no
|
|
1262
|
+
template or controller wiring needed.
|
|
1263
|
+
- Controller: `role` override only — `mountDialog` calls
|
|
1264
|
+
`setAttribute('role', 'alertdialog')` for AlertDialog. Modal v1
|
|
1265
|
+
doesn't override and inherits the browser default.
|
|
1266
|
+
|
|
1267
|
+
### Prefer `aria-labelledby` over `aria-label` when a visible label exists
|
|
1268
|
+
|
|
1269
|
+
When the labelling element is already in the DOM (the dialog title `<h2>`,
|
|
1270
|
+
the field's `<label>`), point at it with `aria-labelledby="<id>"` rather
|
|
1271
|
+
than duplicating the same string in `aria-label`. This avoids the two
|
|
1272
|
+
copies drifting apart and keeps a single source of truth for the visible
|
|
1273
|
+
label.
|
|
1274
|
+
|
|
1275
|
+
`aria-label` is the right call only when there's no visible label
|
|
1276
|
+
(icon-only buttons, search input without a sibling label, decorative
|
|
1277
|
+
landmarks).
|
|
1278
|
+
|
|
1279
|
+
---
|
|
1280
|
+
|
|
1281
|
+
## `:focus-visible` rule
|
|
1282
|
+
|
|
1283
|
+
Every interactive component renders a consistent keyboard-focus ring:
|
|
1284
|
+
|
|
1285
|
+
```css
|
|
1286
|
+
.pui-<block > :focus-visible {
|
|
1287
|
+
outline: 3px solid var(--outline-clr-base);
|
|
1288
|
+
outline-offset: 2px;
|
|
1289
|
+
}
|
|
1290
|
+
```
|
|
1291
|
+
|
|
1292
|
+
- Use `:focus-visible`, not `:focus` — mouse clicks on a button SHOULD NOT show the ring.
|
|
1293
|
+
- Token-only — never hardcode the color or width.
|
|
1294
|
+
- Apply on the focusable element. For native-element wrappers that's the wrapper itself; for composite components (Tabs, RadioGroup) it's the focusable child (e.g., `.pui-tabs__trigger:focus-visible`).
|
|
1295
|
+
- Do not remove the ring in a `:focus { outline: 0 }` reset — let it default and override only via `:focus-visible`.
|
|
1296
|
+
|
|
1297
|
+
---
|
|
1298
|
+
|
|
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
|
+
## shadcn / Radix commit pins
|
|
1322
|
+
|
|
1323
|
+
_Pinned 2026-05-03. Update both together as a separate, intentional change._
|
|
1324
|
+
|
|
1325
|
+
- **shadcn/ui**: [`7865621397708917369251d67029ad920e390a38`](https://github.com/shadcn-ui/ui/tree/7865621397708917369251d67029ad920e390a38) — 2026-04-30
|
|
1326
|
+
- **Radix Primitives**: [`22473d16404bfd446305db5b6c9308aece99fdec`](https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec) — 2026-02-13
|
|
1327
|
+
|
|
1328
|
+
Per-component briefs cite specific source paths under these SHAs. Subagents read the pinned references, not `main`.
|
|
1329
|
+
|
|
1330
|
+
---
|
|
1331
|
+
|
|
1332
|
+
## Per-component audits
|
|
1333
|
+
|
|
1334
|
+
Each component keeps its own audit next to its source, so a decision and the
|
|
1335
|
+
code it governs move in the same diff. Add an entry when you add a component;
|
|
1336
|
+
follow the shape of an existing one.
|
|
1337
|
+
|
|
1338
|
+
| Component | Audit |
|
|
1339
|
+
| ------------ | ------------------------------------------------------------------------------------------ |
|
|
1340
|
+
| Accordion | [`components/layout/accordion/AUDIT.md`](./components/layout/accordion/AUDIT.md) |
|
|
1341
|
+
| Alert | [`components/feedback/alert/AUDIT.md`](./components/feedback/alert/AUDIT.md) |
|
|
1342
|
+
| AlertDialog | [`components/overlay/alert-dialog/AUDIT.md`](./components/overlay/alert-dialog/AUDIT.md) |
|
|
1343
|
+
| Avatar | [`components/display/avatar/AUDIT.md`](./components/display/avatar/AUDIT.md) |
|
|
1344
|
+
| AvatarStack | [`components/display/avatar/AUDIT.md`](./components/display/avatar/AUDIT.md) |
|
|
1345
|
+
| Badge | [`components/display/badge/AUDIT.md`](./components/display/badge/AUDIT.md) |
|
|
1346
|
+
| Breadcrumb | [`components/navigation/breadcrumb/AUDIT.md`](./components/navigation/breadcrumb/AUDIT.md) |
|
|
1347
|
+
| Button | [`components/action/button/AUDIT.md`](./components/action/button/AUDIT.md) |
|
|
1348
|
+
| Card | [`components/layout/card/AUDIT.md`](./components/layout/card/AUDIT.md) |
|
|
1349
|
+
| Checkbox | [`components/form/checkbox/AUDIT.md`](./components/form/checkbox/AUDIT.md) |
|
|
1350
|
+
| Collapsible | [`components/layout/collapsible/AUDIT.md`](./components/layout/collapsible/AUDIT.md) |
|
|
1351
|
+
| Combobox | [`components/form/combobox/AUDIT.md`](./components/form/combobox/AUDIT.md) |
|
|
1352
|
+
| Command | [`components/overlay/command/AUDIT.md`](./components/overlay/command/AUDIT.md) |
|
|
1353
|
+
| DataTable | [`components/layout/table/AUDIT.md`](./components/layout/table/AUDIT.md) |
|
|
1354
|
+
| DateInput | [`components/form/date-input/AUDIT.md`](./components/form/date-input/AUDIT.md) |
|
|
1355
|
+
| DropdownMenu | [`components/overlay/dropdown-menu/AUDIT.md`](./components/overlay/dropdown-menu/AUDIT.md) |
|
|
1356
|
+
| Empty | [`components/display/empty/AUDIT.md`](./components/display/empty/AUDIT.md) |
|
|
1357
|
+
| Form | [`components/form/form/AUDIT.md`](./components/form/form/AUDIT.md) |
|
|
1358
|
+
| Icon | [`components/display/icon/AUDIT.md`](./components/display/icon/AUDIT.md) |
|
|
1359
|
+
| IconButton | [`components/action/button/AUDIT.md`](./components/action/button/AUDIT.md) |
|
|
1360
|
+
| InputOTP | [`components/form/input-otp/AUDIT.md`](./components/form/input-otp/AUDIT.md) |
|
|
1361
|
+
| Label | [`components/form/label/AUDIT.md`](./components/form/label/AUDIT.md) |
|
|
1362
|
+
| Modal | [`components/overlay/modal/AUDIT.md`](./components/overlay/modal/AUDIT.md) |
|
|
1363
|
+
| Navbar | [`components/navigation/navbar/AUDIT.md`](./components/navigation/navbar/AUDIT.md) |
|
|
1364
|
+
| NumberField | [`components/form/number-field/AUDIT.md`](./components/form/number-field/AUDIT.md) |
|
|
1365
|
+
| Pagination | [`components/navigation/pagination/AUDIT.md`](./components/navigation/pagination/AUDIT.md) |
|
|
1366
|
+
| Popover | [`components/overlay/popover/AUDIT.md`](./components/overlay/popover/AUDIT.md) |
|
|
1367
|
+
| Progress | [`components/feedback/progress/AUDIT.md`](./components/feedback/progress/AUDIT.md) |
|
|
1368
|
+
| RadioGroup | [`components/form/radio-group/AUDIT.md`](./components/form/radio-group/AUDIT.md) |
|
|
1369
|
+
| Segmented | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
|
|
1370
|
+
| Select | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
|
|
1371
|
+
| Separator | [`components/layout/separator/AUDIT.md`](./components/layout/separator/AUDIT.md) |
|
|
1372
|
+
| Sheet | [`components/overlay/sheet/AUDIT.md`](./components/overlay/sheet/AUDIT.md) |
|
|
1373
|
+
| Sidebar | [`components/navigation/sidebar/AUDIT.md`](./components/navigation/sidebar/AUDIT.md) |
|
|
1374
|
+
| Skeleton | [`components/feedback/skeleton/AUDIT.md`](./components/feedback/skeleton/AUDIT.md) |
|
|
1375
|
+
| Slider | [`components/form/slider/AUDIT.md`](./components/form/slider/AUDIT.md) |
|
|
1376
|
+
| Spinner | [`components/feedback/spinner/AUDIT.md`](./components/feedback/spinner/AUDIT.md) |
|
|
1377
|
+
| Switch | [`components/form/switch/AUDIT.md`](./components/form/switch/AUDIT.md) |
|
|
1378
|
+
| Table | [`components/layout/table/AUDIT.md`](./components/layout/table/AUDIT.md) |
|
|
1379
|
+
| Tabs | [`components/navigation/tabs/AUDIT.md`](./components/navigation/tabs/AUDIT.md) |
|
|
1380
|
+
| TagsInput | [`components/form/tags-input/AUDIT.md`](./components/form/tags-input/AUDIT.md) |
|
|
1381
|
+
| Textarea | [`components/form/textarea/AUDIT.md`](./components/form/textarea/AUDIT.md) |
|
|
1382
|
+
| TextInput | [`components/form/text-input/AUDIT.md`](./components/form/text-input/AUDIT.md) |
|
|
1383
|
+
| Toast | [`components/feedback/toast/AUDIT.md`](./components/feedback/toast/AUDIT.md) |
|
|
1384
|
+
| Toggle | [`components/action/toggle/AUDIT.md`](./components/action/toggle/AUDIT.md) |
|
|
1385
|
+
| ToggleGroup | [`components/action/toggle-group/AUDIT.md`](./components/action/toggle-group/AUDIT.md) |
|
|
1386
|
+
| Tooltip | [`components/overlay/tooltip/AUDIT.md`](./components/overlay/tooltip/AUDIT.md) |
|
|
1387
|
+
|
|
1388
|
+
## Form submit response contract
|
|
1389
|
+
|
|
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._
|
|
1391
|
+
|
|
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.
|
|
1393
|
+
|
|
1394
|
+
```ts
|
|
1395
|
+
// packages/ui/utils/validation/form.ts
|
|
1396
|
+
export interface FormSubmitResponse {
|
|
1397
|
+
/** Whether the submit succeeded. */
|
|
1398
|
+
ok: boolean;
|
|
1399
|
+
/** Per-field errors keyed by field `name`. Single string or array of strings. */
|
|
1400
|
+
fieldErrors?: Record<string, string | string[]>;
|
|
1401
|
+
/** Top-level errors not tied to a specific field. */
|
|
1402
|
+
errors?: string[];
|
|
1403
|
+
/** Success/status message to announce via aria-live. */
|
|
1404
|
+
statusMessage?: string;
|
|
1405
|
+
/**
|
|
1406
|
+
* Optional post-submit redirect URL — orchestration does NOT
|
|
1407
|
+
* auto-navigate; the value is just stored on the response for the
|
|
1408
|
+
* consumer to act on.
|
|
1409
|
+
*/
|
|
1410
|
+
redirect?: string;
|
|
1411
|
+
}
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
**Mapping rules — both frameworks**:
|
|
1415
|
+
|
|
1416
|
+
- `ok: true` clears all errors, renders `statusMessage` in `.pui-form__status`, and announces it politely.
|
|
1417
|
+
- `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.
|
|
1419
|
+
|
|
1420
|
+
**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
|
+
|
|
1422
|
+
```ts
|
|
1423
|
+
// Server APIs commonly send fieldErrors as `string` for single errors
|
|
1424
|
+
// and `string[]` for multiple. Use `normalizeFieldErrors` to coerce to
|
|
1425
|
+
// the canonical `Record<string, string[]>` shape:
|
|
1426
|
+
import { normalizeFieldErrors } from '@pienter/ui/utils/validation';
|
|
1427
|
+
|
|
1428
|
+
const response = await api.submit(data);
|
|
1429
|
+
const normalized = normalizeFieldErrors(response.fieldErrors);
|
|
1430
|
+
// Now safe to .map() and .length-check every entry.
|
|
1431
|
+
```
|
|
1432
|
+
|
|
1433
|
+
---
|
|
1434
|
+
|
|
1435
|
+
## RTL approach
|
|
1436
|
+
|
|
1437
|
+
_Deferred unless a component explicitly needs it. Captured here when it does._
|
|
1438
|
+
|
|
1439
|
+
---
|
|
1440
|
+
|
|
1441
|
+
## ARIA live region
|
|
1442
|
+
|
|
1443
|
+
A single global ARIA live region is mounted lazily by the first `announce(text, priority)` call from `utils/a11y/live-region.ts`. Components SHALL NOT create per-component live regions; SHALL use `announce()` for dynamic state-change announcements.
|
|
1444
|
+
|
|
1445
|
+
---
|
|
1446
|
+
|
|
1447
|
+
## Component audit table
|
|
1448
|
+
|
|
1449
|
+
One row per module directory under `packages/ui/components/<category>/`. Where a directory ships multiple components (e.g. `avatar/` ships `Avatar` + `AvatarStack`; `select/` ships `Select` + `Segmented`) the row summarises the primary component and the co-located `AUDIT.md` carries the detail. "Last audited" is the date this table was last refreshed in full; a component's `AUDIT.md` may carry an older date if untouched since.
|
|
1450
|
+
|
|
1451
|
+
Pinned upstream SHAs used across the table:
|
|
1452
|
+
|
|
1453
|
+
- shadcn-ui/ui: `7865621397708917369251d67029ad920e390a38` (apps/v4 New York v4 registry, captured 2026-05-03)
|
|
1454
|
+
- radix-ui/primitives: `22473d16404bfd446305db5b6c9308aece99fdec` (captured 2026-05-03)
|
|
1455
|
+
|
|
1456
|
+
| Component | shadcn URL | shadcn SHA | Radix-Vue URL | Radix-Vue SHA | Last audited |
|
|
1457
|
+
| ------------- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------ |
|
|
1458
|
+
| accordion | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/accordion.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/accordion/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1459
|
+
| alert | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/alert.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (Radix has no Alert primitive; `role="alert"` is native) | n/a | 2026-05-27 |
|
|
1460
|
+
| alert-dialog | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/alert-dialog.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/alert-dialog/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1461
|
+
| avatar | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/avatar.tsx | 7865621397708917369251d67029ad920e390a38 | https://www.radix-ui.com/primitives/docs/components/avatar | n/a — referenced docs page, not source pin | 2026-05-27 |
|
|
1462
|
+
| badge | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/badge.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (Badge is pure markup) | n/a | 2026-05-27 |
|
|
1463
|
+
| breadcrumb | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/breadcrumb.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (semantic `<nav>` + `<ol>`) | n/a | 2026-05-27 |
|
|
1464
|
+
| button | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/button.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (native `<button>`/`<a>`) | n/a | 2026-05-27 |
|
|
1465
|
+
| card | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/card.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (Card is pure markup) | n/a | 2026-05-27 |
|
|
1466
|
+
| checkbox | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/checkbox.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/blob/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/checkbox/src/checkbox.tsx | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1467
|
+
| collapsible | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/collapsible.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/collapsible/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1468
|
+
| combobox | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/combobox.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — Radix does not ship a Combobox primitive | n/a | 2026-05-27 |
|
|
1469
|
+
| command | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/command.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — Radix does not ship a Command primitive | n/a | 2026-05-27 |
|
|
1470
|
+
| date-input | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/date-picker.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — Radix does not publish a DatePicker primitive | n/a | 2026-05-27 |
|
|
1471
|
+
| dropdown-menu | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/dropdown-menu.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/dropdown-menu/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1472
|
+
| empty | Bespoke — shadcn does not ship an Empty / EmptyState component; internal design, manual a11y review | Bespoke | n/a — no headless dep (Empty is pure markup) | n/a | 2026-05-27 |
|
|
1473
|
+
| form | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/form.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/blob/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/form/src/form.tsx | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1474
|
+
| icon | Bespoke — shadcn does not ship a generic Icon component; internal design, manual a11y review | Bespoke | n/a — no headless dep | n/a | 2026-05-27 |
|
|
1475
|
+
| input-otp | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/input-otp.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — Radix does not publish an OTP primitive | n/a | 2026-05-27 |
|
|
1476
|
+
| label | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/label.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/label/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1477
|
+
| modal | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/dialog.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/dialog/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1478
|
+
| navbar | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/navigation-menu.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/navigation-menu | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1479
|
+
| number-field | Bespoke — shadcn/ui v4 has no dedicated NumberField component; internal design referencing WAI spinbutton pattern | Bespoke | n/a — Radix does not publish a Number primitive | n/a | 2026-05-27 |
|
|
1480
|
+
| pagination | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/pagination.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (Pagination is semantic links) | n/a | 2026-05-27 |
|
|
1481
|
+
| popover | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/popover.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/popover/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1482
|
+
| progress | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/progress.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/blob/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/progress/src/progress.tsx | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1483
|
+
| radio-group | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/radio-group.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/blob/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/radio-group/src/radio-group.tsx | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1484
|
+
| select | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/select.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — v1 uses native `<select>`; Segmented co-located in this directory references the toggle-group Radix entry below | n/a | 2026-05-27 |
|
|
1485
|
+
| separator | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/separator.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/separator/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1486
|
+
| sheet | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/sheet.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/dialog/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1487
|
+
| sidebar | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/sidebar.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (Radix has no Sidebar primitive; item-shape API is bespoke) | n/a | 2026-05-27 |
|
|
1488
|
+
| skeleton | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/skeleton.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (Skeleton is pure CSS) | n/a | 2026-05-27 |
|
|
1489
|
+
| slider | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/slider.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/blob/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/slider/src/slider.tsx | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1490
|
+
| switch | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/switch.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/blob/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/switch/src/switch.tsx | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1491
|
+
| table | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/table.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (semantic `<table>`) | n/a | 2026-05-27 |
|
|
1492
|
+
| tabs | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/tabs.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/tabs/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1493
|
+
| tags-input | Bespoke — shadcn/ui v4 has no dedicated TagsInput; internal design referencing combobox + chip patterns | Bespoke | n/a — Radix does not publish a TagsInput primitive | n/a | 2026-05-27 |
|
|
1494
|
+
| text-input | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/input.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (native `<input>`) | n/a | 2026-05-27 |
|
|
1495
|
+
| textarea | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/textarea.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (native `<textarea>`) | n/a | 2026-05-27 |
|
|
1496
|
+
| toast | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/sonner.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/toast/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
|
|
1497
|
+
| 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
|
+
| 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
|
+
| 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 |
|