@pienter/ui 0.15.0 → 0.17.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 +59 -0
- package/CONVENTIONS.md +27 -14
- package/components/display/record-details/record-details.css +8 -0
- package/components/form/form/Form.vue +2 -0
- package/components/form/form/form.css +1 -1
- package/components/form/record-form/RecordForm.vue +3 -0
- package/components/layout/app-layout/app-layout.css +4 -49
- 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/package.json +2 -2
- package/utils/cms/index.ts +13 -0
- package/components/layout/record-layout/RecordLayout.vue +0 -35
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,65 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.17.0 - 2026-09-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `splitFilterValues` and `joinFilterValues` in `@pienter/ui/utils/cms`, for
|
|
10
|
+
the multi-value filter convention the backend contract now documents: a
|
|
11
|
+
filter such as `filter[type]=1,2` carries its values as one comma-separated
|
|
12
|
+
string of identifiers or enum tokens, and a numeric range is two ordinary
|
|
13
|
+
allowlisted filters, `<name>_min` and `<name>_max`. Both stay one string per
|
|
14
|
+
`filter[name]`, so `decodeListQuery`, `encodeListQuery`, the URL sync and
|
|
15
|
+
the allowlist are unchanged. `splitFilterValues('')` is `[]` and
|
|
16
|
+
`joinFilterValues([])` is `undefined`, so clearing deletes the parameter
|
|
17
|
+
rather than sending an empty string. See ADR 0009.
|
|
18
|
+
|
|
19
|
+
## 0.16.0 - 2026-09-15
|
|
20
|
+
|
|
21
|
+
### Breaking
|
|
22
|
+
|
|
23
|
+
- `RecordLayout` is retired, one release after it shipped: with the page
|
|
24
|
+
container providing the main column and the new `PageAside` taking the
|
|
25
|
+
sidebar's place on the right, nothing of it was left but a gap. `Panel`
|
|
26
|
+
stays. Migration: drop the `RecordLayout` wrapper and its `sidebar` slot,
|
|
27
|
+
render the main column's `Tabs` and `Panel`s directly in `RecordForm`'s
|
|
28
|
+
`fields` slot, and move the sidebar `Panel`'s children into
|
|
29
|
+
`<PageAside :title="…">` in `RecordForm`'s new `aside` slot (the title
|
|
30
|
+
comes from the panel; `sidebar-labelledby` and `title-id` go, the aside
|
|
31
|
+
names itself). The `./components/RecordLayout.vue` export is gone.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- The page container no longer stacks the aside below the main row under
|
|
36
|
+
56em. `PageAside` owns that breakpoint: the column becomes a rail at the
|
|
37
|
+
right edge and the toggle opens the content as a drawer. The aside track is
|
|
38
|
+
now `auto` and the component sets its own width, so a consumer-written
|
|
39
|
+
`<aside class="pui-page__aside">` without the component gets no size; use
|
|
40
|
+
`PageAside`.
|
|
41
|
+
- `RecordDetails` stacks each label over its value when its own container is
|
|
42
|
+
narrower than 24rem, so it reads in a `PageAside` or any other narrow
|
|
43
|
+
column without the consumer overriding its grid.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- `PageAside`, the right band of a CMS page: filters beside a module index,
|
|
48
|
+
settings beside a record form. `title` (required; heads the column and
|
|
49
|
+
names the landmark), `description`, a default slot, `toggle` (default on)
|
|
50
|
+
with `toggle-label`, and `v-model:open` (default open). Open, it is the
|
|
51
|
+
sticky, self-scrolling aside column with a flat header and a left rule;
|
|
52
|
+
closed, a rail one toggle wide in the same column, its content `hidden`
|
|
53
|
+
but mounted so fields in the band still submit; below 56em the rail stays
|
|
54
|
+
and the toggle opens a drawer from the right with the Sidebar drawer's
|
|
55
|
+
mechanics (backdrop, focus trap, Escape, the same toggle closing it). The
|
|
56
|
+
toggle keeps the `sidebar-right` icon in both states and carries the state
|
|
57
|
+
in `aria-expanded` and its name, "Hide {title}" / "Show {title}".
|
|
58
|
+
- `Form` has an `aside` slot rendered as a direct child of `<form>`, between
|
|
59
|
+
the body and the actions row, and `RecordForm` forwards it. A `PageAside`
|
|
60
|
+
in it becomes the page's aside column through the form's `display:
|
|
61
|
+
contents` while its fields keep their form owner. The page's `:has()`
|
|
62
|
+
switch matches that path too.
|
|
63
|
+
|
|
5
64
|
## 0.15.0 - 2026-09-15
|
|
6
65
|
|
|
7
66
|
### 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) |
|
|
@@ -1435,7 +1436,7 @@ The main region has no padding. A page is a `.pui-page` grid of four
|
|
|
1435
1436
|
full-width rows placed by named area — band (`pui-page__band`, the
|
|
1436
1437
|
breadcrumb strip), header (`__header`), main (`__main`, the row that takes
|
|
1437
1438
|
the leftover height) and footer (`__footer`, sticky at the region's bottom) —
|
|
1438
|
-
plus
|
|
1439
|
+
plus the optional `PageAside` beside the main row. Every row takes its inline
|
|
1439
1440
|
padding from the `.pui-gutter` utility, so the trail, the heading, the table
|
|
1440
1441
|
and the pagination summary share one edge and a band is full width because it
|
|
1441
1442
|
is a full-width row, not because it escaped an inset. No negative margins, no
|
|
@@ -1445,8 +1446,18 @@ a direct child of the page supplies its own rows through `display: contents`
|
|
|
1445
1446
|
and needs no prop for it. **A row has one occupant.** Two elements in one
|
|
1446
1447
|
named area overlap, silently; a module `Index` already supplies the footer
|
|
1447
1448
|
row, so a page with one does not add its own. Anything that is not a row goes
|
|
1448
|
-
inside `pui-page__main`. The AppLayout audit holds the
|
|
1449
|
-
|
|
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.
|
|
1450
1461
|
|
|
1451
1462
|
Index accepts `load(query, { signal })`, `columns` and `queryOptions`. It owns
|
|
1452
1463
|
query state, rows, metadata, loading, failures and retry. Search is debounced;
|
|
@@ -1494,12 +1505,14 @@ custom sidebar/tab layouts. RecordFields takes the same draft and renders a
|
|
|
1494
1505
|
subset, with `--pui-record-fields-columns` as its grid seam. It never renders a
|
|
1495
1506
|
form element. Callers expose the relevant tab when backend issues arrive.
|
|
1496
1507
|
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
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.
|
|
1503
1516
|
|
|
1504
1517
|
BlockEditor owns an ordered array of stable `id`/`type` blocks and emits updates
|
|
1505
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
|
}
|
|
@@ -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">
|
|
@@ -132,28 +132,15 @@
|
|
|
132
132
|
gap: var(--space-s) 0;
|
|
133
133
|
min-inline-size: 0;
|
|
134
134
|
|
|
135
|
-
|
|
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) {
|
|
136
138
|
grid-template-areas:
|
|
137
139
|
'pui-page-band pui-page-band'
|
|
138
140
|
'pui-page-header pui-page-header'
|
|
139
141
|
'pui-page-main pui-page-aside'
|
|
140
142
|
'pui-page-footer pui-page-footer';
|
|
141
|
-
grid-template-columns:
|
|
142
|
-
minmax(0, 1fr)
|
|
143
|
-
var(--pui-page-aside-inline-size);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
@media (max-width: 56em) {
|
|
147
|
-
&:has(> .pui-page__aside) {
|
|
148
|
-
grid-template-areas:
|
|
149
|
-
'pui-page-band'
|
|
150
|
-
'pui-page-header'
|
|
151
|
-
'pui-page-main'
|
|
152
|
-
'pui-page-aside'
|
|
153
|
-
'pui-page-footer';
|
|
154
|
-
grid-template-rows: auto auto 1fr auto auto;
|
|
155
|
-
grid-template-columns: minmax(0, 1fr);
|
|
156
|
-
}
|
|
143
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
157
144
|
}
|
|
158
145
|
}
|
|
159
146
|
|
|
@@ -181,38 +168,6 @@
|
|
|
181
168
|
background: var(--bg-clr-surface);
|
|
182
169
|
border-block-start: var(--stroke-sm) solid var(--border-clr-subtle);
|
|
183
170
|
}
|
|
184
|
-
|
|
185
|
-
.pui-page__aside {
|
|
186
|
-
grid-area: pui-page-aside;
|
|
187
|
-
/* Size containment: the column is as tall as the content row and never
|
|
188
|
-
the other way round, so a long aside cannot make a short page scroll. */
|
|
189
|
-
contain: size;
|
|
190
|
-
border-inline-start: var(--stroke-sm) solid var(--border-clr-subtle);
|
|
191
|
-
|
|
192
|
-
/* The child sticks, not the column; `block-size: 100%` here would
|
|
193
|
-
defeat sticky. `100%` is the content row, which is exact on a short
|
|
194
|
-
page; the viewport term caps a long one at the scrollport. */
|
|
195
|
-
& > * {
|
|
196
|
-
position: sticky;
|
|
197
|
-
inset-block-start: 0;
|
|
198
|
-
max-block-size: min(100%, calc(100dvh - var(--space-2xl)));
|
|
199
|
-
overflow-y: auto;
|
|
200
|
-
overscroll-behavior: contain;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
@media (max-width: 56em) {
|
|
204
|
-
contain: none;
|
|
205
|
-
border-inline-start: 0;
|
|
206
|
-
border-block-start: var(--stroke-sm) solid var(--border-clr-subtle);
|
|
207
|
-
padding-block-start: var(--space-s);
|
|
208
|
-
|
|
209
|
-
& > * {
|
|
210
|
-
position: static;
|
|
211
|
-
max-block-size: none;
|
|
212
|
-
overflow: visible;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
171
|
}
|
|
217
172
|
|
|
218
173
|
/* A utility outranks a component's own padding, so a bar that ships its own
|
|
@@ -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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pienter/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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",
|
package/utils/cms/index.ts
CHANGED
|
@@ -205,6 +205,19 @@ export function encodeListQuery(query: ListQuery): URLSearchParams {
|
|
|
205
205
|
return params;
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
/** Values of a multi-value filter, from its comma-separated wire string. */
|
|
209
|
+
export function splitFilterValues(value: string | undefined): string[] {
|
|
210
|
+
return value ? value.split(',').filter(Boolean) : [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The wire string of a multi-value filter; `undefined` clears the parameter. */
|
|
214
|
+
export function joinFilterValues(
|
|
215
|
+
values: readonly string[],
|
|
216
|
+
): string | undefined {
|
|
217
|
+
const joined = values.filter(Boolean).join(',');
|
|
218
|
+
return joined || undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
208
221
|
export function cloneRecord<T>(record: T): T {
|
|
209
222
|
return JSON.parse(JSON.stringify(record)) as T;
|
|
210
223
|
}
|
|
@@ -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>
|