@pienter/ui 0.7.1 → 0.9.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/CONVENTIONS.md +47 -29
  3. package/README.md +40 -0
  4. package/components/display/record-details/record-details.css +2 -2
  5. package/components/feedback/toast/Toast.vue +3 -4
  6. package/components/feedback/toast/ToastHost.vue +165 -0
  7. package/components/feedback/toast/toast.ts +116 -0
  8. package/components/feedback/toast/types.ts +48 -0
  9. package/components/form/block-editor/BlockEditor.vue +2 -1
  10. package/components/form/checkbox/Checkbox.vue +10 -2
  11. package/components/form/combobox/Combobox.vue +11 -5
  12. package/components/form/combobox/combobox.css +1 -1
  13. package/components/form/date-input/DateInput.vue +10 -2
  14. package/components/form/date-input/date-input.css +2 -17
  15. package/components/form/form/Form.vue +10 -9
  16. package/components/form/form/form.css +2 -22
  17. package/components/form/input-otp/InputOTP.vue +1 -1
  18. package/components/form/input-otp/input-otp.css +1 -1
  19. package/components/form/label/label.css +2 -11
  20. package/components/form/number-field/NumberField.vue +10 -2
  21. package/components/form/number-field/number-field.css +3 -3
  22. package/components/form/radio-group/RadioGroup.vue +1 -1
  23. package/components/form/select/Select.vue +13 -4
  24. package/components/form/select/select.css +6 -9
  25. package/components/form/slider/Slider.vue +1 -1
  26. package/components/form/switch/Switch.vue +1 -1
  27. package/components/form/tags-input/TagsInput.vue +17 -2
  28. package/components/form/tags-input/tags-input.css +16 -12
  29. package/components/form/text-input/TextInput.vue +10 -2
  30. package/components/form/text-input/text-input.css +10 -131
  31. package/components/form/textarea/Textarea.vue +10 -2
  32. package/components/form/textarea/textarea.css +3 -19
  33. package/components/layout/app-layout/AppLayout.vue +116 -0
  34. package/components/layout/app-layout/app-layout.css +115 -0
  35. package/components/layout/index/Index.vue +31 -11
  36. package/components/layout/index/index.css +55 -12
  37. package/components/layout/index/useIndex.ts +31 -14
  38. package/components/layout/table/DataTable.vue +14 -1
  39. package/components/layout/table/Table.vue +12 -0
  40. package/components/navigation/breadcrumb/breadcrumb.css +6 -1
  41. package/components/navigation/sidebar/sidebar.css +23 -16
  42. package/components/navigation/tabs/tabs.css +7 -7
  43. package/composables/useUrlTab.ts +38 -0
  44. package/package.json +7 -1
  45. package/styles/4-components/form-field.css +112 -0
  46. package/styles/4-components/index.css +1 -0
  47. package/styles/main.css +1 -0
  48. package/utils/a11y/index.ts +5 -1
  49. package/utils/a11y/live-region.ts +2 -1
  50. package/utils/cms/index.ts +21 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,48 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.9.0 - 2026-09-15
6
+
7
+ ### Added
8
+
9
+ - `useUrlTab(keys, defaultKey, param = 'tab')` at
10
+ `@pienter/ui/composables/useUrlTab` keeps the active `Tabs` key in the URL
11
+ query, the single source of truth, so a shared link or a reload opens the
12
+ same tab. It returns a writable computed for `v-model`: the default key
13
+ drops the parameter, an unknown value falls back to it, and writes go
14
+ through `router.replace` so tab switches stay out of the back stack and
15
+ other query parameters survive. Requires the optional `vue-router` peer,
16
+ like `useUrlSort`.
17
+ - `formFailure(cause, fallback)` in `@pienter/ui/utils/cms` turns a caught
18
+ save error into RecordForm's `{ issues, errors }`: a value carrying the
19
+ `ErrorResponse` envelope contributes its field issues and leaves `errors`
20
+ empty; otherwise `errors` holds the caught `Error` message or `fallback`.
21
+
22
+ ## 0.8.0 - 2026-09-15
23
+
24
+ ### Breaking
25
+
26
+ - Index drops `headingLevel`. The heading level now follows `placement`: `h1`
27
+ in the default module placement, `h2` in related placement. Replace
28
+ `heading-level="2"` on an embedded list with `placement="related"`.
29
+
30
+ ### Added
31
+
32
+ - Index `placement` (`module` | `related`, default `module`), exposed as
33
+ `data-placement` on `.pui-index`. Module is the page itself and renders flat:
34
+ the heading and actions above a divider, an unboxed toolbar, the table with
35
+ DataTable's own border and radius, and the pagination footer as a band
36
+ below. Related is a list inside another record's detail page and renders as
37
+ one bordered region holding the header, toolbar, table and pagination
38
+ footer, with a second-level heading. Existing consumers are unchanged; the
39
+ toolbar and results no longer share a bordered body in module placement, so
40
+ consumer styles targeting that box no longer apply.
41
+ - Index `sync-query` accepts a string as well as a boolean. The string is a
42
+ key prefix: `sync-query="contacts"` reads and writes `contacts.page`,
43
+ `contacts.page_size`, `contacts.search`, `contacts.sort` and
44
+ `contacts.filter[...]`, so a related index can share its parent page's URL.
45
+ Unrelated parameters, including the bare `page`, survive untouched.
46
+
5
47
  ## 0.7.1 - 2026-09-15
