@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,198 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hasDom,
|
|
3
|
+
resolveHost,
|
|
4
|
+
noop,
|
|
5
|
+
bindOnce,
|
|
6
|
+
scrollIntoViewSafe,
|
|
7
|
+
collectHosts,
|
|
8
|
+
} from './internal.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Image carousel / gallery, built on CSS scroll-snap so touch + trackpad
|
|
12
|
+
* swipe (and momentum) are the browser's, not hand-rolled. This wires the
|
|
13
|
+
* parts scroll-snap can't do alone: prev/next buttons, keyboard nav, a
|
|
14
|
+
* thumbnail strip, the position counter, and ARIA — keeping a JS index in
|
|
15
|
+
* sync with the scroll position both ways.
|
|
16
|
+
*
|
|
17
|
+
* Markup: `[data-bronto-carousel]` containing a `.ui-carousel__viewport`
|
|
18
|
+
* of `.ui-carousel__slide` children; optionally
|
|
19
|
+
* `[data-bronto-carousel-prev]` / `[data-bronto-carousel-next]` controls,
|
|
20
|
+
* a `.ui-carousel__thumbs` list of `.ui-carousel__thumb` buttons, and a
|
|
21
|
+
* `.ui-carousel__status` counter slot. Add `data-bronto-carousel-loop` to
|
|
22
|
+
* wrap at the ends, `data-bronto-carousel-label` to name the region.
|
|
23
|
+
*
|
|
24
|
+
* A full-screen **lightbox** is the same markup inside a native
|
|
25
|
+
* `<dialog class="ui-lightbox">` opened by {@link initDialog}: the
|
|
26
|
+
* `<dialog>` provides the top layer, focus-trap, Escape and focus-return,
|
|
27
|
+
* so this behavior never touches focus management.
|
|
28
|
+
*
|
|
29
|
+
* Emits `bronto:change` ({ detail: { index } }) on every index change
|
|
30
|
+
* (button, key, thumbnail, or swipe). SSR-safe, idempotent per carousel;
|
|
31
|
+
* returns a cleanup function.
|
|
32
|
+
*/
|
|
33
|
+
export function initCarousel({ root } = {}) {
|
|
34
|
+
if (!hasDom()) return noop;
|
|
35
|
+
const host = resolveHost(root);
|
|
36
|
+
if (!host) return noop;
|
|
37
|
+
const boxes = collectHosts(host, '[data-bronto-carousel]');
|
|
38
|
+
const cleanups = [];
|
|
39
|
+
|
|
40
|
+
for (const box of boxes) {
|
|
41
|
+
const viewport = box.querySelector('.ui-carousel__viewport');
|
|
42
|
+
if (!viewport) continue;
|
|
43
|
+
const slides = [...viewport.children].filter((el) =>
|
|
44
|
+
el.classList.contains('ui-carousel__slide'),
|
|
45
|
+
);
|
|
46
|
+
if (!slides.length) continue;
|
|
47
|
+
const n = slides.length;
|
|
48
|
+
const thumbs = [...box.querySelectorAll('.ui-carousel__thumb')];
|
|
49
|
+
const status = box.querySelector('.ui-carousel__status');
|
|
50
|
+
const prevBtn = box.querySelector('[data-bronto-carousel-prev]');
|
|
51
|
+
const nextBtn = box.querySelector('[data-bronto-carousel-next]');
|
|
52
|
+
const loop = box.hasAttribute('data-bronto-carousel-loop');
|
|
53
|
+
|
|
54
|
+
// ARIA scaffolding — pragmatic carousel semantics (not the full APG
|
|
55
|
+
// tablist), the same restraint initMenu takes.
|
|
56
|
+
viewport.setAttribute('role', 'group');
|
|
57
|
+
viewport.setAttribute('aria-roledescription', 'carousel');
|
|
58
|
+
if (!viewport.hasAttribute('aria-label'))
|
|
59
|
+
viewport.setAttribute(
|
|
60
|
+
'aria-label',
|
|
61
|
+
box.getAttribute('data-bronto-carousel-label') || 'Carousel',
|
|
62
|
+
);
|
|
63
|
+
if (!viewport.hasAttribute('tabindex')) viewport.tabIndex = 0;
|
|
64
|
+
slides.forEach((s, i) => {
|
|
65
|
+
s.setAttribute('role', 'group');
|
|
66
|
+
s.setAttribute('aria-roledescription', 'slide');
|
|
67
|
+
if (!s.hasAttribute('aria-label')) s.setAttribute('aria-label', `${i + 1} of ${n}`);
|
|
68
|
+
});
|
|
69
|
+
if (status) status.setAttribute('aria-live', 'polite');
|
|
70
|
+
for (const b of [prevBtn, nextBtn]) {
|
|
71
|
+
if (!b) continue;
|
|
72
|
+
if (b.tagName === 'BUTTON' && !b.hasAttribute('type')) b.type = 'button';
|
|
73
|
+
}
|
|
74
|
+
if (prevBtn && !prevBtn.hasAttribute('aria-label'))
|
|
75
|
+
prevBtn.setAttribute('aria-label', 'Previous');
|
|
76
|
+
if (nextBtn && !nextBtn.hasAttribute('aria-label')) nextBtn.setAttribute('aria-label', 'Next');
|
|
77
|
+
|
|
78
|
+
let index = Math.max(
|
|
79
|
+
0,
|
|
80
|
+
slides.findIndex((s) => s.hasAttribute('data-bronto-carousel-current')),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// While a button/keyboard nav is smooth-scrolling, the IntersectionObserver
|
|
84
|
+
// would observe the intermediate slides crossing its threshold and re-fire
|
|
85
|
+
// `bronto:change` for each — a feedback burst on a single Home→End jump.
|
|
86
|
+
// This flag makes the IO drive the index on *user* swipes only; a timeout
|
|
87
|
+
// (not the patchy `scrollend` event) releases it once the scroll settles.
|
|
88
|
+
let programmatic = false;
|
|
89
|
+
let progTimer = null;
|
|
90
|
+
const holdProgrammatic = () => {
|
|
91
|
+
programmatic = true;
|
|
92
|
+
if (progTimer) clearTimeout(progTimer);
|
|
93
|
+
progTimer = setTimeout(() => {
|
|
94
|
+
programmatic = false;
|
|
95
|
+
}, 500);
|
|
96
|
+
progTimer?.unref?.(); // don't keep a Node test process alive
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const render = () => {
|
|
100
|
+
if (status) status.textContent = `${index + 1} / ${n}`;
|
|
101
|
+
thumbs.forEach((t, i) => {
|
|
102
|
+
if (i === index) t.setAttribute('aria-current', 'true');
|
|
103
|
+
else t.removeAttribute('aria-current');
|
|
104
|
+
});
|
|
105
|
+
if (prevBtn && !loop) prevBtn.disabled = index === 0;
|
|
106
|
+
if (nextBtn && !loop) nextBtn.disabled = index === n - 1;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const emit = () =>
|
|
110
|
+
box.dispatchEvent(new CustomEvent('bronto:change', { detail: { index }, bubbles: true }));
|
|
111
|
+
|
|
112
|
+
const reveal = (el) => scrollIntoViewSafe(el, { block: 'nearest', inline: 'center' });
|
|
113
|
+
|
|
114
|
+
const goTo = (i, { emitChange = true } = {}) => {
|
|
115
|
+
const next = loop ? (i + n) % n : Math.max(0, Math.min(n - 1, i));
|
|
116
|
+
const changed = next !== index;
|
|
117
|
+
index = next;
|
|
118
|
+
holdProgrammatic(); // suppress IO echo from the smooth-scroll this triggers
|
|
119
|
+
reveal(slides[index]);
|
|
120
|
+
reveal(thumbs[index]);
|
|
121
|
+
render();
|
|
122
|
+
if (changed && emitChange) emit();
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const onKey = (e) => {
|
|
126
|
+
let target = null;
|
|
127
|
+
if (e.key === 'ArrowRight') target = index + 1;
|
|
128
|
+
else if (e.key === 'ArrowLeft') target = index - 1;
|
|
129
|
+
else if (e.key === 'Home') target = 0;
|
|
130
|
+
else if (e.key === 'End') target = n - 1;
|
|
131
|
+
else return;
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
goTo(target);
|
|
134
|
+
};
|
|
135
|
+
const onClick = (e) => {
|
|
136
|
+
if (prevBtn && e.target.closest('[data-bronto-carousel-prev]')) {
|
|
137
|
+
goTo(index - 1);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (nextBtn && e.target.closest('[data-bronto-carousel-next]')) {
|
|
141
|
+
goTo(index + 1);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const thumb = e.target.closest('.ui-carousel__thumb');
|
|
145
|
+
if (thumb) {
|
|
146
|
+
const i = thumbs.indexOf(thumb);
|
|
147
|
+
if (i >= 0) goTo(i);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Swipe sync (enhancement): when the user scrolls the viewport, snap
|
|
152
|
+
// the JS index to the slide that's settled into view. Feature-detected
|
|
153
|
+
// so the buttons/keyboard still work where IntersectionObserver is
|
|
154
|
+
// absent (jsdom, older engines).
|
|
155
|
+
let io = null;
|
|
156
|
+
if (typeof IntersectionObserver === 'function') {
|
|
157
|
+
io = new IntersectionObserver(
|
|
158
|
+
(entries) => {
|
|
159
|
+
if (programmatic) return; // ignore the echo of a button/key-driven scroll
|
|
160
|
+
let best = null;
|
|
161
|
+
for (const ent of entries) {
|
|
162
|
+
if (ent.isIntersecting && (!best || ent.intersectionRatio > best.intersectionRatio))
|
|
163
|
+
best = ent;
|
|
164
|
+
}
|
|
165
|
+
if (!best) return;
|
|
166
|
+
const i = slides.indexOf(best.target);
|
|
167
|
+
if (i >= 0 && i !== index) {
|
|
168
|
+
index = i;
|
|
169
|
+
render();
|
|
170
|
+
reveal(thumbs[index]);
|
|
171
|
+
emit();
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
{ root: viewport, threshold: 0.6 },
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
render();
|
|
179
|
+
const bound = bindOnce(box, 'carousel', () => {
|
|
180
|
+
viewport.addEventListener('keydown', onKey);
|
|
181
|
+
box.addEventListener('click', onClick);
|
|
182
|
+
// Observe inside the add callback so observe/disconnect pair with the
|
|
183
|
+
// binding lifecycle: a re-init tears down the prior binding (which
|
|
184
|
+
// disconnects the old observer) before this starts, so two observers
|
|
185
|
+
// never watch the same slides — even for one tick.
|
|
186
|
+
slides.forEach((s) => io?.observe(s));
|
|
187
|
+
return () => {
|
|
188
|
+
viewport.removeEventListener('keydown', onKey);
|
|
189
|
+
box.removeEventListener('click', onClick);
|
|
190
|
+
io?.disconnect();
|
|
191
|
+
if (progTimer) clearTimeout(progTimer);
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
cleanups.push(bound);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return () => cleanups.forEach((fn) => fn());
|
|
198
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hasDom,
|
|
3
|
+
resolveHost,
|
|
4
|
+
noop,
|
|
5
|
+
bindOnce,
|
|
6
|
+
nextFieldUid,
|
|
7
|
+
scrollIntoViewSafe,
|
|
8
|
+
wrapIndex,
|
|
9
|
+
collectHosts,
|
|
10
|
+
} from './internal.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Editable combobox with a filtered listbox popup, implementing the
|
|
14
|
+
* WAI-ARIA APG combobox pattern (the widget the framework most lacked
|
|
15
|
+
* and consumers most often build badly). Dependency-free, no
|
|
16
|
+
* positioning library — the list is CSS-anchored under the input.
|
|
17
|
+
*
|
|
18
|
+
* The input MUST have an accessible name — a `<label>`, `aria-label`, or
|
|
19
|
+
* `aria-labelledby` (a placeholder does not count). A nameless `role="combobox"`
|
|
20
|
+
* is a silent screen-reader failure, so the behavior warns at dev time when it
|
|
21
|
+
* finds one, and mirrors the input's name onto the listbox.
|
|
22
|
+
*
|
|
23
|
+
* Markup: `[data-bronto-combobox]` wrapping an `<input role="combobox">`
|
|
24
|
+
* (`.ui-combobox__input`) and a `<ul role="listbox">`
|
|
25
|
+
* (`.ui-combobox__list`) of `<li role="option">` (`.ui-combobox__option`,
|
|
26
|
+
* optional `data-value`). An optional `.ui-combobox__empty` shows when
|
|
27
|
+
* nothing matches. The behavior owns ids, `aria-expanded`,
|
|
28
|
+
* `aria-controls`, `aria-activedescendant`, roving active option,
|
|
29
|
+
* type-to-filter, full keyboard (Down/Up/Home/End/Enter/Escape/Tab),
|
|
30
|
+
* pointer select, and outside-click close; it emits a `bronto:change`
|
|
31
|
+
* CustomEvent ({ detail: { value } }) on selection. SSR-safe,
|
|
32
|
+
* idempotent per instance; returns a cleanup function.
|
|
33
|
+
*
|
|
34
|
+
* Options are read from the DOM at init; if you replace the listbox contents
|
|
35
|
+
* (e.g. async/remote results) without re-initialising, filtering and keyboard
|
|
36
|
+
* nav act on the stale nodes — re-run initCombobox after mutating the options.
|
|
37
|
+
*/
|
|
38
|
+
export function initCombobox({ root } = {}) {
|
|
39
|
+
if (!hasDom()) return noop;
|
|
40
|
+
const host = resolveHost(root);
|
|
41
|
+
if (!host) return noop;
|
|
42
|
+
const boxes = collectHosts(host, '[data-bronto-combobox]');
|
|
43
|
+
const cleanups = [];
|
|
44
|
+
|
|
45
|
+
for (const box of boxes) {
|
|
46
|
+
const input = box.querySelector('[role="combobox"], .ui-combobox__input');
|
|
47
|
+
const list = box.querySelector('[role="listbox"], .ui-combobox__list');
|
|
48
|
+
if (!input || !list) continue;
|
|
49
|
+
const empty = box.querySelector('.ui-combobox__empty');
|
|
50
|
+
const options = [...list.querySelectorAll('[role="option"], .ui-combobox__option')];
|
|
51
|
+
|
|
52
|
+
const listId = list.id || (list.id = `bronto-cb-list-${nextFieldUid()}`);
|
|
53
|
+
options.forEach((o, i) => {
|
|
54
|
+
if (!o.id) o.id = `${listId}-opt-${i}`;
|
|
55
|
+
o.setAttribute('role', 'option');
|
|
56
|
+
});
|
|
57
|
+
list.setAttribute('role', 'listbox');
|
|
58
|
+
// Give the listbox its own accessible name (a bare role=listbox is unnamed
|
|
59
|
+
// to a screen reader) by mirroring the input's name. (a11y review C30.)
|
|
60
|
+
if (!list.hasAttribute('aria-label') && !list.hasAttribute('aria-labelledby')) {
|
|
61
|
+
const name =
|
|
62
|
+
input.getAttribute('aria-label') ||
|
|
63
|
+
input.labels?.[0]?.textContent?.trim() ||
|
|
64
|
+
input.getAttribute('placeholder');
|
|
65
|
+
if (name) list.setAttribute('aria-label', name);
|
|
66
|
+
}
|
|
67
|
+
// A `role="combobox"` with no accessible name is a silent AT failure. A
|
|
68
|
+
// placeholder is not a robust name (it can vanish and is ignored by some
|
|
69
|
+
// AT), so warn unless there is a real label/aria-label/aria-labelledby/title
|
|
70
|
+
// (C7). We can't invent a good name, hence a dev-time warning, not a guess.
|
|
71
|
+
const inputNamed =
|
|
72
|
+
input.hasAttribute('aria-label') ||
|
|
73
|
+
input.hasAttribute('aria-labelledby') ||
|
|
74
|
+
!!input.labels?.length ||
|
|
75
|
+
input.hasAttribute('title');
|
|
76
|
+
if (!inputNamed && typeof console !== 'undefined') {
|
|
77
|
+
console.warn(
|
|
78
|
+
'[bronto] initCombobox(): the combobox input has no accessible name — add a <label>, aria-label, or aria-labelledby (a placeholder is not enough).',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
input.setAttribute('role', 'combobox');
|
|
82
|
+
input.setAttribute('aria-controls', listId);
|
|
83
|
+
input.setAttribute('aria-autocomplete', 'list');
|
|
84
|
+
input.setAttribute('aria-expanded', 'false');
|
|
85
|
+
input.setAttribute('autocomplete', 'off');
|
|
86
|
+
list.hidden = true;
|
|
87
|
+
|
|
88
|
+
let active = -1;
|
|
89
|
+
const visible = () => options.filter((o) => !o.hidden);
|
|
90
|
+
|
|
91
|
+
const setActive = (opt) => {
|
|
92
|
+
options.forEach((o) => o.classList.remove('is-active'));
|
|
93
|
+
if (opt) {
|
|
94
|
+
opt.classList.add('is-active');
|
|
95
|
+
input.setAttribute('aria-activedescendant', opt.id);
|
|
96
|
+
scrollIntoViewSafe(opt);
|
|
97
|
+
} else {
|
|
98
|
+
input.removeAttribute('aria-activedescendant');
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const open = () => {
|
|
103
|
+
if (!list.hidden) return;
|
|
104
|
+
list.hidden = false;
|
|
105
|
+
input.setAttribute('aria-expanded', 'true');
|
|
106
|
+
};
|
|
107
|
+
const close = () => {
|
|
108
|
+
list.hidden = true;
|
|
109
|
+
input.setAttribute('aria-expanded', 'false');
|
|
110
|
+
active = -1;
|
|
111
|
+
setActive(null);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const filter = () => {
|
|
115
|
+
const q = input.value.trim().toLowerCase();
|
|
116
|
+
let any = false;
|
|
117
|
+
for (const o of options) {
|
|
118
|
+
const match = !q || o.textContent.toLowerCase().includes(q);
|
|
119
|
+
o.hidden = !match;
|
|
120
|
+
if (match) any = true;
|
|
121
|
+
}
|
|
122
|
+
if (empty) empty.hidden = any;
|
|
123
|
+
// The active option may have just been filtered out — drop the
|
|
124
|
+
// stale activedescendant so Enter can't select a hidden option.
|
|
125
|
+
if (active >= 0 && options[active]?.hidden) {
|
|
126
|
+
active = -1;
|
|
127
|
+
setActive(null);
|
|
128
|
+
}
|
|
129
|
+
open();
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const select = (opt) => {
|
|
133
|
+
input.value = opt.dataset.value ?? opt.textContent.trim();
|
|
134
|
+
options.forEach((o) => o.setAttribute('aria-selected', String(o === opt)));
|
|
135
|
+
close();
|
|
136
|
+
input.focus();
|
|
137
|
+
box.dispatchEvent(
|
|
138
|
+
new CustomEvent('bronto:change', {
|
|
139
|
+
detail: { value: input.value },
|
|
140
|
+
bubbles: true,
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const move = (delta) => {
|
|
146
|
+
const vis = visible();
|
|
147
|
+
if (!vis.length) return;
|
|
148
|
+
open();
|
|
149
|
+
const next = wrapIndex(vis.indexOf(options[active]), delta, vis.length);
|
|
150
|
+
active = options.indexOf(vis[next]);
|
|
151
|
+
setActive(options[active]);
|
|
152
|
+
};
|
|
153
|
+
const activateEdge = (which) => {
|
|
154
|
+
if (list.hidden) return false;
|
|
155
|
+
const v = visible();
|
|
156
|
+
if (!v.length) return true;
|
|
157
|
+
active = options.indexOf(which === 'first' ? v[0] : v[v.length - 1]);
|
|
158
|
+
setActive(options[active]);
|
|
159
|
+
return true;
|
|
160
|
+
};
|
|
161
|
+
const selectActive = () => {
|
|
162
|
+
if (list.hidden || active < 0 || options[active].hidden) return false;
|
|
163
|
+
select(options[active]);
|
|
164
|
+
return true;
|
|
165
|
+
};
|
|
166
|
+
const closeIfOpen = () => {
|
|
167
|
+
if (list.hidden) return false;
|
|
168
|
+
close();
|
|
169
|
+
return true;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const onInput = () => filter();
|
|
173
|
+
const onKey = (e) => {
|
|
174
|
+
switch (e.key) {
|
|
175
|
+
case 'ArrowDown':
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
list.hidden ? filter() : move(1);
|
|
178
|
+
break;
|
|
179
|
+
case 'ArrowUp':
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
move(-1);
|
|
182
|
+
break;
|
|
183
|
+
case 'Home':
|
|
184
|
+
if (activateEdge('first')) e.preventDefault();
|
|
185
|
+
break;
|
|
186
|
+
case 'End':
|
|
187
|
+
if (activateEdge('last')) e.preventDefault();
|
|
188
|
+
break;
|
|
189
|
+
case 'Enter':
|
|
190
|
+
if (selectActive()) e.preventDefault();
|
|
191
|
+
break;
|
|
192
|
+
case 'Escape':
|
|
193
|
+
if (closeIfOpen()) e.preventDefault();
|
|
194
|
+
break;
|
|
195
|
+
case 'Tab':
|
|
196
|
+
close();
|
|
197
|
+
break;
|
|
198
|
+
default:
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const onOptionClick = (e) => {
|
|
203
|
+
const opt = e.target.closest('[role="option"], .ui-combobox__option');
|
|
204
|
+
if (opt) select(opt);
|
|
205
|
+
};
|
|
206
|
+
const onDocClick = (e) => {
|
|
207
|
+
if (!box.contains(e.target)) close();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const bound = bindOnce(box, 'combobox', () => {
|
|
211
|
+
input.addEventListener('input', onInput);
|
|
212
|
+
input.addEventListener('keydown', onKey);
|
|
213
|
+
list.addEventListener('click', onOptionClick);
|
|
214
|
+
document.addEventListener('click', onDocClick);
|
|
215
|
+
return () => {
|
|
216
|
+
input.removeEventListener('input', onInput);
|
|
217
|
+
input.removeEventListener('keydown', onKey);
|
|
218
|
+
list.removeEventListener('click', onOptionClick);
|
|
219
|
+
document.removeEventListener('click', onDocClick);
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
cleanups.push(bound);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return () => cleanups.forEach((fn) => fn());
|
|
226
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hasDom,
|
|
3
|
+
resolveHost,
|
|
4
|
+
noop,
|
|
5
|
+
bindOnce,
|
|
6
|
+
nextFieldUid,
|
|
7
|
+
collectHosts,
|
|
8
|
+
scrollIntoViewSafe,
|
|
9
|
+
wrapIndex,
|
|
10
|
+
} from './internal.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Command palette — filter + keyboard-navigate a DOM-authored command list.
|
|
14
|
+
* The CSS shell (`.ui-command`) is opt-in; this wires the listbox behavior the
|
|
15
|
+
* shell needs. Bronto filters and navigates; the HOST owns the action registry,
|
|
16
|
+
* permission checks, routing, async effects, and command execution (it listens
|
|
17
|
+
* for `bronto:command:select`). There is no global Cmd/Ctrl+K — open the palette
|
|
18
|
+
* yourself (e.g. a `<dialog>` via `initDialog`).
|
|
19
|
+
*
|
|
20
|
+
* Markup: `[data-bronto-command]` wrapping an `<input>` (`.ui-command__input`)
|
|
21
|
+
* and a list (`.ui-command__list`) of `.ui-command__item` rows (optional
|
|
22
|
+
* `data-value`), interleaved with `.ui-command__group` labels and an optional
|
|
23
|
+
* `.ui-command__empty`. The behavior owns ids, `role=combobox/listbox/option`,
|
|
24
|
+
* `aria-activedescendant`, a roving active item, substring filtering (hiding
|
|
25
|
+
* empty groups), full keyboard (Down/Up/Home/End/Enter/Escape), and pointer
|
|
26
|
+
* select. It emits `bronto:command:select` ({ detail: { value, label } }) on
|
|
27
|
+
* choose and `bronto:command:close` on Escape. SSR-safe, idempotent per
|
|
28
|
+
* instance; returns a cleanup function.
|
|
29
|
+
*
|
|
30
|
+
* Items are read from the DOM at init; re-run initCommand after replacing the
|
|
31
|
+
* command list so filtering/navigation see the current nodes.
|
|
32
|
+
*/
|
|
33
|
+
export function initCommand({ root } = {}) {
|
|
34
|
+
if (!hasDom()) return noop;
|
|
35
|
+
const host = resolveHost(root);
|
|
36
|
+
if (!host) return noop;
|
|
37
|
+
const palettes = collectHosts(host, '[data-bronto-command]');
|
|
38
|
+
const cleanups = [];
|
|
39
|
+
|
|
40
|
+
for (const box of palettes) {
|
|
41
|
+
const input = box.querySelector('.ui-command__input, input');
|
|
42
|
+
const list = box.querySelector('.ui-command__list, [role="listbox"]');
|
|
43
|
+
if (!input || !list) continue;
|
|
44
|
+
const empty = box.querySelector('.ui-command__empty');
|
|
45
|
+
const items = [...list.querySelectorAll('.ui-command__item, [role="option"]')];
|
|
46
|
+
const groups = [...list.querySelectorAll('.ui-command__group')];
|
|
47
|
+
|
|
48
|
+
const listId = list.id || (list.id = `bronto-cmd-${nextFieldUid()}`);
|
|
49
|
+
items.forEach((it, i) => {
|
|
50
|
+
if (!it.id) it.id = `${listId}-opt-${i}`;
|
|
51
|
+
it.setAttribute('role', 'option');
|
|
52
|
+
});
|
|
53
|
+
groups.forEach((g) => g.setAttribute('role', 'presentation'));
|
|
54
|
+
list.setAttribute('role', 'listbox');
|
|
55
|
+
input.setAttribute('role', 'combobox');
|
|
56
|
+
input.setAttribute('aria-controls', listId);
|
|
57
|
+
input.setAttribute('aria-autocomplete', 'list');
|
|
58
|
+
input.setAttribute('aria-expanded', 'true');
|
|
59
|
+
input.setAttribute('autocomplete', 'off');
|
|
60
|
+
|
|
61
|
+
let active = -1;
|
|
62
|
+
const visible = () => items.filter((it) => !it.hidden);
|
|
63
|
+
|
|
64
|
+
const setActive = (item) => {
|
|
65
|
+
items.forEach((it) => {
|
|
66
|
+
it.classList.toggle('is-active', it === item);
|
|
67
|
+
it.setAttribute('aria-selected', String(it === item));
|
|
68
|
+
});
|
|
69
|
+
if (item) {
|
|
70
|
+
active = items.indexOf(item);
|
|
71
|
+
input.setAttribute('aria-activedescendant', item.id);
|
|
72
|
+
scrollIntoViewSafe(item);
|
|
73
|
+
} else {
|
|
74
|
+
active = -1;
|
|
75
|
+
input.removeAttribute('aria-activedescendant');
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Hide a group whose items are all filtered out.
|
|
80
|
+
const syncGroups = () => {
|
|
81
|
+
for (const g of groups) {
|
|
82
|
+
let any = false;
|
|
83
|
+
for (
|
|
84
|
+
let n = g.nextElementSibling;
|
|
85
|
+
n && !n.matches('.ui-command__group');
|
|
86
|
+
n = n.nextElementSibling
|
|
87
|
+
) {
|
|
88
|
+
if (n.matches('.ui-command__item, [role="option"]') && !n.hidden) any = true;
|
|
89
|
+
}
|
|
90
|
+
g.hidden = !any;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const filter = () => {
|
|
95
|
+
const q = input.value.trim().toLowerCase();
|
|
96
|
+
let any = false;
|
|
97
|
+
for (const it of items) {
|
|
98
|
+
const match = !q || it.textContent.toLowerCase().includes(q);
|
|
99
|
+
it.hidden = !match;
|
|
100
|
+
if (match) any = true;
|
|
101
|
+
}
|
|
102
|
+
syncGroups();
|
|
103
|
+
if (empty) empty.hidden = any;
|
|
104
|
+
const vis = visible();
|
|
105
|
+
setActive(vis[0] || null);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const move = (delta) => {
|
|
109
|
+
const vis = visible();
|
|
110
|
+
if (!vis.length) return;
|
|
111
|
+
setActive(vis[wrapIndex(vis.indexOf(items[active]), delta, vis.length)]);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const choose = (item) => {
|
|
115
|
+
if (!item || item.hidden) return;
|
|
116
|
+
// Label = the command name only — strip the shortcut/meta hints so the
|
|
117
|
+
// host doesn't get "Open settings G S".
|
|
118
|
+
const clone = item.cloneNode(true);
|
|
119
|
+
clone.querySelectorAll('.ui-command__shortcut, .ui-command__meta').forEach((n) => n.remove());
|
|
120
|
+
const label = clone.textContent.replace(/\s+/g, ' ').trim();
|
|
121
|
+
box.dispatchEvent(
|
|
122
|
+
new CustomEvent('bronto:command:select', {
|
|
123
|
+
detail: { value: item.dataset.value ?? label, label },
|
|
124
|
+
bubbles: true,
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const onInput = () => filter();
|
|
130
|
+
const onKey = (e) => {
|
|
131
|
+
switch (e.key) {
|
|
132
|
+
case 'ArrowDown':
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
move(1);
|
|
135
|
+
break;
|
|
136
|
+
case 'ArrowUp':
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
move(-1);
|
|
139
|
+
break;
|
|
140
|
+
case 'Home': {
|
|
141
|
+
const v = visible();
|
|
142
|
+
if (v.length) {
|
|
143
|
+
setActive(v[0]);
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
}
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
case 'End': {
|
|
149
|
+
const v = visible();
|
|
150
|
+
if (v.length) {
|
|
151
|
+
setActive(v[v.length - 1]);
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case 'Enter':
|
|
157
|
+
if (active >= 0 && !items[active].hidden) {
|
|
158
|
+
choose(items[active]);
|
|
159
|
+
e.preventDefault();
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
case 'Escape':
|
|
163
|
+
box.dispatchEvent(new CustomEvent('bronto:command:close', { bubbles: true }));
|
|
164
|
+
break;
|
|
165
|
+
default:
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
const onClick = (e) => {
|
|
170
|
+
const item = e.target.closest('.ui-command__item, [role="option"]');
|
|
171
|
+
if (item && list.contains(item)) choose(item);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const bound = bindOnce(box, 'command', () => {
|
|
175
|
+
input.addEventListener('input', onInput);
|
|
176
|
+
input.addEventListener('keydown', onKey);
|
|
177
|
+
list.addEventListener('click', onClick);
|
|
178
|
+
return () => {
|
|
179
|
+
input.removeEventListener('input', onInput);
|
|
180
|
+
input.removeEventListener('keydown', onKey);
|
|
181
|
+
list.removeEventListener('click', onClick);
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
// Seed the initial active item (first visible).
|
|
185
|
+
filter();
|
|
186
|
+
cleanups.push(bound);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return () => cleanups.forEach((fn) => fn());
|
|
190
|
+
}
|