@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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/react/README.md +111 -2
- package/react/dist/index.css +11 -13
- package/react/dist/index.d.ts +111 -4
- package/react/dist/index.js +811 -167
- package/src/components/back.js +57 -0
- package/src/components/command-palette.js +597 -0
- package/src/components/confirm.js +3 -2
- package/src/components/drawer.js +31 -2
- package/src/components/dropdown.js +192 -17
- package/src/components/feedback.js +2 -2
- package/src/components/index.js +5 -2
- package/src/components/loading.js +3 -2
- package/src/components/nav.js +15 -7
- package/src/components/overlay.js +67 -14
- package/src/components/pagination.js +344 -0
- package/src/components/shell.js +11 -2
- package/src/components/tabs.js +7 -1
- package/src/components/tooltip.js +237 -0
- package/src/components/topbar.js +9 -4
- package/src/index.css +4 -0
- package/src/index.js +4 -0
- package/src/inline.js +8 -0
- package/src/motion.js +43 -2
- package/src/styles/back.css +42 -0
- package/src/styles/badge.css +5 -7
- package/src/styles/base.css +8 -7
- package/src/styles/button.css +14 -0
- package/src/styles/card.css +12 -8
- package/src/styles/code.css +3 -4
- package/src/styles/command-palette.css +321 -0
- package/src/styles/confirm.css +11 -11
- package/src/styles/drawer.css +57 -15
- package/src/styles/dropdown.css +70 -11
- package/src/styles/feedback.css +2 -0
- package/src/styles/footer.css +3 -4
- package/src/styles/input.css +7 -1
- package/src/styles/layout.css +3 -2
- package/src/styles/loading.css +5 -0
- package/src/styles/nav.css +12 -8
- package/src/styles/pagination.css +124 -0
- package/src/styles/success.css +3 -2
- package/src/styles/table.css +4 -5
- package/src/styles/tabs.css +3 -0
- package/src/styles/tooltip.css +65 -0
- package/src/styles/topbar.css +3 -4
- 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)
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
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
|
|
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
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
+
}
|
package/src/components/shell.js
CHANGED
|
@@ -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>
|
package/src/components/tabs.js
CHANGED
|
@@ -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)
|
|
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
|
};
|