6
48
 
7
49
  ### Changed
@@ -33,15 +75,81 @@
33
75
  results card instead of inside it, mirroring the header. Pages can pin
34
76
  it to the viewport bottom; consumer styles targeting the footer's card
35
77
  corner radii no longer apply.
78
+ - Text inputs, textareas, selects, input groups and the tags input now rest
79
+ on the `--border-clr-input` border Button already uses, so control edges
80
+ clear 3:1 in both themes, and take the standard `:focus-visible` outline
81
+ ring instead of `outline: none` plus a translucent halo. The brand border
82
+ tint on focus stays; consumer overrides of the old `box-shadow` ring no
83
+ longer apply. Tabs and the tags-input remove button use the same tokenised
84
+ ring.
85
+ - Breadcrumb links get a `:focus-visible` ring and the bar's gap reads the
86
+ `--space-s` token it was meant to. Record-details labels are
87
+ `--text-clr-muted` and its row separators `--border-clr-subtle`, matching
88
+ other in-panel dividers; Tabs' rule uses the same subtle border and its
89
+ active marker is `--stroke-lg`.
90
+ - Sidebar honours `prefers-reduced-motion` for the drawer slide, backdrop
91
+ fade, caret rotation and width collapse, not only the rail flyout.
92
+ - The tags-input remove button is a 24px target; pills keep their height by
93
+ dropping block padding.
94
+ - Form renders its top-level error summary with the danger `Alert` instead of
95
+ a bespoke box, so it matches every other error surface; consumer styles
96
+ targeting `.pui-form__error-summary` no longer apply. The shared live region
97
+ is created when a Form mounts, so the first "N errors found" announcement is
98
+ no longer dropped.
99
+ - Per-field error lists on every form primitive are `role="status"` rather
100
+ than `role="alert"`: a failed submit is announced once, assertively, by the
101
+ summary and the live region, and each field's errors stay reachable through
102
+ `aria-describedby`. Required fields show a visual `*` after the label.
103
+ - Index announces result changes: the pagination summary and the selection
104
+ container are persistent `role="status"` regions, so page, sort, filter and
105
+ selection-count changes are read out. Its title now names the table through
106
+ `aria-labelledby`.
107
+ - The tags input announces `Removed <tag>` on either removal path, and
108
+ DataTable row checkboxes are named after the first column's cell value
109
+ (`Select <value>`) instead of a uniform "Select row".
110
+ - The block editor's drag handle leaves the tab order (`tabindex="-1"`) since
111
+ Enter and Space do nothing on it; the move buttons are the keyboard path and
112
+ the hint text now says so.
113
+ - The shared field scaffold (`.pui-field`, its label, hint and required
114
+ marker, and the `.pui-input` / `.pui-textarea` surface) moved from
115
+ TextInput's stylesheet to `styles/4-components/form-field.css`, loaded by
116
+ `@pienter/ui/styles`. Textarea, Select, DateInput, Label and the other
117
+ primitives no longer depend on TextInput being on the page for their
118
+ border, focus ring and marker to render. Form's error summary list drops
119
+ the user-agent margin and indent.
120
+
121
+ - Collapsed sidebar rail entries drop their own inline padding so a
122
+ nine-character caption such as "Companies" no longer breaks mid-word.
36
123
 
