@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,538 @@
1
+ // select.ts — UISelectElement, the Wave-4 S4 single-select form control
2
+ // (control-suite-wave4-overlay.decomp.md S4 · ADR-0043 · listbox-roving.lld).
3
+ //
4
+ // Composition: a UIFormElement host + overlay controller (Popover API + JS positioning +
5
+ // light-dismiss) + rovingFocus (keyboard navigation within the panel's [role=option] items) +
6
+ // selectionCommit (single-select; aria-selected + `select` event). The trigger is a
7
+ // Control-class <button> showing the selected option's label (or placeholder) + a caret
8
+ // glyph sized = font (the §4.1 caret law: --ui-select-glyph = font). The panel is a
9
+ // Container/surface `<div>` with `role=listbox` (set on the panel element itself, NOT on
10
+ // the host — the host has no ARIA role: it is a logical select wrapper). Options are
11
+ // light-DOM children ([role=option]) adopted into the panel at first connect AND on every
12
+ // LATER light-DOM mutation (TKT-0026: a MutationObserver on the host's own childList; see
13
+ // #syncOptions) — an Option (or [role=group]) appended after connect is adopted too, in the
14
+ // order it was added.
15
+ //
16
+ // Two-way `open` (ADR-0019): a scope-owned effect drives model→overlay (open/close the
17
+ // handle); a `close` listener on the host drives overlay→model (light-dismiss syncs the
18
+ // prop back). Mirrors the ui-popover S1 pattern exactly.
19
+ //
20
+ // Anatomy (parts created ONCE — idempotent across disconnect/reconnect):
21
+ // <ui-select>
22
+ // <button data-part="trigger" type="button" aria-haspopup="listbox"
23
+ // aria-expanded="…" aria-controls="ui-select-listbox-N">
24
+ // <span data-part="label">…selected label or placeholder…</span>
25
+ // <span data-part="caret" aria-hidden="true"><svg>…caret-down (@agent-ui-kit/icons)…</svg></span>
26
+ // </button>
27
+ // <span data-part="aria-label">…visually-hidden `label` prop text…</span>
28
+ // <div data-part="listbox" role="listbox" id="ui-select-listbox-N"
29
+ // popover="auto" tabindex="-1">
30
+ // <!-- author's [role=option] children, adopted here at first connect and on every later add -->
31
+ // </div>
32
+ // </ui-select>
33
+ //
34
+ // ADR-0085 — the labelling seam: the trigger is a <button> (name-from-content = the value span), so a
35
+ // bare aria-label would ERASE the value (accname precedence). Instead the trigger's aria-labelledby
36
+ // CONCATENATES a name source with the [data-part=label] value span, which recomputes live as the
37
+ // selection changes: bare usage (unfielded) points at the control-created, visually-hidden
38
+ // [data-part=aria-label] span holding the `label` prop text; fielded usage (inside a ui-field) points
39
+ // at the field's own visible label part instead (applyFieldLabelling override, merge-not-clobber). No
40
+ // label + no field ⇒ no aria-labelledby ⇒ content-only name (today's default, zero drift).
41
+ //
42
+ // Closed-trigger keyboard (platform parity): ArrowDown/ArrowUp on the CLOSED trigger
43
+ // opens the panel; focus lands on the current selection via the overlay controller's
44
+ // moveFocusIn() (which uses the tabindex=0 set by rovingFocus on the selected/first
45
+ // option). Within the open panel, rovingFocus (container: listbox) handles Arrow/Home/End
46
+ // and type-ahead; Enter (and click on an option) commits via selectionCommit. Escape is
47
+ // handled by the Popover API (popover=auto light-dismiss) + the overlay controller's
48
+ // toggle listener, which emits `close` → our listener → open=false.
49
+ //
50
+ // ARIA: the host carries no explicit role (a logical select wrapper; internals.role stays
51
+ // unset). The trigger button has aria-haspopup="listbox" + aria-expanded (synced via a
52
+ // scope-owned effect) + aria-controls pointing to the panel's stable id. The panel carries
53
+ // role="listbox" as a direct child attribute (never on the host — the listbox is the PANEL
54
+ // part, not the host). [role=option] children carry aria-selected (driven by selectionCommit).
55
+ //
56
+ // Form: formValue() = the selected key string (null when nothing is selected); formValidity()
57
+ // = required && nothing selected → { valid: false, flags: { valueMissing: true } }.
58
+ // formReset() restores value to '' (the default / unselected state).
59
+ //
60
+ // Import layers: controls/ → dom + traits + reactive (inward-only ✓).
61
+ // erasableSyntaxOnly ✓ (no enum/namespace/decorators). verbatimModuleSyntax ✓ (import type).
62
+ import { prop } from "../../dom/index.js";
63
+ import { UIFormElement } from "../../dom/index.js";
64
+ import { overlay } from "../../traits/overlay.js";
65
+ import { rovingFocus } from "../../traits/roving-focus.js";
66
+ import { selectionCommit } from "../../traits/selection-commit.js";
67
+ import { scrollFade } from "../../traits/scroll-fade.js";
68
+ import { trackUserInvalid } from "../../traits/track-user-invalid.js";
69
+ import { setIcon } from '@agent-ui-kit/icons';
70
+ // ── Module-level stable-id counter (one per listbox panel, never reused across instances) ──────
71
+ let _nextListboxId = 0;
72
+ // ── Props ────────────────────────────────────────────────────────────────────────────────────────
73
+ const props = {
74
+ // Universal form attributes from the spreadable formProps (name/disabled/required).
75
+ // Each reflects — name for submission keying, disabled/required for attribute-selector styling.
76
+ ...UIFormElement.formProps,
77
+ // `value` — the selected option key (the matched `value` attribute of the [role=option] child).
78
+ // '' = nothing selected. Reflected so `<ui-select value="apple">` works declaratively AND the
79
+ // renderer two-way-binds it (value:{prop:'value',event:'select'} — the catalog's binding pair).
80
+ value: { ...prop.string(''), reflect: true },
81
+ // `label` — the bare-usage accessible-name source (ADR-0085; the text-field `label` precedent).
82
+ // NOT reflected — an accessibility hint, not a styling hook. '' = no label → the trigger keeps its
83
+ // content-only accessible name (back-compat). A host `aria-label` attribute stays inert (the host is
84
+ // role-less) — this prop, not an attribute passthrough, is the seam.
85
+ label: { ...prop.string(), reflect: true },
86
+ // `open` — whether the listbox panel is currently shown. Reflected + BINDABLE (ADR-0019).
87
+ // Drives the overlay handle via a scope-owned effect (model→overlay). Overlay→model sync via
88
+ // the `close` listener (platform light-dismiss writes open=false).
89
+ open: { ...prop.boolean(false), reflect: true },
90
+ // `placeholder` — the label shown on the trigger when nothing is selected. Not submitted.
91
+ placeholder: { ...prop.string('') },
92
+ // `size` — the dimensional-ramp step (text-field.ts:130 precedent). Reflects so the [size]
93
+ // attribute-selector repoint in select.css (trigger height/font/icon/gap, sm/lg blocks) applies
94
+ // to JS-set values too, not only author-set attributes — the T7 coherence fix (ADR-0081 doc-tail:
95
+ // select shipped the [size] CSS ramp with no declaring prop/attribute, an API-drift blind spot).
96
+ size: { ...prop.enum(['sm', 'md', 'lg'], 'md'), reflect: true },
97
+ };
98
+ export class UISelectElement extends UIFormElement {
99
+ static props = props;
100
+ // The control-created listbox panel PART — created ONCE (idempotent guard in #ensureParts()).
101
+ // Persists through disconnect/reconnect (like the modal dialog / popover panel).
102
+ #listbox = null;
103
+ #trigger = null;
104
+ #labelSpan = null;
105
+ #ariaLabelSpan = null;
106
+ // TKT-0026 — watches the HOST's own light-DOM childList so an Option/group added AFTER first
107
+ // connect still adopts into the panel (see #syncOptions below). Created fresh on every connect
108
+ // (the split.ts precedent); disconnected + nulled in disconnected().
109
+ #optionObserver = null;
110
+ // Shared group-header id sequence (was a local `let groupSeq` inside the old one-time move loop) —
111
+ // hoisted to an instance field so a LATE group's header id never collides with an earlier one.
112
+ #groupSeq = 0;
113
+ // The user-invalid TIMING controller (ADR-0051), created per connection (re-arms on reconnect;
114
+ // released on disconnect) — the text-field precedent.
115
+ #userInvalid = null;
116
+ /**
117
+ * Protected overlay handle — accessible to test probes (C10 idempotent-cleanup DoD).
118
+ * Replaced on each reconnect (connected() re-runs); the old handle's cleanup fires via the
119
+ * scope effect disposer at disconnect before this is re-assigned.
120
+ */
121
+ _overlayHandle = null;
122
+ // ── Form seams (UIFormElement hooks) ──────────────────────────────────────────────────────────
123
+ formValue() {
124
+ // '' = nothing selected → no form entry (null). Non-empty = the selected option key.
125
+ return this.value === '' ? null : this.value;
126
+ }
127
+ formValidity() {
128
+ if (this.required && this.value === '') {
129
+ return { valid: false, flags: { valueMissing: true }, message: 'Please select an option.' };
130
+ }
131
+ return { valid: true };
132
+ }
133
+ formReset() {
134
+ // Restore to the default (nothing selected) on form reset.
135
+ this.value = '';
136
+ // ADR-0051 — a reset must not leave a required-empty select showing :state(user-invalid) until
137
+ // the user re-interacts (the text-field formReset() precedent).
138
+ this.#userInvalid?.reset();
139
+ }
140
+ disconnected() {
141
+ this.#userInvalid?.release(); // idempotent — the listeners already die with the connection scope
142
+ this.#userInvalid = null;
143
+ this.#optionObserver?.disconnect(); // TKT-0026 — the observer does not outlive the connection
144
+ this.#optionObserver = null;
145
+ }
146
+ /** Feeds `FormConnectDetail.userInvalid` (ADR-0050) — the `trackUserInvalid` tracker IS the one
147
+ * timing source; this override just exposes its gate. */
148
+ formUserInvalid() {
149
+ return this.#userInvalid?.userInvalid() ?? false;
150
+ }
151
+ // ── ADR-0085 — the field-labelling seam wire (the button-vs-editor MERGE override) ──────────────
152
+ /**
153
+ * The part-role override (ADR-0085 cl.4) — the trigger carries no `internals.role` (a logical select
154
+ * wrapper), so the base's guarded internals-reflection default (dom/form.ts) never fires; wire the
155
+ * trigger's `aria-labelledby`/`aria-describedby` content attributes directly instead (the text-field
156
+ * `applyFieldLabelling` precedent, ADR-0051 cl.2/LLD-C2).
157
+ *
158
+ * `aria-labelledby` is the BARE-mode effect's (connected(), below) exclusive concern whenever
159
+ * `fieldLabelling === null` — this override never touches it on dissociation (`refs === null`), so the
160
+ * two effects can never race to write the SAME attribute in the same flush wave (the F3 dual-writer
161
+ * discipline text-field's own override documents). While fielded (`refs !== null`), THIS method is the
162
+ * exclusive writer: MERGE, never clobber — `<refs.label.id> <value-span-id>` (field label + value),
163
+ * the same two-id shape as the bare path, only the name source swaps.
164
+ *
165
+ * `aria-describedby` has NO other owner for this control (the trigger carries no internal
166
+ * validity-message node, unlike text-field's editor) — so unlike text-field's override, THIS one is the
167
+ * describedby's exclusive owner in BOTH directions: written from `[refs.description, refs.error]` when
168
+ * fielded, cleared on dissociation.
169
+ *
170
+ * Guards a not-yet-created trigger (the LLD-C2 override contract) — cannot happen in practice
171
+ * (`#ensureParts()` runs synchronously at the top of `connected()`, before the base's forwarding effect
172
+ * installs), but the guard costs nothing and documents the contract.
173
+ */
174
+ applyFieldLabelling(refs) {
175
+ const trigger = this.#trigger;
176
+ const labelSpan = this.#labelSpan;
177
+ if (!trigger || !labelSpan)
178
+ return;
179
+ if (refs === null) {
180
+ // aria-labelledby is NOT this branch's concern — the bare-mode effect below recomputes the full
181
+ // unfielded state in the SAME flush wave. aria-describedby IS this method's exclusive concern in
182
+ // both directions (no bare-mode owner exists for it), so it clears here.
183
+ trigger.removeAttribute('aria-describedby');
184
+ return;
185
+ }
186
+ if (refs.label)
187
+ trigger.setAttribute('aria-labelledby', `${refs.label.id} ${labelSpan.id}`);
188
+ else
189
+ trigger.removeAttribute('aria-labelledby');
190
+ const described = [refs.description, refs.error].filter((el) => el !== null);
191
+ if (described.length > 0)
192
+ trigger.setAttribute('aria-describedby', described.map((el) => el.id).join(' '));
193
+ else
194
+ trigger.removeAttribute('aria-describedby');
195
+ }
196
+ // ── Connection lifecycle ───────────────────────────────────────────────────────────────────────
197
+ connected() {
198
+ const { trigger, listbox, labelSpan, ariaLabelSpan } = this.#ensureParts();
199
+ // TKT-0026 — adopt any [role=option]/[role=group] children sitting as direct light-DOM children
200
+ // of the host RIGHT NOW (catches anything appended while disconnected — no observer runs during
201
+ // that gap) and arm the observer for everything after. Idempotent + self-terminating: see
202
+ // #syncOptions' own doc for why this never double-moves an already-adopted node on reconnect.
203
+ this.#syncOptions();
204
+ this.#optionObserver = new MutationObserver(() => this.#syncOptions());
205
+ this.#optionObserver.observe(this, { childList: true });
206
+ // ADR-0051 — the user-invalid TIMING controller. `select` never emits a native `change` event
207
+ // (selectionCommit only emits `select` — grep-provable), so BLUR is the sole interaction signal
208
+ // here: the danger treatment arms once the trigger has been visited and left, the same
209
+ // "first blur or change" contract every other control honours (this one just has one of the two
210
+ // triggers). Reflects :state(user-invalid) + aria-invalid on the trigger (the role-carrying part
211
+ // — the host carries no internals.role, so the base's guarded default never fires).
212
+ const invalidController = trackUserInvalid(this, { invalid: () => !this.formValidity().valid });
213
+ this.#userInvalid = invalidController;
214
+ this.effect(() => {
215
+ if (invalidController.userInvalid()) {
216
+ this.internals.states?.add('user-invalid');
217
+ trigger.setAttribute('aria-invalid', 'true');
218
+ }
219
+ else {
220
+ this.internals.states?.delete('user-invalid');
221
+ trigger.removeAttribute('aria-invalid');
222
+ }
223
+ });
224
+ // Wire the overlay controller (LLD-C1..C4) — proves overlay + listbox together (ADR-0043 S4).
225
+ // placement='bottom-start' matches a standard select dropdown. auto=true → light-dismiss.
226
+ // focusOnOpen=true → focus moves into the listbox on open (focus lands on the tabindex=0 option
227
+ // set by rovingFocus, which is the current selection or the first option).
228
+ const handle = overlay(this, {
229
+ popup: listbox,
230
+ anchor: trigger,
231
+ placement: 'bottom-start',
232
+ auto: true,
233
+ focusOnOpen: true,
234
+ });
235
+ this._overlayHandle = handle;
236
+ // overlay→model: when the Popover API light-dismisses (Escape / outside-click), the overlay
237
+ // controller emits `close` on the host. Sync the prop back so the two-way bind stays consistent.
238
+ this.listen(this, 'close', () => {
239
+ this.open = false;
240
+ });
241
+ // model→overlay: a scope-owned effect drives open/close from the prop + keeps aria-expanded in
242
+ // sync. Runs immediately on creation (open=false → handle.close() no-op + aria-expanded='false').
243
+ // B1 fix: measure the trigger width in JS and stamp it as a minInlineSize on the panel BEFORE
244
+ // showPopover() is called. In the Popover API top layer `100%` CSS resolves to 100vw (the viewport
245
+ // width), NOT the trigger width — so matching the trigger width must come from JS, not CSS.
246
+ this.effect(() => {
247
+ const isOpen = this.open;
248
+ if (isOpen) {
249
+ const tw = trigger.getBoundingClientRect().width;
250
+ if (tw > 0)
251
+ listbox.style.minInlineSize = `${tw}px`;
252
+ handle.open();
253
+ }
254
+ else {
255
+ handle.close();
256
+ }
257
+ trigger.setAttribute('aria-expanded', String(isOpen));
258
+ });
259
+ // Trigger label effect: update the visible label whenever `value` or `placeholder` changes.
260
+ // Reads both signals reactively — re-runs on either change. Searches for the matching option
261
+ // by its `value` attribute (options are in the listbox, still within host.contains()).
262
+ // `[data-empty]` on the trigger (TKT-0062) — the SAME emptiness signal text-field/textarea/combo-box
263
+ // already toggle on their own editor part (`value === '' ⇒ showing the placeholder`), added here so
264
+ // select.css's filled/container state law can key off it identically to its siblings — select had no
265
+ // prior emptiness hook (it always wrote SOME text into labelSpan, selection or placeholder alike).
266
+ this.effect(() => {
267
+ const val = this.value;
268
+ const ph = this.placeholder;
269
+ trigger.toggleAttribute('data-empty', val === '');
270
+ if (val !== '') {
271
+ const options = listbox.querySelectorAll('[role=option]');
272
+ let found = null;
273
+ for (const opt of options) {
274
+ if (opt.getAttribute('value') === val) {
275
+ found = opt;
276
+ break;
277
+ }
278
+ }
279
+ labelSpan.textContent = found ? (found.textContent?.trim() ?? val) : val;
280
+ }
281
+ else {
282
+ labelSpan.textContent = ph;
283
+ }
284
+ });
285
+ // ADR-0085 — the trigger's accessible-name seam (bare/unfielded path). Split ownership of
286
+ // `aria-labelledby` with applyFieldLabelling above (the F3 dual-writer discipline): this effect owns
287
+ // it EXCLUSIVELY while unfielded and never touches it while fielded (the early return below) — the
288
+ // override is the exclusive writer in that state, so the two can never race in the same flush wave.
289
+ // `label` set → the hidden aria-label span + the value span (axis + value, e.g. "Scheme light");
290
+ // `label` empty → no aria-labelledby → content-only accessible name (today's default, zero drift).
291
+ this.effect(() => {
292
+ if (this.fieldLabelling !== null)
293
+ return; // fielded — applyFieldLabelling owns aria-labelledby exclusively
294
+ const lbl = this.label;
295
+ if (lbl) {
296
+ ariaLabelSpan.textContent = lbl;
297
+ trigger.setAttribute('aria-labelledby', `${ariaLabelSpan.id} ${labelSpan.id}`);
298
+ }
299
+ else {
300
+ trigger.removeAttribute('aria-labelledby');
301
+ }
302
+ });
303
+ // B3 fix: a scope-owned effect keeps the trigger button's disabled state in sync with the
304
+ // effective-disabled signal (own `disabled` prop OR ancestor <fieldset disabled>). This
305
+ // makes the trigger pointer-inert and prevents AT from activating it while disabled.
306
+ // aria-disabled is NOT set additionally — the button's `disabled` attribute is sufficient
307
+ // (it carries the a11y semantics natively for <button>; a dual aria-disabled would be redundant).
308
+ // TKT-0063: also mirrors text-field.ts's `:state(disabled)` CSS hook (the effective-disabled
309
+ // channel) — the host's own `[disabled]` attribute alone misses the ancestor-fieldset case, so
310
+ // select.css's disabled row (:is([disabled], :state(disabled))) needs this to key off form-disabled too.
311
+ this.effect(() => {
312
+ if (this.effectiveDisabled()) {
313
+ trigger.setAttribute('disabled', '');
314
+ this.internals.states?.add('disabled');
315
+ }
316
+ else {
317
+ trigger.removeAttribute('disabled');
318
+ this.internals.states?.delete('disabled');
319
+ }
320
+ });
321
+ // Trigger click → toggle the panel (the disclosure interaction).
322
+ // B3 fix: guard with effectiveDisabled() — a disabled select must not open via click.
323
+ //
324
+ // ADR-0101 erratum fix: flip the PROP, not `handle.toggle()` directly. A raw `handle.toggle()`
325
+ // mutates the trait's internal `isOpen` without ever writing `this.open`, so a later
326
+ // programmatic close (`selectionCommit.onSelect` → `this.open = false`, below) is a
327
+ // same-value `Object.is` no-op under the reactive cutoff — the model→overlay effect never
328
+ // re-runs, `handle.close()` never fires, and the panel visibly sticks open after a commit
329
+ // (the mouse-open path this control shipped with). Routing through the prop makes it the
330
+ // single source of truth: click → `this.open` flips → the model→overlay effect (below) drives
331
+ // `handle.open()`/`handle.close()` and the trait announces (ADR-0101) — the exact combo-box
332
+ // pattern (combo-box.ts's editor never calls `handle.toggle()` either).
333
+ //
334
+ // Race check (traced against traits/overlay.ts): a prop write only *schedules* the effect
335
+ // (reactive/scheduler.ts: `schedule()` queues a microtask; it does not run synchronously), so
336
+ // `this.open` has already settled to its new value by the time the effect body reads it on the
337
+ // next microtask — the same ordering `selectionCommit`'s `onSelect` already relies on below.
338
+ // Toggle-while-open (click the trigger of an open panel): `this.open` flips true→false → the
339
+ // effect runs once → `handle.close()` → one `close`+`toggle` pair, `el.open` already `false` at
340
+ // listener time (ADR-0101 mechanic 3). No new re-entrancy: this is the identical write→schedule→
341
+ // effect→announce chain the keyboard-open (`this.open = true`, closed-trigger Arrow handler) and
342
+ // commit-close paths already exercise; only the previously-bypassed mouse gesture now uses it.
343
+ this.listen(trigger, 'click', () => {
344
+ if (!this.effectiveDisabled())
345
+ this.open = !this.open;
346
+ });
347
+ // Closed-trigger keyboard (platform parity): ArrowDown/ArrowUp on the CLOSED trigger opens
348
+ // the panel. Focus is moved to the current selection (or first option) by the overlay
349
+ // controller's moveFocusIn() via the tabindex=0 option that rovingFocus maintains.
350
+ // B3 fix: guard with effectiveDisabled() — a disabled select must not open via keyboard.
351
+ this.listen(trigger, 'keydown', (event) => {
352
+ if (this.effectiveDisabled())
353
+ return;
354
+ const e = event;
355
+ if (!this.open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
356
+ e.preventDefault();
357
+ this.open = true;
358
+ }
359
+ });
360
+ // Live option accessor (re-reads the DOM on each event for dynamic option sets).
361
+ const items = () => [...listbox.querySelectorAll('[role=option]')];
362
+ // Key extractor — the option's `value` attribute ('' = no key, skipped by selectionCommit).
363
+ const keyOf = (el) => el.getAttribute('value') ?? '';
364
+ // rovingFocus on the listbox panel (container: listbox) — the keydown listener attaches to
365
+ // the listbox so arrow-key navigation is scoped to the panel and does NOT intercept arrows
366
+ // in other regions. initialIndex seeds from the current selection so reconnect lands on the
367
+ // previously-selected option.
368
+ rovingFocus(this, {
369
+ items,
370
+ orientation: 'vertical',
371
+ container: listbox,
372
+ initialIndex: () => {
373
+ const list = items();
374
+ const idx = list.findIndex((el) => keyOf(el) === this.value && this.value !== '');
375
+ return idx >= 0 ? idx : 0;
376
+ },
377
+ });
378
+ // selectionCommit — single mode. onSelect updates `value` (which drives formValue() via the
379
+ // UIFormElement effect) and closes the overlay. selectionCommit also emits `select` on the
380
+ // host and sets aria-selected on each option.
381
+ selectionCommit(this, {
382
+ mode: 'single',
383
+ items,
384
+ keyOf,
385
+ onSelect: (selection) => {
386
+ // Single mode: selection is a string key.
387
+ this.value = selection;
388
+ // Close the overlay (model-drives the effect → handle.close() → focus restored to trigger).
389
+ this.open = false;
390
+ },
391
+ });
392
+ // Edge-aware scroll fade (the gutter-exposure fix, 2026-07-04) — always on, no opt-in prop. The listbox
393
+ // panel is the scroll viewport (`overflow-y: auto` in select.css); a sticky [data-part="group-label"] is
394
+ // already full-bleed (not affected by the inset-region gutter bug), but a long OPTION LIST still benefits
395
+ // from the same edge affordance the rest of the family gets.
396
+ scrollFade(this, { viewport: listbox });
397
+ }
398
+ // ── Part creation (idempotent across disconnect/reconnect) ─────────────────────────────────────
399
+ /**
400
+ * Create the control's THREE light-DOM parts ONCE (idempotent across disconnect/reconnect):
401
+ * - trigger: a `<button data-part="trigger">` with the label span + caret span.
402
+ * - ariaLabelSpan: a visually-hidden `<span data-part="aria-label">` (ADR-0085) — the bare-usage
403
+ * name source the trigger's `aria-labelledby` references alongside the value span.
404
+ * - listbox: a `<div data-part="listbox" role="listbox">` panel; author's [role=option]
405
+ * children are moved here at first connect.
406
+ * The overlay controller sets `popover="auto"` on the listbox. `render()` stays the inherited VOID.
407
+ */
408
+ #ensureParts() {
409
+ if (this.#listbox && this.#trigger && this.#labelSpan && this.#ariaLabelSpan) {
410
+ // Parts persist through disconnect/reconnect — return the existing ones.
411
+ return { trigger: this.#trigger, listbox: this.#listbox, labelSpan: this.#labelSpan, ariaLabelSpan: this.#ariaLabelSpan };
412
+ }
413
+ // One shared per-instance sequence number for every part id minted below (the ui-field precedent —
414
+ // `ui-select-value-N` / `-aria-label-N` / `-listbox-N` read as a set at a glance).
415
+ const seq = ++_nextListboxId;
416
+ // ── Build the trigger button ──
417
+ const trigger = document.createElement('button');
418
+ trigger.setAttribute('data-part', 'trigger');
419
+ trigger.setAttribute('type', 'button'); // prevent unintentional form submission
420
+ trigger.setAttribute('aria-haspopup', 'listbox');
421
+ // The label span shows the selected option's text (or placeholder). It also doubles as the
422
+ // SECOND id in the trigger's aria-labelledby concatenation (ADR-0085) — it needs a stable id even
423
+ // when unlabelled, since a later `label` write can arm the seam without recreating parts. The
424
+ // caret span is an inline affordance glyph sized = font (the §4.1 caret law; CSS handles centering
425
+ // in the icon cell via `padding: calc((icon - glyph) / 2)` — the `--ui-select-glyph = font` chain).
426
+ const labelSpan = document.createElement('span');
427
+ labelSpan.setAttribute('data-part', 'label');
428
+ labelSpan.id = `ui-select-value-${seq}`;
429
+ labelSpan.textContent = this.placeholder || '';
430
+ const caretSpan = document.createElement('span');
431
+ caretSpan.setAttribute('data-part', 'caret');
432
+ caretSpan.setAttribute('aria-hidden', 'true');
433
+ setIcon(caretSpan, 'caret-down'); // Phosphor, via @agent-ui-kit/icons — sized = font by the caret span's own CSS (§4.1)
434
+ trigger.appendChild(labelSpan);
435
+ trigger.appendChild(caretSpan);
436
+ // ── Build the visually-hidden aria-label span (ADR-0085 — the bare-usage name source) ──
437
+ // Always created (idempotent-parts precedent, text-field's `message` node) so the connected()
438
+ // effect never has to conditionally mint DOM; inert (unreferenced) when `label` is empty or the
439
+ // control is fielded. select.css clips it — NOT `hidden`/`display:none`, which some AT/engine
440
+ // combinations can fail to fold into the accessible-name computation for a labelledby reference.
441
+ const ariaLabelSpan = document.createElement('span');
442
+ ariaLabelSpan.setAttribute('data-part', 'aria-label');
443
+ ariaLabelSpan.id = `ui-select-aria-label-${seq}`;
444
+ // ── Build the listbox panel ──
445
+ const listbox = document.createElement('div');
446
+ listbox.setAttribute('data-part', 'listbox');
447
+ listbox.setAttribute('data-box', ''); // adopt the shared container box-model (inset margins + sticky headers)
448
+ // role="listbox" on the panel element (NOT on the host — the listbox is the PANEL, not the
449
+ // select wrapper). The host carries no explicit role (a logical select wrapper).
450
+ listbox.setAttribute('role', 'listbox');
451
+ // tabindex="-1" lets the overlay's moveFocusIn() fall back to panel.focus() when no option
452
+ // has tabindex=0 yet (empty select or before rovingFocus seeds). rovingFocus subsequently
453
+ // sets the selected/first option to tabindex=0.
454
+ listbox.setAttribute('tabindex', '-1');
455
+ // Stable id for the trigger's aria-controls (created once, never reused across instances).
456
+ listbox.id = `ui-select-listbox-${seq}`;
457
+ // Wire the trigger's ARIA affordances (aria-expanded is set reactively by the scope-owned
458
+ // effect in connected(); aria-controls is stable and set here once at part creation).
459
+ trigger.setAttribute('aria-controls', listbox.id);
460
+ // Author [role=option]/[role=group] light-DOM children are adopted into the listbox panel by
461
+ // #syncOptions (TKT-0026) — called once here (below the parts are recorded) AND on every later
462
+ // light-DOM mutation via #optionObserver (wired in connected()). The overlay controller sets
463
+ // popover="auto" on the listbox — not here (single-ownership).
464
+ this.appendChild(trigger);
465
+ this.appendChild(ariaLabelSpan);
466
+ this.appendChild(listbox);
467
+ this.#trigger = trigger;
468
+ this.#listbox = listbox;
469
+ this.#labelSpan = labelSpan;
470
+ this.#ariaLabelSpan = ariaLabelSpan;
471
+ return { trigger, listbox, labelSpan, ariaLabelSpan };
472
+ }
473
+ // ── Dynamic-options adoption (TKT-0026) ─────────────────────────────────────────────────────────
474
+ /**
475
+ * Adopt every author [role=option]/[role=group] node CURRENTLY sitting as a DIRECT light-DOM child
476
+ * of the host into the listbox panel. Runs once per connect (first-connect AND reconnect — the
477
+ * reconnect call catches anything appended while disconnected, when no observer was running) and
478
+ * again on every subsequent host `childList` mutation via `#optionObserver` (armed in connected()).
479
+ *
480
+ * Idempotent + self-terminating, no re-entrancy guard needed: an already-adopted node lives inside
481
+ * `#listbox`, not as a direct child of `this` — so `this.firstElementChild`/`nextElementSibling`
482
+ * traversal never re-visits it (the relocation law: no double-move on reconnect, and the mutation
483
+ * this method's OWN `listbox.appendChild` calls trigger on `this` resolves to a no-op re-entry: the
484
+ * moved node is gone from `this`'s childList by the time the self-triggered notification fires).
485
+ *
486
+ * Ordering: a newly-adopted node ALWAYS lands at the listbox's CURRENT tail, in the relative order
487
+ * it (and any OTHER not-yet-adopted sibling) currently occupies among the host's own children — the
488
+ * only position a native mutation can ever produce. An already-adopted sibling's true parent is the
489
+ * listbox, not the host, so no `insertBefore`/`insertAfter` call an author makes against the host can
490
+ * ever reference it — there is no DOM operation that legally splices new content BEFORE previously-
491
+ * adopted content. Processing the host's current children in document order is therefore already the
492
+ * fully general fix, not a narrowed one: it exactly preserves the one authored ordering an append/
493
+ * insert into the SELECT itself can ever express.
494
+ */
495
+ #syncOptions() {
496
+ const listbox = this.#listbox;
497
+ if (!listbox)
498
+ return;
499
+ let child = this.firstElementChild;
500
+ while (child) {
501
+ const next = child.nextElementSibling;
502
+ const role = child.getAttribute('role');
503
+ if (role === 'option' || role === 'group')
504
+ this.#adoptChild(child, listbox);
505
+ child = next;
506
+ }
507
+ }
508
+ /**
509
+ * Move ONE author [role=option] or [role=group] child into the listbox panel — the group-header
510
+ * mint-once logic is byte-identical to the original first-connect-only loop (GROUPS/optgroup
511
+ * parity: a `<div role="group" label="…">` renders a non-interactive header from its `label` (or
512
+ * `aria-label`) and moves with its nested `[role=option]` children; rovingFocus + selectionCommit
513
+ * operate on `[role=option]` found nested via querySelectorAll, so they traverse groups
514
+ * transparently and never land on a header; the group is named for AT via aria-labelledby). Shared
515
+ * by `#syncOptions`' first-connect and later-mutation passes — one code path, one behaviour.
516
+ */
517
+ #adoptChild(child, listbox) {
518
+ const role = child.getAttribute('role');
519
+ if (role === 'option') {
520
+ listbox.appendChild(child);
521
+ return;
522
+ }
523
+ // role === 'group' — the only other caller-checked branch.
524
+ const label = child.getAttribute('label') ?? child.getAttribute('aria-label') ?? '';
525
+ if (label && !child.querySelector(':scope > [data-part="group-label"]')) {
526
+ const header = document.createElement('div');
527
+ header.setAttribute('data-part', 'group-label');
528
+ header.id = `${listbox.id}-grp-${++this.#groupSeq}`;
529
+ header.textContent = label;
530
+ child.removeAttribute('label'); // consumed — the visible header + aria-labelledby replace it
531
+ child.setAttribute('aria-labelledby', header.id); // AT names the group from the header
532
+ child.insertBefore(header, child.firstChild);
533
+ }
534
+ listbox.appendChild(child);
535
+ }
536
+ }
537
+ if (!customElements.get('ui-select'))
538
+ customElements.define('ui-select', UISelectElement);