@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.
- package/CHANGELOG.md +70 -0
- package/README.md +3 -3
- package/annotations/index.d.ts.map +1 -1
- package/annotations/index.js +21 -3
- package/behaviors/carousel.d.ts.map +1 -1
- package/behaviors/carousel.js +91 -35
- package/behaviors/combobox.d.ts.map +1 -1
- package/behaviors/combobox.js +117 -43
- package/behaviors/command.d.ts.map +1 -1
- package/behaviors/command.js +74 -14
- package/behaviors/connectors.d.ts.map +1 -1
- package/behaviors/connectors.js +92 -9
- package/behaviors/crosshair.d.ts.map +1 -1
- package/behaviors/crosshair.js +47 -1
- package/behaviors/dialog.d.ts.map +1 -1
- package/behaviors/dialog.js +24 -9
- package/behaviors/disclosure.d.ts.map +1 -1
- package/behaviors/disclosure.js +33 -3
- package/behaviors/dismissible.d.ts.map +1 -1
- package/behaviors/dismissible.js +3 -2
- package/behaviors/forms.d.ts.map +1 -1
- package/behaviors/forms.js +67 -0
- package/behaviors/glyph.d.ts.map +1 -1
- package/behaviors/glyph.js +17 -2
- package/behaviors/inert.js +3 -2
- package/behaviors/internal.d.ts.map +1 -1
- package/behaviors/internal.js +2 -1
- package/behaviors/legend.d.ts +0 -5
- package/behaviors/legend.d.ts.map +1 -1
- package/behaviors/legend.js +45 -13
- package/behaviors/menu.d.ts.map +1 -1
- package/behaviors/menu.js +13 -8
- package/behaviors/modal.d.ts.map +1 -1
- package/behaviors/modal.js +77 -19
- package/behaviors/popover.d.ts +4 -3
- package/behaviors/popover.d.ts.map +1 -1
- package/behaviors/popover.js +89 -9
- package/behaviors/sources.d.ts.map +1 -1
- package/behaviors/sources.js +14 -2
- package/behaviors/splitter.d.ts.map +1 -1
- package/behaviors/splitter.js +149 -110
- package/behaviors/spotlight.d.ts.map +1 -1
- package/behaviors/spotlight.js +28 -2
- package/behaviors/table.d.ts.map +1 -1
- package/behaviors/table.js +103 -11
- package/behaviors/tabs.d.ts.map +1 -1
- package/behaviors/tabs.js +82 -18
- package/behaviors/theme.d.ts.map +1 -1
- package/behaviors/theme.js +25 -5
- package/classes/index.d.ts +15 -2
- package/classes/index.js +0 -1
- package/connectors/index.d.ts +39 -6
- package/connectors/index.d.ts.map +1 -1
- package/connectors/index.js +67 -9
- package/css/annotations.css +12 -0
- package/css/crosshair.css +27 -2
- package/css/feedback.css +2 -30
- package/css/navigation.css +12 -0
- package/css/tokens.css +16 -0
- package/dist/bronto.css +1 -1
- package/dist/css/analytical.css +1 -1
- package/dist/css/annotations.css +1 -1
- package/dist/css/crosshair.css +1 -1
- package/dist/css/feedback.css +1 -1
- package/dist/css/navigation.css +1 -1
- package/dist/css/report-kit.css +1 -1
- package/dist/css/tokens.css +1 -1
- package/docs/adr/0001-color-system.md +3 -2
- package/docs/annotations.md +12 -1
- package/docs/architecture.md +46 -13
- package/docs/command.md +4 -1
- package/docs/connectors.md +16 -0
- package/docs/crosshair.md +1 -1
- package/docs/dots.md +4 -1
- package/docs/glyphs.md +11 -0
- package/docs/migrations/0.2-to-0.3.md +1 -1
- package/docs/package-contract.md +5 -5
- package/docs/reporting.md +23 -12
- package/docs/stability.md +18 -2
- package/docs/theming.md +2 -2
- package/docs/usage.md +16 -2
- package/docs/vega.md +4 -4
- package/llms.txt +10 -5
- package/package.json +20 -4
- package/svelte/index.d.ts +71 -45
- package/svelte/index.d.ts.map +1 -1
- package/svelte/index.js +29 -2
- package/vue/index.d.ts +42 -5
- package/vue/index.d.ts.map +1 -1
- package/vue/index.js +32 -1
package/behaviors/glyph.js
CHANGED
|
@@ -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
|
|
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
|
|
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);
|
package/behaviors/inert.js
CHANGED
|
@@ -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
|
|
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,
|
|
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"}
|
package/behaviors/internal.js
CHANGED
|
@@ -90,7 +90,8 @@ export function byIdInHost(host, id) {
|
|
|
90
90
|
|
|
91
91
|
export function closestSafe(el, selector) {
|
|
92
92
|
try {
|
|
93
|
-
|
|
93
|
+
const start = el?.nodeType === 1 ? el : el?.parentElement;
|
|
94
|
+
return start?.closest?.(selector) ?? null;
|
|
94
95
|
} catch {
|
|
95
96
|
return null;
|
|
96
97
|
}
|
package/behaviors/legend.d.ts
CHANGED
|
@@ -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":"
|
|
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"}
|
package/behaviors/legend.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
99
|
+
const legends = collectHosts(host, '[data-bronto-legend]');
|
|
100
|
+
const itemStates = [];
|
|
75
101
|
for (const legend of legends) {
|
|
76
|
-
for (const el of legend
|
|
77
|
-
|
|
78
|
-
|
|
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 =
|
|
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
|
}
|
package/behaviors/menu.d.ts.map
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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 &&
|
|
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(
|
|
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
|
|
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
|
-
|
|
48
|
+
doc.addEventListener('click', onClick);
|
|
44
49
|
host.addEventListener('keydown', onKey);
|
|
45
50
|
return () => {
|
|
46
|
-
|
|
51
|
+
doc.removeEventListener('click', onClick);
|
|
47
52
|
host.removeEventListener('keydown', onKey);
|
|
48
53
|
};
|
|
49
54
|
});
|
package/behaviors/modal.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"modal.d.ts","sourceRoot":"","sources":["modal.js"],"names":[],"mappings":"
|
|
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"}
|
package/behaviors/modal.js
CHANGED
|
@@ -1,4 +1,56 @@
|
|
|
1
|
-
import {
|
|
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
|
);
|
package/behaviors/popover.d.ts
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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":"
|
|
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"}
|
package/behaviors/popover.js
CHANGED
|
@@ -1,12 +1,50 @@
|
|
|
1
|
-
import {
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
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":"
|
|
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"}
|