@pienter/ui 0.7.1 → 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.
Files changed (48) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/CONVENTIONS.md +47 -29
  3. package/README.md +36 -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/package.json +6 -1
  44. package/styles/4-components/form-field.css +112 -0
  45. package/styles/4-components/index.css +1 -0
  46. package/styles/main.css +1 -0
  47. package/utils/a11y/index.ts +5 -1
  48. package/utils/a11y/live-region.ts +2 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
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
+
5
30
  ## 0.7.1 - 2026-09-15
6
31
 
7
32
  ### Changed
@@ -33,15 +58,81 @@
33
58
  results card instead of inside it, mirroring the header. Pages can pin
34
59
  it to the viewport bottom; consumer styles targeting the footer's card
35
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.
36
106
 
37
107
  ### Added
38
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.
39
117
  - Index's sort select shares state with table headers and supports allowed fields
40
118
  without visible columns. Sort labels can be customized independently of columns.
41
119
  - Individual filter updates and refresh/loading access in Index action and
42
120
  selection slots reduce consumer wiring.
43
121
  - `normalizeListQuery` validates structured query state directly, without a URL
44
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()`.
45
136
 
46
137
  ## 0.6.0 - 2026-09-11
47
138
 
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`:
@@ -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';