@agent-ui-kit/components 0.0.3

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 (271) hide show
  1. package/dist/base-styles.css +8 -0
  2. package/dist/component-styles.css +147 -0
  3. package/dist/controls/_base/index.d.ts +3 -0
  4. package/dist/controls/_base/index.js +9 -0
  5. package/dist/controls/_base/indicator-element.d.ts +132 -0
  6. package/dist/controls/_base/indicator-element.js +158 -0
  7. package/dist/controls/_base/listbox-element.d.ts +70 -0
  8. package/dist/controls/_base/listbox-element.js +74 -0
  9. package/dist/controls/_base/range-element.d.ts +120 -0
  10. package/dist/controls/_base/range-element.js +158 -0
  11. package/dist/controls/_surface/container-box.css +301 -0
  12. package/dist/controls/_surface/container.css +156 -0
  13. package/dist/controls/_token-surface/token-surface.d.ts +35 -0
  14. package/dist/controls/_token-surface/token-surface.js +97 -0
  15. package/dist/controls/attachment/attachment-meta.d.ts +8 -0
  16. package/dist/controls/attachment/attachment-meta.js +84 -0
  17. package/dist/controls/attachment/attachment.css +91 -0
  18. package/dist/controls/attachment/attachment.d.ts +40 -0
  19. package/dist/controls/attachment/attachment.js +70 -0
  20. package/dist/controls/avatar/avatar-initials.d.ts +4 -0
  21. package/dist/controls/avatar/avatar-initials.js +30 -0
  22. package/dist/controls/avatar/avatar.css +87 -0
  23. package/dist/controls/avatar/avatar.d.ts +40 -0
  24. package/dist/controls/avatar/avatar.js +77 -0
  25. package/dist/controls/badge/badge.css +175 -0
  26. package/dist/controls/badge/badge.d.ts +36 -0
  27. package/dist/controls/badge/badge.js +71 -0
  28. package/dist/controls/bar-chart/bar-chart.css +110 -0
  29. package/dist/controls/bar-chart/bar-chart.d.ts +26 -0
  30. package/dist/controls/bar-chart/bar-chart.js +80 -0
  31. package/dist/controls/bar-chart/bar-math.d.ts +31 -0
  32. package/dist/controls/bar-chart/bar-math.js +106 -0
  33. package/dist/controls/button/button.css +263 -0
  34. package/dist/controls/button/button.d.ts +61 -0
  35. package/dist/controls/button/button.js +115 -0
  36. package/dist/controls/calendar/calendar.css +495 -0
  37. package/dist/controls/calendar/calendar.d.ts +161 -0
  38. package/dist/controls/calendar/calendar.js +1004 -0
  39. package/dist/controls/card/card-content.d.ts +24 -0
  40. package/dist/controls/card/card-content.js +133 -0
  41. package/dist/controls/card/card-footer.d.ts +3 -0
  42. package/dist/controls/card/card-footer.js +16 -0
  43. package/dist/controls/card/card-header.d.ts +3 -0
  44. package/dist/controls/card/card-header.js +18 -0
  45. package/dist/controls/card/card.css +445 -0
  46. package/dist/controls/card/card.d.ts +51 -0
  47. package/dist/controls/card/card.js +61 -0
  48. package/dist/controls/checkbox/checkbox.css +204 -0
  49. package/dist/controls/checkbox/checkbox.d.ts +114 -0
  50. package/dist/controls/checkbox/checkbox.js +130 -0
  51. package/dist/controls/code/code.css +72 -0
  52. package/dist/controls/code/code.d.ts +23 -0
  53. package/dist/controls/code/code.js +40 -0
  54. package/dist/controls/color-picker/color-picker.css +209 -0
  55. package/dist/controls/color-picker/color-picker.d.ts +84 -0
  56. package/dist/controls/color-picker/color-picker.js +545 -0
  57. package/dist/controls/color-picker/color.d.ts +40 -0
  58. package/dist/controls/color-picker/color.js +159 -0
  59. package/dist/controls/column/column.css +151 -0
  60. package/dist/controls/column/column.d.ts +107 -0
  61. package/dist/controls/column/column.js +57 -0
  62. package/dist/controls/combo-box/combo-box.css +397 -0
  63. package/dist/controls/combo-box/combo-box.d.ts +133 -0
  64. package/dist/controls/combo-box/combo-box.js +680 -0
  65. package/dist/controls/command-modal/command-modal.css +226 -0
  66. package/dist/controls/command-modal/command-modal.d.ts +42 -0
  67. package/dist/controls/command-modal/command-modal.js +315 -0
  68. package/dist/controls/disclosure/disclosure.css +162 -0
  69. package/dist/controls/disclosure/disclosure.d.ts +37 -0
  70. package/dist/controls/disclosure/disclosure.js +207 -0
  71. package/dist/controls/field/field.css +140 -0
  72. package/dist/controls/field/field.d.ts +27 -0
  73. package/dist/controls/field/field.js +228 -0
  74. package/dist/controls/form-provider/form-provider.css +40 -0
  75. package/dist/controls/form-provider/form-provider.d.ts +42 -0
  76. package/dist/controls/form-provider/form-provider.js +105 -0
  77. package/dist/controls/grid/grid.css +72 -0
  78. package/dist/controls/grid/grid.d.ts +60 -0
  79. package/dist/controls/grid/grid.js +56 -0
  80. package/dist/controls/icon/icon.css +37 -0
  81. package/dist/controls/icon/icon.d.ts +25 -0
  82. package/dist/controls/icon/icon.js +55 -0
  83. package/dist/controls/index.d.ts +57 -0
  84. package/dist/controls/index.js +136 -0
  85. package/dist/controls/ladder/ladder.css +89 -0
  86. package/dist/controls/ladder/ladder.d.ts +27 -0
  87. package/dist/controls/ladder/ladder.js +78 -0
  88. package/dist/controls/list/list.css +118 -0
  89. package/dist/controls/list/list.d.ts +84 -0
  90. package/dist/controls/list/list.js +48 -0
  91. package/dist/controls/menu/menu.css +195 -0
  92. package/dist/controls/menu/menu.d.ts +44 -0
  93. package/dist/controls/menu/menu.js +248 -0
  94. package/dist/controls/modal/modal.css +99 -0
  95. package/dist/controls/modal/modal.d.ts +61 -0
  96. package/dist/controls/modal/modal.js +155 -0
  97. package/dist/controls/popover/popover.css +83 -0
  98. package/dist/controls/popover/popover.d.ts +44 -0
  99. package/dist/controls/popover/popover.js +164 -0
  100. package/dist/controls/progress/progress.css +106 -0
  101. package/dist/controls/progress/progress.d.ts +28 -0
  102. package/dist/controls/progress/progress.js +80 -0
  103. package/dist/controls/radio/radio-group.css +84 -0
  104. package/dist/controls/radio/radio-group.d.ts +152 -0
  105. package/dist/controls/radio/radio-group.js +350 -0
  106. package/dist/controls/radio/radio.css +219 -0
  107. package/dist/controls/radio/radio.d.ts +19 -0
  108. package/dist/controls/radio/radio.js +75 -0
  109. package/dist/controls/ramp/ramp.css +75 -0
  110. package/dist/controls/ramp/ramp.d.ts +29 -0
  111. package/dist/controls/ramp/ramp.js +72 -0
  112. package/dist/controls/row/row.css +152 -0
  113. package/dist/controls/row/row.d.ts +95 -0
  114. package/dist/controls/row/row.js +45 -0
  115. package/dist/controls/segment/segment.css +66 -0
  116. package/dist/controls/segment/segment.d.ts +3 -0
  117. package/dist/controls/segment/segment.js +26 -0
  118. package/dist/controls/segmented-control/segmented-control.css +189 -0
  119. package/dist/controls/segmented-control/segmented-control.d.ts +20 -0
  120. package/dist/controls/segmented-control/segmented-control.js +64 -0
  121. package/dist/controls/select/select.css +467 -0
  122. package/dist/controls/select/select.d.ts +147 -0
  123. package/dist/controls/select/select.js +538 -0
  124. package/dist/controls/slider/slider.css +231 -0
  125. package/dist/controls/slider/slider.d.ts +111 -0
  126. package/dist/controls/slider/slider.js +60 -0
  127. package/dist/controls/slider-multi/slider-multi.css +197 -0
  128. package/dist/controls/slider-multi/slider-multi.d.ts +149 -0
  129. package/dist/controls/slider-multi/slider-multi.js +380 -0
  130. package/dist/controls/sparkline/sparkline-math.d.ts +23 -0
  131. package/dist/controls/sparkline/sparkline-math.js +79 -0
  132. package/dist/controls/sparkline/sparkline.css +61 -0
  133. package/dist/controls/sparkline/sparkline.d.ts +27 -0
  134. package/dist/controls/sparkline/sparkline.js +74 -0
  135. package/dist/controls/split/constrain.d.ts +44 -0
  136. package/dist/controls/split/constrain.js +126 -0
  137. package/dist/controls/split/split-pane.css +63 -0
  138. package/dist/controls/split/split-pane.d.ts +49 -0
  139. package/dist/controls/split/split-pane.js +42 -0
  140. package/dist/controls/split/split.css +127 -0
  141. package/dist/controls/split/split.d.ts +42 -0
  142. package/dist/controls/split/split.js +364 -0
  143. package/dist/controls/stat/stat-model.d.ts +22 -0
  144. package/dist/controls/stat/stat-model.js +69 -0
  145. package/dist/controls/stat/stat.css +101 -0
  146. package/dist/controls/stat/stat.d.ts +30 -0
  147. package/dist/controls/stat/stat.js +73 -0
  148. package/dist/controls/status-stream/status-stream.css +151 -0
  149. package/dist/controls/status-stream/status-stream.d.ts +108 -0
  150. package/dist/controls/status-stream/status-stream.js +417 -0
  151. package/dist/controls/swatch/swatch.css +66 -0
  152. package/dist/controls/swatch/swatch.d.ts +28 -0
  153. package/dist/controls/swatch/swatch.js +74 -0
  154. package/dist/controls/swiper/swiper-item.css +17 -0
  155. package/dist/controls/swiper/swiper-item.d.ts +31 -0
  156. package/dist/controls/swiper/swiper-item.js +47 -0
  157. package/dist/controls/swiper/swiper-label.css +10 -0
  158. package/dist/controls/swiper/swiper-label.d.ts +4 -0
  159. package/dist/controls/swiper/swiper-label.js +20 -0
  160. package/dist/controls/swiper/swiper-paddles.css +26 -0
  161. package/dist/controls/swiper/swiper-paddles.d.ts +10 -0
  162. package/dist/controls/swiper/swiper-paddles.js +62 -0
  163. package/dist/controls/swiper/swiper-pagination.css +60 -0
  164. package/dist/controls/swiper/swiper-pagination.d.ts +31 -0
  165. package/dist/controls/swiper/swiper-pagination.js +78 -0
  166. package/dist/controls/swiper/swiper.css +153 -0
  167. package/dist/controls/swiper/swiper.d.ts +158 -0
  168. package/dist/controls/swiper/swiper.js +659 -0
  169. package/dist/controls/switch/switch.css +194 -0
  170. package/dist/controls/switch/switch.d.ts +5 -0
  171. package/dist/controls/switch/switch.js +18 -0
  172. package/dist/controls/table/table-model.d.ts +43 -0
  173. package/dist/controls/table/table-model.js +124 -0
  174. package/dist/controls/table/table.css +90 -0
  175. package/dist/controls/table/table.d.ts +29 -0
  176. package/dist/controls/table/table.js +178 -0
  177. package/dist/controls/tabs/tab-panel.d.ts +10 -0
  178. package/dist/controls/tabs/tab-panel.js +89 -0
  179. package/dist/controls/tabs/tab.d.ts +26 -0
  180. package/dist/controls/tabs/tab.js +67 -0
  181. package/dist/controls/tabs/tabs.css +181 -0
  182. package/dist/controls/tabs/tabs.d.ts +61 -0
  183. package/dist/controls/tabs/tabs.js +176 -0
  184. package/dist/controls/text/href.d.ts +26 -0
  185. package/dist/controls/text/href.js +48 -0
  186. package/dist/controls/text/text.css +328 -0
  187. package/dist/controls/text/text.d.ts +85 -0
  188. package/dist/controls/text/text.js +273 -0
  189. package/dist/controls/text-field/text-field.css +498 -0
  190. package/dist/controls/text-field/text-field.d.ts +226 -0
  191. package/dist/controls/text-field/text-field.js +905 -0
  192. package/dist/controls/textarea/textarea.css +251 -0
  193. package/dist/controls/textarea/textarea.d.ts +136 -0
  194. package/dist/controls/textarea/textarea.js +312 -0
  195. package/dist/controls/theme-provider/theme-provider.css +48 -0
  196. package/dist/controls/theme-provider/theme-provider.d.ts +59 -0
  197. package/dist/controls/theme-provider/theme-provider.js +40 -0
  198. package/dist/controls/timeline/timeline.css +33 -0
  199. package/dist/controls/timeline/timeline.d.ts +39 -0
  200. package/dist/controls/timeline/timeline.js +52 -0
  201. package/dist/controls/timeline-item/timeline-item.css +495 -0
  202. package/dist/controls/timeline-item/timeline-item.d.ts +79 -0
  203. package/dist/controls/timeline-item/timeline-item.js +360 -0
  204. package/dist/controls/toast/toast-region.css +56 -0
  205. package/dist/controls/toast/toast-region.d.ts +20 -0
  206. package/dist/controls/toast/toast-region.js +113 -0
  207. package/dist/controls/toast/toast.css +96 -0
  208. package/dist/controls/toast/toast.d.ts +28 -0
  209. package/dist/controls/toast/toast.js +191 -0
  210. package/dist/controls/toolbar/toolbar.css +125 -0
  211. package/dist/controls/toolbar/toolbar.d.ts +108 -0
  212. package/dist/controls/toolbar/toolbar.js +68 -0
  213. package/dist/controls/tooltip/tooltip.css +80 -0
  214. package/dist/controls/tooltip/tooltip.d.ts +57 -0
  215. package/dist/controls/tooltip/tooltip.js +205 -0
  216. package/dist/descriptor/component-descriptor.d.ts +112 -0
  217. package/dist/descriptor/component-descriptor.js +492 -0
  218. package/dist/descriptor/index.d.ts +1 -0
  219. package/dist/descriptor/index.js +5 -0
  220. package/dist/dom/container.d.ts +45 -0
  221. package/dist/dom/container.js +60 -0
  222. package/dist/dom/element.d.ts +98 -0
  223. package/dist/dom/element.js +221 -0
  224. package/dist/dom/form.d.ts +240 -0
  225. package/dist/dom/form.js +399 -0
  226. package/dist/dom/index.d.ts +10 -0
  227. package/dist/dom/index.js +31 -0
  228. package/dist/dom/props.d.ts +56 -0
  229. package/dist/dom/props.js +231 -0
  230. package/dist/dom/repeat.d.ts +10 -0
  231. package/dist/dom/repeat.js +202 -0
  232. package/dist/dom/template.d.ts +161 -0
  233. package/dist/dom/template.js +634 -0
  234. package/dist/dom/watch.d.ts +11 -0
  235. package/dist/dom/watch.js +82 -0
  236. package/dist/foundation-styles.css +9 -0
  237. package/dist/index.d.ts +2 -0
  238. package/dist/index.js +3 -0
  239. package/dist/reactive/graph.d.ts +65 -0
  240. package/dist/reactive/graph.js +310 -0
  241. package/dist/reactive/index.d.ts +3 -0
  242. package/dist/reactive/index.js +4 -0
  243. package/dist/reactive/scheduler.d.ts +13 -0
  244. package/dist/reactive/scheduler.js +49 -0
  245. package/dist/traits/area-drag.d.ts +12 -0
  246. package/dist/traits/area-drag.js +70 -0
  247. package/dist/traits/form-registry.d.ts +36 -0
  248. package/dist/traits/form-registry.js +108 -0
  249. package/dist/traits/index.d.ts +24 -0
  250. package/dist/traits/index.js +19 -0
  251. package/dist/traits/overlay.d.ts +50 -0
  252. package/dist/traits/overlay.js +277 -0
  253. package/dist/traits/pane-resize.d.ts +26 -0
  254. package/dist/traits/pane-resize.js +103 -0
  255. package/dist/traits/press-activation.d.ts +11 -0
  256. package/dist/traits/press-activation.js +55 -0
  257. package/dist/traits/roving-focus.d.ts +52 -0
  258. package/dist/traits/roving-focus.js +214 -0
  259. package/dist/traits/scroll-fade.d.ts +28 -0
  260. package/dist/traits/scroll-fade.js +120 -0
  261. package/dist/traits/selection-commit.d.ts +30 -0
  262. package/dist/traits/selection-commit.js +146 -0
  263. package/dist/traits/tabbable.d.ts +12 -0
  264. package/dist/traits/tabbable.js +59 -0
  265. package/dist/traits/track-user-invalid.d.ts +36 -0
  266. package/dist/traits/track-user-invalid.js +52 -0
  267. package/dist/traits/value-codec.d.ts +120 -0
  268. package/dist/traits/value-codec.js +497 -0
  269. package/dist/traits/value-drag.d.ts +18 -0
  270. package/dist/traits/value-drag.js +84 -0
  271. package/package.json +263 -0
