@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.
- package/CHANGELOG.md +108 -0
- package/CONVENTIONS.md +47 -29
- package/README.md +40 -0
- package/components/display/record-details/record-details.css +2 -2
- 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 +2 -1
- package/components/form/checkbox/Checkbox.vue +10 -2
- package/components/form/combobox/Combobox.vue +11 -5
- 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 -2
- package/components/form/number-field/number-field.css +3 -3
- package/components/form/radio-group/RadioGroup.vue +1 -1
- 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 -131
- 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 +31 -11
- package/components/layout/index/index.css +55 -12
- package/components/layout/index/useIndex.ts +31 -14
- package/components/layout/table/DataTable.vue +14 -1
- package/components/layout/table/Table.vue +12 -0
- package/components/navigation/breadcrumb/breadcrumb.css +6 -1
- package/components/navigation/sidebar/sidebar.css +23 -16
- package/components/navigation/tabs/tabs.css +7 -7
- package/composables/useUrlTab.ts +38 -0
- package/package.json +7 -1
- 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/index.ts +5 -1
- package/utils/a11y/live-region.ts +2 -1
- 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="
|
|
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.
|
|
@@ -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-
|
|
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-
|
|
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
|
-
|
|
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;
|
|
@@ -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';
|