@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.
Files changed (105) hide show
  1. package/CHANGELOG.md +230 -8
  2. package/MIGRATIONS.json +92 -0
  3. package/README.md +9 -6
  4. package/annotations/index.d.ts +280 -0
  5. package/annotations/index.js +522 -0
  6. package/behaviors/carousel.js +197 -0
  7. package/behaviors/combobox.js +195 -0
  8. package/behaviors/command.js +187 -0
  9. package/behaviors/connectors.js +96 -0
  10. package/behaviors/crosshair.js +58 -0
  11. package/behaviors/dialog.js +73 -0
  12. package/behaviors/disclosure.js +25 -0
  13. package/behaviors/dismissible.js +24 -0
  14. package/behaviors/forms.js +158 -0
  15. package/behaviors/glyph.js +109 -0
  16. package/behaviors/index.d.ts +79 -0
  17. package/behaviors/index.js +18 -1409
  18. package/behaviors/internal.js +50 -0
  19. package/behaviors/legend.js +46 -0
  20. package/behaviors/menu.js +46 -0
  21. package/behaviors/popover.js +108 -0
  22. package/behaviors/spotlight.js +53 -0
  23. package/behaviors/table.js +109 -0
  24. package/behaviors/tabs.js +103 -0
  25. package/behaviors/theme.js +82 -0
  26. package/behaviors/toast.js +152 -0
  27. package/classes/index.d.ts +280 -2
  28. package/classes/index.js +313 -2
  29. package/connectors/index.d.ts +71 -0
  30. package/connectors/index.js +179 -0
  31. package/css/analytical.css +21 -0
  32. package/css/annotations.css +292 -0
  33. package/css/command.css +97 -0
  34. package/css/connectors.css +93 -0
  35. package/css/crosshair.css +100 -0
  36. package/css/feedback.css +51 -0
  37. package/css/fonts.css +11 -7
  38. package/css/generated.css +117 -0
  39. package/css/legend.css +268 -0
  40. package/css/marks.css +144 -0
  41. package/css/primitives.css +18 -0
  42. package/css/report.css +12 -31
  43. package/css/selection.css +46 -0
  44. package/css/sources.css +179 -0
  45. package/css/spotlight.css +104 -0
  46. package/css/state.css +121 -0
  47. package/css/tokens.css +25 -37
  48. package/css/workbench.css +83 -0
  49. package/dist/bronto.css +1 -1
  50. package/dist/css/analytical.css +1 -0
  51. package/dist/css/annotations.css +1 -0
  52. package/dist/css/command.css +1 -0
  53. package/dist/css/connectors.css +1 -0
  54. package/dist/css/crosshair.css +1 -0
  55. package/dist/css/feedback.css +1 -1
  56. package/dist/css/fonts.css +1 -1
  57. package/dist/css/generated.css +1 -0
  58. package/dist/css/legend.css +1 -0
  59. package/dist/css/marks.css +1 -0
  60. package/dist/css/primitives.css +1 -1
  61. package/dist/css/report.css +1 -1
  62. package/dist/css/selection.css +1 -0
  63. package/dist/css/sources.css +1 -0
  64. package/dist/css/spotlight.css +1 -0
  65. package/dist/css/state.css +1 -0
  66. package/dist/css/workbench.css +1 -0
  67. package/docs/adr/0003-theme-model.md +7 -4
  68. package/docs/annotations.md +345 -0
  69. package/docs/architecture.md +202 -0
  70. package/docs/command.md +95 -0
  71. package/docs/connectors.md +91 -0
  72. package/docs/crosshair.md +63 -0
  73. package/docs/generated.md +91 -0
  74. package/docs/legends.md +168 -0
  75. package/docs/marks.md +86 -0
  76. package/docs/reference.md +309 -3
  77. package/docs/reporting.md +49 -14
  78. package/docs/selection.md +40 -0
  79. package/docs/sources.md +110 -0
  80. package/docs/spotlight.md +78 -0
  81. package/docs/stability.md +16 -1
  82. package/docs/state.md +85 -0
  83. package/docs/usage.md +22 -0
  84. package/docs/workbench.md +72 -0
  85. package/fonts/doto-400.woff2 +0 -0
  86. package/fonts/doto-500.woff2 +0 -0
  87. package/fonts/doto-600.woff2 +0 -0
  88. package/fonts/doto-700.woff2 +0 -0
  89. package/fonts/doto-800.woff2 +0 -0
  90. package/fonts/doto-900.woff2 +0 -0
  91. package/llms.txt +229 -6
  92. package/package.json +69 -4
  93. package/qwik/index.d.ts +5 -0
  94. package/qwik/index.js +20 -0
  95. package/react/index.d.ts +5 -0
  96. package/react/index.js +10 -0
  97. package/solid/index.d.ts +5 -0
  98. package/solid/index.js +10 -0
  99. package/tokens/index.js +9 -5
  100. package/fonts/doto-400.ttf +0 -0
  101. package/fonts/doto-500.ttf +0 -0
  102. package/fonts/doto-600.ttf +0 -0
  103. package/fonts/doto-700.ttf +0 -0
  104. package/fonts/doto-800.ttf +0 -0
  105. 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
+ }