@apliteni/apliteni-ui 0.26.0 → 0.27.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 +63 -2
- package/react/dist/index.css +0 -12
- package/react/dist/index.d.ts +51 -3
- package/react/dist/index.js +359 -108
- package/src/components/pagination.js +344 -0
- package/src/index.css +1 -0
- package/src/index.js +1 -0
- package/src/inline.js +2 -0
- package/src/styles/button.css +14 -0
- package/src/styles/input.css +6 -0
- package/src/styles/pagination.css +124 -0
- package/src/tokens/tokens.css +6 -0
package/README.md
CHANGED
|
@@ -107,7 +107,7 @@ import { tokensCss, topbarCss, cssText } from '@apliteni/apliteni-ui/inline';
|
|
|
107
107
|
|
|
108
108
|
## React components (stateful surfaces)
|
|
109
109
|
|
|
110
|
-
`DataTable`, `Modal`, `Button`, `Badge`, `Card` and `Icon` — same `.ui-*` classes,
|
|
110
|
+
`DataTable`, `Pagination`, `Modal`, `Button`, `Badge`, `Card` and `Icon` — same `.ui-*` classes,
|
|
111
111
|
same tokens, TypeScript types included. They ship as a **subpath of this package**,
|
|
112
112
|
not as a package of their own: one install, one version, one pin.
|
|
113
113
|
|
|
@@ -126,7 +126,7 @@ its tree.
|
|
|
126
126
|
|
|
127
127
|
```tsx
|
|
128
128
|
import '@apliteni/apliteni-ui/css'; // kit tokens + .ui-* classes
|
|
129
|
-
import '@apliteni/apliteni-ui/react/css'; // React components' shell styles (modal,
|
|
129
|
+
import '@apliteni/apliteni-ui/react/css'; // React components' shell styles (modal, sort control)
|
|
130
130
|
import { DataTable, Modal } from '@apliteni/apliteni-ui/react';
|
|
131
131
|
```
|
|
132
132
|
|
package/package.json
CHANGED
package/react/README.md
CHANGED
|
@@ -25,11 +25,19 @@ The kit declares no dependency on `react` or `react-dom`, so install them yourse
|
|
|
25
25
|
|
|
26
26
|
```tsx
|
|
27
27
|
import '@apliteni/apliteni-ui/css'; // kit tokens + .ui-* classes
|
|
28
|
-
import '@apliteni/apliteni-ui/react/css'; // React components' shell styles (modal,
|
|
28
|
+
import '@apliteni/apliteni-ui/react/css'; // React components' shell styles (modal, sort control)
|
|
29
29
|
import { DataTable, Modal, Button } from '@apliteni/apliteni-ui/react';
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
Components: `DataTable`, `Modal`, `Button`, `Badge`, `Card`, `Icon`.
|
|
32
|
+
Components: `DataTable`, `Pagination`, `Modal`, `Button`, `Badge`, `Card`, `Icon`.
|
|
33
|
+
|
|
34
|
+
`Pagination` renders the kit's `pagination()` markup, class for class, so its styles come from
|
|
35
|
+
`@apliteni/apliteni-ui/css` rather than from this bundle. One deliberate difference: it takes no
|
|
36
|
+
`href`, because a React pager reports through `onPageChange` rather than navigating. Use the
|
|
37
|
+
vanilla factory where the steps have to be real links. `PAGE_SIZES` and
|
|
38
|
+
`DEFAULT_PAGE_SIZE` are exported here too — the scale is the kit's, so no call site writes
|
|
39
|
+
either number. They are declared in this package's own types, and `PAGE_SIZES` is a
|
|
40
|
+
`readonly number[]`: pass it to `pageSizes`, but do not add sizes to it.
|
|
33
41
|
|
|
34
42
|
## What the Modal does with focus
|
|
35
43
|
|
|
@@ -93,3 +101,56 @@ a new array, including when `key` is `undefined`.
|
|
|
93
101
|
Choose controlled or uncontrolled once per table. Passing `sort` for a while and then
|
|
94
102
|
dropping it is not supported: the table falls back to the sort state it started with, not to
|
|
95
103
|
the one it was last given.
|
|
104
|
+
|
|
105
|
+
### Tables paged by a server
|
|
106
|
+
|
|
107
|
+
`page` and `onPageChange` make pagination controlled, the same way `sort` does — and the same
|
|
108
|
+
rule applies: choose one mode per table and keep it. Given a `page`, the table renders `rows`
|
|
109
|
+
exactly as handed to it and never slices or re-orders them; the range comes from `page`,
|
|
110
|
+
`pageSize` and `total`:
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
<DataTable columns={columns} rows={pageOfRows} selectable={false}
|
|
114
|
+
page={page} total={total} pageSize={size} onPageChange={fetchPage}
|
|
115
|
+
pageSizes={PAGE_SIZES} onPageSizeChange={setSize} loading={loading} />
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`total` is required with `page`: it is the row count of the whole result, not of `rows`. Pass
|
|
119
|
+
`total={null}` for a result whose size is not known, and then `hasMore` is required too — the
|
|
120
|
+
pager offers Prev and Next alone, because no other control can be computed without a last
|
|
121
|
+
page. Leave both out and the call does not type-check: a pager told nothing can only draw two
|
|
122
|
+
dead buttons.
|
|
123
|
+
|
|
124
|
+
Without `pageSize`, a controlled table takes the page size from `rows.length`, the page it was
|
|
125
|
+
handed. Pass `pageSize` whenever the last page can be shorter than the rest.
|
|
126
|
+
|
|
127
|
+
A controlled table never sorts the rows it is handed. Changing the sort asks its owner for page
|
|
128
|
+
1 through `onPageChange`, which is the most a controlled table can do about it. Keep `sort`
|
|
129
|
+
controlled too, so the headers can say which column the server ordered by. Without it the
|
|
130
|
+
headers still report a press through `onSortChange`, but no column announces `aria-sort` or
|
|
131
|
+
draws a direction, because the table does not know the server's order.
|
|
132
|
+
|
|
133
|
+
Omit `page` to keep the table's own paging: it slices `rows` in memory and the total is
|
|
134
|
+
`rows.length`. `pageSizes` offers a size control in either mode — without a `pageSize` prop the
|
|
135
|
+
table remembers the size the reader picked, with one it reports the choice through
|
|
136
|
+
`onPageSizeChange` and shows what it is given. A table that owns its page returns the reader to
|
|
137
|
+
page 1 when the size changes. A controlled table only calls `onPageSizeChange`, once, and
|
|
138
|
+
leaves the page to its owner: a new size means page 1, so fetch page 1 at that size. It does
|
|
139
|
+
not also call `onPageChange(1)`, because that second call would carry the old size.
|
|
140
|
+
|
|
141
|
+
`pageSize` is read the way the pager reads it, so the rows and the range always agree: a
|
|
142
|
+
fraction is truncated, and `NaN`, zero or a negative number falls back to the default. A value
|
|
143
|
+
taken from a URL, such as `Number(params.get('size'))`, is safe to pass as it is.
|
|
144
|
+
|
|
145
|
+
`pager={false}` renders no pager at all, for a surface that supplies its own. One page of
|
|
146
|
+
content renders none either: the pager keeps GOV.UK's rule that pagination for a single page is
|
|
147
|
+
not shown, and with a size control on offer it keeps the row count and that control alone.
|
|
148
|
+
|
|
149
|
+
Focus stays on the step the reader pressed, so they can press it again. At an end that step is
|
|
150
|
+
disabled, and a browser drops focus from a disabled control to the page body. So once the new
|
|
151
|
+
page has arrived, the pager moves focus to the nearest step that can still move, never into
|
|
152
|
+
the rows. It moves focus only after a press in the pager, and never while `loading`.
|
|
153
|
+
Clearing the page-jump box, or typing into it and then pressing a step, does not change the
|
|
154
|
+
page.
|
|
155
|
+
|
|
156
|
+
`pageSize` defaults to `DEFAULT_PAGE_SIZE` (100). **Breaking:** it used to default to 4.
|
package/react/dist/index.css
CHANGED
|
@@ -80,15 +80,3 @@
|
|
|
80
80
|
.rx-caret {
|
|
81
81
|
margin-left: 2px;
|
|
82
82
|
}
|
|
83
|
-
.rx-pager {
|
|
84
|
-
display: flex;
|
|
85
|
-
align-items: center;
|
|
86
|
-
gap: 8px;
|
|
87
|
-
justify-content: flex-end;
|
|
88
|
-
margin-top: 16px;
|
|
89
|
-
}
|
|
90
|
-
.rx-pager__info {
|
|
91
|
-
color: var(--muted);
|
|
92
|
-
font-size: var(--text-sm);
|
|
93
|
-
margin-right: auto;
|
|
94
|
-
}
|
package/react/dist/index.d.ts
CHANGED
|
@@ -60,11 +60,39 @@ type SelectionProps = {
|
|
|
60
60
|
onToggle: (name: string) => void;
|
|
61
61
|
onTogglePage: (names: string[]) => void;
|
|
62
62
|
};
|
|
63
|
+
type PagerProps = {
|
|
64
|
+
page?: never;
|
|
65
|
+
onPageChange?: (page: number) => void;
|
|
66
|
+
total?: never;
|
|
67
|
+
hasMore?: never;
|
|
68
|
+
} | {
|
|
69
|
+
page: number;
|
|
70
|
+
onPageChange: (page: number) => void;
|
|
71
|
+
total: number;
|
|
72
|
+
hasMore?: boolean;
|
|
73
|
+
} | {
|
|
74
|
+
page: number;
|
|
75
|
+
onPageChange: (page: number) => void;
|
|
76
|
+
total: null;
|
|
77
|
+
hasMore: boolean;
|
|
78
|
+
};
|
|
63
79
|
type DataTableProps<T> = {
|
|
64
80
|
columns: Column<T>[];
|
|
65
81
|
rows: T[];
|
|
66
82
|
pageSize?: number;
|
|
67
|
-
|
|
83
|
+
pageSizes?: readonly number[] | null;
|
|
84
|
+
onPageSizeChange?: (size: number) => void;
|
|
85
|
+
/** `false` renders no pager at all — for a surface that supplies its own. */
|
|
86
|
+
pager?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* The pager's accessible name. Two tables on one page otherwise publish two
|
|
89
|
+
* landmarks called "Pagination", and a reader listing the landmarks cannot tell
|
|
90
|
+
* which one moves which table. Axe will not catch it: `landmark-unique` is a
|
|
91
|
+
* best-practice rule and the kit's gate runs only the WCAG A/AA tags.
|
|
92
|
+
*/
|
|
93
|
+
pagerLabel?: string;
|
|
94
|
+
loading?: boolean;
|
|
95
|
+
} & SelectionProps & PagerProps & ({
|
|
68
96
|
sort?: never;
|
|
69
97
|
onSortChange?: (sort: TableSort<T>) => void;
|
|
70
98
|
} | {
|
|
@@ -74,7 +102,24 @@ type DataTableProps<T> = {
|
|
|
74
102
|
declare function sortTableRows<T>(rows: T[], sort: TableSort<T>): T[];
|
|
75
103
|
declare function DataTable<T extends {
|
|
76
104
|
name: string;
|
|
77
|
-
}>({ columns, rows, pageSize, selectable, selected, onToggle, onTogglePage, sort: controlledSort, onSortChange, }: DataTableProps<T>): react.JSX.Element;
|
|
105
|
+
}>({ columns, rows, pageSize, pageSizes, onPageSizeChange, pager, pagerLabel, loading, selectable, selected, onToggle, onTogglePage, sort: controlledSort, onSortChange, page: controlledPage, onPageChange, total, hasMore, }: DataTableProps<T>): react.JSX.Element;
|
|
106
|
+
|
|
107
|
+
type PaginationProps = {
|
|
108
|
+
page?: number;
|
|
109
|
+
pageSize?: number;
|
|
110
|
+
/** Rows in the whole result. `null` — the honest answer for a caller who cannot count. */
|
|
111
|
+
total?: number | null;
|
|
112
|
+
/** Read only when `total` is null: whether a page exists after this one. */
|
|
113
|
+
hasMore?: boolean;
|
|
114
|
+
pageSizes?: readonly number[] | null;
|
|
115
|
+
variant?: 'steps' | 'numbered' | 'jump';
|
|
116
|
+
label?: string;
|
|
117
|
+
loading?: boolean;
|
|
118
|
+
id?: string;
|
|
119
|
+
onPageChange?: (page: number) => void;
|
|
120
|
+
onPageSizeChange?: (size: number) => void;
|
|
121
|
+
};
|
|
122
|
+
declare function Pagination({ page, pageSize, total, hasMore, pageSizes, variant, label, loading, id, onPageChange, onPageSizeChange, }: PaginationProps): react.JSX.Element | null;
|
|
78
123
|
|
|
79
124
|
type SkeletonProps = {
|
|
80
125
|
/** A count of bars, or explicit widths when a ragged prose edge matters. */
|
|
@@ -115,4 +160,7 @@ type DeniedProps = {
|
|
|
115
160
|
};
|
|
116
161
|
declare function Denied({ title, sub, need, icon, className, children, }: DeniedProps): react.JSX.Element;
|
|
117
162
|
|
|
118
|
-
|
|
163
|
+
declare const PAGE_SIZES: readonly number[];
|
|
164
|
+
declare const DEFAULT_PAGE_SIZE: number;
|
|
165
|
+
|
|
166
|
+
export { Badge, BusyRegion, type BusyRegionProps, Button, type ButtonProps, Card, type Column, DEFAULT_PAGE_SIZE, DataTable, type DataTableProps, Denied, type DeniedProps, Icon, Modal, type ModalProps, PAGE_SIZES, Pagination, type PaginationProps, Skeleton, type SkeletonProps, SkeletonTable, type SkeletonTableProps, type TableSort, sortTableRows };
|
package/react/dist/index.js
CHANGED
|
@@ -170,8 +170,236 @@ function Modal({ open, title, onClose, footer, children }) {
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
// src/DataTable.tsx
|
|
173
|
-
import { useMemo, useState } from "react";
|
|
174
|
-
import {
|
|
173
|
+
import { useMemo, useState as useState2 } from "react";
|
|
174
|
+
import { DEFAULT_PAGE_SIZE as DEFAULT_PAGE_SIZE2 } from "@apliteni/apliteni-ui";
|
|
175
|
+
|
|
176
|
+
// src/Pagination.tsx
|
|
177
|
+
import { useEffect as useEffect2, useId, useRef as useRef2, useState } from "react";
|
|
178
|
+
import { DEFAULT_PAGE_SIZE } from "@apliteni/apliteni-ui";
|
|
179
|
+
import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
180
|
+
var GHOST_SM = "ui-btn ui-btn--ghost ui-btn--sm";
|
|
181
|
+
var VARIANTS = ["steps", "numbered", "jump"];
|
|
182
|
+
var cx2 = (...a) => a.filter(Boolean).join(" ");
|
|
183
|
+
var CAP = Number.MAX_SAFE_INTEGER;
|
|
184
|
+
var int = (v, fallback) => {
|
|
185
|
+
const raw = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN;
|
|
186
|
+
if (!Number.isFinite(raw)) return fallback;
|
|
187
|
+
return Math.min(Math.max(Math.trunc(raw), -CAP), CAP);
|
|
188
|
+
};
|
|
189
|
+
var sizeOf = (v, fallback) => {
|
|
190
|
+
const asked = int(v, fallback);
|
|
191
|
+
return asked > 0 ? asked : fallback;
|
|
192
|
+
};
|
|
193
|
+
var fmt = (n) => n.toLocaleString("en-US");
|
|
194
|
+
function slotsFor(page, pageCount) {
|
|
195
|
+
const wanted = [1, pageCount, page - 1, page, page + 1].filter((n) => n >= 1 && n <= pageCount);
|
|
196
|
+
const shown = [...new Set(wanted)].sort((a, b) => a - b);
|
|
197
|
+
const out = [];
|
|
198
|
+
for (const n of shown) {
|
|
199
|
+
const prev = out.length ? out[out.length - 1] : null;
|
|
200
|
+
if (prev != null) {
|
|
201
|
+
if (n - prev === 2) out.push(prev + 1);
|
|
202
|
+
else if (n - prev > 2) out.push(null);
|
|
203
|
+
}
|
|
204
|
+
out.push(n);
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
function Pagination({
|
|
209
|
+
page = 1,
|
|
210
|
+
pageSize,
|
|
211
|
+
total = null,
|
|
212
|
+
hasMore = false,
|
|
213
|
+
pageSizes = null,
|
|
214
|
+
variant = "steps",
|
|
215
|
+
label = "Pagination",
|
|
216
|
+
loading = false,
|
|
217
|
+
id,
|
|
218
|
+
onPageChange,
|
|
219
|
+
onPageSizeChange
|
|
220
|
+
}) {
|
|
221
|
+
const auto = useId();
|
|
222
|
+
const [draft, setDraft] = useState("");
|
|
223
|
+
const [drafting, setDrafting] = useState(false);
|
|
224
|
+
const nav = useRef2(null);
|
|
225
|
+
const pressed = useRef2(null);
|
|
226
|
+
const uid = id ?? auto;
|
|
227
|
+
const kind = VARIANTS.includes(variant) ? variant : "steps";
|
|
228
|
+
const size = sizeOf(pageSize, DEFAULT_PAGE_SIZE);
|
|
229
|
+
const asRows = int(total, null);
|
|
230
|
+
const counted = total != null && asRows !== null;
|
|
231
|
+
const rows = counted ? Math.max(0, asRows) : null;
|
|
232
|
+
const last = counted ? Math.max(1, Math.ceil(rows / size)) : null;
|
|
233
|
+
const at = counted ? Math.min(Math.max(1, int(page, 1)), last) : Math.min(Math.max(1, int(page, 1)), CAP - 1);
|
|
234
|
+
const [draftFor, setDraftFor] = useState(at);
|
|
235
|
+
if (draftFor !== at) {
|
|
236
|
+
setDraftFor(at);
|
|
237
|
+
setDraft("");
|
|
238
|
+
setDrafting(false);
|
|
239
|
+
}
|
|
240
|
+
const offered = (Array.isArray(pageSizes) ? pageSizes : []).slice(0, 12).map((s) => int(s, 0)).filter((s) => s > 0);
|
|
241
|
+
const sizes = offered.length ? [.../* @__PURE__ */ new Set([...offered, size])].sort((a, b) => a - b) : [];
|
|
242
|
+
useEffect2(() => {
|
|
243
|
+
const was = pressed.current;
|
|
244
|
+
if (!was || loading) return;
|
|
245
|
+
pressed.current = null;
|
|
246
|
+
const lost = document.activeElement === document.body || document.activeElement === was;
|
|
247
|
+
if (!lost || !nav.current?.contains(was)) return;
|
|
248
|
+
if (!was.disabled) {
|
|
249
|
+
was.focus();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const strip = [...nav.current.querySelectorAll(".ui-pager__step")];
|
|
253
|
+
const from2 = strip.indexOf(was);
|
|
254
|
+
for (let d = 1; d < strip.length; d += 1) {
|
|
255
|
+
const near = [strip[from2 - d], strip[from2 + d]].find((el) => el && !el.disabled);
|
|
256
|
+
if (near) {
|
|
257
|
+
near.focus();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}, [at, loading]);
|
|
262
|
+
const single = counted && last === 1;
|
|
263
|
+
if (single && !sizes.length) return null;
|
|
264
|
+
const from = (at - 1) * size + 1;
|
|
265
|
+
const to = counted ? Math.min(at * size, rows) : null;
|
|
266
|
+
const status = !counted ? `Page ${fmt(at)}` : rows === 0 ? "0 of 0" : from === to ? `${fmt(from)} of ${fmt(rows)}` : `${fmt(from)}\u2013${fmt(to)} of ${fmt(rows)}`;
|
|
267
|
+
const go = (n) => onPageChange?.(n);
|
|
268
|
+
const step = (label_, target, disabled) => (
|
|
269
|
+
// A control at an end is disabled and stays where it is: removing it slides
|
|
270
|
+
// the next control under a pointer already travelling toward it.
|
|
271
|
+
/* @__PURE__ */ jsx6(
|
|
272
|
+
"button",
|
|
273
|
+
{
|
|
274
|
+
type: "button",
|
|
275
|
+
className: `${GHOST_SM} ui-pager__step`,
|
|
276
|
+
"data-page": target,
|
|
277
|
+
disabled: loading || disabled,
|
|
278
|
+
"aria-disabled": loading || disabled ? true : void 0,
|
|
279
|
+
onClick: (e) => {
|
|
280
|
+
pressed.current = e.currentTarget;
|
|
281
|
+
go(target);
|
|
282
|
+
},
|
|
283
|
+
children: label_
|
|
284
|
+
},
|
|
285
|
+
label_
|
|
286
|
+
)
|
|
287
|
+
);
|
|
288
|
+
const atStart = at === 1;
|
|
289
|
+
const atEnd = counted && at === last;
|
|
290
|
+
const ends = kind !== "numbered";
|
|
291
|
+
const commitJump = () => {
|
|
292
|
+
setDrafting(false);
|
|
293
|
+
const typed = draft.trim();
|
|
294
|
+
if (typed === "") return;
|
|
295
|
+
const n = Math.min(Math.max(1, int(typed, at)), last);
|
|
296
|
+
if (n !== at) go(n);
|
|
297
|
+
};
|
|
298
|
+
const leaveJump = (to2) => {
|
|
299
|
+
if (to2 && nav.current?.contains(to2)) {
|
|
300
|
+
setDrafting(false);
|
|
301
|
+
setDraft("");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
commitJump();
|
|
305
|
+
};
|
|
306
|
+
let steps = null;
|
|
307
|
+
if (single) {
|
|
308
|
+
steps = null;
|
|
309
|
+
} else if (!counted) {
|
|
310
|
+
steps = /* @__PURE__ */ jsxs4("div", { className: "ui-pager__steps", children: [
|
|
311
|
+
step("Prev", Math.max(1, at - 1), atStart),
|
|
312
|
+
step("Next", at + 1, !hasMore)
|
|
313
|
+
] });
|
|
314
|
+
} else {
|
|
315
|
+
let middle = null;
|
|
316
|
+
if (kind === "numbered") {
|
|
317
|
+
middle = slotsFor(at, last).map((n, i) => n == null ? /* @__PURE__ */ jsx6("span", { className: "ui-pager__gap", "aria-hidden": "true", children: "\u2026" }, `gap${i}`) : /* @__PURE__ */ jsx6(
|
|
318
|
+
"button",
|
|
319
|
+
{
|
|
320
|
+
type: "button",
|
|
321
|
+
"data-page": n,
|
|
322
|
+
className: cx2(`${GHOST_SM} ui-pager__page`, n === at && "is-current"),
|
|
323
|
+
"aria-current": n === at ? "page" : void 0,
|
|
324
|
+
disabled: loading,
|
|
325
|
+
"aria-disabled": loading ? true : void 0,
|
|
326
|
+
onClick: () => go(n),
|
|
327
|
+
children: fmt(n)
|
|
328
|
+
},
|
|
329
|
+
n
|
|
330
|
+
));
|
|
331
|
+
} else if (kind === "jump") {
|
|
332
|
+
middle = /* @__PURE__ */ jsxs4("span", { className: "ui-pager__jump", children: [
|
|
333
|
+
/* @__PURE__ */ jsx6("label", { htmlFor: `${uid}-jump`, children: "Page" }),
|
|
334
|
+
/* @__PURE__ */ jsx6(
|
|
335
|
+
"input",
|
|
336
|
+
{
|
|
337
|
+
className: "ui-input ui-pager__jump-input",
|
|
338
|
+
id: `${uid}-jump`,
|
|
339
|
+
type: "number",
|
|
340
|
+
min: 1,
|
|
341
|
+
max: last,
|
|
342
|
+
disabled: loading,
|
|
343
|
+
value: drafting ? draft : String(at),
|
|
344
|
+
onChange: (e) => {
|
|
345
|
+
setDrafting(true);
|
|
346
|
+
setDraft(e.target.value);
|
|
347
|
+
},
|
|
348
|
+
onBlur: (e) => leaveJump(e.relatedTarget),
|
|
349
|
+
onKeyDown: (e) => {
|
|
350
|
+
if (e.key === "Enter") {
|
|
351
|
+
e.preventDefault();
|
|
352
|
+
commitJump();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
),
|
|
357
|
+
/* @__PURE__ */ jsxs4("span", { className: "ui-pager__jump-of", children: [
|
|
358
|
+
"of ",
|
|
359
|
+
fmt(last)
|
|
360
|
+
] })
|
|
361
|
+
] });
|
|
362
|
+
}
|
|
363
|
+
steps = /* @__PURE__ */ jsxs4("div", { className: "ui-pager__steps", children: [
|
|
364
|
+
ends ? step("First", 1, atStart) : null,
|
|
365
|
+
step("Prev", Math.max(1, at - 1), atStart),
|
|
366
|
+
middle,
|
|
367
|
+
step("Next", Math.min(last, at + 1), atEnd),
|
|
368
|
+
ends ? step("Last", last, atEnd) : null
|
|
369
|
+
] });
|
|
370
|
+
}
|
|
371
|
+
return /* @__PURE__ */ jsxs4(
|
|
372
|
+
"nav",
|
|
373
|
+
{
|
|
374
|
+
ref: nav,
|
|
375
|
+
className: cx2("ui-pager", `ui-pager--${kind}`, !counted && "ui-pager--open", single && "ui-pager--single"),
|
|
376
|
+
"aria-label": label,
|
|
377
|
+
"aria-busy": loading ? true : void 0,
|
|
378
|
+
children: [
|
|
379
|
+
/* @__PURE__ */ jsx6("p", { className: "ui-pager__status", "aria-live": "polite", "aria-atomic": "true", children: status }),
|
|
380
|
+
sizes.length ? /* @__PURE__ */ jsxs4("div", { className: "ui-pager__size", children: [
|
|
381
|
+
/* @__PURE__ */ jsx6("label", { className: "ui-pager__size-label", htmlFor: `${uid}-size`, children: "Rows" }),
|
|
382
|
+
/* @__PURE__ */ jsx6(
|
|
383
|
+
"select",
|
|
384
|
+
{
|
|
385
|
+
className: "ui-select ui-pager__size-select",
|
|
386
|
+
id: `${uid}-size`,
|
|
387
|
+
disabled: loading,
|
|
388
|
+
value: size,
|
|
389
|
+
onChange: (e) => onPageSizeChange?.(Number(e.target.value)),
|
|
390
|
+
children: sizes.map((s) => /* @__PURE__ */ jsx6("option", { value: s, children: fmt(s) }, s))
|
|
391
|
+
}
|
|
392
|
+
)
|
|
393
|
+
] }) : null,
|
|
394
|
+
steps
|
|
395
|
+
]
|
|
396
|
+
}
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// src/DataTable.tsx
|
|
401
|
+
import { Fragment, jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
402
|
+
var NO_SORT = { key: void 0, dir: -1 };
|
|
175
403
|
function sortTableRows(rows, sort) {
|
|
176
404
|
if (sort.key === void 0) return [...rows];
|
|
177
405
|
const key = sort.key;
|
|
@@ -180,7 +408,12 @@ function sortTableRows(rows, sort) {
|
|
|
180
408
|
function DataTable({
|
|
181
409
|
columns,
|
|
182
410
|
rows,
|
|
183
|
-
pageSize
|
|
411
|
+
pageSize,
|
|
412
|
+
pageSizes = null,
|
|
413
|
+
onPageSizeChange,
|
|
414
|
+
pager = true,
|
|
415
|
+
pagerLabel,
|
|
416
|
+
loading = false,
|
|
184
417
|
selectable = true,
|
|
185
418
|
selected = /* @__PURE__ */ new Set(),
|
|
186
419
|
onToggle = () => {
|
|
@@ -188,113 +421,124 @@ function DataTable({
|
|
|
188
421
|
onTogglePage = () => {
|
|
189
422
|
},
|
|
190
423
|
sort: controlledSort,
|
|
191
|
-
onSortChange
|
|
424
|
+
onSortChange,
|
|
425
|
+
page: controlledPage,
|
|
426
|
+
onPageChange,
|
|
427
|
+
total,
|
|
428
|
+
hasMore = false
|
|
192
429
|
}) {
|
|
193
|
-
const [localSort, setLocalSort] =
|
|
430
|
+
const [localSort, setLocalSort] = useState2(
|
|
194
431
|
{ key: columns.find((c) => c.sortable)?.key, dir: -1 }
|
|
195
432
|
);
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
const
|
|
433
|
+
const owned = controlledPage === void 0;
|
|
434
|
+
const sort = controlledSort ?? (owned ? localSort : NO_SORT);
|
|
435
|
+
const sortIsKnown = owned || controlledSort !== void 0;
|
|
436
|
+
const [localPage, setLocalPage] = useState2(1);
|
|
437
|
+
const [localSize, setLocalSize] = useState2(DEFAULT_PAGE_SIZE2);
|
|
438
|
+
const size = sizeOf(pageSize, owned ? localSize : rows.length || DEFAULT_PAGE_SIZE2);
|
|
439
|
+
const [pagedSort, setPagedSort] = useState2(sort);
|
|
199
440
|
if (pagedSort.key !== sort.key || pagedSort.dir !== sort.dir) {
|
|
200
441
|
setPagedSort(sort);
|
|
201
|
-
|
|
442
|
+
setLocalPage(1);
|
|
202
443
|
}
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
444
|
+
const ordered = useMemo(
|
|
445
|
+
() => owned ? sortTableRows(rows, sort) : rows,
|
|
446
|
+
[owned, rows, sort.key, sort.dir]
|
|
447
|
+
);
|
|
448
|
+
const pages = Math.max(1, Math.ceil(ordered.length / size));
|
|
449
|
+
const page = owned ? Math.min(Math.max(1, localPage), pages) : controlledPage;
|
|
450
|
+
const slice = owned ? ordered.slice((page - 1) * size, page * size) : ordered;
|
|
451
|
+
const goTo = (n) => owned ? setLocalPage(n) : onPageChange?.(n);
|
|
452
|
+
const toFirstPage = () => {
|
|
453
|
+
if (owned) setLocalPage(1);
|
|
454
|
+
else if (page !== 1) onPageChange?.(1);
|
|
455
|
+
};
|
|
207
456
|
const onSort = (key) => {
|
|
208
457
|
const next = sort.key === key ? { key, dir: sort.dir === 1 ? -1 : 1 } : { key, dir: -1 };
|
|
209
458
|
if (controlledSort === void 0) setLocalSort(next);
|
|
210
459
|
onSortChange?.(next);
|
|
211
|
-
|
|
460
|
+
toFirstPage();
|
|
212
461
|
};
|
|
213
462
|
const caret = (k) => sort.key === k ? sort.dir === 1 ? " \u25B2" : " \u25BC" : " \u2195";
|
|
214
463
|
const pageAllOn = selectable && slice.length > 0 && slice.every((r) => selected.has(r.name));
|
|
215
|
-
return /* @__PURE__ */
|
|
216
|
-
/* @__PURE__ */
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
"
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
"
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
Button,
|
|
282
|
-
{
|
|
283
|
-
variant: "ghost",
|
|
284
|
-
size: "sm",
|
|
285
|
-
iconRight: "chevronRight",
|
|
286
|
-
disabled: safePage >= pages - 1,
|
|
287
|
-
onClick: () => setPage(safePage + 1),
|
|
288
|
-
children: "Next"
|
|
464
|
+
return /* @__PURE__ */ jsxs5(Fragment, { children: [
|
|
465
|
+
/* @__PURE__ */ jsxs5(
|
|
466
|
+
"table",
|
|
467
|
+
{
|
|
468
|
+
className: "ui-table ui-table--hover ui-table--zebra",
|
|
469
|
+
"aria-busy": loading || void 0,
|
|
470
|
+
children: [
|
|
471
|
+
/* @__PURE__ */ jsx7("thead", { children: /* @__PURE__ */ jsxs5("tr", { children: [
|
|
472
|
+
selectable ? /* @__PURE__ */ jsx7("th", { scope: "col", children: /* @__PURE__ */ jsx7(
|
|
473
|
+
"input",
|
|
474
|
+
{
|
|
475
|
+
type: "checkbox",
|
|
476
|
+
checked: pageAllOn,
|
|
477
|
+
"aria-label": "Select all rows on this page",
|
|
478
|
+
onChange: () => onTogglePage(slice.map((r) => r.name))
|
|
479
|
+
}
|
|
480
|
+
) }) : null,
|
|
481
|
+
columns.map((c) => (
|
|
482
|
+
// The sort control is a real <button> inside the header cell. It used to be
|
|
483
|
+
// role="button" ON the <th>, which threw away the columnheader role and put
|
|
484
|
+
// aria-sort on a role that forbids it.
|
|
485
|
+
/* @__PURE__ */ jsx7(
|
|
486
|
+
"th",
|
|
487
|
+
{
|
|
488
|
+
scope: "col",
|
|
489
|
+
className: [c.num && "ui-table__num", c.sortable && "rx-sortable"].filter(Boolean).join(" "),
|
|
490
|
+
"aria-sort": sort.key === c.key ? sort.dir === 1 ? "ascending" : "descending" : c.sortable && sortIsKnown ? "none" : void 0,
|
|
491
|
+
children: c.sortable ? /* @__PURE__ */ jsxs5("button", { type: "button", className: "rx-sort", onClick: () => onSort(c.key), children: [
|
|
492
|
+
c.label,
|
|
493
|
+
/* @__PURE__ */ jsx7("span", { className: "rx-caret", "aria-hidden": "true", children: caret(c.key) })
|
|
494
|
+
] }) : c.label
|
|
495
|
+
},
|
|
496
|
+
c.key
|
|
497
|
+
)
|
|
498
|
+
))
|
|
499
|
+
] }) }),
|
|
500
|
+
/* @__PURE__ */ jsx7("tbody", { children: slice.map((r) => /* @__PURE__ */ jsxs5("tr", { children: [
|
|
501
|
+
selectable ? /* @__PURE__ */ jsx7("td", { children: /* @__PURE__ */ jsx7(
|
|
502
|
+
"input",
|
|
503
|
+
{
|
|
504
|
+
type: "checkbox",
|
|
505
|
+
checked: selected.has(r.name),
|
|
506
|
+
"aria-label": `Select ${r.name}`,
|
|
507
|
+
onChange: () => onToggle(r.name)
|
|
508
|
+
}
|
|
509
|
+
) }) : null,
|
|
510
|
+
columns.map((c) => /* @__PURE__ */ jsx7("td", { className: c.num ? "ui-table__num" : void 0, children: c.render ? c.render(r) : String(r[c.key]) }, c.key))
|
|
511
|
+
] }, r.name)) })
|
|
512
|
+
]
|
|
513
|
+
}
|
|
514
|
+
),
|
|
515
|
+
pager ? /* @__PURE__ */ jsx7(
|
|
516
|
+
Pagination,
|
|
517
|
+
{
|
|
518
|
+
page,
|
|
519
|
+
pageSize: size,
|
|
520
|
+
total: owned ? ordered.length : total ?? null,
|
|
521
|
+
hasMore,
|
|
522
|
+
pageSizes,
|
|
523
|
+
loading,
|
|
524
|
+
...pagerLabel === void 0 ? {} : { label: pagerLabel },
|
|
525
|
+
onPageChange: goTo,
|
|
526
|
+
onPageSizeChange: (s) => {
|
|
527
|
+
if (pageSize === void 0) setLocalSize(s);
|
|
528
|
+
onPageSizeChange?.(s);
|
|
529
|
+
if (owned) setLocalPage(1);
|
|
289
530
|
}
|
|
290
|
-
|
|
291
|
-
|
|
531
|
+
}
|
|
532
|
+
) : null
|
|
292
533
|
] });
|
|
293
534
|
}
|
|
294
535
|
|
|
536
|
+
// src/index.ts
|
|
537
|
+
import { PAGE_SIZES as KIT_PAGE_SIZES, DEFAULT_PAGE_SIZE as KIT_DEFAULT_PAGE_SIZE } from "@apliteni/apliteni-ui";
|
|
538
|
+
|
|
295
539
|
// src/Loading.tsx
|
|
296
|
-
import { jsx as
|
|
297
|
-
var
|
|
540
|
+
import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
541
|
+
var cx3 = (...a) => a.filter(Boolean).join(" ");
|
|
298
542
|
function Skeleton({ lines = 3, width, height, radius, className }) {
|
|
299
543
|
const widths = Array.isArray(lines) ? lines : null;
|
|
300
544
|
const n = widths ? widths.length : Math.max(1, lines);
|
|
@@ -303,19 +547,19 @@ function Skeleton({ lines = 3, width, height, radius, className }) {
|
|
|
303
547
|
if (!w && !height && !radius) return void 0;
|
|
304
548
|
return { ...w && { width: w }, ...height && { height }, ...radius && { borderRadius: radius } };
|
|
305
549
|
};
|
|
306
|
-
return /* @__PURE__ */
|
|
550
|
+
return /* @__PURE__ */ jsx8("div", { className: cx3("ui-skel", className), "aria-hidden": "true", children: Array.from({ length: n }, (_, i) => /* @__PURE__ */ jsx8("span", { className: "ui-skel__bar m-skeleton", style: styleFor(i) }, i)) });
|
|
307
551
|
}
|
|
308
552
|
function SkeletonTable({ rows = 5, cols = 4, head = true }) {
|
|
309
|
-
const cells = Array.from({ length: Math.max(1, cols) }, (_, i) => /* @__PURE__ */
|
|
310
|
-
return /* @__PURE__ */
|
|
553
|
+
const cells = Array.from({ length: Math.max(1, cols) }, (_, i) => /* @__PURE__ */ jsx8("span", { className: "ui-skel__bar m-skeleton" }, i));
|
|
554
|
+
return /* @__PURE__ */ jsxs6(
|
|
311
555
|
"div",
|
|
312
556
|
{
|
|
313
557
|
className: "ui-skel ui-skel--table",
|
|
314
558
|
style: { "--skel-cols": Math.max(1, cols) },
|
|
315
559
|
"aria-hidden": "true",
|
|
316
560
|
children: [
|
|
317
|
-
head && /* @__PURE__ */
|
|
318
|
-
Array.from({ length: Math.max(1, rows) }, (_, i) => /* @__PURE__ */
|
|
561
|
+
head && /* @__PURE__ */ jsx8("div", { className: "ui-skel__row ui-skel__row--head", children: cells }),
|
|
562
|
+
Array.from({ length: Math.max(1, rows) }, (_, i) => /* @__PURE__ */ jsx8("div", { className: "ui-skel__row", children: cells }, i))
|
|
319
563
|
]
|
|
320
564
|
}
|
|
321
565
|
);
|
|
@@ -328,16 +572,16 @@ function BusyRegion({
|
|
|
328
572
|
className,
|
|
329
573
|
children
|
|
330
574
|
}) {
|
|
331
|
-
return /* @__PURE__ */
|
|
575
|
+
return /* @__PURE__ */ jsxs6(
|
|
332
576
|
"div",
|
|
333
577
|
{
|
|
334
|
-
className:
|
|
578
|
+
className: cx3("ui-busy", className),
|
|
335
579
|
role: "status",
|
|
336
580
|
"aria-live": "polite",
|
|
337
581
|
"aria-busy": busy,
|
|
338
582
|
children: [
|
|
339
|
-
/* @__PURE__ */
|
|
340
|
-
/* @__PURE__ */
|
|
583
|
+
/* @__PURE__ */ jsx8("span", { className: "ui-sr", children: busy ? label : message }),
|
|
584
|
+
/* @__PURE__ */ jsx8("div", { className: "ui-busy__body", children: busy ? placeholder ?? /* @__PURE__ */ jsx8(Skeleton, { lines: 3 }) : children })
|
|
341
585
|
]
|
|
342
586
|
}
|
|
343
587
|
);
|
|
@@ -350,26 +594,33 @@ function Denied({
|
|
|
350
594
|
className,
|
|
351
595
|
children
|
|
352
596
|
}) {
|
|
353
|
-
return /* @__PURE__ */
|
|
354
|
-
/* @__PURE__ */
|
|
355
|
-
/* @__PURE__ */
|
|
356
|
-
sub && /* @__PURE__ */
|
|
357
|
-
need && /* @__PURE__ */
|
|
597
|
+
return /* @__PURE__ */ jsxs6("div", { className: cx3("ui-denied", className), children: [
|
|
598
|
+
/* @__PURE__ */ jsx8("div", { className: "ui-denied__seal", children: /* @__PURE__ */ jsx8(Icon, { name: icon2 }) }),
|
|
599
|
+
/* @__PURE__ */ jsx8("div", { className: "ui-denied__title", children: title }),
|
|
600
|
+
sub && /* @__PURE__ */ jsx8("div", { className: "ui-denied__sub", children: sub }),
|
|
601
|
+
need && /* @__PURE__ */ jsxs6("div", { className: "ui-denied__need", children: [
|
|
358
602
|
"Needs ",
|
|
359
|
-
/* @__PURE__ */
|
|
603
|
+
/* @__PURE__ */ jsx8("code", { className: "ui-code", children: need })
|
|
360
604
|
] }),
|
|
361
|
-
children && /* @__PURE__ */
|
|
605
|
+
children && /* @__PURE__ */ jsx8("div", { className: "ui-denied__actions", children })
|
|
362
606
|
] });
|
|
363
607
|
}
|
|
608
|
+
|
|
609
|
+
// src/index.ts
|
|
610
|
+
var PAGE_SIZES = KIT_PAGE_SIZES;
|
|
611
|
+
var DEFAULT_PAGE_SIZE3 = KIT_DEFAULT_PAGE_SIZE;
|
|
364
612
|
export {
|
|
365
613
|
Badge,
|
|
366
614
|
BusyRegion,
|
|
367
615
|
Button,
|
|
368
616
|
Card,
|
|
617
|
+
DEFAULT_PAGE_SIZE3 as DEFAULT_PAGE_SIZE,
|
|
369
618
|
DataTable,
|
|
370
619
|
Denied,
|
|
371
620
|
Icon,
|
|
372
621
|
Modal,
|
|
622
|
+
PAGE_SIZES,
|
|
623
|
+
Pagination,
|
|
373
624
|
Skeleton,
|
|
374
625
|
SkeletonTable,
|
|
375
626
|
sortTableRows
|
|
@@ -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/index.css
CHANGED
package/src/index.js
CHANGED
|
@@ -13,6 +13,7 @@ export * from './components/feedback.js';
|
|
|
13
13
|
export * from './components/toasts.js';
|
|
14
14
|
export * from './components/success.js';
|
|
15
15
|
export * from './components/loading.js';
|
|
16
|
+
export * from './components/pagination.js';
|
|
16
17
|
export * from './assets/icons.js';
|
|
17
18
|
export * from './assets/brand.js';
|
|
18
19
|
export * from './motion.js';
|
package/src/inline.js
CHANGED
|
@@ -41,6 +41,7 @@ export const styles = {
|
|
|
41
41
|
drawer: read('styles/drawer.css'),
|
|
42
42
|
confirm: read('styles/confirm.css'),
|
|
43
43
|
table: read('styles/table.css'),
|
|
44
|
+
pagination: read('styles/pagination.css'),
|
|
44
45
|
empty: read('styles/empty.css'),
|
|
45
46
|
callout: read('styles/callout.css'),
|
|
46
47
|
code: read('styles/code.css'),
|
|
@@ -69,6 +70,7 @@ export const cssText = [
|
|
|
69
70
|
styles.drawer,
|
|
70
71
|
styles.confirm,
|
|
71
72
|
styles.table,
|
|
73
|
+
styles.pagination,
|
|
72
74
|
styles.empty,
|
|
73
75
|
styles.callout,
|
|
74
76
|
styles.code,
|
package/src/styles/button.css
CHANGED
|
@@ -100,7 +100,21 @@
|
|
|
100
100
|
.ui-btn--ghost[aria-disabled="true"] {
|
|
101
101
|
background: transparent;
|
|
102
102
|
border-color: transparent;
|
|
103
|
+
color: var(--disabled-ink-bare);
|
|
103
104
|
}
|
|
105
|
+
/* With no box, the ink is read on whatever is behind it. --disabled-ink read
|
|
106
|
+
5.18:1 on a card and 4.66:1 on --surface-3 in dark, under #220's 5.56, so the
|
|
107
|
+
ghost takes an ink set for the dullest ground: in dark 7.00 / 6.24 / 6.69 /
|
|
108
|
+
5.62 on --bg / --surface / --surface-2 / --surface-3, in light 6.50 / 6.50 /
|
|
109
|
+
6.01 / 5.60. The enabled ghost's --dim reads 1.5 times that in dark, 1.6 in light.
|
|
110
|
+
|
|
111
|
+
#273 got here the long way. It first gave the ghost the flat disabled box
|
|
112
|
+
instead, on the claim that nothing would get less readable. That was false:
|
|
113
|
+
--bg and --surface lost up to 0.46. Then the box was rendered in a pager at
|
|
114
|
+
page 1, and the boxed First and Prev read heavier than the boxless Next and
|
|
115
|
+
Last beside them: the controls that were off looked like the live ones. No
|
|
116
|
+
contrast table shows that. The box was reverted and this ink shipped.
|
|
117
|
+
Held by src/styles/button-disabled.test.js. */
|
|
104
118
|
|
|
105
119
|
/* Busy: keep the label, run an indeterminate accent shimmer along the base, and
|
|
106
120
|
the button is disabled (not clickable). Pass busy + the .ui-btn__bars markup.
|
package/src/styles/input.css
CHANGED
|
@@ -49,7 +49,13 @@
|
|
|
49
49
|
|
|
50
50
|
/* The value a reader typed is still the value; it goes quiet, it does not fade
|
|
51
51
|
into the field under it the way an opacity would take both down together. #220 */
|
|
52
|
+
/* `.ui-select` joins these two in #273. It had no disabled rule at all, and it
|
|
53
|
+
sets `background: var(--surface-2)` as an author declaration — which outranks
|
|
54
|
+
the UA's own grey-out — so a disabled select rendered pixel-identical to a live
|
|
55
|
+
one. A pager marked busy showed every button and the jump input visibly off
|
|
56
|
+
beside a size control that still looked available. */
|
|
52
57
|
.ui-input:disabled,
|
|
58
|
+
.ui-select:disabled,
|
|
53
59
|
.ui-textarea:disabled {
|
|
54
60
|
background: var(--disabled-surface);
|
|
55
61
|
border-color: var(--disabled-border);
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
* Pagination — .ui-pager (variants: --steps / --numbered / --jump)
|
|
3
|
+
* --open total unknown: Prev and Next only, no last page to reach
|
|
4
|
+
* --single one page of content: the size control alone, no steps
|
|
5
|
+
* The controls are .ui-btn ghost/sm and the size control is a .ui-select, so
|
|
6
|
+
* everything here is layout and the two states those sheets do not own.
|
|
7
|
+
* ========================================================================== */
|
|
8
|
+
|
|
9
|
+
.ui-pager {
|
|
10
|
+
display: flex;
|
|
11
|
+
flex-wrap: wrap;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: var(--space-3) var(--space-4);
|
|
14
|
+
font-size: var(--text-sm);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* The status is the only thing anchored to the start of the strip: it is read,
|
|
18
|
+
not aimed at, and pushing the controls to the far end keeps the pointer's
|
|
19
|
+
target group in one place as the row count changes width. */
|
|
20
|
+
.ui-pager__status {
|
|
21
|
+
margin: 0 auto 0 0;
|
|
22
|
+
color: var(--muted);
|
|
23
|
+
font-variant-numeric: tabular-nums;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.ui-pager__size {
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
gap: var(--space-2);
|
|
30
|
+
}
|
|
31
|
+
.ui-pager__size-label { color: var(--muted); }
|
|
32
|
+
|
|
33
|
+
/* .ui-select is a form field sized for a form — full width, 12px/15px padding.
|
|
34
|
+
In a pager it sits in a row of sm buttons, so it takes their scale. Two things
|
|
35
|
+
travel with that: the chevron .ui-select paints is positioned from the right
|
|
36
|
+
edge, so the padding on that side has to stay clear of it, and the height is
|
|
37
|
+
declared so this control and the jump input beside it agree on one box. */
|
|
38
|
+
.ui-pager__size-select {
|
|
39
|
+
width: auto;
|
|
40
|
+
min-height: var(--space-8);
|
|
41
|
+
padding: var(--space-1) var(--space-6) var(--space-1) var(--space-2);
|
|
42
|
+
font-size: var(--text-sm);
|
|
43
|
+
border-radius: var(--radius-sm);
|
|
44
|
+
background-position: right var(--space-2) center;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.ui-pager__steps {
|
|
48
|
+
display: flex;
|
|
49
|
+
flex-wrap: wrap;
|
|
50
|
+
align-items: center;
|
|
51
|
+
gap: var(--space-1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Page numbers are read as a row of equal cells, and .ui-btn--sm's padding makes
|
|
55
|
+
"1" narrower than "49" — so a strip re-flows under the pointer as the reader
|
|
56
|
+
walks into three-digit pages. The number is WCAG 2.5.8's 24px: an equalised
|
|
57
|
+
cell should not be narrower than a target is allowed to be. */
|
|
58
|
+
.ui-pager__page {
|
|
59
|
+
min-width: var(--space-6);
|
|
60
|
+
font-variant-numeric: tabular-nums;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* The page you are on. Guarded, because this weighs the same as `.ui-btn:disabled`
|
|
64
|
+
and is imported after it: written plainly it would repaint the current page of
|
|
65
|
+
a `loading` pager as though it were live, which is the one thing the busy state
|
|
66
|
+
must not leave behind. Spelled `[disabled]` rather than `:disabled` — the same
|
|
67
|
+
selector for the markup this component emits, and the spelling that keeps a
|
|
68
|
+
rule about an ENABLED control out of the disabled-rule sweep in
|
|
69
|
+
stories/guidelines/accessibility-floor.test.js, which reads selector text. */
|
|
70
|
+
.ui-pager__page.is-current:not([disabled]) {
|
|
71
|
+
background: var(--surface-3);
|
|
72
|
+
border-color: var(--border);
|
|
73
|
+
color: var(--strong);
|
|
74
|
+
font-weight: var(--weight-semibold);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.ui-pager__gap {
|
|
78
|
+
padding: 0 var(--space-1);
|
|
79
|
+
color: var(--muted);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.ui-pager__jump {
|
|
83
|
+
display: inline-flex;
|
|
84
|
+
align-items: center;
|
|
85
|
+
gap: var(--space-2);
|
|
86
|
+
color: var(--muted);
|
|
87
|
+
padding: 0 var(--space-1);
|
|
88
|
+
}
|
|
89
|
+
/* Wide enough for four digits and the spinner a number input draws beside them;
|
|
90
|
+
in ch so it follows the type rather than a measured pixel. The height is
|
|
91
|
+
declared rather than left to the line box, because an <input> holds its value
|
|
92
|
+
in a property: it is the one control in the strip with no text of its own, and
|
|
93
|
+
nothing else gives it the 24px WCAG 2.5.8 asks of a pointer target. */
|
|
94
|
+
.ui-pager__jump-input {
|
|
95
|
+
width: 8ch;
|
|
96
|
+
min-height: var(--space-8);
|
|
97
|
+
padding: var(--space-1) var(--space-2);
|
|
98
|
+
font-size: var(--text-sm);
|
|
99
|
+
border-radius: var(--radius-sm);
|
|
100
|
+
text-align: center;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* A pager directly after a table stands off it. The strip owns no margin of its
|
|
104
|
+
own — it is dropped under lists and cards too, and a component that carries
|
|
105
|
+
spacing everywhere is one every caller has to cancel somewhere — so the gap is
|
|
106
|
+
declared on the one adjacency the kit can see. `.rx-pager` carried a bare
|
|
107
|
+
`margin-top: 16px` for this, which is --space-4; the number moves to the scale
|
|
108
|
+
with it rather than travelling as a literal.
|
|
109
|
+
why: docs/specification.md#spacing-and-rhythm */
|
|
110
|
+
.ui-table + .ui-pager { margin-top: var(--space-4); }
|
|
111
|
+
|
|
112
|
+
/* A step given an `href` is an anchor, and a host stylesheet's `a:link` is
|
|
113
|
+
(0,1,1) — which outranks `.ui-btn--ghost` at (0,1,0) and paints the enabled
|
|
114
|
+
steps in the host's link colour while the disabled ends keep --disabled-ink-bare.
|
|
115
|
+
One strip, reading as two different controls. The kit has paid for this twice
|
|
116
|
+
already and both fixes are the same shape: see the (0,2,0) note on
|
|
117
|
+
`.ui-nav .ui-nav__item` in nav.css. Storybook ships no `a:link`, so the "Pages
|
|
118
|
+
as links" story cannot show it — which is why the rule is stated rather than
|
|
119
|
+
discovered.
|
|
120
|
+
why: docs/specification.md#pagination */
|
|
121
|
+
a.ui-pager__step,
|
|
122
|
+
a.ui-pager__page { color: var(--dim); }
|
|
123
|
+
a.ui-pager__step:hover,
|
|
124
|
+
a.ui-pager__page:hover { color: var(--strong); }
|
package/src/tokens/tokens.css
CHANGED
|
@@ -158,6 +158,11 @@
|
|
|
158
158
|
--disabled-ink: var(--muted);
|
|
159
159
|
--disabled-surface: var(--surface-2);
|
|
160
160
|
--disabled-border: var(--border);
|
|
161
|
+
/* The ink of a disabled control that paints no box of its own — a ghost button. With
|
|
162
|
+
no surface beside it, it is read on whatever is behind it, so it is set to clear the
|
|
163
|
+
floor on the dullest ground there is (--surface-3). #273
|
|
164
|
+
why: docs/specification.md#colour-and-contrast */
|
|
165
|
+
--disabled-ink-bare: #a39eb7;
|
|
161
166
|
|
|
162
167
|
/* Lifted from #9b5dff, which cleared the flat surfaces and failed on its own wash over
|
|
163
168
|
a card; the value was already in the ramp. --ring is var(--accent) and follows by
|
|
@@ -271,6 +276,7 @@
|
|
|
271
276
|
--disabled-ink: var(--muted);
|
|
272
277
|
--disabled-surface: var(--surface-2);
|
|
273
278
|
--disabled-border: var(--border);
|
|
279
|
+
--disabled-ink-bare: #585e6c;
|
|
274
280
|
|
|
275
281
|
--accent: #6a2dcc;
|
|
276
282
|
--accent-strong: #6a2dcc; /* already dark enough for white text */
|