@ponchia/ui 0.6.7 → 0.6.8

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 (90) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +3 -3
  3. package/annotations/index.d.ts.map +1 -1
  4. package/annotations/index.js +21 -3
  5. package/behaviors/carousel.d.ts.map +1 -1
  6. package/behaviors/carousel.js +91 -35
  7. package/behaviors/combobox.d.ts.map +1 -1
  8. package/behaviors/combobox.js +117 -43
  9. package/behaviors/command.d.ts.map +1 -1
  10. package/behaviors/command.js +74 -14
  11. package/behaviors/connectors.d.ts.map +1 -1
  12. package/behaviors/connectors.js +92 -9
  13. package/behaviors/crosshair.d.ts.map +1 -1
  14. package/behaviors/crosshair.js +47 -1
  15. package/behaviors/dialog.d.ts.map +1 -1
  16. package/behaviors/dialog.js +24 -9
  17. package/behaviors/disclosure.d.ts.map +1 -1
  18. package/behaviors/disclosure.js +33 -3
  19. package/behaviors/dismissible.d.ts.map +1 -1
  20. package/behaviors/dismissible.js +3 -2
  21. package/behaviors/forms.d.ts.map +1 -1
  22. package/behaviors/forms.js +67 -0
  23. package/behaviors/glyph.d.ts.map +1 -1
  24. package/behaviors/glyph.js +17 -2
  25. package/behaviors/inert.js +3 -2
  26. package/behaviors/internal.d.ts.map +1 -1
  27. package/behaviors/internal.js +2 -1
  28. package/behaviors/legend.d.ts +0 -5
  29. package/behaviors/legend.d.ts.map +1 -1
  30. package/behaviors/legend.js +45 -13
  31. package/behaviors/menu.d.ts.map +1 -1
  32. package/behaviors/menu.js +13 -8
  33. package/behaviors/modal.d.ts.map +1 -1
  34. package/behaviors/modal.js +77 -19
  35. package/behaviors/popover.d.ts +4 -3
  36. package/behaviors/popover.d.ts.map +1 -1
  37. package/behaviors/popover.js +89 -9
  38. package/behaviors/sources.d.ts.map +1 -1
  39. package/behaviors/sources.js +14 -2
  40. package/behaviors/splitter.d.ts.map +1 -1
  41. package/behaviors/splitter.js +149 -110
  42. package/behaviors/spotlight.d.ts.map +1 -1
  43. package/behaviors/spotlight.js +28 -2
  44. package/behaviors/table.d.ts.map +1 -1
  45. package/behaviors/table.js +103 -11
  46. package/behaviors/tabs.d.ts.map +1 -1
  47. package/behaviors/tabs.js +82 -18
  48. package/behaviors/theme.d.ts.map +1 -1
  49. package/behaviors/theme.js +25 -5
  50. package/classes/index.d.ts +15 -2
  51. package/classes/index.js +0 -1
  52. package/connectors/index.d.ts +39 -6
  53. package/connectors/index.d.ts.map +1 -1
  54. package/connectors/index.js +67 -9
  55. package/css/annotations.css +12 -0
  56. package/css/crosshair.css +27 -2
  57. package/css/feedback.css +2 -30
  58. package/css/navigation.css +12 -0
  59. package/css/tokens.css +16 -0
  60. package/dist/bronto.css +1 -1
  61. package/dist/css/analytical.css +1 -1
  62. package/dist/css/annotations.css +1 -1
  63. package/dist/css/crosshair.css +1 -1
  64. package/dist/css/feedback.css +1 -1
  65. package/dist/css/navigation.css +1 -1
  66. package/dist/css/report-kit.css +1 -1
  67. package/dist/css/tokens.css +1 -1
  68. package/docs/adr/0001-color-system.md +3 -2
  69. package/docs/annotations.md +12 -1
  70. package/docs/architecture.md +46 -13
  71. package/docs/command.md +4 -1
  72. package/docs/connectors.md +16 -0
  73. package/docs/crosshair.md +1 -1
  74. package/docs/dots.md +4 -1
  75. package/docs/glyphs.md +11 -0
  76. package/docs/migrations/0.2-to-0.3.md +1 -1
  77. package/docs/package-contract.md +5 -5
  78. package/docs/reporting.md +23 -12
  79. package/docs/stability.md +18 -2
  80. package/docs/theming.md +2 -2
  81. package/docs/usage.md +16 -2
  82. package/docs/vega.md +4 -4
  83. package/llms.txt +10 -5
  84. package/package.json +20 -4
  85. package/svelte/index.d.ts +71 -45
  86. package/svelte/index.d.ts.map +1 -1
  87. package/svelte/index.js +29 -2
  88. package/vue/index.d.ts +42 -5
  89. package/vue/index.d.ts.map +1 -1
  90. package/vue/index.js +32 -1
