@danieldeusing/design 0.20.0 → 0.22.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 +5 -1
- package/dist/danieldeusing-design.css +327 -1
- package/dist/danieldeusing-design.min.css +2 -2
- package/package.json +1 -1
- package/runtime/index.js +2 -0
- package/runtime/pagination.js +341 -0
- package/runtime/select.js +539 -0
- package/src/base.css +14 -0
- package/src/components.css +311 -0
- package/src/print.css +1 -0
- package/templates/documentation.html +11 -6
- package/templates/page-chrome.html +6 -1
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* pagination.js — a long table shows 20 rows at a time, and the rest are still THERE.
|
|
3
|
+
*
|
|
4
|
+
* Markup contract:
|
|
5
|
+
* <table data-table-id="docs-scopes">
|
|
6
|
+
*
|
|
7
|
+
* One attribute — unlike `initTableScroll()` and `initSelects()`, whose contract is
|
|
8
|
+
* nothing at all. The difference is deliberate and it is explained under "IDENTITY"
|
|
9
|
+
* below: a remembered page size has to be remembered AGAINST something, and every
|
|
10
|
+
* scheme for guessing that something is wrong the first time a table moves.
|
|
11
|
+
*
|
|
12
|
+
* ── THE ORDER IS FILTER, THEN SORT, THEN SLICE, AND THAT IS THE WHOLE FEATURE ──────
|
|
13
|
+
*
|
|
14
|
+
* The natural wrong implementation of paging is to cut the data to twenty rows and
|
|
15
|
+
* then wire the sort and the filter to what is on screen. It looks right on page 1
|
|
16
|
+
* and it is a table that lies: sorting by "newest" reorders twenty arbitrary rows
|
|
17
|
+
* while the actual newest row sits on page 3, and filtering finds nothing because the
|
|
18
|
+
* match was never in the slice being searched.
|
|
19
|
+
*
|
|
20
|
+
* THIS COMPONENT CANNOT MAKE THAT MISTAKE, because it cannot sort and it cannot
|
|
21
|
+
* filter. It has no idea what a row means. It reads the `<tbody>` that is already in
|
|
22
|
+
* the document — whatever produced those rows has already filtered and already sorted
|
|
23
|
+
* the FULL dataset, because that is the only way rows get into a tbody — and hides
|
|
24
|
+
* all but one window of them. Slicing last is not a rule anyone here has to remember;
|
|
25
|
+
* it is the only thing this code is able to do. A page keeps its own sort and filter
|
|
26
|
+
* and needs no edit to gain paging (cockpit's `cockpitTable` engine is the worked
|
|
27
|
+
* example: `visibleRows()` filters and sorts `rows`, the full array, exactly as it did
|
|
28
|
+
* before this existed).
|
|
29
|
+
*
|
|
30
|
+
* ── HIDING, NOT REMOVING ──────────────────────────────────────────────────────────
|
|
31
|
+
*
|
|
32
|
+
* Turning the page sets the `hidden` attribute on the rows that are off-window and
|
|
33
|
+
* clears it on the twenty that are not. No markup is rebuilt, nothing is re-parsed and
|
|
34
|
+
* no `innerHTML` is written — which matters here beyond speed: cockpit patches its
|
|
35
|
+
* tables in place (`cockpit/pages/dom-patch.js`) precisely so a background refresh does
|
|
36
|
+
* not destroy half-typed input, focus, or an opened <details>. A pager that re-rendered
|
|
37
|
+
* the table on every page change would hand all of that back.
|
|
38
|
+
*
|
|
39
|
+
* ── IDENTITY: `data-table-id`, OR PAGINATION DOES NOT ENGAGE ──────────────────────
|
|
40
|
+
*
|
|
41
|
+
* The size the reader picks is remembered per table in localStorage, so the key has to
|
|
42
|
+
* name a table. Deriving one from the page path plus the table's index on the page is
|
|
43
|
+
* the obvious move and it is a bug with a delay on it: add a table above another one
|
|
44
|
+
* and every reader's "100 per page" silently becomes some other table's setting, with
|
|
45
|
+
* no error and nothing to notice. So the id is REQUIRED and never guessed.
|
|
46
|
+
*
|
|
47
|
+
* A table without one is left alone entirely — it keeps every row, which is what it did
|
|
48
|
+
* before this ran, so a reader is never shown a broken or half-paged table. The warning
|
|
49
|
+
* is aimed at the one person who can fix it and fires only when the omission actually
|
|
50
|
+
* costs something: a table long enough to have been paged. Short reference tables (the
|
|
51
|
+
* estate has around forty of them — four machines, three agents) are silent, because
|
|
52
|
+
* "you forgot an id" on a table that would never have paged anyway is noise that
|
|
53
|
+
* teaches people to ignore the console.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/** The sizes offered in the picker. 20 is the default; the rest are the reader's call. */
|
|
57
|
+
export const PAGE_SIZES = [5, 10, 20, 50, 100, 200];
|
|
58
|
+
export const DEFAULT_PAGE_SIZE = 20;
|
|
59
|
+
|
|
60
|
+
const STORE_PREFIX = "table-rows:";
|
|
61
|
+
const enhanced = new WeakMap();
|
|
62
|
+
let documentObserver = null;
|
|
63
|
+
|
|
64
|
+
/* ── the pure core ────────────────────────────────────────────────────────────────
|
|
65
|
+
* Three functions with no DOM in them, because they hold every decision worth being
|
|
66
|
+
* wrong about — which window of rows, what to do with a stored value that is junk, and
|
|
67
|
+
* which rows that window actually hides. `scripts/check-pagination.mjs` tests these
|
|
68
|
+
* directly, so the assertions are about the shipped logic rather than about a copy of
|
|
69
|
+
* it written into a harness.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The window of row indices a page covers.
|
|
74
|
+
*
|
|
75
|
+
* `page` is CLAMPED rather than rejected, and that is the behaviour a live table needs:
|
|
76
|
+
* rows arrive and leave under a reader who is on page 4 (a filter narrows the set, a 30s
|
|
77
|
+
* poll returns fewer rows), and the answer to "page 4 of 2" is page 2, not an error and
|
|
78
|
+
* not an empty screen. Clamping rather than resetting to 1 is equally deliberate — a
|
|
79
|
+
* background refresh must not yank the reader back to the top of a table they are part
|
|
80
|
+
* way through.
|
|
81
|
+
*
|
|
82
|
+
* @param {number} total Rows in the full, already-filtered, already-sorted set.
|
|
83
|
+
* @param {number} size Rows per page.
|
|
84
|
+
* @param {number} page 1-based page number, possibly out of range.
|
|
85
|
+
* @returns {{page:number,pageCount:number,from:number,to:number}} `from`/`to` are a
|
|
86
|
+
* half-open index range into the full set.
|
|
87
|
+
*/
|
|
88
|
+
export function pageWindow(total, size, page) {
|
|
89
|
+
const pageCount = Math.max(1, Math.ceil(total / size));
|
|
90
|
+
const current = Math.min(Math.max(1, Math.floor(page) || 1), pageCount);
|
|
91
|
+
const from = (current - 1) * size;
|
|
92
|
+
return { page: current, pageCount, from, to: Math.min(from + size, total) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A stored page size, or the default.
|
|
97
|
+
*
|
|
98
|
+
* Everything that is not one of the offered sizes becomes 20: `null` (never set),
|
|
99
|
+
* `"abc"` (junk), `999` (a size this build no longer offers), `""`, an object. The
|
|
100
|
+
* store is shared with every other tab, every older build of the page and anyone with a
|
|
101
|
+
* devtools console, so "it can only contain what we wrote" is not true of it.
|
|
102
|
+
*
|
|
103
|
+
* @param {unknown} raw
|
|
104
|
+
* @returns {number}
|
|
105
|
+
*/
|
|
106
|
+
export function normalizePageSize(raw) {
|
|
107
|
+
const size = Number(raw);
|
|
108
|
+
return PAGE_SIZES.includes(size) ? size : DEFAULT_PAGE_SIZE;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Show the rows inside `[from, to)` and hide the rest.
|
|
113
|
+
*
|
|
114
|
+
* Writes are checked first so an unchanged row is not touched at all. That keeps the
|
|
115
|
+
* function idempotent at the DOM level — no mutation record, so the observer watching
|
|
116
|
+
* these very rows cannot be woken by this function's own work and cannot loop.
|
|
117
|
+
*
|
|
118
|
+
* @param {ArrayLike<{hidden:boolean}>} rows The full set, in display order.
|
|
119
|
+
*/
|
|
120
|
+
export function applyPageWindow(rows, from, to) {
|
|
121
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
122
|
+
const off = i < from || i >= to;
|
|
123
|
+
if (rows[i].hidden !== off) rows[i].hidden = off;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ── storage ──────────────────────────────────────────────────────────────────────
|
|
128
|
+
* Both wrapped: Safari in private mode THROWS on localStorage access rather than
|
|
129
|
+
* returning null, and a table that will not render because a preference could not be
|
|
130
|
+
* read is a worse table than one that always starts at 20. Same shape as the theme
|
|
131
|
+
* read every page in the estate does pre-paint.
|
|
132
|
+
*/
|
|
133
|
+
const storedSize = (id) => {
|
|
134
|
+
try {
|
|
135
|
+
return normalizePageSize(localStorage.getItem(STORE_PREFIX + id));
|
|
136
|
+
} catch {
|
|
137
|
+
return DEFAULT_PAGE_SIZE;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const storeSize = (id, size) => {
|
|
141
|
+
try {
|
|
142
|
+
localStorage.setItem(STORE_PREFIX + id, String(size));
|
|
143
|
+
} catch {
|
|
144
|
+
/* nothing to do and nothing to say — the size still applies for this visit */
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/* ── the component ────────────────────────────────────────────────────────────────*/
|
|
149
|
+
|
|
150
|
+
/*
|
|
151
|
+
* The rows this pager owns: the first tbody's direct rows, minus any the renderer
|
|
152
|
+
* marked `data-table-placeholder`. That opt-out exists because an engine's
|
|
153
|
+
* "Nothing matches these filters." row is a full-width message wearing a <tr>, and
|
|
154
|
+
* counting it would report "1 of 1" for an empty table and give it a page.
|
|
155
|
+
*/
|
|
156
|
+
function dataRows(table) {
|
|
157
|
+
const body = table.tBodies[0];
|
|
158
|
+
if (!body) return [];
|
|
159
|
+
const rows = [];
|
|
160
|
+
for (const row of body.rows) if (!row.hasAttribute("data-table-placeholder")) rows.push(row);
|
|
161
|
+
return rows;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/*
|
|
165
|
+
* Built ONCE per table and then only updated — the status text and the two disabled
|
|
166
|
+
* flags. Rebuilding it per page change would destroy and re-create the <select>, which
|
|
167
|
+
* initSelects() has enhanced: the reader would lose the open list mid-click and the
|
|
168
|
+
* focus with it.
|
|
169
|
+
*
|
|
170
|
+
* The picker is a plain <select> with no class and no wrapper, which is the entire
|
|
171
|
+
* markup contract of the estate's dropdown — initSelects() finds it here exactly as it
|
|
172
|
+
* finds one written by hand, including the ones this creates long after page load.
|
|
173
|
+
* Hand-rolling a second picker beside the system's is how five copies of `.cfg-sel`
|
|
174
|
+
* happened.
|
|
175
|
+
*
|
|
176
|
+
* The label WRAPS the select and carries no aria-label, on purpose: that is the branch
|
|
177
|
+
* of initSelects()'s naming that makes the trigger announce the label AND the current
|
|
178
|
+
* value ("rows, 20"), the way a native select does. An aria-label here would replace
|
|
179
|
+
* the whole name and drop the value from it.
|
|
180
|
+
*/
|
|
181
|
+
function buildBar(instance) {
|
|
182
|
+
const bar = document.createElement("div");
|
|
183
|
+
bar.className = "table-pager";
|
|
184
|
+
|
|
185
|
+
const status = document.createElement("p");
|
|
186
|
+
status.className = "table-pager-status";
|
|
187
|
+
// role="status" is aria-live="polite" with a sensible default: turning the page
|
|
188
|
+
// changes nothing a screen reader would otherwise be told about, because the rows
|
|
189
|
+
// themselves are not focused.
|
|
190
|
+
status.setAttribute("role", "status");
|
|
191
|
+
|
|
192
|
+
const label = document.createElement("label");
|
|
193
|
+
label.className = "table-pager-size";
|
|
194
|
+
label.append("rows ");
|
|
195
|
+
const select = document.createElement("select");
|
|
196
|
+
for (const size of PAGE_SIZES) {
|
|
197
|
+
const option = document.createElement("option");
|
|
198
|
+
option.value = String(size);
|
|
199
|
+
option.textContent = String(size);
|
|
200
|
+
if (size === instance.size) option.selected = true;
|
|
201
|
+
select.appendChild(option);
|
|
202
|
+
}
|
|
203
|
+
label.appendChild(select);
|
|
204
|
+
|
|
205
|
+
const nav = document.createElement("div");
|
|
206
|
+
nav.className = "table-pager-nav";
|
|
207
|
+
const button = (text) => {
|
|
208
|
+
const element = document.createElement("button");
|
|
209
|
+
element.type = "button";
|
|
210
|
+
element.className = "btn-terminal btn-terminal--ghost btn-terminal--compact";
|
|
211
|
+
element.textContent = text;
|
|
212
|
+
return element;
|
|
213
|
+
};
|
|
214
|
+
const previous = button("← prev");
|
|
215
|
+
const next = button("next →");
|
|
216
|
+
nav.append(previous, next);
|
|
217
|
+
|
|
218
|
+
bar.append(status, label, nav);
|
|
219
|
+
|
|
220
|
+
previous.addEventListener("click", () => {
|
|
221
|
+
instance.page -= 1;
|
|
222
|
+
render(instance);
|
|
223
|
+
});
|
|
224
|
+
next.addEventListener("click", () => {
|
|
225
|
+
instance.page += 1;
|
|
226
|
+
render(instance);
|
|
227
|
+
});
|
|
228
|
+
select.addEventListener("change", () => {
|
|
229
|
+
instance.size = normalizePageSize(select.value);
|
|
230
|
+
storeSize(instance.id, instance.size);
|
|
231
|
+
// Back to the top: after "show me 200" the reader is asking to see the set, and
|
|
232
|
+
// landing on page 4 of a re-cut table is disorienting in a way clamping is not.
|
|
233
|
+
instance.page = 1;
|
|
234
|
+
render(instance);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
Object.assign(instance, { bar, status, select, previous, next });
|
|
238
|
+
return bar;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function render(instance) {
|
|
242
|
+
const rows = dataRows(instance.table);
|
|
243
|
+
const total = rows.length;
|
|
244
|
+
const window_ = pageWindow(total, instance.size, instance.page);
|
|
245
|
+
instance.page = window_.page;
|
|
246
|
+
applyPageWindow(rows, window_.from, window_.to);
|
|
247
|
+
|
|
248
|
+
/*
|
|
249
|
+
* WHEN THE BAR IS THERE AT ALL. A control that can never do anything is noise, and
|
|
250
|
+
* most of the estate's tables are short reference tables — four machines, three
|
|
251
|
+
* agents. So: only when there is more than one page of rows to look at.
|
|
252
|
+
*
|
|
253
|
+
* The second clause is what stops that rule from being a trap. Pick 100 on a 30-row
|
|
254
|
+
* table and the first clause alone would remove the very control that was just used,
|
|
255
|
+
* with no way back to 20. While a non-default size is in force the bar stays.
|
|
256
|
+
*/
|
|
257
|
+
const useful = total > instance.size || instance.size !== DEFAULT_PAGE_SIZE;
|
|
258
|
+
instance.bar.hidden = !useful;
|
|
259
|
+
if (!useful) return;
|
|
260
|
+
|
|
261
|
+
instance.status.textContent = total ? `${window_.from + 1}–${window_.to} of ${total}` : "no rows";
|
|
262
|
+
instance.previous.disabled = window_.page <= 1;
|
|
263
|
+
instance.next.disabled = window_.page >= window_.pageCount;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function enhance(table) {
|
|
267
|
+
if (enhanced.has(table)) return;
|
|
268
|
+
|
|
269
|
+
const id = table.dataset.tableId;
|
|
270
|
+
if (!id) {
|
|
271
|
+
// Only worth saying when the omission cost something — see IDENTITY above.
|
|
272
|
+
if (dataRows(table).length > DEFAULT_PAGE_SIZE) {
|
|
273
|
+
console.warn(
|
|
274
|
+
"[design] this table is long enough to paginate but has no data-table-id, so it is showing every row. " +
|
|
275
|
+
"Add a stable data-table-id to page it and remember the reader's rows-per-page.",
|
|
276
|
+
table,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const instance = { table, id, size: storedSize(id), page: 1 };
|
|
283
|
+
enhanced.set(table, instance);
|
|
284
|
+
buildBar(instance);
|
|
285
|
+
|
|
286
|
+
// After the scroll wrapper, never inside it: `.tablewrap` scrolls horizontally, and a
|
|
287
|
+
// pager parked in there slides out of reach on exactly the wide tables that need it.
|
|
288
|
+
// `closest` because initTableScroll may not have run yet — and if it runs later it
|
|
289
|
+
// wraps the <table> alone, so the bar stays put either way.
|
|
290
|
+
const anchor = table.closest(".tablewrap") || table;
|
|
291
|
+
anchor.after(instance.bar);
|
|
292
|
+
|
|
293
|
+
/*
|
|
294
|
+
* Rows change under us constantly: a filter keystroke, a 30s poll, a sort. childList
|
|
295
|
+
* catches a re-render; the `hidden` attributeFilter catches an in-place patcher that
|
|
296
|
+
* has stripped our own attribute off a row it kept (cockpit's dom-patch.js removes
|
|
297
|
+
* any attribute the incoming markup lacks — correct in general, and this is the
|
|
298
|
+
* exception, so it also exempts `hidden` on a <tr>). applyPageWindow() writes only
|
|
299
|
+
* real changes, so this observer cannot be woken by its own output.
|
|
300
|
+
*
|
|
301
|
+
* WATCHING THE TABLE, NOT THE TBODY, and the difference is not caution. A renderer is
|
|
302
|
+
* entitled to REPLACE the tbody rather than fill it — `tbody.outerHTML = …` is how
|
|
303
|
+
* cockpit's docs page still repaints, and it swaps in a new node. An observer bound to
|
|
304
|
+
* the old tbody would be left watching a detached element: the first paint would be
|
|
305
|
+
* paged and every one after it silently unpaged. The table element survives that, and
|
|
306
|
+
* `dataRows()` re-reads `tBodies[0]` on every render, so a swapped body is simply the
|
|
307
|
+
* body now.
|
|
308
|
+
*/
|
|
309
|
+
instance.observer = new MutationObserver(() => render(instance));
|
|
310
|
+
instance.observer.observe(table, { childList: true, attributes: true, attributeFilter: ["hidden"], subtree: true });
|
|
311
|
+
render(instance);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Page every `<table data-table-id>` to 20 rows, and keep doing so for tables rendered
|
|
316
|
+
* later.
|
|
317
|
+
*
|
|
318
|
+
* Filtering and sorting are none of this function's business and stay with whatever
|
|
319
|
+
* already owns them — it slices the rows it finds, which are by definition the whole
|
|
320
|
+
* set after that work has happened.
|
|
321
|
+
*
|
|
322
|
+
* @param {ParentNode} [root=document] Where to look for the initial pass.
|
|
323
|
+
*/
|
|
324
|
+
export function initTablePagination(root = document) {
|
|
325
|
+
for (const table of root.querySelectorAll("table")) enhance(table);
|
|
326
|
+
|
|
327
|
+
if (documentObserver) return;
|
|
328
|
+
// Same reasoning as initSelects(): cockpit rebuilds whole panels out of innerHTML on
|
|
329
|
+
// a poll, so a pager that only enhanced what existed at load would work until the
|
|
330
|
+
// first refresh and then quietly stop.
|
|
331
|
+
documentObserver = new MutationObserver((records) => {
|
|
332
|
+
for (const record of records) {
|
|
333
|
+
for (const node of record.addedNodes) {
|
|
334
|
+
if (node.nodeType !== 1) continue;
|
|
335
|
+
if (node.tagName === "TABLE") enhance(node);
|
|
336
|
+
else for (const table of node.querySelectorAll("table")) enhance(table);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
documentObserver.observe(document.documentElement, { childList: true, subtree: true });
|
|
341
|
+
}
|