37
124
  ### Added
38
125
 
126
+ - `ToastHost` and the `toast` controller provide package-owned FIFO queuing,
127
+ auto-dismiss timing, persistent notifications, hover/focus pause, actions,
128
+ Escape dismissal and live-region announcements.
129
+ - `AppLayout` provides a reusable Vue application shell with header slots,
130
+ Sidebar navigation, a responsive drawer, skip link and focusable main
131
+ landmark without depending on Vue Router.
132
+ - An explicit `@pienter/ui/utils/a11y` export supports directory imports in
133
+ consumer TypeScript project builds.
39
134
  - Index's sort select shares state with table headers and supports allowed fields
40
135
  without visible columns. Sort labels can be customized independently of columns.
41
136
  - Individual filter updates and refresh/loading access in Index action and
42
137
  selection slots reduce consumer wiring.
43
138
  - `normalizeListQuery` validates structured query state directly, without a URL
44
139
  encoding round trip.
140
+ - Index `headingLevel` (`1 | 2 | 3`, default `1`) so a nested list can keep its
141
+ `title`, `description` and actions without a second `h1`; at level 2 or 3
142
+ the title sizes like a section heading. `tableLabel` names the table when
143
+ there is no title.
144
+ - Table and DataTable `label` (a visually hidden `<caption>`) and `labelledby`
145
+ (`aria-labelledby` to a visible heading) give the `<table>` an accessible
146
+ name.
147
+ - Select `required`, mirroring TextInput.
148
+ - TagsInput `required`: the marker plus `aria-required` on the entry input,
149
+ since the native attribute would validate the typed text rather than the
150
+ tag list.
151
+ - `ensureLiveRegion()` from `@pienter/ui/utils/a11y` creates the shared live
152
+ region ahead of the first `announce()`.
45
153
 
46
154
  ## 0.6.0 - 2026-09-11
47
155
 
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="alert">` with one `<li>` per error message, only when errors are
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="alert"` (which carries implicit `aria-live="assertive"` and
242
- `aria-atomic="true"`) is appropriate for submit-time validation messages
243
- that arrive as a batch. For live/as-you-type validation out of scope
244
- for v1 use `role="status"` or `aria-live="polite"` instead, to avoid
245
- interrupting the user mid-keystroke. When live validation lands, this
246
- rule is reopened.
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 co-located with TextInput for now**: the shared
314
- `.pui-field`, `.pui-field__label`, `.pui-field__hint`, and
315
- `.pui-field[data-layout='inline']` rules live in
316
- `packages/ui/components/form/text-input/text-input.css` because TextInput was
317
- the first form-primitive consumer. Per-control element rules
318
- (`.pui-input`, `.pui-switch`, etc.) live in their own component CSS. **When
319
- the fifth form-primitive ships** (Checkbox, RadioGroup, Select, Textarea,
320
- NumberField, DateInput will push that count over), extract the
321
- `pui-field*` rules to `packages/ui/styles/4-components/form-field.css` so
322
- the scaffold isn't tied to one component's filename. The `data-layout`
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="alert"` element, set `aria-invalid="true"` on the control, set `data-status="error"` on the wrapper. The consumer wires `:errors` on each form-primitive child (typical Vue data flow).
457
- - Top-level errors (`response.errors[]` or consumer-supplied `:errors` prop) land in `.pui-form__error-summary` at the top of the form, with `role="alert"` and `aria-live="assertive"`.
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 | Examples |
852
- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
853
- | Modal, alert dialog, sheet | `<dialog>` + `showModal()` / `close()` | Modal, AlertDialog, Sheet (slide-side variants of the dialog primitive) |
854
- | Anchored popover, tooltip, dropdown menu, combobox listbox | Popover API (`popover` attribute + `showPopover()` / `hidePopover()`) | Popover, Tooltip, DropdownMenu, Combobox |
855
- | Navigation drawer, sidebar | **Manual** | Sidebar drawer is `<nav>`, not `<dialog>` — keep manual `inert` / focus-trap mechanics. The semantic landmark is more important than the modal mechanics. |
856
- | Toast | **Manual** — `<ol class="pui-toast-region">` with `aria-live="polite"` and `aria-atomic="false"` | Toasts are non-modal status notifications. The visual layer is a fixed-position `<ol>`; auto-dismiss + queue are owned by whoever renders the region. The Popover API was considered and rejected: it requires a single element, but the toast region holds N siblings whose lifetimes are independent. |
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 `.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.
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.
@@ -1421,7 +1425,21 @@ search/filter/sort/page-size changes reset page to 1. New requests and unmount
1421
1425
  abort earlier work; stale responses are ignored even when loaders ignore the signal.
