@ponchia/ui 0.5.0 → 0.6.3
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 +386 -4
- package/MIGRATIONS.json +14 -0
- package/README.md +29 -6
- package/annotations/index.d.ts +398 -276
- package/annotations/index.d.ts.map +1 -0
- package/annotations/index.js +350 -77
- package/behaviors/carousel.d.ts +28 -0
- package/behaviors/carousel.d.ts.map +1 -0
- package/behaviors/carousel.js +20 -16
- package/behaviors/combobox.d.ts +40 -0
- package/behaviors/combobox.d.ts.map +1 -0
- package/behaviors/combobox.js +111 -29
- package/behaviors/command.d.ts +41 -0
- package/behaviors/command.d.ts.map +1 -0
- package/behaviors/command.js +27 -15
- package/behaviors/connectors.d.ts +17 -0
- package/behaviors/connectors.d.ts.map +1 -0
- package/behaviors/connectors.js +7 -5
- package/behaviors/crosshair.d.ts +42 -0
- package/behaviors/crosshair.d.ts.map +1 -0
- package/behaviors/crosshair.js +23 -6
- package/behaviors/dialog.d.ts +20 -0
- package/behaviors/dialog.d.ts.map +1 -0
- package/behaviors/dialog.js +6 -2
- package/behaviors/disclosure.d.ts +10 -0
- package/behaviors/disclosure.d.ts.map +1 -0
- package/behaviors/disclosure.js +6 -2
- package/behaviors/dismissible.d.ts +10 -0
- package/behaviors/dismissible.d.ts.map +1 -0
- package/behaviors/dismissible.js +6 -2
- package/behaviors/forms.d.ts +27 -0
- package/behaviors/forms.d.ts.map +1 -0
- package/behaviors/forms.js +54 -13
- package/behaviors/glyph.d.ts +14 -0
- package/behaviors/glyph.d.ts.map +1 -0
- package/behaviors/glyph.js +28 -5
- package/behaviors/index.d.ts +31 -237
- package/behaviors/index.d.ts.map +1 -0
- package/behaviors/index.js +17 -0
- package/behaviors/inert.d.ts +20 -0
- package/behaviors/inert.d.ts.map +1 -0
- package/behaviors/inert.js +46 -0
- package/behaviors/internal.d.ts +25 -0
- package/behaviors/internal.d.ts.map +1 -0
- package/behaviors/internal.js +77 -1
- package/behaviors/legend.d.ts +35 -0
- package/behaviors/legend.d.ts.map +1 -0
- package/behaviors/legend.js +32 -2
- package/behaviors/menu.d.ts +16 -0
- package/behaviors/menu.d.ts.map +1 -0
- package/behaviors/menu.js +6 -2
- package/behaviors/modal.d.ts +41 -0
- package/behaviors/modal.d.ts.map +1 -0
- package/behaviors/modal.js +124 -0
- package/behaviors/popover.d.ts +28 -0
- package/behaviors/popover.d.ts.map +1 -0
- package/behaviors/popover.js +78 -7
- package/behaviors/spotlight.d.ts +17 -0
- package/behaviors/spotlight.d.ts.map +1 -0
- package/behaviors/spotlight.js +7 -5
- package/behaviors/table.d.ts +36 -0
- package/behaviors/table.d.ts.map +1 -0
- package/behaviors/table.js +84 -17
- package/behaviors/tabs.d.ts +20 -0
- package/behaviors/tabs.d.ts.map +1 -0
- package/behaviors/tabs.js +17 -14
- package/behaviors/theme.d.ts +54 -0
- package/behaviors/theme.d.ts.map +1 -0
- package/behaviors/theme.js +22 -3
- package/behaviors/toast.d.ts +49 -0
- package/behaviors/toast.d.ts.map +1 -0
- package/behaviors/toast.js +47 -3
- package/classes/classes.json +2527 -0
- package/classes/index.d.ts +134 -15
- package/classes/index.js +280 -80
- package/classes/vscode.css-custom-data.json +12 -0
- package/connectors/index.d.ts +201 -69
- package/connectors/index.d.ts.map +1 -0
- package/connectors/index.js +142 -25
- package/css/app.css +69 -13
- package/css/base.css +15 -10
- package/css/bullet.css +108 -0
- package/css/code.css +98 -0
- package/css/connectors.css +17 -0
- package/css/content.css +22 -3
- package/css/crosshair.css +7 -7
- package/css/dataviz.css +5 -1
- package/css/diff.css +153 -0
- package/css/disclosure.css +53 -7
- package/css/dots.css +94 -7
- package/css/feedback.css +97 -7
- package/css/forms.css +113 -4
- package/css/legend.css +16 -9
- package/css/marks.css +38 -8
- package/css/motion.css +98 -53
- package/css/navigation.css +7 -0
- package/css/overlay.css +90 -3
- package/css/primitives.css +158 -13
- package/css/report.css +73 -56
- package/css/sidenote.css +67 -0
- package/css/site.css +16 -2
- package/css/sources.css +43 -1
- package/css/spark.css +62 -0
- package/css/spotlight.css +1 -1
- package/css/table.css +9 -2
- package/css/term.css +110 -0
- package/css/textref.css +63 -0
- package/css/toc.css +91 -0
- package/css/tokens.css +49 -1
- package/css/tree.css +134 -0
- package/css/workbench.css +1 -1
- package/dist/bronto.css +1 -1
- package/dist/css/analytical.css +1 -1
- package/dist/css/app.css +1 -1
- package/dist/css/base.css +1 -1
- package/dist/css/bullet.css +1 -0
- package/dist/css/code.css +1 -0
- package/dist/css/connectors.css +1 -1
- package/dist/css/content.css +1 -1
- package/dist/css/crosshair.css +1 -1
- package/dist/css/diff.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/forms.css +1 -1
- package/dist/css/legend.css +1 -1
- package/dist/css/marks.css +1 -1
- 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/sidenote.css +1 -0
- package/dist/css/site.css +1 -1
- package/dist/css/sources.css +1 -1
- package/dist/css/spark.css +1 -0
- package/dist/css/spotlight.css +1 -1
- package/dist/css/table.css +1 -1
- package/dist/css/term.css +1 -0
- package/dist/css/textref.css +1 -0
- package/dist/css/toc.css +1 -0
- package/dist/css/tokens.css +1 -1
- package/dist/css/tree.css +1 -0
- package/dist/css/workbench.css +1 -1
- package/docs/adr/0003-theme-model.md +1 -1
- package/docs/annotations.md +133 -14
- package/docs/architecture.md +49 -6
- package/docs/bullet.md +78 -0
- package/docs/code.md +76 -0
- package/docs/contrast.md +116 -92
- package/docs/d2.md +196 -0
- package/docs/diff.md +146 -0
- package/docs/legends.md +23 -3
- package/docs/marks.md +9 -2
- package/docs/mermaid.md +169 -0
- package/docs/reference.md +201 -26
- package/docs/reporting.md +416 -57
- package/docs/sidenote.md +64 -0
- package/docs/sources.md +27 -0
- package/docs/spark.md +78 -0
- package/docs/stability.md +10 -2
- package/docs/term.md +81 -0
- package/docs/textref.md +78 -0
- package/docs/theming.md +44 -5
- package/docs/toc.md +83 -0
- package/docs/tree.md +74 -0
- package/docs/usage.md +354 -16
- package/docs/vega.md +244 -0
- package/docs/workbench.md +7 -1
- package/glyphs/glyphs.js +13 -5
- package/llms.txt +285 -14
- package/package.json +95 -17
- package/qwik/index.d.ts +44 -59
- package/qwik/index.d.ts.map +1 -0
- package/qwik/index.js +65 -3
- package/react/index.d.ts +41 -61
- package/react/index.d.ts.map +1 -0
- package/react/index.js +63 -3
- package/solid/index.d.ts +68 -61
- package/solid/index.d.ts.map +1 -0
- package/solid/index.js +66 -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 +15 -1
- 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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce } from './internal.js';
|
|
2
|
+
|
|
3
|
+
const DISABLED = '[aria-disabled="true"]';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Make `aria-disabled="true"` controls keyboard-inert, not just pointer-inert.
|
|
7
|
+
*
|
|
8
|
+
* CSS can dim an `aria-disabled` control and set `pointer-events: none`, but it
|
|
9
|
+
* cannot block keyboard activation: a focused `<a aria-disabled>` or
|
|
10
|
+
* `<button aria-disabled>` still fires on Enter/Space, so a keyboard user can
|
|
11
|
+
* activate a control that looks dead. (Native `disabled` is already fully inert;
|
|
12
|
+
* this guard brings the ARIA path up to parity.) The element intentionally stays
|
|
13
|
+
* focusable and announced — the WAI-ARIA disabled pattern keeps it in the tab
|
|
14
|
+
* order — but it no longer *acts*.
|
|
15
|
+
*
|
|
16
|
+
* Wire once near the root, like {@link applyStoredTheme}. Capturing listeners
|
|
17
|
+
* intercept activation before any component handler (tabs, pagination, menus)
|
|
18
|
+
* sees it. (component audit C4.)
|
|
19
|
+
*
|
|
20
|
+
* @param {import('./internal.js').DelegateOpts} [opts]
|
|
21
|
+
* @returns {import('./internal.js').Cleanup}
|
|
22
|
+
*/
|
|
23
|
+
export function initDisabledGuard({ root } = {}) {
|
|
24
|
+
if (!hasDom()) return noop;
|
|
25
|
+
const host = resolveHost(root);
|
|
26
|
+
if (!host) return noop;
|
|
27
|
+
const block = (e) => {
|
|
28
|
+
const el = e.target.closest?.(DISABLED);
|
|
29
|
+
if (el && host.contains(el)) {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
e.stopPropagation();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const onKeydown = (e) => {
|
|
35
|
+
// Only the activation keys; Tab/arrows must still move focus PAST the control.
|
|
36
|
+
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') block(e);
|
|
37
|
+
};
|
|
38
|
+
return bindOnce(host, 'disabled-guard', () => {
|
|
39
|
+
host.addEventListener('click', block, true);
|
|
40
|
+
host.addEventListener('keydown', onKeydown, true);
|
|
41
|
+
return () => {
|
|
42
|
+
host.removeEventListener('click', block, true);
|
|
43
|
+
host.removeEventListener('keydown', onKeydown, true);
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function resolveHost(root: any, fallback?: Document): any;
|
|
2
|
+
export function bindOnce(target: any, key: any, add: any): () => void;
|
|
3
|
+
export function byIdInHost(host: any, id: any): any;
|
|
4
|
+
export function closestSafe(el: any, selector: any): any;
|
|
5
|
+
export function collectHosts(host: any, selector: any): any[];
|
|
6
|
+
export function scrollIntoViewSafe(el: any, opts?: {
|
|
7
|
+
block: string;
|
|
8
|
+
}): void;
|
|
9
|
+
export function focusInto(container: any): void;
|
|
10
|
+
export function wrapIndex(cur: any, delta: any, len: any): any;
|
|
11
|
+
export function noop(): void;
|
|
12
|
+
export function hasDom(): boolean;
|
|
13
|
+
export function nextFieldUid(): number;
|
|
14
|
+
/**
|
|
15
|
+
* Cleanup function returned by every initializer; calling it tears down the
|
|
16
|
+
* behavior's listeners/observers.
|
|
17
|
+
*/
|
|
18
|
+
export type Cleanup = () => void;
|
|
19
|
+
export type DelegateOpts = {
|
|
20
|
+
/**
|
|
21
|
+
* Event-delegation root; also scopes which controls are queried. Default: `document`.
|
|
22
|
+
*/
|
|
23
|
+
root?: Document | Element | undefined;
|
|
24
|
+
};
|
|
25
|
+
//# sourceMappingURL=internal.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"internal.d.ts","sourceRoot":"","sources":["internal.js"],"names":[],"mappings":"AA8BA,iEAGC;AAeD,sEAUC;AAED,oDAQC;AAED,yDAMC;AAMD,8DAIC;AAID;;SAMC;AAUD,gDAQC;AAMD,+DAKC;AA9GM,6BAAqB;AAErB,kCAAoD;AAsBpD,uCAAqC;;;;;sBAjC/B,MAAM,IAAI"}
|
package/behaviors/internal.js
CHANGED
|
@@ -1,11 +1,38 @@
|
|
|
1
1
|
// Shared, dependency-free DOM helpers for the behavior modules.
|
|
2
2
|
// Not part of the public @ponchia/ui/behaviors surface (the barrel
|
|
3
|
-
// re-exports only the documented behaviors
|
|
3
|
+
// re-exports only the documented behaviors' values — these shared option
|
|
4
|
+
// types are re-exported by name from index.js).
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {() => void} Cleanup
|
|
8
|
+
* Cleanup function returned by every initializer; calling it tears down the
|
|
9
|
+
* behavior's listeners/observers.
|
|
10
|
+
*
|
|
11
|
+
* @typedef {object} DelegateOpts
|
|
12
|
+
* @property {Document | Element} [root]
|
|
13
|
+
* Event-delegation root; also scopes which controls are queried. Default: `document`.
|
|
14
|
+
*/
|
|
4
15
|
|
|
5
16
|
export const noop = () => {};
|
|
6
17
|
|
|
7
18
|
export const hasDom = () => typeof document !== 'undefined';
|
|
8
19
|
|
|
20
|
+
// Resolve the delegation host from an init call's `root` option, distinguishing
|
|
21
|
+
// three cases so an unattached/null root never silently widens to whole-document
|
|
22
|
+
// delegation (the "scoped island hijacks every control" foot-gun):
|
|
23
|
+
// • root absent/undefined → no scope requested → delegate from `fallback`
|
|
24
|
+
// (default `document`). This is the intended global-wiring path.
|
|
25
|
+
// • root === null → a scope WAS requested but isn't ready yet (e.g. a
|
|
26
|
+
// framework ref still null at mount). Return null so the caller no-ops
|
|
27
|
+
// instead of hijacking the whole document.
|
|
28
|
+
// • root is an element → use it.
|
|
29
|
+
// The bindings (@ponchia/ui/{react,solid,qwik}) emit `root: null` for the
|
|
30
|
+
// not-ready case precisely so this distinction survives across the boundary.
|
|
31
|
+
export function resolveHost(root, fallback = document) {
|
|
32
|
+
if (root === null) return null;
|
|
33
|
+
return root || fallback;
|
|
34
|
+
}
|
|
35
|
+
|
|
9
36
|
// Monotonic counter for auto-minted field / list ids, shared across
|
|
10
37
|
// initFormValidation and initCombobox so separate calls (and separate
|
|
11
38
|
// behaviors) never collide on an id.
|
|
@@ -48,3 +75,52 @@ export function closestSafe(el, selector) {
|
|
|
48
75
|
return null;
|
|
49
76
|
}
|
|
50
77
|
}
|
|
78
|
+
|
|
79
|
+
// Collect the hosts an initializer should wire: the descendants matching
|
|
80
|
+
// `selector` PLUS `host` itself when it matches (querySelectorAll only sees
|
|
81
|
+
// descendants, so a `root` that *is* a target would otherwise be skipped).
|
|
82
|
+
// Self-first, null-safe — the shape ~9 delegated behaviors hand-rolled.
|
|
83
|
+
export function collectHosts(host, selector) {
|
|
84
|
+
const out = host !== document && host.matches?.(selector) ? [host] : [];
|
|
85
|
+
out.push(...(host.querySelectorAll?.(selector) ?? []));
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// scrollIntoView is a pure affordance and throws in jsdom/layout-less envs;
|
|
90
|
+
// never let that break a keyboard/roving handler. (combobox/command/carousel.)
|
|
91
|
+
export function scrollIntoViewSafe(el, opts = { block: 'nearest' }) {
|
|
92
|
+
try {
|
|
93
|
+
el?.scrollIntoView(opts);
|
|
94
|
+
} catch {
|
|
95
|
+
/* headless / no layout — the scroll is cosmetic */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The focusable-element selector + "move focus into a container" helper shared
|
|
100
|
+
// by the modal and popover focus paths (a dialog/modal must move focus into
|
|
101
|
+
// itself on open). Focus the first focusable descendant, else make the
|
|
102
|
+
// container programmatically focusable and focus it, so a content-only
|
|
103
|
+
// panel/modal still receives focus. (code-quality audit Q4.)
|
|
104
|
+
const FOCUSABLE =
|
|
105
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
106
|
+
|
|
107
|
+
export function focusInto(container) {
|
|
108
|
+
const first = container.querySelector(FOCUSABLE);
|
|
109
|
+
if (first) {
|
|
110
|
+
first.focus?.();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (!container.hasAttribute('tabindex')) container.setAttribute('tabindex', '-1');
|
|
114
|
+
container.focus?.();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Wrap an index by `delta` within [0, len), the roving keyboard math shared by
|
|
118
|
+
// the combobox and command listboxes (a -1 `cur` lands on the first/last as
|
|
119
|
+
// before). Only this core is shared — the surrounding setActive/filter/group
|
|
120
|
+
// logic diverges between the two for real reasons. (code-quality audit Q12.)
|
|
121
|
+
export function wrapIndex(cur, delta, len) {
|
|
122
|
+
let next = cur + delta;
|
|
123
|
+
if (next < 0) next = len - 1;
|
|
124
|
+
if (next >= len) next = 0;
|
|
125
|
+
return next;
|
|
126
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {object} LegendToggleDetail
|
|
3
|
+
* @property {string | number} series The entry's `data-series`, or its 0-based index when unset.
|
|
4
|
+
* @property {boolean} active The new state (`true` ⇒ series shown).
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Wire `[data-bronto-legend]` interactive legends. Each entry is a
|
|
8
|
+
* `.ui-legend__item` authored as a `<button aria-pressed>`; clicking it (or
|
|
9
|
+
* Enter/Space, native to `<button>`) flips `aria-pressed`, toggles the
|
|
10
|
+
* `.is-inactive` class, and dispatches `bronto:legend:toggle` on the legend
|
|
11
|
+
* with `{ detail: { series, active } }` (`series` is the entry's
|
|
12
|
+
* `data-series`, or its 0-based index if unset).
|
|
13
|
+
*
|
|
14
|
+
* Bronto owns only the control and its pressed/inactive *state*. It does not
|
|
15
|
+
* know the chart's series, hide anything, or announce the change: the host
|
|
16
|
+
* listens for the event, hides its own series, and owns any `aria-live`
|
|
17
|
+
* announcement. The convention is `aria-pressed="true"` ⇒ the series is shown
|
|
18
|
+
* (the default); the entry's label never changes on toggle (WAI-ARIA). SSR-safe
|
|
19
|
+
* and idempotent per host; returns a cleanup function.
|
|
20
|
+
*
|
|
21
|
+
* @param {import('./internal.js').DelegateOpts} [opts]
|
|
22
|
+
* @returns {import('./internal.js').Cleanup}
|
|
23
|
+
*/
|
|
24
|
+
export function initLegend({ root }?: import("./internal.js").DelegateOpts): import("./internal.js").Cleanup;
|
|
25
|
+
export type LegendToggleDetail = {
|
|
26
|
+
/**
|
|
27
|
+
* The entry's `data-series`, or its 0-based index when unset.
|
|
28
|
+
*/
|
|
29
|
+
series: string | number;
|
|
30
|
+
/**
|
|
31
|
+
* The new state (`true` ⇒ series shown).
|
|
32
|
+
*/
|
|
33
|
+
active: boolean;
|
|
34
|
+
};
|
|
35
|
+
//# sourceMappingURL=legend.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"legend.d.ts","sourceRoot":"","sources":["legend.js"],"names":[],"mappings":"AAEA;;;;GAIG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,sCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAmD3C;;;;;YAvEa,MAAM,GAAG,MAAM;;;;YACf,OAAO"}
|
package/behaviors/legend.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import { hasDom, noop, bindOnce } from './internal.js';
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce } from './internal.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {object} LegendToggleDetail
|
|
5
|
+
* @property {string | number} series The entry's `data-series`, or its 0-based index when unset.
|
|
6
|
+
* @property {boolean} active The new state (`true` ⇒ series shown).
|
|
7
|
+
*/
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* Wire `[data-bronto-legend]` interactive legends. Each entry is a
|
|
@@ -14,15 +20,24 @@ import { hasDom, noop, bindOnce } from './internal.js';
|
|
|
14
20
|
* announcement. The convention is `aria-pressed="true"` ⇒ the series is shown
|
|
15
21
|
* (the default); the entry's label never changes on toggle (WAI-ARIA). SSR-safe
|
|
16
22
|
* and idempotent per host; returns a cleanup function.
|
|
23
|
+
*
|
|
24
|
+
* @param {import('./internal.js').DelegateOpts} [opts]
|
|
25
|
+
* @returns {import('./internal.js').Cleanup}
|
|
17
26
|
*/
|
|
18
27
|
export function initLegend({ root } = {}) {
|
|
19
28
|
if (!hasDom()) return noop;
|
|
20
|
-
const host = root
|
|
29
|
+
const host = resolveHost(root);
|
|
30
|
+
if (!host) return noop;
|
|
31
|
+
const isButton = (el) => el.tagName === 'BUTTON' || el.getAttribute('role') === 'button';
|
|
21
32
|
const onClick = (e) => {
|
|
22
33
|
const item = e.target.closest('.ui-legend__item');
|
|
23
34
|
if (!item || !host.contains(item)) return;
|
|
24
35
|
const legend = item.closest('[data-bronto-legend]');
|
|
25
36
|
if (!legend || !host.contains(legend)) return;
|
|
37
|
+
// The contract requires a real `<button>` (keyboard-operable, focusable). A
|
|
38
|
+
// non-button item is mouse-only — refuse to toggle it rather than ship a
|
|
39
|
+
// pointer-only control (WCAG 2.1.1 — C11). The author is warned at bind.
|
|
40
|
+
if (!isButton(item)) return;
|
|
26
41
|
const active = item.getAttribute('aria-pressed') !== 'false';
|
|
27
42
|
const next = !active;
|
|
28
43
|
item.setAttribute('aria-pressed', String(next));
|
|
@@ -40,6 +55,21 @@ export function initLegend({ root } = {}) {
|
|
|
40
55
|
);
|
|
41
56
|
};
|
|
42
57
|
return bindOnce(host, 'legend', () => {
|
|
58
|
+
// Warn once per non-button item present at bind: it gets cursor:pointer from
|
|
59
|
+
// the CSS but is neither focusable nor keyboard-operable (C11).
|
|
60
|
+
if (typeof console !== 'undefined') {
|
|
61
|
+
for (const legend of host.querySelectorAll?.('[data-bronto-legend]') ?? []) {
|
|
62
|
+
const stray = [...legend.querySelectorAll('.ui-legend__item')].some(
|
|
63
|
+
(el) => el.closest('[data-bronto-legend]') === legend && !isButton(el),
|
|
64
|
+
);
|
|
65
|
+
if (stray) {
|
|
66
|
+
console.warn(
|
|
67
|
+
'[bronto] initLegend(): interactive legend entries must be <button> (or role="button") to be keyboard-operable — a non-button .ui-legend__item is ignored.',
|
|
68
|
+
);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
43
73
|
host.addEventListener('click', onClick);
|
|
44
74
|
return () => host.removeEventListener('click', onClick);
|
|
45
75
|
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dropdown-menu close affordances for a native `<details data-bronto-menu>`
|
|
3
|
+
* holding a `.ui-menu`. `<details>` alone won't close on Escape, on an
|
|
4
|
+
* outside click, or when a `.ui-menu__item` is activated — this adds
|
|
5
|
+
* exactly those, returning focus to the `<summary>` on Esc/activate.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately NOT a full WAI-ARIA menu (no arrow-key roving): the items
|
|
8
|
+
* are real buttons, Tab-reachable; this is a disclosure of actions, and
|
|
9
|
+
* over-claiming `role="menu"` semantics would be worse. SSR-safe,
|
|
10
|
+
* idempotent; returns a cleanup function.
|
|
11
|
+
*
|
|
12
|
+
* @param {import('./internal.js').DelegateOpts} [opts]
|
|
13
|
+
* @returns {import('./internal.js').Cleanup}
|
|
14
|
+
*/
|
|
15
|
+
export function initMenu({ root }?: import("./internal.js").DelegateOpts): import("./internal.js").Cleanup;
|
|
16
|
+
//# sourceMappingURL=menu.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"menu.d.ts","sourceRoot":"","sources":["menu.js"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;GAaG;AACH,oCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAmC3C"}
|
package/behaviors/menu.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hasDom, noop, bindOnce } from './internal.js';
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce } from './internal.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Dropdown-menu close affordances for a native `<details data-bronto-menu>`
|
|
@@ -10,10 +10,14 @@ import { hasDom, noop, bindOnce } from './internal.js';
|
|
|
10
10
|
* are real buttons, Tab-reachable; this is a disclosure of actions, and
|
|
11
11
|
* over-claiming `role="menu"` semantics would be worse. SSR-safe,
|
|
12
12
|
* idempotent; returns a cleanup function.
|
|
13
|
+
*
|
|
14
|
+
* @param {import('./internal.js').DelegateOpts} [opts]
|
|
15
|
+
* @returns {import('./internal.js').Cleanup}
|
|
13
16
|
*/
|
|
14
17
|
export function initMenu({ root } = {}) {
|
|
15
18
|
if (!hasDom()) return noop;
|
|
16
|
-
const host = root
|
|
19
|
+
const host = resolveHost(root);
|
|
20
|
+
if (!host) return noop;
|
|
17
21
|
const openMenus = () => host.querySelectorAll?.('[data-bronto-menu][open]') ?? [];
|
|
18
22
|
const shut = (menu) => {
|
|
19
23
|
if (!menu || !menu.open) return;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {object} ModalCloseDetail
|
|
3
|
+
* @property {'escape'} reason What asked the modal to close (currently only Escape).
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Focus management for the **controlled, non-`<dialog>` modal** — the
|
|
7
|
+
* `.ui-modal.is-open` path a portal/React overlay uses when it genuinely can't
|
|
8
|
+
* be a native `<dialog>`. The native `<dialog>` path gets a focus trap, Escape,
|
|
9
|
+
* and the top layer for free (use `initDialog`); this supplies the equivalent
|
|
10
|
+
* for the `.is-open` path, which otherwise leaves focus management to the
|
|
11
|
+
* consumer.
|
|
12
|
+
*
|
|
13
|
+
* Mark the overlay `[data-bronto-modal]` (opt-in). On bind it gives the modal a
|
|
14
|
+
* `role="dialog"` + `aria-modal="true"` (unless the author set a role) and
|
|
15
|
+
* dev-warns when it has no accessible name, so it announces as a named modal
|
|
16
|
+
* dialog — parity with `initPopover`. The behavior watches its
|
|
17
|
+
* `class` for `is-open`: on open it remembers the focused element, moves focus
|
|
18
|
+
* into the modal (first focusable, else the panel itself), and **traps focus by
|
|
19
|
+
* marking every sibling at each ancestor level `inert`** so the rest of the page
|
|
20
|
+
* is non-focusable and non-interactive — the modern, robust trap. On close it
|
|
21
|
+
* un-inerts exactly what it inerted and returns focus to the opener. Bronto owns
|
|
22
|
+
* focus only: the **consumer still owns open/close state** (the `is-open`
|
|
23
|
+
* class). Escape dispatches a cancelable `bronto:modal:close`
|
|
24
|
+
* ({@link ModalCloseDetail}) on the modal so the consumer can drop `is-open` in
|
|
25
|
+
* response; the behavior never changes visibility itself.
|
|
26
|
+
*
|
|
27
|
+
* Best suited to a body-/portal-level overlay (the documented `.is-open` use
|
|
28
|
+
* case); a deeply-nested modal still gets focus-into, focus-return, and the
|
|
29
|
+
* Escape signal. SSR-safe, idempotent per modal; returns a cleanup function.
|
|
30
|
+
*
|
|
31
|
+
* @param {import('./internal.js').DelegateOpts} [opts]
|
|
32
|
+
* @returns {import('./internal.js').Cleanup}
|
|
33
|
+
*/
|
|
34
|
+
export function initModal({ root }?: import("./internal.js").DelegateOpts): import("./internal.js").Cleanup;
|
|
35
|
+
export type ModalCloseDetail = {
|
|
36
|
+
/**
|
|
37
|
+
* What asked the modal to close (currently only Escape).
|
|
38
|
+
*/
|
|
39
|
+
reason: "escape";
|
|
40
|
+
};
|
|
41
|
+
//# sourceMappingURL=modal.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"modal.d.ts","sourceRoot":"","sources":["modal.js"],"names":[],"mappings":"AAEA;;;GAGG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,qCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAyF3C;;;;;YAvHa,QAAQ"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce, collectHosts, focusInto } from './internal.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {object} ModalCloseDetail
|
|
5
|
+
* @property {'escape'} reason What asked the modal to close (currently only Escape).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Focus management for the **controlled, non-`<dialog>` modal** — the
|
|
10
|
+
* `.ui-modal.is-open` path a portal/React overlay uses when it genuinely can't
|
|
11
|
+
* be a native `<dialog>`. The native `<dialog>` path gets a focus trap, Escape,
|
|
12
|
+
* and the top layer for free (use `initDialog`); this supplies the equivalent
|
|
13
|
+
* for the `.is-open` path, which otherwise leaves focus management to the
|
|
14
|
+
* consumer.
|
|
15
|
+
*
|
|
16
|
+
* Mark the overlay `[data-bronto-modal]` (opt-in). On bind it gives the modal a
|
|
17
|
+
* `role="dialog"` + `aria-modal="true"` (unless the author set a role) and
|
|
18
|
+
* dev-warns when it has no accessible name, so it announces as a named modal
|
|
19
|
+
* dialog — parity with `initPopover`. The behavior watches its
|
|
20
|
+
* `class` for `is-open`: on open it remembers the focused element, moves focus
|
|
21
|
+
* into the modal (first focusable, else the panel itself), and **traps focus by
|
|
22
|
+
* marking every sibling at each ancestor level `inert`** so the rest of the page
|
|
23
|
+
* is non-focusable and non-interactive — the modern, robust trap. On close it
|
|
24
|
+
* un-inerts exactly what it inerted and returns focus to the opener. Bronto owns
|
|
25
|
+
* focus only: the **consumer still owns open/close state** (the `is-open`
|
|
26
|
+
* class). Escape dispatches a cancelable `bronto:modal:close`
|
|
27
|
+
* ({@link ModalCloseDetail}) on the modal so the consumer can drop `is-open` in
|
|
28
|
+
* response; the behavior never changes visibility itself.
|
|
29
|
+
*
|
|
30
|
+
* Best suited to a body-/portal-level overlay (the documented `.is-open` use
|
|
31
|
+
* case); a deeply-nested modal still gets focus-into, focus-return, and the
|
|
32
|
+
* Escape signal. SSR-safe, idempotent per modal; returns a cleanup function.
|
|
33
|
+
*
|
|
34
|
+
* @param {import('./internal.js').DelegateOpts} [opts]
|
|
35
|
+
* @returns {import('./internal.js').Cleanup}
|
|
36
|
+
*/
|
|
37
|
+
export function initModal({ root } = {}) {
|
|
38
|
+
if (!hasDom()) return noop;
|
|
39
|
+
const host = resolveHost(root);
|
|
40
|
+
if (!host) return noop;
|
|
41
|
+
const modals = collectHosts(host, '[data-bronto-modal]');
|
|
42
|
+
const cleanups = [];
|
|
43
|
+
|
|
44
|
+
for (const modal of modals) {
|
|
45
|
+
let opener = null;
|
|
46
|
+
let inerted = [];
|
|
47
|
+
|
|
48
|
+
// A controlled modal must announce AS a modal dialog, not a generic group —
|
|
49
|
+
// parity with initPopover. Apply a dialog role + aria-modal (unless the
|
|
50
|
+
// author set a role), and dev-warn on a missing accessible name since we
|
|
51
|
+
// can't invent a good one. (component audit C13.)
|
|
52
|
+
if (!modal.hasAttribute('role')) modal.setAttribute('role', 'dialog');
|
|
53
|
+
if (!modal.hasAttribute('aria-modal')) modal.setAttribute('aria-modal', 'true');
|
|
54
|
+
const named =
|
|
55
|
+
modal.hasAttribute('aria-label') ||
|
|
56
|
+
modal.hasAttribute('aria-labelledby') ||
|
|
57
|
+
modal.hasAttribute('title');
|
|
58
|
+
if (!named && typeof console !== 'undefined') {
|
|
59
|
+
console.warn(
|
|
60
|
+
`[bronto] initModal(): a [data-bronto-modal] has no accessible name — add aria-label or aria-labelledby so it is announced as a named dialog.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Inert every sibling at each ancestor level up to <body>: the rest of the
|
|
65
|
+
// page becomes non-focusable/non-interactive while the modal subtree stays
|
|
66
|
+
// live. Skip already-inert nodes so release() can't un-inert something the
|
|
67
|
+
// app inerted for its own reasons.
|
|
68
|
+
const trap = () => {
|
|
69
|
+
if (opener) return; // already trapped
|
|
70
|
+
opener = document.activeElement;
|
|
71
|
+
let el = modal;
|
|
72
|
+
while (el && el.parentElement && el !== document.body) {
|
|
73
|
+
for (const sib of el.parentElement.children) {
|
|
74
|
+
if (sib !== el && !sib.inert) {
|
|
75
|
+
sib.inert = true;
|
|
76
|
+
inerted.push(sib);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
el = el.parentElement;
|
|
80
|
+
}
|
|
81
|
+
focusInto(modal);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const release = () => {
|
|
85
|
+
if (!opener) return;
|
|
86
|
+
for (const el of inerted) el.inert = false;
|
|
87
|
+
inerted = [];
|
|
88
|
+
const back = opener;
|
|
89
|
+
opener = null;
|
|
90
|
+
if (back?.isConnected && typeof back.focus === 'function') back.focus();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const sync = () => (modal.classList.contains('is-open') ? trap() : release());
|
|
94
|
+
|
|
95
|
+
const onKey = (e) => {
|
|
96
|
+
if (e.key === 'Escape' && opener) {
|
|
97
|
+
modal.dispatchEvent(
|
|
98
|
+
new CustomEvent('bronto:modal:close', {
|
|
99
|
+
detail: { reason: 'escape' },
|
|
100
|
+
bubbles: true,
|
|
101
|
+
cancelable: true,
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const observer = typeof MutationObserver === 'function' ? new MutationObserver(sync) : null;
|
|
108
|
+
|
|
109
|
+
cleanups.push(
|
|
110
|
+
bindOnce(modal, 'modal', () => {
|
|
111
|
+
observer?.observe(modal, { attributes: true, attributeFilter: ['class'] });
|
|
112
|
+
document.addEventListener('keydown', onKey, true);
|
|
113
|
+
if (modal.classList.contains('is-open')) trap(); // already open at init
|
|
114
|
+
return () => {
|
|
115
|
+
observer?.disconnect();
|
|
116
|
+
document.removeEventListener('keydown', onKey, true);
|
|
117
|
+
release();
|
|
118
|
+
};
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return () => cleanups.forEach((fn) => fn());
|
|
124
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collision-aware popover, dependency-free. A `[data-bronto-popover]`
|
|
3
|
+
* trigger toggles the `.ui-popover` panel whose id it names. The panel
|
|
4
|
+
* is placed under the trigger and **flips above** when it would
|
|
5
|
+
* overflow the viewport, with its inline edge clamped on-screen — the
|
|
6
|
+
* thing the CSS-only tooltip can't do near edges / inside scroll
|
|
7
|
+
* containers. If the panel has the native `popover` attribute and the
|
|
8
|
+
* Popover API is available it is shown in the top layer (never
|
|
9
|
+
* clipped); otherwise an `.is-open` class is toggled. Manages
|
|
10
|
+
* `aria-expanded` / `aria-controls`, closes on Escape and outside
|
|
11
|
+
* click, and re-positions on scroll/resize while open. SSR-safe,
|
|
12
|
+
* idempotent; returns a cleanup function.
|
|
13
|
+
*
|
|
14
|
+
* The trigger advertises `aria-haspopup="dialog"`, so on open the panel is
|
|
15
|
+
* given `role="dialog"` (unless the author set a role) and focus is moved into
|
|
16
|
+
* it — the first focusable descendant, or the panel itself. It is a *non-modal*
|
|
17
|
+
* dialog: the rest of the page stays interactive and there is no focus trap.
|
|
18
|
+
* Author an accessible name on the panel (`aria-label` / `aria-labelledby`); a
|
|
19
|
+
* dev-time `console.warn` fires when it is missing.
|
|
20
|
+
*
|
|
21
|
+
* Escape returns focus to the trigger; closing via outside-click leaves focus
|
|
22
|
+
* where the click landed (treated as deliberate intent to move on).
|
|
23
|
+
*
|
|
24
|
+
* @param {import('./internal.js').DelegateOpts} [opts]
|
|
25
|
+
* @returns {import('./internal.js').Cleanup}
|
|
26
|
+
*/
|
|
27
|
+
export function initPopover({ root }?: import("./internal.js").DelegateOpts): import("./internal.js").Cleanup;
|
|
28
|
+
//# sourceMappingURL=popover.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"popover.d.ts","sourceRoot":"","sources":["popover.js"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,uCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAwJ3C"}
|
package/behaviors/popover.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hasDom, noop, bindOnce, byIdInHost } from './internal.js';
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce, byIdInHost, focusInto } from './internal.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Collision-aware popover, dependency-free. A `[data-bronto-popover]`
|
|
@@ -12,15 +12,33 @@ import { hasDom, noop, bindOnce, byIdInHost } from './internal.js';
|
|
|
12
12
|
* `aria-expanded` / `aria-controls`, closes on Escape and outside
|
|
13
13
|
* click, and re-positions on scroll/resize while open. SSR-safe,
|
|
14
14
|
* idempotent; returns a cleanup function.
|
|
15
|
+
*
|
|
16
|
+
* The trigger advertises `aria-haspopup="dialog"`, so on open the panel is
|
|
17
|
+
* given `role="dialog"` (unless the author set a role) and focus is moved into
|
|
18
|
+
* it — the first focusable descendant, or the panel itself. It is a *non-modal*
|
|
19
|
+
* dialog: the rest of the page stays interactive and there is no focus trap.
|
|
20
|
+
* Author an accessible name on the panel (`aria-label` / `aria-labelledby`); a
|
|
21
|
+
* dev-time `console.warn` fires when it is missing.
|
|
22
|
+
*
|
|
23
|
+
* Escape returns focus to the trigger; closing via outside-click leaves focus
|
|
24
|
+
* where the click landed (treated as deliberate intent to move on).
|
|
25
|
+
*
|
|
26
|
+
* @param {import('./internal.js').DelegateOpts} [opts]
|
|
27
|
+
* @returns {import('./internal.js').Cleanup}
|
|
15
28
|
*/
|
|
16
29
|
export function initPopover({ root } = {}) {
|
|
17
30
|
if (!hasDom()) return noop;
|
|
18
|
-
const host = root
|
|
31
|
+
const host = resolveHost(root);
|
|
32
|
+
if (!host) return noop;
|
|
19
33
|
const view = document.defaultView;
|
|
20
34
|
const GAP = 8;
|
|
21
35
|
let openPanel = null;
|
|
22
36
|
let openTrigger = null;
|
|
23
37
|
|
|
38
|
+
// The trigger advertises `aria-haspopup="dialog"`, so the open panel must BE a
|
|
39
|
+
// dialog: a role, an accessible name, and focus moved into it (C6) — see the
|
|
40
|
+
// shared `focusInto` in internal.js.
|
|
41
|
+
|
|
24
42
|
const place = (trigger, panel) => {
|
|
25
43
|
const r = trigger.getBoundingClientRect();
|
|
26
44
|
const pw = panel.offsetWidth;
|
|
@@ -39,6 +57,10 @@ export function initPopover({ root } = {}) {
|
|
|
39
57
|
if (!openPanel) return;
|
|
40
58
|
const panel = openPanel;
|
|
41
59
|
const trigger = openTrigger;
|
|
60
|
+
// Only steal focus back to the trigger when focus is still inside the panel
|
|
61
|
+
// (Escape / programmatic re-toggle). An outside-click leaves focus where the
|
|
62
|
+
// click landed — deliberate intent to move on, per the doc contract.
|
|
63
|
+
const focusWasInside = panel.contains(document.activeElement);
|
|
42
64
|
openPanel = openTrigger = null;
|
|
43
65
|
if (panel.hasAttribute('popover') && typeof panel.hidePopover === 'function') {
|
|
44
66
|
try {
|
|
@@ -50,10 +72,15 @@ export function initPopover({ root } = {}) {
|
|
|
50
72
|
panel.classList.remove('is-open');
|
|
51
73
|
}
|
|
52
74
|
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
|
75
|
+
if (focusWasInside && trigger?.isConnected) trigger.focus?.();
|
|
53
76
|
};
|
|
54
77
|
|
|
55
78
|
const open = (trigger, panel) => {
|
|
56
79
|
close();
|
|
80
|
+
// Live up to the advertised `aria-haspopup="dialog"`: give the panel a
|
|
81
|
+
// dialog role (unless the author set one) so AT announces it as the promised
|
|
82
|
+
// dialog rather than a generic group (C6).
|
|
83
|
+
if (!panel.hasAttribute('role')) panel.setAttribute('role', 'dialog');
|
|
57
84
|
trigger.setAttribute('aria-controls', panel.id);
|
|
58
85
|
trigger.setAttribute('aria-expanded', 'true');
|
|
59
86
|
if (panel.hasAttribute('popover') && typeof panel.showPopover === 'function') {
|
|
@@ -68,6 +95,7 @@ export function initPopover({ root } = {}) {
|
|
|
68
95
|
openPanel = panel;
|
|
69
96
|
openTrigger = trigger;
|
|
70
97
|
place(trigger, panel);
|
|
98
|
+
focusInto(panel);
|
|
71
99
|
};
|
|
72
100
|
|
|
73
101
|
const onClick = (e) => {
|
|
@@ -83,22 +111,65 @@ export function initPopover({ root } = {}) {
|
|
|
83
111
|
if (openPanel && !openPanel.contains(e.target)) close();
|
|
84
112
|
};
|
|
85
113
|
const onKey = (e) => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
114
|
+
// close() returns focus to the trigger because focus is inside the panel.
|
|
115
|
+
if (e.key !== 'Escape' || !openPanel) return;
|
|
116
|
+
// A popover open *inside* a <dialog>/modal owns this Escape. Without this,
|
|
117
|
+
// the same keypress closed BOTH: we hidePopover() the panel synchronously,
|
|
118
|
+
// the browser's close-request then finds the dialog as the new topmost
|
|
119
|
+
// element and dismisses it too. preventDefault() stops that native
|
|
120
|
+
// close-request and stopPropagation() keeps it off other delegated keydown
|
|
121
|
+
// listeners (e.g. initModal's), so only the popover closes — the documented
|
|
122
|
+
// "popover + dialog open together" contract.
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
e.stopPropagation();
|
|
125
|
+
close();
|
|
91
126
|
};
|
|
92
127
|
const onReflow = () => {
|
|
93
128
|
if (openPanel && openTrigger) place(openTrigger, openPanel);
|
|
94
129
|
};
|
|
95
130
|
|
|
131
|
+
// Seed resting ARIA on every trigger and keep it in sync when the UA itself
|
|
132
|
+
// toggles a native popover (light-dismiss / Escape on the `popover` attribute
|
|
133
|
+
// never routes through close(), so aria-expanded would otherwise go stale).
|
|
134
|
+
const seedTeardowns = [];
|
|
135
|
+
const seed = () => {
|
|
136
|
+
for (const trigger of host.querySelectorAll('[data-bronto-popover]')) {
|
|
137
|
+
const panel = byIdInHost(host, trigger.getAttribute('data-bronto-popover'));
|
|
138
|
+
if (!panel) continue;
|
|
139
|
+
if (!trigger.hasAttribute('aria-haspopup')) trigger.setAttribute('aria-haspopup', 'dialog');
|
|
140
|
+
trigger.setAttribute('aria-controls', panel.id);
|
|
141
|
+
if (!trigger.hasAttribute('aria-expanded')) trigger.setAttribute('aria-expanded', 'false');
|
|
142
|
+
// A dialog with no accessible name is announced as just "dialog". We can't
|
|
143
|
+
// invent a good name, so warn the author at dev time (C6).
|
|
144
|
+
const named =
|
|
145
|
+
panel.hasAttribute('aria-label') ||
|
|
146
|
+
panel.hasAttribute('aria-labelledby') ||
|
|
147
|
+
panel.hasAttribute('title');
|
|
148
|
+
if (!named && typeof console !== 'undefined') {
|
|
149
|
+
console.warn(
|
|
150
|
+
`[bronto] initPopover(): panel #${panel.id} has no accessible name — add aria-label or aria-labelledby so it is announced as a named dialog.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (panel.hasAttribute('popover')) {
|
|
154
|
+
const onToggle = (e) => {
|
|
155
|
+
const isOpen = e.newState === 'open';
|
|
156
|
+
trigger.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
|
157
|
+
if (!isOpen && openPanel === panel) openPanel = openTrigger = null;
|
|
158
|
+
};
|
|
159
|
+
panel.addEventListener('toggle', onToggle);
|
|
160
|
+
seedTeardowns.push(() => panel.removeEventListener('toggle', onToggle));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
96
165
|
return bindOnce(host, 'popover', () => {
|
|
166
|
+
seed();
|
|
97
167
|
document.addEventListener('click', onClick);
|
|
98
168
|
document.addEventListener('keydown', onKey);
|
|
99
169
|
view?.addEventListener('scroll', onReflow, true);
|
|
100
170
|
view?.addEventListener('resize', onReflow);
|
|
101
171
|
return () => {
|
|
172
|
+
for (const t of seedTeardowns.splice(0)) t();
|
|
102
173
|
document.removeEventListener('click', onClick);
|
|
103
174
|
document.removeEventListener('keydown', onKey);
|
|
104
175
|
view?.removeEventListener('scroll', onReflow, true);
|