@@ -1,11 +1,25 @@
1
1
  import { hasDom, resolveHost, noop, collectHosts } from './internal.js';
2
2
  import { GLYPH_SIZE, glyphCells, glyphMask } from '../glyphs/glyphs.js';
3
3
 
4
+ const GLYPH_CLEANUP = Symbol('bronto-glyph-cleanup');
5
+
4
6
  function restoreAttr(el, name, prev) {
5
7
  if (prev === null) el.removeAttribute(name);
6
8
  else el.setAttribute(name, prev);
7
9
  }
8
10
 
11
+ function rememberCleanup(el, cleanups, cleanup) {
12
+ let done = false;
13
+ const wrapped = () => {
14
+ if (done) return;
15
+ done = true;
16
+ cleanup();
17
+ if (el[GLYPH_CLEANUP] === wrapped) delete el[GLYPH_CLEANUP];
18
+ };
19
+ el[GLYPH_CLEANUP] = wrapped;
20
+ cleanups.push(wrapped);
21
+ }
22
+
9
23
  // `dot`/`gap`/`size` land in inline CSS, so allow only length/calc syntax —
10
24
  // drop anything with a `;`/`{` that could open a second declaration (mirrors
11
25
  // glyphs.js cssLen). Used for the mask path's --icon-size.