1422
1426
  `sync-query` opts into Vue Router query decoding and history synchronization.
1423
1427
  Without it, state stays local. Index imports the optional `vue-router` peer, but
1424
- requires an installed router instance only for URL synchronization.
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.
1425
1443
 
1426
1444
  Index applies the existing default sort cycle; Table/DataTable still emit a column
1427
1445
  key as intent, per ADR 0005. A toolbar select shares that sort state and includes
package/README.md CHANGED
@@ -32,6 +32,42 @@ 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
+
35
71
  ### CMS pages
36
72
 
37
73
  Compose ordinary Vue routes with `Index`, `RecordForm`, and `RecordDetails`:
@@ -56,6 +92,10 @@ declarations, endpoint functions and saving. Use RecordForm’s `fields` slot an
56
92
  RecordFields to put groups in sidebars and tab panels while retaining one form.
57
93
  BlockEditor accepts `v-model`, block creation factories and a `block` slot; it
58
94
  provides drag-and-drop, keyboard movement, add/remove and collapse controls.
95
+ `useUrlTab` from `@pienter/ui/composables/useUrlTab` keeps the active tab key in
96
+ the query string for `v-model` on Tabs, and `formFailure(cause, fallback)` from
97
+ `@pienter/ui/utils/cms` turns a caught save error into RecordForm's `issues` and
98
+ `errors`.
59
99
 
60
100
  The [CMS guide](../../docs/cms/README.md) contains working examples. The
61
101
  [backend contract](../../docs/cms/backend-contract.md) defines query parameters,
@@ -13,11 +13,11 @@
13
13
  grid-template-columns: minmax(0, 1fr) minmax(0, 3fr);
14
14
  gap: var(--space-s);
15
15
  padding-block: var(--space-xs);
16
- border-block-end: var(--stroke-sm) solid var(--border-clr-base);
16
+ border-block-end: var(--stroke-sm) solid var(--border-clr-subtle);
17
17
  }
18
18
 
19
19
  & .pui-record-details__label {
20
- color: var(--text-clr-dim);
20
+ color: var(--text-clr-muted);
21
21
  font-size: var(--step--1);
22
22
  }
23
23
 
@@ -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
- type Tone = 'brand' | 'success' | 'warning' | 'danger';
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?: Tone;
45
+ tone?: ToastTone;
47
46
  title: string;
48
47
  message?: string;
49
48
  dismissible?: boolean;
