@ponchia/ui 0.5.0 → 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 +322 -0
- package/MIGRATIONS.json +14 -0
- package/README.md +28 -5
- package/annotations/index.d.ts +398 -276
- package/annotations/index.d.ts.map +1 -0
- package/annotations/index.js +315 -45
- package/behaviors/carousel.js +17 -16
- package/behaviors/combobox.js +47 -16
- package/behaviors/command.js +18 -15
- package/behaviors/connectors.js +4 -5
- package/behaviors/crosshair.js +4 -5
- package/behaviors/dialog.js +3 -2
- package/behaviors/disclosure.js +3 -2
- package/behaviors/dismissible.js +3 -2
- package/behaviors/forms.js +41 -13
- package/behaviors/glyph.js +4 -5
- package/behaviors/internal.js +47 -0
- package/behaviors/legend.js +23 -2
- package/behaviors/menu.js +3 -2
- package/behaviors/popover.js +78 -7
- package/behaviors/spotlight.js +4 -5
- package/behaviors/table.js +39 -12
- package/behaviors/tabs.js +14 -14
- package/behaviors/theme.js +5 -3
- package/behaviors/toast.js +13 -1
- package/classes/classes.json +1857 -0
- package/classes/index.d.ts +28 -13
- package/classes/index.js +34 -18
- package/classes/vscode.css-custom-data.json +12 -0
- package/connectors/index.d.ts +189 -69
- package/connectors/index.d.ts.map +1 -0
- package/connectors/index.js +120 -24
- package/css/app.css +43 -13
- package/css/base.css +15 -10
- package/css/connectors.css +17 -0
- package/css/content.css +7 -1
- package/css/dataviz.css +5 -1
- package/css/disclosure.css +38 -6
- package/css/dots.css +57 -0
- package/css/feedback.css +60 -2
- package/css/forms.css +42 -1
- package/css/legend.css +11 -7
- package/css/marks.css +38 -8
- package/css/motion.css +24 -44
- package/css/navigation.css +7 -0
- package/css/overlay.css +31 -1
- package/css/primitives.css +91 -5
- package/css/report.css +40 -63
- package/css/site.css +16 -2
- package/css/sources.css +43 -1
- package/css/spotlight.css +1 -1
- package/css/tokens.css +36 -1
- 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/connectors.css +1 -1
- package/dist/css/content.css +1 -1
- 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/site.css +1 -1
- package/dist/css/sources.css +1 -1
- package/dist/css/spotlight.css +1 -1
- package/dist/css/tokens.css +1 -1
- package/dist/css/workbench.css +1 -1
- package/docs/adr/0003-theme-model.md +1 -1
- package/docs/annotations.md +94 -14
- package/docs/architecture.md +50 -6
- package/docs/contrast.md +116 -92
- package/docs/d2.md +195 -0
- package/docs/legends.md +18 -2
- package/docs/marks.md +9 -2
- package/docs/mermaid.md +152 -0
- package/docs/reference.md +78 -22
- package/docs/reporting.md +395 -57
- package/docs/sources.md +27 -0
- package/docs/stability.md +9 -2
- package/docs/usage.md +101 -4
- package/docs/vega.md +225 -0
- package/docs/workbench.md +7 -1
- package/glyphs/glyphs.js +6 -4
- package/llms.txt +139 -14
- package/package.json +50 -12
- package/qwik/index.d.ts +42 -59
- package/qwik/index.d.ts.map +1 -0
- package/qwik/index.js +55 -3
- package/react/index.d.ts +39 -61
- package/react/index.d.ts.map +1 -0
- package/react/index.js +57 -3
- package/solid/index.d.ts +64 -61
- package/solid/index.d.ts.map +1 -0
- package/solid/index.js +60 -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
package/behaviors/command.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
hasDom,
|
|
3
|
+
resolveHost,
|
|
4
|
+
noop,
|
|
5
|
+
bindOnce,
|
|
6
|
+
nextFieldUid,
|
|
7
|
+
collectHosts,
|
|
8
|
+
scrollIntoViewSafe,
|
|
9
|
+
wrapIndex,
|
|
10
|
+
} from './internal.js';
|
|
2
11
|
|
|
3
12
|
/**
|
|
4
13
|
* Command palette — filter + keyboard-navigate a DOM-authored command list.
|
|
@@ -17,13 +26,15 @@ import { hasDom, noop, bindOnce, nextFieldUid } from './internal.js';
|
|
|
17
26
|
* select. It emits `bronto:command:select` ({ detail: { value, label } }) on
|
|
18
27
|
* choose and `bronto:command:close` on Escape. SSR-safe, idempotent per
|
|
19
28
|
* instance; returns a cleanup function.
|
|
29
|
+
*
|
|
30
|
+
* Items are read from the DOM at init; re-run initCommand after replacing the
|
|
31
|
+
* command list so filtering/navigation see the current nodes.
|
|
20
32
|
*/
|
|
21
33
|
export function initCommand({ root } = {}) {
|
|
22
34
|
if (!hasDom()) return noop;
|
|
23
|
-
const host = root
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
palettes.push(...(host.querySelectorAll?.('[data-bronto-command]') ?? []));
|
|
35
|
+
const host = resolveHost(root);
|
|
36
|
+
if (!host) return noop;
|
|
37
|
+
const palettes = collectHosts(host, '[data-bronto-command]');
|
|
27
38
|
const cleanups = [];
|
|
28
39
|
|
|
29
40
|
for (const box of palettes) {
|
|
@@ -58,11 +69,7 @@ export function initCommand({ root } = {}) {
|
|
|
58
69
|
if (item) {
|
|
59
70
|
active = items.indexOf(item);
|
|
60
71
|
input.setAttribute('aria-activedescendant', item.id);
|
|
61
|
-
|
|
62
|
-
item.scrollIntoView({ block: 'nearest' });
|
|
63
|
-
} catch {
|
|
64
|
-
/* headless — scrollIntoView is a pure affordance */
|
|
65
|
-
}
|
|
72
|
+
scrollIntoViewSafe(item);
|
|
66
73
|
} else {
|
|
67
74
|
active = -1;
|
|
68
75
|
input.removeAttribute('aria-activedescendant');
|
|
@@ -101,11 +108,7 @@ export function initCommand({ root } = {}) {
|
|
|
101
108
|
const move = (delta) => {
|
|
102
109
|
const vis = visible();
|
|
103
110
|
if (!vis.length) return;
|
|
104
|
-
|
|
105
|
-
let next = cur + delta;
|
|
106
|
-
if (next < 0) next = vis.length - 1;
|
|
107
|
-
if (next >= vis.length) next = 0;
|
|
108
|
-
setActive(vis[next]);
|
|
111
|
+
setActive(vis[wrapIndex(vis.indexOf(items[active]), delta, vis.length)]);
|
|
109
112
|
};
|
|
110
113
|
|
|
111
114
|
const choose = (item) => {
|
package/behaviors/connectors.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hasDom, noop, bindOnce, byIdInHost } from './internal.js';
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce, byIdInHost, collectHosts } from './internal.js';
|
|
2
2
|
import { connectRects, arrowHead, dotMark } from '../connectors/index.js';
|
|
3
3
|
|
|
4
4
|
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
@@ -17,10 +17,9 @@ const SVGNS = 'http://www.w3.org/2000/svg';
|
|
|
17
17
|
*/
|
|
18
18
|
export function initConnectors({ root } = {}) {
|
|
19
19
|
if (!hasDom()) return noop;
|
|
20
|
-
const host = root
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
connectors.push(...host.querySelectorAll('[data-bronto-connector]'));
|
|
20
|
+
const host = resolveHost(root);
|
|
21
|
+
if (!host) return noop;
|
|
22
|
+
const connectors = collectHosts(host, '[data-bronto-connector]');
|
|
24
23
|
if (!connectors.length) return noop;
|
|
25
24
|
|
|
26
25
|
const draw = () => {
|
package/behaviors/crosshair.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hasDom, noop, bindOnce } from './internal.js';
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce, collectHosts } from './internal.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Track the pointer over a plot and drive a crosshair. Each
|
|
@@ -14,10 +14,9 @@ import { hasDom, noop, bindOnce } from './internal.js';
|
|
|
14
14
|
*/
|
|
15
15
|
export function initCrosshair({ root } = {}) {
|
|
16
16
|
if (!hasDom()) return noop;
|
|
17
|
-
const host = root
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
plots.push(...host.querySelectorAll('[data-bronto-crosshair]'));
|
|
17
|
+
const host = resolveHost(root);
|
|
18
|
+
if (!host) return noop;
|
|
19
|
+
const plots = collectHosts(host, '[data-bronto-crosshair]');
|
|
21
20
|
if (!plots.length) return noop;
|
|
22
21
|
|
|
23
22
|
const cleanups = [];
|
package/behaviors/dialog.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hasDom, noop, bindOnce, byIdInHost } from './internal.js';
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce, byIdInHost } from './internal.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Wire native <dialog> open/close glue (the one bit <dialog> can't do
|
|
@@ -17,7 +17,8 @@ import { hasDom, noop, bindOnce, byIdInHost } from './internal.js';
|
|
|
17
17
|
*/
|
|
18
18
|
export function initDialog({ root } = {}) {
|
|
19
19
|
if (!hasDom()) return noop;
|
|
20
|
-
const host = root
|
|
20
|
+
const host = resolveHost(root);
|
|
21
|
+
if (!host) return noop;
|
|
21
22
|
const managedDialogs = new WeakSet();
|
|
22
23
|
const canManageDialog = (dlg, origin) => host.contains(origin) || managedDialogs.has(dlg);
|
|
23
24
|
|
package/behaviors/disclosure.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hasDom, noop, bindOnce, byIdInHost } from './internal.js';
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce, byIdInHost } from './internal.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Disclosure: a `[data-bronto-disclosure]` trigger toggles the element
|
|
@@ -7,7 +7,8 @@ import { hasDom, noop, bindOnce, byIdInHost } from './internal.js';
|
|
|
7
7
|
*/
|
|
8
8
|
export function initDisclosure({ root } = {}) {
|
|
9
9
|
if (!hasDom()) return noop;
|
|
10
|
-
const host = root
|
|
10
|
+
const host = resolveHost(root);
|
|
11
|
+
if (!host) return noop;
|
|
11
12
|
const onClick = (e) => {
|
|
12
13
|
const trigger = e.target.closest('[data-bronto-disclosure]');
|
|
13
14
|
if (!trigger || !host.contains(trigger)) return;
|
package/behaviors/dismissible.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hasDom, noop, bindOnce, closestSafe } from './internal.js';
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce, closestSafe } from './internal.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Click on `[data-bronto-dismiss]` removes the nearest ancestor matching
|
|
@@ -7,7 +7,8 @@ import { hasDom, noop, bindOnce, closestSafe } from './internal.js';
|
|
|
7
7
|
*/
|
|
8
8
|
export function dismissible({ root } = {}) {
|
|
9
9
|
if (!hasDom()) return noop;
|
|
10
|
-
const host = root
|
|
10
|
+
const host = resolveHost(root);
|
|
11
|
+
if (!host) return noop;
|
|
11
12
|
const onClick = (e) => {
|
|
12
13
|
const btn = e.target.closest('[data-bronto-dismiss]');
|
|
13
14
|
if (!btn || !host.contains(btn)) return;
|
package/behaviors/forms.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hasDom, noop, bindOnce, nextFieldUid } from './internal.js';
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce, nextFieldUid, collectHosts } from './internal.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Accessible form validation glue for `<form data-bronto-validate>`.
|
|
@@ -11,7 +11,10 @@ import { hasDom, noop, bindOnce, nextFieldUid } from './internal.js';
|
|
|
11
11
|
* - on blur and on submit sets `aria-invalid` and writes the browser's
|
|
12
12
|
* `validationMessage` into the field's error slot
|
|
13
13
|
* (`[data-bronto-error]` inside the `.ui-field`, falling back to a
|
|
14
|
-
* `.ui-hint`), linked via `aria-describedby
|
|
14
|
+
* `.ui-hint`), linked via `aria-describedby`. When it falls back to a
|
|
15
|
+
* `.ui-hint` the original help text is snapshotted and restored once the
|
|
16
|
+
* field is valid again (so the error does not eat the help permanently); a
|
|
17
|
+
* dedicated empty `[data-bronto-error]` node is still the recommended slot,
|
|
15
18
|
* - on an invalid submit, fills the form's
|
|
16
19
|
* `[data-bronto-error-summary]` (a `.ui-error-summary`) with
|
|
17
20
|
* in-page links to each bad field, focuses it, and blocks submit.
|
|
@@ -21,17 +24,26 @@ import { hasDom, noop, bindOnce, nextFieldUid } from './internal.js';
|
|
|
21
24
|
*/
|
|
22
25
|
export function initFormValidation({ root } = {}) {
|
|
23
26
|
if (!hasDom()) return noop;
|
|
24
|
-
const host = root
|
|
27
|
+
const host = resolveHost(root);
|
|
28
|
+
if (!host) return noop;
|
|
25
29
|
|
|
26
30
|
const ensureId = (el, prefix) => {
|
|
27
31
|
if (!el.id) el.id = `${prefix}-${nextFieldUid()}`;
|
|
28
32
|
return el.id;
|
|
29
33
|
};
|
|
30
34
|
|
|
35
|
+
// When the field has no dedicated `[data-bronto-error]` node we fall back to
|
|
36
|
+
// the shared `.ui-hint` help slot. Snapshot its original help text the first
|
|
37
|
+
// time we overwrite it with an error, so the valid branch can RESTORE the help
|
|
38
|
+
// rather than blanking it permanently (component-audit C8).
|
|
39
|
+
const hintHelp = new WeakMap();
|
|
40
|
+
|
|
31
41
|
const slotFor = (control) => {
|
|
32
42
|
const field = control.closest('.ui-field');
|
|
33
43
|
if (!field) return null;
|
|
34
|
-
|
|
44
|
+
const dedicated = field.querySelector('[data-bronto-error]');
|
|
45
|
+
if (dedicated) return dedicated;
|
|
46
|
+
return field.querySelector('.ui-hint');
|
|
35
47
|
};
|
|
36
48
|
|
|
37
49
|
const link = (control, slot) => {
|
|
@@ -43,21 +55,42 @@ export function initFormValidation({ root } = {}) {
|
|
|
43
55
|
}
|
|
44
56
|
};
|
|
45
57
|
|
|
58
|
+
const unlink = (control, slot) => {
|
|
59
|
+
if (!slot.id) return;
|
|
60
|
+
const ids = (control.getAttribute('aria-describedby') || '')
|
|
61
|
+
.split(/\s+/)
|
|
62
|
+
.filter((id) => id && id !== slot.id);
|
|
63
|
+
if (ids.length) control.setAttribute('aria-describedby', ids.join(' '));
|
|
64
|
+
else control.removeAttribute('aria-describedby');
|
|
65
|
+
};
|
|
66
|
+
|
|
46
67
|
const validateField = (control) => {
|
|
47
68
|
if (!control.willValidate) return true;
|
|
48
69
|
const ok = control.validity.valid;
|
|
49
70
|
const slot = slotFor(control);
|
|
71
|
+
const isHint = slot?.classList.contains('ui-hint');
|
|
50
72
|
if (ok) {
|
|
51
73
|
control.removeAttribute('aria-invalid');
|
|
52
74
|
if (slot) {
|
|
53
|
-
|
|
54
|
-
|
|
75
|
+
if (isHint) {
|
|
76
|
+
// Restore the snapshotted help text (or clear if there was none); a
|
|
77
|
+
// help-bearing hint stays linked via aria-describedby (it describes
|
|
78
|
+
// the field in the valid state too).
|
|
79
|
+
slot.textContent = hintHelp.get(slot) ?? '';
|
|
80
|
+
slot.classList.remove('ui-hint--error');
|
|
81
|
+
} else {
|
|
82
|
+
// Dedicated error node: clear it and drop the now-stale describedby
|
|
83
|
+
// so AT doesn't announce an empty error association.
|
|
84
|
+
slot.textContent = '';
|
|
85
|
+
unlink(control, slot);
|
|
86
|
+
}
|
|
55
87
|
}
|
|
56
88
|
} else {
|
|
57
89
|
control.setAttribute('aria-invalid', 'true');
|
|
58
90
|
if (slot) {
|
|
91
|
+
if (isHint && !hintHelp.has(slot)) hintHelp.set(slot, slot.textContent);
|
|
59
92
|
slot.textContent = control.validationMessage;
|
|
60
|
-
if (
|
|
93
|
+
if (isHint) slot.classList.add('ui-hint--error');
|
|
61
94
|
link(control, slot);
|
|
62
95
|
}
|
|
63
96
|
}
|
|
@@ -136,12 +169,7 @@ export function initFormValidation({ root } = {}) {
|
|
|
136
169
|
// otherwise show the native UA bubble instead of the Bronto
|
|
137
170
|
// summary — contradicting the documented contract. (Forms added
|
|
138
171
|
// after init are still covered by the in-handler set.)
|
|
139
|
-
|
|
140
|
-
// a guaranteed global (SSR / the no-DOM test env), and `host` is
|
|
141
|
-
// either `document` (no `.matches`) or a root Element.
|
|
142
|
-
const selfForm =
|
|
143
|
-
typeof host.matches === 'function' && host.matches('[data-bronto-validate]') ? [host] : [];
|
|
144
|
-
const forms = [...selfForm, ...(host.querySelectorAll?.('[data-bronto-validate]') ?? [])];
|
|
172
|
+
const forms = collectHosts(host, '[data-bronto-validate]');
|
|
145
173
|
const priorNoValidate = new Map();
|
|
146
174
|
for (const f of forms) {
|
|
147
175
|
priorNoValidate.set(f, f.noValidate);
|
package/behaviors/glyph.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hasDom, noop } from './internal.js';
|
|
1
|
+
import { hasDom, resolveHost, noop, collectHosts } from './internal.js';
|
|
2
2
|
import { GLYPH_SIZE, glyphCells } from '../glyphs/glyphs.js';
|
|
3
3
|
|
|
4
4
|
function restoreAttr(el, name, prev) {
|
|
@@ -17,10 +17,9 @@ function restoreAttr(el, name, prev) {
|
|
|
17
17
|
*/
|
|
18
18
|
export function initDotGlyph({ root } = {}) {
|
|
19
19
|
if (!hasDom()) return noop;
|
|
20
|
-
const host = root
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
els.push(...(host.querySelectorAll?.('[data-bronto-glyph]') ?? []));
|
|
20
|
+
const host = resolveHost(root);
|
|
21
|
+
if (!host) return noop;
|
|
22
|
+
const els = collectHosts(host, '[data-bronto-glyph]');
|
|
24
23
|
const cleanups = [];
|
|
25
24
|
|
|
26
25
|
for (const el of els) {
|
package/behaviors/internal.js
CHANGED
|
@@ -6,6 +6,22 @@ export const noop = () => {};
|
|
|
6
6
|
|
|
7
7
|
export const hasDom = () => typeof document !== 'undefined';
|
|
8
8
|
|
|
9
|
+
// Resolve the delegation host from an init call's `root` option, distinguishing
|
|
10
|
+
// three cases so an unattached/null root never silently widens to whole-document
|
|
11
|
+
// delegation (the "scoped island hijacks every control" foot-gun):
|
|
12
|
+
// • root absent/undefined → no scope requested → delegate from `fallback`
|
|
13
|
+
// (default `document`). This is the intended global-wiring path.
|
|
14
|
+
// • root === null → a scope WAS requested but isn't ready yet (e.g. a
|
|
15
|
+
// framework ref still null at mount). Return null so the caller no-ops
|
|
16
|
+
// instead of hijacking the whole document.
|
|
17
|
+
// • root is an element → use it.
|
|
18
|
+
// The bindings (@ponchia/ui/{react,solid,qwik}) emit `root: null` for the
|
|
19
|
+
// not-ready case precisely so this distinction survives across the boundary.
|
|
20
|
+
export function resolveHost(root, fallback = document) {
|
|
21
|
+
if (root === null) return null;
|
|
22
|
+
return root || fallback;
|
|
23
|
+
}
|
|
24
|
+
|
|
9
25
|
// Monotonic counter for auto-minted field / list ids, shared across
|
|
10
26
|
// initFormValidation and initCombobox so separate calls (and separate
|
|
11
27
|
// behaviors) never collide on an id.
|
|
@@ -48,3 +64,34 @@ export function closestSafe(el, selector) {
|
|
|
48
64
|
return null;
|
|
49
65
|
}
|
|
50
66
|
}
|
|
67
|
+
|
|
68
|
+
// Collect the hosts an initializer should wire: the descendants matching
|
|
69
|
+
// `selector` PLUS `host` itself when it matches (querySelectorAll only sees
|
|
70
|
+
// descendants, so a `root` that *is* a target would otherwise be skipped).
|
|
71
|
+
// Self-first, null-safe — the shape ~9 delegated behaviors hand-rolled.
|
|
72
|
+
export function collectHosts(host, selector) {
|
|
73
|
+
const out = host !== document && host.matches?.(selector) ? [host] : [];
|
|
74
|
+
out.push(...(host.querySelectorAll?.(selector) ?? []));
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// scrollIntoView is a pure affordance and throws in jsdom/layout-less envs;
|
|
79
|
+
// never let that break a keyboard/roving handler. (combobox/command/carousel.)
|
|
80
|
+
export function scrollIntoViewSafe(el, opts = { block: 'nearest' }) {
|
|
81
|
+
try {
|
|
82
|
+
el?.scrollIntoView(opts);
|
|
83
|
+
} catch {
|
|
84
|
+
/* headless / no layout — the scroll is cosmetic */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Wrap an index by `delta` within [0, len), the roving keyboard math shared by
|
|
89
|
+
// the combobox and command listboxes (a -1 `cur` lands on the first/last as
|
|
90
|
+
// before). Only this core is shared — the surrounding setActive/filter/group
|
|
91
|
+
// logic diverges between the two for real reasons. (code-quality audit Q12.)
|
|
92
|
+
export function wrapIndex(cur, delta, len) {
|
|
93
|
+
let next = cur + delta;
|
|
94
|
+
if (next < 0) next = len - 1;
|
|
95
|
+
if (next >= len) next = 0;
|
|
96
|
+
return next;
|
|
97
|
+
}
|
package/behaviors/legend.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
|
* Wire `[data-bronto-legend]` interactive legends. Each entry is a
|
|
@@ -17,12 +17,18 @@ import { hasDom, noop, bindOnce } from './internal.js';
|
|
|
17
17
|
*/
|
|
18
18
|
export function initLegend({ root } = {}) {
|
|
19
19
|
if (!hasDom()) return noop;
|
|
20
|
-
const host = root
|
|
20
|
+
const host = resolveHost(root);
|
|
21
|
+
if (!host) return noop;
|
|
22
|
+
const isButton = (el) => el.tagName === 'BUTTON' || el.getAttribute('role') === 'button';
|
|
21
23
|
const onClick = (e) => {
|
|
22
24
|
const item = e.target.closest('.ui-legend__item');
|
|
23
25
|
if (!item || !host.contains(item)) return;
|
|
24
26
|
const legend = item.closest('[data-bronto-legend]');
|
|
25
27
|
if (!legend || !host.contains(legend)) return;
|
|
28
|
+
// The contract requires a real `<button>` (keyboard-operable, focusable). A
|
|
29
|
+
// non-button item is mouse-only — refuse to toggle it rather than ship a
|
|
30
|
+
// pointer-only control (WCAG 2.1.1 — C11). The author is warned at bind.
|
|
31
|
+
if (!isButton(item)) return;
|
|
26
32
|
const active = item.getAttribute('aria-pressed') !== 'false';
|
|
27
33
|
const next = !active;
|
|
28
34
|
item.setAttribute('aria-pressed', String(next));
|
|
@@ -40,6 +46,21 @@ export function initLegend({ root } = {}) {
|
|
|
40
46
|
);
|
|
41
47
|
};
|
|
42
48
|
return bindOnce(host, 'legend', () => {
|
|
49
|
+
// Warn once per non-button item present at bind: it gets cursor:pointer from
|
|
50
|
+
// the CSS but is neither focusable nor keyboard-operable (C11).
|
|
51
|
+
if (typeof console !== 'undefined') {
|
|
52
|
+
for (const legend of host.querySelectorAll?.('[data-bronto-legend]') ?? []) {
|
|
53
|
+
const stray = [...legend.querySelectorAll('.ui-legend__item')].some(
|
|
54
|
+
(el) => el.closest('[data-bronto-legend]') === legend && !isButton(el),
|
|
55
|
+
);
|
|
56
|
+
if (stray) {
|
|
57
|
+
console.warn(
|
|
58
|
+
'[bronto] initLegend(): interactive legend entries must be <button> (or role="button") to be keyboard-operable — a non-button .ui-legend__item is ignored.',
|
|
59
|
+
);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
43
64
|
host.addEventListener('click', onClick);
|
|
44
65
|
return () => host.removeEventListener('click', onClick);
|
|
45
66
|
});
|
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>`
|
|
@@ -13,7 +13,8 @@ import { hasDom, noop, bindOnce } from './internal.js';
|
|
|
13
13
|
*/
|
|
14
14
|
export function initMenu({ root } = {}) {
|
|
15
15
|
if (!hasDom()) return noop;
|
|
16
|
-
const host = root
|
|
16
|
+
const host = resolveHost(root);
|
|
17
|
+
if (!host) return noop;
|
|
17
18
|
const openMenus = () => host.querySelectorAll?.('[data-bronto-menu][open]') ?? [];
|
|
18
19
|
const shut = (menu) => {
|
|
19
20
|
if (!menu || !menu.open) return;
|
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 } from './internal.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Collision-aware popover, dependency-free. A `[data-bronto-popover]`
|
|
@@ -12,15 +12,43 @@ 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).
|
|
15
25
|
*/
|
|
16
26
|
export function initPopover({ root } = {}) {
|
|
17
27
|
if (!hasDom()) return noop;
|
|
18
|
-
const host = root
|
|
28
|
+
const host = resolveHost(root);
|
|
29
|
+
if (!host) return noop;
|
|
19
30
|
const view = document.defaultView;
|
|
20
31
|
const GAP = 8;
|
|
21
32
|
let openPanel = null;
|
|
22
33
|
let openTrigger = null;
|
|
23
34
|
|
|
35
|
+
const FOCUSABLE =
|
|
36
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
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). Focus the
|
|
40
|
+
// first focusable descendant, else the panel itself (made programmatically
|
|
41
|
+
// focusable) so a content-only panel still receives focus.
|
|
42
|
+
const focusInto = (panel) => {
|
|
43
|
+
const first = panel.querySelector(FOCUSABLE);
|
|
44
|
+
if (first) {
|
|
45
|
+
first.focus?.();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (!panel.hasAttribute('tabindex')) panel.setAttribute('tabindex', '-1');
|
|
49
|
+
panel.focus?.();
|
|
50
|
+
};
|
|
51
|
+
|
|
24
52
|
const place = (trigger, panel) => {
|
|
25
53
|
const r = trigger.getBoundingClientRect();
|
|
26
54
|
const pw = panel.offsetWidth;
|
|
@@ -39,6 +67,10 @@ export function initPopover({ root } = {}) {
|
|
|
39
67
|
if (!openPanel) return;
|
|
40
68
|
const panel = openPanel;
|
|
41
69
|
const trigger = openTrigger;
|
|
70
|
+
// Only steal focus back to the trigger when focus is still inside the panel
|
|
71
|
+
// (Escape / programmatic re-toggle). An outside-click leaves focus where the
|
|
72
|
+
// click landed — deliberate intent to move on, per the doc contract.
|
|
73
|
+
const focusWasInside = panel.contains(document.activeElement);
|
|
42
74
|
openPanel = openTrigger = null;
|
|
43
75
|
if (panel.hasAttribute('popover') && typeof panel.hidePopover === 'function') {
|
|
44
76
|
try {
|
|
@@ -50,10 +82,15 @@ export function initPopover({ root } = {}) {
|
|
|
50
82
|
panel.classList.remove('is-open');
|
|
51
83
|
}
|
|
52
84
|
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
|
85
|
+
if (focusWasInside && trigger?.isConnected) trigger.focus?.();
|
|
53
86
|
};
|
|
54
87
|
|
|
55
88
|
const open = (trigger, panel) => {
|
|
56
89
|
close();
|
|
90
|
+
// Live up to the advertised `aria-haspopup="dialog"`: give the panel a
|
|
91
|
+
// dialog role (unless the author set one) so AT announces it as the promised
|
|
92
|
+
// dialog rather than a generic group (C6).
|
|
93
|
+
if (!panel.hasAttribute('role')) panel.setAttribute('role', 'dialog');
|
|
57
94
|
trigger.setAttribute('aria-controls', panel.id);
|
|
58
95
|
trigger.setAttribute('aria-expanded', 'true');
|
|
59
96
|
if (panel.hasAttribute('popover') && typeof panel.showPopover === 'function') {
|
|
@@ -68,6 +105,7 @@ export function initPopover({ root } = {}) {
|
|
|
68
105
|
openPanel = panel;
|
|
69
106
|
openTrigger = trigger;
|
|
70
107
|
place(trigger, panel);
|
|
108
|
+
focusInto(panel);
|
|
71
109
|
};
|
|
72
110
|
|
|
73
111
|
const onClick = (e) => {
|
|
@@ -83,22 +121,55 @@ export function initPopover({ root } = {}) {
|
|
|
83
121
|
if (openPanel && !openPanel.contains(e.target)) close();
|
|
84
122
|
};
|
|
85
123
|
const onKey = (e) => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
close();
|
|
89
|
-
t?.focus?.();
|
|
90
|
-
}
|
|
124
|
+
// close() returns focus to the trigger because focus is inside the panel.
|
|
125
|
+
if (e.key === 'Escape' && openPanel) 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);
|
package/behaviors/spotlight.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hasDom, noop, bindOnce, byIdInHost } from './internal.js';
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce, byIdInHost, collectHosts } from './internal.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Position a spotlight cutout over a target element. Each
|
|
@@ -14,10 +14,9 @@ import { hasDom, noop, bindOnce, byIdInHost } from './internal.js';
|
|
|
14
14
|
*/
|
|
15
15
|
export function initSpotlight({ root } = {}) {
|
|
16
16
|
if (!hasDom()) return noop;
|
|
17
|
-
const host = root
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
spots.push(...host.querySelectorAll('[data-bronto-spotlight]'));
|
|
17
|
+
const host = resolveHost(root);
|
|
18
|
+
if (!host) return noop;
|
|
19
|
+
const spots = collectHosts(host, '[data-bronto-spotlight]');
|
|
21
20
|
if (!spots.length) return noop;
|
|
22
21
|
|
|
23
22
|
const place = () => {
|