@ponchia/ui 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +552 -8
- package/MIGRATIONS.json +106 -0
- package/README.md +34 -8
- package/annotations/index.d.ts +402 -0
- package/annotations/index.d.ts.map +1 -0
- package/annotations/index.js +792 -0
- package/behaviors/carousel.js +198 -0
- package/behaviors/combobox.js +226 -0
- package/behaviors/command.js +190 -0
- package/behaviors/connectors.js +95 -0
- package/behaviors/crosshair.js +57 -0
- package/behaviors/dialog.js +74 -0
- package/behaviors/disclosure.js +26 -0
- package/behaviors/dismissible.js +25 -0
- package/behaviors/forms.js +186 -0
- package/behaviors/glyph.js +108 -0
- package/behaviors/index.d.ts +79 -0
- package/behaviors/index.js +18 -1409
- package/behaviors/internal.js +97 -0
- package/behaviors/legend.js +67 -0
- package/behaviors/menu.js +47 -0
- package/behaviors/popover.js +179 -0
- package/behaviors/spotlight.js +52 -0
- package/behaviors/table.js +136 -0
- package/behaviors/tabs.js +103 -0
- package/behaviors/theme.js +84 -0
- package/behaviors/toast.js +164 -0
- package/classes/classes.json +1857 -0
- package/classes/index.d.ts +306 -13
- package/classes/index.js +339 -12
- package/classes/vscode.css-custom-data.json +12 -0
- package/connectors/index.d.ts +191 -0
- package/connectors/index.d.ts.map +1 -0
- package/connectors/index.js +275 -0
- package/css/analytical.css +21 -0
- package/css/annotations.css +292 -0
- package/css/app.css +43 -13
- package/css/base.css +15 -10
- package/css/command.css +97 -0
- package/css/connectors.css +110 -0
- package/css/content.css +7 -1
- package/css/crosshair.css +100 -0
- package/css/dataviz.css +5 -1
- package/css/disclosure.css +38 -6
- package/css/dots.css +57 -0
- package/css/feedback.css +111 -2
- package/css/fonts.css +11 -7
- package/css/forms.css +42 -1
- package/css/generated.css +117 -0
- package/css/legend.css +272 -0
- package/css/marks.css +174 -0
- package/css/motion.css +24 -44
- package/css/navigation.css +7 -0
- package/css/overlay.css +31 -1
- package/css/primitives.css +109 -5
- package/css/report.css +39 -81
- package/css/selection.css +46 -0
- package/css/site.css +16 -2
- package/css/sources.css +221 -0
- package/css/spotlight.css +104 -0
- package/css/state.css +121 -0
- package/css/tokens.css +60 -37
- package/css/workbench.css +83 -0
- package/dist/bronto.css +1 -1
- package/dist/css/analytical.css +1 -0
- package/dist/css/annotations.css +1 -0
- package/dist/css/app.css +1 -1
- package/dist/css/base.css +1 -1
- package/dist/css/command.css +1 -0
- package/dist/css/connectors.css +1 -0
- package/dist/css/content.css +1 -1
- package/dist/css/crosshair.css +1 -0
- package/dist/css/disclosure.css +1 -1
- package/dist/css/dots.css +1 -1
- package/dist/css/feedback.css +1 -1
- package/dist/css/fonts.css +1 -1
- package/dist/css/forms.css +1 -1
- package/dist/css/generated.css +1 -0
- package/dist/css/legend.css +1 -0
- package/dist/css/marks.css +1 -0
- package/dist/css/motion.css +1 -1
- package/dist/css/navigation.css +1 -1
- package/dist/css/overlay.css +1 -1
- package/dist/css/primitives.css +1 -1
- package/dist/css/report.css +1 -1
- package/dist/css/selection.css +1 -0
- package/dist/css/site.css +1 -1
- package/dist/css/sources.css +1 -0
- package/dist/css/spotlight.css +1 -0
- package/dist/css/state.css +1 -0
- package/dist/css/tokens.css +1 -1
- package/dist/css/workbench.css +1 -0
- package/docs/adr/0003-theme-model.md +7 -4
- package/docs/annotations.md +425 -0
- package/docs/architecture.md +246 -0
- package/docs/command.md +95 -0
- package/docs/connectors.md +91 -0
- package/docs/contrast.md +116 -92
- package/docs/crosshair.md +63 -0
- package/docs/d2.md +195 -0
- package/docs/generated.md +91 -0
- package/docs/legends.md +184 -0
- package/docs/marks.md +93 -0
- package/docs/mermaid.md +152 -0
- package/docs/reference.md +385 -23
- package/docs/reporting.md +436 -63
- package/docs/selection.md +40 -0
- package/docs/sources.md +137 -0
- package/docs/spotlight.md +78 -0
- package/docs/stability.md +24 -2
- package/docs/state.md +85 -0
- package/docs/usage.md +123 -4
- package/docs/vega.md +225 -0
- package/docs/workbench.md +78 -0
- package/fonts/doto-400.woff2 +0 -0
- package/fonts/doto-500.woff2 +0 -0
- package/fonts/doto-600.woff2 +0 -0
- package/fonts/doto-700.woff2 +0 -0
- package/fonts/doto-800.woff2 +0 -0
- package/fonts/doto-900.woff2 +0 -0
- package/glyphs/glyphs.js +6 -4
- package/llms.txt +362 -14
- package/package.json +115 -12
- package/qwik/index.d.ts +42 -54
- package/qwik/index.d.ts.map +1 -0
- package/qwik/index.js +75 -3
- package/react/index.d.ts +39 -56
- package/react/index.d.ts.map +1 -0
- package/react/index.js +67 -3
- package/solid/index.d.ts +64 -56
- package/solid/index.d.ts.map +1 -0
- package/solid/index.js +70 -3
- package/tokens/d2.d.ts +38 -0
- package/tokens/d2.js +71 -0
- package/tokens/d2.json +43 -0
- package/tokens/index.d.ts +5 -5
- package/tokens/index.js +23 -5
- package/tokens/index.json +9 -0
- package/tokens/mermaid.d.ts +23 -0
- package/tokens/mermaid.js +181 -0
- package/tokens/mermaid.json +163 -0
- package/tokens/resolved.json +45 -1
- package/tokens/skins.js +3 -2
- package/tokens/tokens.dtcg.json +26 -0
- package/tokens/vega.d.ts +34 -0
- package/tokens/vega.js +155 -0
- package/tokens/vega.json +179 -0
- package/fonts/doto-400.ttf +0 -0
- package/fonts/doto-500.ttf +0 -0
- package/fonts/doto-600.ttf +0 -0
- package/fonts/doto-700.ttf +0 -0
- package/fonts/doto-800.ttf +0 -0
- package/fonts/doto-900.ttf +0 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce, byIdInHost, collectHosts } from './internal.js';
|
|
2
|
+
import { connectRects, arrowHead, dotMark } from '../connectors/index.js';
|
|
3
|
+
|
|
4
|
+
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Draw + keep leader lines in sync. Each `[data-bronto-connector]` is an
|
|
8
|
+
* `.ui-connector` SVG overlaying a positioned container; `data-from`/`data-to`
|
|
9
|
+
* are the ids of the elements to connect. Optional `data-shape`
|
|
10
|
+
* (`straight`|`elbow`|`curve`), `data-from-side`/`data-to-side`
|
|
11
|
+
* (`top`|`right`|`bottom`|`left`|`center`), and `data-end` (`arrow`|`dot`|`none`).
|
|
12
|
+
*
|
|
13
|
+
* Bronto computes the geometry (the pure `@ponchia/ui/connectors` helpers) and
|
|
14
|
+
* sets the path; it owns no layout. Redraws on resize/scroll via a
|
|
15
|
+
* ResizeObserver + listeners. SSR-safe, idempotent per host; returns a cleanup
|
|
16
|
+
* that disconnects everything. Re-run after adding/removing connectors.
|
|
17
|
+
*/
|
|
18
|
+
export function initConnectors({ root } = {}) {
|
|
19
|
+
if (!hasDom()) return noop;
|
|
20
|
+
const host = resolveHost(root);
|
|
21
|
+
if (!host) return noop;
|
|
22
|
+
const connectors = collectHosts(host, '[data-bronto-connector]');
|
|
23
|
+
if (!connectors.length) return noop;
|
|
24
|
+
|
|
25
|
+
const draw = () => {
|
|
26
|
+
for (const svg of connectors) {
|
|
27
|
+
const from = byIdInHost(host, svg.dataset.from);
|
|
28
|
+
const to = byIdInHost(host, svg.dataset.to);
|
|
29
|
+
if (!from || !to) continue;
|
|
30
|
+
const o = svg.getBoundingClientRect();
|
|
31
|
+
const rel = (el) => {
|
|
32
|
+
const r = el.getBoundingClientRect();
|
|
33
|
+
return { x: r.left - o.left, y: r.top - o.top, width: r.width, height: r.height };
|
|
34
|
+
};
|
|
35
|
+
const {
|
|
36
|
+
d,
|
|
37
|
+
to: end,
|
|
38
|
+
angle,
|
|
39
|
+
} = connectRects({
|
|
40
|
+
fromRect: rel(from),
|
|
41
|
+
toRect: rel(to),
|
|
42
|
+
shape: svg.dataset.shape || 'straight',
|
|
43
|
+
fromSide: svg.dataset.fromSide || undefined,
|
|
44
|
+
toSide: svg.dataset.toSide || undefined,
|
|
45
|
+
});
|
|
46
|
+
let path = svg.querySelector('.ui-connector__path');
|
|
47
|
+
if (!path) {
|
|
48
|
+
path = document.createElementNS(SVGNS, 'path');
|
|
49
|
+
path.setAttribute('class', 'ui-connector__path');
|
|
50
|
+
svg.appendChild(path);
|
|
51
|
+
}
|
|
52
|
+
path.setAttribute('d', d);
|
|
53
|
+
// pathLength="1" normalises the draw animation, but it would also reframe
|
|
54
|
+
// a dashed line's user-unit dasharray — so only set it for draw connectors.
|
|
55
|
+
if (svg.classList.contains('ui-connector--draw')) path.setAttribute('pathLength', '1');
|
|
56
|
+
else path.removeAttribute('pathLength');
|
|
57
|
+
|
|
58
|
+
const kind = svg.dataset.end || 'arrow';
|
|
59
|
+
let cap = svg.querySelector('.ui-connector__end');
|
|
60
|
+
if (kind === 'none') {
|
|
61
|
+
cap?.remove();
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!cap) {
|
|
65
|
+
cap = document.createElementNS(SVGNS, 'path');
|
|
66
|
+
cap.setAttribute('class', 'ui-connector__end');
|
|
67
|
+
svg.appendChild(cap);
|
|
68
|
+
}
|
|
69
|
+
cap.setAttribute('d', kind === 'dot' ? dotMark(end, 3) : arrowHead(end, angle, 8));
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return bindOnce(host, 'connectors', () => {
|
|
74
|
+
draw();
|
|
75
|
+
const view = host.defaultView || host.ownerDocument?.defaultView || null;
|
|
76
|
+
const RO = view?.ResizeObserver;
|
|
77
|
+
const ro = RO ? new RO(draw) : null;
|
|
78
|
+
if (ro) {
|
|
79
|
+
for (const svg of connectors) {
|
|
80
|
+
if (svg.parentElement) ro.observe(svg.parentElement);
|
|
81
|
+
const f = byIdInHost(host, svg.dataset.from);
|
|
82
|
+
const t = byIdInHost(host, svg.dataset.to);
|
|
83
|
+
if (f) ro.observe(f);
|
|
84
|
+
if (t) ro.observe(t);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
view?.addEventListener('resize', draw);
|
|
88
|
+
view?.addEventListener('scroll', draw, true);
|
|
89
|
+
return () => {
|
|
90
|
+
ro?.disconnect();
|
|
91
|
+
view?.removeEventListener('resize', draw);
|
|
92
|
+
view?.removeEventListener('scroll', draw, true);
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce, collectHosts } from './internal.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Track the pointer over a plot and drive a crosshair. Each
|
|
5
|
+
* `[data-bronto-crosshair]` is the plot; it contains a `.ui-crosshair` overlay.
|
|
6
|
+
* On pointer move the behavior sets `--crosshair-x/y` (pixels within the plot)
|
|
7
|
+
* on the overlay, marks it `.is-active`, and dispatches
|
|
8
|
+
* `bronto:crosshair:move` with `{ x, y, fx, fy }` (px + 0..1 fractions);
|
|
9
|
+
* `bronto:crosshair:leave` on exit.
|
|
10
|
+
*
|
|
11
|
+
* Bronto reports WHERE the pointer is — it does not find the nearest datum or
|
|
12
|
+
* map pixels to data values (that needs the host's scales). SSR-safe,
|
|
13
|
+
* idempotent per plot; returns a cleanup function.
|
|
14
|
+
*/
|
|
15
|
+
export function initCrosshair({ root } = {}) {
|
|
16
|
+
if (!hasDom()) return noop;
|
|
17
|
+
const host = resolveHost(root);
|
|
18
|
+
if (!host) return noop;
|
|
19
|
+
const plots = collectHosts(host, '[data-bronto-crosshair]');
|
|
20
|
+
if (!plots.length) return noop;
|
|
21
|
+
|
|
22
|
+
const cleanups = [];
|
|
23
|
+
for (const plot of plots) {
|
|
24
|
+
const overlay = plot.querySelector('.ui-crosshair');
|
|
25
|
+
if (!overlay) continue;
|
|
26
|
+
const onMove = (e) => {
|
|
27
|
+
const r = plot.getBoundingClientRect();
|
|
28
|
+
if (!r.width || !r.height) return;
|
|
29
|
+
const x = e.clientX - r.left;
|
|
30
|
+
const y = e.clientY - r.top;
|
|
31
|
+
overlay.style.setProperty('--crosshair-x', `${x}px`);
|
|
32
|
+
overlay.style.setProperty('--crosshair-y', `${y}px`);
|
|
33
|
+
overlay.classList.add('is-active');
|
|
34
|
+
plot.dispatchEvent(
|
|
35
|
+
new CustomEvent('bronto:crosshair:move', {
|
|
36
|
+
bubbles: true,
|
|
37
|
+
detail: { x, y, fx: x / r.width, fy: y / r.height },
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
};
|
|
41
|
+
const onLeave = () => {
|
|
42
|
+
overlay.classList.remove('is-active');
|
|
43
|
+
plot.dispatchEvent(new CustomEvent('bronto:crosshair:leave', { bubbles: true }));
|
|
44
|
+
};
|
|
45
|
+
cleanups.push(
|
|
46
|
+
bindOnce(plot, 'crosshair', () => {
|
|
47
|
+
plot.addEventListener('pointermove', onMove);
|
|
48
|
+
plot.addEventListener('pointerleave', onLeave);
|
|
49
|
+
return () => {
|
|
50
|
+
plot.removeEventListener('pointermove', onMove);
|
|
51
|
+
plot.removeEventListener('pointerleave', onLeave);
|
|
52
|
+
};
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return () => cleanups.forEach((fn) => fn());
|
|
57
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce, byIdInHost } from './internal.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wire native <dialog> open/close glue (the one bit <dialog> can't do
|
|
5
|
+
* declaratively). Click `[data-bronto-open="dialogId"]` calls
|
|
6
|
+
* `showModal()` on `#dialogId`; click `[data-bronto-close]` closes the
|
|
7
|
+
* nearest enclosing <dialog>. Clicking the backdrop of a dialog that has
|
|
8
|
+
* `[data-bronto-dialog-light]` closes it too. On open the trigger is
|
|
9
|
+
* remembered and focus is returned to it on *every* close path (Esc,
|
|
10
|
+
* close button, backdrop light-dismiss, programmatic) via the native
|
|
11
|
+
* `close` event, so keyboard/SR users are never dropped at `<body>`.
|
|
12
|
+
* SSR-safe and idempotent; returns cleanup.
|
|
13
|
+
*
|
|
14
|
+
* `root` scopes delegated triggers (default `document`). Controlled targets are
|
|
15
|
+
* resolved root-first, then document-wide, so scoped islands win duplicate-id
|
|
16
|
+
* conflicts without breaking body/portal-mounted overlays.
|
|
17
|
+
*/
|
|
18
|
+
export function initDialog({ root } = {}) {
|
|
19
|
+
if (!hasDom()) return noop;
|
|
20
|
+
const host = resolveHost(root);
|
|
21
|
+
if (!host) return noop;
|
|
22
|
+
const managedDialogs = new WeakSet();
|
|
23
|
+
const canManageDialog = (dlg, origin) => host.contains(origin) || managedDialogs.has(dlg);
|
|
24
|
+
|
|
25
|
+
const openFrom = (opener) => {
|
|
26
|
+
const dlg = byIdInHost(host, opener.getAttribute('data-bronto-open'));
|
|
27
|
+
if (!dlg || typeof dlg.showModal !== 'function' || dlg.open) return;
|
|
28
|
+
managedDialogs.add(dlg);
|
|
29
|
+
dlg.addEventListener(
|
|
30
|
+
'close',
|
|
31
|
+
() => {
|
|
32
|
+
if (opener.isConnected && typeof opener.focus === 'function') opener.focus();
|
|
33
|
+
},
|
|
34
|
+
{ once: true },
|
|
35
|
+
);
|
|
36
|
+
dlg.showModal();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const closeFrom = (closer) => {
|
|
40
|
+
const dlg = closer.closest('dialog');
|
|
41
|
+
if (dlg && dlg.open && canManageDialog(dlg, closer)) dlg.close();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const lightDismiss = (dlg) => {
|
|
45
|
+
if (
|
|
46
|
+
dlg.tagName === 'DIALOG' &&
|
|
47
|
+
dlg.open &&
|
|
48
|
+
dlg.hasAttribute('data-bronto-dialog-light') &&
|
|
49
|
+
canManageDialog(dlg, dlg)
|
|
50
|
+
) {
|
|
51
|
+
dlg.close();
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const onClick = (e) => {
|
|
56
|
+
const opener = e.target.closest('[data-bronto-open]');
|
|
57
|
+
if (opener && host.contains(opener)) {
|
|
58
|
+
openFrom(opener);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const closer = e.target.closest('[data-bronto-close]');
|
|
62
|
+
if (closer) {
|
|
63
|
+
closeFrom(closer);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// Light-dismiss: a click whose target is the <dialog> itself is the
|
|
67
|
+
// backdrop (content sits in child elements).
|
|
68
|
+
lightDismiss(e.target);
|
|
69
|
+
};
|
|
70
|
+
return bindOnce(host, 'dialog', () => {
|
|
71
|
+
document.addEventListener('click', onClick);
|
|
72
|
+
return () => document.removeEventListener('click', onClick);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce, byIdInHost } from './internal.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Disclosure: a `[data-bronto-disclosure]` trigger toggles the element
|
|
5
|
+
* referenced by its `aria-controls` id, keeping `aria-expanded` and the
|
|
6
|
+
* panel's `hidden` attribute in sync.
|
|
7
|
+
*/
|
|
8
|
+
export function initDisclosure({ root } = {}) {
|
|
9
|
+
if (!hasDom()) return noop;
|
|
10
|
+
const host = resolveHost(root);
|
|
11
|
+
if (!host) return noop;
|
|
12
|
+
const onClick = (e) => {
|
|
13
|
+
const trigger = e.target.closest('[data-bronto-disclosure]');
|
|
14
|
+
if (!trigger || !host.contains(trigger)) return;
|
|
15
|
+
const id = trigger.getAttribute('aria-controls');
|
|
16
|
+
const panel = byIdInHost(host, id);
|
|
17
|
+
if (!panel) return;
|
|
18
|
+
const open = trigger.getAttribute('aria-expanded') === 'true';
|
|
19
|
+
trigger.setAttribute('aria-expanded', String(!open));
|
|
20
|
+
panel.hidden = open;
|
|
21
|
+
};
|
|
22
|
+
return bindOnce(host, 'disclosure', () => {
|
|
23
|
+
host.addEventListener('click', onClick);
|
|
24
|
+
return () => host.removeEventListener('click', onClick);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce, closestSafe } from './internal.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Click on `[data-bronto-dismiss]` removes the nearest ancestor matching
|
|
5
|
+
* `[data-bronto-dismissible]` (or the selector given as the attribute
|
|
6
|
+
* value). Emits a cancelable `bronto:dismiss` event first.
|
|
7
|
+
*/
|
|
8
|
+
export function dismissible({ root } = {}) {
|
|
9
|
+
if (!hasDom()) return noop;
|
|
10
|
+
const host = resolveHost(root);
|
|
11
|
+
if (!host) return noop;
|
|
12
|
+
const onClick = (e) => {
|
|
13
|
+
const btn = e.target.closest('[data-bronto-dismiss]');
|
|
14
|
+
if (!btn || !host.contains(btn)) return;
|
|
15
|
+
const sel = btn.getAttribute('data-bronto-dismiss');
|
|
16
|
+
const target = sel ? closestSafe(btn, sel) : btn.closest('[data-bronto-dismissible]');
|
|
17
|
+
if (!target) return;
|
|
18
|
+
const ev = new CustomEvent('bronto:dismiss', { bubbles: true, cancelable: true });
|
|
19
|
+
if (target.dispatchEvent(ev)) target.remove();
|
|
20
|
+
};
|
|
21
|
+
return bindOnce(host, 'dismissible', () => {
|
|
22
|
+
host.addEventListener('click', onClick);
|
|
23
|
+
return () => host.removeEventListener('click', onClick);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { hasDom, resolveHost, noop, bindOnce, nextFieldUid, collectHosts } from './internal.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Accessible form validation glue for `<form data-bronto-validate>`.
|
|
5
|
+
* Progressive enhancement over the native Constraint Validation API —
|
|
6
|
+
* the framework already ships the `[aria-invalid]` / `.ui-hint--error`
|
|
7
|
+
* styling; this wires the a11y plumbing every consumer would otherwise
|
|
8
|
+
* re-implement (and usually get wrong):
|
|
9
|
+
*
|
|
10
|
+
* - suppresses the native error bubbles (`form.noValidate`),
|
|
11
|
+
* - on blur and on submit sets `aria-invalid` and writes the browser's
|
|
12
|
+
* `validationMessage` into the field's error slot
|
|
13
|
+
* (`[data-bronto-error]` inside the `.ui-field`, falling back to a
|
|
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,
|
|
18
|
+
* - on an invalid submit, fills the form's
|
|
19
|
+
* `[data-bronto-error-summary]` (a `.ui-error-summary`) with
|
|
20
|
+
* in-page links to each bad field, focuses it, and blocks submit.
|
|
21
|
+
*
|
|
22
|
+
* Pure enhancement: with JS off the form still submits and the browser
|
|
23
|
+
* validates natively. SSR-safe, idempotent; returns a cleanup function.
|
|
24
|
+
*/
|
|
25
|
+
export function initFormValidation({ root } = {}) {
|
|
26
|
+
if (!hasDom()) return noop;
|
|
27
|
+
const host = resolveHost(root);
|
|
28
|
+
if (!host) return noop;
|
|
29
|
+
|
|
30
|
+
const ensureId = (el, prefix) => {
|
|
31
|
+
if (!el.id) el.id = `${prefix}-${nextFieldUid()}`;
|
|
32
|
+
return el.id;
|
|
33
|
+
};
|
|
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
|
+
|
|
41
|
+
const slotFor = (control) => {
|
|
42
|
+
const field = control.closest('.ui-field');
|
|
43
|
+
if (!field) return null;
|
|
44
|
+
const dedicated = field.querySelector('[data-bronto-error]');
|
|
45
|
+
if (dedicated) return dedicated;
|
|
46
|
+
return field.querySelector('.ui-hint');
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const link = (control, slot) => {
|
|
50
|
+
const slotId = ensureId(slot, 'bronto-err');
|
|
51
|
+
const ids = (control.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
|
|
52
|
+
if (!ids.includes(slotId)) {
|
|
53
|
+
ids.push(slotId);
|
|
54
|
+
control.setAttribute('aria-describedby', ids.join(' '));
|
|
55
|
+
}
|
|
56
|
+
};
|
|
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
|
+
|
|
67
|
+
const validateField = (control) => {
|
|
68
|
+
if (!control.willValidate) return true;
|
|
69
|
+
const ok = control.validity.valid;
|
|
70
|
+
const slot = slotFor(control);
|
|
71
|
+
const isHint = slot?.classList.contains('ui-hint');
|
|
72
|
+
if (ok) {
|
|
73
|
+
control.removeAttribute('aria-invalid');
|
|
74
|
+
if (slot) {
|
|
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
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
control.setAttribute('aria-invalid', 'true');
|
|
90
|
+
if (slot) {
|
|
91
|
+
if (isHint && !hintHelp.has(slot)) hintHelp.set(slot, slot.textContent);
|
|
92
|
+
slot.textContent = control.validationMessage;
|
|
93
|
+
if (isHint) slot.classList.add('ui-hint--error');
|
|
94
|
+
link(control, slot);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return ok;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const controlsOf = (form) =>
|
|
101
|
+
[...form.elements].filter(
|
|
102
|
+
(el) => el.willValidate && el.type !== 'submit' && el.type !== 'button',
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const refreshSummary = (form, invalid) => {
|
|
106
|
+
const summary = form.querySelector('[data-bronto-error-summary]');
|
|
107
|
+
if (!summary) return;
|
|
108
|
+
if (!invalid.length) {
|
|
109
|
+
summary.hidden = true;
|
|
110
|
+
summary.replaceChildren();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const title = document.createElement('p');
|
|
114
|
+
title.className = 'ui-error-summary__title';
|
|
115
|
+
title.textContent = 'There is a problem';
|
|
116
|
+
const list = document.createElement('ul');
|
|
117
|
+
list.className = 'ui-error-summary__list';
|
|
118
|
+
for (const c of invalid) {
|
|
119
|
+
const id = ensureId(c, 'bronto-field');
|
|
120
|
+
const li = document.createElement('li');
|
|
121
|
+
const a = document.createElement('a');
|
|
122
|
+
a.href = `#${id}`;
|
|
123
|
+
a.textContent = c.validationMessage;
|
|
124
|
+
a.addEventListener('click', (e) => {
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
c.focus();
|
|
127
|
+
});
|
|
128
|
+
li.appendChild(a);
|
|
129
|
+
list.appendChild(li);
|
|
130
|
+
}
|
|
131
|
+
summary.replaceChildren(title, list);
|
|
132
|
+
summary.setAttribute('role', 'alert');
|
|
133
|
+
summary.tabIndex = -1;
|
|
134
|
+
summary.hidden = false;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const onSubmit = (e) => {
|
|
138
|
+
const form = e.target.closest?.('[data-bronto-validate]');
|
|
139
|
+
if (!form) return;
|
|
140
|
+
form.noValidate = true;
|
|
141
|
+
const invalid = controlsOf(form).filter((c) => !validateField(c));
|
|
142
|
+
refreshSummary(form, invalid);
|
|
143
|
+
if (invalid.length) {
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
const summary = form.querySelector('[data-bronto-error-summary]');
|
|
146
|
+
(summary && !summary.hidden ? summary : invalid[0]).focus();
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const onBlur = (e) => {
|
|
151
|
+
const control = e.target;
|
|
152
|
+
if (!control.willValidate) return;
|
|
153
|
+
const form = control.closest?.('[data-bronto-validate]');
|
|
154
|
+
if (!form) return;
|
|
155
|
+
form.noValidate = true;
|
|
156
|
+
validateField(control);
|
|
157
|
+
const summary = form.querySelector('[data-bronto-error-summary]');
|
|
158
|
+
if (summary && !summary.hidden)
|
|
159
|
+
refreshSummary(
|
|
160
|
+
form,
|
|
161
|
+
controlsOf(form).filter((c) => !c.validity.valid),
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return bindOnce(host, 'formValidation', () => {
|
|
166
|
+
// Suppress native bubbles UP FRONT for forms present at init. The
|
|
167
|
+
// in-handler `noValidate = true` only fires after the first
|
|
168
|
+
// submit/blur, so the very first invalid real-browser submit would
|
|
169
|
+
// otherwise show the native UA bubble instead of the Bronto
|
|
170
|
+
// summary — contradicting the documented contract. (Forms added
|
|
171
|
+
// after init are still covered by the in-handler set.)
|
|
172
|
+
const forms = collectHosts(host, '[data-bronto-validate]');
|
|
173
|
+
const priorNoValidate = new Map();
|
|
174
|
+
for (const f of forms) {
|
|
175
|
+
priorNoValidate.set(f, f.noValidate);
|
|
176
|
+
f.noValidate = true;
|
|
177
|
+
}
|
|
178
|
+
host.addEventListener('submit', onSubmit, true);
|
|
179
|
+
host.addEventListener('focusout', onBlur);
|
|
180
|
+
return () => {
|
|
181
|
+
host.removeEventListener('submit', onSubmit, true);
|
|
182
|
+
host.removeEventListener('focusout', onBlur);
|
|
183
|
+
for (const [f, v] of priorNoValidate) f.noValidate = v;
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { hasDom, resolveHost, noop, collectHosts } from './internal.js';
|
|
2
|
+
import { GLYPH_SIZE, glyphCells } from '../glyphs/glyphs.js';
|
|
3
|
+
|
|
4
|
+
function restoreAttr(el, name, prev) {
|
|
5
|
+
if (prev === null) el.removeAttribute(name);
|
|
6
|
+
else el.setAttribute(name, prev);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Expand `[data-bronto-glyph="name"]` placeholders into a `.ui-dotmatrix`
|
|
11
|
+
* grid of GLYPH_SIZE² cells — the DOM counterpart to renderGlyph() from
|
|
12
|
+
* `@ponchia/ui/glyphs`, for when you'd rather drop a placeholder than inline
|
|
13
|
+
* the markup. Decorative by default (`aria-hidden`); add
|
|
14
|
+
* `data-bronto-glyph-label` to expose it as `role="img"`. An unknown glyph
|
|
15
|
+
* name is left untouched. Idempotent (skips an already-expanded host); the
|
|
16
|
+
* returned cleanup removes the cells and restores the original attributes.
|
|
17
|
+
*/
|
|
18
|
+
export function initDotGlyph({ root } = {}) {
|
|
19
|
+
if (!hasDom()) return noop;
|
|
20
|
+
const host = resolveHost(root);
|
|
21
|
+
if (!host) return noop;
|
|
22
|
+
const els = collectHosts(host, '[data-bronto-glyph]');
|
|
23
|
+
const cleanups = [];
|
|
24
|
+
|
|
25
|
+
for (const el of els) {
|
|
26
|
+
// Scope to DIRECT-child cells (the ones we append) — so a placeholder that
|
|
27
|
+
// legitimately nests its own .ui-dotmatrix is neither mis-read as already
|
|
28
|
+
// expanded here nor have its inner cells removed by cleanup below.
|
|
29
|
+
if (el.querySelector(':scope > .ui-dotmatrix__cell')) continue; // already expanded
|
|
30
|
+
const cells = glyphCells(el.getAttribute('data-bronto-glyph'));
|
|
31
|
+
if (!cells.length) continue; // unknown glyph — leave the placeholder as-is
|
|
32
|
+
|
|
33
|
+
const label = el.getAttribute('data-bronto-glyph-label');
|
|
34
|
+
// `data-bronto-glyph-solid` → square, gapless pixel glyph (legible small),
|
|
35
|
+
// the DOM counterpart to renderGlyph's `solid` option. Implies glyph-only.
|
|
36
|
+
const solid = el.hasAttribute('data-bronto-glyph-solid');
|
|
37
|
+
// `data-bronto-glyph-anim="reveal|pulse"` → decorative animation (the DOM
|
|
38
|
+
// counterpart to renderGlyph's `anim`; reduced-motion-safe via CSS).
|
|
39
|
+
const animAttr = el.getAttribute('data-bronto-glyph-anim');
|
|
40
|
+
const animClass =
|
|
41
|
+
animAttr === 'reveal'
|
|
42
|
+
? 'ui-dotmatrix--reveal'
|
|
43
|
+
: animAttr === 'pulse'
|
|
44
|
+
? 'ui-dotmatrix--pulse'
|
|
45
|
+
: null;
|
|
46
|
+
const hadAnimClass = animClass ? el.classList.contains(animClass) : false;
|
|
47
|
+
const hadMatrix = el.classList.contains('ui-dotmatrix');
|
|
48
|
+
const hadCols = el.style.getPropertyValue('--dotmatrix-cols');
|
|
49
|
+
const hadRadius = el.style.getPropertyValue('--dotmatrix-dot-radius');
|
|
50
|
+
const hadGap = el.style.getPropertyValue('--dotmatrix-gap');
|
|
51
|
+
const hadAriaHidden = el.getAttribute('aria-hidden');
|
|
52
|
+
const hadRole = el.getAttribute('role');
|
|
53
|
+
const hadAriaLabel = el.getAttribute('aria-label');
|
|
54
|
+
|
|
55
|
+
el.classList.add('ui-dotmatrix');
|
|
56
|
+
if (animClass) el.classList.add(animClass);
|
|
57
|
+
el.style.setProperty('--dotmatrix-cols', String(GLYPH_SIZE));
|
|
58
|
+
if (solid) {
|
|
59
|
+
el.style.setProperty('--dotmatrix-dot-radius', '0');
|
|
60
|
+
el.style.setProperty('--dotmatrix-gap', '0');
|
|
61
|
+
}
|
|
62
|
+
if (label) {
|
|
63
|
+
el.setAttribute('role', 'img');
|
|
64
|
+
el.setAttribute('aria-label', label);
|
|
65
|
+
el.removeAttribute('aria-hidden'); // a labelled img must not also be hidden
|
|
66
|
+
} else {
|
|
67
|
+
el.setAttribute('aria-hidden', 'true');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const frag = document.createDocumentFragment();
|
|
71
|
+
cells.forEach((c, i) => {
|
|
72
|
+
const span = document.createElement('span');
|
|
73
|
+
span.className = !c.on
|
|
74
|
+
? 'ui-dotmatrix__cell'
|
|
75
|
+
: c.tone === 'hot'
|
|
76
|
+
? 'ui-dotmatrix__cell ui-dotmatrix__cell--hot'
|
|
77
|
+
: c.tone === 'accent'
|
|
78
|
+
? 'ui-dotmatrix__cell ui-dotmatrix__cell--accent'
|
|
79
|
+
: 'ui-dotmatrix__cell';
|
|
80
|
+
if (!c.on && solid) span.style.background = 'transparent'; // glyph-only
|
|
81
|
+
if (animAttr === 'reveal') span.style.setProperty('--i', String(i)); // scan stagger
|
|
82
|
+
frag.appendChild(span);
|
|
83
|
+
});
|
|
84
|
+
el.appendChild(frag);
|
|
85
|
+
|
|
86
|
+
cleanups.push(() => {
|
|
87
|
+
el.querySelectorAll(':scope > .ui-dotmatrix__cell').forEach((n) => n.remove());
|
|
88
|
+
if (!hadMatrix) el.classList.remove('ui-dotmatrix');
|
|
89
|
+
if (animClass && !hadAnimClass) el.classList.remove(animClass);
|
|
90
|
+
if (solid) {
|
|
91
|
+
if (hadRadius) el.style.setProperty('--dotmatrix-dot-radius', hadRadius);
|
|
92
|
+
else el.style.removeProperty('--dotmatrix-dot-radius');
|
|
93
|
+
if (hadGap) el.style.setProperty('--dotmatrix-gap', hadGap);
|
|
94
|
+
else el.style.removeProperty('--dotmatrix-gap');
|
|
95
|
+
}
|
|
96
|
+
if (hadCols) el.style.setProperty('--dotmatrix-cols', hadCols);
|
|
97
|
+
else el.style.removeProperty('--dotmatrix-cols');
|
|
98
|
+
restoreAttr(el, 'aria-hidden', hadAriaHidden);
|
|
99
|
+
restoreAttr(el, 'role', hadRole);
|
|
100
|
+
restoreAttr(el, 'aria-label', hadAriaLabel);
|
|
101
|
+
// Don't leave behind empty class=""/style="" we ourselves created.
|
|
102
|
+
if (el.getAttribute('class') === '') el.removeAttribute('class');
|
|
103
|
+
if (el.getAttribute('style') === '') el.removeAttribute('style');
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return () => cleanups.forEach((fn) => fn());
|
|
108
|
+
}
|
package/behaviors/index.d.ts
CHANGED
|
@@ -156,3 +156,82 @@ export declare function toast(message: string, opts?: ToastOpts): Cleanup;
|
|
|
156
156
|
* attributes.
|
|
157
157
|
*/
|
|
158
158
|
export declare function initDotGlyph(opts?: DelegateOpts): Cleanup;
|
|
159
|
+
|
|
160
|
+
/** `bronto:legend:toggle` CustomEvent detail. `series` is the entry's
|
|
161
|
+
* `data-series`, or its 0-based index when unset. `active` is the new state
|
|
162
|
+
* (`true` ⇒ series shown). */
|
|
163
|
+
export interface LegendToggleDetail {
|
|
164
|
+
series: string | number;
|
|
165
|
+
active: boolean;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Wire `[data-bronto-legend]` interactive legends. Each `.ui-legend__item` is a
|
|
170
|
+
* `<button aria-pressed>`; activating it flips `aria-pressed`, toggles
|
|
171
|
+
* `.is-inactive`, and dispatches `bronto:legend:toggle`
|
|
172
|
+
* ({@link LegendToggleDetail}) on the legend. Bronto owns the control + its
|
|
173
|
+
* state only — the host hides its own series and owns any `aria-live`
|
|
174
|
+
* announcement (`aria-pressed="true"` ⇒ series shown). SSR-safe, idempotent per
|
|
175
|
+
* host. Returns a cleanup function.
|
|
176
|
+
*/
|
|
177
|
+
export declare function initLegend(opts?: DelegateOpts): Cleanup;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Draw + keep leader lines in sync. Each `[data-bronto-connector]` is a
|
|
181
|
+
* `.ui-connector` SVG overlaying a positioned container; `data-from`/`data-to`
|
|
182
|
+
* are the ids of the elements to connect (with optional `data-shape`,
|
|
183
|
+
* `data-from-side`/`data-to-side`, `data-end`). Computes geometry via the
|
|
184
|
+
* `@ponchia/ui/connectors` helpers and redraws on resize/scroll. Bronto owns no
|
|
185
|
+
* layout. SSR-safe, idempotent per host; returns a cleanup that disconnects
|
|
186
|
+
* observers/listeners. Re-run after adding/removing connectors.
|
|
187
|
+
*/
|
|
188
|
+
export declare function initConnectors(opts?: DelegateOpts): Cleanup;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Position a spotlight cutout over a target. Each `[data-bronto-spotlight]` is a
|
|
192
|
+
* `.ui-spotlight` overlay; `data-target` is the id of the element to highlight.
|
|
193
|
+
* Sets `--spot-x/y/w/h` and re-places on resize/scroll and when `data-target`
|
|
194
|
+
* changes. NOT a tour engine — the host owns step order/advancing/visibility.
|
|
195
|
+
* SSR-safe, idempotent per host; returns a cleanup function.
|
|
196
|
+
*/
|
|
197
|
+
export declare function initSpotlight(opts?: DelegateOpts): Cleanup;
|
|
198
|
+
|
|
199
|
+
/** `bronto:crosshair:move` CustomEvent detail — pointer position over the plot
|
|
200
|
+
* in pixels and as 0..1 fractions. Bronto reports where; mapping to data is
|
|
201
|
+
* the host's. */
|
|
202
|
+
export interface CrosshairMoveDetail {
|
|
203
|
+
x: number;
|
|
204
|
+
y: number;
|
|
205
|
+
fx: number;
|
|
206
|
+
fy: number;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Track the pointer over `[data-bronto-crosshair]` plots and drive a contained
|
|
211
|
+
* `.ui-crosshair` overlay: sets `--crosshair-x/y` (px), marks `.is-active`, and
|
|
212
|
+
* dispatches `bronto:crosshair:move` ({@link CrosshairMoveDetail}) /
|
|
213
|
+
* `bronto:crosshair:leave`. Reports the pointer position only — it does not find
|
|
214
|
+
* the nearest datum or map pixels to data. SSR-safe, idempotent per plot;
|
|
215
|
+
* returns a cleanup function.
|
|
216
|
+
*/
|
|
217
|
+
export declare function initCrosshair(opts?: DelegateOpts): Cleanup;
|
|
218
|
+
|
|
219
|
+
/** `bronto:command:select` CustomEvent detail — the chosen command's value and
|
|
220
|
+
* visible label. The host executes it; Bronto only filters and navigates. */
|
|
221
|
+
export interface CommandSelectDetail {
|
|
222
|
+
value: string;
|
|
223
|
+
label: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Filter + keyboard-navigate a DOM-authored command list inside
|
|
228
|
+
* `[data-bronto-command]` (the `.ui-command` shell). Owns ids,
|
|
229
|
+
* `role=combobox/listbox/option`, `aria-activedescendant`, a roving active item,
|
|
230
|
+
* substring filtering (hiding empty groups), full keyboard
|
|
231
|
+
* (Down/Up/Home/End/Enter/Escape), and pointer select. Emits
|
|
232
|
+
* `bronto:command:select` ({@link CommandSelectDetail}) on choose and
|
|
233
|
+
* `bronto:command:close` on Escape; the host owns the action registry, routing,
|
|
234
|
+
* and execution. No global Cmd/Ctrl+K. SSR-safe, idempotent per instance;
|
|
235
|
+
* returns a cleanup function.
|
|
236
|
+
*/
|
|
237
|
+
export declare function initCommand(opts?: DelegateOpts): Cleanup;
|