@@ -0,0 +1,165 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
3
+ import Toast from './Toast.vue';
4
+ import { toast as defaultController } from './toast.js';
5
+ import type { ToastController, ToastItem } from './types.js';
6
+ import { ensureLiveRegion } from '../../../utils/a11y/live-region.js';
7
+
8
+ interface Timer {
9
+ handle?: ReturnType<typeof setTimeout>;
10
+ startedAt: number;
11
+ remaining: number;
12
+ autoDismiss: boolean;
13
+ hovered: boolean;
14
+ focused: boolean;
15
+ }
16
+
17
+ const props = withDefaults(
18
+ defineProps<{
19
+ controller?: ToastController;
20
+ label?: string;
21
+ }>(),
22
+ {
23
+ controller: () => defaultController,
24
+ label: 'Notifications',
25
+ },
26
+ );
27
+ const items = computed(() => props.controller.visible.value);
28
+ const position = computed(() => props.controller.position.value);
29
+ const timers = new Map<string, Timer>();
30
+
31
+ function start(item: ToastItem, remaining = item.duration): void {
32
+ const timer: Timer = {
33
+ startedAt: Date.now(),
34
+ remaining,
35
+ autoDismiss: item.duration > 0,
36
+ hovered: false,
37
+ focused: false,
38
+ };
39
+ if (remaining > 0)
40
+ timer.handle = setTimeout(
41
+ () => props.controller.dismiss(item.id),
42
+ remaining,
43
+ );
44
+ timers.set(item.id, timer);
45
+ }
46
+
47
+ function pause(id: string): void {
48
+ const timer = timers.get(id);
49
+ if (!timer || timer.handle === undefined) return;
50
+ clearTimeout(timer.handle);
51
+ timer.remaining = Math.max(
52
+ 0,
53
+ timer.remaining - (Date.now() - timer.startedAt),
54
+ );
55
+ timer.handle = undefined;
56
+ }
57
+
58
+ function resume(item: ToastItem): void {
59
+ const timer = timers.get(item.id);
60
+ if (
61
+ !timer ||
62
+ timer.handle !== undefined ||
63
+ !timer.autoDismiss ||
64
+ timer.hovered ||
65
+ timer.focused
66
+ )
67
+ return;
68
+ if (timer.remaining <= 0) {
69
+ props.controller.dismiss(item.id);
70
+ return;
71
+ }
72
+ timer.startedAt = Date.now();
73
+ timer.handle = setTimeout(
74
+ () => props.controller.dismiss(item.id),
75
+ timer.remaining,
76
+ );
77
+ }
78
+
79
+ function setHovered(item: ToastItem, hovered: boolean): void {
80
+ const timer = timers.get(item.id);
81
+ if (!timer) return;
82
+ timer.hovered = hovered;
83
+ if (hovered) pause(item.id);
84
+ else resume(item);
85
+ }
86
+
87
+ function setFocused(item: ToastItem, focused: boolean): void {
88
+ const timer = timers.get(item.id);
89
+ if (!timer) return;
90
+ timer.focused = focused;
91
+ if (focused) pause(item.id);
92
+ else resume(item);
93
+ }
94
+
95
+ function onFocusOut(item: ToastItem, event: FocusEvent): void {
96
+ const root = event.currentTarget as HTMLElement;
97
+ const target = event.relatedTarget;
98
+ setFocused(item, target instanceof Node && root.contains(target));
99
+ }
100
+
101
+ function dismiss(id: string): void {
102
+ props.controller.dismiss(id);
103
+ }
104
+
105
+ function runAction(item: ToastItem): void {
106
+ try {
107
+ item.action?.onClick();
108
+ } finally {
109
+ dismiss(item.id);
110
+ }
111
+ }
112
+
113
+ watch(
114
+ items,
115
+ (next) => {
116
+ const ids = new Set(next.map((item) => item.id));
117
+ for (const [id, timer] of timers) {
118
+ if (ids.has(id)) continue;
119
+ if (timer.handle !== undefined) clearTimeout(timer.handle);
120
+ timers.delete(id);
121
+ }
122
+ for (const item of next) if (!timers.has(item.id)) start(item);
123
+ },
124
+ { immediate: true },
125
+ );
126
+
127
+ onMounted(ensureLiveRegion);
128
+
129
+ onBeforeUnmount(() => {
130
+ for (const timer of timers.values())
131
+ if (timer.handle !== undefined) clearTimeout(timer.handle);
132
+ timers.clear();
133
+ });
134
+ </script>
135
+
136
+ <template>
137
+ <Teleport to="body">
138
+ <div
139
+ v-if="items.length"
140
+ class="pui-toast-region"
141
+ role="region"
142
+ :aria-label="label"
143
+ :data-position="position"
144
+ >
145
+ <Toast
146
+ v-for="item in items"
147
+ :key="item.id"
148
+ :tone="item.tone"
149
+ :title="item.title"
150
+ :message="item.message"
151
+ :dismissible="item.dismissible"
152
+ :icon="item.icon"
153
+ :action="item.action"
154
+ aria-keyshortcuts="Escape"
155
+ @dismiss="dismiss(item.id)"
156
+ @action="runAction(item)"
157
+ @keydown.escape.prevent="dismiss(item.id)"
158
+ @mouseenter="setHovered(item, true)"
159
+ @mouseleave="setHovered(item, false)"
160
+ @focusin="setFocused(item, true)"
161
+ @focusout="onFocusOut(item, $event)"
162
+ />
163
+ </div>
164
+ </Teleport>
165
+ </template>
@@ -0,0 +1,116 @@
1
+ import { readonly, shallowRef, ref } from 'vue';
2
+ import { announce } from '../../../utils/a11y/live-region.js';
3
+ import type {
4
+ ToastConfig,
5
+ ToastController,
6
+ ToastItem,
7
+ ToastPosition,
8
+ } from './types.js';
9
+
10
+ const DEFAULT_CONFIG = {
11
+ maxVisible: 3,
12
+ defaultDuration: 5000,
13
+ position: 'bottom-right' as ToastPosition,
14
+ };
15
+
16
+ export function createToastController(
17
+ initialConfig: ToastConfig = {},
18
+ ): ToastController {
19
+ const config = { ...DEFAULT_CONFIG };
20
+ const visible = shallowRef<ToastItem[]>([]);
21
+ const position = ref<ToastPosition>(config.position);
22
+ const queued: ToastItem[] = [];
23
+ let nextId = 0;
24
+
25
+ function configure(next: ToastConfig): void {
26
+ if (
27
+ next.maxVisible !== undefined &&
28
+ Number.isFinite(next.maxVisible) &&
29
+ next.maxVisible > 0
30
+ )
31
+ config.maxVisible = Math.floor(next.maxVisible);
32
+ if (
33
+ next.defaultDuration !== undefined &&
34
+ Number.isFinite(next.defaultDuration) &&
35
+ next.defaultDuration >= 0
36
+ )
37
+ config.defaultDuration = next.defaultDuration;
38
+ if (next.position) {
39
+ config.position = next.position;
40
+ position.value = next.position;
41
+ }
42
+ if (visible.value.length > config.maxVisible) {
43
+ const overflow = visible.value.slice(config.maxVisible);
44
+ visible.value = visible.value.slice(0, config.maxVisible);
45
+ queued.unshift(...overflow);
46
+ }
47
+ drain();
48
+ }
49
+
50
+ function generateId(): string {
51
+ nextId += 1;
52
+ return `pui-toast-${Date.now().toString(36)}-${nextId}`;
53
+ }
54
+
55
+ function drain(): void {
56
+ if (visible.value.length >= config.maxVisible || !queued.length) return;
57
+ const slots = config.maxVisible - visible.value.length;
58
+ visible.value = [...visible.value, ...queued.splice(0, slots)];
59
+ }
60
+
61
+ function dismiss(id: string): void {
62
+ const next = visible.value.filter((item) => item.id !== id);
63
+ if (next.length !== visible.value.length) {
64
+ visible.value = next;
65
+ drain();
66
+ return;
67
+ }
68
+ const queuedIndex = queued.findIndex((item) => item.id === id);
69
+ if (queuedIndex >= 0) queued.splice(queuedIndex, 1);
70
+ }
71
+
72
+ configure(initialConfig);
73
+
74
+ return {
75
+ visible: readonly(visible),
76
+ position: readonly(position),
77
+ show(options) {
78
+ const requestedDuration =
79
+ options.duration ?? config.defaultDuration;
80
+ const item: ToastItem = {
81
+ ...options,
82
+ id: options.id ?? generateId(),
83
+ duration: Number.isFinite(requestedDuration)
84
+ ? Math.max(0, requestedDuration)
85
+ : config.defaultDuration,
86
+ dismissible: options.dismissible ?? true,
87
+ };
88
+ if (visible.value.length < config.maxVisible)
89
+ visible.value = [...visible.value, item];
90
+ else queued.push(item);
91
+ if (typeof document !== 'undefined')
92
+ announce(
93
+ [item.title, item.message].filter(Boolean).join('. '),
94
+ item.tone === 'danger' ? 'assertive' : 'polite',
95
+ );
96
+ return item.id;
97
+ },
98
+ dismiss,
99
+ dismissAll() {
100
+ queued.length = 0;
101
+ visible.value = [];
102
+ },
103
+ configure,
104
+ };
105
+ }
106
+
107
+ export const toast = createToastController();
108
+
109
+ export type {
110
+ ToastConfig,
111
+ ToastController,
112
+ ToastItem,
113
+ ToastOptions,
114
+ ToastPosition,
115
+ ToastTone,
116
+ } from './types.js';