@@ -40,6 +54,7 @@ export function initDotGlyph({ root } = {}) {
40
54
  const cleanups = [];
41
55
 
42
56
  for (const el of els) {
57
+ el[GLYPH_CLEANUP]?.();
43
58
  const name = el.getAttribute('data-bronto-glyph');
44
59
  const label = el.getAttribute('data-bronto-glyph-label');
45
60
 
@@ -67,7 +82,7 @@ export function initDotGlyph({ root } = {}) {
67
82
  el.setAttribute('aria-hidden', 'true');
68
83
  }
69
84
 
70
- cleanups.push(() => {
85
+ rememberCleanup(el, cleanups, () => {
71
86
  if (!hadIcon) el.classList.remove('ui-icon');
72
87
  if (hadMask) el.style.setProperty('--icon-mask', hadMask);
73
88
  else el.style.removeProperty('--icon-mask');
@@ -159,7 +174,7 @@ export function initDotGlyph({ root } = {}) {
159
174
  });
160
175
  el.appendChild(frag);
161
176
 
162
- cleanups.push(() => {
177
+ rememberCleanup(el, cleanups, () => {
163
178
  el.querySelectorAll(':scope > .ui-dotmatrix__cell').forEach((n) => n.remove());
164
179
  if (!hadMatrix) el.classList.remove('ui-dotmatrix');
165
180
  if (animClass && !hadAnimClass) el.classList.remove(animClass);
@@ -1,4 +1,4 @@
1
- import { hasDom, resolveHost, noop, bindOnce } from './internal.js';
1
+ import { hasDom, resolveHost, noop, bindOnce, closestSafe } from './internal.js';
2
2
 
3
3
  const DISABLED = '[aria-disabled="true"]';
4
4
 
@@ -25,9 +25,10 @@ export function initDisabledGuard({ root } = {}) {
25
25
  const host = resolveHost(root);
26
26
  if (!host) return noop;
27
27
  const block = (e) => {
28
- const el = e.target.closest?.(DISABLED);
28
+ const el = closestSafe(e.target, DISABLED);
29
29
  if (el && host.contains(el)) {
30
30
  e.preventDefault();
31
+ e.stopImmediatePropagation?.();
31
32
  e.stopPropagation();
32
33
  }
33
34
  };
@@ -1 +1 @@
1
- {"version":3,"file":"internal.d.ts","sourceRoot":"","sources":["internal.js"],"names":[],"mappings":"AAiDA,iEAIC;AAeD,sEAUC;AAED,oDAQC;AAED,yDAMC;AAMD,8DAIC;AAID;;SAMC;AAUD,gDAQC;AAMD,+DAKC;AAjIM,6BAAqB;AAErB,kCAAoD;AAyCpD,uCAAqC;;;;;sBArD/B,MAAM,IAAI"}
1
+ {"version":3,"file":"internal.d.ts","sourceRoot":"","sources":["internal.js"],"names":[],"mappings":"AAiDA,iEAIC;AAeD,sEAUC;AAED,oDAQC;AAED,yDAOC;AAMD,8DAIC;AAID;;SAMC;AAUD,gDAQC;AAMD,+DAKC;AAlIM,6BAAqB;AAErB,kCAAoD;AAyCpD,uCAAqC;;;;;sBArD/B,MAAM,IAAI"}
@@ -90,7 +90,8 @@ export function byIdInHost(host, id) {
90
90
 
91
91
  export function closestSafe(el, selector) {
92
92
  try {
93
- return el.closest(selector);
93
+ const start = el?.nodeType === 1 ? el : el?.parentElement;
94
+ return start?.closest?.(selector) ?? null;
94
95
  } catch {
95
96
  return null;
96
97
  }
@@ -1,8 +1,3 @@
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
1
  /**
7
2
  * Wire `[data-bronto-legend]` interactive legends. Each entry is a
8
3
  * `.ui-legend__item` authored as a `<button aria-pressed>`; clicking it (or
@@ -1 +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,CAmF3C;;;;;YAvGa,MAAM,GAAG,MAAM;;;;YACf,OAAO"}
1
+ {"version":3,"file":"legend.d.ts","sourceRoot":"","sources":["legend.js"],"names":[],"mappings":"AAUA;;;;;;;;;;;;;;;;;GAiBG;AACH,sCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAiH3C;;;;;YAvIa,MAAM,GAAG,MAAM;;;;YACf,OAAO"}
@@ -1,4 +1,4 @@
1
- import { hasDom, resolveHost, noop, bindOnce } from './internal.js';
1
+ import { hasDom, resolveHost, noop, bindOnce, collectHosts, closestSafe } from './internal.js';
2
2
 
3
3
  /**
4
4
  * @typedef {object} LegendToggleDetail
@@ -6,6 +6,8 @@ import { hasDom, resolveHost, noop, bindOnce } from './internal.js';
6
6
  * @property {boolean} active The new state (`true` ⇒ series shown).
7
7
  */
8
8
 
9
+ const handledEvents = new WeakSet();
10
+
9
11
  /**
10
12
  * Wire `[data-bronto-legend]` interactive legends. Each entry is a
11
13
  * `.ui-legend__item` authored as a `<button aria-pressed>`; clicking it (or
@@ -28,6 +30,26 @@ export function initLegend({ root } = {}) {
28
30
  if (!hasDom()) return noop;
29
31
  const host = resolveHost(root);
30
32
  if (!host) return noop;
33
+ const snapshotAttrs = (el, names) => {
34
+ const attrs = {};
35
+ for (const name of names) {
36
+ attrs[name] = {
37
+ had: el.hasAttribute(name),
38
+ value: el.getAttribute(name),
39
+ };
40
+ }
41
+ return attrs;
42
+ };
43
+ const restoreAttrs = (el, attrs) => {
44
+ for (const [name, state] of Object.entries(attrs)) {
45
+ if (state.had) el.setAttribute(name, state.value);
46
+ else el.removeAttribute(name);
47
+ }
48
+ };
49
+ const directItems = (legend) =>
50
+ [...legend.querySelectorAll('.ui-legend__item')].filter(
51
+ (el) => el.closest('[data-bronto-legend]') === legend,
52
+ );
31
53
  const isButton = (el) => el.tagName === 'BUTTON' || el.getAttribute('role') === 'button';
32
54
  const legendFor = (item) => {
33
55
  if (!item || !host.contains(item)) return;
@@ -37,11 +59,11 @@ export function initLegend({ root } = {}) {
37
59
  };
38
60
  const toggle = (item) => {
39
61
  const legend = legendFor(item);
40
- if (!legend) return;
62
+ if (!legend) return false;
41
63
  // The contract requires a real `<button>` (keyboard-operable, focusable). A
42
64
  // non-button item is mouse-only unless role=button is keyboard-normalized
43
65
  // below — refuse anything else rather than ship a pointer-only control.
44
- if (!isButton(item)) return;
66
+ if (!isButton(item)) return false;
45
67
  const active = item.getAttribute('aria-pressed') !== 'false';
46
68
  const next = !active;
47
69
  item.setAttribute('aria-pressed', String(next));
@@ -57,25 +79,33 @@ export function initLegend({ root } = {}) {
57
79
  detail: { series: item.dataset.series ?? items.indexOf(item), active: next },
58
80
  }),
59
81
  );
82
+ return true;
60
83
  };
61
84
  const onClick = (e) => {
62
- toggle(e.target.closest('.ui-legend__item'));
85
+ if (handledEvents.has(e)) return;
86
+ if (toggle(closestSafe(e.target, '.ui-legend__item'))) handledEvents.add(e);
63
87
  };
64
88
  const onKey = (e) => {
89
+ if (handledEvents.has(e)) return;
65
90
  if (e.key !== 'Enter' && e.key !== ' ') return;
66
- const item = e.target.closest('.ui-legend__item');
91
+ const item = closestSafe(e.target, '.ui-legend__item');
67
92
  if (!item || item.tagName === 'BUTTON' || item.getAttribute('role') !== 'button') return;
68
93
  e.preventDefault();
69
- toggle(item);
94
+ if (toggle(item)) handledEvents.add(e);
70
95
  };
71
96
  return bindOnce(host, 'legend', () => {
72
97
  // Normalize role=button entries and warn once per unsupported non-button
73
98
  // item present at bind. A real <button> remains the recommended markup.
74
- const legends = [...(host.querySelectorAll?.('[data-bronto-legend]') ?? [])];
99
+ const legends = collectHosts(host, '[data-bronto-legend]');
100
+ const itemStates = [];
75
101
  for (const legend of legends) {
76
- for (const el of legend.querySelectorAll('.ui-legend__item')) {
77
- if (el.closest('[data-bronto-legend]') !== legend) continue;
78
- if (el.tagName === 'BUTTON' && !el.hasAttribute('type')) el.type = 'button';
102
+ for (const el of directItems(legend)) {
103
+ itemStates.push({
104
+ el,
105
+ attrs: snapshotAttrs(el, ['type', 'tabindex', 'aria-pressed']),
106
+ inactive: el.classList.contains('is-inactive'),
107
+ });
108
+ if (el.tagName === 'BUTTON' && !el.hasAttribute('type')) el.setAttribute('type', 'button');
79
109
  if (
80
110
  el.tagName !== 'BUTTON' &&
81
111
  el.getAttribute('role') === 'button' &&
@@ -87,9 +117,7 @@ export function initLegend({ root } = {}) {
87
117
  }
88
118
  if (typeof console !== 'undefined') {
89
119
  for (const legend of legends) {
90
- const stray = [...legend.querySelectorAll('.ui-legend__item')].some(
91
- (el) => el.closest('[data-bronto-legend]') === legend && !isButton(el),
92
- );
120
+ const stray = directItems(legend).some((el) => !isButton(el));
93
121
  if (stray) {
94
122
  console.warn(
95
123
  '[bronto] initLegend(): interactive legend entries must be <button> or role="button" — unsupported .ui-legend__item controls are ignored.',
@@ -103,6 +131,10 @@ export function initLegend({ root } = {}) {
103
131
  return () => {
104
132
  host.removeEventListener('click', onClick);
105
133
  host.removeEventListener('keydown', onKey);
134
+ for (const state of itemStates) {
135
+ restoreAttrs(state.el, state.attrs);
136
+ state.el.classList.toggle('is-inactive', state.inactive);
137
+ }
106
138
  };
107
139
  });
108
140
  }
@@ -1 +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"}
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,CAwC3C"}
package/behaviors/menu.js CHANGED
@@ -1,4 +1,4 @@
1
- import { hasDom, resolveHost, noop, bindOnce } from './internal.js';
1
+ import { hasDom, resolveHost, noop, bindOnce, closestSafe, collectHosts } from './internal.js';
2
2
 
3
3
  /**
4
4
  * Dropdown-menu close affordances for a native `<details data-bronto-menu>`
@@ -18,32 +18,37 @@ export function initMenu({ root } = {}) {
18
18
  if (!hasDom()) return noop;
19
19
  const host = resolveHost(root);
20
20
  if (!host) return noop;
21
- const openMenus = () => host.querySelectorAll?.('[data-bronto-menu][open]') ?? [];
21
+ const doc = host.nodeType === 9 ? host : host.ownerDocument || document;
22
+ const openMenus = () => collectHosts(host, '[data-bronto-menu][open]');
22
23
  const shut = (menu) => {
23
24
  if (!menu || !menu.open) return;
24
25
  menu.open = false;
25
26
  menu.querySelector('summary')?.focus();
26
27
  };
27
28
  const onClick = (e) => {
28
- const menu = e.target.closest('[data-bronto-menu]');
29
+ const target = e.target;
30
+ const menu = closestSafe(target, '[data-bronto-menu]');
29
31
  // Activate an item → close its menu (and return focus to summary).
30
- if (menu && e.target.closest('.ui-menu__item')) {
32
+ if (menu && host.contains(menu) && closestSafe(target, '.ui-menu__item')) {
31
33
  shut(menu);
32
34
  return;
33
35
  }
34
36
  // Click outside any open menu → close them all (no focus move).
35
- for (const m of openMenus()) if (!m.contains(e.target)) m.open = false;
37
+ for (const m of openMenus()) if (!m.contains(target)) m.open = false;
36
38
  };
37
39
  const onKey = (e) => {
38
40
  if (e.key !== 'Escape') return;
39
- const menu = e.target.closest?.('[data-bronto-menu][open]') || openMenus()[0];
41
+ const menu = closestSafe(e.target, '[data-bronto-menu][open]') || openMenus()[0];
42
+ if (!menu) return;
43
+ e.preventDefault();
44
+ e.stopPropagation();
40
45
  shut(menu);
41
46
  };
42
47
  return bindOnce(host, 'menu', () => {
43
- host.addEventListener('click', onClick);
48
+ doc.addEventListener('click', onClick);
44
49
  host.addEventListener('keydown', onKey);
45
50
  return () => {
46
- host.removeEventListener('click', onClick);
51
+ doc.removeEventListener('click', onClick);
47
52
  host.removeEventListener('keydown', onKey);
48
53
  };
49
54
  });
@@ -1 +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"}
1
+ {"version":3,"file":"modal.d.ts","sourceRoot":"","sources":["modal.js"],"names":[],"mappings":"AAsDA;;;GAGG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,qCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CA+F3C;;;;;YA7Ha,QAAQ"}
@@ -1,4 +1,56 @@
1
- import { hasDom, resolveHost, noop, bindOnce, collectHosts, focusInto } from './internal.js';
1
+ import {
2
+ hasDom,
3
+ resolveHost,
4
+ noop,
5
+ bindOnce,
6
+ collectHosts,
7
+ focusInto,
8
+ closestSafe,
9
+ } from './internal.js';
10
+
11
+ function insideOpenPopover(target, modal) {
12
+ const classPanel = closestSafe(target, '.ui-popover.is-open');
13
+ if (classPanel && modal.contains(classPanel)) return true;
14
+
15
+ const nativePanel = closestSafe(target, '[popover]');
16
+ if (!nativePanel || !modal.contains(nativePanel)) return false;
17
+ try {
18
+ return nativePanel.matches(':popover-open');
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ const activeModals = [];
25
+
26
+ const snapshotAttrs = (el, names) => {
27
+ const attrs = {};
28
+ for (const name of names) {
29
+ attrs[name] = {
30
+ had: el.hasAttribute(name),
31
+ value: el.getAttribute(name),
32
+ };
33
+ }
34
+ return attrs;
35
+ };
36
+
37
+ const restoreAttrs = (el, attrs) => {
38
+ for (const [name, state] of Object.entries(attrs)) {
39
+ if (state.had) el.setAttribute(name, state.value);
40
+ else el.removeAttribute(name);
41
+ }
42
+ };
43
+
44
+ const pushActiveModal = (modal) => {
45
+ const index = activeModals.indexOf(modal);
46
+ if (index !== -1) activeModals.splice(index, 1);
47
+ activeModals.push(modal);
48
+ };
49
+
50
+ const removeActiveModal = (modal) => {
51
+ const index = activeModals.indexOf(modal);
52
+ if (index !== -1) activeModals.splice(index, 1);
53
+ };
2
54
 
3
55
  /**
4
56
  * @typedef {object} ModalCloseDetail
@@ -45,22 +97,6 @@ export function initModal({ root } = {}) {
45
97
  let opener = null;
46
98
  let inerted = [];
47
99
 
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
100
  // Inert every sibling at each ancestor level up to <body>: the rest of the
65
101
  // page becomes non-focusable/non-interactive while the modal subtree stays
66
102
  // live. Skip already-inert nodes so release() can't un-inert something the
@@ -68,6 +104,7 @@ export function initModal({ root } = {}) {
68
104
  const trap = () => {
69
105
  if (opener) return; // already trapped
70
106
  opener = document.activeElement;
107
+ pushActiveModal(modal);
71
108
  let el = modal;
72
109
  while (el && el.parentElement && el !== document.body) {
73
110
  for (const sib of el.parentElement.children) {
@@ -83,6 +120,7 @@ export function initModal({ root } = {}) {
83
120
 
84
121
  const release = () => {
85
122
  if (!opener) return;
123
+ removeActiveModal(modal);
86
124
  for (const el of inerted) el.inert = false;
87
125
  inerted = [];
88
126
  const back = opener;
@@ -94,6 +132,8 @@ export function initModal({ root } = {}) {
94
132
 
95
133
  const onKey = (e) => {
96
134
  if (e.key === 'Escape' && opener) {
135
+ if (activeModals.at(-1) !== modal) return;
136
+ if (insideOpenPopover(e.target, modal)) return;
97
137
  modal.dispatchEvent(
98
138
  new CustomEvent('bronto:modal:close', {
99
139
  detail: { reason: 'escape' },
@@ -104,10 +144,27 @@ export function initModal({ root } = {}) {
104
144
  }
105
145
  };
106
146
 
107
- const observer = typeof MutationObserver === 'function' ? new MutationObserver(sync) : null;
108
-
109
147
  cleanups.push(
110
148
  bindOnce(modal, 'modal', () => {
149
+ const attrs = snapshotAttrs(modal, ['role', 'aria-modal', 'tabindex']);
150
+
151
+ // A controlled modal must announce AS a modal dialog, not a generic group —
152
+ // parity with initPopover. Apply a dialog role + aria-modal (unless the
153
+ // author set a role), and dev-warn on a missing accessible name since we
154
+ // can't invent a good one. (component audit C13.)
155
+ if (!modal.hasAttribute('role')) modal.setAttribute('role', 'dialog');
156
+ if (!modal.hasAttribute('aria-modal')) modal.setAttribute('aria-modal', 'true');
157
+ const named =
158
+ modal.hasAttribute('aria-label') ||
159
+ modal.hasAttribute('aria-labelledby') ||
160
+ modal.hasAttribute('title');
161
+ if (!named && typeof console !== 'undefined') {
162
+ console.warn(
163
+ `[bronto] initModal(): a [data-bronto-modal] has no accessible name — add aria-label or aria-labelledby so it is announced as a named dialog.`,
164
+ );
165
+ }
166
+
167
+ const observer = typeof MutationObserver === 'function' ? new MutationObserver(sync) : null;
111
168
  observer?.observe(modal, { attributes: true, attributeFilter: ['class'] });
112
169
  document.addEventListener('keydown', onKey, true);
113
170
  if (modal.classList.contains('is-open')) trap(); // already open at init
@@ -115,6 +172,7 @@ export function initModal({ root } = {}) {
115
172
  observer?.disconnect();
116
173
  document.removeEventListener('keydown', onKey, true);
117
174
  release();
175
+ restoreAttrs(modal, attrs);
118
176
  };
119
177
  }),
120
178
  );
@@ -2,9 +2,10 @@
2
2
  * Collision-aware popover, dependency-free. A `[data-bronto-popover]`
3
3
  * trigger toggles the `.ui-popover` panel whose id it names. The panel
4
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
5
+ * overflow the viewport, with its inline edge clamped on-screen and tall
6
+ * panels constrained to scroll inside the viewport the thing the CSS-only
7
+ * tooltip can't do near edges / inside scroll containers. If the panel has
8
+ * the native `popover` attribute and the
8
9
  * Popover API is available it is shown in the top layer (never
9
10
  * clipped); otherwise an `.is-open` class is toggled. Manages
10
11
  * `aria-expanded` / `aria-controls`, closes on Escape and outside
@@ -1 +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"}
1
+ {"version":3,"file":"popover.d.ts","sourceRoot":"","sources":["popover.js"],"names":[],"mappings":"AAuCA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,uCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAkM3C"}
@@ -1,12 +1,50 @@
1
- import { hasDom, resolveHost, noop, bindOnce, byIdInHost, focusInto } from './internal.js';
1
+ import {
2
+ hasDom,
3
+ resolveHost,
4
+ noop,
5
+ bindOnce,
6
+ byIdInHost,
7
+ focusInto,
8
+ collectHosts,
9
+ closestSafe,
10
+ } from './internal.js';
11
+
12
+ const snapshotAttrs = (el, names) => {
13
+ const attrs = {};
14
+ for (const name of names) {
15
+ attrs[name] = {
16
+ had: el.hasAttribute(name),
17
+ value: el.getAttribute(name),
18
+ };
19
+ }
20
+ return attrs;
21
+ };
22
+
23
+ const restoreAttrs = (el, attrs) => {
24
+ for (const [name, state] of Object.entries(attrs)) {
25
+ if (state.had) el.setAttribute(name, state.value);
26
+ else el.removeAttribute(name);
27
+ }
28
+ };
29
+
30
+ const snapshotStyle = (el, names) => {
31
+ const styles = {};
32
+ for (const name of names) styles[name] = el.style[name];
33
+ return styles;
34
+ };
35
+
36
+ const restoreStyle = (el, styles) => {
37
+ for (const [name, value] of Object.entries(styles)) el.style[name] = value;
38
+ };
2
39
 
3
40
  /**
4
41
  * Collision-aware popover, dependency-free. A `[data-bronto-popover]`
5
42
  * trigger toggles the `.ui-popover` panel whose id it names. The panel
6
43
  * is placed under the trigger and **flips above** when it would
7
- * overflow the viewport, with its inline edge clamped on-screen the
8
- * thing the CSS-only tooltip can't do near edges / inside scroll
9
- * containers. If the panel has the native `popover` attribute and the
44
+ * overflow the viewport, with its inline edge clamped on-screen and tall
45
+ * panels constrained to scroll inside the viewport the thing the CSS-only
46
+ * tooltip can't do near edges / inside scroll containers. If the panel has
47
+ * the native `popover` attribute and the
10
48
  * Popover API is available it is shown in the top layer (never
11
49
  * clipped); otherwise an `.is-open` class is toggled. Manages
12
50
  * `aria-expanded` / `aria-controls`, closes on Escape and outside
@@ -34,6 +72,27 @@ export function initPopover({ root } = {}) {
34
72
  const GAP = 8;
35
73
  let openPanel = null;
36
74
  let openTrigger = null;
75
+ const triggerStates = new Map();
76
+ const panelStates = new Map();
77
+
78
+ const rememberTrigger = (trigger) => {
79
+ if (!triggerStates.has(trigger)) {
80
+ triggerStates.set(
81
+ trigger,
82
+ snapshotAttrs(trigger, ['aria-haspopup', 'aria-controls', 'aria-expanded']),
83
+ );
84
+ }
85
+ };
86
+
87
+ const rememberPanel = (panel) => {
88
+ if (!panelStates.has(panel)) {
89
+ panelStates.set(panel, {
90
+ attrs: snapshotAttrs(panel, ['role', 'tabindex']),
91
+ open: panel.classList.contains('is-open'),
92
+ style: snapshotStyle(panel, ['maxBlockSize', 'top', 'left']),
93
+ });
94
+ }
95
+ };
37
96
 
38
97
  // The trigger advertises `aria-haspopup="dialog"`, so the open panel must BE a
39
98
  // dialog: a role, an accessible name, and focus moved into it (C6) — see the
@@ -41,12 +100,20 @@ export function initPopover({ root } = {}) {
41
100
 
42
101
  const place = (trigger, panel) => {
43
102
  const r = trigger.getBoundingClientRect();
103
+ panel.style.maxBlockSize = 'none';
44
104
  const pw = panel.offsetWidth;
45
- const ph = panel.offsetHeight;
105
+ const ph = Math.max(panel.offsetHeight, panel.scrollHeight);
46
106
  const vw = view?.innerWidth ?? 0;
47
107
  const vh = view?.innerHeight ?? 0;
48
- let top = r.bottom + GAP;
49
- if (top + ph > vh && r.top - GAP - ph >= 0) top = r.top - GAP - ph;
108
+ const maxHeight = Math.max(0, vh - GAP * 2);
109
+ const below = Math.max(0, vh - r.bottom - GAP * 2);
110
+ const above = Math.max(0, r.top - GAP * 2);
111
+ const placeAbove = ph > below && above > below;
112
+ const available = placeAbove ? above : below;
113
+ const height = Math.min(ph, available || maxHeight);
114
+ panel.style.maxBlockSize = `${height}px`;
115
+ let top = placeAbove ? r.top - GAP - height : r.bottom + GAP;
116
+ if (vh) top = Math.max(GAP, Math.min(top, vh - height - GAP));
50
117
  let left = r.left;
51
118
  if (vw) left = Math.max(GAP, Math.min(left, vw - pw - GAP));
52
119
  panel.style.top = `${Math.max(GAP, top)}px`;
@@ -77,6 +144,8 @@ export function initPopover({ root } = {}) {
77
144
 
78
145
  const open = (trigger, panel) => {
79
146
  close();
147
+ rememberTrigger(trigger);
148
+ rememberPanel(panel);
80
149
  // Live up to the advertised `aria-haspopup="dialog"`: give the panel a
81
150
  // dialog role (unless the author set one) so AT announces it as the promised
82
151
  // dialog rather than a generic group (C6).
@@ -99,7 +168,7 @@ export function initPopover({ root } = {}) {
99
168
  };
100
169
 
101
170
  const onClick = (e) => {
102
- const trigger = e.target.closest?.('[data-bronto-popover]');
171
+ const trigger = closestSafe(e.target, '[data-bronto-popover]');
103
172
  if (trigger && host.contains(trigger)) {
104
173
  const panel = byIdInHost(host, trigger.getAttribute('data-bronto-popover'));
105
174
  if (!panel) return;
@@ -133,9 +202,11 @@ export function initPopover({ root } = {}) {
133
202
  // never routes through close(), so aria-expanded would otherwise go stale).
134
203
  const seedTeardowns = [];
135
204
  const seed = () => {
136
- for (const trigger of host.querySelectorAll('[data-bronto-popover]')) {
205
+ for (const trigger of collectHosts(host, '[data-bronto-popover]')) {
137
206
  const panel = byIdInHost(host, trigger.getAttribute('data-bronto-popover'));
138
207
  if (!panel) continue;
208
+ rememberTrigger(trigger);
209
+ rememberPanel(panel);
139
210
  if (!trigger.hasAttribute('aria-haspopup')) trigger.setAttribute('aria-haspopup', 'dialog');
140
211
  trigger.setAttribute('aria-controls', panel.id);
141
212
  if (!trigger.hasAttribute('aria-expanded')) trigger.setAttribute('aria-expanded', 'false');
@@ -169,7 +240,16 @@ export function initPopover({ root } = {}) {
169
240
  view?.addEventListener('scroll', onReflow, true);
170
241
  view?.addEventListener('resize', onReflow);
171
242
  return () => {
243
+ close();
172
244
  for (const t of seedTeardowns.splice(0)) t();
245
+ for (const [trigger, attrs] of triggerStates) restoreAttrs(trigger, attrs);
246
+ triggerStates.clear();
247
+ for (const [panel, state] of panelStates) {
248
+ restoreAttrs(panel, state.attrs);
249
+ panel.classList.toggle('is-open', state.open);
250
+ restoreStyle(panel, state.style);
251
+ }
252
+ panelStates.clear();
173
253
  document.removeEventListener('click', onClick);
174
254
  document.removeEventListener('keydown', onKey);
175
255
  view?.removeEventListener('scroll', onReflow, true);
@@ -1 +1 @@
1
- {"version":3,"file":"sources.d.ts","sourceRoot":"","sources":["sources.js"],"names":[],"mappings":"AA2CA;;;;;;;;;;;GAWG;AACH,uCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAwG3C;;;;;QAjJa,MAAM;;;;cACN,OAAO;;;;YACP,OAAO"}
1
+ {"version":3,"file":"sources.d.ts","sourceRoot":"","sources":["sources.js"],"names":[],"mappings":"AA4CA;;;;;;;;;;;GAWG;AACH,uCAHW,OAAO,eAAe,EAAE,YAAY,GAClC,OAAO,eAAe,EAAE,OAAO,CAmH3C;;;;;QA5Ja,MAAM;;;;cACN,OAAO;;;;YACP,OAAO"}