@apliteni/apliteni-ui 0.9.1 → 0.11.4
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 +6 -0
- package/package.json +1 -1
- package/react/dist/index.css +0 -1
- package/src/components/account-nav.js +47 -0
- package/src/components/confirm.js +131 -0
- package/src/components/drawer.js +36 -98
- package/src/components/index.js +7 -1
- package/src/components/nav.js +25 -6
- package/src/components/overlay.js +231 -0
- package/src/components/shell.js +235 -32
- package/src/components/topbar.js +30 -4
- package/src/index.css +1 -0
- package/src/index.js +1 -0
- package/src/inline.js +2 -0
- package/src/styles/callout.css +30 -11
- package/src/styles/confirm.css +130 -0
- package/src/styles/drawer.css +30 -15
- package/src/styles/layout.css +197 -42
- package/src/styles/nav.css +33 -6
- package/src/tokens/accents.css +27 -4
- package/src/tokens/tokens.css +35 -8
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Overlay primitives — the page state the kit's modal surfaces share.
|
|
2
|
+
//
|
|
3
|
+
// Drawer and Confirm are the same problem twice: content over a scrim that owns
|
|
4
|
+
// the keyboard until it is answered. Three of the questions they raise are
|
|
5
|
+
// properties of the *page*, not of either component — what is inert right now,
|
|
6
|
+
// which overlay Escape talks to, and where Tab may go — and an overlay that
|
|
7
|
+
// answers them from its own storage gets them wrong as soon as a second one is
|
|
8
|
+
// open. So they are answered here, once, from one stack per document.
|
|
9
|
+
//
|
|
10
|
+
// Internal — not re-exported from src/index.js.
|
|
11
|
+
|
|
12
|
+
// What each overlay paints on, as its stylesheet resolves it today: a drawer at
|
|
13
|
+
// `--z-overlay` (src/styles/drawer.css) and a confirm one above it
|
|
14
|
+
// (src/styles/confirm.css). Absolute values, not ranks, so a sheet that moves and
|
|
15
|
+
// a table that did not is a failed test rather than a keyboard on the wrong layer
|
|
16
|
+
// — stories/overlay-css.test.js holds these two numbers to those two rules.
|
|
17
|
+
export const OVERLAY_LAYER = { drawer: 100, confirm: 101 };
|
|
18
|
+
|
|
19
|
+
const FOCUSABLE = [
|
|
20
|
+
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
|
|
21
|
+
'select:not([disabled])', 'textarea:not([disabled])',
|
|
22
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
23
|
+
].join(',');
|
|
24
|
+
|
|
25
|
+
// Per document, not per module: JSDOM tests and Storybook iframes each get their
|
|
26
|
+
// own page, and one global stack would let one page's overlays decide another's.
|
|
27
|
+
// stack open overlays, bottom → top: { root, panel, dismiss, layer }
|
|
28
|
+
// marked what the current top hid, with the state to give back
|
|
29
|
+
const pages = new WeakMap();
|
|
30
|
+
function pageOf(doc) {
|
|
31
|
+
let page = pages.get(doc);
|
|
32
|
+
if (!page) { page = { stack: [], marked: [], keys: false }; pages.set(doc, page); }
|
|
33
|
+
return page;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Focusable to the *browser*, not merely matching the selector. A control in a
|
|
37
|
+
// closed overlay, an inert subtree or a hidden ancestor is skipped by the real
|
|
38
|
+
// tab order, so a trap that counts it wraps at an element focus never reaches
|
|
39
|
+
// and Tab walks straight out of the modal.
|
|
40
|
+
function reachable(el) {
|
|
41
|
+
for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
|
|
42
|
+
if (n.inert || n.hasAttribute('inert') || n.hasAttribute('hidden')) return false;
|
|
43
|
+
const overlayRoot = n.hasAttribute('data-drawer') || n.hasAttribute('data-confirm');
|
|
44
|
+
if (overlayRoot && !n.classList.contains('is-open')) return false;
|
|
45
|
+
}
|
|
46
|
+
// Browsers can answer the rest properly; JSDOM has no layout and no such method.
|
|
47
|
+
return typeof el.checkVisibility !== 'function'
|
|
48
|
+
|| el.checkVisibility({ visibilityProperty: true, contentVisibilityAuto: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Focusable controls inside the panel, in DOM order.
|
|
52
|
+
export function focusablesIn(panel) {
|
|
53
|
+
return Array.from(panel.querySelectorAll(FOCUSABLE)).filter(reachable);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Hide everything *outside* `root` from AT + the tab order: walk root→body and
|
|
57
|
+
// mark each ancestor's other children. The scrim and panel live inside the root
|
|
58
|
+
// so they stay interactive; an overlay one layer down is outside it, so it does
|
|
59
|
+
// not — a drawer under an aria-modal alertdialog must not be tabbable.
|
|
60
|
+
function mark(page, doc, root) {
|
|
61
|
+
let node = root;
|
|
62
|
+
while (node && node.parentElement && node !== doc.body) {
|
|
63
|
+
for (const sib of node.parentElement.children) {
|
|
64
|
+
if (sib === node) continue;
|
|
65
|
+
page.marked.push([sib, sib.getAttribute('aria-hidden'), sib.hasAttribute('inert')]);
|
|
66
|
+
sib.setAttribute('aria-hidden', 'true');
|
|
67
|
+
sib.setAttribute('inert', '');
|
|
68
|
+
sib.inert = true;
|
|
69
|
+
}
|
|
70
|
+
node = node.parentElement;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function unmark(page) {
|
|
75
|
+
for (let i = page.marked.length - 1; i >= 0; i--) {
|
|
76
|
+
const [el, ariaHidden, hadInert] = page.marked[i];
|
|
77
|
+
if (ariaHidden == null) el.removeAttribute('aria-hidden');
|
|
78
|
+
else el.setAttribute('aria-hidden', ariaHidden);
|
|
79
|
+
if (!hadInert) { el.removeAttribute('inert'); el.inert = false; }
|
|
80
|
+
}
|
|
81
|
+
page.marked = [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Recompute the page from the stack — never replay a snapshot taken when an
|
|
85
|
+
// overlay opened, because by the time it closes that snapshot describes a page
|
|
86
|
+
// that has moved on. Roots torn down while open drop out here, so the record of
|
|
87
|
+
// what to give back outlives the node that hid it.
|
|
88
|
+
function sync(doc) {
|
|
89
|
+
const page = pageOf(doc);
|
|
90
|
+
for (let i = page.stack.length - 1; i >= 0; i--) {
|
|
91
|
+
if (!page.stack[i].root.isConnected) page.stack.splice(i, 1);
|
|
92
|
+
}
|
|
93
|
+
unmark(page);
|
|
94
|
+
const top = page.stack[page.stack.length - 1];
|
|
95
|
+
if (top) mark(page, doc, top.root);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// One keydown owner per document. Escape closes the single topmost overlay and
|
|
99
|
+
// Tab is trapped in that one's panel, so the answer never turns on which
|
|
100
|
+
// component registered a listener first or on where focus happens to be.
|
|
101
|
+
function ownKeys(page, doc) {
|
|
102
|
+
if (page.keys) return;
|
|
103
|
+
page.keys = true;
|
|
104
|
+
doc.addEventListener('keydown', (e) => {
|
|
105
|
+
const top = page.stack[page.stack.length - 1];
|
|
106
|
+
if (!top) return;
|
|
107
|
+
if (e.key === 'Escape') {
|
|
108
|
+
if (top.dismiss) { e.preventDefault(); top.dismiss(); }
|
|
109
|
+
} else if (e.key === 'Tab') {
|
|
110
|
+
trapTab(top.panel, e);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Node.DOCUMENT_POSITION_PRECEDING, without reaching for a global Node that a
|
|
116
|
+
// document-scoped module has no business assuming is there.
|
|
117
|
+
const PRECEDING = 2;
|
|
118
|
+
|
|
119
|
+
// One way onto the stack. `where` picks the slot; everything after it — the
|
|
120
|
+
// duplicate guard, the key owner, the recompute — is the same either way. Every
|
|
121
|
+
// entry carries its layer, so the comparisons in adoptOverlay never meet undefined.
|
|
122
|
+
function place(root, panel, dismiss, layer, where) {
|
|
123
|
+
const doc = root.ownerDocument;
|
|
124
|
+
const page = pageOf(doc);
|
|
125
|
+
if (page.stack.some((e) => e.root === root)) return;
|
|
126
|
+
page.stack.splice(where(page), 0, { root, panel, dismiss, layer });
|
|
127
|
+
ownKeys(page, doc);
|
|
128
|
+
sync(doc);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The layer this root actually paints on. In a browser the live z-index is the
|
|
132
|
+
// truth, so a consumer who moves either overlay in their own stylesheet gets the
|
|
133
|
+
// keyboard on the layer they can see. JSDOM has no cascade to resolve: it hands
|
|
134
|
+
// back the declared text — `calc(var(--z-overlay) + 1)` with the kit's sheets,
|
|
135
|
+
// `auto` with none — and neither is a number, so under test the passed constant
|
|
136
|
+
// stands. `auto` must not read as 0; anything unparseable falls through.
|
|
137
|
+
function paintedLayer(root, layer) {
|
|
138
|
+
const painted = root.ownerDocument.defaultView?.getComputedStyle(root)?.zIndex;
|
|
139
|
+
const live = painted ? Number(painted) : NaN;
|
|
140
|
+
return Number.isFinite(live) ? live : layer;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Put an overlay on top of the page. `dismiss` is what Escape calls — pass null
|
|
145
|
+
* for one that refuses to be dismissed, and Escape then does nothing rather than
|
|
146
|
+
* falling through to the overlay underneath. This one goes on top whatever layer
|
|
147
|
+
* it paints on, because opening is history the stack can order by: the thing just
|
|
148
|
+
* opened is the thing the reader is looking at.
|
|
149
|
+
*/
|
|
150
|
+
export function pushOverlay(root, panel, dismiss, layer) {
|
|
151
|
+
place(root, panel, dismiss, layer, (page) => page.stack.length);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Take on a root that arrived already open — markup rendered with `open: true`,
|
|
156
|
+
* which nobody called open…() for. Without this its aria-modal is a claim the
|
|
157
|
+
* page contradicts: nothing is inert, Tab walks out, Escape has no owner.
|
|
158
|
+
*
|
|
159
|
+
* It goes in by paint order, because wiring has no history to order by and the
|
|
160
|
+
* overlay the reader is looking at is the one drawn over the rest: a confirm sits
|
|
161
|
+
* a layer above a drawer whichever component's wiring ran first, and whichever
|
|
162
|
+
* root the markup happens to put first. Document position only separates two
|
|
163
|
+
* overlays on the same layer, and there it is the right answer rather than a
|
|
164
|
+
* fallback — at equal stack levels the later root paints on top (CSS 2.2 Appendix
|
|
165
|
+
* E, steps 8 and 9). That comparison earns its keep whenever adoption order and
|
|
166
|
+
* document order disagree: a root inserted above one already on the stack and
|
|
167
|
+
* wired after it, and — in a browser only — a consumer stylesheet that lifts the
|
|
168
|
+
* drawer onto the confirm's layer, where wiring runs drawer-first however the
|
|
169
|
+
* markup is ordered. A browser refines the layer from the live z-index; JSDOM has
|
|
170
|
+
* none to give, so the tests hold OVERLAY_LAYER to the sheets instead. A root that
|
|
171
|
+
* renders closed is left alone: wiring is not an opening, and it waits for the one
|
|
172
|
+
* that is. A specimen never reaches here at all — it carries no hook for the
|
|
173
|
+
* wiring to find.
|
|
174
|
+
*/
|
|
175
|
+
export function adoptOverlay(root, panel, dismiss, layer) {
|
|
176
|
+
if (!root.classList.contains('is-open')) return;
|
|
177
|
+
const level = paintedLayer(root, layer);
|
|
178
|
+
place(root, panel, dismiss, level, (page) => {
|
|
179
|
+
const at = page.stack.findIndex((e) => e.layer > level
|
|
180
|
+
|| (e.layer === level && (e.root.compareDocumentPosition(root) & PRECEDING)));
|
|
181
|
+
return at === -1 ? page.stack.length : at;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Take an overlay off the page, wherever in the stack it sits. */
|
|
186
|
+
export function popOverlay(root) {
|
|
187
|
+
const doc = root.ownerDocument;
|
|
188
|
+
const page = pageOf(doc);
|
|
189
|
+
const at = page.stack.findIndex((e) => e.root === root);
|
|
190
|
+
if (at !== -1) page.stack.splice(at, 1);
|
|
191
|
+
sync(doc);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Recompute from what is still on the page. The wiring calls this, and the
|
|
196
|
+
* wiring runs on every re-render, so an overlay destroyed while open cannot
|
|
197
|
+
* leave the page inert with no node left to hand it back.
|
|
198
|
+
*/
|
|
199
|
+
export function syncOverlays(doc = document) {
|
|
200
|
+
sync(doc);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Hand focus back to `el`. The overlay was open while the page carried on, so
|
|
205
|
+
* the element it came from may be detached by now — and focus() on a detached
|
|
206
|
+
* node is a silent no-op that leaves the reader with no place on the page. Look
|
|
207
|
+
* for whatever inherited its identity in the re-render, then give up to the page.
|
|
208
|
+
*/
|
|
209
|
+
export function returnFocus(el, doc) {
|
|
210
|
+
const live = el && !el.isConnected && el.id ? doc?.getElementById(el.id) : el;
|
|
211
|
+
if (live && live.isConnected && typeof live.focus === 'function') { live.focus(); return; }
|
|
212
|
+
doc?.body?.focus?.();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Trap Tab within the panel while open; wrap at both ends, and pull focus back
|
|
216
|
+
// in when it has fallen outside the panel altogether.
|
|
217
|
+
export function trapTab(panel, e) {
|
|
218
|
+
if (e.key !== 'Tab' || !panel) return;
|
|
219
|
+
const items = focusablesIn(panel);
|
|
220
|
+
if (!items.length) { e.preventDefault(); panel.focus(); return; }
|
|
221
|
+
const first = items[0];
|
|
222
|
+
const last = items[items.length - 1];
|
|
223
|
+
const active = (panel.ownerDocument || document).activeElement;
|
|
224
|
+
if (!panel.contains(active)) {
|
|
225
|
+
e.preventDefault(); (e.shiftKey ? last : first).focus();
|
|
226
|
+
} else if (e.shiftKey && (active === first || active === panel)) {
|
|
227
|
+
e.preventDefault(); last.focus();
|
|
228
|
+
} else if (!e.shiftKey && active === last) {
|
|
229
|
+
e.preventDefault(); first.focus();
|
|
230
|
+
}
|
|
231
|
+
}
|
package/src/components/shell.js
CHANGED
|
@@ -1,24 +1,211 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The kit's one page shell. `appShell()` is a full-height rail — brand, the
|
|
2
|
+
// kit's own sidebarNav(), the signed-in reader — beside one <main> that opens
|
|
3
|
+
// with a breadcrumb trail the caller owns. `accountShell()` is a thin preset
|
|
4
|
+
// over it that keeps the topbar, so the published /account API still works.
|
|
4
5
|
// Call wireTopbar() once after mounting to wire the account menu + theme toggle.
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export const ACCOUNT_NAV = [
|
|
12
|
-
['prefs', 'gear', 'Preferences'],
|
|
13
|
-
['access', 'key', 'Access & agents'],
|
|
14
|
-
];
|
|
15
|
-
|
|
16
|
-
const sidebar = (nav, active, cap = 'Account') =>
|
|
17
|
-
`<nav class="ui-side"><div class="cap">${cap}</div>` +
|
|
18
|
-
nav.map(([id, ic, label, href, target]) =>
|
|
19
|
-
`<a href="${href || '#' + id}"${target ? ` target="${target}"` : ''}${id === active ? ' class="on"' : ''}>${icon(ic)}${label}</a>`).join('') +
|
|
20
|
-
`<div class="ssep"></div><a class="out" href="#logout">${icon('logout')}Sign out</a></nav>`;
|
|
6
|
+
// why: docs/adr/0007-one-page-shell-built-from-the-kits-own-nav.md
|
|
7
|
+
import { topbar as productTopbar } from './topbar.js';
|
|
8
|
+
import { esc, icon } from './index.js';
|
|
9
|
+
import { sidebarNav, breadcrumbs } from './nav.js';
|
|
10
|
+
import { prism } from '../assets/brand.js';
|
|
11
|
+
import { ACCOUNT_NAV, toMenuTuple, initials } from './account-nav.js';
|
|
21
12
|
|
|
13
|
+
// The one account navigation definition lives in account-nav.js, because
|
|
14
|
+
// topbar.js needs it too and this file already imports topbar.js. Re-exported
|
|
15
|
+
// here so the published name docs/library.md documents keeps working.
|
|
16
|
+
export { ACCOUNT_NAV };
|
|
17
|
+
|
|
18
|
+
const str = (v) => (v == null ? '' : String(v));
|
|
19
|
+
const isRecord = (v) => typeof v === 'object' && v !== null;
|
|
20
|
+
|
|
21
|
+
// ---- the options bag, settled once --------------------------------------
|
|
22
|
+
//
|
|
23
|
+
// Every option below carries a shape, and a default parameter covers
|
|
24
|
+
// `undefined` and nothing else — so `nav: null` from an /auth/me, a `maxWidth`
|
|
25
|
+
// out of tenant config, a `crumbs` string written by somebody reading the
|
|
26
|
+
// migration note all arrived as they were. A shell that throws mid-render takes
|
|
27
|
+
// the page with it, so each is settled here, before the first sink sees it, and
|
|
28
|
+
// a parameter is protected by being declared rather than by what it crashes
|
|
29
|
+
// into. Adding one to appShell() means adding it to SHAPES or deciding in the
|
|
30
|
+
// open that it needs nothing.
|
|
31
|
+
|
|
32
|
+
// accountShell() has always taken its nav as [id, icon, label, href?, target?].
|
|
33
|
+
// Accept that shape and nav.js's object shape side by side, so a consumer's
|
|
34
|
+
// existing tuples and the exported ACCOUNT_NAV both work. A nav that is not a
|
|
35
|
+
// list at all falls back to the default; an entry inside one that is neither
|
|
36
|
+
// shape is dropped, because sideItem() reads `.items` off whatever it is given.
|
|
37
|
+
// An empty list is an answer, not a mistake — it stays empty.
|
|
38
|
+
const toItems = (nav) => (Array.isArray(nav) ? nav : ACCOUNT_NAV)
|
|
39
|
+
.filter(isRecord)
|
|
40
|
+
.map((n) => (Array.isArray(n)
|
|
41
|
+
? { id: n[0], icon: n[1], label: n[2], href: n[3], target: n[4] }
|
|
42
|
+
: n));
|
|
43
|
+
|
|
44
|
+
// The trail is the caller's, and `crumbs` is the one option whose shape changed
|
|
45
|
+
// in this release: the old API was `crumb: 'Payouts'`, a string, and the new one
|
|
46
|
+
// is `crumbs: [{ label }]`. One letter apart. A value that is not a list is not
|
|
47
|
+
// read as a one-crumb trail — that would draw a plausible page and hide the
|
|
48
|
+
// migration mistake — so it is no trail at all, which is the visible answer. A
|
|
49
|
+
// crumb with no label would draw an empty cell, so it goes too.
|
|
50
|
+
const toCrumbs = (crumbs) => (Array.isArray(crumbs) ? crumbs : [])
|
|
51
|
+
.filter((c) => isRecord(c) && !Array.isArray(c) && str(c.label) !== '');
|
|
52
|
+
|
|
53
|
+
// The reader, as two strings. railUser() and initials() both read them, and an
|
|
54
|
+
// /auth/me answering `account: null` or a numeric display name reached both.
|
|
55
|
+
const toReader = (a) => (isRecord(a) ? { name: str(a.name), email: str(a.email) } : { name: '', email: '' });
|
|
56
|
+
|
|
57
|
+
// ---- the topbar, which interpolates where the rail escapes ----------------
|
|
58
|
+
//
|
|
59
|
+
// brand() writes `word` straight into its markup and accountMenu() does the
|
|
60
|
+
// same with the reader's name, address and menu entries. So the escaping is
|
|
61
|
+
// here, on the one path into productTopbar(), and not in each caller:
|
|
62
|
+
// accountShell() escaped for itself and the `topbar` option underneath it
|
|
63
|
+
// escaped for nobody. The caller passes text either way.
|
|
64
|
+
|
|
65
|
+
// The reader the menu draws. Both fields are always written, empty when the
|
|
66
|
+
// caller gave none — accountMenu()'s own defaults are a demo identity, and a
|
|
67
|
+
// key dropped here is a key its default fills in, which is how a consumer's
|
|
68
|
+
// page came to name its own reader in the rail and the kit's fixture beside it.
|
|
69
|
+
//
|
|
70
|
+
// `initials` travels with them because it is derived, and a derived value comes
|
|
71
|
+
// from what the caller passed, not from the entities made of it: `<Ada>` and
|
|
72
|
+
// `<Ada>` do not start with the same character. The entries land in the
|
|
73
|
+
// same sink and toMenuTuple() is what escapes them; it reads item objects, so
|
|
74
|
+
// tuples go through toItems() first. A nav nobody passed stays unpassed, and
|
|
75
|
+
// accountMenu() falls back to its own derived default rather than to nothing.
|
|
76
|
+
const toMenuReader = (a) => {
|
|
77
|
+
const { name, email } = toReader(a);
|
|
78
|
+
const rest = isRecord(a) && !Array.isArray(a) ? a : {};
|
|
79
|
+
const out = { ...rest, name: esc(name), email: esc(email), initials: esc(initials(name, email)) };
|
|
80
|
+
if (out.nav != null) out.nav = toItems(out.nav).map(toMenuTuple);
|
|
81
|
+
return out;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const toTopbar = (t) => {
|
|
85
|
+
if (!isRecord(t) || Array.isArray(t)) return null;
|
|
86
|
+
const out = { ...t };
|
|
87
|
+
if (out.word != null) out.word = esc(str(out.word));
|
|
88
|
+
if (out.account != null) out.account = toMenuReader(out.account);
|
|
89
|
+
return out;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// `maxWidth` lands inside a style attribute, which is a declaration list: esc()
|
|
93
|
+
// stops a quote closing the attribute, and `;` is the character that matters
|
|
94
|
+
// there. So a length is all this accepts — a number and a unit the reading
|
|
95
|
+
// column can use, or `none`. Anything else falls back to the default rather
|
|
96
|
+
// than throwing, because a shell that throws mid-render takes the page with it.
|
|
97
|
+
const MAIN_MAX = '860px';
|
|
98
|
+
const LENGTH = /^(?:\d+|\d*\.\d+)(?:px|rem|em|ch|%|vw)$/;
|
|
99
|
+
const mainMax = (v) => {
|
|
100
|
+
const s = str(v).trim();
|
|
101
|
+
return s === 'none' || LENGTH.test(s) ? s : MAIN_MAX;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// The one pass. Each key names the function that settles it; nothing else in
|
|
105
|
+
// this file re-checks a value that has been through here.
|
|
106
|
+
const SHAPES = {
|
|
107
|
+
nav: toItems, crumbs: toCrumbs, account: toReader, maxWidth: mainMax, topbar: toTopbar,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// The text options settle too, by the same argument the rest of them do. A
|
|
111
|
+
// default parameter covers `undefined` and not `null`, so `body: null` from a
|
|
112
|
+
// record with no description drew the word "null" on the page, and `word: null`
|
|
113
|
+
// left the brand link with no accessible name at all. Dropping the key is what
|
|
114
|
+
// lets the declared default apply.
|
|
115
|
+
const TEXT = ['word', 'brandHref', 'navLabel', 'title', 'sub', 'body', 'signOutHref', 'active'];
|
|
116
|
+
|
|
117
|
+
function settle(options) {
|
|
118
|
+
const out = { ...options };
|
|
119
|
+
for (const key of Object.keys(SHAPES)) out[key] = SHAPES[key](out[key]);
|
|
120
|
+
for (const key of TEXT) if (out[key] == null) delete out[key];
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Signing out is a navigation action, so it belongs in the rail nav's footer
|
|
125
|
+
// slot. Opt-in: a shell that renders it unasked puts a dead link on a page with
|
|
126
|
+
// no session behind it.
|
|
127
|
+
const signOut = (href) =>
|
|
128
|
+
`<a class="ui-nav__item is-danger" href="${esc(href)}" aria-label="Sign out">` +
|
|
129
|
+
`<span class="ui-nav__ic">${icon('logout')}</span>` +
|
|
130
|
+
`<span class="ui-nav__label">Sign out</span></a>`;
|
|
131
|
+
|
|
132
|
+
// Who is signed in. A sibling of the <nav>, not its footer: a reader's name and
|
|
133
|
+
// address are not navigation, and inside the landmark a screen reader announces
|
|
134
|
+
// the address as an entry. Empty when nobody is — a shell must not invent an
|
|
135
|
+
// identity for a reader it does not know.
|
|
136
|
+
//
|
|
137
|
+
// The initials carry the name, and the spelled-out half is aria-hidden. The
|
|
138
|
+
// narrow rail folds `.ui-app__who` out of view, so a name that lived only there
|
|
139
|
+
// left the initials on screen with nothing at all in the accessibility tree.
|
|
140
|
+
// Naming the mark instead makes the two agree at every width, and says it once.
|
|
141
|
+
function railUser({ name, email }) {
|
|
142
|
+
if (!name && !email) return '';
|
|
143
|
+
const who = [name, email].filter(Boolean).join(', ');
|
|
144
|
+
return `<div class="ui-app__user">` +
|
|
145
|
+
`<span class="ui-app__av" role="img" aria-label="Signed in as ${esc(who)}">` +
|
|
146
|
+
`${esc(initials(name, email))}</span>` +
|
|
147
|
+
`<span class="ui-app__who" aria-hidden="true">` +
|
|
148
|
+
(name ? `<b>${esc(name)}</b>` : '') +
|
|
149
|
+
(email ? `<span>${esc(email)}</span>` : '') +
|
|
150
|
+
`</span></div>`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Unique-per-render suffix for the brand mark's clip id — the same reason
|
|
154
|
+
// nav.js keeps a module counter. Two shells on one page must not collide.
|
|
155
|
+
let _shellUid = 0;
|
|
156
|
+
|
|
157
|
+
export function appShell(options = {}) {
|
|
158
|
+
// Everything in SHAPES arrives settled; the rest is text, and a text default
|
|
159
|
+
// is what a default parameter is for.
|
|
160
|
+
const {
|
|
161
|
+
word = 'apliteni-ui',
|
|
162
|
+
brandHref = '#',
|
|
163
|
+
nav,
|
|
164
|
+
active,
|
|
165
|
+
navLabel = 'Account',
|
|
166
|
+
crumbs,
|
|
167
|
+
title = '',
|
|
168
|
+
sub = '',
|
|
169
|
+
body = '',
|
|
170
|
+
account,
|
|
171
|
+
signOutHref = '',
|
|
172
|
+
topbar,
|
|
173
|
+
maxWidth,
|
|
174
|
+
} = settle(options);
|
|
175
|
+
const rail = sidebarNav({
|
|
176
|
+
sections: [{ label: navLabel, items: nav }],
|
|
177
|
+
active,
|
|
178
|
+
ariaLabel: navLabel,
|
|
179
|
+
footer: signOutHref ? signOut(signOutHref) : '',
|
|
180
|
+
});
|
|
181
|
+
// The topbar already says the product word. Two lockups on one screen is one
|
|
182
|
+
// product word too many, so the rail head steps aside when there is a topbar.
|
|
183
|
+
// The word is the link's only text and the narrow rail folds it out of view,
|
|
184
|
+
// so the name is written out — the mark itself is aria-hidden.
|
|
185
|
+
const brand = topbar ? '' : `<a class="ui-app__brand" href="${esc(brandHref)}" aria-label="${esc(word)}">`
|
|
186
|
+
+ `${prism(`appb-${++_shellUid}`, 24)}<span>${esc(word)}</span></a>`;
|
|
187
|
+
// A <div>, not an <aside>: <aside> is the `complementary` landmark — content
|
|
188
|
+
// related to the page but separable from it — and this holds the page's
|
|
189
|
+
// primary navigation and the reader who is signed in. The <nav> inside it is
|
|
190
|
+
// already the landmark that names the menu.
|
|
191
|
+
const grid = `<div class="ui-app">
|
|
192
|
+
<div class="ui-app__rail">
|
|
193
|
+
${brand}
|
|
194
|
+
${rail}
|
|
195
|
+
${railUser(account)}
|
|
196
|
+
</div>
|
|
197
|
+
<main class="ui-app__main" style="--ui-app-main: ${maxWidth}">
|
|
198
|
+
${crumbs.length ? breadcrumbs({ items: crumbs }) : ''}
|
|
199
|
+
${title ? `<h1>${title}</h1>` : ''}
|
|
200
|
+
${sub ? `<p class="ui-app__sub">${sub}</p>` : ''}
|
|
201
|
+
<div class="ui-app__body">${body}</div>
|
|
202
|
+
</main>
|
|
203
|
+
</div>`;
|
|
204
|
+
return topbar ? `<div class="ui-app-page">${productTopbar(topbar)}${grid}</div>` : grid;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The /account preset: appShell() with the topbar switched on, and the old
|
|
208
|
+
// `cap` + `crumb` strings folded into the trail the caller now owns.
|
|
22
209
|
export function accountShell({
|
|
23
210
|
word = 'Account',
|
|
24
211
|
versions,
|
|
@@ -31,18 +218,34 @@ export function accountShell({
|
|
|
31
218
|
title = '',
|
|
32
219
|
sub = '',
|
|
33
220
|
body = '',
|
|
221
|
+
signOutHref = '#logout',
|
|
34
222
|
} = {}) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
223
|
+
// The same normaliser appShell() runs, called once here so the rail and the
|
|
224
|
+
// topbar menu are handed one list rather than two readings of `nav`.
|
|
225
|
+
const items = toItems(nav);
|
|
226
|
+
const trail = [{ label: cap }, { label: crumb || title }];
|
|
227
|
+
// The preset hands the topbar the caller's text, exactly as it hands the rail
|
|
228
|
+
// the caller's text. toTopbar() is what escapes for the menu's raw sinks, and
|
|
229
|
+
// it runs once inside appShell() — escaping here as well would reach the menu
|
|
230
|
+
// as entities, and escaping nowhere is what the public `topbar` option used
|
|
231
|
+
// to do.
|
|
232
|
+
return appShell({
|
|
233
|
+
word,
|
|
234
|
+
nav: items,
|
|
235
|
+
active,
|
|
236
|
+
navLabel: cap,
|
|
237
|
+
crumbs: trail,
|
|
238
|
+
title,
|
|
239
|
+
sub,
|
|
240
|
+
body,
|
|
241
|
+
account,
|
|
242
|
+
signOutHref,
|
|
243
|
+
topbar: {
|
|
244
|
+
word,
|
|
245
|
+
view: 'text',
|
|
246
|
+
showSwitch,
|
|
247
|
+
versions,
|
|
248
|
+
account: { ...(isRecord(account) ? account : {}), active, nav: items },
|
|
249
|
+
},
|
|
250
|
+
});
|
|
48
251
|
}
|
package/src/components/topbar.js
CHANGED
|
@@ -4,6 +4,7 @@ import { brand } from '../assets/brand.js';
|
|
|
4
4
|
import { icon, sun, moon } from '../assets/icons.js';
|
|
5
5
|
import { esc } from './index.js';
|
|
6
6
|
import { wireDropdown } from './dropdown.js';
|
|
7
|
+
import { accountMenuNav, initials, toMenuTuple } from './account-nav.js';
|
|
7
8
|
|
|
8
9
|
const THEME_KEY = 'apliteni-strategy-theme';
|
|
9
10
|
|
|
@@ -52,10 +53,35 @@ export function versionSwitcher(versions = [], activeIdx = 0) {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
// `nav` ([id, icon, label, href?, target?][]) mirrors the account sidebar so the
|
|
55
|
-
// dropdown and the sidebar stay in sync
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
// dropdown and the sidebar stay in sync. The fallback is derived from the one
|
|
57
|
+
// ACCOUNT_NAV definition rather than restated here: a second literal agreed
|
|
58
|
+
// with it by hand about the icon and disagreed about the encoding, which is the
|
|
59
|
+
// drift #127 was filed about. Every field below is interpolated raw, so what
|
|
60
|
+
// arrives has to arrive escaped — accountMenuNav() is what does that.
|
|
61
|
+
//
|
|
62
|
+
// `initials` is the avatar, for a caller that escapes on the way in. A mark is
|
|
63
|
+
// derived from the reader's name, and a derived value has to be derived before
|
|
64
|
+
// the escaping: `<Ada>` and `<Ada>` do not begin with the same character,
|
|
65
|
+
// so shell.js — which escapes both fields for this sink — computes the mark
|
|
66
|
+
// from the caller's own strings and passes it down beside them. Left out, it is
|
|
67
|
+
// computed here from `name` and `email`, exactly where it always came from.
|
|
68
|
+
export function accountMenu({
|
|
69
|
+
name = 'Ada Lovelace', email = 'ada@apliteni.com', active = 'prefs', nav, initials: mark,
|
|
70
|
+
} = {}) {
|
|
71
|
+
// initials() is shared with the rail's avatar — the two are the same reader
|
|
72
|
+
// on the /account preset, and they used to disagree about who that was.
|
|
73
|
+
const ini = mark == null ? initials(name, email) : mark;
|
|
74
|
+
// ACCOUNT_NAV is published, and it is a list of item objects — so the shape a
|
|
75
|
+
// consumer most naturally hands this option is the one that used to throw here.
|
|
76
|
+
// Read either; toMenuTuple() escapes an object on the way, which a tuple that
|
|
77
|
+
// arrives already escaped does not need.
|
|
78
|
+
//
|
|
79
|
+
// A list that is empty is an answer and stays empty, which is what the rail
|
|
80
|
+
// does with the same value. Falling back on `.length` meant a caller who asked
|
|
81
|
+
// for no entries got none in the rail and the kit's two in the menu — one nav,
|
|
82
|
+
// two answers, which is the drift #127 exists to close.
|
|
83
|
+
const items = (Array.isArray(nav) ? nav : accountMenuNav())
|
|
84
|
+
.map((n) => (Array.isArray(n) ? n : toMenuTuple(n)));
|
|
59
85
|
const it = ([id, ic, label, href, target]) =>
|
|
60
86
|
`<a href="${href || '#' + id}"${target ? ` target="${target}"` : ''} data-dd-item tabindex="-1"${active === id ? ' class="cur"' : ''} role="menuitem">${icon(ic)}${label}</a>`;
|
|
61
87
|
// `on` so the menu is visible in Storybook / standalone use (no /auth/me gate).
|
package/src/index.css
CHANGED
package/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export * from './components/dropdown.js';
|
|
|
5
5
|
export * from './components/tabs.js';
|
|
6
6
|
export * from './components/nav.js';
|
|
7
7
|
export * from './components/drawer.js';
|
|
8
|
+
export * from './components/confirm.js';
|
|
8
9
|
export * from './components/topbar.js';
|
|
9
10
|
export * from './components/shell.js';
|
|
10
11
|
export * from './components/footer.js';
|
package/src/inline.js
CHANGED
|
@@ -38,6 +38,7 @@ export const styles = {
|
|
|
38
38
|
dropdown: read('styles/dropdown.css'),
|
|
39
39
|
nav: read('styles/nav.css'),
|
|
40
40
|
drawer: read('styles/drawer.css'),
|
|
41
|
+
confirm: read('styles/confirm.css'),
|
|
41
42
|
table: read('styles/table.css'),
|
|
42
43
|
empty: read('styles/empty.css'),
|
|
43
44
|
callout: read('styles/callout.css'),
|
|
@@ -63,6 +64,7 @@ export const cssText = [
|
|
|
63
64
|
styles.dropdown,
|
|
64
65
|
styles.nav,
|
|
65
66
|
styles.drawer,
|
|
67
|
+
styles.confirm,
|
|
66
68
|
styles.table,
|
|
67
69
|
styles.empty,
|
|
68
70
|
styles.callout,
|