@apliteni/apliteni-ui 0.26.0 → 0.30.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.
Files changed (48) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/react/README.md +111 -2
  4. package/react/dist/index.css +11 -13
  5. package/react/dist/index.d.ts +111 -4
  6. package/react/dist/index.js +811 -167
  7. package/src/components/back.js +57 -0
  8. package/src/components/command-palette.js +597 -0
  9. package/src/components/confirm.js +3 -2
  10. package/src/components/drawer.js +31 -2
  11. package/src/components/dropdown.js +192 -17
  12. package/src/components/feedback.js +2 -2
  13. package/src/components/index.js +5 -2
  14. package/src/components/loading.js +3 -2
  15. package/src/components/nav.js +15 -7
  16. package/src/components/overlay.js +67 -14
  17. package/src/components/pagination.js +344 -0
  18. package/src/components/shell.js +11 -2
  19. package/src/components/tabs.js +7 -1
  20. package/src/components/tooltip.js +237 -0
  21. package/src/components/topbar.js +9 -4
  22. package/src/index.css +4 -0
  23. package/src/index.js +4 -0
  24. package/src/inline.js +8 -0
  25. package/src/motion.js +43 -2
  26. package/src/styles/back.css +42 -0
  27. package/src/styles/badge.css +5 -7
  28. package/src/styles/base.css +8 -7
  29. package/src/styles/button.css +14 -0
  30. package/src/styles/card.css +12 -8
  31. package/src/styles/code.css +3 -4
  32. package/src/styles/command-palette.css +321 -0
  33. package/src/styles/confirm.css +11 -11
  34. package/src/styles/drawer.css +57 -15
  35. package/src/styles/dropdown.css +70 -11
  36. package/src/styles/feedback.css +2 -0
  37. package/src/styles/footer.css +3 -4
  38. package/src/styles/input.css +7 -1
  39. package/src/styles/layout.css +3 -2
  40. package/src/styles/loading.css +5 -0
  41. package/src/styles/nav.css +12 -8
  42. package/src/styles/pagination.css +124 -0
  43. package/src/styles/success.css +3 -2
  44. package/src/styles/table.css +4 -5
  45. package/src/styles/tabs.css +3 -0
  46. package/src/styles/tooltip.css +65 -0
  47. package/src/styles/topbar.css +3 -4
  48. package/src/tokens/tokens.css +14 -8
@@ -4,10 +4,15 @@
4
4
  // properties of the *page*, so they are answered here from one stack per document rather
5
5
  // than from either component's own storage. Internal — not re-exported from src/index.js.
6
6
 
7
- // What each overlay paints on today: a drawer at `--z-overlay` (styles/drawer.css) and a
8
- // confirm above it (styles/confirm.css). Absolute values, not ranks, so a sheet that moves
9
- // and a table that did not is a failed test stories/overlay-css.test.js holds both.
10
- export const OVERLAY_LAYER = { drawer: 100, confirm: 101 };
7
+ // What each overlay paints on today: a drawer at `--z-overlay` (styles/drawer.css), a
8
+ // command palette one above it (styles/command-palette.css) and a confirm above both
9
+ // (styles/confirm.css). Three steps and not two, because at equal levels paint order falls
10
+ // back to document order and the overlay that owns the keyboard is then not reliably the
11
+ // one the reader can see. A palette is summoned deliberately and must be seen, so it goes
12
+ // over a drawer that was already open; a confirm is a question about whatever is under it,
13
+ // so it goes over both. Absolute values, not ranks, so a sheet that moves and a table that
14
+ // did not is a failed test — stories/overlay-css.test.js holds all three.
15
+ export const OVERLAY_LAYER = { drawer: 100, palette: 101, confirm: 102 };
11
16
 
