@apliteni/apliteni-ui 0.32.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 +10 -6
- package/package.json +1 -1
- package/src/components/dropdown.js +77 -30
- package/src/components/footer.js +6 -1
- package/src/components/nav.js +4 -3
- package/src/components/shell.js +197 -30
- package/src/components/success.js +12 -1
- package/src/styles/dropdown.css +18 -9
- package/src/styles/layout.css +201 -55
- package/src/styles/nav.css +88 -10
- package/src/styles/topbar.css +28 -2
package/README.md
CHANGED
|
@@ -28,11 +28,13 @@ They use the vanilla kit's `.ui-*` classes and tokens.
|
|
|
28
28
|
**Which one:** does the surface hold meaningful client state? No → the HTML-string
|
|
29
29
|
factories below. Yes → the [React components](#react-components-stateful-surfaces).
|
|
30
30
|
|
|
31
|
-
Either layer follows the same UI rules —
|
|
32
|
-
owes, how colour and wording work. They live in the **Guidelines** section
|
|
33
|
-
which opens on
|
|
34
|
-
[an overview of
|
|
35
|
-
and what the kit does and does not yet meet.
|
|
31
|
+
Either layer follows the same UI rules — what one page may hold, which component to reach
|
|
32
|
+
for, the states it owes, how colour and wording work. They live in the **Guidelines** section
|
|
33
|
+
of Storybook, which opens on
|
|
34
|
+
[an overview of every page](https://ui.apli.tech/storybook/?path=/story/guidelines-overview--overview)
|
|
35
|
+
and what the kit does and does not yet meet.
|
|
36
|
+
[The page](https://ui.apli.tech/storybook/?path=/story/guidelines-the-page--the-page) is the one
|
|
37
|
+
to read before you design a screen: the limits one page keeps, whatever it is about.
|
|
36
38
|
|
|
37
39
|
## Install
|
|
38
40
|
|
|
@@ -78,7 +80,7 @@ The whole `/account` layout (topbar + sticky sidebar + page body) ships as one
|
|
|
78
80
|
factory, so every product renders the same account shell instead of re-building it:
|
|
79
81
|
|
|
80
82
|
```js
|
|
81
|
-
import { accountShell, card, switchToggle, wireTopbar } from '@apliteni/apliteni-ui';
|
|
83
|
+
import { accountShell, card, switchToggle, wireTopbar, wireShell } from '@apliteni/apliteni-ui';
|
|
82
84
|
|
|
83
85
|
el.innerHTML = accountShell({
|
|
84
86
|
word: 'Strategy', // the product word in the topbar
|
|
@@ -89,8 +91,10 @@ el.innerHTML = accountShell({
|
|
|
89
91
|
body: card({ title: 'Appearance', body: switchToggle({ label: 'Reduce motion' }) }),
|
|
90
92
|
});
|
|
91
93
|
wireTopbar(el); // menus, theme toggle, segmented controls
|
|
94
|
+
wireShell(el); // the toggle that folds the rail, the reader's menu, the nav's groups
|
|
92
95
|
|
|
93
96
|
// Custom sidebar nav? pass `nav: [['prefs','gear','Preferences'], ['billing','wallet','Billing']]`
|
|
97
|
+
// A page that will never call wireShell()? pass `collapsible: false` and no toggle is drawn
|
|
94
98
|
```
|
|
95
99
|
|
|
96
100
|
Server-rendered apps that inline CSS (like the strategy portal) import the stylesheet
|
package/package.json
CHANGED
|
@@ -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/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>`;
|
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',
|
|
@@ -64,6 +64,7 @@ function countdownEl({ seconds = 5, label = 'Redirecting' } = {}) {
|
|
|
64
64
|
// changing one never moves the other.
|
|
65
65
|
export function success({
|
|
66
66
|
layout = 'hero', // 'hero' | 'split' | 'compact'
|
|
67
|
+
level, // heading level of the title; see the note below
|
|
67
68
|
backdrop = 'aurora', // 'aurora' | 'glow' | 'flat'
|
|
68
69
|
eyebrow = '',
|
|
69
70
|
title = 'All done',
|
|
@@ -76,6 +77,16 @@ export function success({
|
|
|
76
77
|
const cls = ['ui-sx', `ui-sx--${layout}`, `ui-sx--bd-${backdrop}`, confetti && 'ui-sx--confetti', className]
|
|
77
78
|
.filter(Boolean).join(' ');
|
|
78
79
|
|
|
80
|
+
// The title's rank follows the layout, because the layout is the question
|
|
81
|
+
// "how much of the screen does this own": hero and split ARE the page a flow
|
|
82
|
+
// lands on, so their title is its h1; compact sits beside other content and
|
|
83
|
+
// takes h2. It was an h3 either way, which left a page whose whole content is
|
|
84
|
+
// a success() with no h1 at all — the fault Guidelines / The page names.
|
|
85
|
+
// A caller who knows better passes `level`. The look is the class's.
|
|
86
|
+
// why: docs/specification.md#the-page
|
|
87
|
+
const rank = [1, 2, 3, 4, 5, 6].includes(Number(level)) ? Number(level) : (layout === 'compact' ? 2 : 1);
|
|
88
|
+
const h = `h${rank}`;
|
|
89
|
+
|
|
79
90
|
const bd = backdrop === 'aurora'
|
|
80
91
|
? `<div class="ui-sx__aurora" aria-hidden="true">
|
|
81
92
|
<span class="ui-sx__glow ui-sx__glow--a"></span>
|
|
@@ -99,7 +110,7 @@ export function success({
|
|
|
99
110
|
<div class="ui-sx__visual">${successCheck()}</div>
|
|
100
111
|
<div class="ui-sx__content">
|
|
101
112
|
${eyebrowEl}
|
|
102
|
-
|
|
113
|
+
<${h} class="ui-sx__title">${esc(title)}</${h}>
|
|
103
114
|
${bodyEl}
|
|
104
115
|
${actionsEl}
|
|
105
116
|
${countEl}
|
package/src/styles/dropdown.css
CHANGED
|
@@ -65,6 +65,10 @@
|
|
|
65
65
|
padding: 6px;
|
|
66
66
|
opacity: 0;
|
|
67
67
|
visibility: hidden;
|
|
68
|
+
/* Drawn for the whole of the fade out, so the rows do not vanish mid-fade — and a
|
|
69
|
+
box that is drawn is a box that is hit. The open rules take this back.
|
|
70
|
+
why: docs/specification.md#the-dropdown-panel */
|
|
71
|
+
pointer-events: none;
|
|
68
72
|
transform: translateY(-6px);
|
|
69
73
|
transition:
|
|
70
74
|
opacity var(--dur-med) var(--ease),
|
|
@@ -89,7 +93,13 @@
|
|
|
89
93
|
transform: translateY(6px);
|
|
90
94
|
}
|
|
91
95
|
|
|
92
|
-
.ui-dropdown.open .ui-dropdown__panel { opacity: 1; visibility: visible; transform: translateY(0); }
|
|
96
|
+
.ui-dropdown.open .ui-dropdown__panel { opacity: 1; visibility: visible; pointer-events: auto; transform: translateY(0); }
|
|
97
|
+
/* Open, the panel is visible AT ONCE: `visibility` is off the list, so it is not
|
|
98
|
+
held at `hidden` for the frame a key opens the panel in — the frame the focus
|
|
99
|
+
call happens in. Closing still fades on every property it always did.
|
|
100
|
+
why: docs/specification.md#the-dropdown-panel */
|
|
101
|
+
.ui-dropdown.open .ui-dropdown__panel,
|
|
102
|
+
.ui-dropdown__panel--portal.is-open { transition-property: opacity, transform; }
|
|
93
103
|
|
|
94
104
|
/* Portalled panel — wireDropdown() moves it onto <body>, clear of every
|
|
95
105
|
ancestor's overflow and every ancestor's stacking context. The descendant
|
|
@@ -101,7 +111,7 @@
|
|
|
101
111
|
position: fixed;
|
|
102
112
|
top: auto; right: auto; bottom: auto; left: auto;
|
|
103
113
|
}
|
|
104
|
-
.ui-dropdown__panel--portal.is-open { opacity: 1; visibility: visible; transform: translateY(0); }
|
|
114
|
+
.ui-dropdown__panel--portal.is-open { opacity: 1; visibility: visible; pointer-events: auto; transform: translateY(0); }
|
|
105
115
|
|
|
106
116
|
/* Item row — [icon] [main: label + desc] [badge] [tick]. A row is a <div>, an
|
|
107
117
|
<a href> or a <button>: `is-selected` and __tick below mean a row gets
|
|
@@ -160,7 +170,7 @@
|
|
|
160
170
|
}
|
|
161
171
|
.ui-dropdown__badge.is-live { color: var(--chip-success-ink); background: var(--chip-success-fill); }
|
|
162
172
|
/* The accent counter keeps the badge's flat surface and changes only the ink — the shape
|
|
163
|
-
src/styles/nav.css:
|
|
173
|
+
src/styles/nav.css:163 `.ui-nav__item.is-active .ui-nav__badge.is-accent` already holds.
|
|
164
174
|
The accent wash goes on a base surface, never a raised one, and #295 made this panel a
|
|
165
175
|
raised one. why: docs/specification.md#elevation */
|
|
166
176
|
.ui-dropdown__badge.is-accent { color: var(--accent); background: var(--surface-3); }
|
|
@@ -169,8 +179,12 @@
|
|
|
169
179
|
.ui-dropdown__item.is-disabled { color: var(--disabled-ink); cursor: not-allowed; pointer-events: none; }
|
|
170
180
|
.ui-dropdown__item.is-disabled .ui-dropdown__label,
|
|
171
181
|
.ui-dropdown__item.is-disabled .ui-dropdown__desc { color: inherit; }
|
|
182
|
+
/* Quiet at rest, --pink on the way to being pressed. Both states, not hover alone:
|
|
183
|
+
the ring says where the reader is, not that this row ends something.
|
|
184
|
+
why: docs/specification.md#a-dropdown-row-is-a-div-a-link-or-a-button */
|
|
172
185
|
.ui-dropdown__item.is-danger .ui-dropdown__label { color: var(--muted); }
|
|
173
|
-
.ui-dropdown__item.is-danger:hover .ui-dropdown__label
|
|
186
|
+
.ui-dropdown__item.is-danger:hover .ui-dropdown__label,
|
|
187
|
+
.ui-dropdown__item.is-danger:focus-visible .ui-dropdown__label { color: var(--pink); }
|
|
174
188
|
|
|
175
189
|
/* Grouped sections. `~` and :not([hidden]), because `+` counts a group a search
|
|
176
190
|
query has hidden and drew the divider above the first group left showing. */
|
|
@@ -200,11 +214,6 @@
|
|
|
200
214
|
variant emits, so the plain dropdown renders as it did.
|
|
201
215
|
why: docs/specification.md#a-dropdown-with-a-search-field */
|
|
202
216
|
.ui-dropdown__panel--search { min-width: 280px; }
|
|
203
|
-
/* Open, the panel is visible at once instead of at the first step of the
|
|
204
|
-
`visibility` transition, because a browser will not focus a field in a box
|
|
205
|
-
that is still `hidden` and opening puts focus in the field. Closing still fades. */
|
|
206
|
-
.ui-dropdown.open .ui-dropdown__panel--search,
|
|
207
|
-
.ui-dropdown__panel--search.is-open { transition-property: opacity, transform; }
|
|
208
217
|
|
|
209
218
|
.ui-dropdown__search { position: relative; display: flex; align-items: center; margin-bottom: 6px; }
|
|
210
219
|
.ui-dropdown__search-ic {
|
package/src/styles/layout.css
CHANGED
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
background: var(--bg);
|
|
17
17
|
color: var(--text);
|
|
18
18
|
font-family: var(--font-sans);
|
|
19
|
+
/* The rail's width, and the one property the fold moves: the nav's own column
|
|
20
|
+
plus the inset either side plus the rule, so the shell has no geometry of its
|
|
21
|
+
own to drift from the rows inside it. why: docs/specification.md#the-page-shell */
|
|
22
|
+
--ui-rail-rule: 1px;
|
|
23
|
+
--ui-rail-w: calc(var(--ui-nav-col) + 2 * var(--space-4) + var(--ui-rail-rule));
|
|
19
24
|
}
|
|
20
25
|
/* With a topbar above it, the shell starts below one. 52px is .topbar's height
|
|
21
26
|
in topbar.css; the topbar sticks so the rail below it can too. */
|
|
@@ -33,21 +38,27 @@
|
|
|
33
38
|
flex-direction: column;
|
|
34
39
|
gap: var(--space-5);
|
|
35
40
|
padding: var(--space-5) var(--space-4);
|
|
36
|
-
border-right:
|
|
41
|
+
border-right: var(--ui-rail-rule) solid var(--border);
|
|
37
42
|
background: var(--surface-2);
|
|
38
43
|
position: sticky;
|
|
39
44
|
top: var(--ui-app-top, 0px);
|
|
40
45
|
height: calc(100vh - var(--ui-app-top, 0px));
|
|
46
|
+
width: var(--ui-rail-w);
|
|
47
|
+
/* hidden across, auto down: the fold clips what leaves the screen rather than
|
|
48
|
+
laying it out again. Two longhands, not the shorthand — the gates' CSS engine
|
|
49
|
+
does not read the pair. why: docs/specification.md#the-page-shell */
|
|
50
|
+
overflow-x: hidden;
|
|
41
51
|
overflow-y: auto;
|
|
52
|
+
transition: width var(--dur-med) var(--ease);
|
|
42
53
|
}
|
|
54
|
+
/* Each block of the rail keeps the open column while the box closes over it. The
|
|
55
|
+
nav declares its own; these are the two that are not the nav. */
|
|
56
|
+
.ui-app__head,
|
|
57
|
+
.ui-app__user { width: var(--ui-nav-col); flex: none; }
|
|
43
58
|
/* The rail's hover WAS --surface-2, so moving the rail onto it would have made
|
|
44
|
-
hovering a row do nothing.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
was quietly losing the --pink wash the guideline page cites nav.css for. The
|
|
48
|
-
current row is left out for the same reason: this rule outranks nav.css's
|
|
49
|
-
active hover, and painting --surface-3 over a row already resting on it is
|
|
50
|
-
what made the row you are standing on the one row that ignores the pointer. */
|
|
59
|
+
hovering a row do nothing. The danger row and the current row are left out by
|
|
60
|
+
name, because this rule outranks the states nav.css writes for them.
|
|
61
|
+
why: CONTRIBUTING.md#a-rule-that-outranks-another-cancels-every-state-that-other-one-writes */
|
|
51
62
|
.ui-app__rail .ui-nav__item:not(.is-danger):not(.is-active):hover { background: var(--surface-3); }
|
|
52
63
|
.ui-app__brand {
|
|
53
64
|
display: flex;
|
|
@@ -55,6 +66,10 @@
|
|
|
55
66
|
gap: var(--space-2);
|
|
56
67
|
padding: 2px 6px;
|
|
57
68
|
text-decoration: none;
|
|
69
|
+
/* The lockup leaves on the words' clock, `visibility` with it so the link leaves
|
|
70
|
+
the tab order — `linear`, as every discrete property in the kit is.
|
|
71
|
+
why: docs/specification.md#the-page-shell */
|
|
72
|
+
transition: opacity var(--dur-fast) var(--ease), visibility var(--dur-fast) linear;
|
|
58
73
|
/* display: brand — the rail's lockup is the same mark topbar.css sets, and the
|
|
59
74
|
two have to be the same face or the shell reads as two products. */
|
|
60
75
|
font-family: var(--font-display);
|
|
@@ -66,11 +81,23 @@
|
|
|
66
81
|
.ui-app .ui-app__brand { color: var(--strong); }
|
|
67
82
|
.ui-app__brand svg { display: block; flex: none; }
|
|
68
83
|
|
|
84
|
+
/* The head band: the product's mark, and the rail's own control at the far end of
|
|
85
|
+
the same line. `min-width: 0` so a long word shrinks the lockup rather than pushing
|
|
86
|
+
the control off the band. why: docs/specification.md#the-page-shell */
|
|
87
|
+
.ui-app__head {
|
|
88
|
+
display: flex;
|
|
89
|
+
align-items: center;
|
|
90
|
+
gap: var(--space-2);
|
|
91
|
+
padding-bottom: var(--space-3);
|
|
92
|
+
border-bottom: 1px solid var(--border);
|
|
93
|
+
}
|
|
94
|
+
.ui-app__head > .ui-app__brand { min-width: 0; }
|
|
95
|
+
|
|
69
96
|
/* The nav grows, so the reader block below it pins to the bottom of a full-height
|
|
70
|
-
rail instead of hugging the last nav row
|
|
71
|
-
|
|
97
|
+
rail instead of hugging the last nav row. The nav's own footer slot is unused —
|
|
98
|
+
sign out is in the reader's menu, because it ends a session rather than going
|
|
99
|
+
anywhere — so the rail's foot is the account block alone. */
|
|
72
100
|
.ui-app__rail .ui-nav--side { flex: 1 1 auto; }
|
|
73
|
-
.ui-app__rail .ui-nav__foot { margin-top: auto; }
|
|
74
101
|
|
|
75
102
|
/* Which row is current is structural here, not chromatic: the resting glyphs
|
|
76
103
|
step down so the current one is the brightest whatever --accent is doing, and
|
|
@@ -78,30 +105,37 @@
|
|
|
78
105
|
smallest of three weak signals doing the most work, so it grows with the row. */
|
|
79
106
|
/* Re-tuned at #295, which took the light rail down a step with the page. Below 720px
|
|
80
107
|
the glyph is the whole row, so it is held at text grade rather than at 1.4.11's bar —
|
|
81
|
-
stories/apps/shell-states.test.js:
|
|
108
|
+
stories/apps/shell-states.test.js:1340 `a resting glyph is dimmer but still legible`. */
|
|
82
109
|
.ui-app__rail .ui-nav__ic svg { opacity: 0.66; }
|
|
83
110
|
.ui-app__rail .ui-nav__item.is-active .ui-nav__ic svg,
|
|
84
111
|
.ui-app__rail .ui-nav__item.is-current .ui-nav__ic svg { opacity: 1; stroke: currentColor; } /* motion: still — the glyph brightens with its row, whose own highlight already transitions */
|
|
85
112
|
.ui-app__rail .ui-nav__item.is-active::before { width: 3px; height: 62%; }
|
|
86
113
|
|
|
87
|
-
/*
|
|
88
|
-
step nav.css writes for it
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
it forgets is a state it silently cancels. */
|
|
114
|
+
/* A destructive row of the rail is a navigation row and rests like one; the danger
|
|
115
|
+
intent is the step nav.css writes for it. Both of nav.css's danger states are
|
|
116
|
+
named, not hover alone.
|
|
117
|
+
why: CONTRIBUTING.md#a-rule-that-outranks-another-cancels-every-state-that-other-one-writes */
|
|
92
118
|
.ui-app__rail .ui-nav__item.is-danger:not(:hover):not(:focus-visible) { color: var(--text); }
|
|
93
119
|
|
|
94
|
-
/* One rule closes the rail,
|
|
95
|
-
|
|
96
|
-
|
|
120
|
+
/* One rule closes the rail, and it is this one: the nav's footer slot is empty now
|
|
121
|
+
that sign out is in the menu this block opens, so the hairline that fenced sign
|
|
122
|
+
out off moved down onto the block it was fencing. Two of them twenty pixels apart
|
|
123
|
+
read as a third region of the rail rather than as its foot. */
|
|
97
124
|
.ui-app__user {
|
|
98
125
|
display: flex;
|
|
99
126
|
align-items: center;
|
|
100
127
|
gap: var(--space-2);
|
|
101
|
-
padding: var(--space-5)
|
|
128
|
+
padding-top: var(--space-5);
|
|
129
|
+
border-top: 1px solid var(--border);
|
|
102
130
|
}
|
|
131
|
+
/* The avatar is the reader block's own glyph, so half the difference between the
|
|
132
|
+
glyph column and itself is the inset that centres it on the line every glyph above
|
|
133
|
+
it stands on. The arithmetic and not the 5.5 it comes to: both numbers are declared
|
|
134
|
+
elsewhere. why: docs/specification.md#the-page-shell */
|
|
135
|
+
.ui-app { --ui-app-av: 30px; }
|
|
103
136
|
.ui-app__av {
|
|
104
|
-
width:
|
|
137
|
+
width: var(--ui-app-av); height: var(--ui-app-av); flex: none;
|
|
138
|
+
margin-inline-start: calc((var(--ui-nav-strip) - var(--ui-app-av)) / 2);
|
|
105
139
|
border-radius: var(--radius-pill);
|
|
106
140
|
background: var(--surface-3);
|
|
107
141
|
color: var(--strong);
|
|
@@ -115,6 +149,112 @@
|
|
|
115
149
|
the rail column is max-content, so a long one widens the rail instead. */
|
|
116
150
|
.ui-app__who span { display: block; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
117
151
|
|
|
152
|
+
/* -- the reader's menu ---------------------------------------------------- */
|
|
153
|
+
/* The account block is a dropdown() trigger, so it takes the rail's own row skin
|
|
154
|
+
rather than the dropdown's pill — in place it is the last row of the rail. The
|
|
155
|
+
dropdown's own ring is left standing. why: docs/specification.md#the-page-shell */
|
|
156
|
+
.ui-app__user .ui-dropdown { width: 100%; }
|
|
157
|
+
.ui-app .ui-app__user-trigger {
|
|
158
|
+
width: 100%;
|
|
159
|
+
gap: var(--space-2);
|
|
160
|
+
/* As tall as the mark inside it plus its own padding — 30 + 4 + 4, written out
|
|
161
|
+
rather than as the calc() it comes from, and held to that arithmetic by
|
|
162
|
+
stories/apps/shell-states.test.js.
|
|
163
|
+
why: CONTRIBUTING.md#an-unresolved-var-measures-nothing-and-reports-green */
|
|
164
|
+
min-height: 38px;
|
|
165
|
+
padding: var(--space-1) 0;
|
|
166
|
+
border: 0;
|
|
167
|
+
border-radius: var(--radius-sm);
|
|
168
|
+
background: none;
|
|
169
|
+
color: var(--text);
|
|
170
|
+
font-size: inherit;
|
|
171
|
+
font-weight: inherit;
|
|
172
|
+
letter-spacing: normal;
|
|
173
|
+
}
|
|
174
|
+
.ui-app .ui-app__user-trigger:hover { background: var(--surface-3); }
|
|
175
|
+
.ui-app__user-trigger .ui-app__who { flex: 1 1 auto; text-align: left; }
|
|
176
|
+
/* The panel is portalled onto <body>, so nothing here may hang off .ui-app: by the
|
|
177
|
+
time it is open it is no longer inside the rail that drew it.
|
|
178
|
+
why: docs/specification.md#the-dropdown-panel */
|
|
179
|
+
.ui-app__user-panel .ui-dropdown__head { display: flex; flex-direction: column; gap: 2px; }
|
|
180
|
+
.ui-app__user-panel .ui-dropdown__head b { display: block; color: var(--strong); font-size: 12.5px; font-weight: var(--weight-semibold); }
|
|
181
|
+
/* The address wraps rather than truncating: nothing bounds this line, so
|
|
182
|
+
`text-overflow` would have nothing to fire against — the inert pair
|
|
183
|
+
.ui-app__who's own comment warns about. `anywhere` keeps an address with no
|
|
184
|
+
break in it from widening the panel instead. */
|
|
185
|
+
.ui-app__user-panel .ui-dropdown__head span { display: block; color: var(--muted); font-size: 11px; overflow-wrap: anywhere; }
|
|
186
|
+
|
|
187
|
+
/* The toggle's cell, at the far end of the brand row: the auto margin puts it there
|
|
188
|
+
with or without a wordmark beside it, and the offset below rides it back onto the
|
|
189
|
+
glyph column as the rail closes. `position: relative` and not a transform, because
|
|
190
|
+
the chip inside is `position: fixed`. why: docs/specification.md#the-page-shell */
|
|
191
|
+
.ui-app__fold-row {
|
|
192
|
+
flex: none;
|
|
193
|
+
margin-inline-start: auto;
|
|
194
|
+
position: relative;
|
|
195
|
+
inset-inline-start: 0;
|
|
196
|
+
transition: inset-inline-start var(--dur-med) var(--ease);
|
|
197
|
+
}
|
|
198
|
+
/* One box on the glyph column at both widths — --ui-nav-strip, the width nav.css
|
|
199
|
+
derives the closed rail from. `gap: 0` leaves that column the whole of the
|
|
200
|
+
content box, so the label below is squeezed to nothing until the chip rule lifts
|
|
201
|
+
it out. why: docs/specification.md#the-page-shell */
|
|
202
|
+
.ui-app__fold { width: var(--ui-nav-strip); gap: 0; }
|
|
203
|
+
/* The chip rule lifts the name out of the flow, and a 17px glyph is shorter than
|
|
204
|
+
the line the name occupied — so the button lost 0.39px on hover and moved every
|
|
205
|
+
row under it. The glyph box carries the line instead, in both states.
|
|
206
|
+
why: docs/specification.md#the-page-shell */
|
|
207
|
+
.ui-app__fold .ui-nav__ic { height: 1lh; }
|
|
208
|
+
.ui-app .ui-app__fold { color: var(--dim); }
|
|
209
|
+
.ui-app .ui-app__fold:hover,
|
|
210
|
+
.ui-app .ui-app__fold:focus-visible { color: var(--strong); }
|
|
211
|
+
/* The width's own clock, not the words', so the mark and the closing edge arrive
|
|
212
|
+
together; no `!important`, so the reduced-motion net still takes it. 6 is not a
|
|
213
|
+
number: shell.js draws the frame 3..21 and the seam at 9, and 6 mirrors it about
|
|
214
|
+
the centre. why: docs/specification.md#the-page-shell */
|
|
215
|
+
.ui-app__fold .ui-app__fold-seam { transition: transform var(--dur-med) var(--ease); }
|
|
216
|
+
.ui-app.is-collapsed .ui-app__fold .ui-app__fold-seam { transform: translateX(6px); }
|
|
217
|
+
|
|
218
|
+
/* -- The collapsed rail --------------------------------------------------- */
|
|
219
|
+
/* Each rule here has its twin in the 720px block below; nothing lays a row out
|
|
220
|
+
again. why: docs/specification.md#the-page-shell */
|
|
221
|
+
.ui-app.is-collapsed { --ui-rail-w: calc(var(--ui-nav-strip) + 2 * var(--space-4) + var(--ui-rail-rule)); }
|
|
222
|
+
:where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__cap,
|
|
223
|
+
:where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__label,
|
|
224
|
+
:where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__badge,
|
|
225
|
+
:where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__caret,
|
|
226
|
+
:where(.ui-app.is-collapsed) .ui-app__rail .ui-app__who,
|
|
227
|
+
:where(.ui-app.is-collapsed) .ui-app__rail .ui-dropdown__chevron,
|
|
228
|
+
:where(.ui-app.is-collapsed) .ui-app__brand span { opacity: 0; pointer-events: none; }
|
|
229
|
+
/* The toggle rides onto the glyph column — the two widths' own difference — and the
|
|
230
|
+
lockup goes whole, since that column is where its mark stood. The mark may only go
|
|
231
|
+
where the control arrives: below 720px the toggle is not drawn, so the 720px block
|
|
232
|
+
gives the lockup back rather than leaving an empty band with a hairline under it.
|
|
233
|
+
why: docs/specification.md#the-page-shell */
|
|
234
|
+
:where(.ui-app.is-collapsed) .ui-app__fold-row { inset-inline-start: calc(var(--ui-nav-strip) - var(--ui-nav-col)); }
|
|
235
|
+
:where(.ui-app.is-collapsed) .ui-app__brand { opacity: 0; visibility: hidden; pointer-events: none; }
|
|
236
|
+
:where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__sub { margin-inline-start: 0; padding-inline-start: 0; border-inline-start-width: 0; }
|
|
237
|
+
:where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__item:not(:has(.ui-nav__ic))::after { opacity: 0.62; }
|
|
238
|
+
/* The label IS the chip. `fixed` is the fallback placement, out of the rail's own
|
|
239
|
+
clip; the @supports branch below pins it to the row and is the only one that
|
|
240
|
+
survives a scroll. The toggle's selector is unscoped, because it is wordless at
|
|
241
|
+
both widths. why: docs/specification.md#the-page-shell */
|
|
242
|
+
:where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__item:is(:hover, :focus-visible) > .ui-nav__label,
|
|
243
|
+
:where(.ui-app.is-collapsed) .ui-app__rail .ui-app__user-trigger:is(:hover, :focus-visible) > .ui-app__who,
|
|
244
|
+
.ui-app__rail .ui-app__fold:is(:hover, :focus-visible) > .ui-nav__label { position: fixed; left: calc(var(--ui-rail-w) + var(--space-2)); z-index: var(--z-dropdown); width: max-content; max-width: 15rem; margin-block: -7px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius-xs); background: var(--surface-3); color: var(--strong); box-shadow: var(--shadow-md); opacity: 1; pointer-events: none; }
|
|
245
|
+
@supports (anchor-name: --a) and (anchor-scope: --a) {
|
|
246
|
+
.ui-app.is-collapsed,
|
|
247
|
+
.ui-app:has(.ui-app__fold) { anchor-scope: --ui-rail; }
|
|
248
|
+
:where(.ui-app.is-collapsed) .ui-app__rail,
|
|
249
|
+
.ui-app__rail:has(.ui-app__fold) { anchor-name: --ui-rail; }
|
|
250
|
+
:where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__item,
|
|
251
|
+
:where(.ui-app.is-collapsed) .ui-app__rail .ui-app__user-trigger,
|
|
252
|
+
.ui-app__rail .ui-app__fold { anchor-name: --ui-rail-row; anchor-scope: --ui-rail-row; }
|
|
253
|
+
:where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__item:is(:hover, :focus-visible) > .ui-nav__label,
|
|
254
|
+
:where(.ui-app.is-collapsed) .ui-app__rail .ui-app__user-trigger:is(:hover, :focus-visible) > .ui-app__who,
|
|
255
|
+
.ui-app__rail .ui-app__fold:is(:hover, :focus-visible) > .ui-nav__label { position-anchor: --ui-rail-row; left: calc(anchor(--ui-rail right) + var(--space-2)); top: anchor(top); bottom: anchor(bottom); align-self: center; margin-block: 0; }
|
|
256
|
+
}
|
|
257
|
+
|
|
118
258
|
/* The reading column centres in the track it is given — without width: 100% it
|
|
119
259
|
was hugging the rail, 519px of dead background on the right at 1728px wide.
|
|
120
260
|
The width is what keeps the auto margins honest: an auto inline margin takes
|
|
@@ -166,45 +306,51 @@
|
|
|
166
306
|
.ui-toolbar > .ui-input-group { flex: 1 1 6rem; min-width: 0; }
|
|
167
307
|
|
|
168
308
|
/* -- The narrow rail ------------------------------------------------------ */
|
|
169
|
-
/* Below this width the rail folds to
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
guessed. Nothing here resizes the glyph: an icon-sizing rule inside a media
|
|
175
|
-
query is one src/styles/icon-size.test.js cannot measure, and it says so. */
|
|
309
|
+
/* Below this width the rail folds to the icon strip instead of disappearing, and
|
|
310
|
+
it is the fold the reader's own press makes — every rule here has its twin
|
|
311
|
+
above. Scoped to the shell's own rail; a sidebarNav() elsewhere keeps its
|
|
312
|
+
labels. Nothing here resizes a glyph: an icon-sizing rule inside a media query
|
|
313
|
+
is one src/styles/icon-size.test.js cannot measure, and it says so. */
|
|
176
314
|
@media (max-width: 720px) {
|
|
177
|
-
.ui-app {
|
|
178
|
-
|
|
179
|
-
.ui-app__rail .ui-
|
|
315
|
+
.ui-app { --ui-rail-w: calc(var(--ui-nav-strip) + 2 * var(--space-4) + var(--ui-rail-rule)); }
|
|
316
|
+
/* The strip is the only layout this width has, so there is nothing to fold. */
|
|
317
|
+
.ui-app__rail .ui-app__fold-row { display: none; }
|
|
318
|
+
/* …and with it the band, when the band has nothing else in it: a head with no
|
|
319
|
+
wordmark is the toggle alone, so at this width it is padding and a hairline
|
|
320
|
+
drawn over nothing. The reader's fold cannot take this rule — there the toggle
|
|
321
|
+
IS drawn. why: docs/specification.md#the-page-shell */
|
|
322
|
+
.ui-app__head:not(:has(> .ui-app__brand)) { display: none; }
|
|
323
|
+
/* And where the band does hold the mark, the mark stays — at this width the press
|
|
324
|
+
that folded the rail on a desktop still reads out of the cookie, and the control
|
|
325
|
+
that would undo it is the one above. A head band faded to nothing here is an empty
|
|
326
|
+
41px box over a rule, with no way home. The reader's fold cannot take this rule —
|
|
327
|
+
there the toggle lands on the column the mark gives up.
|
|
328
|
+
why: docs/specification.md#the-page-shell */
|
|
329
|
+
.ui-app__brand { opacity: 1; visibility: visible; pointer-events: auto; }
|
|
180
330
|
.ui-app__rail .ui-nav__cap,
|
|
181
331
|
.ui-app__rail .ui-nav__label,
|
|
182
332
|
.ui-app__rail .ui-nav__badge,
|
|
183
333
|
.ui-app__rail .ui-nav__caret,
|
|
184
334
|
.ui-app__rail .ui-app__who,
|
|
185
|
-
.ui-
|
|
186
|
-
.ui-
|
|
187
|
-
.ui-app__rail .ui-
|
|
188
|
-
.ui-app__rail .ui-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
.ui-app__rail .ui-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
width: 5px; height: 5px;
|
|
205
|
-
border-radius: 50%;
|
|
206
|
-
background: currentColor;
|
|
207
|
-
opacity: 0.62;
|
|
335
|
+
.ui-app__rail .ui-dropdown__chevron,
|
|
336
|
+
.ui-app__brand span { opacity: 0; pointer-events: none; }
|
|
337
|
+
.ui-app__rail .ui-nav__sub { margin-inline-start: 0; padding-inline-start: 0; border-inline-start-width: 0; }
|
|
338
|
+
.ui-app__rail .ui-nav__item:not(:has(.ui-nav__ic))::after { opacity: 0.62; }
|
|
339
|
+
/* The one line of this block that is deliberately NOT the reader's fold: a finger
|
|
340
|
+
is the only pointer this width has, so a row and the account block are held to
|
|
341
|
+
44px. Neither floor can spread to the press, where growing the box is the one
|
|
342
|
+
thing the travel promises nothing does. why: docs/specification.md#the-page-shell */
|
|
343
|
+
.ui-app__rail .ui-nav__item,
|
|
344
|
+
.ui-app__rail .ui-app__user-trigger { min-height: 44px; }
|
|
345
|
+
.ui-app__rail .ui-nav__item:is(:hover, :focus-visible) > .ui-nav__label,
|
|
346
|
+
.ui-app__rail .ui-app__user-trigger:is(:hover, :focus-visible) > .ui-app__who { position: fixed; left: calc(var(--ui-rail-w) + var(--space-2)); z-index: var(--z-dropdown); width: max-content; max-width: 15rem; margin-block: -7px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius-xs); background: var(--surface-3); color: var(--strong); box-shadow: var(--shadow-md); opacity: 1; pointer-events: none; }
|
|
347
|
+
@supports (anchor-name: --a) and (anchor-scope: --a) {
|
|
348
|
+
.ui-app { anchor-scope: --ui-rail; }
|
|
349
|
+
.ui-app__rail { anchor-name: --ui-rail; }
|
|
350
|
+
.ui-app__rail .ui-nav__item,
|
|
351
|
+
.ui-app__rail .ui-app__user-trigger { anchor-name: --ui-rail-row; anchor-scope: --ui-rail-row; }
|
|
352
|
+
.ui-app__rail .ui-nav__item:is(:hover, :focus-visible) > .ui-nav__label,
|
|
353
|
+
.ui-app__rail .ui-app__user-trigger:is(:hover, :focus-visible) > .ui-app__who { position-anchor: --ui-rail-row; left: calc(anchor(--ui-rail right) + var(--space-2)); top: anchor(top); bottom: anchor(bottom); align-self: center; margin-block: 0; }
|
|
208
354
|
}
|
|
209
355
|
.ui-app__main { padding: 28px var(--space-4) 56px; }
|
|
210
356
|
.ui-app__main h1 { font-size: 25px; }
|
package/src/styles/nav.css
CHANGED
|
@@ -9,12 +9,32 @@
|
|
|
9
9
|
.ui-nav :where(ul, ol) { list-style: none; margin: 0; padding: 0; } /* floor, not ceiling */
|
|
10
10
|
|
|
11
11
|
/* -- Sidebar rail -------------------------------------------------------- */
|
|
12
|
+
/* The rail's two widths, and the one place they are written. --ui-nav-col is the
|
|
13
|
+
open column. --ui-nav-strip is twice a row's own glyph centre — the row's
|
|
14
|
+
padding plus half a glyph — so a strip that wide has the glyph standing in the
|
|
15
|
+
middle of it, exactly where it stood in the open rail. That is what lets the
|
|
16
|
+
fold animate: the column keeps --ui-nav-col and is clipped to the strip, so
|
|
17
|
+
the width travels and nothing inside it is laid out again. layout.css adds the
|
|
18
|
+
shell rail's own inset to both. stories/apps/shell-states.test.js derives the
|
|
19
|
+
strip from the resolved cascade and refuses a number that has drifted from it.
|
|
20
|
+
Declared for the shell as well as the nav, because the shell's rail is this
|
|
21
|
+
nav with an inset around it, and two copies of 41 is the drift itself. */
|
|
22
|
+
.ui-nav--side,
|
|
23
|
+
.ui-app { --ui-nav-col: 216px; --ui-nav-strip: 41px; }
|
|
12
24
|
.ui-nav--side {
|
|
13
25
|
display: flex;
|
|
14
26
|
flex-direction: column;
|
|
15
27
|
gap: 4px;
|
|
16
|
-
width:
|
|
28
|
+
width: var(--ui-nav-col);
|
|
29
|
+
/* The width is the only property the fold travels. Nothing is clipped here:
|
|
30
|
+
inside the shell it is the rail that clips, and a clip of its own would have
|
|
31
|
+
been a second scroll box cutting the rows off at the rail's height. */
|
|
32
|
+
transition: width var(--dur-med) var(--ease);
|
|
17
33
|
}
|
|
34
|
+
/* Every block keeps the open column while the nav clips to the strip. Without
|
|
35
|
+
this each one stretches to whatever the nav is wide and every row inside it is
|
|
36
|
+
laid out again on each frame of the fold. */
|
|
37
|
+
.ui-nav--side > * { width: var(--ui-nav-col); flex: none; }
|
|
18
38
|
.ui-nav__section + .ui-nav__section { margin-top: 14px; }
|
|
19
39
|
.ui-nav__list { display: flex; flex-direction: column; gap: 2px; }
|
|
20
40
|
.ui-nav__cap {
|
|
@@ -23,6 +43,9 @@
|
|
|
23
43
|
font-weight: var(--weight-medium);
|
|
24
44
|
color: var(--muted);
|
|
25
45
|
padding: 4px 12px 8px;
|
|
46
|
+
/* Goes on the labels' clock — a section heading is words, and the fold takes
|
|
47
|
+
every word on the rail together. */
|
|
48
|
+
transition: opacity var(--dur-fast) var(--ease);
|
|
26
49
|
}
|
|
27
50
|
|
|
28
51
|
/* Item row — link, group toggle, and leaf share the same skin */
|
|
@@ -72,7 +95,15 @@
|
|
|
72
95
|
.ui-nav__ic svg { width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 2.2; opacity: 0.72; }
|
|
73
96
|
.ui-nav__item.is-active .ui-nav__ic svg,
|
|
74
97
|
.ui-nav__item.is-current .ui-nav__ic svg { opacity: 1; stroke: var(--accent); } /* motion: still — the glyph brightens with its row, whose own highlight already transitions */
|
|
75
|
-
.ui-nav__label {
|
|
98
|
+
.ui-nav__label {
|
|
99
|
+
flex: 1 1 auto; min-width: 0;
|
|
100
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
101
|
+
/* The words go before the closing edge reaches them, which is most of why the
|
|
102
|
+
fold is worth animating — a label cut through by that edge reads as the rail
|
|
103
|
+
eating it. --dur-fast against the width's --dur-med, as the reference times
|
|
104
|
+
its own. */
|
|
105
|
+
transition: opacity var(--dur-fast) var(--ease);
|
|
106
|
+
}
|
|
76
107
|
|
|
77
108
|
/* Destructive row (sign out, revoke). Quiet at rest, --pink once a pointer or
|
|
78
109
|
the keyboard reaches it — as .ui-btn--danger and .ui-dropdown__item do. */
|
|
@@ -111,6 +142,10 @@
|
|
|
111
142
|
line-height: 1;
|
|
112
143
|
background: var(--surface-3);
|
|
113
144
|
color: var(--dim);
|
|
145
|
+
/* The count fades with the words. It is never lost: sidebarNav() spells it
|
|
146
|
+
into the row's aria-label, so "Pending 3" is what a reader hears at either
|
|
147
|
+
width. */
|
|
148
|
+
transition: opacity var(--dur-fast) var(--ease);
|
|
114
149
|
}
|
|
115
150
|
.ui-nav__badge.is-accent { background: var(--glow-purple); color: var(--accent); }
|
|
116
151
|
.ui-nav__badge.is-live { background: var(--chip-success-fill); color: var(--chip-success-ink); }
|
|
@@ -135,7 +170,7 @@
|
|
|
135
170
|
border-right: 1.6px solid var(--muted);
|
|
136
171
|
border-bottom: 1.6px solid var(--muted);
|
|
137
172
|
transform: rotate(-45deg);
|
|
138
|
-
transition: transform var(--dur-fast) var(--ease);
|
|
173
|
+
transition: transform var(--dur-fast) var(--ease), opacity var(--dur-fast) var(--ease);
|
|
139
174
|
}
|
|
140
175
|
.ui-nav__group.is-open > .ui-nav__toggle .ui-nav__caret { transform: rotate(45deg); }
|
|
141
176
|
.ui-nav__sub {
|
|
@@ -164,16 +199,59 @@
|
|
|
164
199
|
/* Footer slot (divider + trailing links, e.g. sign-out) */
|
|
165
200
|
.ui-nav__foot { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
|
|
166
201
|
|
|
167
|
-
/*
|
|
168
|
-
|
|
202
|
+
/* A row with no glyph, on a rail folded to its glyphs, would be a blank target.
|
|
203
|
+
It gets a dot on the glyph column instead — out of flow, so the open rail's
|
|
204
|
+
labels stay where they are, and resting at nothing, so what the fold does is
|
|
205
|
+
raise it rather than conjure it. 18px is the row's own 12px padding plus half
|
|
206
|
+
a 17px glyph less half the dot: the column the glyph beside it stands on.
|
|
207
|
+
sidebarNav() takes `icon` as optional at every level, sub rows included. */
|
|
208
|
+
.ui-nav--side .ui-nav__item:not(:has(.ui-nav__ic))::after {
|
|
209
|
+
content: "";
|
|
210
|
+
position: absolute;
|
|
211
|
+
left: 18px; top: 50%;
|
|
212
|
+
width: 5px; height: 5px;
|
|
213
|
+
margin-top: -2.5px;
|
|
214
|
+
border-radius: 50%;
|
|
215
|
+
background: currentColor;
|
|
216
|
+
opacity: 0;
|
|
217
|
+
transition: opacity var(--dur-fast) var(--ease);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* The nested list's indent, which the strip has no room for. It travels with the
|
|
221
|
+
width rather than vanishing under it: the guide hairline thins to nothing and
|
|
222
|
+
the children walk back onto the glyph column their parent stands on. */
|
|
223
|
+
.ui-nav__sub {
|
|
224
|
+
transition: margin-inline-start var(--dur-med) var(--ease),
|
|
225
|
+
padding-inline-start var(--dur-med) var(--ease),
|
|
226
|
+
border-inline-start-width var(--dur-med) var(--ease);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* Collapsed (icon-only) rail — the same fold the shell's rail makes, drawn once
|
|
230
|
+
rather than pressed: the column keeps its width and the nav clips to the
|
|
231
|
+
strip, so no row is laid out again and no glyph moves. A group's children stay
|
|
232
|
+
rows here, because wireNav() toggles the list's `hidden` attribute and nothing
|
|
233
|
+
else, so folding .ui-nav__sub away left the toggle announcing a list it could
|
|
234
|
+
not open. What goes is the indent and the words, and sidebarNav({ collapsed })
|
|
235
|
+
hands each row its name as a `title` so a pointer can still read it. The
|
|
236
|
+
shell's rail draws a chip of its own instead, on keyboard focus as well — see
|
|
237
|
+
layout.css. */
|
|
238
|
+
.ui-nav--side.is-collapsed {
|
|
239
|
+
width: var(--ui-nav-strip);
|
|
240
|
+
/* clip, not hidden: a standalone rail has nothing around it to clip for it,
|
|
241
|
+
and `hidden` would make this a scroll box in both axes and cut the rows off
|
|
242
|
+
at whatever height it was given. `clip` across leaves `visible` down alone,
|
|
243
|
+
which is the one pair that does. */
|
|
244
|
+
overflow-x: clip;
|
|
245
|
+
overflow-y: visible;
|
|
246
|
+
}
|
|
169
247
|
.ui-nav--side.is-collapsed .ui-nav__cap,
|
|
170
248
|
.ui-nav--side.is-collapsed .ui-nav__label,
|
|
171
249
|
.ui-nav--side.is-collapsed .ui-nav__badge,
|
|
172
|
-
.ui-nav--side.is-collapsed .ui-nav__caret {
|
|
173
|
-
.ui-nav--side.is-collapsed .ui-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
.ui-nav--side.is-collapsed .ui-
|
|
250
|
+
.ui-nav--side.is-collapsed .ui-nav__caret { opacity: 0; pointer-events: none; }
|
|
251
|
+
.ui-nav--side.is-collapsed .ui-nav__sub {
|
|
252
|
+
margin-inline-start: 0; padding-inline-start: 0; border-inline-start-width: 0;
|
|
253
|
+
}
|
|
254
|
+
.ui-nav--side.is-collapsed .ui-nav__item:not(:has(.ui-nav__ic))::after { opacity: 0.62; }
|
|
177
255
|
|
|
178
256
|
/* -- Horizontal tabs ----------------------------------------------------- */
|
|
179
257
|
.ui-nav--tabs {
|
package/src/styles/topbar.css
CHANGED
|
@@ -128,6 +128,10 @@
|
|
|
128
128
|
padding: 6px;
|
|
129
129
|
opacity: 0;
|
|
130
130
|
visibility: hidden;
|
|
131
|
+
/* Drawn for the whole of the fade out, so the rows do not vanish mid-fade — and a box
|
|
132
|
+
that is drawn is a box that is hit. The open rule takes this back.
|
|
133
|
+
why: docs/specification.md#the-dropdown-panel */
|
|
134
|
+
pointer-events: none;
|
|
131
135
|
transform: translateY(-6px);
|
|
132
136
|
/* Named properties, not the bare shorthand: `transition: 0.18s` is `all`, and `all`
|
|
133
137
|
includes visibility — held at the OLD value for the whole duration, so the menu is
|
|
@@ -138,7 +142,20 @@
|
|
|
138
142
|
visibility var(--dur-med) linear;
|
|
139
143
|
z-index: var(--z-dropdown);
|
|
140
144
|
}
|
|
141
|
-
|
|
145
|
+
/* Open, the menu is clickable and visible AT ONCE. Both menus in this file are
|
|
146
|
+
`wireDropdown()` in bespoke clothes — same hooks, same keyboard, same fade — so they
|
|
147
|
+
owe a panel's two rules as much as `.ui-dropdown__panel` does: `visibility` off the
|
|
148
|
+
open transition, because the frame the open class lands is the frame the wiring moves
|
|
149
|
+
focus onto a row in and a browser will not focus inside a hidden box; and
|
|
150
|
+
`pointer-events` back on, because the closed rule turned it off.
|
|
151
|
+
why: docs/specification.md#the-dropdown-panel */
|
|
152
|
+
.vsw.open .vsw__menu {
|
|
153
|
+
opacity: 1;
|
|
154
|
+
visibility: visible;
|
|
155
|
+
pointer-events: auto;
|
|
156
|
+
transform: translateY(0);
|
|
157
|
+
transition-property: opacity, transform;
|
|
158
|
+
}
|
|
142
159
|
.vopt {
|
|
143
160
|
display: flex;
|
|
144
161
|
gap: 11px;
|
|
@@ -210,6 +227,7 @@
|
|
|
210
227
|
overflow: hidden;
|
|
211
228
|
opacity: 0;
|
|
212
229
|
visibility: hidden;
|
|
230
|
+
pointer-events: none; /* see .vsw__menu above */
|
|
213
231
|
transform: translateY(-6px);
|
|
214
232
|
/* Named properties — see .vsw__menu above. */
|
|
215
233
|
transition:
|
|
@@ -218,7 +236,15 @@
|
|
|
218
236
|
visibility var(--dur-med) linear;
|
|
219
237
|
z-index: var(--z-dropdown);
|
|
220
238
|
}
|
|
221
|
-
.
|
|
239
|
+
/* Clickable and visible at once — see .vsw.open .vsw__menu above. The row this one
|
|
240
|
+
costs is `.aout`: a stray click in the fade out ends the reader's session. */
|
|
241
|
+
.acct.open .amenu {
|
|
242
|
+
opacity: 1;
|
|
243
|
+
visibility: visible;
|
|
244
|
+
pointer-events: auto;
|
|
245
|
+
transform: translateY(0);
|
|
246
|
+
transition-property: opacity, transform;
|
|
247
|
+
}
|
|
222
248
|
.amenu .ahead { padding: 13px 15px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 11px; }
|
|
223
249
|
.amenu .ahead .avatar { cursor: default; }
|
|
224
250
|
.amenu .ahead .aw { min-width: 0; display: flex; flex-direction: column; gap: 1px; line-height: 1.35; }
|