@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 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. 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) |
@@ -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
- An index definition one collection's columns, query options, copy and cell
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
- RecordLayout and Panel give a record page its shape (`CONTEXT.md`): a sidebar
1479
- `aside` named through `sidebar-labelledby` beside a main column that stacks
1480
- below 56em, and one bordered section per field group or tab with its heading
1481
- inside it. Panel honours `hidden`, so a consumer toggles tab panels with the
1482
- attribute, and drops its chrome around a direct `BlockEditor` child. Both set
1483
- `--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.
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
- <Alert
10
- v-if="topErrors.length > 0"
11
- tone="danger"
12
- title="There was a problem"
13
- >
14
- <ul class="pui-form__errors">
15
- <li v-for="error in topErrors" :key="error">{{ error }}</li>
16
- </ul>
17
- </Alert>
18
-
19
- <slot />
20
-
21
- <div v-if="statusMessage" class="pui-form__status" aria-live="polite">
22
- {{ statusMessage }}
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.14.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>