12
17
  const FOCUSABLE = [
13
18
  'a[href]', 'button:not([disabled])', 'input:not([disabled])',
@@ -32,7 +37,8 @@ function pageOf(doc) {
32
37
  function reachable(el) {
33
38
  for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
34
39
  if (n.inert || n.hasAttribute('inert') || n.hasAttribute('hidden')) return false;
35
- const overlayRoot = n.hasAttribute('data-drawer') || n.hasAttribute('data-confirm');
40
+ const overlayRoot = n.hasAttribute('data-drawer') || n.hasAttribute('data-confirm')
41
+ || n.hasAttribute('data-cmdk');
36
42
  if (overlayRoot && !n.classList.contains('is-open')) return false;
37
43
  }
38
44
  // Browsers can answer the rest properly; JSDOM has no layout and no such method.
@@ -108,7 +114,7 @@ const PRECEDING = 2;
108
114
 
109
115
  // One way onto the stack. `where` picks the slot; everything after it — the
110
116
  // duplicate guard, the key owner, the recompute — is the same either way. Every
111
- // entry carries its layer, so the comparisons in adoptOverlay never meet undefined.
117
+ // entry carries its layer, so the comparisons in the slot pickers never meet undefined.
112
118
  function place(root, panel, dismiss, layer, where) {
113
119
  const doc = root.ownerDocument;
114
120
  const page = pageOf(doc);
@@ -130,14 +136,23 @@ function paintedLayer(root, layer) {
130
136
  }
131
137
 
132
138
  /**
133
- * Put an overlay on top of the page. `dismiss` is what Escape calls — pass null
134
- * for one that refuses to be dismissed, and Escape then does nothing rather than
135
- * falling through to the overlay underneath. This one goes on top whatever layer
136
- * it paints on, because opening is history the stack can order by: the thing just
137
- * opened is the thing the reader is looking at.
139
+ * Put an overlay on the page. `dismiss` is what Escape calls — pass null for one
140
+ * that refuses to be dismissed, and Escape then does nothing rather than falling
141
+ * through to the overlay underneath.
142
+ *
143
+ * It goes on top of everything it paints over, and under anything painted above
144
+ * it. Opening is history the stack can order by, but only within a layer: an
145
+ * overlay opened under one already on screen — a drawer opened from a palette
146
+ * row, a palette opened while a confirm is up — is not the one the reader is
147
+ * looking at, and giving it Escape and the Tab trap would put the keyboard on a
148
+ * surface that is covered.
138
149
  */
139
150
  export function pushOverlay(root, panel, dismiss, layer) {
140
- place(root, panel, dismiss, layer, (page) => page.stack.length);
151
+ const level = paintedLayer(root, layer);
152
+ place(root, panel, dismiss, level, (page) => {
153
+ const at = page.stack.findIndex((e) => e.layer > level);
154
+ return at === -1 ? page.stack.length : at;
155
+ });
141
156
  }
142
157
 
143
158
  /**
@@ -157,13 +172,45 @@ export function adoptOverlay(root, panel, dismiss, layer) {
157
172
  });
158
173
  }
159
174
 
160
- /** Take an overlay off the page, wherever in the stack it sits. */
161
- export function popOverlay(root) {
175
+ // Where an overlay puts focus when it opens: the first stop inside its panel,
176
+ // else the panel itself. One rule serves all three, because the kit's own markup
177
+ // puts each one's opening target first — a drawer's first control, the palette's
178
+ // text box, and the confirm's safe answer, which its panel renders before the
179
+ // destructive one.
180
+ function initialFocus(panel) {
181
+ return (panel && focusablesIn(panel)[0]) || panel;
182
+ }
183
+
184
+ // What `el` can actually be handed focus, or null when nothing can take it. An
185
+ // overlay stays open while the page carries on, so by the time it closes the
186
+ // element it came from may be detached — look for whatever inherited its identity
187
+ // in the re-render — or sitting in a subtree that a lower overlay has just made
188
+ // inert again, where focus() is a silent no-op. <body> is null too: with no
189
+ // tabindex it cannot be focused either, and `activeElement === body` is what
190
+ // having no focus looks like, which is what an overlay summoned by its hotkey out
191
+ // of a page nobody had touched yet records as the place it came from.
192
+ function focusTarget(el, doc) {
193
+ const live = el && !el.isConnected && el.id ? doc?.getElementById(el.id) : el;
194
+ if (!live || !live.isConnected || live === live.ownerDocument.body) return null;
195
+ return reachable(live) && typeof live.focus === 'function' ? live : null;
196
+ }
197
+
198
+ /**
199
+ * Take an overlay off the page, wherever in the stack it sits. `opener` is what
200
+ * the caller is about to hand focus back to, which this has to see: one overlay
201
+ * can close over another that is still open, and the opener is then out on a page
202
+ * that lower overlay is holding inert. Nobody would place focus at all, and the
203
+ * reader would be left on <body> looking at a panel that holds neither the
204
+ * keyboard nor the Tab trap — so open the panel now on top where it opens.
205
+ */
206
+ export function popOverlay(root, opener) {
162
207
  const doc = root.ownerDocument;
163
208
  const page = pageOf(doc);
164
209
  const at = page.stack.findIndex((e) => e.root === root);
165
210
  if (at !== -1) page.stack.splice(at, 1);
166
211
  sync(doc);
212
+ const top = page.stack[page.stack.length - 1];
213
+ if (top && opener && !focusTarget(opener, doc)) initialFocus(top.panel)?.focus();
167
214
  }
168
215
 
169
216
  /**
@@ -180,8 +227,14 @@ export function syncOverlays(doc = document) {
180
227
  * the element it came from may be detached by now — and focus() on a detached
181
228
  * node is a silent no-op that leaves the reader with no place on the page. Look
182
229
  * for whatever inherited its identity in the re-render, then give up to the page.
230
+ *
231
+ * With another overlay still open, the only place worth having focus is inside
232
+ * it: an opener that overlay is holding inert cannot take focus at all, popOverlay
233
+ * has already opened the panel now on top for exactly that case, and <body> behind
234
+ * a live modal is not somewhere to give up to.
183
235
  */
184
236
  export function returnFocus(el, doc) {
237
+ if (doc && pageOf(doc).stack.length) { focusTarget(el, doc)?.focus(); return; }
185
238
  const live = el && !el.isConnected && el.id ? doc?.getElementById(el.id) : el;
186
239
  if (live && live.isConnected && typeof live.focus === 'function') { live.focus(); return; }
187
240
  doc?.body?.focus?.();
@@ -0,0 +1,344 @@
1
+ // Pagination — the strip under a table or a list, as an HTML string.
2
+ //
3
+ // It renders a page the CALLER computed. Rows never come in here: `page`,
4
+ // `pageSize` and `total` are numbers, so a page counted by a server and a page
5
+ // sliced out of an array in memory produce the same markup.
6
+ //
7
+ // `total: null` is the honest shape for an API that cannot count what it has not
8
+ // fetched. The component then knows no last page, so it draws Prev and Next and
9
+ // nothing else — see `ui-pager--open` below.
10
+ import { esc } from './index.js';
11
+
12
+ // The sizes a table offers, and the one it starts on. Named here so no call site
13
+ // writes either number: both moved once already and would have moved in thirteen
14
+ // files. MUI's DataGrid ships exactly this pair (default 100, options 25/50/100),
15
+ // AG Grid defaults to 100, and the finance portal's two data-heavy surfaces
16
+ // already page at 100. A 250 step was in the first draft of this component and
17
+ // no surveyed kit offers one, so it went rather than being invented.
18
+ export const PAGE_SIZES = [25, 50, 100];
19
+ export const DEFAULT_PAGE_SIZE = 100;
20
+
21
+ // The classes button({ variant: 'ghost', size: 'sm' }) emits, written out because
22
+ // button() takes no extra class and every control here needs one of its own for
23
+ // the React component to key on. src/components/pagination.test.js asserts the
24
+ // two agree, so a change to button()'s class list fails there instead of drifting.
25
+ const GHOST_SM = 'ui-btn ui-btn--ghost ui-btn--sm';
26
+
27
+ const VARIANTS = ['steps', 'numbered', 'jump'];
28
+
29
+ // The id seeds a <label for> and the control it names, so two pagers that share
30
+ // one are two labels pointing at one control: the second table's "Rows" label
31
+ // focuses the FIRST table's select, and the second select has no accessible name
32
+ // at all. `tabs()` states the same requirement in words and leaves it there; a
33
+ // pager is dropped under a table by a caller who is not thinking about ids, and
34
+ // two tables on a page is the ordinary case rather than the exotic one. So an
35
+ // omitted id is unique by construction instead. A caller who needs a stable id —
36
+ // a server rendering the same page twice, a test — passes one.
37
+ let seq = 0;
38
+ const cx = (...a) => a.filter(Boolean).join(' ');
39
+
40
+ // Every number here arrives from a URL in real use — `?page=-2`, `?page=abc`,
41
+ // `?page=` — so each is coerced before it is clamped, and nothing that is not a
42
+ // finite integer reaches the markup.
43
+ //
44
+ // `Number()` alone is not that check, and the first draft of this used it. It
45
+ // reads '', ' ', null, [] and false as 0, all of which are finite: `?total=` then
46
+ // meant "this result holds zero rows" and erased the whole component, and
47
+ // `?pageSize=` meant one row per page. Only a number, or a string with something
48
+ // in it, is a number here. A Symbol is refused rather than thrown on — a string
49
+ // factory that raises a TypeError is a worse answer than a pager.
50
+ //
51
+ // The cap is Number.MAX_SAFE_INTEGER because above it arithmetic stops moving:
52
+ // `at - 1 === at === at + 1`, so Prev and Next would carry the same target while
53
+ // both rendered live, and a page past 1e21 prints as `1e+21`, which parseInt reads
54
+ // back as 1.
55
+ const CAP = Number.MAX_SAFE_INTEGER;
56
+ const int = (v, fallback) => {
57
+ const raw = typeof v === 'number' ? v
58
+ : (typeof v === 'string' && v.trim() !== '') ? Number(v)
59
+ : NaN;
60
+ if (!Number.isFinite(raw)) return fallback;
61
+ return Math.min(Math.max(Math.trunc(raw), -CAP), CAP);
62
+ };
63
+ const fmt = (n) => n.toLocaleString('en-US');
64
+
65
+ // A control at an end is DISABLED, never removed. Polaris states the rule as
66
+ // "Hint when merchants are at the first or the last page by disabling the
67
+ // corresponding button": removing it slides the next control sideways under a
68
+ // pointer already travelling toward it, and takes away the only evidence a
69
+ // screen-reader user has that they are at the start.
70
+ //
71
+ // An <a> has no disabled state, so an end that is off renders as
72
+ // <button disabled> even when `href` was given — a link that goes nowhere reads
73
+ // as available right up until it is followed.
74
+ function control({ cls, page, label, href, disabled = false, current = false }) {
75
+ const attrs = `class="${cls}" data-page="${page}"${current ? ' aria-current="page"' : ''}`;
76
+ return href && !disabled
77
+ ? `<a href="${esc(href(page))}" ${attrs}>${esc(label)}</a>`
78
+ : `<button type="button" ${attrs}${disabled ? ' disabled aria-disabled="true"' : ''}>${esc(label)}</button>`;
79
+ }
80
+
81
+ /**
82
+ * The page numbers a `numbered` pager shows, with `null` where a run was cut.
83
+ *
84
+ * Seven slots at most: page 1, the last page, the current page with one
85
+ * neighbour each side, and a gap for each run removed between them. Two rules
86
+ * keep the strip honest — no two gaps side by side, and no gap standing in for a
87
+ * single page, because an ellipsis hiding one number is wider than the number
88
+ * and costs a click to find out what it was.
89
+ */
90
+ function slotsFor(page, pageCount) {
91
+ const wanted = [1, pageCount, page - 1, page, page + 1]
92
+ .filter((n) => n >= 1 && n <= pageCount);
93
+ const shown = [...new Set(wanted)].sort((a, b) => a - b);
94
+ const out = [];
95
+ for (const n of shown) {
96
+ const prev = out.length ? out[out.length - 1] : null;
97
+ if (prev != null) {
98
+ if (n - prev === 2) out.push(prev + 1); // one page hidden — draw it, not a gap
99
+ else if (n - prev > 2) out.push(null);
100
+ }
101
+ out.push(n);
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * pagination({ … }) → the <nav> a caller drops under a table.
108
+ *
109
+ * A pager for a single page draws no steps: GOV.UK's guidance is "Do not show
110
+ * pagination if there's only one page of content", and the kit was rendering
111
+ * two permanently dead buttons under every short table. With no size control to
112
+ * offer, such a pager has no job at all and returns the empty string; with one,
113
+ * the <nav> stays for the size control alone — somebody looking at 25 of 25 rows
114
+ * may still want 100 per page — and carries `ui-pager--single`.
115
+ */
116
+ export function pagination({
117
+ page = 1,
118
+ pageSize = DEFAULT_PAGE_SIZE,
119
+ total = null,
120
+ hasMore = false,
121
+ pageSizes = null,
122
+ variant = 'steps',
123
+ label = 'Pagination',
124
+ loading = false,
125
+ href = null,
126
+ id,
127
+ } = {}) {
128
+ const uid = esc(String(id ?? `pager-${++seq}`));
129
+ const kind = VARIANTS.includes(variant) ? variant : 'steps';
130
+ // A size of zero or less is not a size, so it is read as absent rather than
131
+ // clamped to 1 — clamping turns `?pageSize=0` into 4,812 pages of one row.
132
+ const asked = int(pageSize, DEFAULT_PAGE_SIZE);
133
+ const size = asked > 0 ? asked : DEFAULT_PAGE_SIZE;
134
+ // A total that is not a number is a total nobody knows — the open shape is the
135
+ // truthful answer to it, and it is the same answer `total: null` asks for.
136
+ const asRows = int(total, null);
137
+ const counted = total != null && asRows !== null;
138
+ const rows = counted ? Math.max(0, asRows) : null;
139
+ const last = counted ? Math.max(1, Math.ceil(rows / size)) : null;
140
+ // One below the cap when nothing is counted, because the open shape emits
141
+ // `at + 1` as Next's target and MAX_SAFE_INTEGER + 1 is not representable — it
142
+ // rounds back onto its neighbour, so Prev and Next would carry one page again.
143
+ const at = counted
144
+ ? Math.min(Math.max(1, int(page, 1)), last)
145
+ : Math.min(Math.max(1, int(page, 1)), CAP - 1);
146
+
147
+ // The current size is offered even when the caller's list forgot it: a select
148
+ // whose value is not among its options renders as the first one, which reports
149
+ // a page size the table is not using.
150
+ // Capped: this is a string factory, and a caller who hands it a hundred thousand
151
+ // sizes gets a hundred thousand <option>s and megabytes of HTML rather than an
152
+ // error. Twelve is past any real size menu — MUI ships four.
153
+ const offered = (Array.isArray(pageSizes) ? pageSizes : [])
154
+ .slice(0, 12)
155
+ .map((s) => int(s, 0))
156
+ .filter((s) => s > 0);
157
+ const sizes = offered.length
158
+ ? [...new Set([...offered, size])].sort((a, b) => a - b)
159
+ : [];
160
+
161
+ const single = counted && last === 1;
162
+ if (single && !sizes.length) return '';
163
+
164
+ const from = (at - 1) * size + 1;
165
+ const to = counted ? Math.min(at * size, rows) : null;
166
+ // Not "1–0 of 0" for an empty result, and not "7–7 of 7" for a single row:
167
+ // both are arithmetic a reader has to undo.
168
+ const status = !counted ? `Page ${fmt(at)}`
169
+ : rows === 0 ? '0 of 0'
170
+ : from === to ? `${fmt(from)} of ${fmt(rows)}`
171
+ : `${fmt(from)}–${fmt(to)} of ${fmt(rows)}`;
172
+
173
+ const sizeBlock = sizes.length
174
+ ? `<div class="ui-pager__size">`
175
+ + `<label class="ui-pager__size-label" for="${uid}-size">Rows</label>`
176
+ + `<select class="ui-select ui-pager__size-select" id="${uid}-size"${loading ? ' disabled' : ''}>`
177
+ + sizes.map((s) => `<option value="${s}"${s === size ? ' selected' : ''}>${fmt(s)}</option>`).join('')
178
+ + `</select></div>`
179
+ : '';
180
+
181
+ const step = (spec) => control({ cls: `${GHOST_SM} ui-pager__step`, href, ...spec, disabled: loading || spec.disabled });
182
+ const atStart = at === 1;
183
+ const atEnd = counted && at === last;
184
+
185
+ let steps = '';
186
+ if (single) {
187
+ steps = ''; // GOV.UK: no pagination for one page of content.
188
+ } else if (!counted) {
189
+ // The open shape: no last page exists, so no control may claim to reach one.
190
+ // Next is off when the caller says there is nothing after this page.
191
+ steps = `<div class="ui-pager__steps">`
192
+ + step({ page: Math.max(1, at - 1), label: 'Prev', disabled: atStart })
193
+ + step({ page: at + 1, label: 'Next', disabled: !hasMore })
194
+ + `</div>`;
195
+ } else {
196
+ let middle = '';
197
+ if (kind === 'numbered') {
198
+ middle = slotsFor(at, last).map((n) => (n == null
199
+ ? '<span class="ui-pager__gap" aria-hidden="true">…</span>'
200
+ : control({
201
+ cls: cx(`${GHOST_SM} ui-pager__page`, n === at && 'is-current'),
202
+ href,
203
+ page: n,
204
+ label: fmt(n),
205
+ current: n === at,
206
+ disabled: loading,
207
+ }))).join('');
208
+ } else if (kind === 'jump') {
209
+ middle = `<span class="ui-pager__jump"><label for="${uid}-jump">Page</label>`
210
+ + `<input class="ui-input ui-pager__jump-input" id="${uid}-jump" type="number"`
211
+ + ` min="1" max="${last}" value="${at}"${loading ? ' disabled' : ''}>`
212
+ + `<span class="ui-pager__jump-of">of ${fmt(last)}</span></span>`;
213
+ }
214
+ // First and Last are the numbered variant's own job — its first and last
215
+ // slots are always those two pages, so a second pair of controls for them
216
+ // would be the same jump written twice.
217
+ const ends = kind !== 'numbered';
218
+ steps = `<div class="ui-pager__steps">`
219
+ + (ends ? step({ page: 1, label: 'First', disabled: atStart }) : '')
220
+ + step({ page: Math.max(1, at - 1), label: 'Prev', disabled: atStart })
221
+ + middle
222
+ + step({ page: Math.min(last, at + 1), label: 'Next', disabled: atEnd })
223
+ + (ends ? step({ page: last, label: 'Last', disabled: atEnd }) : '')
224
+ + `</div>`;
225
+ }
226
+
227
+ // The status line is the only live region in the component: a strip where the
228
+ // numbers, the size control and four buttons all announced would read the same
229
+ // change out four times.
230
+ return `<nav class="${cx('ui-pager', `ui-pager--${kind}`, !counted && 'ui-pager--open', single && 'ui-pager--single')}"`
231
+ + ` aria-label="${esc(label)}"${loading ? ' aria-busy="true"' : ''}>`
232
+ + `<p class="ui-pager__status" aria-live="polite" aria-atomic="true">${esc(status)}</p>`
233
+ + sizeBlock
234
+ + steps
235
+ + `</nav>`;
236
+ }
237
+
238
+ /**
239
+ * Make a rendered pager work.
240
+ *
241
+ * Every control the factory draws is inert markup until this runs: the steps
242
+ * carry `data-page` and nothing reads it, the size control is a `<select>` with
243
+ * no handler, and the jump input is an `<input>` with no handler. Shipping the
244
+ * `jump` variant without this meant shipping the one control that reaches an
245
+ * arbitrary page and having it do nothing.
246
+ *
247
+ * It reads the class contract rather than hooks of its own — `.ui-pager__step`,
248
+ * `.ui-pager__page`, `.ui-pager__size-select`, `.ui-pager__jump-input` — so the
249
+ * markup is exactly what `pagination()` already returns and the React component
250
+ * is still class-for-class identical to it.
251
+ *
252
+ * const pager = wirePagination(root, {
253
+ * onPage: (page) => load({ page }),
254
+ * onPageSize: (size) => load({ page: 1, size }),
255
+ * });
256
+ *
257
+ * Listeners are delegated from `root`, so a pager re-rendered underneath stays
258
+ * wired. Returns a function that removes them.
259
+ *
260
+ * A step rendered as an `<a href>` is left alone: it is a link, the browser owns
261
+ * it, and calling it back as well would navigate twice.
262
+ */
263
+ export function wirePagination(root = document, { onPage, onPageSize } = {}) {
264
+ const scope = typeof root === 'string' ? document.querySelector(root) : root;
265
+ if (!scope || typeof scope.addEventListener !== 'function') return () => {};
266
+
267
+ const pageOf = (el) => int(el.getAttribute('data-page'), null);
268
+ const inPager = (el) => el && el.closest && el.closest('.ui-pager');
269
+
270
+ const onClick = (e) => {
271
+ const step = e.target.closest?.('.ui-pager__step, .ui-pager__page');
272
+ if (!step || !inPager(step) || step.tagName === 'A' || step.disabled) return;
273
+ const page = pageOf(step);
274
+ if (page !== null) onPage?.(page);
275
+ };
276
+
277
+ const onChange = (e) => {
278
+ const select = e.target.closest?.('.ui-pager__size-select');
279
+ if (!select || !inPager(select)) return;
280
+ const size = int(select.value, null);
281
+ if (size !== null && size > 0) onPageSize?.(size);
282
+ };
283
+
284
+ // Enter commits; so does leaving the box. A value the input cannot parse — it
285
+ // is type="number", so a rejected keystroke leaves it empty — is not a page,
286
+ // and the box goes back to the one it was showing rather than to page 1.
287
+ const commit = (box) => {
288
+ const asked = int(box.value, null);
289
+ const max = int(box.getAttribute('max'), null);
290
+ if (asked === null || asked < 1) {
291
+ box.value = box.defaultValue;
292
+ return;
293
+ }
294
+ const page = max === null ? asked : Math.min(asked, max);
295
+ box.value = String(page);
296
+ if (String(page) !== box.defaultValue) onPage?.(page);
297
+ };
298
+ const onKeydown = (e) => {
299
+ const box = e.target.closest?.('.ui-pager__jump-input');
300
+ if (!box || !inPager(box) || e.key !== 'Enter') return;
301
+ // A lone number input inside a <form> submits it on Enter, which reloads the
302
+ // page the reader was trying to move within.
303
+ e.preventDefault();
304
+ commit(box);
305
+ };
306
+ const onBlur = (e) => {
307
+ const box = e.target.closest?.('.ui-pager__jump-input');
308
+ if (box && inPager(box)) commit(box);
309
+ };
310
+
311
+ scope.addEventListener('click', onClick);
312
+ scope.addEventListener('change', onChange);
313
+ scope.addEventListener('keydown', onKeydown);
314
+ scope.addEventListener('focusout', onBlur);
315
+ return () => {
316
+ scope.removeEventListener('click', onClick);
317
+ scope.removeEventListener('change', onChange);
318
+ scope.removeEventListener('keydown', onKeydown);
319
+ scope.removeEventListener('focusout', onBlur);
320
+ };
321
+ }
322
+
323
+ /**
324
+ * Rewrite a pager's row range, in place. THIS is the announcement.
325
+ *
326
+ * The factory returns a whole `<nav>`, so the obvious way to show a new page is
327
+ * to replace it — which inserts a brand-new live region that already contains its
328
+ * text, and several screen readers say nothing at all about a region that arrived
329
+ * with its content. The kit has met this before and answered it the same way:
330
+ * `setBusy()` rewrites the line its region already holds rather than inserting a
331
+ * new one. why: docs/specification.md#pending-and-denied-states
332
+ *
333
+ * So a consumer re-rendering a pager should hand the new range here instead of
334
+ * relying on the replacement to speak. Returns the status element, or null when
335
+ * there is nothing to update — safe against a torn-down view.
336
+ */
337
+ export function setPagerStatus(root, text) {
338
+ const el = typeof root === 'string' ? document.querySelector(root) : root;
339
+ if (!el || typeof el.querySelector !== 'function') return null;
340
+ const status = el.matches?.('.ui-pager__status') ? el : el.querySelector('.ui-pager__status');
341
+ if (!status) return null;
342
+ if (status.textContent !== String(text)) status.textContent = String(text);
343
+ return status;
344
+ }
@@ -7,6 +7,7 @@
7
7
  import { topbar as productTopbar } from './topbar.js';
8
8
  import { esc, icon } from './index.js';
9
9
  import { sidebarNav, breadcrumbs } from './nav.js';
10
+ import { backLink } from './back.js';
10
11
  import { prism } from '../assets/brand.js';
11
12
  import { ACCOUNT_NAV, toMenuTuple, initials } from './account-nav.js';
12
13
 
@@ -41,6 +42,11 @@ const toItems = (nav) => (Array.isArray(nav) ? nav : ACCOUNT_NAV)
41
42
  const toCrumbs = (crumbs) => (Array.isArray(crumbs) ? crumbs : [])
42
43
  .filter((c) => isRecord(c) && !Array.isArray(c) && str(c.label) !== '');
43
44
 
45
+ // A back link replaces the trail rather than joining it: the two would name the same parent
46
+ // twice. Anything but a record is no back link; its fields go through as given, so a back
47
+ // backLink() refuses leaves the trail standing. why: docs/specification.md#the-back-link
48
+ const toBack = (b) => (isRecord(b) && !Array.isArray(b) ? { href: b.href, label: b.label } : null);
49
+
44
50
  // The reader, as two strings. railUser() and initials() both read them, and an
45
51
  // /auth/me answering `account: null` or a numeric display name reached both.
46
52
  const toReader = (a) => (isRecord(a) ? { name: str(a.name), email: str(a.email) } : { name: '', email: '' });
@@ -88,7 +94,7 @@ const mainMax = (v) => {
88
94
  // The one pass. Each key names the function that settles it; nothing else in
89
95
  // this file re-checks a value that has been through here.
90
96
  const SHAPES = {
91
- nav: toItems, crumbs: toCrumbs, account: toReader, maxWidth: mainMax, topbar: toTopbar,
97
+ nav: toItems, crumbs: toCrumbs, back: toBack, account: toReader, maxWidth: mainMax, topbar: toTopbar,
92
98
  };
93
99
 
94
100
  // The text options settle by the same argument. `body: null` from a record with no
@@ -141,6 +147,7 @@ export function appShell(options = {}) {
141
147
  active,
142
148
  navLabel = 'Account',
143
149
  crumbs,
150
+ back,
144
151
  title = '',
145
152
  sub = '',
146
153
  body = '',
@@ -149,9 +156,11 @@ export function appShell(options = {}) {
149
156
  topbar,
150
157
  maxWidth,
151
158
  } = settle(options);
159
+ const up = back ? backLink(back) : '';
152
160
  const rail = sidebarNav({
153
161
  sections: [{ label: navLabel, items: nav }],
154
162
  active,
163
+ activeIs: up ? 'section' : 'page',
155
164
  ariaLabel: navLabel,
156
165
  footer: signOutHref ? signOut(signOutHref) : '',
157
166
  });
@@ -170,7 +179,7 @@ export function appShell(options = {}) {
170
179
  ${railUser(account)}
171
180
  </div>
172
181
  <main class="ui-app__main"${maxWidth ? ` style="--ui-app-main: ${maxWidth}"` : ''}>
173
- ${crumbs.length ? breadcrumbs({ items: crumbs }) : ''}
182
+ ${up || (crumbs.length ? breadcrumbs({ items: crumbs }) : '')}
174
183
  ${title ? `<h1>${title}</h1>` : ''}
175
184
  ${sub ? `<p class="ui-app__sub">${sub}</p>` : ''}
176
185
  <div class="ui-app__body">${body}</div>
@@ -14,6 +14,7 @@
14
14
  // Follows the WAI-ARIA tabs pattern: roving tabindex, ArrowLeft/Right + Home/End,
15
15
  // aria-selected, and aria-controls / aria-labelledby wiring. `name` must be unique
16
16
  // per tabs instance on a page (it seeds the tab/panel ids).
17
+ import { playEntrance } from '../motion.js';
17
18
 
18
19
  export function tabs({ items = [], active = 0, name = 'tabs', ariaLabel = 'Tabs', className = '' } = {}) {
19
20
  const cls = ['ui-tabs', className].filter(Boolean).join(' ');
@@ -57,7 +58,12 @@ export function initTabs(root) {
57
58
  t.setAttribute('aria-selected', on ? 'true' : 'false');
58
59
  t.tabIndex = on ? 0 : -1;
59
60
  const p = panelFor(t);
60
- if (p) p.hidden = !on;
61
+ if (p) {
62
+ // Only the panel a switch reveals fades in; the one the page loaded with does not.
63
+ const appearing = on && p.hidden;
64
+ p.hidden = !on;
65
+ if (appearing) playEntrance(p);
66
+ }
61
67
  });
62
68
  if (focus && tabEls[i]) tabEls[i].focus();
63
69
  };