@pienter/ui 0.13.0 → 0.15.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,84 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.15.0 - 2026-09-15
6
+
7
+ ### Changed
8
+
9
+ - `AppLayout`'s main region no longer has padding. A page's rows carry their
10
+ own gutter (see the page container under Added), so a sticky band sits
11
+ flush against the region's edges without negative margins. Consumer-visible:
12
+ a page that has not adopted `.pui-page` loses its inset. The seams stay,
13
+ defaulting to `0`; one line restores the previous behaviour:
14
+ `.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); }`.
15
+ - `Form` wraps everything that is not the actions row — the error summary,
16
+ the default slot, the status message — in a new `<div class="pui-form__body">`.
17
+ This changes the DOM for every consumer, page-placed or not; selectors and
18
+ tests that assumed the fields were direct children of the `<form>` need the
19
+ extra level. The body inherits the form's `gap`, so a `.pui-form` override
20
+ still spaces the fields.
21
+
22
+ ### Added
23
+
24
+ - `.pui-page`, the page container for `AppLayout`'s default slot: one grid of
25
+ four full-width rows placed by named area — `pui-page__band` (the breadcrumb
26
+ strip), `__header`, `__main` (takes the leftover height) and `__footer`
27
+ (sticky at the region's bottom) — plus an optional `__aside` beside the main
28
+ row: a stretched, size-contained column whose child sticks to the top of the
29
+ scrollport with its own scroll, stacking below the content under 56em. The
30
+ bands span the whole region without knowing the aside exists. A row has one
31
+ occupant: two elements in the same named area overlap.
32
+ - `.pui-gutter`, the one inline padding every page row takes, in the
33
+ `utilities` layer so it replaces a bar's own padding without a specificity
34
+ fight. It reads `--pui-gutter-inline`, `--space-l` on the shell and
35
+ `--space-s` below the 48rem breakpoint.
36
+ - A module `Index` or a `Form` (so a `RecordForm`) that is a direct child of
37
+ `.pui-page` supplies the page's rows itself: its header, body and pagination
38
+ footer, or its body and its actions row as the sticky footer band. Inferred
39
+ from the position through `display: contents`, no prop; a related index and
40
+ any form inside `pui-page__main` are unaffected.
41
+
42
+ ### Fixed
43
+
44
+ - A long record title in a `Breadcrumb` wraps inside its item instead of
45
+ widening the trail.
46
+
47
+ ## 0.14.0 - 2026-09-15
48
+
49
+ ### Changed
50
+
51
+ - `AppLayout`'s main region is now the scrollport. The shell is one viewport
52
+ tall (`100dvh`), `.pui-app-layout__main` takes the height left under the
53
+ header and scrolls with `overflow: auto`, and the document never scrolls.
54
+ The header and navigation rail lose their `position: sticky`; they simply
55
+ stay put. This gives a page a definite height to work against: a sticky
56
+ pagination or action band pins to the region's bottom edge, a `1fr` row
57
+ takes the real leftover space, and a short page no longer grows a scrollbar
58
+ to fit a sticky aside. `overscroll-behavior-block: contain` on the region
59
+ keeps an iOS rubber-band from dragging the whole shell, and a print rule
60
+ (`block-size: auto`, `overflow: visible`) prints the whole page rather than
61
+ one screenful.
62
+
63
+ Consumer-visible: anything that scrolled the window must now scroll the
64
+ region. Vue Router's `scrollBehavior` only scrolls the window, so return
65
+ `false` from it and set `main.scrollTop` yourself on route change (and on
66
+ back, if you restore); move a `scroll-padding-block-end` for sticky bands
67
+ from `html` to `<main>`, and drop any `scroll-padding-block-start` for the
68
+ header, which is now outside the scrollport. A sticky band pins at the
69
+ region's content edge, inside its padding, so a page that ends in one
70
+ sets `--pui-app-layout-main-padding` with a `0` block-end instead of
71
+ pulling the band down with a negative margin. Fragment links to targets
72
+ inside the region keep working natively. Escape hatch, one line:
73
+ `.pui-app-layout { block-size: auto; min-block-size: 100dvh }` makes the
74
+ document the scrollport again (pin the header yourself if you want it
75
+ fixed).
76
+ ### Added
77
+
78
+ - `sidebar-left` and `sidebar-right` icons: a panel outline with the divider on
79
+ the left or the right, for toggling a side column. `sidebar-left` is
80
+ Feather's `sidebar`; `sidebar-right` is its mirror on the same 24px grid.
81
+
82
+
5
83
  ## 0.13.0 - 2026-09-15
6
84
 
7
85
  ### Added
package/CONVENTIONS.md CHANGED
@@ -1424,6 +1424,30 @@ Pinned upstream SHAs used across the table:
1424
1424
  `Index`, `RecordForm`, and `RecordDetails` compose existing primitives. Consumers
1425
1425
  register routes and supply endpoint-specific data functions, rendering and actions.
1426
1426
 
1427
+ They render inside `AppLayout`, whose main region is the scrollport: the shell
1428
+ is `100dvh` tall, `<main>` scrolls, the document does not. Page-level bands
1429
+ (`position: sticky`) and leftover-height rows (`1fr`) inside a page therefore
1430
+ resolve against a definite height. Scroll-to-top on navigation,
1431
+ `scroll-padding` for sticky bands and scroll restoration target that region,
1432
+ not the window; the AppLayout audit lists what a consumer wires.
1433
+
1434
+ The main region has no padding. A page is a `.pui-page` grid of four
1435
+ full-width rows placed by named area — band (`pui-page__band`, the
1436
+ breadcrumb strip), header (`__header`), main (`__main`, the row that takes
1437
+ 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
+ padding from the `.pui-gutter` utility, so the trail, the heading, the table
1440
+ and the pagination summary share one edge and a band is full width because it
1441
+ is a full-width row, not because it escaped an inset. No negative margins, no
1442
+ bleed properties, no component computes a distance. A consumer-written row
1443
+ carries the row class plus `pui-gutter`; a module `Index` or a `Form` that is
1444
+ a direct child of the page supplies its own rows through `display: contents`
1445
+ and needs no prop for it. **A row has one occupant.** Two elements in one
1446
+ named area overlap, silently; a module `Index` already supplies the footer
1447
+ 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.
1450
+
1427
1451
  Index accepts `load(query, { signal })`, `columns` and `queryOptions`. It owns
1428
1452
  query state, rows, metadata, loading, failures and retry. Search is debounced;
1429
1453
  search/filter/sort/page-size changes reset page to 1. New requests and unmount
@@ -1442,7 +1466,9 @@ DataTable's own border, and the pagination footer as a band below. `related` is
1442
1466
  a list inside another record's detail page: one bordered region holding the
1443
1467
  `h2` header, toolbar, table and pagination footer, divided by the subtle border
1444
1468
  token. The heading level follows the placement; there is no heading-level prop.
1445
- An index definition one collection's columns, query options, copy and cell
1469
+ A module index that is a direct child of `.pui-page` supplies the page's
1470
+ header, main and footer rows itself; a related index never does. An index
1471
+ definition — one collection's columns, query options, copy and cell
1446
1472
  slots — is a consumer component composing `Index` and taking `placement` and a
1447
1473
  parent id as props. The library ships no definition type or helper.
1448
1474
 
@@ -6,20 +6,28 @@
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
 
25
33
  <div v-if="!readonly" class="pui-form__actions">
@@ -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 > * {
60
+ padding-inline: var(--pui-gutter-inline, var(--space-l));
61
+ }
26
62
  }
@@ -1,8 +1,10 @@
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
- min-block-size: 100vh;
7
+ block-size: 100dvh;
6
8
 
7
9
  & .pui-app-layout__skip-link {
8
10
  position: fixed;
@@ -33,9 +35,6 @@
33
35
  }
34
36
 
35
37
  & .pui-app-layout__header {
36
- position: sticky;
37
- inset-block-start: 0;
38
- z-index: var(--layer-4);
39
38
  display: flex;
40
39
  align-items: center;
41
40
  gap: var(--space-s);
@@ -60,6 +59,7 @@
60
59
  & .pui-app-layout__body {
61
60
  display: grid;
62
61
  grid-template-columns: auto minmax(0, 1fr);
62
+ grid-template-rows: minmax(0, 1fr);
63
63
  min-block-size: 0;
64
64
  }
65
65
 
@@ -69,12 +69,13 @@
69
69
 
70
70
  & .pui-app-layout__main {
71
71
  display: grid;
72
- grid-template-rows: minmax(0, 1fr);
72
+ /* minmax(auto, 1fr): a page fills the region and grows past it. */
73
+ grid-template-rows: 1fr;
73
74
  min-inline-size: 0;
74
- padding: var(
75
- --pui-app-layout-main-padding,
76
- var(--space-m) var(--space-l) var(--space-xl)
77
- );
75
+ min-block-size: 0;
76
+ overflow: auto;
77
+ overscroll-behavior-block: contain;
78
+ padding: var(--pui-app-layout-main-padding, 0);
78
79
 
79
80
  &:focus:not(:focus-visible) {
80
81
  outline: none;
@@ -91,25 +92,133 @@
91
92
  flex-shrink: 0;
92
93
  }
93
94
 
94
- @media (min-width: 48em) {
95
- & .pui-app-layout__navigation {
96
- position: sticky;
97
- inset-block-start: var(--space-2xl);
98
- block-size: calc(100vh - var(--space-2xl));
99
- }
100
- }
101
-
102
95
  @media (max-width: 47.999em) {
96
+ --pui-gutter-inline: var(--space-s);
97
+
103
98
  & .pui-app-layout__drawer-toggle {
104
99
  display: inline-flex;
105
100
  }
106
101
 
107
102
  & .pui-app-layout__main {
108
- padding: var(
109
- --pui-app-layout-main-padding-mobile,
110
- var(--space-s)
111
- );
103
+ padding: var(--pui-app-layout-main-padding-mobile, 0);
104
+ }
105
+ }
106
+
107
+ @media print {
108
+ block-size: auto;
109
+
110
+ & .pui-app-layout__main {
111
+ overflow: visible;
112
112
  }
113
113
  }
114
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
+ &:has(> .pui-page__aside) {
136
+ grid-template-areas:
137
+ 'pui-page-band pui-page-band'
138
+ 'pui-page-header pui-page-header'
139
+ 'pui-page-main pui-page-aside'
140
+ '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
+ }
157
+ }
158
+ }
159
+
160
+ .pui-page__band {
161
+ grid-area: pui-page-band;
162
+ padding-block: var(--space-xs);
163
+ background: var(--bg-clr-surface);
164
+ border-block-end: var(--stroke-sm) solid var(--border-clr-subtle);
165
+ }
166
+
167
+ .pui-page__header {
168
+ grid-area: pui-page-header;
169
+ }
170
+
171
+ .pui-page__main {
172
+ grid-area: pui-page-main;
173
+ }
174
+
175
+ .pui-page__footer {
176
+ grid-area: pui-page-footer;
177
+ position: sticky;
178
+ inset-block-end: 0;
179
+ z-index: var(--layer-2);
180
+ padding-block: var(--space-xs);
181
+ background: var(--bg-clr-surface);
182
+ border-block-start: var(--stroke-sm) solid var(--border-clr-subtle);
183
+ }
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
+ }
217
+
218
+ /* A utility outranks a component's own padding, so a bar that ships its own
219
+ `padding-inline` lines up when it becomes a page row. */
220
+ @layer utilities {
221
+ .pui-gutter {
222
+ padding-inline: var(--pui-gutter-inline, var(--space-l));
223
+ }
115
224
  }
@@ -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
  }
@@ -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/icons/index.ts CHANGED
@@ -41,6 +41,8 @@ import send from './send.js';
41
41
  import settings from './settings.js';
42
42
  import tool from './tool.js';
43
43
  import trash from './trash.js';
44
+ import sidebarLeft from './sidebar-left.js';
45
+ import sidebarRight from './sidebar-right.js';
44
46
 
45
47
  export const icons = {
46
48
  info: info,
@@ -86,6 +88,8 @@ export const icons = {
86
88
  settings: settings,
87
89
  tool: tool,
88
90
  trash: trash,
91
+ 'sidebar-left': sidebarLeft,
92
+ 'sidebar-right': sidebarRight,
89
93
  } as const;
90
94
 
91
95
  export type IconName = keyof typeof icons;
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M9 3v18"/></svg>';
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M15 3v18"/></svg>';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pienter/ui",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Shared Pienter UI components, styles, icons, and browser utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",