@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
package/README.md
CHANGED
|
@@ -31,6 +31,12 @@ as the vanilla kit, so the two layers can't drift.
|
|
|
31
31
|
**Which one:** does the surface hold meaningful client state? No → the HTML-string
|
|
32
32
|
factories below. Yes → the [React components](#react-components-stateful-surfaces).
|
|
33
33
|
|
|
34
|
+
Either layer follows the same UI rules — which component to reach for, the states it
|
|
35
|
+
owes, how colour and wording work. They live in the **Guidelines** section of Storybook,
|
|
36
|
+
which opens on
|
|
37
|
+
[an overview of the five pages](https://ui.apli.tech/storybook/?path=/story/guidelines-overview--overview)
|
|
38
|
+
and what the kit does and does not yet meet. Worth reading before you design a screen.
|
|
39
|
+
|
|
34
40
|
## Install
|
|
35
41
|
|
|
36
42
|
Published on the **public npm registry** — no scope config, no token:
|
package/package.json
CHANGED
package/react/dist/index.css
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The one account navigation definition, and the one way to hand it to a sink
|
|
2
|
+
// that interpolates raw. It lives here rather than in shell.js because both
|
|
3
|
+
// shell.js and topbar.js need it and shell.js already imports topbar.js — the
|
|
4
|
+
// other direction would be a cycle. shell.js re-exports ACCOUNT_NAV, which is
|
|
5
|
+
// the published name docs/library.md documents.
|
|
6
|
+
// why: docs/adr/0007-one-page-shell-built-from-the-kits-own-nav.md
|
|
7
|
+
import { esc } from './index.js';
|
|
8
|
+
|
|
9
|
+
// nav.js item objects. Labels are raw text — every nav primitive escapes, so a
|
|
10
|
+
// pre-escaped `&` would come out as `&`. `key` means credentials;
|
|
11
|
+
// `plug` means integration. These two are the kit's default, so each is a live
|
|
12
|
+
// link on a consumer's /account page — a screen the kit does not ship belongs
|
|
13
|
+
// in the caller's own nav, not here.
|
|
14
|
+
export const ACCOUNT_NAV = [
|
|
15
|
+
{ id: 'prefs', icon: 'gear', label: 'Preferences' },
|
|
16
|
+
{ id: 'access', icon: 'key', label: 'Access & agents' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// topbar()'s account menu speaks [id, icon, label, href?, target?] tuples, and
|
|
20
|
+
// it interpolates every field raw — the label, the href and the target alike.
|
|
21
|
+
// Escape all three on the way in rather than sending the menu a string the rail
|
|
22
|
+
// would have escaped a second time.
|
|
23
|
+
export const toMenuTuple = (it) => [
|
|
24
|
+
esc(it.id ?? ''), it.icon, esc(it.label || ''),
|
|
25
|
+
it.href ? esc(it.href) : it.href,
|
|
26
|
+
it.target ? esc(it.target) : it.target,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// What accountMenu() falls back to. Derived on call, not frozen at import, so
|
|
30
|
+
// the menu and the rail cannot answer differently about the same definition.
|
|
31
|
+
export const accountMenuNav = () => ACCOUNT_NAV.map(toMenuTuple);
|
|
32
|
+
|
|
33
|
+
// One reader, one pair of initials. The rail (shell.js) and the topbar's
|
|
34
|
+
// account menu (topbar.js) both draw an avatar for the same person, and each
|
|
35
|
+
// computed it for itself: the rail preferred the display name, the menu only
|
|
36
|
+
// ever read the email's local part. So the /account preset — the one screen
|
|
37
|
+
// with both on it — said "AL" in the rail and "A" in the topbar. This lives
|
|
38
|
+
// beside ACCOUNT_NAV for the same reason ACCOUNT_NAV lives here: both files
|
|
39
|
+
// need it and shell.js already imports topbar.js.
|
|
40
|
+
//
|
|
41
|
+
// The display name is what a reader recognises, so it wins; the address is the
|
|
42
|
+
// fallback. `?` is what nobody at all comes out as.
|
|
43
|
+
export const initials = (name, email) => {
|
|
44
|
+
const from = String(name ?? '').trim() || String(email ?? '').split('@')[0] || '';
|
|
45
|
+
const parts = from.split(/[\s._-]+/).filter(Boolean).map((w) => w[0]);
|
|
46
|
+
return (parts.slice(0, 2).join('') || '?').toUpperCase();
|
|
47
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Confirm — the kit's confirmation dialog: a question the page stops for.
|
|
2
|
+
//
|
|
3
|
+
// container.innerHTML = confirm({ title, body, confirmLabel, cancelLabel });
|
|
4
|
+
// wireConfirm(container); // scrim/Esc/answers + focus trap
|
|
5
|
+
//
|
|
6
|
+
// A page trigger opens it by id: <button data-confirm-open="ID">…</button>
|
|
7
|
+
// (or call openConfirm(rootEl) directly). Pass `open: true` to render it already
|
|
8
|
+
// open, or `specimen: true` for a picture of one on a documentation page.
|
|
9
|
+
//
|
|
10
|
+
// Answering is the caller's job: both answers close the dialog, and a listener
|
|
11
|
+
// on [data-confirm-accept] is where the destructive work goes. Focus opens on
|
|
12
|
+
// the SAFE answer, never the destructive one, so a reader who hits Enter out of
|
|
13
|
+
// habit keeps what they have.
|
|
14
|
+
//
|
|
15
|
+
// Inertness, Escape and the focus trap come from ./overlay.js — one stack for
|
|
16
|
+
// every overlay on the page, so a confirm over a drawer never has to guess which
|
|
17
|
+
// of the two the keyboard belongs to.
|
|
18
|
+
import { button, esc } from './index.js';
|
|
19
|
+
import { OVERLAY_LAYER, adoptOverlay, focusablesIn, popOverlay, pushOverlay, returnFocus, syncOverlays } from './overlay.js';
|
|
20
|
+
|
|
21
|
+
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
22
|
+
|
|
23
|
+
// Module counter (never Date/random) → stable, unique ids for aria-labelledby.
|
|
24
|
+
let _uid = 0;
|
|
25
|
+
const nextId = (p = 'confirm') => `${p}-${++_uid}`;
|
|
26
|
+
|
|
27
|
+
// The public factory. Returns an HTML string; wire it with wireConfirm().
|
|
28
|
+
// title the question — also the dialog's accessible name
|
|
29
|
+
// body the consequence — the dialog's accessible description
|
|
30
|
+
// confirmLabel the destructive answer (default 'Confirm')
|
|
31
|
+
// cancelLabel the safe answer (default 'Cancel')
|
|
32
|
+
// variant 'danger' (default) | 'primary' — which button the answer is
|
|
33
|
+
// open render already-open, as a real dialog: wireConfirm() adopts it
|
|
34
|
+
// onto the overlay stack, so the page behind it goes inert, Tab
|
|
35
|
+
// is trapped in the panel and Escape closes it
|
|
36
|
+
// specimen render open as a *picture* of the dialog: same markup, minus
|
|
37
|
+
// the data-confirm hook and aria-modal, so no wiring and no key
|
|
38
|
+
// handler can reach it. A documentation page shows several at
|
|
39
|
+
// once and none of them owns the page or answers Escape — an
|
|
40
|
+
// answered specimen would erase itself with nothing to bring it
|
|
41
|
+
// back, and three modal dialogs on one page trap the reader in
|
|
42
|
+
// the first. `open` is the one to use when the dialog is real.
|
|
43
|
+
// id root id — a [data-confirm-open="id"] trigger targets it
|
|
44
|
+
export function confirm({
|
|
45
|
+
title = 'Are you sure?', body = '', confirmLabel = 'Confirm', cancelLabel = 'Cancel',
|
|
46
|
+
variant = 'danger', id, open = false, specimen = false,
|
|
47
|
+
} = {}) {
|
|
48
|
+
const titleId = nextId('confirm-title');
|
|
49
|
+
const bodyId = body ? nextId('confirm-body') : null;
|
|
50
|
+
const describedBy = bodyId ? ` aria-describedby="${bodyId}"` : '';
|
|
51
|
+
|
|
52
|
+
// The safe answer comes FIRST in the DOM, so the trap's first stop and the
|
|
53
|
+
// reader's first Tab both land on the answer that changes nothing.
|
|
54
|
+
const cancelBtn = button({ label: cancelLabel, variant: 'ghost' })
|
|
55
|
+
.replace('<button ', '<button data-confirm-cancel ');
|
|
56
|
+
const acceptBtn = button({ label: confirmLabel, variant })
|
|
57
|
+
.replace('<button ', '<button data-confirm-accept ');
|
|
58
|
+
|
|
59
|
+
const rootCls = cx('ui-confirm', (open || specimen) && 'is-open');
|
|
60
|
+
return `<div class="${rootCls}"${specimen ? '' : ' data-confirm'}${id ? ` id="${esc(id)}"` : ''}>`
|
|
61
|
+
+ `<div class="ui-confirm__scrim" data-confirm-scrim></div>`
|
|
62
|
+
+ `<div class="ui-confirm__panel" role="alertdialog"${specimen ? '' : ' aria-modal="true"'}`
|
|
63
|
+
+ ` aria-labelledby="${titleId}"${describedBy} tabindex="-1" data-confirm-panel>`
|
|
64
|
+
+ `<h2 class="ui-confirm__title" id="${titleId}">${esc(title)}</h2>`
|
|
65
|
+
+ (bodyId ? `<p class="ui-confirm__body" id="${bodyId}">${esc(body)}</p>` : '')
|
|
66
|
+
+ `<div class="ui-confirm__acts">${cancelBtn}${acceptBtn}</div>`
|
|
67
|
+
+ `</div></div>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- Shared behaviour ----------------------------------------------------
|
|
71
|
+
// Per-instance handlers are attached once (guarded by a flag on the node);
|
|
72
|
+
// the [data-confirm-open] delegation is attached once per document. Safe to
|
|
73
|
+
// call repeatedly (Storybook re-renders).
|
|
74
|
+
|
|
75
|
+
export function openConfirm(root, returnFocusTo) {
|
|
76
|
+
if (!root || root.classList.contains('is-open')) return;
|
|
77
|
+
root.__confirmReturn = returnFocusTo
|
|
78
|
+
|| (document.activeElement instanceof HTMLElement ? document.activeElement : null);
|
|
79
|
+
root.classList.add('is-open');
|
|
80
|
+
const panel = root.querySelector('[data-confirm-panel]');
|
|
81
|
+
pushOverlay(root, panel, () => closeConfirm(root), OVERLAY_LAYER.confirm);
|
|
82
|
+
// The safe answer, else the first control, else the panel itself. Asked for by
|
|
83
|
+
// name and not by position: DOM order puts the safe answer first today, and a
|
|
84
|
+
// preference that only agrees with the order proves nothing about either.
|
|
85
|
+
const safe = panel && (panel.querySelector('[data-confirm-cancel]') || focusablesIn(panel)[0]);
|
|
86
|
+
(safe || panel)?.focus();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function closeConfirm(root) {
|
|
90
|
+
if (!root || !root.classList.contains('is-open')) return;
|
|
91
|
+
root.classList.remove('is-open');
|
|
92
|
+
popOverlay(root);
|
|
93
|
+
const back = root.__confirmReturn;
|
|
94
|
+
root.__confirmReturn = null;
|
|
95
|
+
// The destructive work a caller hangs off [data-confirm-accept] usually deletes
|
|
96
|
+
// the row the trigger stood in, so the trigger can be detached by now — and
|
|
97
|
+
// focus() on a detached node is a silent no-op that strands the reader.
|
|
98
|
+
returnFocus(back, root.ownerDocument);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function wireConfirm(scope = document) {
|
|
102
|
+
const root = scope === document ? document : scope;
|
|
103
|
+
root.querySelectorAll('[data-confirm]').forEach((cf) => {
|
|
104
|
+
if (cf.__confirmWired) return;
|
|
105
|
+
cf.__confirmWired = true;
|
|
106
|
+
|
|
107
|
+
cf.querySelector('[data-confirm-scrim]')?.addEventListener('click', () => closeConfirm(cf));
|
|
108
|
+
cf.querySelectorAll('[data-confirm-cancel],[data-confirm-accept]').forEach((btn) =>
|
|
109
|
+
btn.addEventListener('click', () => closeConfirm(cf)));
|
|
110
|
+
|
|
111
|
+
// Rendered with `open: true`, so nothing called openConfirm() and nothing
|
|
112
|
+
// put it on the stack. Adopting it here is what makes its aria-modal true.
|
|
113
|
+
adoptOverlay(cf, cf.querySelector('[data-confirm-panel]'), () => closeConfirm(cf), OVERLAY_LAYER.confirm);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const doc = scope === document ? document : (scope.ownerDocument || document);
|
|
117
|
+
if (!doc.__confirmGlobalWired) {
|
|
118
|
+
doc.__confirmGlobalWired = true;
|
|
119
|
+
// Any [data-confirm-open="ID"] trigger opens the matching dialog.
|
|
120
|
+
doc.addEventListener('click', (e) => {
|
|
121
|
+
const opener = e.target.closest?.('[data-confirm-open]');
|
|
122
|
+
if (!opener) return;
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
const target = doc.getElementById(opener.getAttribute('data-confirm-open'));
|
|
125
|
+
if (target) openConfirm(target, opener);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// This runs on every re-render, which is the moment to notice that an overlay
|
|
129
|
+
// was destroyed while it was open and hand the page back.
|
|
130
|
+
syncOverlays(doc);
|
|
131
|
+
}
|
package/src/components/drawer.js
CHANGED
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
//
|
|
9
9
|
// A page trigger opens it by id: <button data-drawer-open="ID">…</button>
|
|
10
10
|
// (or call openDrawer(rootEl) directly). Pass `open: true` to render it already
|
|
11
|
-
// open
|
|
11
|
+
// open, or `specimen: true` for a picture of one on a documentation page.
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// of edge-anchored). Kept as one component for now per issue #34.
|
|
13
|
+
// Inertness, Escape and the focus trap are shared with confirm(): both are
|
|
14
|
+
// "content over a scrim, focus-trapped, Esc-dismissable", and they push onto one
|
|
15
|
+
// stack in ./overlay.js so the two can never disagree about which of them the
|
|
16
|
+
// keyboard currently belongs to.
|
|
18
17
|
import { esc, icon } from './index.js';
|
|
18
|
+
import { OVERLAY_LAYER, adoptOverlay, focusablesIn, popOverlay, pushOverlay, returnFocus, syncOverlays } from './overlay.js';
|
|
19
19
|
|
|
20
20
|
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
21
21
|
|
|
@@ -29,14 +29,20 @@ const nextId = (p = 'drawer') => `${p}-${++_uid}`;
|
|
|
29
29
|
// title header heading (also the dialog's accessible name)
|
|
30
30
|
// body scrollable body HTML (trusted markup)
|
|
31
31
|
// footer pinned footer actions HTML (trusted markup)
|
|
32
|
-
// open render already-open (
|
|
32
|
+
// open render already-open, as a real panel: wireDrawer() adopts it onto
|
|
33
|
+
// the overlay stack, so the page behind it goes inert, Tab is
|
|
34
|
+
// trapped in the panel and Escape closes it
|
|
35
|
+
// specimen render open as a *picture* of the panel: same markup, minus the
|
|
36
|
+
// data-drawer hook and aria-modal, so no wiring and no key handler
|
|
37
|
+
// can reach it. See confirm() for why a documentation page wants
|
|
38
|
+
// that; `open` is the one to use when the drawer is real.
|
|
33
39
|
// id root id — a [data-drawer-open="id"] trigger targets it
|
|
34
40
|
// ariaLabel accessible name when there's no visible title
|
|
35
41
|
// dismissible show the close button + allow scrim/Esc dismiss (default true)
|
|
36
42
|
// closeLabel accessible name for the close button (default 'Close')
|
|
37
43
|
export function drawer({
|
|
38
44
|
side = 'right', size = 'md', title, body = '', footer = '',
|
|
39
|
-
open = false, id, ariaLabel, dismissible = true, closeLabel = 'Close',
|
|
45
|
+
open = false, specimen = false, id, ariaLabel, dismissible = true, closeLabel = 'Close',
|
|
40
46
|
} = {}) {
|
|
41
47
|
const titleId = title ? nextId('drawer-title') : null;
|
|
42
48
|
const nameAttr = titleId
|
|
@@ -54,71 +60,31 @@ export function drawer({
|
|
|
54
60
|
const bodyEl = `<div class="ui-drawer__body">${body}</div>`;
|
|
55
61
|
const footEl = footer ? `<footer class="ui-drawer__footer">${footer}</footer>` : '';
|
|
56
62
|
|
|
57
|
-
const rootCls = cx('ui-drawer', `ui-drawer--${side}`, `ui-drawer--${size}`, open && 'is-open');
|
|
58
|
-
return `<div class="${rootCls}" data-drawer data-drawer-side="${esc(side)}"`
|
|
63
|
+
const rootCls = cx('ui-drawer', `ui-drawer--${side}`, `ui-drawer--${size}`, (open || specimen) && 'is-open');
|
|
64
|
+
return `<div class="${rootCls}"${specimen ? '' : ' data-drawer'} data-drawer-side="${esc(side)}"`
|
|
59
65
|
+ `${dismissible ? '' : ' data-drawer-static'}${id ? ` id="${esc(id)}"` : ''}>`
|
|
60
66
|
+ `<div class="ui-drawer__scrim" data-drawer-scrim></div>`
|
|
61
|
-
+ `<aside class="ui-drawer__panel" role="dialog" aria-modal="true" ${nameAttr} tabindex="-1" data-drawer-panel>`
|
|
67
|
+
+ `<aside class="ui-drawer__panel" role="dialog"${specimen ? '' : ' aria-modal="true"'} ${nameAttr} tabindex="-1" data-drawer-panel>`
|
|
62
68
|
+ `${header}${bodyEl}${footEl}`
|
|
63
69
|
+ `</aside></div>`;
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
// ---- Shared behaviour ----------------------------------------------------
|
|
67
|
-
// One open/close/scrim/
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// document (guarded by a flag on the document
|
|
71
|
-
// get their own). Safe to call repeatedly
|
|
72
|
-
|
|
73
|
-
const FOCUSABLE = [
|
|
74
|
-
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
|
|
75
|
-
'select:not([disabled])', 'textarea:not([disabled])',
|
|
76
|
-
'[tabindex]:not([tabindex="-1"])',
|
|
77
|
-
].join(',');
|
|
78
|
-
|
|
79
|
-
// Focusable controls inside the panel, in DOM order. Scoped to the panel, so
|
|
80
|
-
// nothing outside the trap is returned; hidden closed drawers are never queried
|
|
81
|
-
// (we only call this while open).
|
|
82
|
-
function focusablesIn(panel) {
|
|
83
|
-
return Array.from(panel.querySelectorAll(FOCUSABLE));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Hide everything *outside* the drawer from AT + tab order: walk root→body and
|
|
87
|
-
// mark each ancestor's other children inert + aria-hidden, remembering prior
|
|
88
|
-
// state so close can restore it exactly. The scrim + panel live inside the root,
|
|
89
|
-
// so they stay interactive. This is the focus-trap primitive a Modal reuses.
|
|
90
|
-
function inertOutside(root, on) {
|
|
91
|
-
if (on) {
|
|
92
|
-
const touched = [];
|
|
93
|
-
let node = root;
|
|
94
|
-
while (node && node.parentElement && node !== document.body) {
|
|
95
|
-
for (const sib of node.parentElement.children) {
|
|
96
|
-
if (sib === node || sib.hasAttribute('data-drawer')) continue;
|
|
97
|
-
touched.push([sib, sib.getAttribute('aria-hidden'), sib.hasAttribute('inert')]);
|
|
98
|
-
sib.setAttribute('aria-hidden', 'true');
|
|
99
|
-
sib.setAttribute('inert', '');
|
|
100
|
-
sib.inert = true;
|
|
101
|
-
}
|
|
102
|
-
node = node.parentElement;
|
|
103
|
-
}
|
|
104
|
-
root.__drawerInert = touched;
|
|
105
|
-
} else {
|
|
106
|
-
(root.__drawerInert || []).forEach(([el, ariaHidden, hadInert]) => {
|
|
107
|
-
if (ariaHidden == null) el.removeAttribute('aria-hidden');
|
|
108
|
-
else el.setAttribute('aria-hidden', ariaHidden);
|
|
109
|
-
if (!hadInert) { el.removeAttribute('inert'); el.inert = false; }
|
|
110
|
-
});
|
|
111
|
-
root.__drawerInert = null;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
73
|
+
// One open/close/scrim/close-button implementation for every drawer in the kit;
|
|
74
|
+
// inertness, Escape and Tab belong to the overlay stack. Per-instance handlers
|
|
75
|
+
// are attached once (guarded by a flag on the node); the [data-drawer-open]
|
|
76
|
+
// delegation is attached once per document (guarded by a flag on the document
|
|
77
|
+
// node, so multiple documents each get their own). Safe to call repeatedly
|
|
78
|
+
// (Storybook re-renders).
|
|
114
79
|
|
|
115
80
|
export function openDrawer(root, returnFocusTo) {
|
|
116
81
|
if (!root || root.classList.contains('is-open')) return;
|
|
117
82
|
root.__drawerReturn = returnFocusTo
|
|
118
83
|
|| (document.activeElement instanceof HTMLElement ? document.activeElement : null);
|
|
119
84
|
root.classList.add('is-open');
|
|
120
|
-
inertOutside(root, true);
|
|
121
85
|
const panel = root.querySelector('[data-drawer-panel]');
|
|
86
|
+
const dismissible = !root.hasAttribute('data-drawer-static');
|
|
87
|
+
pushOverlay(root, panel, dismissible ? () => closeDrawer(root) : null, OVERLAY_LAYER.drawer);
|
|
122
88
|
// Focus the first focusable control, else the panel itself.
|
|
123
89
|
const first = panel && focusablesIn(panel)[0];
|
|
124
90
|
(first || panel)?.focus();
|
|
@@ -127,33 +93,12 @@ export function openDrawer(root, returnFocusTo) {
|
|
|
127
93
|
export function closeDrawer(root) {
|
|
128
94
|
if (!root || !root.classList.contains('is-open')) return;
|
|
129
95
|
root.classList.remove('is-open');
|
|
130
|
-
|
|
96
|
+
popOverlay(root);
|
|
131
97
|
const back = root.__drawerReturn;
|
|
132
98
|
root.__drawerReturn = null;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// Trap Tab within the panel while open; wrap at both ends.
|
|
137
|
-
function trapTab(root, e) {
|
|
138
|
-
if (e.key !== 'Tab') return;
|
|
139
|
-
const panel = root.querySelector('[data-drawer-panel]');
|
|
140
|
-
if (!panel) return;
|
|
141
|
-
const items = focusablesIn(panel);
|
|
142
|
-
if (!items.length) { e.preventDefault(); panel.focus(); return; }
|
|
143
|
-
const first = items[0];
|
|
144
|
-
const last = items[items.length - 1];
|
|
145
|
-
const active = document.activeElement;
|
|
146
|
-
if (e.shiftKey && (active === first || active === panel)) {
|
|
147
|
-
e.preventDefault(); last.focus();
|
|
148
|
-
} else if (!e.shiftKey && active === last) {
|
|
149
|
-
e.preventDefault(); first.focus();
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Topmost open drawer (last in document order) — Esc closes that one.
|
|
154
|
-
function topOpenDrawer(doc = document) {
|
|
155
|
-
const open = doc.querySelectorAll('[data-drawer].is-open');
|
|
156
|
-
return open.length ? open[open.length - 1] : null;
|
|
99
|
+
// The trigger can be gone by now — focus() on a detached node does nothing at
|
|
100
|
+
// all, which leaves the reader with no place on the page.
|
|
101
|
+
returnFocus(back, root.ownerDocument);
|
|
157
102
|
}
|
|
158
103
|
|
|
159
104
|
export function wireDrawer(root = document) {
|
|
@@ -169,14 +114,10 @@ export function wireDrawer(root = document) {
|
|
|
169
114
|
dr.querySelectorAll('[data-drawer-close]').forEach((btn) =>
|
|
170
115
|
btn.addEventListener('click', () => closeDrawer(dr)));
|
|
171
116
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
} else if (e.key === 'Tab') {
|
|
177
|
-
trapTab(dr, e);
|
|
178
|
-
}
|
|
179
|
-
});
|
|
117
|
+
// Rendered with `open: true`, so nothing called openDrawer() and nothing put
|
|
118
|
+
// it on the stack. Adopting it here is what makes its aria-modal true.
|
|
119
|
+
adoptOverlay(dr, dr.querySelector('[data-drawer-panel]'), dismissible ? () => closeDrawer(dr) : null,
|
|
120
|
+
OVERLAY_LAYER.drawer);
|
|
180
121
|
});
|
|
181
122
|
|
|
182
123
|
const doc = root === document ? document : (root.ownerDocument || document);
|
|
@@ -190,11 +131,8 @@ export function wireDrawer(root = document) {
|
|
|
190
131
|
const target = doc.getElementById(opener.getAttribute('data-drawer-open'));
|
|
191
132
|
if (target) openDrawer(target, opener);
|
|
192
133
|
});
|
|
193
|
-
// Esc closes the topmost open drawer even when focus escaped the panel.
|
|
194
|
-
doc.addEventListener('keydown', (e) => {
|
|
195
|
-
if (e.key !== 'Escape') return;
|
|
196
|
-
const top = topOpenDrawer(doc);
|
|
197
|
-
if (top && !top.hasAttribute('data-drawer-static')) { e.preventDefault(); closeDrawer(top); }
|
|
198
|
-
});
|
|
199
134
|
}
|
|
135
|
+
// This runs on every re-render, which is the moment to notice that an overlay
|
|
136
|
+
// was destroyed while it was open and hand the page back.
|
|
137
|
+
syncOverlays(doc);
|
|
200
138
|
}
|
package/src/components/index.js
CHANGED
|
@@ -93,8 +93,14 @@ export function segmented({ options = [], active = 0, size, block, name = 'seg',
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
// ---- Accent picker -------------------------------------------------------
|
|
96
|
+
// Each swatch is made of the tokens its accent selects: it fades from the next
|
|
97
|
+
// distinct step up that accent's dark ramp down to dark --accent. Most accents
|
|
98
|
+
// land on --purple-light; Nebula's is its own --accent since #157, so it walks
|
|
99
|
+
// on to --purple-mid. site/chrome.mjs and site/index.html hand-keep the same
|
|
100
|
+
// four strings and must change with these. Held by stories/accent-swatch.test.js,
|
|
101
|
+
// which derives both sides rather than restating them. See issue #190.
|
|
96
102
|
const ACCENT_SWATCH = {
|
|
97
|
-
default: 'linear-gradient(135deg,#
|
|
103
|
+
default: 'linear-gradient(135deg,#bd8cff,#b479ff)',
|
|
98
104
|
phoenix: 'linear-gradient(135deg,#ff8a5c,#ff6a3d)',
|
|
99
105
|
ocean: 'linear-gradient(135deg,#5ab0ff,#3b9dff)',
|
|
100
106
|
emerald: 'linear-gradient(135deg,#3ad9a0,#16c98a)',
|
package/src/components/nav.js
CHANGED
|
@@ -30,15 +30,33 @@ const nextId = (p = 'nav') => `${p}-${++_uid}`;
|
|
|
30
30
|
|
|
31
31
|
// A trailing counter/badge on a nav item. `badge` is a number, a string, or
|
|
32
32
|
// { text, tone }. Tones map to the badge token set (accent | live | neutral…).
|
|
33
|
-
|
|
33
|
+
const badgeText = (badge) => {
|
|
34
34
|
if (badge == null || badge === '') return '';
|
|
35
35
|
const text = typeof badge === 'object' ? badge.text : badge;
|
|
36
|
+
return text == null || text === '' ? '' : String(text);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function navBadge(badge) {
|
|
40
|
+
const text = badgeText(badge);
|
|
41
|
+
if (!text) return '';
|
|
36
42
|
const tone = typeof badge === 'object' && badge.tone ? badge.tone : 'neutral';
|
|
37
43
|
return `<span class="${cx('ui-nav__badge', `is-${tone}`)}">${esc(text)}</span>`;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
46
|
+
// An item's accessible name. It is emitted at every width, not only when
|
|
47
|
+
// `collapsed`, so a shell may fold `.ui-nav__label` out of view in CSS and keep
|
|
48
|
+
// the icon named. An always-on name overrides the row's own text, so the badge
|
|
49
|
+
// has to be spelled into it — otherwise "Pending 3" narrows to "Pending". The
|
|
50
|
+
// `title` tooltip is for the collapsed rail only — a narrow touch device has no
|
|
51
|
+
// hover to show it with, and the badge is folded away there too.
|
|
52
|
+
const leafName = (label, collapsed, badge) => {
|
|
53
|
+
const count = badgeText(badge);
|
|
54
|
+
const name = esc(count ? `${label} ${count}` : label);
|
|
55
|
+
return ` aria-label="${name}"${collapsed ? ` title="${name}"` : ''}`;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// One sidebar leaf: a link (or a plain, aria-disabled span). `collapsed` also
|
|
59
|
+
// hides the label visually, leaving the icon hoverable.
|
|
42
60
|
function sideLeaf(it, active, { collapsed, sub } = {}) {
|
|
43
61
|
const on = it.id != null && it.id === active;
|
|
44
62
|
const disabled = !!it.disabled;
|
|
@@ -47,7 +65,7 @@ function sideLeaf(it, active, { collapsed, sub } = {}) {
|
|
|
47
65
|
const text = `<span class="ui-nav__label">${esc(label)}</span>`;
|
|
48
66
|
const badge = navBadge(it.badge);
|
|
49
67
|
const cls = cx('ui-nav__item', sub && 'ui-nav__item--sub', on && 'is-active', it.danger && 'is-danger', disabled && 'is-disabled');
|
|
50
|
-
const name =
|
|
68
|
+
const name = leafName(label, collapsed, it.badge);
|
|
51
69
|
if (disabled) {
|
|
52
70
|
return `<li><span class="${cls}" aria-disabled="true"${name}>${lead}${text}${badge}</span></li>`;
|
|
53
71
|
}
|
|
@@ -72,7 +90,7 @@ function sideGroup(it, active, { collapsed } = {}) {
|
|
|
72
90
|
const label = it.label || '';
|
|
73
91
|
const text = `<span class="ui-nav__label">${esc(label)}</span>`;
|
|
74
92
|
const kids = (it.items || []).map((c) => sideLeaf(c, active, { collapsed, sub: true })).join('');
|
|
75
|
-
const name =
|
|
93
|
+
const name = leafName(label, collapsed);
|
|
76
94
|
const btn =
|
|
77
95
|
`<button type="button" class="${cx('ui-nav__item', 'ui-nav__toggle', childActive && 'is-current')}"` +
|
|
78
96
|
` data-nav-toggle aria-expanded="${open ? 'true' : 'false'}" aria-controls="${listId}"${name}>` +
|
|
@@ -89,7 +107,8 @@ function sideItem(it, active, opts) {
|
|
|
89
107
|
// items/section items: { id, label, icon?, href?, target?, badge?, danger?,
|
|
90
108
|
// disabled?, items?, open? } (items ⇒ collapsible group)
|
|
91
109
|
// active id of the current item (gets aria-current="page")
|
|
92
|
-
// collapsed icon-only rail;
|
|
110
|
+
// collapsed icon-only rail; every item is aria-labelled at every width, and
|
|
111
|
+
// collapsed adds the hover tooltip on top
|
|
93
112
|
// footer trusted HTML pinned below a divider (e.g. a sign-out link)
|
|
94
113
|
// ariaLabel accessible name for the <nav> landmark
|
|
95
114
|
export function sidebarNav({
|