@apliteni/apliteni-ui 0.31.0 → 0.33.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 +33 -34
- package/package.json +1 -1
- package/react/README.md +7 -9
- package/react/dist/index.css +10 -2
- package/react/dist/index.d.ts +25 -1
- package/react/dist/index.js +136 -92
- package/src/components/dropdown.js +77 -30
- package/src/components/footer.js +6 -1
- package/src/components/index.js +2 -2
- package/src/components/nav.js +4 -3
- package/src/components/pagination.js +9 -31
- package/src/components/shell.js +197 -30
- package/src/components/stat.js +75 -0
- package/src/components/success.js +12 -1
- package/src/index.css +2 -0
- package/src/index.js +1 -0
- package/src/inline.js +4 -0
- package/src/styles/button.css +6 -5
- package/src/styles/callout.css +7 -4
- package/src/styles/card.css +12 -19
- package/src/styles/command-palette.css +6 -5
- package/src/styles/confirm.css +10 -19
- package/src/styles/drawer.css +6 -6
- package/src/styles/dropdown.css +36 -20
- package/src/styles/feedback.css +11 -13
- package/src/styles/field-zoom.css +20 -0
- package/src/styles/input.css +17 -2
- package/src/styles/layout.css +208 -57
- package/src/styles/motion.css +3 -3
- package/src/styles/nav.css +88 -10
- package/src/styles/pagination.css +1 -0
- package/src/styles/segmented.css +1 -2
- package/src/styles/stat.css +121 -0
- package/src/styles/success.css +2 -2
- package/src/styles/tooltip.css +3 -4
- package/src/styles/topbar.css +36 -10
- package/src/tokens/accents.css +7 -6
- package/src/tokens/tokens.css +51 -38
|
@@ -222,7 +222,27 @@ export function dropdown({
|
|
|
222
222
|
// Per-instance trigger + keyboard handlers are attached once (guarded by a flag
|
|
223
223
|
// on the element). Document-level click-outside + Esc are attached once per
|
|
224
224
|
// document. Safe to call repeatedly (e.g. Storybook re-renders).
|
|
225
|
-
|
|
225
|
+
|
|
226
|
+
// Every wired container, so the close handlers can reach a dropdown wherever it
|
|
227
|
+
// was drawn: `document.querySelectorAll` enters no shadow root and sees no other
|
|
228
|
+
// document. A Set and not a WeakSet, because this has to be walked.
|
|
229
|
+
// why: docs/specification.md#the-dropdown-panel
|
|
230
|
+
const _ddAll = new Set();
|
|
231
|
+
// One pair of close handlers per document that holds a dropdown, the same way
|
|
232
|
+
// wireShell() listens once per document it is handed.
|
|
233
|
+
const _ddWiredDocs = new WeakSet();
|
|
234
|
+
|
|
235
|
+
/** Where a portalled panel goes: the top of the tree its trigger lives in. A
|
|
236
|
+
* shadow root is its own top — moving the panel out to the page's <body> would
|
|
237
|
+
* leave every style scoped to that root behind. */
|
|
238
|
+
const ddHostOf = (node) => {
|
|
239
|
+
const root = node.getRootNode ? node.getRootNode() : node.ownerDocument;
|
|
240
|
+
return root.nodeType === 9 ? root.body : root;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/** The window a box is measured in. A panel in a frame is laid out against the
|
|
244
|
+
* frame's viewport, not the top page's. */
|
|
245
|
+
const ddViewOf = (node) => node.ownerDocument?.defaultView || window;
|
|
226
246
|
|
|
227
247
|
// The trigger-to-panel offset is --ui-dropdown-gap in src/styles/dropdown.css.
|
|
228
248
|
// This is the fallback for a document that has not loaded the sheet;
|
|
@@ -234,7 +254,7 @@ const DD_GAP = 9;
|
|
|
234
254
|
const ddPanelOf = (dd) => dd.__ddPanel || dd.querySelector('[data-dropdown-panel]');
|
|
235
255
|
|
|
236
256
|
function ddGap(panel) {
|
|
237
|
-
const declared = parseFloat(getComputedStyle(panel).getPropertyValue('--ui-dropdown-gap'));
|
|
257
|
+
const declared = parseFloat(ddViewOf(panel).getComputedStyle(panel).getPropertyValue('--ui-dropdown-gap'));
|
|
238
258
|
return Number.isFinite(declared) ? declared : DD_GAP;
|
|
239
259
|
}
|
|
240
260
|
|
|
@@ -305,7 +325,7 @@ function ddResolveDirection(dd, panel) {
|
|
|
305
325
|
const trigger = dd.querySelector('[data-dropdown-trigger]');
|
|
306
326
|
if (!trigger || typeof trigger.getBoundingClientRect !== 'function') return;
|
|
307
327
|
const t = trigger.getBoundingClientRect();
|
|
308
|
-
const below =
|
|
328
|
+
const below = ddViewOf(panel).innerHeight - t.bottom;
|
|
309
329
|
panel.classList.toggle('is-up', below < panel.offsetHeight + ddGap(panel) && t.top > below);
|
|
310
330
|
}
|
|
311
331
|
|
|
@@ -314,29 +334,31 @@ function ddResolveDirection(dd, panel) {
|
|
|
314
334
|
function positionPortalPanel(dd, panel) {
|
|
315
335
|
const trigger = dd.querySelector('[data-dropdown-trigger]');
|
|
316
336
|
if (!trigger || typeof trigger.getBoundingClientRect !== 'function') return;
|
|
337
|
+
const view = ddViewOf(panel);
|
|
317
338
|
const t = trigger.getBoundingClientRect();
|
|
318
339
|
const gap = ddGap(panel);
|
|
319
340
|
const s = panel.style;
|
|
320
341
|
if (panel.classList.contains('is-up')) {
|
|
321
342
|
s.top = 'auto';
|
|
322
|
-
s.bottom = `${
|
|
343
|
+
s.bottom = `${view.innerHeight - t.top + gap}px`;
|
|
323
344
|
} else {
|
|
324
345
|
s.bottom = 'auto';
|
|
325
346
|
s.top = `${t.bottom + gap}px`;
|
|
326
347
|
}
|
|
327
348
|
if (panel.classList.contains('is-end')) {
|
|
328
349
|
s.left = 'auto';
|
|
329
|
-
s.right = `${
|
|
350
|
+
s.right = `${view.innerWidth - t.right}px`;
|
|
330
351
|
} else {
|
|
331
352
|
s.right = 'auto';
|
|
332
353
|
s.left = `${t.left}px`;
|
|
333
354
|
}
|
|
334
355
|
}
|
|
335
356
|
|
|
336
|
-
// A panel
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
|
|
357
|
+
// A portalled panel outlives the container that owned it — a re-render replaces
|
|
358
|
+
// the container and the old panel has nothing pointing at it. Swept in the host
|
|
359
|
+
// it was put in, which is the tree the new container is in too.
|
|
360
|
+
function sweepOrphanPanels(host) {
|
|
361
|
+
host.querySelectorAll(':scope > [data-dropdown-panel][data-dropdown-portal]')
|
|
340
362
|
.forEach((p) => { if (!p.__ddOwner || !p.__ddOwner.isConnected) p.remove(); });
|
|
341
363
|
}
|
|
342
364
|
|
|
@@ -347,8 +369,19 @@ function closeDropdown(dd) {
|
|
|
347
369
|
dd.querySelector('[data-dropdown-trigger]')?.setAttribute('aria-expanded', 'false');
|
|
348
370
|
}
|
|
349
371
|
|
|
372
|
+
/** Every wired dropdown still in a tree, the ones in frames and shadow roots
|
|
373
|
+
* included. Disconnected containers are dropped as they are passed. */
|
|
374
|
+
function ddLive() {
|
|
375
|
+
const out = [];
|
|
376
|
+
for (const dd of _ddAll) {
|
|
377
|
+
if (dd.isConnected) out.push(dd);
|
|
378
|
+
else _ddAll.delete(dd);
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
|
|
350
383
|
function closeAllDropdowns(except) {
|
|
351
|
-
|
|
384
|
+
for (const dd of ddLive()) if (dd !== except && dd.classList.contains('open')) closeDropdown(dd);
|
|
352
385
|
}
|
|
353
386
|
|
|
354
387
|
function openDropdown(dd, focusIdx) {
|
|
@@ -390,11 +423,39 @@ function selectOption(dd, item) {
|
|
|
390
423
|
if (valueEl && label) valueEl.textContent = label.textContent;
|
|
391
424
|
}
|
|
392
425
|
|
|
426
|
+
// Click-outside, Escape and the repositioning sweep, registered once per document
|
|
427
|
+
// that holds a dropdown — the same shape wireShell()'s listen() has, and for the
|
|
428
|
+
// same reason: a frame is its own document and a listener on the page's never
|
|
429
|
+
// fires there. why: docs/specification.md#the-dropdown-panel
|
|
430
|
+
function ddListen(doc) {
|
|
431
|
+
if (!doc || _ddWiredDocs.has(doc)) return;
|
|
432
|
+
_ddWiredDocs.add(doc);
|
|
433
|
+
doc.addEventListener('click', () => closeAllDropdowns());
|
|
434
|
+
doc.addEventListener('keydown', (e) => {
|
|
435
|
+
if (e.key !== 'Escape' || ddComposing(e)) return;
|
|
436
|
+
const open = ddLive().find((dd) => dd.classList.contains('open') && dd.ownerDocument === doc);
|
|
437
|
+
if (open) { closeDropdown(open); open.querySelector('[data-dropdown-trigger]')?.focus(); }
|
|
438
|
+
});
|
|
439
|
+
// Viewport coordinates go stale the moment anything scrolls. Capture, so a
|
|
440
|
+
// scroll inside the rail the panel was lifted out of counts too.
|
|
441
|
+
const reposition = () => {
|
|
442
|
+
for (const dd of ddLive()) {
|
|
443
|
+
if (dd.classList.contains('open') && dd.__ddPanel) positionPortalPanel(dd, dd.__ddPanel);
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
const view = doc.defaultView;
|
|
447
|
+
if (!view) return;
|
|
448
|
+
view.addEventListener('scroll', reposition, true);
|
|
449
|
+
view.addEventListener('resize', reposition);
|
|
450
|
+
}
|
|
451
|
+
|
|
393
452
|
export function wireDropdown(root = document) {
|
|
394
453
|
const scope = root === document ? document : root;
|
|
395
454
|
scope.querySelectorAll('[data-dropdown]').forEach((dd) => {
|
|
396
455
|
if (dd.__ddWired) return;
|
|
397
456
|
dd.__ddWired = true;
|
|
457
|
+
_ddAll.add(dd);
|
|
458
|
+
ddListen(dd.ownerDocument);
|
|
398
459
|
const trigger = dd.querySelector('[data-dropdown-trigger]');
|
|
399
460
|
const panel = dd.querySelector('[data-dropdown-panel]');
|
|
400
461
|
if (!trigger) return;
|
|
@@ -404,11 +465,15 @@ export function wireDropdown(root = document) {
|
|
|
404
465
|
// a stacking context whatever z-index the panel carries — the app rail is
|
|
405
466
|
// both at once. why: docs/specification.md#the-dropdown-panel
|
|
406
467
|
if (panel && dd.hasAttribute('data-dropdown-portal')) {
|
|
407
|
-
|
|
468
|
+
// The tree the trigger is in, not the page's: a panel lifted out of a
|
|
469
|
+
// frame or a shadow root into the top document leaves its stylesheet and
|
|
470
|
+
// its close handler behind. why: docs/specification.md#the-dropdown-panel
|
|
471
|
+
const host = ddHostOf(dd);
|
|
472
|
+
sweepOrphanPanels(host);
|
|
408
473
|
panel.setAttribute('data-dropdown-portal', '');
|
|
409
474
|
panel.__ddOwner = dd;
|
|
410
475
|
dd.__ddPanel = panel;
|
|
411
|
-
|
|
476
|
+
host.appendChild(panel);
|
|
412
477
|
if (dd.classList.contains('open')) {
|
|
413
478
|
ddResolveDirection(dd, panel);
|
|
414
479
|
positionPortalPanel(dd, panel);
|
|
@@ -508,22 +573,4 @@ export function wireDropdown(root = document) {
|
|
|
508
573
|
}
|
|
509
574
|
});
|
|
510
575
|
|
|
511
|
-
if (!_ddGlobalWired) {
|
|
512
|
-
_ddGlobalWired = true;
|
|
513
|
-
document.addEventListener('click', () => closeAllDropdowns());
|
|
514
|
-
document.addEventListener('keydown', (e) => {
|
|
515
|
-
if (e.key !== 'Escape' || ddComposing(e)) return;
|
|
516
|
-
const open = document.querySelector('[data-dropdown].open');
|
|
517
|
-
if (open) { closeDropdown(open); open.querySelector('[data-dropdown-trigger]')?.focus(); }
|
|
518
|
-
});
|
|
519
|
-
// Viewport coordinates go stale the moment anything scrolls. Capture, so a
|
|
520
|
-
// scroll inside the rail the panel was lifted out of counts too.
|
|
521
|
-
const reposition = () => {
|
|
522
|
-
document.querySelectorAll('[data-dropdown].open').forEach((dd) => {
|
|
523
|
-
if (dd.__ddPanel) positionPortalPanel(dd, dd.__ddPanel);
|
|
524
|
-
});
|
|
525
|
-
};
|
|
526
|
-
window.addEventListener('scroll', reposition, true);
|
|
527
|
-
window.addEventListener('resize', reposition);
|
|
528
|
-
}
|
|
529
576
|
}
|
package/src/components/footer.js
CHANGED
|
@@ -32,9 +32,14 @@ function linkList(links = []) {
|
|
|
32
32
|
`<li><a href="${href}"${target ? ` target="${target}" rel="noreferrer"` : ''}>${esc(label)}</a></li>`).join('')}</ul>`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// The column title is an h2: it names a top-level section of the page's end
|
|
36
|
+
// matter, and h2 is the one rank that cannot skip whatever heading came before
|
|
37
|
+
// it. It was an h4 — the only h4 the kit drew — which read h2 → h4 on the
|
|
38
|
+
// landing page and left a rank a reader hears missing. The look is the class's,
|
|
39
|
+
// not the tag's. why: docs/specification.md#the-page
|
|
35
40
|
function column({ title, links = [] } = {}) {
|
|
36
41
|
return `<div class="ui-footer__col">` +
|
|
37
|
-
(title ? `<
|
|
42
|
+
(title ? `<h2 class="ui-footer__col-title">${esc(title)}</h2>` : '') +
|
|
38
43
|
linkList(links) + `</div>`;
|
|
39
44
|
}
|
|
40
45
|
|
package/src/components/index.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// viz/ server-render idiom so the portal can adopt them with no framework.
|
|
3
3
|
import { icon } from '../assets/icons.js';
|
|
4
4
|
import { illo } from '../assets/illustrations.js';
|
|
5
|
-
|
|
5
|
+
const HTML_ENTITIES = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
6
6
|
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
7
|
-
export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) =>
|
|
7
|
+
export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => HTML_ENTITIES[c]);
|
|
8
8
|
|
|
9
9
|
// ---- Button --------------------------------------------------------------
|
|
10
10
|
// `iconSvg` is a raw leading-icon SVG string (trusted markup, not escaped) for
|
package/src/components/nav.js
CHANGED
|
@@ -68,12 +68,13 @@ function sideLeaf(it, active, { collapsed, sub, current = 'page' } = {}) {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
// A collapsible group: a toggle button (aria-expanded/-controls) over a nested
|
|
71
|
-
// list.
|
|
72
|
-
//
|
|
71
|
+
// list. It opens and closes the same way on the folded rail: forcing it shut
|
|
72
|
+
// there hid the current page's own row and left the toggle announcing a list
|
|
73
|
+
// nobody could reach.
|
|
73
74
|
function sideGroup(it, active, { collapsed, current } = {}) {
|
|
74
75
|
const listId = nextId('nav-grp');
|
|
75
76
|
const childActive = (it.items || []).some((c) => c.id != null && c.id === active);
|
|
76
|
-
const open =
|
|
77
|
+
const open = it.open != null ? !!it.open : childActive;
|
|
77
78
|
const lead = it.icon ? `<span class="ui-nav__ic">${icon(it.icon)}</span>` : '';
|
|
78
79
|
const label = it.label || '';
|
|
79
80
|
const text = `<span class="ui-nav__label">${esc(label)}</span>`;
|
|
@@ -236,29 +236,12 @@ export function pagination({
|
|
|
236
236
|
}
|
|
237
237
|
|
|
238
238
|
/**
|
|
239
|
-
* Make a rendered pager work
|
|
239
|
+
* Make a rendered pager work: delegate the steps, the size control and the jump
|
|
240
|
+
* input from `root`, so a pager re-rendered underneath stays wired.
|
|
240
241
|
*
|
|
241
|
-
*
|
|
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.
|
|
242
|
+
* @returns {() => void} a function that removes the listeners.
|
|
246
243
|
*
|
|
247
|
-
*
|
|
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.
|
|
244
|
+
* why: CONTRIBUTING.md#pagination-event-wiring
|
|
262
245
|
*/
|
|
263
246
|
export function wirePagination(root = document, { onPage, onPageSize } = {}) {
|
|
264
247
|
const scope = typeof root === 'string' ? document.querySelector(root) : root;
|
|
@@ -321,18 +304,13 @@ export function wirePagination(root = document, { onPage, onPageSize } = {}) {
|
|
|
321
304
|
}
|
|
322
305
|
|
|
323
306
|
/**
|
|
324
|
-
* Rewrite a pager's row range
|
|
307
|
+
* Rewrite a pager's row range in place, so the live region it already holds is
|
|
308
|
+
* the thing that announces — as `setBusy()` does.
|
|
325
309
|
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
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
|
|
310
|
+
* @returns {Element|null} the status element, or null when there is nothing to
|
|
311
|
+
* update — safe against a torn-down view.
|
|
332
312
|
*
|
|
333
|
-
*
|
|
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.
|
|
313
|
+
* why: CONTRIBUTING.md#pagination-status-updates
|
|
336
314
|
*/
|
|
337
315
|
export function setPagerStatus(root, text) {
|
|
338
316
|
const el = typeof root === 'string' ? document.querySelector(root) : root;
|
package/src/components/shell.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
// The kit's one page shell
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// over it that keeps the topbar, so the published /account API still works.
|
|
5
|
-
// Call wireTopbar() once after mounting to wire the account menu + theme toggle.
|
|
1
|
+
// The kit's one page shell: a full-height rail beside one <main>. accountShell()
|
|
2
|
+
// is a thin preset over it that keeps the topbar. wireShell() once after mounting
|
|
3
|
+
// wires the fold, the nav's groups and the reader's menu.
|
|
6
4
|
// why: docs/specification.md#the-page-shell
|
|
7
5
|
import { topbar as productTopbar } from './topbar.js';
|
|
8
6
|
import { esc, icon } from './index.js';
|
|
9
|
-
import { sidebarNav, breadcrumbs } from './nav.js';
|
|
7
|
+
import { sidebarNav, breadcrumbs, wireNav } from './nav.js';
|
|
8
|
+
import { dropdown, wireDropdown } from './dropdown.js';
|
|
10
9
|
import { backLink } from './back.js';
|
|
11
10
|
import { prism } from '../assets/brand.js';
|
|
12
11
|
import { ACCOUNT_NAV, toMenuTuple, initials } from './account-nav.js';
|
|
@@ -91,10 +90,58 @@ const mainMax = (v) => {
|
|
|
91
90
|
return s === 'none' || LENGTH.test(s) ? s : '';
|
|
92
91
|
};
|
|
93
92
|
|
|
93
|
+
// ---- the fold, and where the reader's choice is kept ---------------------
|
|
94
|
+
//
|
|
95
|
+
// A cookie rather than localStorage, because a server can read one and paint the
|
|
96
|
+
// rail at the width the reader left it. why: docs/specification.md#the-page-shell
|
|
97
|
+
export const RAIL_COOKIE = 'apliteni-ui-rail';
|
|
98
|
+
const RAIL_MAX_AGE = 60 * 60 * 24 * 365;
|
|
99
|
+
const RAIL_VALUE = new RegExp(`(?:^|;\\s*)${RAIL_COOKIE}=(collapsed|expanded)(?:;|$)`);
|
|
100
|
+
|
|
101
|
+
// A sandboxed frame throws on document.cookie; it has no stored choice.
|
|
102
|
+
const cookieOf = (doc) => {
|
|
103
|
+
try { return doc ? doc.cookie : ''; } catch (e) { return ''; }
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** The reader's stored choice — true, false, or null when there is none. With no
|
|
107
|
+
* argument it reads `document.cookie`; handed a Cookie header, only that. */
|
|
108
|
+
export function railCollapsed(cookies) {
|
|
109
|
+
const src = arguments.length ? cookies : cookieOf(typeof document === 'undefined' ? null : document);
|
|
110
|
+
const m = RAIL_VALUE.exec(String(src ?? ''));
|
|
111
|
+
return m ? m[1] === 'collapsed' : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The name says what the press will do, and aria-expanded says what the rail is.
|
|
115
|
+
const railName = (collapsed) => (collapsed ? 'Expand sidebar' : 'Collapse sidebar');
|
|
116
|
+
|
|
117
|
+
// A frame that holds still and a seam that crosses it. Only the two nodes are
|
|
118
|
+
// written here — a seam that travels has to be a child a stylesheet can reach —
|
|
119
|
+
// and they are spliced into icon()'s own wrapper rather than a copy of it.
|
|
120
|
+
// why: docs/specification.md#the-page-shell
|
|
121
|
+
const MARK = '<rect x="3" y="3" width="18" height="18" rx="2"/>'
|
|
122
|
+
+ '<path class="ui-app__fold-seam" d="M9 3v18"/>';
|
|
123
|
+
const railMark = () => icon('').replace('></svg>', `>${MARK}</svg>`);
|
|
124
|
+
|
|
125
|
+
// The rail's own skin, outside the <nav>: folding the rail is not a place to go.
|
|
126
|
+
// It stands at the far end of the head band's brand row, and its name is written
|
|
127
|
+
// out rather than put in a tooltip, because the name IS the chip layout.css lands
|
|
128
|
+
// beside the glyph — at both widths. why: docs/specification.md#the-page-shell
|
|
129
|
+
const railToggle = (collapsed) =>
|
|
130
|
+
`<div class="ui-app__fold-row">`
|
|
131
|
+
+ `<button type="button" class="ui-nav__item ui-app__fold" data-rail-toggle`
|
|
132
|
+
+ ` aria-expanded="${collapsed ? 'false' : 'true'}" aria-label="${railName(collapsed)}">`
|
|
133
|
+
+ `<span class="ui-nav__ic">${railMark()}</span>`
|
|
134
|
+
+ `<span class="ui-nav__label">${railName(collapsed)}</span></button></div>`;
|
|
135
|
+
|
|
94
136
|
// The one pass. Each key names the function that settles it; nothing else in
|
|
95
137
|
// this file re-checks a value that has been through here.
|
|
96
138
|
const SHAPES = {
|
|
97
139
|
nav: toItems, crumbs: toCrumbs, back: toBack, account: toReader, maxWidth: mainMax, topbar: toTopbar,
|
|
140
|
+
// Drawn by default; `collapsible: false` is the way out, for a page that will
|
|
141
|
+
// never call wireShell(). why: docs/specification.md#the-page-shell
|
|
142
|
+
collapsible: (v) => v !== false,
|
|
143
|
+
// A boolean is the caller's answer. Anything else leaves it to the reader.
|
|
144
|
+
collapsed: (v) => (typeof v === 'boolean' ? v : null),
|
|
98
145
|
};
|
|
99
146
|
|
|
100
147
|
// The text options settle by the same argument. `body: null` from a record with no
|
|
@@ -109,28 +156,46 @@ function settle(options) {
|
|
|
109
156
|
return out;
|
|
110
157
|
}
|
|
111
158
|
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
`<span class="ui-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
159
|
+
// The face of the reader block: the initials, and the two lines beside them that
|
|
160
|
+
// the fold takes away. `named` is the accessible name when nothing else carries
|
|
161
|
+
// one; under the menu trigger it is null, because the button is named by the words
|
|
162
|
+
// inside it. why: docs/specification.md#the-page-shell
|
|
163
|
+
const readerFace = (name, email, named) =>
|
|
164
|
+
`<span class="ui-app__av"${named ? ` role="img" aria-label="Signed in as ${esc(named)}"` : ' aria-hidden="true"'}>`
|
|
165
|
+
+ `${esc(initials(name, email))}</span>`
|
|
166
|
+
+ `<span class="ui-app__who"${named ? ' aria-hidden="true"' : ''}>`
|
|
167
|
+
+ (name ? `<b>${esc(name)}</b>` : '')
|
|
168
|
+
+ (email ? `<span>${esc(email)}</span>` : '')
|
|
169
|
+
+ `</span>`;
|
|
170
|
+
|
|
171
|
+
// Who is signed in, and the one action on the session. A sibling of the <nav>, not
|
|
172
|
+
// its footer: a name and address are not navigation. Given a sign-out href the block
|
|
173
|
+
// is the trigger of the kit's own dropdown(), with Sign out inside it; without one
|
|
174
|
+
// there is no menu, and with nobody signed in there is no block.
|
|
175
|
+
// `portal: true` and `direction: 'up'` are what the rail asks of a panel at its foot.
|
|
176
|
+
// why: docs/specification.md#the-page-shell
|
|
177
|
+
function railUser({ name, email }, signOutHref) {
|
|
125
178
|
if (!name && !email) return '';
|
|
126
179
|
const who = [name, email].filter(Boolean).join(', ');
|
|
127
|
-
|
|
128
|
-
`<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
180
|
+
if (!signOutHref) {
|
|
181
|
+
return `<div class="ui-app__user">${readerFace(name, email, who)}</div>`;
|
|
182
|
+
}
|
|
183
|
+
// The head says who the menu belongs to — and on a folded rail it is the only place
|
|
184
|
+
// a sighted reader can read the address. why: docs/specification.md#the-page-shell
|
|
185
|
+
const head = `<div class="ui-dropdown__head">`
|
|
186
|
+
+ (name ? `<b>${esc(name)}</b>` : '')
|
|
187
|
+
+ (email ? `<span>${esc(email)}</span>` : '')
|
|
188
|
+
+ `</div>`;
|
|
189
|
+
return `<div class="ui-app__user">${dropdown({
|
|
190
|
+
variant: 'menu',
|
|
191
|
+
portal: true,
|
|
192
|
+
direction: 'up',
|
|
193
|
+
triggerClass: 'ui-app__user-trigger',
|
|
194
|
+
triggerContent: readerFace(name, email, null),
|
|
195
|
+
panelClass: 'ui-app__user-panel',
|
|
196
|
+
header: head,
|
|
197
|
+
items: [{ label: 'Sign out', icon: 'logout', href: signOutHref, danger: true }],
|
|
198
|
+
})}</div>`;
|
|
134
199
|
}
|
|
135
200
|
|
|
136
201
|
// Unique-per-render suffix for the brand mark's clip id — the same reason
|
|
@@ -155,14 +220,17 @@ export function appShell(options = {}) {
|
|
|
155
220
|
signOutHref = '',
|
|
156
221
|
topbar,
|
|
157
222
|
maxWidth,
|
|
223
|
+
collapsible,
|
|
224
|
+
collapsed,
|
|
158
225
|
} = settle(options);
|
|
159
226
|
const up = back ? backLink(back) : '';
|
|
227
|
+
// No footer slot: the nav is places to go, and the one thing that was in it —
|
|
228
|
+
// sign out — is in the reader's menu at the rail's foot.
|
|
160
229
|
const rail = sidebarNav({
|
|
161
230
|
sections: [{ label: navLabel, items: nav }],
|
|
162
231
|
active,
|
|
163
232
|
activeIs: up ? 'section' : 'page',
|
|
164
233
|
ariaLabel: navLabel,
|
|
165
|
-
footer: signOutHref ? signOut(signOutHref) : '',
|
|
166
234
|
});
|
|
167
235
|
// The topbar already says the product word, so the rail head steps aside when there is
|
|
168
236
|
// one. The word is the link's only text and the narrow rail folds it out of view, so
|
|
@@ -172,11 +240,22 @@ export function appShell(options = {}) {
|
|
|
172
240
|
// A <div>, not an <aside>: <aside> is the `complementary` landmark, and this holds the
|
|
173
241
|
// page's primary navigation and the signed-in reader. The <nav> inside it is already
|
|
174
242
|
// the landmark that names the menu.
|
|
175
|
-
|
|
243
|
+
// A fold needs the toggle that undoes it. `data-rail="auto"` marks a shell whose
|
|
244
|
+
// caller left the choice to the reader; wireShell() applies the stored one there.
|
|
245
|
+
const folded = collapsible && collapsed === true;
|
|
246
|
+
const auto = collapsible && collapsed === null ? ' data-rail="auto"' : '';
|
|
247
|
+
// The head band: the product's mark, and the rail's own control at the far end of
|
|
248
|
+
// the same line, under one rule. Either may be absent — a shell with a topbar says
|
|
249
|
+
// the word up there, and `collapsible: false` draws no toggle — so the band itself
|
|
250
|
+
// goes when both are.
|
|
251
|
+
const head = brand || collapsible
|
|
252
|
+
? `<div class="ui-app__head">${brand}${collapsible ? railToggle(folded) : ''}</div>`
|
|
253
|
+
: '';
|
|
254
|
+
const grid = `<div class="ui-app${folded ? ' is-collapsed' : ''}"${auto}>
|
|
176
255
|
<div class="ui-app__rail">
|
|
177
|
-
${
|
|
256
|
+
${head}
|
|
178
257
|
${rail}
|
|
179
|
-
${railUser(account)}
|
|
258
|
+
${railUser(account, signOutHref)}
|
|
180
259
|
</div>
|
|
181
260
|
<main class="ui-app__main"${maxWidth ? ` style="--ui-app-main: ${maxWidth}"` : ''}>
|
|
182
261
|
${up || (crumbs.length ? breadcrumbs({ items: crumbs }) : '')}
|
|
@@ -188,6 +267,90 @@ export function appShell(options = {}) {
|
|
|
188
267
|
return topbar ? `<div class="ui-app-page">${productTopbar(topbar)}${grid}</div>` : grid;
|
|
189
268
|
}
|
|
190
269
|
|
|
270
|
+
// ---- Behaviour -----------------------------------------------------------
|
|
271
|
+
// One click listener per document, so a shell rendered after wiring folds too
|
|
272
|
+
// and a frame is wired in its own document. wireShell(root, { persist: false })
|
|
273
|
+
// keeps every shell under root out of the cookie, shells drawn there later
|
|
274
|
+
// included; only `persist: true` on that root turns it back on.
|
|
275
|
+
const _wiredDocs = new WeakSet();
|
|
276
|
+
const _unpersisted = new WeakSet();
|
|
277
|
+
|
|
278
|
+
// The toggle, addressed from the rail that owns it: the head band of a shell's own
|
|
279
|
+
// rail and nowhere else, so a stray [data-rail-toggle] in the page body folds nothing.
|
|
280
|
+
// The path is written once — the listener, the reflector and wireShell() all have to
|
|
281
|
+
// mean the same control, and the head band put one more step between them.
|
|
282
|
+
// why: docs/specification.md#the-page-shell
|
|
283
|
+
const FOLD_PATH = '.ui-app__head > .ui-app__fold-row > [data-rail-toggle]';
|
|
284
|
+
const RAIL_FOLD = `.ui-app__rail > ${FOLD_PATH}`;
|
|
285
|
+
|
|
286
|
+
// Under an opted-out root? Steps out of a shadow root through its host.
|
|
287
|
+
const optedOut = (node) => {
|
|
288
|
+
for (let n = node; n; n = n.parentNode || n.host) if (_unpersisted.has(n)) return true;
|
|
289
|
+
return false;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
function setRail(app, collapsed) {
|
|
293
|
+
app.classList.toggle('is-collapsed', collapsed);
|
|
294
|
+
const rail = app.querySelector(':scope > .ui-app__rail');
|
|
295
|
+
if (!rail) return;
|
|
296
|
+
for (const btn of rail.querySelectorAll(`:scope > ${FOLD_PATH}`)) {
|
|
297
|
+
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
|
298
|
+
btn.setAttribute('aria-label', railName(collapsed));
|
|
299
|
+
const label = btn.querySelector('.ui-nav__label');
|
|
300
|
+
if (label) label.textContent = railName(collapsed);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function listen(doc) {
|
|
305
|
+
if (_wiredDocs.has(doc)) return;
|
|
306
|
+
_wiredDocs.add(doc);
|
|
307
|
+
doc.addEventListener('click', (e) => {
|
|
308
|
+
// A shell's own toggle only, in the head of its own rail: a stray
|
|
309
|
+
// [data-rail-toggle] in the page body folds nothing. composedPath() rather
|
|
310
|
+
// than target, so a shell inside an open shadow root is found. closest()
|
|
311
|
+
// rather than a chain of parents, which the head band made one link longer
|
|
312
|
+
// and which said nothing about what it was walking through.
|
|
313
|
+
const btn = e.composedPath().find((n) => n.nodeType === 1 && n.matches(RAIL_FOLD));
|
|
314
|
+
const app = btn && btn.closest('.ui-app');
|
|
315
|
+
if (!app) return;
|
|
316
|
+
const next = !app.classList.contains('is-collapsed');
|
|
317
|
+
setRail(app, next);
|
|
318
|
+
if (!optedOut(app)) {
|
|
319
|
+
try {
|
|
320
|
+
doc.cookie = `${RAIL_COOKIE}=${next ? 'collapsed' : 'expanded'}; path=/; `
|
|
321
|
+
+ `max-age=${RAIL_MAX_AGE}; SameSite=Lax`;
|
|
322
|
+
} catch (err) { /* a sandboxed frame: the fold works, nothing is kept */ }
|
|
323
|
+
}
|
|
324
|
+
// A document with no window (DOMParser, createHTMLDocument) has no CustomEvent to send.
|
|
325
|
+
const view = doc.defaultView;
|
|
326
|
+
if (view) {
|
|
327
|
+
app.dispatchEvent(new view.CustomEvent('ui-rail', { bubbles: true, composed: true, detail: { collapsed: next } }));
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function wireShell(root = document, { persist } = {}) {
|
|
333
|
+
wireNav(root);
|
|
334
|
+
// The reader's menu is a dropdown() like any other, so it is wired like any
|
|
335
|
+
// other. Idempotent, and a shell with no account draws none to find.
|
|
336
|
+
wireDropdown(root);
|
|
337
|
+
const doc = root.nodeType === 9 ? root : root.ownerDocument;
|
|
338
|
+
listen(doc);
|
|
339
|
+
if (persist === false) _unpersisted.add(root);
|
|
340
|
+
else if (persist === true) _unpersisted.delete(root);
|
|
341
|
+
const saved = railCollapsed(cookieOf(doc));
|
|
342
|
+
const apps = [...root.querySelectorAll('.ui-app')];
|
|
343
|
+
if (root.matches && root.matches('.ui-app')) apps.push(root);
|
|
344
|
+
for (const app of apps) {
|
|
345
|
+
if (!app.querySelector(`:scope > ${RAIL_FOLD}`)) continue;
|
|
346
|
+
// The stored choice goes to a shell whose caller left it to the reader — a
|
|
347
|
+
// paint late, where a server drew it without reading the cookie. Any other
|
|
348
|
+
// shell keeps what it was drawn with, and gets its rows' titles if folded.
|
|
349
|
+
const auto = saved != null && !optedOut(app) && app.getAttribute('data-rail') === 'auto';
|
|
350
|
+
setRail(app, auto ? saved : app.classList.contains('is-collapsed'));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
191
354
|
// The /account preset: appShell() with the topbar switched on, and the old
|
|
192
355
|
// `cap` + `crumb` strings folded into the trail the caller now owns.
|
|
193
356
|
export function accountShell({
|
|
@@ -203,6 +366,8 @@ export function accountShell({
|
|
|
203
366
|
sub = '',
|
|
204
367
|
body = '',
|
|
205
368
|
signOutHref = '#logout',
|
|
369
|
+
collapsible = true,
|
|
370
|
+
collapsed,
|
|
206
371
|
} = {}) {
|
|
207
372
|
// The same normaliser appShell() runs, called once here so the rail and the
|
|
208
373
|
// topbar menu are handed one list rather than two readings of `nav`.
|
|
@@ -222,6 +387,8 @@ export function accountShell({
|
|
|
222
387
|
body,
|
|
223
388
|
account,
|
|
224
389
|
signOutHref,
|
|
390
|
+
collapsible,
|
|
391
|
+
collapsed,
|
|
225
392
|
topbar: {
|
|
226
393
|
word,
|
|
227
394
|
view: 'text',
|