@apliteni/apliteni-ui 0.27.0 → 0.30.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/package.json +1 -1
- package/react/README.md +49 -1
- package/react/dist/index.css +11 -1
- package/react/dist/index.d.ts +61 -2
- package/react/dist/index.js +514 -121
- package/src/components/back.js +57 -0
- package/src/components/command-palette.js +597 -0
- package/src/components/confirm.js +3 -2
- package/src/components/drawer.js +31 -2
- package/src/components/dropdown.js +192 -17
- package/src/components/feedback.js +2 -2
- package/src/components/index.js +5 -2
- package/src/components/loading.js +3 -2
- package/src/components/nav.js +15 -7
- package/src/components/overlay.js +67 -14
- package/src/components/shell.js +11 -2
- package/src/components/tabs.js +7 -1
- package/src/components/tooltip.js +237 -0
- package/src/components/topbar.js +9 -4
- package/src/index.css +3 -0
- package/src/index.js +3 -0
- package/src/inline.js +6 -0
- package/src/motion.js +43 -2
- package/src/styles/back.css +42 -0
- package/src/styles/badge.css +5 -7
- package/src/styles/base.css +8 -7
- package/src/styles/card.css +12 -8
- package/src/styles/code.css +3 -4
- package/src/styles/command-palette.css +321 -0
- package/src/styles/confirm.css +11 -11
- package/src/styles/drawer.css +57 -15
- package/src/styles/dropdown.css +70 -11
- package/src/styles/feedback.css +2 -0
- package/src/styles/footer.css +3 -4
- package/src/styles/input.css +1 -1
- package/src/styles/layout.css +3 -2
- package/src/styles/loading.css +5 -0
- package/src/styles/nav.css +12 -8
- package/src/styles/success.css +3 -2
- package/src/styles/table.css +4 -5
- package/src/styles/tabs.css +3 -0
- package/src/styles/tooltip.css +65 -0
- package/src/styles/topbar.css +3 -4
- package/src/tokens/tokens.css +8 -8
|
@@ -4,10 +4,15 @@
|
|
|
4
4
|
// properties of the *page*, so they are answered here from one stack per document rather
|
|
5
5
|
// than from either component's own storage. Internal — not re-exported from src/index.js.
|
|
6
6
|
|
|
7
|
-
// What each overlay paints on today: a drawer at `--z-overlay` (styles/drawer.css)
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
7
|
+
// What each overlay paints on today: a drawer at `--z-overlay` (styles/drawer.css), a
|
|
8
|
+
// command palette one above it (styles/command-palette.css) and a confirm above both
|
|
9
|
+
// (styles/confirm.css). Three steps and not two, because at equal levels paint order falls
|
|
10
|
+
// back to document order and the overlay that owns the keyboard is then not reliably the
|
|
11
|
+
// one the reader can see. A palette is summoned deliberately and must be seen, so it goes
|
|
12
|
+
// over a drawer that was already open; a confirm is a question about whatever is under it,
|
|
13
|
+
// so it goes over both. Absolute values, not ranks, so a sheet that moves and a table that
|
|
14
|
+
// did not is a failed test — stories/overlay-css.test.js holds all three.
|
|
15
|
+
export const OVERLAY_LAYER = { drawer: 100, palette: 101, confirm: 102 };
|
|
11
16
|
|
|
12
17
|
const FOCUSABLE = [
|
|
13
18
|
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
|
|
@@ -32,7 +37,8 @@ function pageOf(doc) {
|
|
|
32
37
|
function reachable(el) {
|
|
33
38
|
for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
|
|
34
39
|
if (n.inert || n.hasAttribute('inert') || n.hasAttribute('hidden')) return false;
|
|
35
|
-
const overlayRoot = n.hasAttribute('data-drawer') || n.hasAttribute('data-confirm')
|
|
40
|
+
const overlayRoot = n.hasAttribute('data-drawer') || n.hasAttribute('data-confirm')
|
|
41
|
+
|| n.hasAttribute('data-cmdk');
|
|
36
42
|
if (overlayRoot && !n.classList.contains('is-open')) return false;
|
|
37
43
|
}
|
|
38
44
|
// Browsers can answer the rest properly; JSDOM has no layout and no such method.
|
|
@@ -108,7 +114,7 @@ const PRECEDING = 2;
|
|
|
108
114
|
|
|
109
115
|
// One way onto the stack. `where` picks the slot; everything after it — the
|
|
110
116
|
// duplicate guard, the key owner, the recompute — is the same either way. Every
|
|
111
|
-
// entry carries its layer, so the comparisons in
|
|
117
|
+
// entry carries its layer, so the comparisons in the slot pickers never meet undefined.
|
|
112
118
|
function place(root, panel, dismiss, layer, where) {
|
|
113
119
|
const doc = root.ownerDocument;
|
|
114
120
|
const page = pageOf(doc);
|
|
@@ -130,14 +136,23 @@ function paintedLayer(root, layer) {
|
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
/**
|
|
133
|
-
* Put an overlay on
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
139
|
+
* Put an overlay on the page. `dismiss` is what Escape calls — pass null for one
|
|
140
|
+
* that refuses to be dismissed, and Escape then does nothing rather than falling
|
|
141
|
+
* through to the overlay underneath.
|
|
142
|
+
*
|
|
143
|
+
* It goes on top of everything it paints over, and under anything painted above
|
|
144
|
+
* it. Opening is history the stack can order by, but only within a layer: an
|
|
145
|
+
* overlay opened under one already on screen — a drawer opened from a palette
|
|
146
|
+
* row, a palette opened while a confirm is up — is not the one the reader is
|
|
147
|
+
* looking at, and giving it Escape and the Tab trap would put the keyboard on a
|
|
148
|
+
* surface that is covered.
|
|
138
149
|
*/
|
|
139
150
|
export function pushOverlay(root, panel, dismiss, layer) {
|
|
140
|
-
|
|
151
|
+
const level = paintedLayer(root, layer);
|
|
152
|
+
place(root, panel, dismiss, level, (page) => {
|
|
153
|
+
const at = page.stack.findIndex((e) => e.layer > level);
|
|
154
|
+
return at === -1 ? page.stack.length : at;
|
|
155
|
+
});
|
|
141
156
|
}
|
|
142
157
|
|
|
143
158
|
/**
|
|
@@ -157,13 +172,45 @@ export function adoptOverlay(root, panel, dismiss, layer) {
|
|
|
157
172
|
});
|
|
158
173
|
}
|
|
159
174
|
|
|
160
|
-
|
|
161
|
-
|
|
175
|
+
// Where an overlay puts focus when it opens: the first stop inside its panel,
|
|
176
|
+
// else the panel itself. One rule serves all three, because the kit's own markup
|
|
177
|
+
// puts each one's opening target first — a drawer's first control, the palette's
|
|
178
|
+
// text box, and the confirm's safe answer, which its panel renders before the
|
|
179
|
+
// destructive one.
|
|
180
|
+
function initialFocus(panel) {
|
|
181
|
+
return (panel && focusablesIn(panel)[0]) || panel;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// What `el` can actually be handed focus, or null when nothing can take it. An
|
|
185
|
+
// overlay stays open while the page carries on, so by the time it closes the
|
|
186
|
+
// element it came from may be detached — look for whatever inherited its identity
|
|
187
|
+
// in the re-render — or sitting in a subtree that a lower overlay has just made
|
|
188
|
+
// inert again, where focus() is a silent no-op. <body> is null too: with no
|
|
189
|
+
// tabindex it cannot be focused either, and `activeElement === body` is what
|
|
190
|
+
// having no focus looks like, which is what an overlay summoned by its hotkey out
|
|
191
|
+
// of a page nobody had touched yet records as the place it came from.
|
|
192
|
+
function focusTarget(el, doc) {
|
|
193
|
+
const live = el && !el.isConnected && el.id ? doc?.getElementById(el.id) : el;
|
|
194
|
+
if (!live || !live.isConnected || live === live.ownerDocument.body) return null;
|
|
195
|
+
return reachable(live) && typeof live.focus === 'function' ? live : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Take an overlay off the page, wherever in the stack it sits. `opener` is what
|
|
200
|
+
* the caller is about to hand focus back to, which this has to see: one overlay
|
|
201
|
+
* can close over another that is still open, and the opener is then out on a page
|
|
202
|
+
* that lower overlay is holding inert. Nobody would place focus at all, and the
|
|
203
|
+
* reader would be left on <body> looking at a panel that holds neither the
|
|
204
|
+
* keyboard nor the Tab trap — so open the panel now on top where it opens.
|
|
205
|
+
*/
|
|
206
|
+
export function popOverlay(root, opener) {
|
|
162
207
|
const doc = root.ownerDocument;
|
|
163
208
|
const page = pageOf(doc);
|
|
164
209
|
const at = page.stack.findIndex((e) => e.root === root);
|
|
165
210
|
if (at !== -1) page.stack.splice(at, 1);
|
|
166
211
|
sync(doc);
|
|
212
|
+
const top = page.stack[page.stack.length - 1];
|
|
213
|
+
if (top && opener && !focusTarget(opener, doc)) initialFocus(top.panel)?.focus();
|
|
167
214
|
}
|
|
168
215
|
|
|
169
216
|
/**
|
|
@@ -180,8 +227,14 @@ export function syncOverlays(doc = document) {
|
|
|
180
227
|
* the element it came from may be detached by now — and focus() on a detached
|
|
181
228
|
* node is a silent no-op that leaves the reader with no place on the page. Look
|
|
182
229
|
* for whatever inherited its identity in the re-render, then give up to the page.
|
|
230
|
+
*
|
|
231
|
+
* With another overlay still open, the only place worth having focus is inside
|
|
232
|
+
* it: an opener that overlay is holding inert cannot take focus at all, popOverlay
|
|
233
|
+
* has already opened the panel now on top for exactly that case, and <body> behind
|
|
234
|
+
* a live modal is not somewhere to give up to.
|
|
183
235
|
*/
|
|
184
236
|
export function returnFocus(el, doc) {
|
|
237
|
+
if (doc && pageOf(doc).stack.length) { focusTarget(el, doc)?.focus(); return; }
|
|
185
238
|
const live = el && !el.isConnected && el.id ? doc?.getElementById(el.id) : el;
|
|
186
239
|
if (live && live.isConnected && typeof live.focus === 'function') { live.focus(); return; }
|
|
187
240
|
doc?.body?.focus?.();
|
package/src/components/shell.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { topbar as productTopbar } from './topbar.js';
|
|
8
8
|
import { esc, icon } from './index.js';
|
|
9
9
|
import { sidebarNav, breadcrumbs } from './nav.js';
|
|
10
|
+
import { backLink } from './back.js';
|
|
10
11
|
import { prism } from '../assets/brand.js';
|
|
11
12
|
import { ACCOUNT_NAV, toMenuTuple, initials } from './account-nav.js';
|
|
12
13
|
|
|
@@ -41,6 +42,11 @@ const toItems = (nav) => (Array.isArray(nav) ? nav : ACCOUNT_NAV)
|
|
|
41
42
|
const toCrumbs = (crumbs) => (Array.isArray(crumbs) ? crumbs : [])
|
|
42
43
|
.filter((c) => isRecord(c) && !Array.isArray(c) && str(c.label) !== '');
|
|
43
44
|
|
|
45
|
+
// A back link replaces the trail rather than joining it: the two would name the same parent
|
|
46
|
+
// twice. Anything but a record is no back link; its fields go through as given, so a back
|
|
47
|
+
// backLink() refuses leaves the trail standing. why: docs/specification.md#the-back-link
|
|
48
|
+
const toBack = (b) => (isRecord(b) && !Array.isArray(b) ? { href: b.href, label: b.label } : null);
|
|
49
|
+
|
|
44
50
|
// The reader, as two strings. railUser() and initials() both read them, and an
|
|
45
51
|
// /auth/me answering `account: null` or a numeric display name reached both.
|
|
46
52
|
const toReader = (a) => (isRecord(a) ? { name: str(a.name), email: str(a.email) } : { name: '', email: '' });
|
|
@@ -88,7 +94,7 @@ const mainMax = (v) => {
|
|
|
88
94
|
// The one pass. Each key names the function that settles it; nothing else in
|
|
89
95
|
// this file re-checks a value that has been through here.
|
|
90
96
|
const SHAPES = {
|
|
91
|
-
nav: toItems, crumbs: toCrumbs, account: toReader, maxWidth: mainMax, topbar: toTopbar,
|
|
97
|
+
nav: toItems, crumbs: toCrumbs, back: toBack, account: toReader, maxWidth: mainMax, topbar: toTopbar,
|
|
92
98
|
};
|
|
93
99
|
|
|
94
100
|
// The text options settle by the same argument. `body: null` from a record with no
|
|
@@ -141,6 +147,7 @@ export function appShell(options = {}) {
|
|
|
141
147
|
active,
|
|
142
148
|
navLabel = 'Account',
|
|
143
149
|
crumbs,
|
|
150
|
+
back,
|
|
144
151
|
title = '',
|
|
145
152
|
sub = '',
|
|
146
153
|
body = '',
|
|
@@ -149,9 +156,11 @@ export function appShell(options = {}) {
|
|
|
149
156
|
topbar,
|
|
150
157
|
maxWidth,
|
|
151
158
|
} = settle(options);
|
|
159
|
+
const up = back ? backLink(back) : '';
|
|
152
160
|
const rail = sidebarNav({
|
|
153
161
|
sections: [{ label: navLabel, items: nav }],
|
|
154
162
|
active,
|
|
163
|
+
activeIs: up ? 'section' : 'page',
|
|
155
164
|
ariaLabel: navLabel,
|
|
156
165
|
footer: signOutHref ? signOut(signOutHref) : '',
|
|
157
166
|
});
|
|
@@ -170,7 +179,7 @@ export function appShell(options = {}) {
|
|
|
170
179
|
${railUser(account)}
|
|
171
180
|
</div>
|
|
172
181
|
<main class="ui-app__main"${maxWidth ? ` style="--ui-app-main: ${maxWidth}"` : ''}>
|
|
173
|
-
${crumbs.length ? breadcrumbs({ items: crumbs }) : ''}
|
|
182
|
+
${up || (crumbs.length ? breadcrumbs({ items: crumbs }) : '')}
|
|
174
183
|
${title ? `<h1>${title}</h1>` : ''}
|
|
175
184
|
${sub ? `<p class="ui-app__sub">${sub}</p>` : ''}
|
|
176
185
|
<div class="ui-app__body">${body}</div>
|
package/src/components/tabs.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// Follows the WAI-ARIA tabs pattern: roving tabindex, ArrowLeft/Right + Home/End,
|
|
15
15
|
// aria-selected, and aria-controls / aria-labelledby wiring. `name` must be unique
|
|
16
16
|
// per tabs instance on a page (it seeds the tab/panel ids).
|
|
17
|
+
import { playEntrance } from '../motion.js';
|
|
17
18
|
|
|
18
19
|
export function tabs({ items = [], active = 0, name = 'tabs', ariaLabel = 'Tabs', className = '' } = {}) {
|
|
19
20
|
const cls = ['ui-tabs', className].filter(Boolean).join(' ');
|
|
@@ -57,7 +58,12 @@ export function initTabs(root) {
|
|
|
57
58
|
t.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
58
59
|
t.tabIndex = on ? 0 : -1;
|
|
59
60
|
const p = panelFor(t);
|
|
60
|
-
if (p)
|
|
61
|
+
if (p) {
|
|
62
|
+
// Only the panel a switch reveals fades in; the one the page loaded with does not.
|
|
63
|
+
const appearing = on && p.hidden;
|
|
64
|
+
p.hidden = !on;
|
|
65
|
+
if (appearing) playEntrance(p);
|
|
66
|
+
}
|
|
61
67
|
});
|
|
62
68
|
if (focus && tabEls[i]) tabEls[i].focus();
|
|
63
69
|
};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Tooltip — the readout that shows a value while a pointer rests on the mark
|
|
2
|
+
// holding it. An overlay in every state: one element, rendered once inside its
|
|
3
|
+
// host and absolutely placed there, so showing it moves nothing on the page.
|
|
4
|
+
//
|
|
5
|
+
// <div class="ui-tip-host" data-tip-host>
|
|
6
|
+
// <svg>… <rect data-tip-label="Mar 2026" data-tip-value="€48,210" …/> …</svg>
|
|
7
|
+
// ${tooltip()}
|
|
8
|
+
// </div>
|
|
9
|
+
// wireTooltip(container);
|
|
10
|
+
//
|
|
11
|
+
// A mark carries `data-tip-value`, with `data-tip-label` and `data-tip-detail`;
|
|
12
|
+
// a `[data-tip-anchor]` inside it is where the readout opens.
|
|
13
|
+
// why: docs/specification.md#the-hover-readout
|
|
14
|
+
import { esc } from './index.js';
|
|
15
|
+
|
|
16
|
+
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
17
|
+
const PARTS = ['label', 'value', 'detail'];
|
|
18
|
+
const px = (n) => (typeof n === 'number' && Number.isFinite(n) ? `${n}px` : null);
|
|
19
|
+
let seq = 0;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The readout. Render it once inside a `[data-tip-host]`; wireTooltip() fills it.
|
|
23
|
+
*
|
|
24
|
+
* @param {object} [o]
|
|
25
|
+
* @param {string} [o.label] which point — a date, a category, a series
|
|
26
|
+
* @param {string} [o.value] the number, already formatted
|
|
27
|
+
* @param {string} [o.detail] at most one comparison, such as "+4.2% on February"
|
|
28
|
+
* @param {string} [o.placement] 'top' (default) | 'bottom' — the side it prefers; the wiring flips it when clipped
|
|
29
|
+
* @param {boolean} [o.open] render it shown, as a picture; its host leaves off `data-tip-host` so no wiring takes it down
|
|
30
|
+
* @param {number} [o.x] with `open`: the mark's centre, px from the host's left
|
|
31
|
+
* @param {number} [o.y] with `open`: the mark's top edge (its bottom for 'bottom'), px from the host's top
|
|
32
|
+
* @param {string} [o.id]
|
|
33
|
+
* @returns {string} html
|
|
34
|
+
*/
|
|
35
|
+
export function tooltip({
|
|
36
|
+
label = '', value = '', detail = '', placement = 'top', open = false, x, y, id,
|
|
37
|
+
} = {}) {
|
|
38
|
+
const text = { label, value, detail };
|
|
39
|
+
const named = value != null && String(value) !== '';
|
|
40
|
+
const pos =[['--ui-tip-x', px(x)], ['--ui-tip-y', px(y)]]
|
|
41
|
+
.filter(([, v]) => v).map(([k, v]) => `${k}:${v}`).join(';');
|
|
42
|
+
// A tooltip with nothing in it has no name, so an empty readout — the one a
|
|
43
|
+
// chart renders before any mark is hovered — takes the role with its first value.
|
|
44
|
+
const attrs = [
|
|
45
|
+
`class="${cx('ui-tip', placement === 'bottom' && 'is-below', open && 'is-open')}"`,
|
|
46
|
+
named ? 'role="tooltip"' : '',
|
|
47
|
+
'data-tip',
|
|
48
|
+
id ? `id="${esc(id)}"` : '',
|
|
49
|
+
pos ? `style="${pos}"` : '',
|
|
50
|
+
].filter(Boolean).join(' ');
|
|
51
|
+
const spans = PARTS.map((k) => {
|
|
52
|
+
const t = text[k] == null ? '' : String(text[k]);
|
|
53
|
+
return `<span class="ui-tip__${k}"${t ? '' : ' hidden'}>${esc(t)}</span>`;
|
|
54
|
+
}).join('');
|
|
55
|
+
return `<div ${attrs}>${spans}</div>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---- Placement -----------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
// The mark-to-readout distance is --ui-tip-gap in src/styles/tooltip.css. This is
|
|
61
|
+
// the fallback for a document that has not loaded the sheet;
|
|
62
|
+
// src/components/tooltip.test.js pins the two to each other.
|
|
63
|
+
const TIP_GAP = 8;
|
|
64
|
+
|
|
65
|
+
function tipGap(tip) {
|
|
66
|
+
const declared = parseFloat(getComputedStyle(tip).getPropertyValue('--ui-tip-gap'));
|
|
67
|
+
return Number.isFinite(declared) ? declared : TIP_GAP;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Hosts nest. A mark or a readout belongs to the nearest [data-tip-host] above it,
|
|
71
|
+
// so one mark opens one readout, and an outer host is never handed an inner one's.
|
|
72
|
+
function holds(host, el) {
|
|
73
|
+
const nearest = el.parentElement?.closest('[data-tip-host]') ?? null;
|
|
74
|
+
return host.contains(el) && (nearest === host || !host.contains(nearest));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tipOf = (host) => [...host.querySelectorAll('[data-tip]')].find((tip) => holds(host, tip)) ?? null;
|
|
78
|
+
const clips = (cs) => [cs.overflow, cs.overflowX, cs.overflowY].some((v) => v && v !== 'visible');
|
|
79
|
+
|
|
80
|
+
// What the readout has to stay inside: the viewport less its scrollbars, cut
|
|
81
|
+
// down by the host and every ancestor whose overflow clips. <body> is left out —
|
|
82
|
+
// its overflow is the viewport's, and its box can be shorter than the page.
|
|
83
|
+
function clipBox(host) {
|
|
84
|
+
const view = host.ownerDocument.documentElement;
|
|
85
|
+
const box = { top: 0, left: 0, right: view.clientWidth, bottom: view.clientHeight };
|
|
86
|
+
for (let el = host; el && el !== document.body && el !== document.documentElement; el = el.parentElement) {
|
|
87
|
+
if (!clips(getComputedStyle(el))) continue;
|
|
88
|
+
const r = el.getBoundingClientRect();
|
|
89
|
+
box.top = Math.max(box.top, r.top);
|
|
90
|
+
box.left = Math.max(box.left, r.left);
|
|
91
|
+
box.right = Math.min(box.right, r.right);
|
|
92
|
+
box.bottom = Math.min(box.bottom, r.bottom);
|
|
93
|
+
}
|
|
94
|
+
return box;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Flip only when the preferred side is too tight AND the other side is roomier,
|
|
98
|
+
// so a readout that fits on neither side opens on the roomier one. Then slide
|
|
99
|
+
// it along the mark's edge, no further than it takes to stay inside the box.
|
|
100
|
+
function place(host, tip, mark) {
|
|
101
|
+
const m = (mark.querySelector('[data-tip-anchor]') || mark).getBoundingClientRect();
|
|
102
|
+
const h = host.getBoundingClientRect();
|
|
103
|
+
const clip = clipBox(host);
|
|
104
|
+
const need = tip.offsetHeight + tipGap(tip);
|
|
105
|
+
const above = m.top - clip.top;
|
|
106
|
+
const below = clip.bottom - m.bottom;
|
|
107
|
+
const prefersBelow = tip.__tipPrefersBelow;
|
|
108
|
+
const flip = prefersBelow ? below < need && above > below : above < need && below > above;
|
|
109
|
+
const isBelow = prefersBelow !== flip;
|
|
110
|
+
tip.classList.toggle('is-below', isBelow);
|
|
111
|
+
|
|
112
|
+
const centre = m.left + m.width / 2;
|
|
113
|
+
const ideal = centre - tip.offsetWidth / 2;
|
|
114
|
+
const left = Math.max(clip.left, Math.min(ideal, clip.right - tip.offsetWidth));
|
|
115
|
+
// An absolute box is measured from the host's padding edge and rides its scroll.
|
|
116
|
+
const s = tip.style;
|
|
117
|
+
s.setProperty('--ui-tip-x', `${centre - h.left - host.clientLeft + host.scrollLeft}px`);
|
|
118
|
+
s.setProperty('--ui-tip-y', `${(isBelow ? m.bottom : m.top) - h.top - host.clientTop + host.scrollTop}px`);
|
|
119
|
+
s.setProperty('--ui-tip-shift', `${left - ideal}px`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function fill(tip, mark) {
|
|
123
|
+
tip.setAttribute('role', 'tooltip');
|
|
124
|
+
for (const k of PARTS) {
|
|
125
|
+
const el = tip.querySelector(`.ui-tip__${k}`);
|
|
126
|
+
if (!el) continue;
|
|
127
|
+
const t = mark.getAttribute(`data-tip-${k}`) || '';
|
|
128
|
+
el.textContent = t;
|
|
129
|
+
el.hidden = !t;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- Behaviour -----------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Show the host's readout for one mark, until hideTooltip() or Escape. For a chart that does its own
|
|
137
|
+
* hit-testing; its host needs `.ui-tip-host`. After Escape it shows nothing for the dismissed mark
|
|
138
|
+
* until another mark shows or hideTooltip() is called, so a chart may call it on every pointer sample.
|
|
139
|
+
*/
|
|
140
|
+
export function showTooltip(host, mark) {
|
|
141
|
+
const tip = tipOf(host);
|
|
142
|
+
if (!tip || !mark || mark === host.__tipDismissed) return;
|
|
143
|
+
if (tip.__tipPrefersBelow == null) tip.__tipPrefersBelow = tip.classList.contains('is-below');
|
|
144
|
+
if (!tip.id) tip.id = `ui-tip-${++seq}`;
|
|
145
|
+
releaseMark(host);
|
|
146
|
+
fill(tip, mark);
|
|
147
|
+
place(host, tip, mark);
|
|
148
|
+
// Described only while shown, and only a mark that carried no description of its own.
|
|
149
|
+
if (!mark.hasAttribute('aria-describedby')) {
|
|
150
|
+
mark.setAttribute('aria-describedby', tip.id);
|
|
151
|
+
mark.__tipDescribed = true;
|
|
152
|
+
}
|
|
153
|
+
host.__tipMark = mark;
|
|
154
|
+
host.__tipDismissed = null;
|
|
155
|
+
tip.__tipHost = host;
|
|
156
|
+
tip.classList.add('is-open');
|
|
157
|
+
wireEscape(host.ownerDocument);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function releaseMark(host) {
|
|
161
|
+
const mark = host.__tipMark;
|
|
162
|
+
if (mark?.__tipDescribed) { mark.removeAttribute('aria-describedby'); mark.__tipDescribed = false; }
|
|
163
|
+
host.__tipMark = null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function close(host) {
|
|
167
|
+
releaseMark(host);
|
|
168
|
+
tipOf(host)?.classList.remove('is-open');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Hide the host's readout: the pointer has left, so the mark Escape dismissed may show again. */
|
|
172
|
+
export function hideTooltip(host) {
|
|
173
|
+
close(host);
|
|
174
|
+
host.__tipDismissed = null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Escape dismisses every readout the kit is showing without the pointer having
|
|
178
|
+
// to move — the reader it covers something for. One rendered open and never
|
|
179
|
+
// shown is a picture, and stays. The dismissed mark is remembered until another
|
|
180
|
+
// mark shows or the pointer leaves, so crossing the gap between marks and coming
|
|
181
|
+
// back does not return the readout to the mark it was dismissed from.
|
|
182
|
+
function wireEscape(doc) {
|
|
183
|
+
if (doc.__tipEscapeWired) return;
|
|
184
|
+
doc.__tipEscapeWired = true;
|
|
185
|
+
doc.addEventListener('keydown', (e) => {
|
|
186
|
+
if (e.key !== 'Escape') return;
|
|
187
|
+
doc.querySelectorAll('[data-tip].is-open').forEach((tip) => {
|
|
188
|
+
const host = tip.__tipHost;
|
|
189
|
+
if (!host) return;
|
|
190
|
+
const mark = host.__tipMark;
|
|
191
|
+
close(host);
|
|
192
|
+
host.__tipDismissed = mark;
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The readout is placed in px from its host, so the host has to be the box it is
|
|
198
|
+
// positioned against. .ui-tip-host makes it one; a host without it is given the same.
|
|
199
|
+
// A host outside the document has no computed style to read, so it waits until it is in one.
|
|
200
|
+
function anchorHost(host) {
|
|
201
|
+
if (host.__tipAnchored || !host.isConnected) return;
|
|
202
|
+
host.__tipAnchored = true;
|
|
203
|
+
if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Wire every `[data-tip-host]` under root: a pointer resting on a mark, or focus
|
|
208
|
+
* landing on one, shows the readout; leaving the marks hides it. A host with no
|
|
209
|
+
* readout of its own is given one here, once, never on hover. Safe to call again.
|
|
210
|
+
*/
|
|
211
|
+
export function wireTooltip(root = document) {
|
|
212
|
+
const hosts = [...root.querySelectorAll('[data-tip-host]')];
|
|
213
|
+
if (root.matches?.('[data-tip-host]')) hosts.unshift(root);
|
|
214
|
+
hosts.forEach((host) => {
|
|
215
|
+
if (host.__tipWired) return;
|
|
216
|
+
host.__tipWired = true;
|
|
217
|
+
anchorHost(host);
|
|
218
|
+
if (!tipOf(host)) host.insertAdjacentHTML('beforeend', tooltip());
|
|
219
|
+
const markOf = (t) => {
|
|
220
|
+
const mark = t?.closest?.('[data-tip-value]');
|
|
221
|
+
return mark && holds(host, mark) ? mark : null;
|
|
222
|
+
};
|
|
223
|
+
host.addEventListener('pointerover', (e) => {
|
|
224
|
+
anchorHost(host);
|
|
225
|
+
const mark = markOf(e.target);
|
|
226
|
+
if (!mark) close(host);
|
|
227
|
+
else if (mark !== host.__tipMark) showTooltip(host, mark);
|
|
228
|
+
});
|
|
229
|
+
host.addEventListener('pointerleave', () => hideTooltip(host));
|
|
230
|
+
host.addEventListener('focusin', (e) => {
|
|
231
|
+
anchorHost(host);
|
|
232
|
+
const mark = markOf(e.target);
|
|
233
|
+
if (mark) showTooltip(host, mark);
|
|
234
|
+
});
|
|
235
|
+
host.addEventListener('focusout', (e) => { if (!markOf(e.relatedTarget)) hideTooltip(host); });
|
|
236
|
+
});
|
|
237
|
+
}
|
package/src/components/topbar.js
CHANGED
|
@@ -41,12 +41,17 @@ export function deckTextSwitch(active = 'deck') {
|
|
|
41
41
|
// Thin consumer of the shared dropdown wiring (wireDropdown): keeps its own
|
|
42
42
|
// bespoke .vsw/.vopt classes for a pixel-identical look, but emits the generic
|
|
43
43
|
// [data-dropdown] hooks so there's ONE open/close/keyboard implementation.
|
|
44
|
+
// `badge` is a tone key and the kit writes the word for it; the stylesheet used
|
|
45
|
+
// to uppercase the key itself. why: docs/specification.md#labels-and-titles
|
|
46
|
+
const VBADGE = { live: 'Live', archive: 'Archive' };
|
|
44
47
|
export function versionSwitcher(versions = [], activeIdx = 0) {
|
|
45
48
|
const cur = versions[activeIdx]?.label || '';
|
|
46
|
-
const opts = versions.map((v, i) =>
|
|
47
|
-
|
|
48
|
-
`<
|
|
49
|
-
|
|
49
|
+
const opts = versions.map((v, i) => {
|
|
50
|
+
const key = String(v.badge || 'archive').toLowerCase();
|
|
51
|
+
return `<div class="vopt" role="option" data-dd-item tabindex="-1" aria-selected="${i === activeIdx}" data-active="${i === activeIdx ? '1' : '0'}">` +
|
|
52
|
+
`<span><div class="vname">${v.label}</div><div class="vmeta">${v.meta || ''}</div></span>` +
|
|
53
|
+
`<span class="vbadge ${key === 'live' ? 'live' : 'arch'}">${Object.hasOwn(VBADGE, key) ? VBADGE[key] : v.badge}</span></div>`;
|
|
54
|
+
}).join('');
|
|
50
55
|
return `<div class="vsw" data-dropdown><button type="button" class="vsw__btn" data-dropdown-trigger aria-haspopup="listbox" aria-expanded="false" aria-label="Version — ${esc(cur)}">` +
|
|
51
56
|
`<span class="lbl">version:</span><span class="cur">${cur}</span><span class="car"></span></button>` +
|
|
52
57
|
`<div class="vsw__menu" data-dropdown-panel role="listbox" aria-label="Version">${opts}</div></div>`;
|
package/src/index.css
CHANGED
|
@@ -24,9 +24,12 @@
|
|
|
24
24
|
@import "./styles/tabs.css";
|
|
25
25
|
@import "./styles/input.css";
|
|
26
26
|
@import "./styles/dropdown.css";
|
|
27
|
+
@import "./styles/tooltip.css";
|
|
27
28
|
@import "./styles/nav.css";
|
|
29
|
+
@import "./styles/back.css";
|
|
28
30
|
@import "./styles/drawer.css";
|
|
29
31
|
@import "./styles/confirm.css";
|
|
32
|
+
@import "./styles/command-palette.css";
|
|
30
33
|
@import "./styles/table.css";
|
|
31
34
|
@import "./styles/pagination.css";
|
|
32
35
|
@import "./styles/empty.css";
|
package/src/index.js
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
// Styles ship separately: `import 'apliteni-ui/css'`.
|
|
3
3
|
export * from './components/index.js';
|
|
4
4
|
export * from './components/dropdown.js';
|
|
5
|
+
export * from './components/tooltip.js';
|
|
5
6
|
export * from './components/tabs.js';
|
|
6
7
|
export * from './components/nav.js';
|
|
8
|
+
export * from './components/back.js';
|
|
7
9
|
export * from './components/drawer.js';
|
|
8
10
|
export * from './components/confirm.js';
|
|
11
|
+
export * from './components/command-palette.js';
|
|
9
12
|
export * from './components/topbar.js';
|
|
10
13
|
export * from './components/shell.js';
|
|
11
14
|
export * from './components/footer.js';
|
package/src/inline.js
CHANGED
|
@@ -37,9 +37,12 @@ export const styles = {
|
|
|
37
37
|
tabs: read('styles/tabs.css'),
|
|
38
38
|
input: read('styles/input.css'),
|
|
39
39
|
dropdown: read('styles/dropdown.css'),
|
|
40
|
+
tooltip: read('styles/tooltip.css'),
|
|
40
41
|
nav: read('styles/nav.css'),
|
|
42
|
+
back: read('styles/back.css'),
|
|
41
43
|
drawer: read('styles/drawer.css'),
|
|
42
44
|
confirm: read('styles/confirm.css'),
|
|
45
|
+
commandPalette: read('styles/command-palette.css'),
|
|
43
46
|
table: read('styles/table.css'),
|
|
44
47
|
pagination: read('styles/pagination.css'),
|
|
45
48
|
empty: read('styles/empty.css'),
|
|
@@ -66,9 +69,12 @@ export const cssText = [
|
|
|
66
69
|
styles.tabs,
|
|
67
70
|
styles.input,
|
|
68
71
|
styles.dropdown,
|
|
72
|
+
styles.tooltip,
|
|
69
73
|
styles.nav,
|
|
74
|
+
styles.back,
|
|
70
75
|
styles.drawer,
|
|
71
76
|
styles.confirm,
|
|
77
|
+
styles.commandPalette,
|
|
72
78
|
styles.table,
|
|
73
79
|
styles.pagination,
|
|
74
80
|
styles.empty,
|
package/src/motion.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// apliteni-ui — motion hook.
|
|
2
2
|
//
|
|
3
|
-
// The one small
|
|
4
|
-
//
|
|
3
|
+
// The one small piece of JS behind the CSS motion library: a scroll-reveal
|
|
4
|
+
// activator, the entrance player the components call when something they show
|
|
5
|
+
// appears (playEntrance), and a replay helper. Everything else is pure CSS
|
|
5
6
|
// (src/styles/motion.css). Framework-agnostic, no dependencies, guarded so it
|
|
6
7
|
// no-ops cleanly under `node --test` / SSR (no window, no IntersectionObserver).
|
|
7
8
|
//
|
|
@@ -59,6 +60,46 @@ export function initReveal(root) {
|
|
|
59
60
|
return io;
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/**
|
|
64
|
+
* How long playEntrance() waits for `animationend` before taking the class off
|
|
65
|
+
* itself. Longer than the slowest --dur-* token, so it only ever fires for an
|
|
66
|
+
* animation that never ran; src/motion.test.js holds it above that token.
|
|
67
|
+
*/
|
|
68
|
+
export const ENTRANCE_FALLBACK_MS = 1000;
|
|
69
|
+
|
|
70
|
+
const entering = new WeakMap();
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Play the one-shot entrance of something the reader just caused to appear.
|
|
74
|
+
*
|
|
75
|
+
* Adds `className` and takes it off at the element's own `animationend`, or
|
|
76
|
+
* after ENTRANCE_FALLBACK_MS if that never fires (the element was hidden again,
|
|
77
|
+
* or no stylesheet gives the class an animation) — so the class is never left
|
|
78
|
+
* half-applied. The sheet decides what the entrance looks like: the component
|
|
79
|
+
* pairs the class with an animation in its own stylesheet. A component calls
|
|
80
|
+
* this on a change, never at first render, which is how the page loads still.
|
|
81
|
+
* Reduced motion needs no branch here: the net in reduced-motion.css shortens
|
|
82
|
+
* the animation to nothing and `animationend` still fires.
|
|
83
|
+
*/
|
|
84
|
+
export function playEntrance(el, className = 'is-entering') {
|
|
85
|
+
if (!el || !el.classList) return;
|
|
86
|
+
entering.get(el)?.();
|
|
87
|
+
el.classList.remove(className);
|
|
88
|
+
void el.offsetWidth; // restart the animation when the class was already on
|
|
89
|
+
el.classList.add(className);
|
|
90
|
+
let timer;
|
|
91
|
+
const done = (e) => {
|
|
92
|
+
if (e && e.target !== el) return; // a descendant's animation bubbling up
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
el.removeEventListener('animationend', done);
|
|
95
|
+
el.classList.remove(className);
|
|
96
|
+
entering.delete(el);
|
|
97
|
+
};
|
|
98
|
+
el.addEventListener('animationend', done);
|
|
99
|
+
timer = setTimeout(done, ENTRANCE_FALLBACK_MS);
|
|
100
|
+
entering.set(el, () => done());
|
|
101
|
+
}
|
|
102
|
+
|
|
62
103
|
/** Restart the CSS animation on an element (for a "replay" control). */
|
|
63
104
|
export function replay(el) {
|
|
64
105
|
if (!el || !el.style) return;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
* Back link — .ui-back
|
|
3
|
+
* The way up from a page to the page it sits under. It stands above the page
|
|
4
|
+
* title, in the slot a breadcrumb trail would take, and it is quiet at rest: it
|
|
5
|
+
* is on every page below a list, and it is never what the page is for.
|
|
6
|
+
* why: docs/specification.md#the-back-link
|
|
7
|
+
* ========================================================================== */
|
|
8
|
+
|
|
9
|
+
/* Its own line, and only as wide as its words, so the hover ground does not run
|
|
10
|
+
the width of the column. */
|
|
11
|
+
.ui-back {
|
|
12
|
+
display: flex;
|
|
13
|
+
width: fit-content;
|
|
14
|
+
align-items: center;
|
|
15
|
+
gap: var(--space-1);
|
|
16
|
+
/* WCAG 2.5.8's 24px: the line of text alone is 17px tall. */
|
|
17
|
+
min-height: var(--space-6);
|
|
18
|
+
padding: 0 var(--space-2) 0 var(--space-1);
|
|
19
|
+
border-radius: var(--radius-sm);
|
|
20
|
+
font-family: var(--font-sans);
|
|
21
|
+
font-size: var(--text-sm);
|
|
22
|
+
font-weight: var(--weight-medium);
|
|
23
|
+
line-height: 1.3;
|
|
24
|
+
text-decoration: none;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* [href] as well as the class, because a host stylesheet's `a:link` is (0,1,1)
|
|
28
|
+
and outranks a bare class: it would paint this in the host's link colour.
|
|
29
|
+
(0,2,0) wins without !important — the argument `.ui-nav .ui-nav__item` makes
|
|
30
|
+
in nav.css. */
|
|
31
|
+
.ui-back[href] { color: var(--dim); }
|
|
32
|
+
.ui-back[href]:hover { color: var(--strong); background: var(--surface-2); }
|
|
33
|
+
.ui-back:focus-visible { outline: none; box-shadow: var(--ring); }
|
|
34
|
+
|
|
35
|
+
/* 16px, with the stroke raised to match: the glyph set draws at 1.7 on a 24-unit
|
|
36
|
+
box, which is 1.13 CSS px at this size and under the kit's 1.5 for a stroked
|
|
37
|
+
mark. 2.4 paints 1.6. why: docs/specification.md#icons-and-glyphs */
|
|
38
|
+
.ui-back svg { width: 16px; height: 16px; flex: none; stroke-width: 2.4; }
|
|
39
|
+
|
|
40
|
+
/* In the page shell it takes the trail's place, and the trail's distance from
|
|
41
|
+
the title with it. */
|
|
42
|
+
.ui-app__main > .ui-back { margin-bottom: var(--space-5); }
|
package/src/styles/badge.css
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* ============================================================================
|
|
2
2
|
* Badge / Pill / Tag
|
|
3
|
-
* .ui-badge —
|
|
3
|
+
* .ui-badge — status chip (live / soon / archive / neutral)
|
|
4
4
|
* .ui-tag — soft solid label
|
|
5
5
|
* .ui-dot — leading status dot (pulses when .is-live)
|
|
6
6
|
* ========================================================================== */
|
|
@@ -9,10 +9,9 @@
|
|
|
9
9
|
display: inline-flex;
|
|
10
10
|
align-items: center;
|
|
11
11
|
gap: 6px;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
font-weight: var(--weight-bold);
|
|
12
|
+
/* rank: chip */
|
|
13
|
+
font-size: var(--text-xs);
|
|
14
|
+
font-weight: var(--weight-semibold);
|
|
16
15
|
padding: 3px 9px;
|
|
17
16
|
border-radius: var(--radius-pill);
|
|
18
17
|
color: var(--muted);
|
|
@@ -45,9 +44,8 @@
|
|
|
45
44
|
display: inline-flex;
|
|
46
45
|
align-items: center;
|
|
47
46
|
gap: 7px;
|
|
47
|
+
/* rank: chip */
|
|
48
48
|
font-size: var(--text-xs);
|
|
49
|
-
letter-spacing: 0.12em;
|
|
50
|
-
text-transform: uppercase;
|
|
51
49
|
color: var(--muted);
|
|
52
50
|
background: var(--surface-2);
|
|
53
51
|
border-radius: var(--radius-pill);
|