@pienter/ui 0.14.0 → 0.16.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 +87 -0
- package/CONVENTIONS.md +44 -12
- package/components/display/record-details/record-details.css +8 -0
- package/components/form/form/Form.vue +24 -14
- package/components/form/form/form.css +36 -0
- package/components/form/record-form/RecordForm.vue +3 -0
- package/components/layout/app-layout/app-layout.css +70 -8
- package/components/layout/index/index.css +31 -0
- package/components/layout/page-aside/PageAside.vue +163 -0
- package/components/layout/page-aside/page-aside.css +129 -0
- package/components/layout/record-layout/record-layout.css +0 -40
- package/components/navigation/breadcrumb/breadcrumb.css +3 -0
- package/package.json +2 -2
- package/components/layout/record-layout/RecordLayout.vue +0 -35
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,93 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.16.0 - 2026-09-15
|
|
6
|
+
|
|
7
|
+
### Breaking
|
|
8
|
+
|
|
9
|
+
- `RecordLayout` is retired, one release after it shipped: with the page
|
|
10
|
+
container providing the main column and the new `PageAside` taking the
|
|
11
|
+
sidebar's place on the right, nothing of it was left but a gap. `Panel`
|
|
12
|
+
stays. Migration: drop the `RecordLayout` wrapper and its `sidebar` slot,
|
|
13
|
+
render the main column's `Tabs` and `Panel`s directly in `RecordForm`'s
|
|
14
|
+
`fields` slot, and move the sidebar `Panel`'s children into
|
|
15
|
+
`<PageAside :title="…">` in `RecordForm`'s new `aside` slot (the title
|
|
16
|
+
comes from the panel; `sidebar-labelledby` and `title-id` go, the aside
|
|
17
|
+
names itself). The `./components/RecordLayout.vue` export is gone.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- The page container no longer stacks the aside below the main row under
|
|
22
|
+
56em. `PageAside` owns that breakpoint: the column becomes a rail at the
|
|
23
|
+
right edge and the toggle opens the content as a drawer. The aside track is
|
|
24
|
+
now `auto` and the component sets its own width, so a consumer-written
|
|
25
|
+
`<aside class="pui-page__aside">` without the component gets no size; use
|
|
26
|
+
`PageAside`.
|
|
27
|
+
- `RecordDetails` stacks each label over its value when its own container is
|
|
28
|
+
narrower than 24rem, so it reads in a `PageAside` or any other narrow
|
|
29
|
+
column without the consumer overriding its grid.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- `PageAside`, the right band of a CMS page: filters beside a module index,
|
|
34
|
+
settings beside a record form. `title` (required; heads the column and
|
|
35
|
+
names the landmark), `description`, a default slot, `toggle` (default on)
|
|
36
|
+
with `toggle-label`, and `v-model:open` (default open). Open, it is the
|
|
37
|
+
sticky, self-scrolling aside column with a flat header and a left rule;
|
|
38
|
+
closed, a rail one toggle wide in the same column, its content `hidden`
|
|
39
|
+
but mounted so fields in the band still submit; below 56em the rail stays
|
|
40
|
+
and the toggle opens a drawer from the right with the Sidebar drawer's
|
|
41
|
+
mechanics (backdrop, focus trap, Escape, the same toggle closing it). The
|
|
42
|
+
toggle keeps the `sidebar-right` icon in both states and carries the state
|
|
43
|
+
in `aria-expanded` and its name, "Hide {title}" / "Show {title}".
|
|
44
|
+
- `Form` has an `aside` slot rendered as a direct child of `<form>`, between
|
|
45
|
+
the body and the actions row, and `RecordForm` forwards it. A `PageAside`
|
|
46
|
+
in it becomes the page's aside column through the form's `display:
|
|
47
|
+
contents` while its fields keep their form owner. The page's `:has()`
|
|
48
|
+
switch matches that path too.
|
|
49
|
+
|
|
50
|
+
## 0.15.0 - 2026-09-15
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- `AppLayout`'s main region no longer has padding. A page's rows carry their
|
|
55
|
+
own gutter (see the page container under Added), so a sticky band sits
|
|
56
|
+
flush against the region's edges without negative margins. Consumer-visible:
|
|
57
|
+
a page that has not adopted `.pui-page` loses its inset. The seams stay,
|
|
58
|
+
defaulting to `0`; one line restores the previous behaviour:
|
|
59
|
+
`.pui-app-layout__main { --pui-app-layout-main-padding: var(--space-m) var(--space-l) var(--space-xl); --pui-app-layout-main-padding-mobile: var(--space-s); }`.
|
|
60
|
+
- `Form` wraps everything that is not the actions row — the error summary,
|
|
61
|
+
the default slot, the status message — in a new `<div class="pui-form__body">`.
|
|
62
|
+
This changes the DOM for every consumer, page-placed or not; selectors and
|
|
63
|
+
tests that assumed the fields were direct children of the `<form>` need the
|
|
64
|
+
extra level. The body inherits the form's `gap`, so a `.pui-form` override
|
|
65
|
+
still spaces the fields.
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- `.pui-page`, the page container for `AppLayout`'s default slot: one grid of
|
|
70
|
+
four full-width rows placed by named area — `pui-page__band` (the breadcrumb
|
|
71
|
+
strip), `__header`, `__main` (takes the leftover height) and `__footer`
|
|
72
|
+
(sticky at the region's bottom) — plus an optional `__aside` beside the main
|
|
73
|
+
row: a stretched, size-contained column whose child sticks to the top of the
|
|
74
|
+
scrollport with its own scroll, stacking below the content under 56em. The
|
|
75
|
+
bands span the whole region without knowing the aside exists. A row has one
|
|
76
|
+
occupant: two elements in the same named area overlap.
|
|
77
|
+
- `.pui-gutter`, the one inline padding every page row takes, in the
|
|
78
|
+
`utilities` layer so it replaces a bar's own padding without a specificity
|
|
79
|
+
fight. It reads `--pui-gutter-inline`, `--space-l` on the shell and
|
|
80
|
+
`--space-s` below the 48rem breakpoint.
|
|
81
|
+
- A module `Index` or a `Form` (so a `RecordForm`) that is a direct child of
|
|
82
|
+
`.pui-page` supplies the page's rows itself: its header, body and pagination
|
|
83
|
+
footer, or its body and its actions row as the sticky footer band. Inferred
|
|
84
|
+
from the position through `display: contents`, no prop; a related index and
|
|
85
|
+
any form inside `pui-page__main` are unaffected.
|
|
86
|
+
|
|
87
|
+
### Fixed
|
|
88
|
+
|
|
89
|
+
- A long record title in a `Breadcrumb` wraps inside its item instead of
|
|
90
|
+
widening the trail.
|
|
91
|
+
|
|
5
92
|
## 0.14.0 - 2026-09-15
|
|
6
93
|
|
|
7
94
|
### Changed
|
package/CONVENTIONS.md
CHANGED
|
@@ -840,10 +840,11 @@ landmark rather than a `<dialog>` (see "Use native APIs where
|
|
|
840
840
|
applicable" below): focus trap on the panel, ref-counted scroll lock
|
|
841
841
|
on body, capture trigger before mount + restore on dismiss,
|
|
842
842
|
ARIA-promotion (`role="dialog"` + `aria-modal="true"` + `aria-label`)
|
|
843
|
-
while open.
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
843
|
+
while open. PageAside's drawer (`PageAside.vue`, 2026-09-16) is the
|
|
844
|
+
second place: the panel is inside an `<aside>` landmark, so it mirrors
|
|
845
|
+
Sidebar's trap, lock, promotion and Escape rather than using `<dialog>`.
|
|
846
|
+
Two is duplication, reviewed and left as is; when **a third
|
|
847
|
+
non-`<dialog>` overlay** lands with the same mechanics, extract a shared
|
|
847
848
|
`utils/a11y/modal-mode.ts` primitive:
|
|
848
849
|
`enterModalMode(panel, { onEscape, restoreFocusTo, ariaLabel }) → exitModalMode()`.
|
|
849
850
|
One consumer is implementation; two is duplication; three is a pattern.
|
|
@@ -1280,12 +1281,12 @@ follow the shape of an existing one.
|
|
|
1280
1281
|
| Modal | [`components/overlay/modal/AUDIT.md`](./components/overlay/modal/AUDIT.md) |
|
|
1281
1282
|
| Navbar | [`components/navigation/navbar/AUDIT.md`](./components/navigation/navbar/AUDIT.md) |
|
|
1282
1283
|
| NumberField | [`components/form/number-field/AUDIT.md`](./components/form/number-field/AUDIT.md) |
|
|
1284
|
+
| PageAside | [`components/layout/page-aside/AUDIT.md`](./components/layout/page-aside/AUDIT.md) |
|
|
1283
1285
|
| Pagination | [`components/navigation/pagination/AUDIT.md`](./components/navigation/pagination/AUDIT.md) |
|
|
1284
1286
|
| Panel | [`components/layout/record-layout/AUDIT.md`](./components/layout/record-layout/AUDIT.md) |
|
|
1285
1287
|
| Popover | [`components/overlay/popover/AUDIT.md`](./components/overlay/popover/AUDIT.md) |
|
|
1286
1288
|
| Progress | [`components/feedback/progress/AUDIT.md`](./components/feedback/progress/AUDIT.md) |
|
|
1287
1289
|
| RadioGroup | [`components/form/radio-group/AUDIT.md`](./components/form/radio-group/AUDIT.md) |
|
|
1288
|
-
| RecordLayout | [`components/layout/record-layout/AUDIT.md`](./components/layout/record-layout/AUDIT.md) |
|
|
1289
1290
|
| Segmented | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
|
|
1290
1291
|
| Select | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
|
|
1291
1292
|
| Separator | [`components/layout/separator/AUDIT.md`](./components/layout/separator/AUDIT.md) |
|
|
@@ -1431,6 +1432,33 @@ resolve against a definite height. Scroll-to-top on navigation,
|
|
|
1431
1432
|
`scroll-padding` for sticky bands and scroll restoration target that region,
|
|
1432
1433
|
not the window; the AppLayout audit lists what a consumer wires.
|
|
1433
1434
|
|
|
1435
|
+
The main region has no padding. A page is a `.pui-page` grid of four
|
|
1436
|
+
full-width rows placed by named area — band (`pui-page__band`, the
|
|
1437
|
+
breadcrumb strip), header (`__header`), main (`__main`, the row that takes
|
|
1438
|
+
the leftover height) and footer (`__footer`, sticky at the region's bottom) —
|
|
1439
|
+
plus the optional `PageAside` beside the main row. Every row takes its inline
|
|
1440
|
+
padding from the `.pui-gutter` utility, so the trail, the heading, the table
|
|
1441
|
+
and the pagination summary share one edge and a band is full width because it
|
|
1442
|
+
is a full-width row, not because it escaped an inset. No negative margins, no
|
|
1443
|
+
bleed properties, no component computes a distance. A consumer-written row
|
|
1444
|
+
carries the row class plus `pui-gutter`; a module `Index` or a `Form` that is
|
|
1445
|
+
a direct child of the page supplies its own rows through `display: contents`
|
|
1446
|
+
and needs no prop for it. **A row has one occupant.** Two elements in one
|
|
1447
|
+
named area overlap, silently; a module `Index` already supplies the footer
|
|
1448
|
+
row, so a page with one does not add its own. Anything that is not a row goes
|
|
1449
|
+
inside `pui-page__main`. The AppLayout audit holds the details.
|
|
1450
|
+
|
|
1451
|
+
`PageAside` is the right band (`CONTEXT.md`, Page Aside): a flat column with
|
|
1452
|
+
a required `title` naming the `<aside>`, a `description`, a toggle
|
|
1453
|
+
(`toggle`, `toggle-label`) and `v-model:open`. Open, it is the page's aside
|
|
1454
|
+
column, sticky with its own scroll; closed, a rail one toggle wide in the
|
|
1455
|
+
same column with its content `hidden` but mounted; below 56em the rail
|
|
1456
|
+
stays and the toggle opens the content as a drawer with the Sidebar's
|
|
1457
|
+
mechanics. On an index page it is a direct child of the page after the
|
|
1458
|
+
module `Index`; on a record page it goes in `Form`'s `aside` slot (forwarded
|
|
1459
|
+
by `RecordForm`) so its fields stay in the `<form>`. Its audit has the
|
|
1460
|
+
sizing and the accepted footer overlap.
|
|
1461
|
+
|
|
1434
1462
|
Index accepts `load(query, { signal })`, `columns` and `queryOptions`. It owns
|
|
1435
1463
|
query state, rows, metadata, loading, failures and retry. Search is debounced;
|
|
1436
1464
|
search/filter/sort/page-size changes reset page to 1. New requests and unmount
|
|
@@ -1449,7 +1477,9 @@ DataTable's own border, and the pagination footer as a band below. `related` is
|
|
|
1449
1477
|
a list inside another record's detail page: one bordered region holding the
|
|
1450
1478
|
`h2` header, toolbar, table and pagination footer, divided by the subtle border
|
|
1451
1479
|
token. The heading level follows the placement; there is no heading-level prop.
|
|
1452
|
-
|
|
1480
|
+
A module index that is a direct child of `.pui-page` supplies the page's
|
|
1481
|
+
header, main and footer rows itself; a related index never does. An index
|
|
1482
|
+
definition — one collection's columns, query options, copy and cell
|
|
1453
1483
|
slots — is a consumer component composing `Index` and taking `placement` and a
|
|
1454
1484
|
parent id as props. The library ships no definition type or helper.
|
|
1455
1485
|
|
|
@@ -1475,12 +1505,14 @@ custom sidebar/tab layouts. RecordFields takes the same draft and renders a
|
|
|
1475
1505
|
subset, with `--pui-record-fields-columns` as its grid seam. It never renders a
|
|
1476
1506
|
form element. Callers expose the relevant tab when backend issues arrive.
|
|
1477
1507
|
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1508
|
+
Panel gives a record page's main column its sections (`CONTEXT.md`): one
|
|
1509
|
+
bordered section per field group or tab with its heading inside it. Panel
|
|
1510
|
+
honours `hidden`, so a consumer toggles tab panels with the attribute, and
|
|
1511
|
+
drops its chrome around a direct `BlockEditor` child. It sets
|
|
1512
|
+
`--pui-record-fields-columns` for the RecordFields inside it, as PageAside
|
|
1513
|
+
does for the settings fields in the band. `RecordLayout` is retired
|
|
1514
|
+
(2026-09-16, CHANGELOG): the page container is the main column and PageAside
|
|
1515
|
+
the sidebar.
|
|
1484
1516
|
|
|
1485
1517
|
BlockEditor owns an ordered array of stable `id`/`type` blocks and emits updates
|
|
1486
1518
|
for edits, addition, removal and reordering. Creation factories and slot content
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
@layer components {
|
|
2
2
|
.pui-record-details {
|
|
3
|
+
container-type: inline-size;
|
|
3
4
|
display: grid;
|
|
4
5
|
gap: var(--space-s);
|
|
5
6
|
|
|
@@ -33,5 +34,12 @@
|
|
|
33
34
|
flex-wrap: wrap;
|
|
34
35
|
gap: var(--space-xs);
|
|
35
36
|
}
|
|
37
|
+
|
|
38
|
+
@container (max-width: 24rem) {
|
|
39
|
+
& .pui-record-details__field {
|
|
40
|
+
grid-template-columns: minmax(0, 1fr);
|
|
41
|
+
gap: var(--space-3xs);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
36
44
|
}
|
|
37
45
|
}
|
|
@@ -6,22 +6,32 @@
|
|
|
6
6
|
:aria-busy="busy ? 'true' : undefined"
|
|
7
7
|
@submit.prevent="handleSubmit"
|
|
8
8
|
>
|
|
9
|
-
<
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
9
|
+
<div class="pui-form__body">
|
|
10
|
+
<Alert
|
|
11
|
+
v-if="topErrors.length > 0"
|
|
12
|
+
tone="danger"
|
|
13
|
+
title="There was a problem"
|
|
14
|
+
>
|
|
15
|
+
<ul class="pui-form__errors">
|
|
16
|
+
<li v-for="error in topErrors" :key="error">
|
|
17
|
+
{{ error }}
|
|
18
|
+
</li>
|
|
19
|
+
</ul>
|
|
20
|
+
</Alert>
|
|
21
|
+
|
|
22
|
+
<slot />
|
|
23
|
+
|
|
24
|
+
<div
|
|
25
|
+
v-if="statusMessage"
|
|
26
|
+
class="pui-form__status"
|
|
27
|
+
aria-live="polite"
|
|
28
|
+
>
|
|
29
|
+
{{ statusMessage }}
|
|
30
|
+
</div>
|
|
23
31
|
</div>
|
|
24
32
|
|
|
33
|
+
<slot name="aside" />
|
|
34
|
+
|
|
25
35
|
<div v-if="!readonly" class="pui-form__actions">
|
|
26
36
|
<slot name="actions">
|
|
27
37
|
<button
|
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
gap: var(--space-m);
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
.pui-form__body {
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-direction: column;
|
|
11
|
+
/* The body takes the form's gap, so a consumer's `.pui-form` override keeps working. */
|
|
12
|
+
gap: inherit;
|
|
13
|
+
min-inline-size: 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
.pui-form__errors {
|
|
9
17
|
margin: 0;
|
|
10
18
|
padding-inline-start: var(--space-s);
|
|
@@ -23,4 +31,32 @@
|
|
|
23
31
|
gap: var(--space-s);
|
|
24
32
|
align-items: center;
|
|
25
33
|
}
|
|
34
|
+
|
|
35
|
+
/* A form that is a direct child of a page supplies its main and footer
|
|
36
|
+
rows. The `<form>` stays in the DOM, so every control keeps its owner. */
|
|
37
|
+
.pui-page > .pui-form {
|
|
38
|
+
display: contents;
|
|
39
|
+
|
|
40
|
+
& > .pui-form__body {
|
|
41
|
+
grid-area: pui-page-main;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
& > .pui-form__actions {
|
|
45
|
+
grid-area: pui-page-footer;
|
|
46
|
+
position: sticky;
|
|
47
|
+
inset-block-end: 0;
|
|
48
|
+
z-index: var(--layer-2);
|
|
49
|
+
flex-wrap: wrap;
|
|
50
|
+
justify-content: end;
|
|
51
|
+
padding-block: var(--space-xs);
|
|
52
|
+
background: var(--bg-clr-surface);
|
|
53
|
+
border-block-start: var(--stroke-sm) solid var(--border-clr-subtle);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@layer utilities {
|
|
59
|
+
.pui-page > .pui-form > :is(.pui-form__body, .pui-form__actions) {
|
|
60
|
+
padding-inline: var(--pui-gutter-inline, var(--space-l));
|
|
61
|
+
}
|
|
26
62
|
}
|
|
@@ -107,6 +107,9 @@ function submit(): void {
|
|
|
107
107
|
</RecordFields>
|
|
108
108
|
</slot>
|
|
109
109
|
<slot />
|
|
110
|
+
<template v-if="$slots.aside" #aside>
|
|
111
|
+
<slot name="aside" />
|
|
112
|
+
</template>
|
|
110
113
|
<template #actions>
|
|
111
114
|
<slot name="actions" :busy="busy">
|
|
112
115
|
<Button type="submit" variant="primary" :loading="busy">
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
@layer components {
|
|
2
2
|
.pui-app-layout {
|
|
3
|
+
--pui-gutter-inline: var(--space-l);
|
|
4
|
+
|
|
3
5
|
display: grid;
|
|
4
6
|
grid-template-rows: auto minmax(0, 1fr);
|
|
5
7
|
block-size: 100dvh;
|
|
@@ -73,10 +75,7 @@
|
|
|
73
75
|
min-block-size: 0;
|
|
74
76
|
overflow: auto;
|
|
75
77
|
overscroll-behavior-block: contain;
|
|
76
|
-
padding: var(
|
|
77
|
-
--pui-app-layout-main-padding,
|
|
78
|
-
var(--space-m) var(--space-l) var(--space-xl)
|
|
79
|
-
);
|
|
78
|
+
padding: var(--pui-app-layout-main-padding, 0);
|
|
80
79
|
|
|
81
80
|
&:focus:not(:focus-visible) {
|
|
82
81
|
outline: none;
|
|
@@ -94,15 +93,14 @@
|
|
|
94
93
|
}
|
|
95
94
|
|
|
96
95
|
@media (max-width: 47.999em) {
|
|
96
|
+
--pui-gutter-inline: var(--space-s);
|
|
97
|
+
|
|
97
98
|
& .pui-app-layout__drawer-toggle {
|
|
98
99
|
display: inline-flex;
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
& .pui-app-layout__main {
|
|
102
|
-
padding: var(
|
|
103
|
-
--pui-app-layout-main-padding-mobile,
|
|
104
|
-
var(--space-s)
|
|
105
|
-
);
|
|
103
|
+
padding: var(--pui-app-layout-main-padding-mobile, 0);
|
|
106
104
|
}
|
|
107
105
|
}
|
|
108
106
|
|
|
@@ -114,4 +112,68 @@
|
|
|
114
112
|
}
|
|
115
113
|
}
|
|
116
114
|
}
|
|
115
|
+
|
|
116
|
+
.pui-page {
|
|
117
|
+
--pui-page-aside-inline-size: calc(
|
|
118
|
+
var(--space-sidebar-width) + 2 *
|
|
119
|
+
var(--pui-gutter-inline, var(--space-l))
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
display: grid;
|
|
123
|
+
grid-template-areas:
|
|
124
|
+
'pui-page-band'
|
|
125
|
+
'pui-page-header'
|
|
126
|
+
'pui-page-main'
|
|
127
|
+
'pui-page-footer';
|
|
128
|
+
/* 1fr, not minmax(0, 1fr): the auto minimum carries the main row's
|
|
129
|
+
content, so a long page grows past the region instead of capping. */
|
|
130
|
+
grid-template-rows: auto auto 1fr auto;
|
|
131
|
+
grid-template-columns: minmax(0, 1fr);
|
|
132
|
+
gap: var(--space-s) 0;
|
|
133
|
+
min-inline-size: 0;
|
|
134
|
+
|
|
135
|
+
/* `auto`, not the aside token: the aside sets its own width, so its
|
|
136
|
+
rail state changes nothing here. */
|
|
137
|
+
&:has(> .pui-page__aside, > .pui-form > .pui-page__aside) {
|
|
138
|
+
grid-template-areas:
|
|
139
|
+
'pui-page-band pui-page-band'
|
|
140
|
+
'pui-page-header pui-page-header'
|
|
141
|
+
'pui-page-main pui-page-aside'
|
|
142
|
+
'pui-page-footer pui-page-footer';
|
|
143
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.pui-page__band {
|
|
148
|
+
grid-area: pui-page-band;
|
|
149
|
+
padding-block: var(--space-xs);
|
|
150
|
+
background: var(--bg-clr-surface);
|
|
151
|
+
border-block-end: var(--stroke-sm) solid var(--border-clr-subtle);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.pui-page__header {
|
|
155
|
+
grid-area: pui-page-header;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.pui-page__main {
|
|
159
|
+
grid-area: pui-page-main;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.pui-page__footer {
|
|
163
|
+
grid-area: pui-page-footer;
|
|
164
|
+
position: sticky;
|
|
165
|
+
inset-block-end: 0;
|
|
166
|
+
z-index: var(--layer-2);
|
|
167
|
+
padding-block: var(--space-xs);
|
|
168
|
+
background: var(--bg-clr-surface);
|
|
169
|
+
border-block-start: var(--stroke-sm) solid var(--border-clr-subtle);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* A utility outranks a component's own padding, so a bar that ships its own
|
|
174
|
+
`padding-inline` lines up when it becomes a page row. */
|
|
175
|
+
@layer utilities {
|
|
176
|
+
.pui-gutter {
|
|
177
|
+
padding-inline: var(--pui-gutter-inline, var(--space-l));
|
|
178
|
+
}
|
|
117
179
|
}
|
|
@@ -118,4 +118,35 @@
|
|
|
118
118
|
border-end-end-radius: inherit;
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
|
+
|
|
122
|
+
/* A module index that is a direct child of a page supplies three of its
|
|
123
|
+
rows: `display: contents` dissolves the root so the regions are the
|
|
124
|
+
page's grid items. A related index never does. */
|
|
125
|
+
.pui-page > .pui-index[data-placement='module'] {
|
|
126
|
+
display: contents;
|
|
127
|
+
|
|
128
|
+
& > .pui-page-header {
|
|
129
|
+
grid-area: pui-page-header;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
& > .pui-index__body {
|
|
133
|
+
grid-area: pui-page-main;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
& > .pui-index__footer {
|
|
137
|
+
grid-area: pui-page-footer;
|
|
138
|
+
position: sticky;
|
|
139
|
+
inset-block-end: 0;
|
|
140
|
+
z-index: var(--layer-2);
|
|
141
|
+
border-block-start-color: var(--border-clr-subtle);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* Utilities layer, so the gutter replaces PaginationFooter's own inline
|
|
147
|
+
padding without a specificity fight. */
|
|
148
|
+
@layer utilities {
|
|
149
|
+
.pui-page > .pui-index[data-placement='module'] > * {
|
|
150
|
+
padding-inline: var(--pui-gutter-inline, var(--space-l));
|
|
151
|
+
}
|
|
121
152
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<aside
|
|
3
|
+
class="pui-page__aside pui-page-aside"
|
|
4
|
+
:aria-labelledby="titleId"
|
|
5
|
+
:data-state="expanded ? 'open' : 'closed'"
|
|
6
|
+
:data-drawer-state="drawerOpen ? 'open' : 'closed'"
|
|
7
|
+
>
|
|
8
|
+
<div
|
|
9
|
+
class="pui-page-aside__backdrop"
|
|
10
|
+
aria-hidden="true"
|
|
11
|
+
@click="drawerOpen = false"
|
|
12
|
+
/>
|
|
13
|
+
<div ref="panelRef" class="pui-page-aside__panel" v-bind="drawerAria">
|
|
14
|
+
<header class="pui-page-aside__header">
|
|
15
|
+
<div class="pui-page-aside__heading">
|
|
16
|
+
<h2 :id="titleId" class="pui-page-aside__title">
|
|
17
|
+
{{ title }}
|
|
18
|
+
</h2>
|
|
19
|
+
<p v-if="description" class="pui-page-aside__description">
|
|
20
|
+
{{ description }}
|
|
21
|
+
</p>
|
|
22
|
+
</div>
|
|
23
|
+
<IconButton
|
|
24
|
+
v-if="toggle"
|
|
25
|
+
ref="toggleRef"
|
|
26
|
+
class="pui-page-aside__toggle"
|
|
27
|
+
name="sidebar-right"
|
|
28
|
+
variant="ghost"
|
|
29
|
+
size="sm"
|
|
30
|
+
:label="
|
|
31
|
+
toggleLabel ?? `${expanded ? 'Hide' : 'Show'} ${title}`
|
|
32
|
+
"
|
|
33
|
+
:aria-expanded="expanded ? 'true' : 'false'"
|
|
34
|
+
:aria-controls="contentId"
|
|
35
|
+
@click="onToggle"
|
|
36
|
+
/>
|
|
37
|
+
</header>
|
|
38
|
+
<div
|
|
39
|
+
:id="contentId"
|
|
40
|
+
class="pui-page-aside__content"
|
|
41
|
+
:hidden="!expanded"
|
|
42
|
+
>
|
|
43
|
+
<slot />
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</aside>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<script setup lang="ts">
|
|
50
|
+
import {
|
|
51
|
+
computed,
|
|
52
|
+
nextTick,
|
|
53
|
+
onBeforeUnmount,
|
|
54
|
+
onMounted,
|
|
55
|
+
ref,
|
|
56
|
+
useId,
|
|
57
|
+
watch,
|
|
58
|
+
} from 'vue';
|
|
59
|
+
import IconButton from '../../action/button/IconButton.vue';
|
|
60
|
+
import { createFocusTrap, type FocusTrap } from '../../../utils/a11y/focus.js';
|
|
61
|
+
import { lockScroll } from '../../../utils/dom/scroll-lock.js';
|
|
62
|
+
import { useWindowListener } from '../../../composables/useEventListener.js';
|
|
63
|
+
|
|
64
|
+
withDefaults(
|
|
65
|
+
defineProps<{
|
|
66
|
+
/** Heads the column and names the landmark. */
|
|
67
|
+
title: string;
|
|
68
|
+
description?: string;
|
|
69
|
+
/** Render the collapse toggle. Without it the consumer drives `open`; below 56em the toggle is the only way into the drawer. */
|
|
70
|
+
toggle?: boolean;
|
|
71
|
+
/** Accessible name for the toggle in both states; defaults to "Hide {title}" / "Show {title}". */
|
|
72
|
+
toggleLabel?: string;
|
|
73
|
+
}>(),
|
|
74
|
+
{
|
|
75
|
+
description: undefined,
|
|
76
|
+
toggle: true,
|
|
77
|
+
toggleLabel: undefined,
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
defineSlots<{
|
|
82
|
+
default?: () => unknown;
|
|
83
|
+
}>();
|
|
84
|
+
|
|
85
|
+
/** Column open or collapsed to a rail. Above 56em only; the drawer below has its own state. */
|
|
86
|
+
const open = defineModel<boolean>('open', { default: true });
|
|
87
|
+
|
|
88
|
+
const id = useId();
|
|
89
|
+
const titleId = `${id}-title`;
|
|
90
|
+
const contentId = `${id}-content`;
|
|
91
|
+
|
|
92
|
+
const panelRef = ref<HTMLElement | null>(null);
|
|
93
|
+
const toggleRef = ref<{ $el: HTMLElement } | null>(null);
|
|
94
|
+
|
|
95
|
+
const narrow = ref(false);
|
|
96
|
+
const drawerOpen = ref(false);
|
|
97
|
+
const expanded = computed(() => (narrow.value ? drawerOpen.value : open.value));
|
|
98
|
+
|
|
99
|
+
const drawerAria = computed(() =>
|
|
100
|
+
drawerOpen.value
|
|
101
|
+
? { role: 'dialog', 'aria-modal': true, 'aria-labelledby': titleId }
|
|
102
|
+
: {},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const media =
|
|
106
|
+
typeof window === 'undefined'
|
|
107
|
+
? null
|
|
108
|
+
: window.matchMedia('(max-width: 56em)');
|
|
109
|
+
|
|
110
|
+
function onMediaChange(event: MediaQueryListEvent | MediaQueryList): void {
|
|
111
|
+
narrow.value = event.matches;
|
|
112
|
+
if (!event.matches) drawerOpen.value = false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
onMounted(() => {
|
|
116
|
+
if (!media) return;
|
|
117
|
+
onMediaChange(media);
|
|
118
|
+
media.addEventListener('change', onMediaChange);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
function onToggle(): void {
|
|
122
|
+
if (narrow.value) drawerOpen.value = !drawerOpen.value;
|
|
123
|
+
else open.value = !open.value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The trap keeps focus in the panel, so a window listener sees the same keys.
|
|
127
|
+
useWindowListener('keydown', (event) => {
|
|
128
|
+
if (event.key !== 'Escape' || !drawerOpen.value || event.defaultPrevented)
|
|
129
|
+
return;
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
drawerOpen.value = false;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
let trap: FocusTrap | null = null;
|
|
135
|
+
let unlockScroll: (() => void) | null = null;
|
|
136
|
+
|
|
137
|
+
watch(drawerOpen, async (isOpen) => {
|
|
138
|
+
if (isOpen) {
|
|
139
|
+
unlockScroll = lockScroll();
|
|
140
|
+
await nextTick();
|
|
141
|
+
if (!panelRef.value) return;
|
|
142
|
+
trap = createFocusTrap(panelRef.value);
|
|
143
|
+
toggleRef.value?.$el.focus();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
trap?.release();
|
|
147
|
+
trap = null;
|
|
148
|
+
unlockScroll?.();
|
|
149
|
+
unlockScroll = null;
|
|
150
|
+
const toggle = toggleRef.value?.$el;
|
|
151
|
+
if (toggle && document.contains(toggle)) toggle.focus();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
onBeforeUnmount(() => {
|
|
155
|
+
media?.removeEventListener('change', onMediaChange);
|
|
156
|
+
trap?.release();
|
|
157
|
+
unlockScroll?.();
|
|
158
|
+
});
|
|
159
|
+
</script>
|
|
160
|
+
|
|
161
|
+
<style>
|
|
162
|
+
@import './page-aside.css';
|
|
163
|
+
</style>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
.pui-page__aside {
|
|
3
|
+
grid-area: pui-page-aside;
|
|
4
|
+
/* Size containment: the column is as tall as the content row and never
|
|
5
|
+
the other way round, so a long aside cannot make a short page scroll. */
|
|
6
|
+
contain: size;
|
|
7
|
+
inline-size: var(--pui-page-aside-inline-size);
|
|
8
|
+
border-inline-start: var(--stroke-sm) solid var(--border-clr-subtle);
|
|
9
|
+
|
|
10
|
+
/* The rail: one toggle with a little air. Also the column under an
|
|
11
|
+
open drawer. */
|
|
12
|
+
&:is([data-state='closed'], [data-drawer-state='open']) {
|
|
13
|
+
inline-size: calc(var(--control-height-sm) + 2 * var(--space-s));
|
|
14
|
+
|
|
15
|
+
& .pui-page-aside__header {
|
|
16
|
+
padding-inline: var(--space-s);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
&[data-state='closed'] .pui-page-aside__heading {
|
|
21
|
+
display: none;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.pui-page-aside__backdrop {
|
|
26
|
+
display: none;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* The panel sticks, not the column; `block-size: 100%` on the column
|
|
30
|
+
would defeat sticky. `100%` is the content row, exact on a short page;
|
|
31
|
+
the viewport term caps a long one at the scrollport. */
|
|
32
|
+
.pui-page-aside__panel {
|
|
33
|
+
position: sticky;
|
|
34
|
+
inset-block-start: 0;
|
|
35
|
+
display: flex;
|
|
36
|
+
flex-direction: column;
|
|
37
|
+
gap: var(--space-s);
|
|
38
|
+
max-block-size: min(100%, calc(100dvh - var(--space-2xl)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.pui-page-aside__header {
|
|
42
|
+
display: flex;
|
|
43
|
+
align-items: start;
|
|
44
|
+
gap: var(--space-xs);
|
|
45
|
+
padding-inline: var(--pui-gutter-inline, var(--space-l));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.pui-page-aside__heading {
|
|
49
|
+
display: grid;
|
|
50
|
+
gap: var(--space-3xs);
|
|
51
|
+
align-content: center;
|
|
52
|
+
flex: 1;
|
|
53
|
+
min-inline-size: 0;
|
|
54
|
+
min-block-size: var(--control-height-sm);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.pui-page-aside__title {
|
|
58
|
+
margin: 0;
|
|
59
|
+
font-size: var(--step-0);
|
|
60
|
+
font-weight: var(--fw-semibold);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.pui-page-aside__description {
|
|
64
|
+
margin: 0;
|
|
65
|
+
font-size: var(--step--1);
|
|
66
|
+
color: var(--text-clr-muted);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.pui-page-aside__toggle {
|
|
70
|
+
flex-shrink: 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.pui-page-aside__content {
|
|
74
|
+
--pui-record-fields-columns: minmax(0, 1fr);
|
|
75
|
+
|
|
76
|
+
display: grid;
|
|
77
|
+
align-content: start;
|
|
78
|
+
gap: var(--space-s);
|
|
79
|
+
min-block-size: 0;
|
|
80
|
+
padding-inline: var(--pui-gutter-inline, var(--space-l));
|
|
81
|
+
padding-block-end: var(--space-s);
|
|
82
|
+
overflow-y: auto;
|
|
83
|
+
overscroll-behavior: contain;
|
|
84
|
+
|
|
85
|
+
&[hidden] {
|
|
86
|
+
display: none;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@media (max-width: 56em) {
|
|
91
|
+
.pui-page__aside[data-drawer-state='open'] {
|
|
92
|
+
& .pui-page-aside__backdrop {
|
|
93
|
+
display: block;
|
|
94
|
+
position: fixed;
|
|
95
|
+
inset: 0;
|
|
96
|
+
z-index: var(--layer-4);
|
|
97
|
+
background: var(--bg-clr-backdrop);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
& .pui-page-aside__panel {
|
|
101
|
+
position: fixed;
|
|
102
|
+
inset-block: 0;
|
|
103
|
+
inset-inline-end: 0;
|
|
104
|
+
z-index: var(--layer-5);
|
|
105
|
+
inline-size: min(var(--pui-page-aside-inline-size), 100vw);
|
|
106
|
+
max-block-size: none;
|
|
107
|
+
padding-block-start: var(--space-s);
|
|
108
|
+
background: var(--bg-clr-surface);
|
|
109
|
+
border-inline-start: var(--stroke-sm) solid
|
|
110
|
+
var(--border-clr-base);
|
|
111
|
+
box-shadow: var(--shadow-spark-lg);
|
|
112
|
+
animation: pui-page-aside-slide-in var(--duration-base)
|
|
113
|
+
var(--ease-out);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@keyframes pui-page-aside-slide-in {
|
|
119
|
+
from {
|
|
120
|
+
transform: translateX(100%);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@media (prefers-reduced-motion: reduce) {
|
|
125
|
+
.pui-page-aside__panel {
|
|
126
|
+
animation: none;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -1,44 +1,4 @@
|
|
|
1
1
|
@layer components {
|
|
2
|
-
.pui-record-layout {
|
|
3
|
-
display: grid;
|
|
4
|
-
grid-template-columns:
|
|
5
|
-
minmax(0, var(--space-sidebar-width))
|
|
6
|
-
minmax(0, 1fr);
|
|
7
|
-
gap: var(--space-m);
|
|
8
|
-
align-items: start;
|
|
9
|
-
min-inline-size: 0;
|
|
10
|
-
|
|
11
|
-
@media (max-width: 56em) {
|
|
12
|
-
grid-template-columns: minmax(0, 1fr);
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
.pui-record-layout__sidebar {
|
|
17
|
-
min-inline-size: 0;
|
|
18
|
-
|
|
19
|
-
& .pui-panel {
|
|
20
|
-
--pui-record-fields-columns: minmax(0, 1fr);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/* Same layer as record-details.css, so this has to outrank its field rule. */
|
|
24
|
-
& .pui-record-details .pui-record-details__field {
|
|
25
|
-
grid-template-columns: minmax(0, 1fr);
|
|
26
|
-
gap: var(--space-3xs);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
@media (min-width: 24em) and (max-width: 56em) {
|
|
30
|
-
& .pui-panel {
|
|
31
|
-
--pui-record-fields-columns: repeat(2, minmax(0, 1fr));
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
.pui-record-layout__main {
|
|
37
|
-
display: grid;
|
|
38
|
-
gap: var(--space-s);
|
|
39
|
-
min-inline-size: 0;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
2
|
.pui-panel {
|
|
43
3
|
--pui-record-fields-columns: minmax(0, 1fr);
|
|
44
4
|
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
display: inline-flex;
|
|
31
31
|
align-items: center;
|
|
32
32
|
gap: var(--space-2xs);
|
|
33
|
+
min-inline-size: 0;
|
|
34
|
+
/* `anywhere` shrinks min-content too, so a long record title wraps instead of widening the trail. */
|
|
35
|
+
overflow-wrap: anywhere;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
.pui-breadcrumb__link {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pienter/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Shared Pienter UI components, styles, icons, and browser utilities.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -63,9 +63,9 @@
|
|
|
63
63
|
"./components/Card.vue": "./components/layout/card/Card.vue",
|
|
64
64
|
"./components/Collapsible.vue": "./components/layout/collapsible/Collapsible.vue",
|
|
65
65
|
"./components/AppLayout.vue": "./components/layout/app-layout/AppLayout.vue",
|
|
66
|
+
"./components/PageAside.vue": "./components/layout/page-aside/PageAside.vue",
|
|
66
67
|
"./components/PageHeader.vue": "./components/layout/page-header/PageHeader.vue",
|
|
67
68
|
"./components/Panel.vue": "./components/layout/record-layout/Panel.vue",
|
|
68
|
-
"./components/RecordLayout.vue": "./components/layout/record-layout/RecordLayout.vue",
|
|
69
69
|
"./components/Separator.vue": "./components/layout/separator/Separator.vue",
|
|
70
70
|
"./components/Table.vue": "./components/layout/table/Table.vue",
|
|
71
71
|
"./components/TableRow.vue": "./components/layout/table/TableRow.vue",
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<div class="pui-record-layout">
|
|
3
|
-
<aside
|
|
4
|
-
v-if="$slots.sidebar"
|
|
5
|
-
class="pui-record-layout__sidebar"
|
|
6
|
-
:aria-labelledby="sidebarLabelledby"
|
|
7
|
-
>
|
|
8
|
-
<slot name="sidebar" />
|
|
9
|
-
</aside>
|
|
10
|
-
<div class="pui-record-layout__main">
|
|
11
|
-
<slot />
|
|
12
|
-
</div>
|
|
13
|
-
</div>
|
|
14
|
-
</template>
|
|
15
|
-
|
|
16
|
-
<script setup lang="ts">
|
|
17
|
-
withDefaults(
|
|
18
|
-
defineProps<{
|
|
19
|
-
/** `id` of the sidebar's heading; names the `<aside>` landmark through `aria-labelledby`. */
|
|
20
|
-
sidebarLabelledby?: string;
|
|
21
|
-
}>(),
|
|
22
|
-
{
|
|
23
|
-
sidebarLabelledby: undefined,
|
|
24
|
-
},
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
defineSlots<{
|
|
28
|
-
sidebar?: () => unknown;
|
|
29
|
-
default?: () => unknown;
|
|
30
|
-
}>();
|
|
31
|
-
</script>
|
|
32
|
-
|
|
33
|
-
<style>
|
|
34
|
-
@import './record-layout.css';
|
|
35
|
-
</style>
|