@pienter/ui 0.14.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,48 @@
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
+
5
47
  ## 0.14.0 - 2026-09-15
6
48
 
7
49
  ### Changed
package/CONVENTIONS.md CHANGED
@@ -1431,6 +1431,23 @@ resolve against a definite height. Scroll-to-top on navigation,
1431
1431
  `scroll-padding` for sticky bands and scroll restoration target that region,
1432
1432
  not the window; the AppLayout audit lists what a consumer wires.
1433
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
+
1434
1451
  Index accepts `load(query, { signal })`, `columns` and `queryOptions`. It owns
1435
1452
  query state, rows, metadata, loading, failures and retry. Search is debounced;
1436
1453
  search/filter/sort/page-size changes reset page to 1. New requests and unmount
@@ -1449,7 +1466,9 @@ DataTable's own border, and the pagination footer as a band below. `related` is
1449
1466
  a list inside another record's detail page: one bordered region holding the
1450
1467
  `h2` header, toolbar, table and pagination footer, divided by the subtle border
1451
1468
  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
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
1453
1472
  slots — is a consumer component composing `Index` and taking `placement` and a
1454
1473
  parent id as props. The library ships no definition type or helper.
1455
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,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,113 @@
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
+ &: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
+ }
117
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pienter/ui",
3
- "version": "0.14.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",