@@ -0,0 +1,55 @@
1
+ // press-activation.ts — the Space/Enter → click trait (plan §7). A light-DOM custom element does NOT
2
+ // inherit the native button's keyboard activation, so a control opts in by calling this from `connected()`.
3
+ //
4
+ // Semantics (native-button parity):
5
+ // · Space — `preventDefault` on keydown (stop the page from scrolling), activate on key UP.
6
+ // · Enter — `preventDefault` on keydown, THEN activate (bug-B fix, cross-engine e2e-caught): a real
7
+ // `<button>` has no OTHER default action competing for Enter, so nothing ever leaks; ours activates via
8
+ // an explicit `host.click()` inside a keydown LISTENER, which does not itself consume the keydown's
9
+ // default action. Chromium evaluates that default action AFTER the listener returns, against WHATEVER
10
+ // is focused AT THAT POINT — and `host.click()` can synchronously cascade into focus moving elsewhere
11
+ // (e.g. Enter-on-submit → provider.submit() → reportValidity() refocusing an invalid sibling field)
12
+ // before default-action time. An unconsumed Enter then lands wherever focus ended up — a contenteditable
13
+ // editor's native default action for Enter is "insert a line break," corrupting it. WebKit does not
14
+ // reproduce this (its default-action timing/target resolution differs), but the underlying hazard —
15
+ // Enter's default action must not survive to chase a mid-dispatch focus change — is real in any engine;
16
+ // consuming it explicitly, matching Space's own already-explicit `preventDefault`, closes it fleet-wide.
17
+ // · activation = `host.click()` (fires a native-parity click) — a plain method call, NOT itself affected
18
+ // by `preventDefault()` on the keydown (that only suppresses the BROWSER's own default reaction to the
19
+ // key event, never an imperative call a listener makes).
20
+ // · disabled-inert — when `opts.disabled()` is true the trait does NOTHING (no preventDefault, no click).
21
+ //
22
+ // Listeners ride `host.listen` → the host's connection `AbortSignal`, so they auto-remove on disconnect;
23
+ // `release()` is an early-teardown escape hatch (idempotent — it flips a guard the handlers check).
24
+ //
25
+ // `traits → dom` is the one allowed cross-layer direction (reactive ← dom ← traits); the host type only.
26
+ const isSpace = (key) => key === ' ' || key === 'Spacebar';
27
+ /**
28
+ * Wire Space/Enter → `host.click()` on a `UIElement`, native-button style. Invoke from the control's
29
+ * `connected()` (where the host connection is live). Returns `release()` for early teardown (idempotent);
30
+ * otherwise the listeners auto-remove when the host disconnects.
31
+ */
32
+ export function pressActivation(host, opts) {
33
+ let released = false;
34
+ host.listen(host, 'keydown', (event) => {
35
+ if (released || opts.disabled())
36
+ return;
37
+ const e = event;
38
+ if (isSpace(e.key))
39
+ e.preventDefault(); // stop the page scrolling; activation waits for keyup
40
+ else if (e.key === 'Enter') {
41
+ e.preventDefault(); // consume the keydown's default action (bug-B fix) — see the header note
42
+ host.click(); // Enter activates on keydown
43
+ }
44
+ });
45
+ host.listen(host, 'keyup', (event) => {
46
+ if (released || opts.disabled())
47
+ return;
48
+ const e = event;
49
+ if (isSpace(e.key))
50
+ host.click(); // Space activates on keyup
51
+ });
52
+ return () => {
53
+ released = true;
54
+ };
55
+ }
@@ -0,0 +1,52 @@
1
+ import type { UIElement } from '../dom/index.js';
2
+ /** The tab-stop-ownership marker this trait stamps on every item it manages (`applyTabindexes`) and strips
3
+ * on release. `traits/tabbable.ts` checks for its presence to defer its own `tabIndex = 0` write — see the
4
+ * ROVING-MARKER CONTRACT banner above. */
5
+ export declare const ROVING_ITEM_ATTR = "data-roving";
6
+ export type RovingOrientation = 'vertical' | 'horizontal';
7
+ export interface RovingFocusOptions {
8
+ /** Live accessor returning the current ordered set of candidate items (re-read on each key event). */
9
+ items: () => HTMLElement[];
10
+ /** Arrow-key axis: Up/Down for `'vertical'`, Left/Right for `'horizontal'`. Default: `'vertical'`. */
11
+ orientation?: RovingOrientation;
12
+ /** Wrap from last → first and first → last. Default: `true`. */
13
+ loop?: boolean;
14
+ /**
15
+ * Focus the next item whose visible text starts with the typed buffer (200 ms reset between bursts).
16
+ * Default: `true`.
17
+ */
18
+ typeAhead?: boolean;
19
+ /**
20
+ * Called each time the roving index changes. Use to couple selection-follows-focus (listbox/tabs).
21
+ * NOT an active-descendant seam: `moveTo()` unconditionally transfers REAL focus to the item, which
22
+ * is exactly what active-descendant hosts must never do — combo-box/command-modal correctly hand-roll
23
+ * their focus-stays-on-editor navigation instead (their in-file records; TKT-0065 lateral review
24
+ * trued this header to the shipped reality).
25
+ */
26
+ onMove?: (index: number) => void;
27
+ /**
28
+ * Seed the initial roving index rather than defaulting to the first non-disabled item. A function
29
+ * is re-called at each invocation of `rovingFocus` (useful when calling from `connected()` so the
30
+ * currently-selected position after reconnect is honoured). Out-of-range values fall back to
31
+ * `findFirst`. Default: `findFirst(items())`.
32
+ */
33
+ initialIndex?: number | (() => number);
34
+ /**
35
+ * Called at the start of every keydown event to sync the internal roving index with external state.
36
+ * Use for selection-follows-focus hosts (tabs, listbox) where a click or programmatic selection
37
+ * change updates the host's own index without going through the trait's `onMove` callback.
38
+ * Returning a negative number or an out-of-range index is treated as "no update".
39
+ */
40
+ syncIndex?: () => number;
41
+ /**
42
+ * The element to attach the keydown (and type-ahead) listener to. Defaults to `host` itself.
43
+ * Set to a container sub-element (e.g. a `[data-part=tablist]` strip) when the host contains
44
+ * other focusable regions whose Arrow keys must NOT be intercepted by this trait.
45
+ */
46
+ container?: HTMLElement;
47
+ }
48
+ /**
49
+ * Wire keyboard + tabindex roving focus on a `UIElement` host. Invoke from `connected()` so the
50
+ * listeners ride the connection AbortSignal (auto-removed on disconnect). Returns cleanup (idempotent).
51
+ */
52
+ export declare function rovingFocus(host: UIElement, opts: RovingFocusOptions): () => void;
@@ -0,0 +1,214 @@
1
+ // roving-focus.ts — the keyboard + tabindex roving-focus trait (listbox-roving LLD-C1). Extracted
2
+ // from ui-tabs' prior art (tabs.ts:130-168) to serve any "set of items with a moving focus" host:
3
+ // ui-listbox, ui-menu, ui-select, ui-radio-group, ui-toolbar. Role-agnostic: `items` is a live
4
+ // accessor; the host declares the item role in its own CSS/internals.
5
+ //
6
+ // Exactly ONE item holds `tabindex=0`; the rest `-1`. Arrow keys (Up/Down for vertical; Left/Right
7
+ // for horizontal) + Home/End move the roving index AND transfer focus. `loop` wraps at the ends;
8
+ // `typeAhead` searches by the typed buffer (200ms reset). `onMove(index)` notifies the host: couple
9
+ // selection-follows-focus (listbox/tabs) or decouple (menu: focus moves, selection commits on Enter).
10
+ //
11
+ // THE ROVING-MARKER CONTRACT (ADR-0121 amendment, post-toolbar): custom-element connection is preorder
12
+ // (a parent's `connectedCallback` fires before its children's, for a subtree connected all at once) — so
13
+ // a host that roves over ITEMS WHICH OWN THEIR OWN TAB-STOP (e.g. `ui-button`'s `tabbable` trait,
14
+ // traits/tabbable.ts) can lose the race: this trait's synchronous init runs first and sets the correct
15
+ // tabindexes, but each item's OWN later-running `connected()` effect unconditionally re-asserts
16
+ // `tabIndex = 0`, silently breaking the "exactly one tabindex=0" contract. `ui-radio-group` → `ui-radio`
17
+ // carries this same latent bug (masked by jsdom's opposite child-then-parent connection order, which real
18
+ // Chromium/WebKit do not share) — `ui-toolbar` → `ui-button` is what surfaced it. The fix is two-sided,
19
+ // and BOTH traits document it:
20
+ // 1. HERE — `applyTabindexes` stamps every item it manages with `ROVING_ITEM_ATTR` (`data-roving`), on
21
+ // init and on every move, so the marker is always current for whatever the live `items()` set is;
22
+ // `release()` strips it from the last-known set. A settle pass — one `requestAnimationFrame` tick past
23
+ // connect (the tabs.ts `ready`-gate precedent) — re-reads `items()` and re-applies once more, closing
24
+ // the race for any item whose OWN trait installs after this one's synchronous init.
25
+ // 2. `traits/tabbable.ts` — its effect DEFERS the `tabIndex = 0` write while the host carries
26
+ // `ROVING_ITEM_ATTR` (checked on EVERY effect run, not just once), so a later re-enable mid-session
27
+ // still yields to the roving owner instead of reclaiming a second tab stop. `disabled`/`aria-disabled`
28
+ // semantics are untouched — only tab-stop OWNERSHIP yields. Absent the marker, tabbable is unchanged.
29
+ //
30
+ // `traits → dom` is the one allowed cross-layer direction; the host type only. `ROVING_ITEM_ATTR` is the
31
+ // one same-layer (traits → traits) export this module carries — tabbable.ts imports it (layering.test.ts
32
+ // permits same-layer imports; only an UPWARD import is a violation).
33
+ /** The tab-stop-ownership marker this trait stamps on every item it manages (`applyTabindexes`) and strips
34
+ * on release. `traits/tabbable.ts` checks for its presence to defer its own `tabIndex = 0` write — see the
35
+ * ROVING-MARKER CONTRACT banner above. */
36
+ export const ROVING_ITEM_ATTR = 'data-roving';
37
+ // True if the item carries either the native `disabled` attribute or `aria-disabled="true"`.
38
+ function isDisabled(item) {
39
+ return item.hasAttribute('disabled') || item.getAttribute('aria-disabled') === 'true';
40
+ }
41
+ // Apply roving tabindexes: the item at `index` gets 0; all others -1. When `index` is -1 (all
42
+ // disabled) every item gets -1. Stamps ROVING_ITEM_ATTR on every item in `list` (the ownership marker
43
+ // traits/tabbable.ts defers to — the ROVING-MARKER CONTRACT banner above); idempotent, cheap to re-apply
44
+ // on every call (init + every move).
45
+ function applyTabindexes(list, index) {
46
+ for (let i = 0; i < list.length; i++) {
47
+ list[i].tabIndex = i === index ? 0 : -1;
48
+ list[i].setAttribute(ROVING_ITEM_ATTR, '');
49
+ }
50
+ }
51
+ // Index of the first non-disabled item, or -1 if the set is all-disabled.
52
+ function findFirst(list) {
53
+ return list.findIndex((item) => !isDisabled(item));
54
+ }
55
+ // Index of the last non-disabled item, or -1.
56
+ function findLast(list) {
57
+ for (let i = list.length - 1; i >= 0; i--) {
58
+ if (!isDisabled(list[i]))
59
+ return i;
60
+ }
61
+ return -1;
62
+ }
63
+ // Walk from `from` in direction `dir` (+1 or -1), skipping disabled items. With `loop`, wraps
64
+ // modulo the list length. Without loop, stops at the boundary. Returns -1 when no candidate found.
65
+ function walkEnabled(list, from, dir, loop) {
66
+ const n = list.length;
67
+ for (let step = 1; step <= n; step++) {
68
+ const raw = from + dir * step;
69
+ const idx = loop ? ((raw % n) + n) % n : raw;
70
+ if (idx < 0 || idx >= n)
71
+ break;
72
+ if (!isDisabled(list[idx]))
73
+ return idx;
74
+ }
75
+ return -1;
76
+ }
77
+ /**
78
+ * Wire keyboard + tabindex roving focus on a `UIElement` host. Invoke from `connected()` so the
79
+ * listeners ride the connection AbortSignal (auto-removed on disconnect). Returns cleanup (idempotent).
80
+ */
81
+ export function rovingFocus(host, opts) {
82
+ const { items, orientation = 'vertical', loop = true, typeAhead = true, onMove, syncIndex, container } = opts;
83
+ let released = false;
84
+ // The current roving index (-1 = none; arises when the initial item set is all-disabled).
85
+ let rovingIndex = -1;
86
+ // Type-ahead: accumulate printable characters; reset after 200 ms of inactivity.
87
+ let typeBuffer = '';
88
+ let typeTimer = null;
89
+ // Move roving focus to `next`: update tabindexes, transfer real focus, notify the host.
90
+ function moveTo(list, next) {
91
+ if (next === -1 || next >= list.length)
92
+ return;
93
+ rovingIndex = next;
94
+ applyTabindexes(list, next);
95
+ list[next].focus();
96
+ onMove?.(next);
97
+ }
98
+ // Initialise tabindexes: use `initialIndex` (if provided) to seed from the host's current selection
99
+ // rather than defaulting to the first non-disabled item. This is required when the host's selection
100
+ // effect has already placed tabindex=0 on a non-first item (e.g. tabs reconnecting with tab 1 active).
101
+ const init = items();
102
+ if (opts.initialIndex !== undefined) {
103
+ const seed = typeof opts.initialIndex === 'function' ? opts.initialIndex() : opts.initialIndex;
104
+ rovingIndex = (seed >= 0 && seed < init.length) ? seed : findFirst(init);
105
+ }
106
+ else {
107
+ rovingIndex = findFirst(init);
108
+ }
109
+ applyTabindexes(init, rovingIndex);
110
+ // Settle pass — one requestAnimationFrame tick past connect (the tabs.ts `ready`-gate precedent):
111
+ // re-reads the live item set and re-applies tabindexes once more, closing the preorder-connection race
112
+ // for any item whose OWN focus-owning trait (traits/tabbable.ts) installs AFTER this synchronous init
113
+ // and would otherwise win the tabIndex write (the ROVING-MARKER CONTRACT banner above). Cancelled on
114
+ // early release.
115
+ const settleHandle = requestAnimationFrame(() => {
116
+ if (released)
117
+ return;
118
+ const list = items();
119
+ if (list.length === 0)
120
+ return;
121
+ let idx = rovingIndex;
122
+ if (idx < 0 || idx >= list.length || isDisabled(list[idx]))
123
+ idx = findFirst(list);
124
+ rovingIndex = idx;
125
+ applyTabindexes(list, idx);
126
+ });
127
+ // Keydown handler — attached to `container` (default: host) so it can be scoped to a sub-region
128
+ // (e.g. a tablist strip) without intercepting arrow keys inside focusable panel content.
129
+ const listenerTarget = container ?? host;
130
+ host.listen(listenerTarget, 'keydown', (event) => {
131
+ if (released)
132
+ return;
133
+ const e = event;
134
+ const list = items(); // live re-read: handles dynamic item sets
135
+ if (list.length === 0)
136
+ return;
137
+ // Sync external selection state (e.g. click/programmatic) before any key-based navigation so the
138
+ // next Arrow move starts from the correct position even when the host updated its own index
139
+ // via a path that bypassed `onMove` (tabs' #commit() on click, for example).
140
+ if (syncIndex !== undefined) {
141
+ const synced = syncIndex();
142
+ if (synced >= 0 && synced < list.length)
143
+ rovingIndex = synced;
144
+ }
145
+ const isNextKey = orientation === 'vertical' ? e.key === 'ArrowDown' : e.key === 'ArrowRight';
146
+ const isPrevKey = orientation === 'vertical' ? e.key === 'ArrowUp' : e.key === 'ArrowLeft';
147
+ const isHomeKey = e.key === 'Home';
148
+ const isEndKey = e.key === 'End';
149
+ if (!isNextKey && !isPrevKey && !isHomeKey && !isEndKey) {
150
+ // Type-ahead: single printable character with no modifier key shortcuts active.
151
+ if (typeAhead && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
152
+ typeBuffer += e.key.toLowerCase();
153
+ if (typeTimer !== null)
154
+ clearTimeout(typeTimer);
155
+ typeTimer = setTimeout(() => {
156
+ typeBuffer = '';
157
+ typeTimer = null;
158
+ }, 200);
159
+ // Search from the item AFTER current (wrapping), including current only as the last resort.
160
+ const from = rovingIndex; // -1 is safe: ((-1+1) % n + n) % n = 0
161
+ const n = list.length;
162
+ for (let step = 1; step <= n; step++) {
163
+ const idx = ((from + step) % n + n) % n;
164
+ if (!isDisabled(list[idx])) {
165
+ const label = (list[idx].textContent ?? '').trim().toLowerCase();
166
+ if (label.startsWith(typeBuffer)) {
167
+ e.preventDefault();
168
+ moveTo(list, idx);
169
+ break;
170
+ }
171
+ }
172
+ }
173
+ }
174
+ return;
175
+ }
176
+ e.preventDefault();
177
+ // Clamp rovingIndex to valid range in case items changed since the last move.
178
+ let current = rovingIndex;
179
+ if (current < 0 || current >= list.length) {
180
+ current = findFirst(list);
181
+ if (current === -1)
182
+ return; // all-disabled — nothing to do
183
+ }
184
+ let next;
185
+ if (isNextKey)
186
+ next = walkEnabled(list, current, 1, loop);
187
+ else if (isPrevKey)
188
+ next = walkEnabled(list, current, -1, loop);
189
+ else if (isHomeKey)
190
+ next = findFirst(list);
191
+ else
192
+ next = findLast(list); // isEndKey
193
+ moveTo(list, next);
194
+ });
195
+ // Strip the ownership marker (+ cancel a still-pending settle pass) — idempotent, so both the manual
196
+ // early-teardown call below AND the automatic disconnect path (next) can invoke it safely.
197
+ function cleanup() {
198
+ if (released)
199
+ return;
200
+ released = true;
201
+ cancelAnimationFrame(settleHandle);
202
+ // Best-effort — an item that has since left the DOM entirely needs no cleanup. A reconnect's fresh
203
+ // `rovingFocus()` call re-stamps it.
204
+ for (const item of items())
205
+ item.removeAttribute(ROVING_ITEM_ATTR);
206
+ }
207
+ // Auto-release on disconnect: the keydown LISTENER already dies via `host.listen`'s connection
208
+ // AbortSignal, but the marker is real DOM state with a real consumer (traits/tabbable.ts) — it must not
209
+ // outlive the roving host. A no-op-dependency `host.effect` is the scope-owned "run cleanup on dispose"
210
+ // idiom: it runs once (no signal reads to re-trigger it) and its RETURNED cleanup fires when the
211
+ // connection scope disposes at disconnect, exactly like every other scope-owned effect in this fleet.
212
+ host.effect(() => cleanup);
213
+ return cleanup;
214
+ }
@@ -0,0 +1,28 @@
1
+ import type { UIElement } from '../dom/index.js';
2
+ export interface ScrollFadeOptions {
3
+ /**
4
+ * The scrolling viewport — measured (scrollTop/Height/clientHeight) AND painted (the `data-fade-*` flags +
5
+ * the published bracket offsets all land here). Usually `host` itself, sometimes a control-owned part.
6
+ */
7
+ viewport?: HTMLElement;
8
+ /**
9
+ * The element whose DIRECT children are queried for a header/footer bracket — defaults to `viewport`. Set
10
+ * this when the brackets are NOT the viewport's own children (ui-card: `viewport` is `ui-card-content`
11
+ * itself, but the header/footer are the CARD's direct children, one level up).
12
+ */
13
+ brackets?: HTMLElement;
14
+ /**
15
+ * Reactively gate the trait — read inside the host's effect, so toggling the underlying signal turns the
16
+ * fade attributes on/off live (and tears down the listener/observer when it goes false). Default: always on.
17
+ */
18
+ enabled?: () => boolean;
19
+ }
20
+ /**
21
+ * Toggle `data-fade-top`/`data-fade-bottom` on `viewport` from its own live scroll position, with the bracket
22
+ * band queried off `brackets` (default `viewport`). Invoke from the control's `connected()` (e.g.
23
+ * `scrollFade(this, { viewport: dialog })`, or ui-card's `scrollFade(this, { brackets: card })` when the
24
+ * viewport IS the host but the brackets live on a distinct ancestor). Returns `release()` for early teardown
25
+ * (idempotent); otherwise the effect (and the listener/observer it installs) is disposed when the host
26
+ * disconnects.
27
+ */
28
+ export declare function scrollFade(host: UIElement, opts?: ScrollFadeOptions): () => void;
@@ -0,0 +1,120 @@
1
+ // scroll-fade.ts — the edge-aware scroll-fade trait (container box-model revision, ADR-0046 follow-up).
2
+ // A scroll viewport with a sticky header/footer bracket (container-box.css) has an inset gutter around the
3
+ // bracket — the bracket's own `background: inherit` does not reach the frame edge (the region floats inside,
4
+ // ADR-0046 revised 2026-07-04). Scrolled content can therefore show through that thin gutter. The fix is not
5
+ // on the bracket: it is on the CONTENT — fade its own edges before they reach the gutter, so nothing hard ever
6
+ // shows there. This trait supplies the "which edge" decision; container-box.css supplies the paint
7
+ // (`[data-fade-top]`/`[data-fade-bottom]` → a `mask-image` gradient, the same recipe card.css already shipped
8
+ // as a static, always-on fade).
9
+ //
10
+ // The BRACKET QUERY can differ from `viewport` (`opts.brackets`, added 2026-07-06 for ui-card, still needed):
11
+ // `bandOf()` queries `brackets`' DIRECT children for the header/footer, independent of which element is being
12
+ // measured/painted — ui-card-content IS the scroll viewport (and the masked element, below), but the header/
13
+ // footer are the CARD's own direct children, one level up. Defaults to `viewport` (unchanged for every other
14
+ // consumer, where the brackets genuinely are the viewport's own direct children).
15
+ //
16
+ // MEASURE and PAINT are always the SAME element (`viewport`) — a short-lived `opts.paintTarget` split existed
17
+ // 2026-07-05/06 for an EARLIER ui-card shape (the card measured, a sibling `ui-card-content` painted; then a
18
+ // nested `[scroll-wrapper]` measured while `ui-card-content` painted). REVISED 2026-07-07 (Kim): ui-card-content
19
+ // now directly manages its own overflow AND carries the mask — one box, no split — so `paintTarget` is retired
20
+ // (no remaining consumer ever set it distinct from `viewport`); this keeps the trait's surface to the two roles
21
+ // that are still genuinely independent (`viewport`/`brackets`), not three.
22
+ //
23
+ // PRESENCE-AWARE: the fade must ADAPT to a sticky/overlaid header/footer — the gradient itself does the
24
+ // occlusion (a bracket needs no background; a bit of gradient showing through it is fine), so the ramp reaches
25
+ // PAST a present bracket. The trait publishes each edge's bracket band — `--ui-box-head`/`--ui-box-foot`, the
26
+ // bracket's rendered block-size + its inset gutter, or 0px when that edge has none — and the consumer's own CSS
27
+ // (container-box.css's mask rule, or ui-card-content's own block-padding) reads it. Content thus stays faded
28
+ // until it has cleared a present bracket; a bracketless edge (0px) collapses to the exact pre-offset fade/padding.
29
+ //
30
+ // EDGE-AWARE, not symmetric: a flag is present only when content is genuinely hidden past THAT edge — a short
31
+ // / non-scrolling viewport gets neither flag (never faded), and an at-top/at-bottom viewport only fades the
32
+ // edge that still hides something.
33
+ //
34
+ // Deliberately JS-driven, not `animation-timeline: scroll()`: the decision is three comparisons against
35
+ // scrollTop/scrollHeight/clientHeight — trivially unit-testable in jsdom and immune to scroll-driven-animation
36
+ // engine-support gaps. The PAINT it drives is the well-proven `mask-image` mechanism already shipped (card.css).
37
+ //
38
+ // Reactive gate (mirrors tabbable.ts's `disabled: () => boolean` shape): `opts.enabled` is read inside the
39
+ // host's own `host.effect`, so an author-facing opt-in prop (e.g. ui-card-content's `scroll-fade`) can turn the
40
+ // whole mechanism on/off live. Omit it for an always-on internal viewport (a modal dialog, an overlay panel).
41
+ //
42
+ // `traits → dom` is the one allowed cross-layer direction; the host type only.
43
+ // Direct-child brackets, per the container box-model: a `header`/`footer` (or `[data-region]`) region on a
44
+ // `[data-box]` (modal / overlay panels), and `ui-card-header`/`ui-card-footer` on the ui-card that hosts a
45
+ // `ui-card-content` viewport.
46
+ const HEAD = ':scope > :is(header, [data-region="header"], ui-card-header)';
47
+ const FOOT = ':scope > :is(footer, [data-region="footer"], ui-card-footer)';
48
+ /** The rendered band a bracket occupies at its edge = its border-box block-size + the inset gutter (its margin
49
+ * to the frame). `'0px'` when that edge has NO bracket — the fade/padding then reaches the viewport edge. */
50
+ function bandOf(brackets, sel, top) {
51
+ const el = brackets.querySelector(sel);
52
+ if (!(el instanceof HTMLElement))
53
+ return '0px';
54
+ const cs = getComputedStyle(el);
55
+ return `${el.offsetHeight + (parseFloat(top ? cs.marginBlockStart : cs.marginBlockEnd) || 0)}px`;
56
+ }
57
+ /** Publish each edge's bracket band as `--ui-box-head`/`--ui-box-foot` on `viewport` — the offset a consumer's
58
+ * CSS extends the fade ramp (or its own block-padding) PAST, so content stays clear of / faded until it has
59
+ * cleared a present bracket (a bracketless edge, 0px, collapses to the plain depth). The bracket query itself
60
+ * always runs against `brackets` (its direct children — not necessarily `viewport`'s own). Re-run on resize (a
61
+ * growing bracket/viewport), not on the scroll path. */
62
+ function measure(brackets, viewport) {
63
+ viewport.style.setProperty('--ui-box-head', bandOf(brackets, HEAD, true));
64
+ viewport.style.setProperty('--ui-box-foot', bandOf(brackets, FOOT, false));
65
+ }
66
+ /** Zero out both fade flags + the published bracket offsets on `viewport` — the "not active" / "not scrollable" state. */
67
+ function clear(viewport) {
68
+ viewport.removeAttribute('data-fade-top');
69
+ viewport.removeAttribute('data-fade-bottom');
70
+ viewport.style.removeProperty('--ui-box-head');
71
+ viewport.style.removeProperty('--ui-box-foot');
72
+ }
73
+ /**
74
+ * Toggle `data-fade-top`/`data-fade-bottom` on `viewport` from its own live scroll position, with the bracket
75
+ * band queried off `brackets` (default `viewport`). Invoke from the control's `connected()` (e.g.
76
+ * `scrollFade(this, { viewport: dialog })`, or ui-card's `scrollFade(this, { brackets: card })` when the
77
+ * viewport IS the host but the brackets live on a distinct ancestor). Returns `release()` for early teardown
78
+ * (idempotent); otherwise the effect (and the listener/observer it installs) is disposed when the host
79
+ * disconnects.
80
+ */
81
+ export function scrollFade(host, opts = {}) {
82
+ const viewport = opts.viewport ?? host;
83
+ const brackets = opts.brackets ?? viewport;
84
+ const isEnabled = opts.enabled ?? (() => true);
85
+ const dispose = host.effect(() => {
86
+ if (!isEnabled()) {
87
+ clear(viewport);
88
+ return;
89
+ }
90
+ const update = () => {
91
+ const hasOverflow = viewport.scrollHeight > viewport.clientHeight + 1;
92
+ const atTop = viewport.scrollTop <= 1;
93
+ const atBottom = viewport.scrollTop + viewport.clientHeight >= viewport.scrollHeight - 1;
94
+ viewport.toggleAttribute('data-fade-top', hasOverflow && !atTop);
95
+ viewport.toggleAttribute('data-fade-bottom', hasOverflow && !atBottom);
96
+ };
97
+ const remeasure = () => {
98
+ update(); // the edge decision
99
+ measure(brackets, viewport); // the bracket offsets (presence + depth the fade ramp reaches past)
100
+ };
101
+ remeasure(); // the at-mount state (no scroll event has fired yet)
102
+ viewport.addEventListener('scroll', update, { passive: true }); // removed explicitly below — no AbortController
103
+ // needed (this trait is not riding host.listen); a plain remove keeps the marginal size down.
104
+ // The viewport's own box can resize independently of scrolling (a max-block-size cap, a card growing) —
105
+ // re-run both the edge decision and the bracket measurement whenever EITHER does (a bracket resize that
106
+ // reflows the viewport rides this too; `brackets` is observed separately since it can be a distinct element
107
+ // from `viewport`, e.g. ui-card). Feature-detected: every evergreen engine has ResizeObserver, but jsdom
108
+ // (the unit-test environment) does not — the scroll listener above still covers the decision there.
109
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(remeasure) : null;
110
+ ro?.observe(viewport);
111
+ if (brackets !== viewport)
112
+ ro?.observe(brackets);
113
+ return () => {
114
+ viewport.removeEventListener('scroll', update);
115
+ ro?.disconnect();
116
+ clear(viewport);
117
+ };
118
+ });
119
+ return () => dispose();
120
+ }
@@ -0,0 +1,30 @@
1
+ import type { UIElement } from '../dom/index.js';
2
+ export type SelectionMode = 'single' | 'multi';
3
+ export interface SelectionCommitOptions {
4
+ /**
5
+ * `'single'` — at most one selected key. `'multi'` — a Set with Shift/Ctrl range-toggle and
6
+ * Ctrl+Space per-item toggle. Default: `'single'`.
7
+ */
8
+ mode?: SelectionMode;
9
+ /**
10
+ * Called when the committed selection changes.
11
+ * Single mode: a `string` key, or `''` when nothing is selected.
12
+ * Multi mode: a `ReadonlySet<string>` of selected keys (may be empty).
13
+ */
14
+ onSelect?: (selection: string | ReadonlySet<string>) => void;
15
+ /**
16
+ * Live accessor returning the ordered option elements (re-read on each event for Shift-range
17
+ * ordering). Default: `[...host.querySelectorAll('[role=option]')]`.
18
+ */
19
+ items?: () => HTMLElement[];
20
+ /**
21
+ * Extract the string key from an option element. An empty string is treated as "no key" —
22
+ * items without a key are skipped. Default: `el.dataset['key'] ?? ''`.
23
+ */
24
+ keyOf?: (el: HTMLElement) => string;
25
+ }
26
+ /**
27
+ * Wire single/multi selection tracking on a `UIElement` host. Invoke from `connected()` so
28
+ * listeners ride the connection AbortSignal (auto-removed on disconnect). Returns cleanup (idempotent).
29
+ */
30
+ export declare function selectionCommit(host: UIElement, opts: SelectionCommitOptions): () => void;
@@ -0,0 +1,146 @@
1
+ // selection-commit.ts — the selection model + commit controller (listbox-roving LLD-C2). Tracks
2
+ // the selected key(s) for any "set of options" host: single or multi. Publishes `aria-selected`
3
+ // via internals element-reflection and emits `select` on commit. Value-codec-agnostic: the host
4
+ // maps its option elements to string keys.
5
+ //
6
+ // Consumer contract (trued by the TKT-0065 lateral review — the original header named hosts that
7
+ // lawfully cannot consume this trait): the selection marker is an `aria-selected` ATTRIBUTE on each
8
+ // item, so the trait fits attribute-reflected `[role=option]` hosts ONLY (ui-select is the shipped
9
+ // consumer). `role=menuitem` hosts (ui-menu) must not carry aria-selected (action semantics — menu.ts
10
+ // commits via its own click + Enter/Space path, which this trait also lacks a Space leg for), and
11
+ // ui-tab items drive selection through ElementInternals, never host attributes (the fleet ARIA law).
12
+ //
13
+ // `traits → dom` is the one allowed cross-layer direction; the host type only.
14
+ /**
15
+ * Wire single/multi selection tracking on a `UIElement` host. Invoke from `connected()` so
16
+ * listeners ride the connection AbortSignal (auto-removed on disconnect). Returns cleanup (idempotent).
17
+ */
18
+ export function selectionCommit(host, opts) {
19
+ const mode = opts.mode ?? 'single';
20
+ const getItems = opts.items ??
21
+ (() => [...host.querySelectorAll('[role=option]')]);
22
+ const keyOf = opts.keyOf ??
23
+ ((el) => el.dataset['key'] ?? '');
24
+ // A disabled option is non-committable — guards BOTH commit paths (click + Enter) so a disabled
25
+ // option cannot be selected even where CSS `pointer-events:none` is absent (jsdom) or bypassed
26
+ // (keyboard). Matches the rovingFocus/menu disabled convention: the HTML `[disabled]` attribute OR
27
+ // `aria-disabled="true"`. (Selection-follows-focus hosts already skip disabled at the roving layer;
28
+ // this is the commit-layer backstop.)
29
+ const isDisabled = (el) => el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true';
30
+ // Single-mode cursor; multi-mode Set; anchor key held across moves for Shift-range extension.
31
+ let singleKey = '';
32
+ let multiKeys = new Set();
33
+ let anchorKey = '';
34
+ let released = false;
35
+ // Reflect aria-selected on every option element in the host. The attribute is set on the ITEMS
36
+ // (NOT on the host) — aria-selected belongs to each option, not the listbox container.
37
+ const reflectAriaSelected = () => {
38
+ for (const item of getItems()) {
39
+ const k = keyOf(item);
40
+ const selected = mode === 'single'
41
+ ? (k !== '' && k === singleKey)
42
+ : multiKeys.has(k);
43
+ item.setAttribute('aria-selected', String(selected));
44
+ }
45
+ };
46
+ // Reflect ARIA, invoke `onSelect`, and emit `select` with the current committed selection.
47
+ const publish = () => {
48
+ reflectAriaSelected();
49
+ const selection = mode === 'single' ? singleKey : new Set(multiKeys);
50
+ opts.onSelect?.(selection);
51
+ host.emit('select', selection);
52
+ };
53
+ // Resolve the closest [role=option] ancestor of a click target, constrained within the host.
54
+ const optionFromTarget = (target) => {
55
+ if (!(target instanceof HTMLElement))
56
+ return null;
57
+ const item = target.closest('[role=option]');
58
+ return item && host.contains(item) ? item : null;
59
+ };
60
+ host.listen(host, 'click', (event) => {
61
+ if (released)
62
+ return;
63
+ const e = event;
64
+ const item = optionFromTarget(e.target);
65
+ if (!item || isDisabled(item))
66
+ return;
67
+ const key = keyOf(item);
68
+ if (key === '')
69
+ return;
70
+ if (mode === 'single') {
71
+ singleKey = key;
72
+ anchorKey = key;
73
+ publish();
74
+ return;
75
+ }
76
+ // Multi mode — modifier keys determine how the selection evolves.
77
+ if (e.shiftKey && anchorKey !== '') {
78
+ // Range-extend: replace selection with the range from anchor to the clicked item (inclusive).
79
+ // The anchor itself does not move on Shift — it stays at the last plain click/Enter.
80
+ const items = getItems();
81
+ const ai = items.findIndex((el) => keyOf(el) === anchorKey);
82
+ const ci = items.indexOf(item);
83
+ if (ai >= 0 && ci >= 0) {
84
+ const lo = Math.min(ai, ci);
85
+ const hi = Math.max(ai, ci);
86
+ multiKeys = new Set(items.slice(lo, hi + 1).map(keyOf).filter((k) => k !== ''));
87
+ publish();
88
+ }
89
+ }
90
+ else if (e.ctrlKey || e.metaKey) {
91
+ // Toggle: flip this item's membership without moving the anchor.
92
+ const next = new Set(multiKeys);
93
+ if (next.has(key))
94
+ next.delete(key);
95
+ else
96
+ next.add(key);
97
+ multiKeys = next;
98
+ publish();
99
+ }
100
+ else {
101
+ // Plain click: replace the selection and set a new anchor.
102
+ multiKeys = new Set([key]);
103
+ anchorKey = key;
104
+ publish();
105
+ }
106
+ });
107
+ host.listen(host, 'keydown', (event) => {
108
+ if (released)
109
+ return;
110
+ const e = event;
111
+ if (e.key !== 'Enter')
112
+ return;
113
+ // Commit the currently focused option (set by rovingFocus or any active-descendant approach).
114
+ const active = document.activeElement;
115
+ if (!active || !host.contains(active))
116
+ return;
117
+ const item = active.closest('[role=option]');
118
+ if (!item || isDisabled(item))
119
+ return;
120
+ const key = keyOf(item);
121
+ if (key === '')
122
+ return;
123
+ // Enter COMMITS here — suppress its default action so a host that closes an overlay on commit and
124
+ // restores focus to a <button> trigger does NOT let the SAME Enter re-activate that button (which
125
+ // would re-toggle the popup open). Cross-engine bug caught by ui-select's commit-close smoke.
126
+ e.preventDefault();
127
+ if (mode === 'single') {
128
+ singleKey = key;
129
+ anchorKey = key;
130
+ publish();
131
+ }
132
+ else {
133
+ // Toggle the focused item; anchor stays.
134
+ const next = new Set(multiKeys);
135
+ if (next.has(key))
136
+ next.delete(key);
137
+ else
138
+ next.add(key);
139
+ multiKeys = next;
140
+ publish();
141
+ }
142
+ });
143
+ return () => {
144
+ released = true;
145
+ };
146
+ }