@pienter/ui 0.5.0 → 0.8.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 +149 -0
- package/CONVENTIONS.md +91 -28
- package/README.md +66 -0
- package/components/display/record-details/RecordDetails.vue +61 -0
- package/components/display/record-details/record-details.css +37 -0
- package/components/display/record-details/types.ts +8 -0
- package/components/feedback/toast/Toast.vue +3 -4
- package/components/feedback/toast/ToastHost.vue +165 -0
- package/components/feedback/toast/toast.ts +116 -0
- package/components/feedback/toast/types.ts +48 -0
- package/components/form/block-editor/BlockEditor.vue +455 -0
- package/components/form/block-editor/block-editor.css +149 -0
- package/components/form/block-editor/types.ts +15 -0
- package/components/form/checkbox/Checkbox.vue +10 -2
- package/components/form/combobox/Combobox.vue +32 -39
- package/components/form/combobox/combobox.css +1 -1
- package/components/form/date-input/DateInput.vue +10 -2
- package/components/form/date-input/date-input.css +2 -17
- package/components/form/form/Form.vue +10 -9
- package/components/form/form/form.css +2 -22
- package/components/form/input-otp/InputOTP.vue +1 -1
- package/components/form/input-otp/input-otp.css +1 -1
- package/components/form/label/label.css +2 -11
- package/components/form/number-field/NumberField.vue +10 -3
- package/components/form/number-field/number-field.css +3 -3
- package/components/form/radio-group/RadioGroup.vue +1 -1
- package/components/form/record-form/RecordFields.vue +128 -0
- package/components/form/record-form/RecordForm.vue +116 -0
- package/components/form/record-form/fields.ts +20 -0
- package/components/form/record-form/record-form.css +15 -0
- package/components/form/record-form/types.ts +28 -0
- package/components/form/select/Select.vue +13 -4
- package/components/form/select/select.css +6 -9
- package/components/form/slider/Slider.vue +1 -1
- package/components/form/switch/Switch.vue +1 -1
- package/components/form/tags-input/TagsInput.vue +17 -2
- package/components/form/tags-input/tags-input.css +16 -12
- package/components/form/text-input/TextInput.vue +10 -2
- package/components/form/text-input/text-input.css +10 -129
- package/components/form/textarea/Textarea.vue +10 -2
- package/components/form/textarea/textarea.css +3 -19
- package/components/layout/app-layout/AppLayout.vue +116 -0
- package/components/layout/app-layout/app-layout.css +115 -0
- package/components/layout/index/Index.vue +373 -0
- package/components/layout/index/index.css +157 -0
- package/components/layout/index/useIndex.ts +407 -0
- package/components/layout/table/DataTable.vue +14 -1
- package/components/layout/table/Table.vue +12 -0
- package/components/layout/table/table.css +2 -1
- package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
- package/components/navigation/breadcrumb/breadcrumb.css +20 -0
- package/components/navigation/sidebar/Sidebar.vue +11 -8
- package/components/navigation/sidebar/sidebar.css +23 -16
- package/components/navigation/tabs/Tabs.vue +6 -0
- package/components/navigation/tabs/tabs.css +7 -7
- package/composables/useMenu.ts +20 -27
- package/package.json +17 -2
- package/styles/0-settings/colors.css +10 -0
- package/styles/4-components/form-field.css +112 -0
- package/styles/4-components/index.css +1 -0
- package/styles/main.css +1 -0
- package/utils/a11y/focus.ts +9 -3
- package/utils/a11y/index.ts +5 -1
- package/utils/a11y/live-region.ts +2 -1
- package/utils/cms/index.ts +283 -0
- package/utils/cms/schema.json +126 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,155 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.8.0 - 2026-09-15
|
|
6
|
+
|
|
7
|
+
### Breaking
|
|
8
|
+
|
|
9
|
+
- Index drops `headingLevel`. The heading level now follows `placement`: `h1`
|
|
10
|
+
in the default module placement, `h2` in related placement. Replace
|
|
11
|
+
`heading-level="2"` on an embedded list with `placement="related"`.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Index `placement` (`module` | `related`, default `module`), exposed as
|
|
16
|
+
`data-placement` on `.pui-index`. Module is the page itself and renders flat:
|
|
17
|
+
the heading and actions above a divider, an unboxed toolbar, the table with
|
|
18
|
+
DataTable's own border and radius, and the pagination footer as a band
|
|
19
|
+
below. Related is a list inside another record's detail page and renders as
|
|
20
|
+
one bordered region holding the header, toolbar, table and pagination
|
|
21
|
+
footer, with a second-level heading. Existing consumers are unchanged; the
|
|
22
|
+
toolbar and results no longer share a bordered body in module placement, so
|
|
23
|
+
consumer styles targeting that box no longer apply.
|
|
24
|
+
- Index `sync-query` accepts a string as well as a boolean. The string is a
|
|
25
|
+
key prefix: `sync-query="contacts"` reads and writes `contacts.page`,
|
|
26
|
+
`contacts.page_size`, `contacts.search`, `contacts.sort` and
|
|
27
|
+
`contacts.filter[...]`, so a related index can share its parent page's URL.
|
|
28
|
+
Unrelated parameters, including the bare `page`, survive untouched.
|
|
29
|
+
|
|
30
|
+
## 0.7.1 - 2026-09-15
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- RecordFields reuses field error arrays while draft values change, avoiding
|
|
35
|
+
repeated issue scans and unnecessary error-prop updates during typing.
|
|
36
|
+
|
|
37
|
+
- Sidebar and Tabs skip navigation DOM scans for unrelated key presses; Sidebar
|
|
38
|
+
also handles Space activation before collecting visible entries.
|
|
39
|
+
|
|
40
|
+
- Menus initialize roving focus with one enabled-item search and avoid repeating
|
|
41
|
+
full tabindex updates when keyboard navigation triggers a focus event.
|
|
42
|
+
|
|
43
|
+
- NumberField stepper clicks emit one model update while preserving native input
|
|
44
|
+
and change events, so consumer validation and autosave handlers run once.
|
|
45
|
+
|
|
46
|
+
## 0.7.0 - 2026-09-15
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- Index preserves structured backend errors and offers reset for rejected queries.
|
|
51
|
+
Refresh callbacks wait for navigation and loading to settle. Sortable columns
|
|
52
|
+
are checked against the endpoint's allowed fields before loading.
|
|
53
|
+
|
|
54
|
+
- `Breadcrumb` is now a bar with an optional `actions` slot on the
|
|
55
|
+
inline-end side. The `pui-breadcrumb` block class moved from the `<ol>`
|
|
56
|
+
to the `<nav>`; the list is `pui-breadcrumb__trail`.
|
|
57
|
+
- Index renders its pagination footer as a page-level band after the
|
|
58
|
+
results card instead of inside it, mirroring the header. Pages can pin
|
|
59
|
+
it to the viewport bottom; consumer styles targeting the footer's card
|
|
60
|
+
corner radii no longer apply.
|
|
61
|
+
- Text inputs, textareas, selects, input groups and the tags input now rest
|
|
62
|
+
on the `--border-clr-input` border Button already uses, so control edges
|
|
63
|
+
clear 3:1 in both themes, and take the standard `:focus-visible` outline
|
|
64
|
+
ring instead of `outline: none` plus a translucent halo. The brand border
|
|
65
|
+
tint on focus stays; consumer overrides of the old `box-shadow` ring no
|
|
66
|
+
longer apply. Tabs and the tags-input remove button use the same tokenised
|
|
67
|
+
ring.
|
|
68
|
+
- Breadcrumb links get a `:focus-visible` ring and the bar's gap reads the
|
|
69
|
+
`--space-s` token it was meant to. Record-details labels are
|
|
70
|
+
`--text-clr-muted` and its row separators `--border-clr-subtle`, matching
|
|
71
|
+
other in-panel dividers; Tabs' rule uses the same subtle border and its
|
|
72
|
+
active marker is `--stroke-lg`.
|
|
73
|
+
- Sidebar honours `prefers-reduced-motion` for the drawer slide, backdrop
|
|
74
|
+
fade, caret rotation and width collapse, not only the rail flyout.
|
|
75
|
+
- The tags-input remove button is a 24px target; pills keep their height by
|
|
76
|
+
dropping block padding.
|
|
77
|
+
- Form renders its top-level error summary with the danger `Alert` instead of
|
|
78
|
+
a bespoke box, so it matches every other error surface; consumer styles
|
|
79
|
+
targeting `.pui-form__error-summary` no longer apply. The shared live region
|
|
80
|
+
is created when a Form mounts, so the first "N errors found" announcement is
|
|
81
|
+
no longer dropped.
|
|
82
|
+
- Per-field error lists on every form primitive are `role="status"` rather
|
|
83
|
+
than `role="alert"`: a failed submit is announced once, assertively, by the
|
|
84
|
+
summary and the live region, and each field's errors stay reachable through
|
|
85
|
+
`aria-describedby`. Required fields show a visual `*` after the label.
|
|
86
|
+
- Index announces result changes: the pagination summary and the selection
|
|
87
|
+
container are persistent `role="status"` regions, so page, sort, filter and
|
|
88
|
+
selection-count changes are read out. Its title now names the table through
|
|
89
|
+
`aria-labelledby`.
|
|
90
|
+
- The tags input announces `Removed <tag>` on either removal path, and
|
|
91
|
+
DataTable row checkboxes are named after the first column's cell value
|
|
92
|
+
(`Select <value>`) instead of a uniform "Select row".
|
|
93
|
+
- The block editor's drag handle leaves the tab order (`tabindex="-1"`) since
|
|
94
|
+
Enter and Space do nothing on it; the move buttons are the keyboard path and
|
|
95
|
+
the hint text now says so.
|
|
96
|
+
- The shared field scaffold (`.pui-field`, its label, hint and required
|
|
97
|
+
marker, and the `.pui-input` / `.pui-textarea` surface) moved from
|
|
98
|
+
TextInput's stylesheet to `styles/4-components/form-field.css`, loaded by
|
|
99
|
+
`@pienter/ui/styles`. Textarea, Select, DateInput, Label and the other
|
|
100
|
+
primitives no longer depend on TextInput being on the page for their
|
|
101
|
+
border, focus ring and marker to render. Form's error summary list drops
|
|
102
|
+
the user-agent margin and indent.
|
|
103
|
+
|
|
104
|
+
- Collapsed sidebar rail entries drop their own inline padding so a
|
|
105
|
+
nine-character caption such as "Companies" no longer breaks mid-word.
|
|
106
|
+
|
|
107
|
+
### Added
|
|
108
|
+
|
|
109
|
+
- `ToastHost` and the `toast` controller provide package-owned FIFO queuing,
|
|
110
|
+
auto-dismiss timing, persistent notifications, hover/focus pause, actions,
|
|
111
|
+
Escape dismissal and live-region announcements.
|
|
112
|
+
- `AppLayout` provides a reusable Vue application shell with header slots,
|
|
113
|
+
Sidebar navigation, a responsive drawer, skip link and focusable main
|
|
114
|
+
landmark without depending on Vue Router.
|
|
115
|
+
- An explicit `@pienter/ui/utils/a11y` export supports directory imports in
|
|
116
|
+
consumer TypeScript project builds.
|
|
117
|
+
- Index's sort select shares state with table headers and supports allowed fields
|
|
118
|
+
without visible columns. Sort labels can be customized independently of columns.
|
|
119
|
+
- Individual filter updates and refresh/loading access in Index action and
|
|
120
|
+
selection slots reduce consumer wiring.
|
|
121
|
+
- `normalizeListQuery` validates structured query state directly, without a URL
|
|
122
|
+
encoding round trip.
|
|
123
|
+
- Index `headingLevel` (`1 | 2 | 3`, default `1`) so a nested list can keep its
|
|
124
|
+
`title`, `description` and actions without a second `h1`; at level 2 or 3
|
|
125
|
+
the title sizes like a section heading. `tableLabel` names the table when
|
|
126
|
+
there is no title.
|
|
127
|
+
- Table and DataTable `label` (a visually hidden `<caption>`) and `labelledby`
|
|
128
|
+
(`aria-labelledby` to a visible heading) give the `<table>` an accessible
|
|
129
|
+
name.
|
|
130
|
+
- Select `required`, mirroring TextInput.
|
|
131
|
+
- TagsInput `required`: the marker plus `aria-required` on the entry input,
|
|
132
|
+
since the native attribute would validate the typed text rather than the
|
|
133
|
+
tag list.
|
|
134
|
+
- `ensureLiveRegion()` from `@pienter/ui/utils/a11y` creates the shared live
|
|
135
|
+
region ahead of the first `announce()`.
|
|
136
|
+
|
|
137
|
+
## 0.6.0 - 2026-09-11
|
|
138
|
+
|
|
139
|
+
### Changed
|
|
140
|
+
|
|
141
|
+
- CMS surfaces use softer decorative borders and a lower-contrast canvas; the block editor no longer sits inside a second card. New `--bg-clr-canvas` and `--border-clr-subtle` semantic tokens leave control, text and focus contrast intact.
|
|
142
|
+
- Form validation focuses a visible, enabled invalid control, including when other errors are in hidden tab panels.
|
|
143
|
+
- Index separates page headings and actions from a bordered search, filter and results surface.
|
|
144
|
+
- Compact CMS layouts use tighter table rows and inline pagination controls. Form fields no longer double label spacing or stretch controls beside hints.
|
|
145
|
+
|
|
146
|
+
### Added
|
|
147
|
+
|
|
148
|
+
- `RecordFields` and RecordForm’s `fields` slot for sidebar and tabbed layouts within one form; Tabs items can supply panel IDs and relationships.
|
|
149
|
+
- `BlockEditor` for typed content blocks, with model updates, app-provided fields/factories, pointer drag-and-drop, keyboard movement, add/remove and controlled collapse.
|
|
150
|
+
- `Breadcrumb` link slot for router links and `RecordForm` before-field slot for headings and other content between fields.
|
|
151
|
+
- `Index`, `RecordForm`, and `RecordDetails` components for CMS pages composed with ordinary Vue routes, props, events, and slots. Index accepts an async loader and owns query state, debounced search, loading, retry, stale-request protection and optional Vue Router query synchronization.
|
|
152
|
+
- Backend query and response types, JSON Schema, URL query codecs, and immutable record path helpers under `@pienter/ui/utils/cms`.
|
|
153
|
+
|
|
5
154
|
## 0.5.0 - 2026-08-30
|
|
6
155
|
|
|
7
156
|
### Breaking
|
package/CONVENTIONS.md
CHANGED
|
@@ -59,6 +59,7 @@ Current consumers:
|
|
|
59
59
|
- `components/display/avatar/` — `Avatar` + `AvatarStack` (the stack is a visual layout for grouping avatars; stack styling lives in `avatar.css` alongside the avatar visual)
|
|
60
60
|
- `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)
|
|
61
61
|
- `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)
|
|
62
|
+
- `components/feedback/toast/` — `Toast` + `ToastHost` (Toast renders one notification; ToastHost orchestrates the visible family and owns the shared `pui-toast-region` block in `toast.css`)
|
|
62
63
|
|
|
63
64
|
Rules:
|
|
64
65
|
|
|
@@ -106,6 +107,7 @@ Variants and runtime states live on `data-*` attributes on the block element, ne
|
|
|
106
107
|
| `data-status` | Derived semantic status for form primitives (validation outcome) | `error`, `success` |
|
|
107
108
|
| `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` |
|
|
108
109
|
| `data-orientation` | Layout direction for grouped controls (RadioGroup items, future SegmentedControl, etc.) — lives on the group container | `vertical` (default for RadioGroup), `horizontal` |
|
|
110
|
+
| `data-placement` | Where a CMS surface is mounted, which decides its chrome and heading level — lives on `.pui-index` | `module` (default), `related` |
|
|
109
111
|
|
|
110
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.
|
|
111
113
|
|
|
@@ -236,14 +238,16 @@ above the rendered options, hint and errors below.
|
|
|
236
238
|
- [ ] Hint renders as `<p class="pui-field__hint" id={hintId}>` only when the
|
|
237
239
|
`hint` prop is set. Omit the element entirely when absent.
|
|
238
240
|
- [ ] Errors render as `<ul class="pui-field__hint" id={errorsId}
|
|
239
|
-
role="
|
|
241
|
+
role="status">` with one `<li>` per error message, only when errors are
|
|
240
242
|
present. Omit the element entirely when the errors array is empty.
|
|
241
|
-
- `role="
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
243
|
+
- `role="status"` (polite, atomic) keeps a submit batch from being read
|
|
244
|
+
three times over: Form's summary `Alert` and `announce(…, 'assertive')`
|
|
245
|
+
own the interrupting channel, and the list stays reachable through
|
|
246
|
+
`aria-describedby` on the control. Do not put `role="alert"` on a
|
|
247
|
+
per-field list.
|
|
248
|
+
- [ ] When the primitive takes `required`, the label text is followed by
|
|
249
|
+
`<span class="pui-field__required" aria-hidden="true">*</span>`; the
|
|
250
|
+
native attribute carries the semantics, the marker is visual only.
|
|
247
251
|
- [ ] Control element receives `aria-invalid="true"` (string, per the
|
|
248
252
|
boolean-attr-only-when-truthy rule) when errors are present; omit the
|
|
249
253
|
attribute when not.
|
|
@@ -310,18 +314,16 @@ short string label sits directly inside the wrapping label element. ARIA:
|
|
|
310
314
|
`aria-invalid` and `aria-describedby` land on the fieldset (group-level);
|
|
311
315
|
each input carries always-emit `aria-checked="true|false"`.
|
|
312
316
|
|
|
313
|
-
**pui-field scaffold CSS
|
|
314
|
-
`.pui-field`, `.pui-field__label`, `.pui-field__hint`,
|
|
315
|
-
`.pui-field[data-layout='inline']` rules
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
documentation in this file points consumers at the scaffold regardless
|
|
324
|
-
of where the file lives.
|
|
317
|
+
**pui-field scaffold CSS lives in the styles entry**: the shared
|
|
318
|
+
`.pui-field`, `.pui-field__label`, `.pui-field__required`, `.pui-field__hint`,
|
|
319
|
+
`.pui-field[data-layout='inline']` rules and the `.pui-input` /
|
|
320
|
+
`.pui-textarea` control surface live in
|
|
321
|
+
`packages/ui/styles/4-components/form-field.css`, imported by
|
|
322
|
+
`styles/main.css` so every consumer of `@pienter/ui/styles` gets them
|
|
323
|
+
regardless of which primitive is on the page. Per-control element rules
|
|
324
|
+
(`.pui-select__control`, `.pui-switch`, `.pui-inputgroup`, etc.) stay in
|
|
325
|
+
their own component CSS and recolour from the parent
|
|
326
|
+
`.pui-field[data-status]`.
|
|
325
327
|
|
|
326
328
|
### Pure-markup with slots
|
|
327
329
|
|
|
@@ -453,8 +455,8 @@ Locked during Phase 2 alongside Form (2026-05-03). Form is the **orchestrator**:
|
|
|
453
455
|
|
|
454
456
|
**Error display rules**:
|
|
455
457
|
|
|
456
|
-
- Per-field errors land in the form-primitive's `pui-field__hint role="
|
|
457
|
-
- Top-level errors (`response.errors[]` or consumer-supplied `:errors` prop)
|
|
458
|
+
- Per-field errors land in the form-primitive's `pui-field__hint role="status"` element, set `aria-invalid="true"` on the control, set `data-status="error"` on the wrapper. The consumer wires `:errors` on each form-primitive child (typical Vue data flow).
|
|
459
|
+
- Top-level errors (`response.errors[]` or consumer-supplied `:errors` prop) render in an `Alert tone="danger"` at the top of the form; Alert carries `role="alert"`.
|
|
458
460
|
- 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.
|
|
459
461
|
|
|
460
462
|
**Submit lifecycle**:
|
|
@@ -848,12 +850,12 @@ One consumer is implementation; two is duplication; three is a pattern.
|
|
|
848
850
|
Modern HTML provides native overlay primitives that reduce hand-rolled
|
|
849
851
|
focus traps, portals, and escape-key handling. Use them where they fit:
|
|
850
852
|
|
|
851
|
-
| Overlay archetype | Native API
|
|
852
|
-
| ---------------------------------------------------------- |
|
|
853
|
-
| Modal, alert dialog, sheet | `<dialog>` + `showModal()` / `close()`
|
|
854
|
-
| Anchored popover, tooltip, dropdown menu, combobox listbox | Popover API (`popover` attribute + `showPopover()` / `hidePopover()`)
|
|
855
|
-
| Navigation drawer, sidebar | **Manual**
|
|
856
|
-
| Toast | **Manual** — `<
|
|
853
|
+
| Overlay archetype | Native API | Examples |
|
|
854
|
+
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
855
|
+
| Modal, alert dialog, sheet | `<dialog>` + `showModal()` / `close()` | Modal, AlertDialog, Sheet (slide-side variants of the dialog primitive) |
|
|
856
|
+
| Anchored popover, tooltip, dropdown menu, combobox listbox | Popover API (`popover` attribute + `showPopover()` / `hidePopover()`) | Popover, Tooltip, DropdownMenu, Combobox |
|
|
857
|
+
| 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. |
|
|
858
|
+
| Toast | **Manual** — `<ToastHost>` teleports a `role="region"` container to `document.body` | Toasts are non-modal status notifications. `ToastHost` renders the visible queue and owns timing and interaction; the package toast controller owns queue state and announces through the single global live region. The Popover API was considered and rejected because one region holds independently timed siblings. |
|
|
857
859
|
|
|
858
860
|
**What native APIs give us (free)**: top-layer rendering (escapes any
|
|
859
861
|
ancestor stacking context / overflow), focus trap (`Tab` cycles within
|
|
@@ -1252,6 +1254,7 @@ follow the shape of an existing one.
|
|
|
1252
1254
|
| Accordion | [`components/layout/accordion/AUDIT.md`](./components/layout/accordion/AUDIT.md) |
|
|
1253
1255
|
| Alert | [`components/feedback/alert/AUDIT.md`](./components/feedback/alert/AUDIT.md) |
|
|
1254
1256
|
| AlertDialog | [`components/overlay/alert-dialog/AUDIT.md`](./components/overlay/alert-dialog/AUDIT.md) |
|
|
1257
|
+
| AppLayout | [`components/layout/app-layout/AUDIT.md`](./components/layout/app-layout/AUDIT.md) |
|
|
1255
1258
|
| Avatar | [`components/display/avatar/AUDIT.md`](./components/display/avatar/AUDIT.md) |
|
|
1256
1259
|
| AvatarStack | [`components/display/avatar/AUDIT.md`](./components/display/avatar/AUDIT.md) |
|
|
1257
1260
|
| Badge | [`components/display/badge/AUDIT.md`](./components/display/badge/AUDIT.md) |
|
|
@@ -1293,6 +1296,7 @@ follow the shape of an existing one.
|
|
|
1293
1296
|
| Textarea | [`components/form/textarea/AUDIT.md`](./components/form/textarea/AUDIT.md) |
|
|
1294
1297
|
| TextInput | [`components/form/text-input/AUDIT.md`](./components/form/text-input/AUDIT.md) |
|
|
1295
1298
|
| Toast | [`components/feedback/toast/AUDIT.md`](./components/feedback/toast/AUDIT.md) |
|
|
1299
|
+
| ToastHost | [`components/feedback/toast/AUDIT.md`](./components/feedback/toast/AUDIT.md) |
|
|
1296
1300
|
| Toggle | [`components/action/toggle/AUDIT.md`](./components/action/toggle/AUDIT.md) |
|
|
1297
1301
|
| ToggleGroup | [`components/action/toggle-group/AUDIT.md`](./components/action/toggle-group/AUDIT.md) |
|
|
1298
1302
|
| Tooltip | [`components/overlay/tooltip/AUDIT.md`](./components/overlay/tooltip/AUDIT.md) |
|
|
@@ -1326,7 +1330,7 @@ export interface FormSubmitResponse {
|
|
|
1326
1330
|
**Mapping rules**:
|
|
1327
1331
|
|
|
1328
1332
|
- `ok: true` clears all errors, renders `statusMessage` in `.pui-form__status`, and announces it politely.
|
|
1329
|
-
- `ok: false` renders `errors` in
|
|
1333
|
+
- `ok: false` renders `errors` in the danger `Alert`, applies `fieldErrors` to the matching form-primitive wrappers (per-field error list inside `pui-field__hint role="status"` + `aria-invalid="true"` on the control), focuses the first invalid field via `focusFirstInvalid`, and announces the error count assertively.
|
|
1330
1334
|
- `redirect` is a passive value — orchestration does NOT navigate; consumers act on it inside their `@submit` handler.
|
|
1331
1335
|
|
|
1332
1336
|
**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.
|
|
@@ -1409,3 +1413,62 @@ Pinned upstream SHAs used across the table:
|
|
|
1409
1413
|
| 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 |
|
|
1410
1414
|
| 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 |
|
|
1411
1415
|
| 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 |
|
|
1416
|
+
|
|
1417
|
+
## CMS page composition
|
|
1418
|
+
|
|
1419
|
+
`Index`, `RecordForm`, and `RecordDetails` compose existing primitives. Consumers
|
|
1420
|
+
register routes and supply endpoint-specific data functions, rendering and actions.
|
|
1421
|
+
|
|
1422
|
+
Index accepts `load(query, { signal })`, `columns` and `queryOptions`. It owns
|
|
1423
|
+
query state, rows, metadata, loading, failures and retry. Search is debounced;
|
|
1424
|
+
search/filter/sort/page-size changes reset page to 1. New requests and unmount
|
|
1425
|
+
abort earlier work; stale responses are ignored even when loaders ignore the signal.
|
|
1426
|
+
`sync-query` opts into Vue Router query decoding and history synchronization.
|
|
1427
|
+
Without it, state stays local. Index imports the optional `vue-router` peer, but
|
|
1428
|
+
requires an installed router instance only for URL synchronization. A string
|
|
1429
|
+
value is a key prefix: `sync-query="contacts"` reads and writes `contacts.page`,
|
|
1430
|
+
`contacts.search`, `contacts.filter[...]` and so on, so a related index shares
|
|
1431
|
+
its parent page's URL without touching the page's own parameters.
|
|
1432
|
+
|
|
1433
|
+
`placement` selects the chrome for the two CMS placements (`CONTEXT.md`, ADR
|
|
1434
|
+
0008), exposed as `data-placement` on `.pui-index`. `module` (default) is the
|
|
1435
|
+
page itself: an `h1` header above a divider, an unboxed toolbar, the table with
|
|
1436
|
+
DataTable's own border, and the pagination footer as a band below. `related` is
|
|
1437
|
+
a list inside another record's detail page: one bordered region holding the
|
|
1438
|
+
`h2` header, toolbar, table and pagination footer, divided by the subtle border
|
|
1439
|
+
token. The heading level follows the placement; there is no heading-level prop.
|
|
1440
|
+
An index definition — one collection's columns, query options, copy and cell
|
|
1441
|
+
slots — is a consumer component composing `Index` and taking `placement` and a
|
|
1442
|
+
parent id as props. The library ships no definition type or helper.
|
|
1443
|
+
|
|
1444
|
+
Index applies the existing default sort cycle; Table/DataTable still emit a column
|
|
1445
|
+
key as intent, per ADR 0005. A toolbar select shares that sort state and includes
|
|
1446
|
+
all allowed fields, including those without visible columns. Sortable columns
|
|
1447
|
+
must belong to the allowed sort vocabulary; labels may be overridden with
|
|
1448
|
+
`sortLabels`. Filter slots can update one key without replacing siblings. Action
|
|
1449
|
+
and selection slots receive loading state and an awaitable refresh callback.
|
|
1450
|
+
Structured backend query errors offer reset; other load failures offer retry.
|
|
1451
|
+
Use DataTable when the consumer needs controlled data
|
|
1452
|
+
and request state. Index's compact row spacing uses
|
|
1453
|
+
`--pui-table-cell-padding-block`, which Table otherwise defaults to `--space-xs`.
|
|
1454
|
+
|
|
1455
|
+
RecordForm emits detached JSON snapshots, preserving every supplied property.
|
|
1456
|
+
Consumers construct an explicit writable object from backend records. Typed path
|
|
1457
|
+
segments identify nested fields and array indices; fields display issues for
|
|
1458
|
+
their path and descendants. The form summary retains unmatched issues.
|
|
1459
|
+
`--pui-record-form-columns` customizes the field grid, defaulting to one column.
|
|
1460
|
+
|
|
1461
|
+
RecordForm's `fields` slot replaces its default RecordFields rendering for
|
|
1462
|
+
custom sidebar/tab layouts. RecordFields takes the same draft and renders a
|
|
1463
|
+
subset, with `--pui-record-fields-columns` as its grid seam. It never renders a
|
|
1464
|
+
form element. Callers expose the relevant tab when backend issues arrive.
|
|
1465
|
+
|
|
1466
|
+
BlockEditor owns an ordered array of stable `id`/`type` blocks and emits updates
|
|
1467
|
+
for edits, addition, removal and reordering. Creation factories and slot content
|
|
1468
|
+
stay caller-owned. Optional `v-model:collapsed` lets pages reveal invalid blocks.
|
|
1469
|
+
Its colocated audit records pointer, keyboard, focus and cancellation behavior.
|
|
1470
|
+
|
|
1471
|
+
The backend contract lives in `docs/cms/backend-contract.md` with a distributable
|
|
1472
|
+
JSON Schema at `utils/cms/schema.json`. It defines request/response data only;
|
|
1473
|
+
endpoint URLs, authentication, permissions and domain operations belong to the
|
|
1474
|
+
application.
|
package/README.md
CHANGED
|
@@ -32,6 +32,72 @@ import { icons } from '@pienter/ui/icons';
|
|
|
32
32
|
import { generateId } from '@pienter/ui/utils/a11y/id.js';
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
### Application layout and notifications
|
|
36
|
+
|
|
37
|
+
`AppLayout` supplies the application landmarks and responsive Sidebar shell.
|
|
38
|
+
Provide product-specific brand and tools through slots, navigation as
|
|
39
|
+
`SidebarItem[]`, and routed content through the default slot. It does not import
|
|
40
|
+
Vue Router; adapt Sidebar clicks through its `navigate` event and pass the
|
|
41
|
+
current route as `active-href`.
|
|
42
|
+
|
|
43
|
+
```vue
|
|
44
|
+
<script setup lang="ts">
|
|
45
|
+
import { ref } from 'vue';
|
|
46
|
+
import AppLayout from '@pienter/ui/components/AppLayout.vue';
|
|
47
|
+
import ToastHost from '@pienter/ui/components/ToastHost.vue';
|
|
48
|
+
import { toast } from '@pienter/ui/toast';
|
|
49
|
+
|
|
50
|
+
const drawerOpen = ref(false);
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<AppLayout
|
|
55
|
+
v-model:drawer-open="drawerOpen"
|
|
56
|
+
:top-items="navigation"
|
|
57
|
+
:active-href="route.path"
|
|
58
|
+
>
|
|
59
|
+
<template #brand>My project</template>
|
|
60
|
+
<RouterView />
|
|
61
|
+
</AppLayout>
|
|
62
|
+
<ToastHost />
|
|
63
|
+
</template>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Call `toast.show({ tone: 'success', title: 'Page saved' })` from any Vue
|
|
67
|
+
module. The default controller shows three notifications at once for five
|
|
68
|
+
seconds. Use `duration: 0` for a persistent notification or
|
|
69
|
+
`createToastController()` when an application needs isolated state.
|
|
70
|
+
|
|
71
|
+
### CMS pages
|
|
72
|
+
|
|
73
|
+
Compose ordinary Vue routes with `Index`, `RecordForm`, and `RecordDetails`:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import Index from '@pienter/ui/components/Index.vue';
|
|
77
|
+
import RecordForm from '@pienter/ui/components/RecordForm.vue';
|
|
78
|
+
import RecordFields from '@pienter/ui/components/RecordFields.vue';
|
|
79
|
+
import BlockEditor from '@pienter/ui/components/BlockEditor.vue';
|
|
80
|
+
import RecordDetails from '@pienter/ui/components/RecordDetails.vue';
|
|
81
|
+
import type { RecordFormField } from '@pienter/ui/components/RecordForm.types';
|
|
82
|
+
import { decodeListQuery, encodeListQuery } from '@pienter/ui/utils/cms';
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Index receives `load(query, { signal })`, columns and query options. It handles
|
|
86
|
+
loading, search, pagination, errors and retry; `sync-query` enables Vue Router URL
|
|
87
|
+
synchronization. Index requires the `vue-router` package; an installed router
|
|
88
|
+
instance is needed only when synchronization is enabled. RecordForm receives `v-model`, fields and validation issues,
|
|
89
|
+
emitting a JSON payload on `submit`. RecordDetails receives a record and fields.
|
|
90
|
+
Use slots for custom cells, controls, values and actions. Your pages own route
|
|
91
|
+
declarations, endpoint functions and saving. Use RecordForm’s `fields` slot and
|
|
92
|
+
RecordFields to put groups in sidebars and tab panels while retaining one form.
|
|
93
|
+
BlockEditor accepts `v-model`, block creation factories and a `block` slot; it
|
|
94
|
+
provides drag-and-drop, keyboard movement, add/remove and collapse controls.
|
|
95
|
+
|
|
96
|
+
The [CMS guide](../../docs/cms/README.md) contains working examples. The
|
|
97
|
+
[backend contract](../../docs/cms/backend-contract.md) defines query parameters,
|
|
98
|
+
response envelopes and validation paths; its JSON Schema ships at
|
|
99
|
+
`@pienter/ui/utils/cms/schema.json`.
|
|
100
|
+
|
|
35
101
|
### Themes
|
|
36
102
|
|
|
37
103
|
The default theme follows the operating-system color preference, using
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export type { RecordDetailsField } from './types.js';
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script setup lang="ts" generic="T extends object">
|
|
6
|
+
import { readPath } from '../../../utils/cms/index.js';
|
|
7
|
+
import type { RecordDetailsField } from './types.js';
|
|
8
|
+
|
|
9
|
+
const props = withDefaults(
|
|
10
|
+
defineProps<{
|
|
11
|
+
record: T;
|
|
12
|
+
fields: readonly RecordDetailsField[];
|
|
13
|
+
emptyValue?: string;
|
|
14
|
+
}>(),
|
|
15
|
+
{ emptyValue: '—' },
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
function valueFor(field: RecordDetailsField): unknown {
|
|
19
|
+
return readPath(props.record, field.path ?? [field.name]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function displayValue(value: unknown): string {
|
|
23
|
+
if (value === undefined || value === null || value === '')
|
|
24
|
+
return props.emptyValue;
|
|
25
|
+
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
|
26
|
+
if (typeof value === 'object') return JSON.stringify(value);
|
|
27
|
+
return String(value);
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<div class="pui-record-details">
|
|
33
|
+
<dl class="pui-record-details__list">
|
|
34
|
+
<div
|
|
35
|
+
v-for="field in fields"
|
|
36
|
+
:key="field.name"
|
|
37
|
+
class="pui-record-details__field"
|
|
38
|
+
>
|
|
39
|
+
<dt class="pui-record-details__label">{{ field.label }}</dt>
|
|
40
|
+
<dd class="pui-record-details__value">
|
|
41
|
+
<slot
|
|
42
|
+
:name="`field:${field.name}`"
|
|
43
|
+
:field="field"
|
|
44
|
+
:value="valueFor(field)"
|
|
45
|
+
:record="record"
|
|
46
|
+
>
|
|
47
|
+
{{ displayValue(valueFor(field)) }}
|
|
48
|
+
</slot>
|
|
49
|
+
</dd>
|
|
50
|
+
</div>
|
|
51
|
+
</dl>
|
|
52
|
+
<slot />
|
|
53
|
+
<div v-if="$slots.actions" class="pui-record-details__actions">
|
|
54
|
+
<slot name="actions" :record="record" />
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<style>
|
|
60
|
+
@import './record-details.css';
|
|
61
|
+
</style>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
.pui-record-details {
|
|
3
|
+
display: grid;
|
|
4
|
+
gap: var(--space-s);
|
|
5
|
+
|
|
6
|
+
& .pui-record-details__list {
|
|
7
|
+
display: grid;
|
|
8
|
+
margin: 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
& .pui-record-details__field {
|
|
12
|
+
display: grid;
|
|
13
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 3fr);
|
|
14
|
+
gap: var(--space-s);
|
|
15
|
+
padding-block: var(--space-xs);
|
|
16
|
+
border-block-end: var(--stroke-sm) solid var(--border-clr-subtle);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
& .pui-record-details__label {
|
|
20
|
+
color: var(--text-clr-muted);
|
|
21
|
+
font-size: var(--step--1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
& .pui-record-details__value {
|
|
25
|
+
margin: 0;
|
|
26
|
+
min-inline-size: 0;
|
|
27
|
+
overflow-wrap: anywhere;
|
|
28
|
+
white-space: pre-wrap;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
& .pui-record-details__actions {
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-wrap: wrap;
|
|
34
|
+
gap: var(--space-xs);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -31,10 +31,9 @@
|
|
|
31
31
|
import { computed } from 'vue';
|
|
32
32
|
import Icon from '../../display/icon/Icon.vue';
|
|
33
33
|
import type { IconName } from '../../../icons/index.js';
|
|
34
|
+
import type { ToastTone } from './types.js';
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const toneIconMap: Record<Tone, IconName> = {
|
|
36
|
+
const toneIconMap: Record<ToastTone, IconName> = {
|
|
38
37
|
brand: 'info',
|
|
39
38
|
success: 'circle-check',
|
|
40
39
|
warning: 'alert-triangle',
|
|
@@ -43,7 +42,7 @@ const toneIconMap: Record<Tone, IconName> = {
|
|
|
43
42
|
|
|
44
43
|
const props = withDefaults(
|
|
45
44
|
defineProps<{
|
|
46
|
-
tone?:
|
|
45
|
+
tone?: ToastTone;
|
|
47
46
|
title: string;
|
|
48
47
|
message?: string;
|
|
49
48
|
dismissible?: boolean;
|