@ponchia/ui 0.4.1 → 0.5.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 +230 -8
- package/MIGRATIONS.json +92 -0
- package/README.md +9 -6
- package/annotations/index.d.ts +280 -0
- package/annotations/index.js +522 -0
- package/behaviors/carousel.js +197 -0
- package/behaviors/combobox.js +195 -0
- package/behaviors/command.js +187 -0
- package/behaviors/connectors.js +96 -0
- package/behaviors/crosshair.js +58 -0
- package/behaviors/dialog.js +73 -0
- package/behaviors/disclosure.js +25 -0
- package/behaviors/dismissible.js +24 -0
- package/behaviors/forms.js +158 -0
- package/behaviors/glyph.js +109 -0
- package/behaviors/index.d.ts +79 -0
- package/behaviors/index.js +18 -1409
- package/behaviors/internal.js +50 -0
- package/behaviors/legend.js +46 -0
- package/behaviors/menu.js +46 -0
- package/behaviors/popover.js +108 -0
- package/behaviors/spotlight.js +53 -0
- package/behaviors/table.js +109 -0
- package/behaviors/tabs.js +103 -0
- package/behaviors/theme.js +82 -0
- package/behaviors/toast.js +152 -0
- package/classes/index.d.ts +280 -2
- package/classes/index.js +313 -2
- package/connectors/index.d.ts +71 -0
- package/connectors/index.js +179 -0
- package/css/analytical.css +21 -0
- package/css/annotations.css +292 -0
- package/css/command.css +97 -0
- package/css/connectors.css +93 -0
- package/css/crosshair.css +100 -0
- package/css/feedback.css +51 -0
- package/css/fonts.css +11 -7
- package/css/generated.css +117 -0
- package/css/legend.css +268 -0
- package/css/marks.css +144 -0
- package/css/primitives.css +18 -0
- package/css/report.css +12 -31
- package/css/selection.css +46 -0
- package/css/sources.css +179 -0
- package/css/spotlight.css +104 -0
- package/css/state.css +121 -0
- package/css/tokens.css +25 -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/command.css +1 -0
- package/dist/css/connectors.css +1 -0
- package/dist/css/crosshair.css +1 -0
- package/dist/css/feedback.css +1 -1
- package/dist/css/fonts.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/primitives.css +1 -1
- package/dist/css/report.css +1 -1
- package/dist/css/selection.css +1 -0
- package/dist/css/sources.css +1 -0
- package/dist/css/spotlight.css +1 -0
- package/dist/css/state.css +1 -0
- package/dist/css/workbench.css +1 -0
- package/docs/adr/0003-theme-model.md +7 -4
- package/docs/annotations.md +345 -0
- package/docs/architecture.md +202 -0
- package/docs/command.md +95 -0
- package/docs/connectors.md +91 -0
- package/docs/crosshair.md +63 -0
- package/docs/generated.md +91 -0
- package/docs/legends.md +168 -0
- package/docs/marks.md +86 -0
- package/docs/reference.md +309 -3
- package/docs/reporting.md +49 -14
- package/docs/selection.md +40 -0
- package/docs/sources.md +110 -0
- package/docs/spotlight.md +78 -0
- package/docs/stability.md +16 -1
- package/docs/state.md +85 -0
- package/docs/usage.md +22 -0
- package/docs/workbench.md +72 -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/llms.txt +229 -6
- package/package.json +69 -4
- package/qwik/index.d.ts +5 -0
- package/qwik/index.js +20 -0
- package/react/index.d.ts +5 -0
- package/react/index.js +10 -0
- package/solid/index.d.ts +5 -0
- package/solid/index.js +10 -0
- package/tokens/index.js +9 -5
- 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,197 @@
|
|
|
1
|
+
import { hasDom, noop, bindOnce } from './internal.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Image carousel / gallery, built on CSS scroll-snap so touch + trackpad
|
|
5
|
+
* swipe (and momentum) are the browser's, not hand-rolled. This wires the
|
|
6
|
+
* parts scroll-snap can't do alone: prev/next buttons, keyboard nav, a
|
|
7
|
+
* thumbnail strip, the position counter, and ARIA — keeping a JS index in
|
|
8
|
+
* sync with the scroll position both ways.
|
|
9
|
+
*
|
|
10
|
+
* Markup: `[data-bronto-carousel]` containing a `.ui-carousel__viewport`
|
|
11
|
+
* of `.ui-carousel__slide` children; optionally
|
|
12
|
+
* `[data-bronto-carousel-prev]` / `[data-bronto-carousel-next]` controls,
|
|
13
|
+
* a `.ui-carousel__thumbs` list of `.ui-carousel__thumb` buttons, and a
|
|
14
|
+
* `.ui-carousel__status` counter slot. Add `data-bronto-carousel-loop` to
|
|
15
|
+
* wrap at the ends, `data-bronto-carousel-label` to name the region.
|
|
16
|
+
*
|
|
17
|
+
* A full-screen **lightbox** is the same markup inside a native
|
|
18
|
+
* `<dialog class="ui-lightbox">` opened by {@link initDialog}: the
|
|
19
|
+
* `<dialog>` provides the top layer, focus-trap, Escape and focus-return,
|
|
20
|
+
* so this behavior never touches focus management.
|
|
21
|
+
*
|
|
22
|
+
* Emits `bronto:change` ({ detail: { index } }) on every index change
|
|
23
|
+
* (button, key, thumbnail, or swipe). SSR-safe, idempotent per carousel;
|
|
24
|
+
* returns a cleanup function.
|
|
25
|
+
*/
|
|
26
|
+
export function initCarousel({ root } = {}) {
|
|
27
|
+
if (!hasDom()) return noop;
|
|
28
|
+
const host = root || document;
|
|
29
|
+
const boxes = [];
|
|
30
|
+
if (host !== document && host.matches?.('[data-bronto-carousel]')) boxes.push(host);
|
|
31
|
+
boxes.push(...(host.querySelectorAll?.('[data-bronto-carousel]') ?? []));
|
|
32
|
+
const cleanups = [];
|
|
33
|
+
|
|
34
|
+
for (const box of boxes) {
|
|
35
|
+
const viewport = box.querySelector('.ui-carousel__viewport');
|
|
36
|
+
if (!viewport) continue;
|
|
37
|
+
const slides = [...viewport.children].filter((el) =>
|
|
38
|
+
el.classList.contains('ui-carousel__slide'),
|
|
39
|
+
);
|
|
40
|
+
if (!slides.length) continue;
|
|
41
|
+
const n = slides.length;
|
|
42
|
+
const thumbs = [...box.querySelectorAll('.ui-carousel__thumb')];
|
|
43
|
+
const status = box.querySelector('.ui-carousel__status');
|
|
44
|
+
const prevBtn = box.querySelector('[data-bronto-carousel-prev]');
|
|
45
|
+
const nextBtn = box.querySelector('[data-bronto-carousel-next]');
|
|
46
|
+
const loop = box.hasAttribute('data-bronto-carousel-loop');
|
|
47
|
+
|
|
48
|
+
// ARIA scaffolding — pragmatic carousel semantics (not the full APG
|
|
49
|
+
// tablist), the same restraint initMenu takes.
|
|
50
|
+
viewport.setAttribute('role', 'group');
|
|
51
|
+
viewport.setAttribute('aria-roledescription', 'carousel');
|
|
52
|
+
if (!viewport.hasAttribute('aria-label'))
|
|
53
|
+
viewport.setAttribute(
|
|
54
|
+
'aria-label',
|
|
55
|
+
box.getAttribute('data-bronto-carousel-label') || 'Carousel',
|
|
56
|
+
);
|
|
57
|
+
if (!viewport.hasAttribute('tabindex')) viewport.tabIndex = 0;
|
|
58
|
+
slides.forEach((s, i) => {
|
|
59
|
+
s.setAttribute('role', 'group');
|
|
60
|
+
s.setAttribute('aria-roledescription', 'slide');
|
|
61
|
+
if (!s.hasAttribute('aria-label')) s.setAttribute('aria-label', `${i + 1} of ${n}`);
|
|
62
|
+
});
|
|
63
|
+
if (status) status.setAttribute('aria-live', 'polite');
|
|
64
|
+
for (const b of [prevBtn, nextBtn]) {
|
|
65
|
+
if (!b) continue;
|
|
66
|
+
if (b.tagName === 'BUTTON' && !b.hasAttribute('type')) b.type = 'button';
|
|
67
|
+
}
|
|
68
|
+
if (prevBtn && !prevBtn.hasAttribute('aria-label'))
|
|
69
|
+
prevBtn.setAttribute('aria-label', 'Previous');
|
|
70
|
+
if (nextBtn && !nextBtn.hasAttribute('aria-label')) nextBtn.setAttribute('aria-label', 'Next');
|
|
71
|
+
|
|
72
|
+
let index = Math.max(
|
|
73
|
+
0,
|
|
74
|
+
slides.findIndex((s) => s.hasAttribute('data-bronto-carousel-current')),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// While a button/keyboard nav is smooth-scrolling, the IntersectionObserver
|
|
78
|
+
// would observe the intermediate slides crossing its threshold and re-fire
|
|
79
|
+
// `bronto:change` for each — a feedback burst on a single Home→End jump.
|
|
80
|
+
// This flag makes the IO drive the index on *user* swipes only; a timeout
|
|
81
|
+
// (not the patchy `scrollend` event) releases it once the scroll settles.
|
|
82
|
+
let programmatic = false;
|
|
83
|
+
let progTimer = null;
|
|
84
|
+
const holdProgrammatic = () => {
|
|
85
|
+
programmatic = true;
|
|
86
|
+
if (progTimer) clearTimeout(progTimer);
|
|
87
|
+
progTimer = setTimeout(() => {
|
|
88
|
+
programmatic = false;
|
|
89
|
+
}, 500);
|
|
90
|
+
progTimer?.unref?.(); // don't keep a Node test process alive
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const render = () => {
|
|
94
|
+
if (status) status.textContent = `${index + 1} / ${n}`;
|
|
95
|
+
thumbs.forEach((t, i) => {
|
|
96
|
+
if (i === index) t.setAttribute('aria-current', 'true');
|
|
97
|
+
else t.removeAttribute('aria-current');
|
|
98
|
+
});
|
|
99
|
+
if (prevBtn && !loop) prevBtn.disabled = index === 0;
|
|
100
|
+
if (nextBtn && !loop) nextBtn.disabled = index === n - 1;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const emit = () =>
|
|
104
|
+
box.dispatchEvent(new CustomEvent('bronto:change', { detail: { index }, bubbles: true }));
|
|
105
|
+
|
|
106
|
+
// jsdom (and any layout-less env) has no scrollIntoView; it's a pure
|
|
107
|
+
// affordance, so never let it break index/aria sync — same guard as
|
|
108
|
+
// initCombobox.
|
|
109
|
+
const reveal = (el) => {
|
|
110
|
+
try {
|
|
111
|
+
el?.scrollIntoView({ block: 'nearest', inline: 'center' });
|
|
112
|
+
} catch {
|
|
113
|
+
/* no layout — ignore */
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const goTo = (i, { emitChange = true } = {}) => {
|
|
118
|
+
const next = loop ? (i + n) % n : Math.max(0, Math.min(n - 1, i));
|
|
119
|
+
const changed = next !== index;
|
|
120
|
+
index = next;
|
|
121
|
+
holdProgrammatic(); // suppress IO echo from the smooth-scroll this triggers
|
|
122
|
+
reveal(slides[index]);
|
|
123
|
+
reveal(thumbs[index]);
|
|
124
|
+
render();
|
|
125
|
+
if (changed && emitChange) emit();
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const onKey = (e) => {
|
|
129
|
+
let target = null;
|
|
130
|
+
if (e.key === 'ArrowRight') target = index + 1;
|
|
131
|
+
else if (e.key === 'ArrowLeft') target = index - 1;
|
|
132
|
+
else if (e.key === 'Home') target = 0;
|
|
133
|
+
else if (e.key === 'End') target = n - 1;
|
|
134
|
+
else return;
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
goTo(target);
|
|
137
|
+
};
|
|
138
|
+
const onClick = (e) => {
|
|
139
|
+
if (prevBtn && e.target.closest('[data-bronto-carousel-prev]')) {
|
|
140
|
+
goTo(index - 1);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (nextBtn && e.target.closest('[data-bronto-carousel-next]')) {
|
|
144
|
+
goTo(index + 1);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const thumb = e.target.closest('.ui-carousel__thumb');
|
|
148
|
+
if (thumb) {
|
|
149
|
+
const i = thumbs.indexOf(thumb);
|
|
150
|
+
if (i >= 0) goTo(i);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Swipe sync (enhancement): when the user scrolls the viewport, snap
|
|
155
|
+
// the JS index to the slide that's settled into view. Feature-detected
|
|
156
|
+
// so the buttons/keyboard still work where IntersectionObserver is
|
|
157
|
+
// absent (jsdom, older engines).
|
|
158
|
+
let io = null;
|
|
159
|
+
if (typeof IntersectionObserver === 'function') {
|
|
160
|
+
io = new IntersectionObserver(
|
|
161
|
+
(entries) => {
|
|
162
|
+
if (programmatic) return; // ignore the echo of a button/key-driven scroll
|
|
163
|
+
let best = null;
|
|
164
|
+
for (const ent of entries) {
|
|
165
|
+
if (ent.isIntersecting && (!best || ent.intersectionRatio > best.intersectionRatio))
|
|
166
|
+
best = ent;
|
|
167
|
+
}
|
|
168
|
+
if (!best) return;
|
|
169
|
+
const i = slides.indexOf(best.target);
|
|
170
|
+
if (i >= 0 && i !== index) {
|
|
171
|
+
index = i;
|
|
172
|
+
render();
|
|
173
|
+
reveal(thumbs[index]);
|
|
174
|
+
emit();
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
{ root: viewport, threshold: 0.6 },
|
|
178
|
+
);
|
|
179
|
+
slides.forEach((s) => io.observe(s));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
render();
|
|
183
|
+
const bound = bindOnce(box, 'carousel', () => {
|
|
184
|
+
viewport.addEventListener('keydown', onKey);
|
|
185
|
+
box.addEventListener('click', onClick);
|
|
186
|
+
return () => {
|
|
187
|
+
viewport.removeEventListener('keydown', onKey);
|
|
188
|
+
box.removeEventListener('click', onClick);
|
|
189
|
+
io?.disconnect();
|
|
190
|
+
if (progTimer) clearTimeout(progTimer);
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
cleanups.push(bound);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return () => cleanups.forEach((fn) => fn());
|
|
197
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { hasDom, noop, bindOnce, nextFieldUid } from './internal.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Editable combobox with a filtered listbox popup, implementing the
|
|
5
|
+
* WAI-ARIA APG combobox pattern (the widget the framework most lacked
|
|
6
|
+
* and consumers most often build badly). Dependency-free, no
|
|
7
|
+
* positioning library — the list is CSS-anchored under the input.
|
|
8
|
+
*
|
|
9
|
+
* Markup: `[data-bronto-combobox]` wrapping an `<input role="combobox">`
|
|
10
|
+
* (`.ui-combobox__input`) and a `<ul role="listbox">`
|
|
11
|
+
* (`.ui-combobox__list`) of `<li role="option">` (`.ui-combobox__option`,
|
|
12
|
+
* optional `data-value`). An optional `.ui-combobox__empty` shows when
|
|
13
|
+
* nothing matches. The behavior owns ids, `aria-expanded`,
|
|
14
|
+
* `aria-controls`, `aria-activedescendant`, roving active option,
|
|
15
|
+
* type-to-filter, full keyboard (Down/Up/Home/End/Enter/Escape/Tab),
|
|
16
|
+
* pointer select, and outside-click close; it emits a `bronto:change`
|
|
17
|
+
* CustomEvent ({ detail: { value } }) on selection. SSR-safe,
|
|
18
|
+
* idempotent per instance; returns a cleanup function.
|
|
19
|
+
*/
|
|
20
|
+
export function initCombobox({ root } = {}) {
|
|
21
|
+
if (!hasDom()) return noop;
|
|
22
|
+
const host = root || document;
|
|
23
|
+
const boxes = [];
|
|
24
|
+
if (host !== document && host.matches?.('[data-bronto-combobox]')) boxes.push(host);
|
|
25
|
+
boxes.push(...(host.querySelectorAll?.('[data-bronto-combobox]') ?? []));
|
|
26
|
+
const cleanups = [];
|
|
27
|
+
|
|
28
|
+
for (const box of boxes) {
|
|
29
|
+
const input = box.querySelector('[role="combobox"], .ui-combobox__input');
|
|
30
|
+
const list = box.querySelector('[role="listbox"], .ui-combobox__list');
|
|
31
|
+
if (!input || !list) continue;
|
|
32
|
+
const empty = box.querySelector('.ui-combobox__empty');
|
|
33
|
+
const options = [...list.querySelectorAll('[role="option"], .ui-combobox__option')];
|
|
34
|
+
|
|
35
|
+
const listId = list.id || (list.id = `bronto-cb-list-${nextFieldUid()}`);
|
|
36
|
+
options.forEach((o, i) => {
|
|
37
|
+
if (!o.id) o.id = `${listId}-opt-${i}`;
|
|
38
|
+
o.setAttribute('role', 'option');
|
|
39
|
+
});
|
|
40
|
+
list.setAttribute('role', 'listbox');
|
|
41
|
+
input.setAttribute('role', 'combobox');
|
|
42
|
+
input.setAttribute('aria-controls', listId);
|
|
43
|
+
input.setAttribute('aria-autocomplete', 'list');
|
|
44
|
+
input.setAttribute('aria-expanded', 'false');
|
|
45
|
+
input.setAttribute('autocomplete', 'off');
|
|
46
|
+
list.hidden = true;
|
|
47
|
+
|
|
48
|
+
let active = -1;
|
|
49
|
+
const visible = () => options.filter((o) => !o.hidden);
|
|
50
|
+
|
|
51
|
+
const setActive = (opt) => {
|
|
52
|
+
options.forEach((o) => o.classList.remove('is-active'));
|
|
53
|
+
if (opt) {
|
|
54
|
+
opt.classList.add('is-active');
|
|
55
|
+
input.setAttribute('aria-activedescendant', opt.id);
|
|
56
|
+
// jsdom's scrollIntoView throws "Not implemented"; it is a
|
|
57
|
+
// pure affordance, so never let it break keyboard nav.
|
|
58
|
+
try {
|
|
59
|
+
opt.scrollIntoView({ block: 'nearest' });
|
|
60
|
+
} catch {
|
|
61
|
+
/* non-DOM/headless environment — ignore */
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
input.removeAttribute('aria-activedescendant');
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const open = () => {
|
|
69
|
+
if (!list.hidden) return;
|
|
70
|
+
list.hidden = false;
|
|
71
|
+
input.setAttribute('aria-expanded', 'true');
|
|
72
|
+
};
|
|
73
|
+
const close = () => {
|
|
74
|
+
list.hidden = true;
|
|
75
|
+
input.setAttribute('aria-expanded', 'false');
|
|
76
|
+
active = -1;
|
|
77
|
+
setActive(null);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const filter = () => {
|
|
81
|
+
const q = input.value.trim().toLowerCase();
|
|
82
|
+
let any = false;
|
|
83
|
+
for (const o of options) {
|
|
84
|
+
const match = !q || o.textContent.toLowerCase().includes(q);
|
|
85
|
+
o.hidden = !match;
|
|
86
|
+
if (match) any = true;
|
|
87
|
+
}
|
|
88
|
+
if (empty) empty.hidden = any;
|
|
89
|
+
// The active option may have just been filtered out — drop the
|
|
90
|
+
// stale activedescendant so Enter can't select a hidden option.
|
|
91
|
+
if (active >= 0 && options[active]?.hidden) {
|
|
92
|
+
active = -1;
|
|
93
|
+
setActive(null);
|
|
94
|
+
}
|
|
95
|
+
open();
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const select = (opt) => {
|
|
99
|
+
input.value = opt.dataset.value ?? opt.textContent.trim();
|
|
100
|
+
options.forEach((o) => o.setAttribute('aria-selected', String(o === opt)));
|
|
101
|
+
close();
|
|
102
|
+
input.focus();
|
|
103
|
+
box.dispatchEvent(
|
|
104
|
+
new CustomEvent('bronto:change', {
|
|
105
|
+
detail: { value: input.value },
|
|
106
|
+
bubbles: true,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const move = (delta) => {
|
|
112
|
+
const vis = visible();
|
|
113
|
+
if (!vis.length) return;
|
|
114
|
+
open();
|
|
115
|
+
const curIdx = vis.indexOf(options[active]);
|
|
116
|
+
let next = curIdx + delta;
|
|
117
|
+
if (next < 0) next = vis.length - 1;
|
|
118
|
+
if (next >= vis.length) next = 0;
|
|
119
|
+
active = options.indexOf(vis[next]);
|
|
120
|
+
setActive(options[active]);
|
|
121
|
+
};
|
|
122
|
+
const activateEdge = (which) => {
|
|
123
|
+
if (list.hidden) return false;
|
|
124
|
+
const v = visible();
|
|
125
|
+
if (!v.length) return true;
|
|
126
|
+
active = options.indexOf(which === 'first' ? v[0] : v[v.length - 1]);
|
|
127
|
+
setActive(options[active]);
|
|
128
|
+
return true;
|
|
129
|
+
};
|
|
130
|
+
const selectActive = () => {
|
|
131
|
+
if (list.hidden || active < 0 || options[active].hidden) return false;
|
|
132
|
+
select(options[active]);
|
|
133
|
+
return true;
|
|
134
|
+
};
|
|
135
|
+
const closeIfOpen = () => {
|
|
136
|
+
if (list.hidden) return false;
|
|
137
|
+
close();
|
|
138
|
+
return true;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const onInput = () => filter();
|
|
142
|
+
const onKey = (e) => {
|
|
143
|
+
switch (e.key) {
|
|
144
|
+
case 'ArrowDown':
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
list.hidden ? filter() : move(1);
|
|
147
|
+
break;
|
|
148
|
+
case 'ArrowUp':
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
move(-1);
|
|
151
|
+
break;
|
|
152
|
+
case 'Home':
|
|
153
|
+
if (activateEdge('first')) e.preventDefault();
|
|
154
|
+
break;
|
|
155
|
+
case 'End':
|
|
156
|
+
if (activateEdge('last')) e.preventDefault();
|
|
157
|
+
break;
|
|
158
|
+
case 'Enter':
|
|
159
|
+
if (selectActive()) e.preventDefault();
|
|
160
|
+
break;
|
|
161
|
+
case 'Escape':
|
|
162
|
+
if (closeIfOpen()) e.preventDefault();
|
|
163
|
+
break;
|
|
164
|
+
case 'Tab':
|
|
165
|
+
close();
|
|
166
|
+
break;
|
|
167
|
+
default:
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const onOptionClick = (e) => {
|
|
172
|
+
const opt = e.target.closest('[role="option"], .ui-combobox__option');
|
|
173
|
+
if (opt) select(opt);
|
|
174
|
+
};
|
|
175
|
+
const onDocClick = (e) => {
|
|
176
|
+
if (!box.contains(e.target)) close();
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const bound = bindOnce(box, 'combobox', () => {
|
|
180
|
+
input.addEventListener('input', onInput);
|
|
181
|
+
input.addEventListener('keydown', onKey);
|
|
182
|
+
list.addEventListener('click', onOptionClick);
|
|
183
|
+
document.addEventListener('click', onDocClick);
|
|
184
|
+
return () => {
|
|
185
|
+
input.removeEventListener('input', onInput);
|
|
186
|
+
input.removeEventListener('keydown', onKey);
|
|
187
|
+
list.removeEventListener('click', onOptionClick);
|
|
188
|
+
document.removeEventListener('click', onDocClick);
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
cleanups.push(bound);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return () => cleanups.forEach((fn) => fn());
|
|
195
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { hasDom, noop, bindOnce, nextFieldUid } from './internal.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Command palette — filter + keyboard-navigate a DOM-authored command list.
|
|
5
|
+
* The CSS shell (`.ui-command`) is opt-in; this wires the listbox behavior the
|
|
6
|
+
* shell needs. Bronto filters and navigates; the HOST owns the action registry,
|
|
7
|
+
* permission checks, routing, async effects, and command execution (it listens
|
|
8
|
+
* for `bronto:command:select`). There is no global Cmd/Ctrl+K — open the palette
|
|
9
|
+
* yourself (e.g. a `<dialog>` via `initDialog`).
|
|
10
|
+
*
|
|
11
|
+
* Markup: `[data-bronto-command]` wrapping an `<input>` (`.ui-command__input`)
|
|
12
|
+
* and a list (`.ui-command__list`) of `.ui-command__item` rows (optional
|
|
13
|
+
* `data-value`), interleaved with `.ui-command__group` labels and an optional
|
|
14
|
+
* `.ui-command__empty`. The behavior owns ids, `role=combobox/listbox/option`,
|
|
15
|
+
* `aria-activedescendant`, a roving active item, substring filtering (hiding
|
|
16
|
+
* empty groups), full keyboard (Down/Up/Home/End/Enter/Escape), and pointer
|
|
17
|
+
* select. It emits `bronto:command:select` ({ detail: { value, label } }) on
|
|
18
|
+
* choose and `bronto:command:close` on Escape. SSR-safe, idempotent per
|
|
19
|
+
* instance; returns a cleanup function.
|
|
20
|
+
*/
|
|
21
|
+
export function initCommand({ root } = {}) {
|
|
22
|
+
if (!hasDom()) return noop;
|
|
23
|
+
const host = root || document;
|
|
24
|
+
const palettes = [];
|
|
25
|
+
if (host !== document && host.matches?.('[data-bronto-command]')) palettes.push(host);
|
|
26
|
+
palettes.push(...(host.querySelectorAll?.('[data-bronto-command]') ?? []));
|
|
27
|
+
const cleanups = [];
|
|
28
|
+
|
|
29
|
+
for (const box of palettes) {
|
|
30
|
+
const input = box.querySelector('.ui-command__input, input');
|
|
31
|
+
const list = box.querySelector('.ui-command__list, [role="listbox"]');
|
|
32
|
+
if (!input || !list) continue;
|
|
33
|
+
const empty = box.querySelector('.ui-command__empty');
|
|
34
|
+
const items = [...list.querySelectorAll('.ui-command__item, [role="option"]')];
|
|
35
|
+
const groups = [...list.querySelectorAll('.ui-command__group')];
|
|
36
|
+
|
|
37
|
+
const listId = list.id || (list.id = `bronto-cmd-${nextFieldUid()}`);
|
|
38
|
+
items.forEach((it, i) => {
|
|
39
|
+
if (!it.id) it.id = `${listId}-opt-${i}`;
|
|
40
|
+
it.setAttribute('role', 'option');
|
|
41
|
+
});
|
|
42
|
+
groups.forEach((g) => g.setAttribute('role', 'presentation'));
|
|
43
|
+
list.setAttribute('role', 'listbox');
|
|
44
|
+
input.setAttribute('role', 'combobox');
|
|
45
|
+
input.setAttribute('aria-controls', listId);
|
|
46
|
+
input.setAttribute('aria-autocomplete', 'list');
|
|
47
|
+
input.setAttribute('aria-expanded', 'true');
|
|
48
|
+
input.setAttribute('autocomplete', 'off');
|
|
49
|
+
|
|
50
|
+
let active = -1;
|
|
51
|
+
const visible = () => items.filter((it) => !it.hidden);
|
|
52
|
+
|
|
53
|
+
const setActive = (item) => {
|
|
54
|
+
items.forEach((it) => {
|
|
55
|
+
it.classList.toggle('is-active', it === item);
|
|
56
|
+
it.setAttribute('aria-selected', String(it === item));
|
|
57
|
+
});
|
|
58
|
+
if (item) {
|
|
59
|
+
active = items.indexOf(item);
|
|
60
|
+
input.setAttribute('aria-activedescendant', item.id);
|
|
61
|
+
try {
|
|
62
|
+
item.scrollIntoView({ block: 'nearest' });
|
|
63
|
+
} catch {
|
|
64
|
+
/* headless — scrollIntoView is a pure affordance */
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
active = -1;
|
|
68
|
+
input.removeAttribute('aria-activedescendant');
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Hide a group whose items are all filtered out.
|
|
73
|
+
const syncGroups = () => {
|
|
74
|
+
for (const g of groups) {
|
|
75
|
+
let any = false;
|
|
76
|
+
for (
|
|
77
|
+
let n = g.nextElementSibling;
|
|
78
|
+
n && !n.matches('.ui-command__group');
|
|
79
|
+
n = n.nextElementSibling
|
|
80
|
+
) {
|
|
81
|
+
if (n.matches('.ui-command__item, [role="option"]') && !n.hidden) any = true;
|
|
82
|
+
}
|
|
83
|
+
g.hidden = !any;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const filter = () => {
|
|
88
|
+
const q = input.value.trim().toLowerCase();
|
|
89
|
+
let any = false;
|
|
90
|
+
for (const it of items) {
|
|
91
|
+
const match = !q || it.textContent.toLowerCase().includes(q);
|
|
92
|
+
it.hidden = !match;
|
|
93
|
+
if (match) any = true;
|
|
94
|
+
}
|
|
95
|
+
syncGroups();
|
|
96
|
+
if (empty) empty.hidden = any;
|
|
97
|
+
const vis = visible();
|
|
98
|
+
setActive(vis[0] || null);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const move = (delta) => {
|
|
102
|
+
const vis = visible();
|
|
103
|
+
if (!vis.length) return;
|
|
104
|
+
const cur = vis.indexOf(items[active]);
|
|
105
|
+
let next = cur + delta;
|
|
106
|
+
if (next < 0) next = vis.length - 1;
|
|
107
|
+
if (next >= vis.length) next = 0;
|
|
108
|
+
setActive(vis[next]);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const choose = (item) => {
|
|
112
|
+
if (!item || item.hidden) return;
|
|
113
|
+
// Label = the command name only — strip the shortcut/meta hints so the
|
|
114
|
+
// host doesn't get "Open settings G S".
|
|
115
|
+
const clone = item.cloneNode(true);
|
|
116
|
+
clone.querySelectorAll('.ui-command__shortcut, .ui-command__meta').forEach((n) => n.remove());
|
|
117
|
+
const label = clone.textContent.replace(/\s+/g, ' ').trim();
|
|
118
|
+
box.dispatchEvent(
|
|
119
|
+
new CustomEvent('bronto:command:select', {
|
|
120
|
+
detail: { value: item.dataset.value ?? label, label },
|
|
121
|
+
bubbles: true,
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const onInput = () => filter();
|
|
127
|
+
const onKey = (e) => {
|
|
128
|
+
switch (e.key) {
|
|
129
|
+
case 'ArrowDown':
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
move(1);
|
|
132
|
+
break;
|
|
133
|
+
case 'ArrowUp':
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
move(-1);
|
|
136
|
+
break;
|
|
137
|
+
case 'Home': {
|
|
138
|
+
const v = visible();
|
|
139
|
+
if (v.length) {
|
|
140
|
+
setActive(v[0]);
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case 'End': {
|
|
146
|
+
const v = visible();
|
|
147
|
+
if (v.length) {
|
|
148
|
+
setActive(v[v.length - 1]);
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case 'Enter':
|
|
154
|
+
if (active >= 0 && !items[active].hidden) {
|
|
155
|
+
choose(items[active]);
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
case 'Escape':
|
|
160
|
+
box.dispatchEvent(new CustomEvent('bronto:command:close', { bubbles: true }));
|
|
161
|
+
break;
|
|
162
|
+
default:
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
const onClick = (e) => {
|
|
167
|
+
const item = e.target.closest('.ui-command__item, [role="option"]');
|
|
168
|
+
if (item && list.contains(item)) choose(item);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const bound = bindOnce(box, 'command', () => {
|
|
172
|
+
input.addEventListener('input', onInput);
|
|
173
|
+
input.addEventListener('keydown', onKey);
|
|
174
|
+
list.addEventListener('click', onClick);
|
|
175
|
+
return () => {
|
|
176
|
+
input.removeEventListener('input', onInput);
|
|
177
|
+
input.removeEventListener('keydown', onKey);
|
|
178
|
+
list.removeEventListener('click', onClick);
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
// Seed the initial active item (first visible).
|
|
182
|
+
filter();
|
|
183
|
+
cleanups.push(bound);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return () => cleanups.forEach((fn) => fn());
|
|
187
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { hasDom, noop, bindOnce, byIdInHost } 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 = root || document;
|
|
21
|
+
const connectors = [];
|
|
22
|
+
if (host !== document && host.matches?.('[data-bronto-connector]')) connectors.push(host);
|
|
23
|
+
connectors.push(...host.querySelectorAll('[data-bronto-connector]'));
|
|
24
|
+
if (!connectors.length) return noop;
|
|
25
|
+
|
|
26
|
+
const draw = () => {
|
|
27
|
+
for (const svg of connectors) {
|
|
28
|
+
const from = byIdInHost(host, svg.dataset.from);
|
|
29
|
+
const to = byIdInHost(host, svg.dataset.to);
|
|
30
|
+
if (!from || !to) continue;
|
|
31
|
+
const o = svg.getBoundingClientRect();
|
|
32
|
+
const rel = (el) => {
|
|
33
|
+
const r = el.getBoundingClientRect();
|
|
34
|
+
return { x: r.left - o.left, y: r.top - o.top, width: r.width, height: r.height };
|
|
35
|
+
};
|
|
36
|
+
const {
|
|
37
|
+
d,
|
|
38
|
+
to: end,
|
|
39
|
+
angle,
|
|
40
|
+
} = connectRects({
|
|
41
|
+
fromRect: rel(from),
|
|
42
|
+
toRect: rel(to),
|
|
43
|
+
shape: svg.dataset.shape || 'straight',
|
|
44
|
+
fromSide: svg.dataset.fromSide || undefined,
|
|
45
|
+
toSide: svg.dataset.toSide || undefined,
|
|
46
|
+
});
|
|
47
|
+
let path = svg.querySelector('.ui-connector__path');
|
|
48
|
+
if (!path) {
|
|
49
|
+
path = document.createElementNS(SVGNS, 'path');
|
|
50
|
+
path.setAttribute('class', 'ui-connector__path');
|
|
51
|
+
svg.appendChild(path);
|
|
52
|
+
}
|
|
53
|
+
path.setAttribute('d', d);
|
|
54
|
+
// pathLength="1" normalises the draw animation, but it would also reframe
|
|
55
|
+
// a dashed line's user-unit dasharray — so only set it for draw connectors.
|
|
56
|
+
if (svg.classList.contains('ui-connector--draw')) path.setAttribute('pathLength', '1');
|
|
57
|
+
else path.removeAttribute('pathLength');
|
|
58
|
+
|
|
59
|
+
const kind = svg.dataset.end || 'arrow';
|
|
60
|
+
let cap = svg.querySelector('.ui-connector__end');
|
|
61
|
+
if (kind === 'none') {
|
|
62
|
+
cap?.remove();
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!cap) {
|
|
66
|
+
cap = document.createElementNS(SVGNS, 'path');
|
|
67
|
+
cap.setAttribute('class', 'ui-connector__end');
|
|
68
|
+
svg.appendChild(cap);
|
|
69
|
+
}
|
|
70
|
+
cap.setAttribute('d', kind === 'dot' ? dotMark(end, 3) : arrowHead(end, angle, 8));
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return bindOnce(host, 'connectors', () => {
|
|
75
|
+
draw();
|
|
76
|
+
const view = host.defaultView || host.ownerDocument?.defaultView || null;
|
|
77
|
+
const RO = view?.ResizeObserver;
|
|
78
|
+
const ro = RO ? new RO(draw) : null;
|
|
79
|
+
if (ro) {
|
|
80
|
+
for (const svg of connectors) {
|
|
81
|
+
if (svg.parentElement) ro.observe(svg.parentElement);
|
|
82
|
+
const f = byIdInHost(host, svg.dataset.from);
|
|
83
|
+
const t = byIdInHost(host, svg.dataset.to);
|
|
84
|
+
if (f) ro.observe(f);
|
|
85
|
+
if (t) ro.observe(t);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
view?.addEventListener('resize', draw);
|
|
89
|
+
view?.addEventListener('scroll', draw, true);
|
|
90
|
+
return () => {
|
|
91
|
+
ro?.disconnect();
|
|
92
|
+
view?.removeEventListener('resize', draw);
|
|
93
|
+
view?.removeEventListener('scroll', draw, true);
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
}
|