@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.
Files changed (117) hide show
  1. package/CHANGELOG.md +322 -0
  2. package/MIGRATIONS.json +14 -0
  3. package/README.md +28 -5
  4. package/annotations/index.d.ts +398 -276
  5. package/annotations/index.d.ts.map +1 -0
  6. package/annotations/index.js +315 -45
  7. package/behaviors/carousel.js +17 -16
  8. package/behaviors/combobox.js +47 -16
  9. package/behaviors/command.js +18 -15
  10. package/behaviors/connectors.js +4 -5
  11. package/behaviors/crosshair.js +4 -5
  12. package/behaviors/dialog.js +3 -2
  13. package/behaviors/disclosure.js +3 -2
  14. package/behaviors/dismissible.js +3 -2
  15. package/behaviors/forms.js +41 -13
  16. package/behaviors/glyph.js +4 -5
  17. package/behaviors/internal.js +47 -0
  18. package/behaviors/legend.js +23 -2
  19. package/behaviors/menu.js +3 -2
  20. package/behaviors/popover.js +78 -7
  21. package/behaviors/spotlight.js +4 -5
  22. package/behaviors/table.js +39 -12
  23. package/behaviors/tabs.js +14 -14
  24. package/behaviors/theme.js +5 -3
  25. package/behaviors/toast.js +13 -1
  26. package/classes/classes.json +1857 -0
  27. package/classes/index.d.ts +28 -13
  28. package/classes/index.js +34 -18
  29. package/classes/vscode.css-custom-data.json +12 -0
  30. package/connectors/index.d.ts +189 -69
  31. package/connectors/index.d.ts.map +1 -0
  32. package/connectors/index.js +120 -24
  33. package/css/app.css +43 -13
  34. package/css/base.css +15 -10
  35. package/css/connectors.css +17 -0
  36. package/css/content.css +7 -1
  37. package/css/dataviz.css +5 -1
  38. package/css/disclosure.css +38 -6
  39. package/css/dots.css +57 -0
  40. package/css/feedback.css +60 -2
  41. package/css/forms.css +42 -1
  42. package/css/legend.css +11 -7
  43. package/css/marks.css +38 -8
  44. package/css/motion.css +24 -44
  45. package/css/navigation.css +7 -0
  46. package/css/overlay.css +31 -1
  47. package/css/primitives.css +91 -5
  48. package/css/report.css +40 -63
  49. package/css/site.css +16 -2
  50. package/css/sources.css +43 -1
  51. package/css/spotlight.css +1 -1
  52. package/css/tokens.css +36 -1
  53. package/css/workbench.css +1 -1
  54. package/dist/bronto.css +1 -1
  55. package/dist/css/analytical.css +1 -1
  56. package/dist/css/app.css +1 -1
  57. package/dist/css/base.css +1 -1
  58. package/dist/css/connectors.css +1 -1
  59. package/dist/css/content.css +1 -1
  60. package/dist/css/disclosure.css +1 -1
  61. package/dist/css/dots.css +1 -1
  62. package/dist/css/feedback.css +1 -1
  63. package/dist/css/forms.css +1 -1
  64. package/dist/css/legend.css +1 -1
  65. package/dist/css/marks.css +1 -1
  66. package/dist/css/motion.css +1 -1
  67. package/dist/css/navigation.css +1 -1
  68. package/dist/css/overlay.css +1 -1
  69. package/dist/css/primitives.css +1 -1
  70. package/dist/css/report.css +1 -1
  71. package/dist/css/site.css +1 -1
  72. package/dist/css/sources.css +1 -1
  73. package/dist/css/spotlight.css +1 -1
  74. package/dist/css/tokens.css +1 -1
  75. package/dist/css/workbench.css +1 -1
  76. package/docs/adr/0003-theme-model.md +1 -1
  77. package/docs/annotations.md +94 -14
  78. package/docs/architecture.md +50 -6
  79. package/docs/contrast.md +116 -92
  80. package/docs/d2.md +195 -0
  81. package/docs/legends.md +18 -2
  82. package/docs/marks.md +9 -2
  83. package/docs/mermaid.md +152 -0
  84. package/docs/reference.md +78 -22
  85. package/docs/reporting.md +395 -57
  86. package/docs/sources.md +27 -0
  87. package/docs/stability.md +9 -2
  88. package/docs/usage.md +101 -4
  89. package/docs/vega.md +225 -0
  90. package/docs/workbench.md +7 -1
  91. package/glyphs/glyphs.js +6 -4
  92. package/llms.txt +139 -14
  93. package/package.json +50 -12
  94. package/qwik/index.d.ts +42 -59
  95. package/qwik/index.d.ts.map +1 -0
  96. package/qwik/index.js +55 -3
  97. package/react/index.d.ts +39 -61
  98. package/react/index.d.ts.map +1 -0
  99. package/react/index.js +57 -3
  100. package/solid/index.d.ts +64 -61
  101. package/solid/index.d.ts.map +1 -0
  102. package/solid/index.js +60 -3
  103. package/tokens/d2.d.ts +38 -0
  104. package/tokens/d2.js +71 -0
  105. package/tokens/d2.json +43 -0
  106. package/tokens/index.d.ts +5 -5
  107. package/tokens/index.js +15 -1
  108. package/tokens/index.json +9 -0
  109. package/tokens/mermaid.d.ts +23 -0
  110. package/tokens/mermaid.js +181 -0
  111. package/tokens/mermaid.json +163 -0
  112. package/tokens/resolved.json +45 -1
  113. package/tokens/skins.js +3 -2
  114. package/tokens/tokens.dtcg.json +26 -0
  115. package/tokens/vega.d.ts +34 -0
  116. package/tokens/vega.js +155 -0
  117. package/tokens/vega.json +179 -0
@@ -1,4 +1,13 @@
1
- import { hasDom, noop, bindOnce, nextFieldUid } from './internal.js';
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 || document;
24
- const palettes = [];
25
- if (host !== document && host.matches?.('[data-bronto-command]')) palettes.push(host);
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
- try {
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
- const cur = vis.indexOf(items[active]);
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) => {
@@ -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 || document;
21
- const connectors = [];
22
- if (host !== document && host.matches?.('[data-bronto-connector]')) connectors.push(host);
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 = () => {
@@ -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 || document;
18
- const plots = [];
19
- if (host !== document && host.matches?.('[data-bronto-crosshair]')) plots.push(host);
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 = [];
@@ -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 || document;
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
 
@@ -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 || document;
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;
@@ -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 || document;
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;
@@ -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 || document;
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
- return field.querySelector('[data-bronto-error]') || field.querySelector('.ui-hint');
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
- slot.textContent = '';
54
- if (slot.classList.contains('ui-hint')) slot.classList.remove('ui-hint--error');
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 (slot.classList.contains('ui-hint')) slot.classList.add('ui-hint--error');
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
- // Feature-detect rather than `instanceof Element` — `Element` is not
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);
@@ -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 || document;
21
- const els = [];
22
- if (host !== document && host.matches?.('[data-bronto-glyph]')) els.push(host);
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) {
@@ -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
+ }
@@ -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 || document;
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 || document;
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;
@@ -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 || document;
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
- if (e.key === 'Escape' && openPanel) {
87
- const t = openTrigger;
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);
@@ -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 || document;
18
- const spots = [];
19
- if (host !== document && host.matches?.('[data-bronto-spotlight]')) spots.push(host);
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 = () => {