@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 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. Today this duplication exists in exactly one place
844
- (Sidebar). When **a second non-`<dialog>` overlay** lands with the
845
- same mechanics Toast's auto-dismissing mode is a candidate, if it
846
- ends up needing them review whether to extract a shared
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 an optional `__aside` beside the main row. Every row takes its inline
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
- details, the aside included.
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
- RecordLayout and Panel give a record page its shape (`CONTEXT.md`): a sidebar
1498
- `aside` named through `sidebar-labelledby` beside a main column that stacks
1499
- below 56em, and one bordered section per field group or tab with its heading
1500
- inside it. Panel honours `hidden`, so a consumer toggles tab panels with the
1501
- attribute, and drops its chrome around a direct `BlockEditor` child. Both set
1502
- `--pui-record-fields-columns` for the RecordFields inside them.
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
  }
@@ -30,6 +30,8 @@
30
30
  </div>
31
31
  </div>
32
32
 
33
+ <slot name="aside" />
34
+
33
35
  <div v-if="!readonly" class="pui-form__actions">
34
36
  <slot name="actions">
35
37
  <button
@@ -56,7 +56,7 @@
56
56
  }
57
57
 
58
58
  @layer utilities {
59
- .pui-page > .pui-form > * {
59
+ .pui-page > .pui-form > :is(.pui-form__body, .pui-form__actions) {
60
60
  padding-inline: var(--pui-gutter-inline, var(--space-l));
61
61
  }
62
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">
@@ -132,28 +132,15 @@
132
132
  gap: var(--space-s) 0;
133
133
  min-inline-size: 0;
134
134
 
135
- &:has(> .pui-page__aside) {
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.15.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",
@@ -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>