@ponchia/ui 0.4.1 → 0.6.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/CHANGELOG.md +552 -8
- package/MIGRATIONS.json +106 -0
- package/README.md +34 -8
- package/annotations/index.d.ts +402 -0
- package/annotations/index.d.ts.map +1 -0
- package/annotations/index.js +792 -0
- package/behaviors/carousel.js +198 -0
- package/behaviors/combobox.js +226 -0
- package/behaviors/command.js +190 -0
- package/behaviors/connectors.js +95 -0
- package/behaviors/crosshair.js +57 -0
- package/behaviors/dialog.js +74 -0
- package/behaviors/disclosure.js +26 -0
- package/behaviors/dismissible.js +25 -0
- package/behaviors/forms.js +186 -0
- package/behaviors/glyph.js +108 -0
- package/behaviors/index.d.ts +79 -0
- package/behaviors/index.js +18 -1409
- package/behaviors/internal.js +97 -0
- package/behaviors/legend.js +67 -0
- package/behaviors/menu.js +47 -0
- package/behaviors/popover.js +179 -0
- package/behaviors/spotlight.js +52 -0
- package/behaviors/table.js +136 -0
- package/behaviors/tabs.js +103 -0
- package/behaviors/theme.js +84 -0
- package/behaviors/toast.js +164 -0
- package/classes/classes.json +1857 -0
- package/classes/index.d.ts +306 -13
- package/classes/index.js +339 -12
- package/classes/vscode.css-custom-data.json +12 -0
- package/connectors/index.d.ts +191 -0
- package/connectors/index.d.ts.map +1 -0
- package/connectors/index.js +275 -0
- package/css/analytical.css +21 -0
- package/css/annotations.css +292 -0
- package/css/app.css +43 -13
- package/css/base.css +15 -10
- package/css/command.css +97 -0
- package/css/connectors.css +110 -0
- package/css/content.css +7 -1
- package/css/crosshair.css +100 -0
- package/css/dataviz.css +5 -1
- package/css/disclosure.css +38 -6
- package/css/dots.css +57 -0
- package/css/feedback.css +111 -2
- package/css/fonts.css +11 -7
- package/css/forms.css +42 -1
- package/css/generated.css +117 -0
- package/css/legend.css +272 -0
- package/css/marks.css +174 -0
- package/css/motion.css +24 -44
- package/css/navigation.css +7 -0
- package/css/overlay.css +31 -1
- package/css/primitives.css +109 -5
- package/css/report.css +39 -81
- package/css/selection.css +46 -0
- package/css/site.css +16 -2
- package/css/sources.css +221 -0
- package/css/spotlight.css +104 -0
- package/css/state.css +121 -0
- package/css/tokens.css +60 -37
- package/css/workbench.css +83 -0
- package/dist/bronto.css +1 -1
- package/dist/css/analytical.css +1 -0
- package/dist/css/annotations.css +1 -0
- package/dist/css/app.css +1 -1
- package/dist/css/base.css +1 -1
- package/dist/css/command.css +1 -0
- package/dist/css/connectors.css +1 -0
- package/dist/css/content.css +1 -1
- package/dist/css/crosshair.css +1 -0
- package/dist/css/disclosure.css +1 -1
- package/dist/css/dots.css +1 -1
- package/dist/css/feedback.css +1 -1
- package/dist/css/fonts.css +1 -1
- package/dist/css/forms.css +1 -1
- package/dist/css/generated.css +1 -0
- package/dist/css/legend.css +1 -0
- package/dist/css/marks.css +1 -0
- package/dist/css/motion.css +1 -1
- package/dist/css/navigation.css +1 -1
- package/dist/css/overlay.css +1 -1
- package/dist/css/primitives.css +1 -1
- package/dist/css/report.css +1 -1
- package/dist/css/selection.css +1 -0
- package/dist/css/site.css +1 -1
- package/dist/css/sources.css +1 -0
- package/dist/css/spotlight.css +1 -0
- package/dist/css/state.css +1 -0
- package/dist/css/tokens.css +1 -1
- package/dist/css/workbench.css +1 -0
- package/docs/adr/0003-theme-model.md +7 -4
- package/docs/annotations.md +425 -0
- package/docs/architecture.md +246 -0
- package/docs/command.md +95 -0
- package/docs/connectors.md +91 -0
- package/docs/contrast.md +116 -92
- package/docs/crosshair.md +63 -0
- package/docs/d2.md +195 -0
- package/docs/generated.md +91 -0
- package/docs/legends.md +184 -0
- package/docs/marks.md +93 -0
- package/docs/mermaid.md +152 -0
- package/docs/reference.md +385 -23
- package/docs/reporting.md +436 -63
- package/docs/selection.md +40 -0
- package/docs/sources.md +137 -0
- package/docs/spotlight.md +78 -0
- package/docs/stability.md +24 -2
- package/docs/state.md +85 -0
- package/docs/usage.md +123 -4
- package/docs/vega.md +225 -0
- package/docs/workbench.md +78 -0
- package/fonts/doto-400.woff2 +0 -0
- package/fonts/doto-500.woff2 +0 -0
- package/fonts/doto-600.woff2 +0 -0
- package/fonts/doto-700.woff2 +0 -0
- package/fonts/doto-800.woff2 +0 -0
- package/fonts/doto-900.woff2 +0 -0
- package/glyphs/glyphs.js +6 -4
- package/llms.txt +362 -14
- package/package.json +115 -12
- package/qwik/index.d.ts +42 -54
- package/qwik/index.d.ts.map +1 -0
- package/qwik/index.js +75 -3
- package/react/index.d.ts +39 -56
- package/react/index.d.ts.map +1 -0
- package/react/index.js +67 -3
- package/solid/index.d.ts +64 -56
- package/solid/index.d.ts.map +1 -0
- package/solid/index.js +70 -3
- package/tokens/d2.d.ts +38 -0
- package/tokens/d2.js +71 -0
- package/tokens/d2.json +43 -0
- package/tokens/index.d.ts +5 -5
- package/tokens/index.js +23 -5
- package/tokens/index.json +9 -0
- package/tokens/mermaid.d.ts +23 -0
- package/tokens/mermaid.js +181 -0
- package/tokens/mermaid.json +163 -0
- package/tokens/resolved.json +45 -1
- package/tokens/skins.js +3 -2
- package/tokens/tokens.dtcg.json +26 -0
- package/tokens/vega.d.ts +34 -0
- package/tokens/vega.js +155 -0
- package/tokens/vega.json +179 -0
- package/fonts/doto-400.ttf +0 -0
- package/fonts/doto-500.ttf +0 -0
- package/fonts/doto-600.ttf +0 -0
- package/fonts/doto-700.ttf +0 -0
- package/fonts/doto-800.ttf +0 -0
- package/fonts/doto-900.ttf +0 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce, nextFieldUid, collectHosts } from './internal.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wire `[data-bronto-tabs]` groups for full keyboard a11y. The framework
|
|
5
|
+
* ships the look + the ARIA/`.is-active` contract; this adds the WAI-ARIA
|
|
6
|
+
* Tabs pattern: roving `tabindex`, `aria-selected`, Arrow/Home/End
|
|
7
|
+
* navigation with automatic activation, and panel `hidden` sync. Tabs are
|
|
8
|
+
* `.ui-tab[data-tab]`; panels are `.ui-tabs__panel[data-panel]` with
|
|
9
|
+
* matching values. SSR-safe and idempotent (re-init replaces, never
|
|
10
|
+
* stacks, the per-group listeners); returns a cleanup function.
|
|
11
|
+
*
|
|
12
|
+
* Accessibility caveat: this is what makes tabs operable. Do **not**
|
|
13
|
+
* author `hidden` on `.ui-tabs__panel` in server-rendered markup unless
|
|
14
|
+
* `initTabs` is guaranteed to run client-side — without it the panels
|
|
15
|
+
* stay hidden with no keyboard/pointer way to reveal them. Prefer
|
|
16
|
+
* authoring all panels visible and letting `initTabs` add `hidden`.
|
|
17
|
+
*/
|
|
18
|
+
export function initTabs({ root } = {}) {
|
|
19
|
+
if (!hasDom()) return noop;
|
|
20
|
+
const host = resolveHost(root);
|
|
21
|
+
if (!host) return noop;
|
|
22
|
+
const cleanups = [];
|
|
23
|
+
const groups = collectHosts(host, '[data-bronto-tabs]');
|
|
24
|
+
for (const group of groups) {
|
|
25
|
+
// Own group only — a tab/panel inside a nested [data-bronto-tabs]
|
|
26
|
+
// belongs to that inner group, not this one.
|
|
27
|
+
const owned = (el) => el.closest('[data-bronto-tabs]') === group;
|
|
28
|
+
const tabs = [...group.querySelectorAll('.ui-tab')].filter(owned);
|
|
29
|
+
const panels = [...group.querySelectorAll('.ui-tabs__panel')].filter(owned);
|
|
30
|
+
if (!tabs.length) continue;
|
|
31
|
+
const list = group.querySelector('.ui-tabs__list');
|
|
32
|
+
if (list) list.setAttribute('role', 'tablist');
|
|
33
|
+
|
|
34
|
+
// APG: bind each tab to its panel (aria-controls) and back
|
|
35
|
+
// (aria-labelledby), minting stable ids only where absent.
|
|
36
|
+
for (const t of tabs) {
|
|
37
|
+
const p = panels.find((x) => x.dataset.panel === t.dataset.tab);
|
|
38
|
+
if (!p) continue;
|
|
39
|
+
const n = nextFieldUid();
|
|
40
|
+
if (!t.id) t.id = `bronto-tab-${n}`;
|
|
41
|
+
if (!p.id) p.id = `bronto-tabpanel-${n}`;
|
|
42
|
+
t.setAttribute('aria-controls', p.id);
|
|
43
|
+
p.setAttribute('aria-labelledby', t.id);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const select = (tab) => {
|
|
47
|
+
for (const t of tabs) {
|
|
48
|
+
const on = t === tab;
|
|
49
|
+
t.classList.toggle('is-active', on);
|
|
50
|
+
t.setAttribute('role', 'tab');
|
|
51
|
+
t.setAttribute('aria-selected', String(on));
|
|
52
|
+
t.tabIndex = on ? 0 : -1;
|
|
53
|
+
}
|
|
54
|
+
// Only retarget panels when this tab actually controls one. A panel-less
|
|
55
|
+
// tab must NOT hide every panel — leave the prior panel visible (C30).
|
|
56
|
+
if (!panels.some((p) => p.dataset.panel === tab.dataset.tab)) return;
|
|
57
|
+
for (const p of panels) {
|
|
58
|
+
p.setAttribute('role', 'tabpanel');
|
|
59
|
+
const shown = p.dataset.panel === tab.dataset.tab;
|
|
60
|
+
p.hidden = !shown;
|
|
61
|
+
// APG: a tabpanel is focusable so keyboard users can reach a text-only
|
|
62
|
+
// panel; hidden panels drop out of the tab order (C30).
|
|
63
|
+
if (shown) p.tabIndex = 0;
|
|
64
|
+
else p.removeAttribute('tabindex');
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const onClick = (e) => {
|
|
68
|
+
// `tabs` is filtered to this group, so membership (not mere DOM
|
|
69
|
+
// containment) is what isolates nested [data-bronto-tabs] groups.
|
|
70
|
+
const tab = e.target.closest('.ui-tab');
|
|
71
|
+
if (tab && tabs.includes(tab)) {
|
|
72
|
+
select(tab);
|
|
73
|
+
tab.focus();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const onKey = (e) => {
|
|
77
|
+
const i = tabs.indexOf(e.target.closest('.ui-tab'));
|
|
78
|
+
if (i < 0) return;
|
|
79
|
+
let n = i;
|
|
80
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') n = (i + 1) % tabs.length;
|
|
81
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp')
|
|
82
|
+
n = (i - 1 + tabs.length) % tabs.length;
|
|
83
|
+
else if (e.key === 'Home') n = 0;
|
|
84
|
+
else if (e.key === 'End') n = tabs.length - 1;
|
|
85
|
+
else return;
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
select(tabs[n]);
|
|
88
|
+
tabs[n].focus();
|
|
89
|
+
};
|
|
90
|
+
select(tabs.find((t) => t.classList.contains('is-active')) || tabs[0]);
|
|
91
|
+
cleanups.push(
|
|
92
|
+
bindOnce(group, 'tabs', () => {
|
|
93
|
+
group.addEventListener('click', onClick);
|
|
94
|
+
group.addEventListener('keydown', onKey);
|
|
95
|
+
return () => {
|
|
96
|
+
group.removeEventListener('click', onClick);
|
|
97
|
+
group.removeEventListener('keydown', onKey);
|
|
98
|
+
};
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return () => cleanups.forEach((fn) => fn());
|
|
103
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce } from './internal.js';
|
|
2
|
+
|
|
3
|
+
const THEMES = ['light', 'dark'];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Apply the persisted theme to <html data-theme>. Call as early as
|
|
7
|
+
* possible (an inline module in <head>) to avoid a flash before the
|
|
8
|
+
* toggle wires up. No stored value → leaves prefers-color-scheme to act.
|
|
9
|
+
*/
|
|
10
|
+
export function applyStoredTheme({ storageKey = 'bronto-theme', root } = {}) {
|
|
11
|
+
if (!hasDom()) return;
|
|
12
|
+
const el = resolveHost(root, document.documentElement);
|
|
13
|
+
if (!el) return;
|
|
14
|
+
let stored = null;
|
|
15
|
+
try {
|
|
16
|
+
stored = localStorage.getItem(storageKey);
|
|
17
|
+
} catch {
|
|
18
|
+
/* storage blocked (private mode / sandbox) — fall through to OS default */
|
|
19
|
+
}
|
|
20
|
+
if (stored && THEMES.includes(stored)) el.setAttribute('data-theme', stored);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Wire `[data-bronto-theme-toggle]` controls. Click toggles light/dark,
|
|
25
|
+
* persists to localStorage, and **always** sets `data-theme` on <html>
|
|
26
|
+
* (a theme is document-global). State is reflected via `aria-pressed`
|
|
27
|
+
* and a `bronto:themechange` CustomEvent ({ detail: { theme } }) is
|
|
28
|
+
* dispatched on <html> so consumers can sync their own UI without
|
|
29
|
+
* racing the click handler. A control may set
|
|
30
|
+
* `data-bronto-theme-toggle="dark"` to force a specific theme.
|
|
31
|
+
*
|
|
32
|
+
* `root` scopes event delegation and which controls are queried/reflected
|
|
33
|
+
* (default `document`); it does not change where the theme is applied.
|
|
34
|
+
*/
|
|
35
|
+
export function initThemeToggle({ storageKey = 'bronto-theme', root } = {}) {
|
|
36
|
+
if (!hasDom()) return noop;
|
|
37
|
+
const host = resolveHost(root);
|
|
38
|
+
if (!host) return noop;
|
|
39
|
+
const docEl = document.documentElement;
|
|
40
|
+
|
|
41
|
+
const prefersDark = () =>
|
|
42
|
+
typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches;
|
|
43
|
+
|
|
44
|
+
const current = () => {
|
|
45
|
+
const attr = docEl.getAttribute('data-theme');
|
|
46
|
+
if (THEMES.includes(attr)) return attr;
|
|
47
|
+
return prefersDark() ? 'dark' : 'light';
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const reflect = () => {
|
|
51
|
+
const c = current();
|
|
52
|
+
host.querySelectorAll('[data-bronto-theme-toggle]').forEach((el) => {
|
|
53
|
+
const forced = el.getAttribute('data-bronto-theme-toggle');
|
|
54
|
+
// A forced control is "pressed" when its theme is the active one;
|
|
55
|
+
// a plain toggle reflects whether dark is active.
|
|
56
|
+
const pressed = THEMES.includes(forced) ? c === forced : c === 'dark';
|
|
57
|
+
el.setAttribute('aria-pressed', String(pressed));
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const onClick = (e) => {
|
|
62
|
+
const trigger = e.target.closest('[data-bronto-theme-toggle]');
|
|
63
|
+
if (!trigger || !host.contains(trigger)) return;
|
|
64
|
+
const forced = trigger.getAttribute('data-bronto-theme-toggle');
|
|
65
|
+
const next = THEMES.includes(forced) ? forced : current() === 'dark' ? 'light' : 'dark';
|
|
66
|
+
docEl.setAttribute('data-theme', next);
|
|
67
|
+
try {
|
|
68
|
+
localStorage.setItem(storageKey, next);
|
|
69
|
+
} catch {
|
|
70
|
+
/* storage blocked — theme still applies for this session */
|
|
71
|
+
}
|
|
72
|
+
reflect();
|
|
73
|
+
docEl.dispatchEvent(
|
|
74
|
+
new CustomEvent('bronto:themechange', { detail: { theme: next }, bubbles: true }),
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
applyStoredTheme({ storageKey });
|
|
79
|
+
reflect();
|
|
80
|
+
return bindOnce(host, 'themeToggle', () => {
|
|
81
|
+
host.addEventListener('click', onClick);
|
|
82
|
+
return () => host.removeEventListener('click', onClick);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { hasDom, noop } from './internal.js';
|
|
2
|
+
|
|
3
|
+
// The tones that have a `.ui-toast--*` rule. The TS type already unions these,
|
|
4
|
+
// but a plain-JS / LLM caller can pass any string — and an unknown tone built a
|
|
5
|
+
// `.ui-toast--error` class that matches no CSS, yielding a silent neutral toast.
|
|
6
|
+
// Validate so an unknown tone degrades to neutral *and warns*, never lies. (C16)
|
|
7
|
+
const TOAST_TONES = new Set(['accent', 'success', 'warning', 'danger', 'info']);
|
|
8
|
+
|
|
9
|
+
// First-toast deferral queue. The very first toast on a brand-new stack
|
|
10
|
+
// is appended next frame so AT observes the empty aria-live region
|
|
11
|
+
// before its first child. Any further toasts created *before* that frame
|
|
12
|
+
// flushes are queued behind it so call order (FIFO) is preserved instead
|
|
13
|
+
// of a synchronous later toast jumping ahead of the deferred first one.
|
|
14
|
+
const toastQueue = [];
|
|
15
|
+
|
|
16
|
+
let toastFlushScheduled = false;
|
|
17
|
+
|
|
18
|
+
function toastStack(isAssertive) {
|
|
19
|
+
const stackSel = isAssertive
|
|
20
|
+
? '.ui-toast-stack--assertive'
|
|
21
|
+
: '.ui-toast-stack:not(.ui-toast-stack--assertive)';
|
|
22
|
+
let stack = document.querySelector(stackSel);
|
|
23
|
+
const fresh = !stack;
|
|
24
|
+
if (!stack) {
|
|
25
|
+
stack = document.createElement('div');
|
|
26
|
+
stack.className = isAssertive ? 'ui-toast-stack ui-toast-stack--assertive' : 'ui-toast-stack';
|
|
27
|
+
stack.setAttribute('aria-live', isAssertive ? 'assertive' : 'polite');
|
|
28
|
+
if (isAssertive) stack.setAttribute('role', 'alert');
|
|
29
|
+
document.body.appendChild(stack);
|
|
30
|
+
}
|
|
31
|
+
return { stack, fresh };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function enqueueToast(place, freshStack) {
|
|
35
|
+
const canDefer = typeof requestAnimationFrame === 'function';
|
|
36
|
+
if (freshStack && canDefer) {
|
|
37
|
+
toastQueue.push(place);
|
|
38
|
+
toastFlushScheduled = true;
|
|
39
|
+
requestAnimationFrame(() => {
|
|
40
|
+
toastFlushScheduled = false;
|
|
41
|
+
for (const fn of toastQueue.splice(0)) fn();
|
|
42
|
+
});
|
|
43
|
+
} else if (toastFlushScheduled) {
|
|
44
|
+
toastQueue.push(place);
|
|
45
|
+
} else {
|
|
46
|
+
place();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toastElement(message, { tone, title }) {
|
|
51
|
+
const el = document.createElement('div');
|
|
52
|
+
const validTone = tone && TOAST_TONES.has(tone) ? tone : null;
|
|
53
|
+
if (tone && !validTone && typeof console !== 'undefined') {
|
|
54
|
+
console.warn(
|
|
55
|
+
`[bronto] toast(): unknown tone "${tone}" — expected ${[...TOAST_TONES].join('/')}. Rendering neutral.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
el.className = validTone ? `ui-toast ui-toast--${validTone}` : 'ui-toast';
|
|
59
|
+
// No per-item role: the stack itself is the live region; a nested
|
|
60
|
+
// live region risks double announcement in some SRs.
|
|
61
|
+
if (title) {
|
|
62
|
+
const t = document.createElement('p');
|
|
63
|
+
t.className = 'ui-toast__title';
|
|
64
|
+
t.textContent = title;
|
|
65
|
+
el.appendChild(t);
|
|
66
|
+
}
|
|
67
|
+
const body = document.createElement('div');
|
|
68
|
+
body.textContent = message;
|
|
69
|
+
el.appendChild(body);
|
|
70
|
+
return el;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Remove a toast, animating its exit when — and only when — a transition
|
|
74
|
+
// is actually in effect. Detached nodes, reduced-motion, and the no-CSS
|
|
75
|
+
// test/SSR env all resolve to instant removal, so the dismiss contract
|
|
76
|
+
// (toast gone now, the aria-live stack stays resident) is unchanged there;
|
|
77
|
+
// a real browser with motion gets the CSS `.is-leaving` fade-out, with a
|
|
78
|
+
// timeout fallback so an interrupted/never-firing transitionend can't strand
|
|
79
|
+
// a toast in the live region.
|
|
80
|
+
function removeToast(el) {
|
|
81
|
+
const reduce =
|
|
82
|
+
typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
83
|
+
const cs =
|
|
84
|
+
!reduce && el.isConnected && typeof getComputedStyle === 'function'
|
|
85
|
+
? getComputedStyle(el)
|
|
86
|
+
: null;
|
|
87
|
+
const dur = cs ? parseFloat(cs.transitionDuration) || 0 : 0;
|
|
88
|
+
if (dur <= 0) {
|
|
89
|
+
el.remove();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
el.classList.add('is-leaving');
|
|
93
|
+
let done = false;
|
|
94
|
+
const finish = () => {
|
|
95
|
+
if (done) return;
|
|
96
|
+
done = true;
|
|
97
|
+
el.remove();
|
|
98
|
+
};
|
|
99
|
+
el.addEventListener('transitionend', finish, { once: true });
|
|
100
|
+
const timer = setTimeout(finish, dur * 1000 + 120);
|
|
101
|
+
timer?.unref?.(); // don't keep a Node test process alive
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function addToastClose(el, dismiss) {
|
|
105
|
+
const close = document.createElement('button');
|
|
106
|
+
close.type = 'button';
|
|
107
|
+
close.className = 'ui-toast__close';
|
|
108
|
+
close.setAttribute('aria-label', 'Dismiss');
|
|
109
|
+
close.addEventListener('click', dismiss);
|
|
110
|
+
el.appendChild(close);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Push a transient toast into a shared, screen-anchored stack. The stack
|
|
115
|
+
* is the `aria-live="polite"` region: it is created once, appended to
|
|
116
|
+
* <body>, and **kept resident even when empty** so the live region is
|
|
117
|
+
* always present before content is inserted (a freshly created region
|
|
118
|
+
* that receives its first child in the same tick is not reliably
|
|
119
|
+
* announced by VoiceOver/NVDA). On first creation the empty region is
|
|
120
|
+
* inserted and the toast is appended on the next frame for the same
|
|
121
|
+
* reason. `tone` is accent/success/warning/danger/info; `title` is an
|
|
122
|
+
* optional uppercase label; `duration` ms before auto-dismiss (0 keeps
|
|
123
|
+
* it until dismissed). Returns a function that dismisses the toast
|
|
124
|
+
* early. SSR-safe (no-op).
|
|
125
|
+
*/
|
|
126
|
+
export function toast(message, { tone, title, duration = 4000, assertive, closable } = {}) {
|
|
127
|
+
if (!hasDom()) return noop;
|
|
128
|
+
// Errors must interrupt: danger toasts (or an explicit `assertive`)
|
|
129
|
+
// go to a SEPARATE assertive region so they announce immediately,
|
|
130
|
+
// while status toasts stay polite. Two regions — not a per-item
|
|
131
|
+
// role=alert nested in a polite parent — avoids the double
|
|
132
|
+
// announcement that nesting causes in some screen readers.
|
|
133
|
+
const isAssertive = assertive ?? tone === 'danger';
|
|
134
|
+
const { stack, fresh: freshStack } = toastStack(isAssertive);
|
|
135
|
+
const el = toastElement(message, { tone, title });
|
|
136
|
+
// Append after a frame the *first* time so the empty live region is
|
|
137
|
+
// observed by AT before its first child arrives; once the region has
|
|
138
|
+
// been observed, later toasts append synchronously.
|
|
139
|
+
let dismissed = false;
|
|
140
|
+
// `dismissed` guard: a toast dismissed before its frame (e.g.
|
|
141
|
+
// duration:0 + immediate dismiss) must NOT be resurrected into the
|
|
142
|
+
// persistent aria-live region.
|
|
143
|
+
const place = () => {
|
|
144
|
+
if (!dismissed) stack.appendChild(el);
|
|
145
|
+
};
|
|
146
|
+
enqueueToast(place, freshStack);
|
|
147
|
+
|
|
148
|
+
let timer;
|
|
149
|
+
const dismiss = () => {
|
|
150
|
+
if (dismissed) return;
|
|
151
|
+
dismissed = true;
|
|
152
|
+
if (timer) clearTimeout(timer);
|
|
153
|
+
removeToast(el);
|
|
154
|
+
// The stack is a persistent live region — never removed on drain, so
|
|
155
|
+
// the next toast does not recreate (and thus mis-announce) it.
|
|
156
|
+
};
|
|
157
|
+
// A sticky toast (duration:0) is unusable without a manual close, so
|
|
158
|
+
// it gets a dismiss affordance by default; any toast can opt in via
|
|
159
|
+
// `closable`. The button carries no text node (glyph is a CSS
|
|
160
|
+
// ::before) so the toast's announced/textContent stays the message.
|
|
161
|
+
if (closable ?? duration === 0) addToastClose(el, dismiss);
|
|
162
|
+
if (duration > 0) timer = setTimeout(dismiss, duration);
|
|
163
|
+
return dismiss;
|
|
164
|
+
}
|