@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,680 @@
1
+ // combo-box.ts — UIComboBoxElement, the Wave-4 S5 combo-box overlay control
2
+ // (control-suite-wave4-overlay.decomp.md S5 · ADR-0043 · overlay-controller.lld.md).
3
+ //
4
+ // A FACE form-associated combo-box: a contenteditable editor part + a Popover-API listbox panel.
5
+ // Active-descendant pattern (focus STAYS on the editor; Arrow moves the highlighted option via
6
+ // aria-activedescendant WITHOUT moving DOM focus — the key distinction from ui-select/ui-menu).
7
+ // Filter-on-type opens the list and hides non-matching options; Enter/click commits;
8
+ // strict=false (default) allows free-text commit when no highlighted option exists.
9
+ //
10
+ // Architecture (settled):
11
+ // - Extends UIFormElement; self-defines ui-combo-box. Tier: pattern.
12
+ // - Editor part: control-created <div data-part="editor" contenteditable="plaintext-only"
13
+ // role="combobox"> — NOT imported from ui-text-field (concurrent build; active-descendant
14
+ // aria-activedescendant wiring differs from a plain text box). Caret guard mirrors
15
+ // text-field's model→surface write discipline: the model→surface effect only writes
16
+ // editor.textContent when this.value changes (not on every keystroke), so the user's caret
17
+ // is never reset mid-typing.
18
+ // - Listbox panel: control-created <div data-part="listbox" role="listbox" popover="auto">.
19
+ // Author-provided [role=option] children are adopted in at connect time AND on every later
20
+ // light-DOM mutation (TKT-0026: a MutationObserver on the host's own childList — the select.ts
21
+ // precedent; see #syncOptions), the child-move pattern, ADR-0017. Stable per-option ids
22
+ // assigned lazily (#setActive) so a late-adopted option gets one too.
23
+ // - overlay(this, { popup: listbox, anchor: editor, focusOnOpen: false }) — focus NEVER
24
+ // leaves the editor. Two-way `open` (ADR-0019): scope-owned effect drives model→overlay;
25
+ // the overlay's `close` event drives overlay→model.
26
+ // - form: formValue() = value || null; formValidity() enforces required + strict.
27
+ // - Labelling (ADR-0085, text-field ADR-0014 parity): a `label` prop → the editor's `aria-label`
28
+ // (bare usage — the editor has a DISTINCT accessible value, so aria-label does not erase it, unlike
29
+ // ui-select's button trigger); yields to `applyFieldLabelling`'s aria-labelledby the moment the
30
+ // control associates with a `ui-field`.
31
+ // - User-invalid (ADR-0051, text-field precedent): a `trackUserInvalid` controller gates the danger
32
+ // treatment until the first blur/change, reflecting :state(user-invalid) + aria-invalid on the editor.
33
+ //
34
+ // Layer: controls → dom + traits (inward-only ✓).
35
+ import { prop } from "../../dom/index.js";
36
+ import { UIFormElement } from "../../dom/form.js";
37
+ import { overlay } from "../../traits/overlay.js";
38
+ import { scrollFade } from "../../traits/scroll-fade.js";
39
+ import { trackUserInvalid } from "../../traits/track-user-invalid.js";
40
+ // ── Module-level stable-id counters ─────────────────────────────────────────────────────────────
41
+ let _nextPanelId = 0;
42
+ let _nextOptionId = 0;
43
+ // ── Props ────────────────────────────────────────────────────────────────────────────────────────
44
+ const props = {
45
+ // Universal form attributes (name / disabled / required — all reflected; UIFormElement.formProps
46
+ // spreadable bag, per the no-static-props-inheritance contract ADR-0013).
47
+ ...UIFormElement.formProps,
48
+ // `value` — the COMMITTED value: an option's `value` attribute key (on option-commit), a
49
+ // free-text string (on free-text commit when strict=false), or '' (nothing committed yet).
50
+ // NOT reflected — the live value is the editor surface; the attribute seeds the reset baseline.
51
+ value: prop.string(),
52
+ // `label` — the bare-usage accessible-name source (ADR-0085; text-field ADR-0014 parity — the
53
+ // editor has a DISTINCT accessible value, so aria-label does not erase it, unlike ui-select's
54
+ // button trigger). NOT reflected — an accessibility hint, not a styling hook.
55
+ label: { ...prop.string(), reflect: true },
56
+ // `open` — whether the listbox panel is shown. Reflected (bindable two-way, ADR-0019).
57
+ // Drives the overlay handle via a scope-owned effect; synced back on light-dismiss.
58
+ open: { ...prop.boolean(false), reflect: true },
59
+ // `strict` — when true, the committed value MUST match a [role=option] child (select-with-
60
+ // filter semantics). Default false = free text allowed (the general combo-box baseline).
61
+ strict: { ...prop.boolean(false), reflect: true },
62
+ // `placeholder` — displayed via [data-empty]::before { content: attr(data-placeholder) }
63
+ // when the editor is visually empty (no text). The CSS placeholder read-back pattern (matches
64
+ // text-field's approach so the fleet is consistent).
65
+ placeholder: prop.string(),
66
+ };
67
+ export class UIComboBoxElement extends UIFormElement {
68
+ static props = props;
69
+ // The control-created editor part — created ONCE (#ensureParts idempotent guard).
70
+ // Persists through disconnect/reconnect (light-DOM child).
71
+ #editor = null;
72
+ // The control-created listbox panel part — created ONCE. Children with [role=option] are
73
+ // adopted into it at connect time AND on every later light-DOM mutation (TKT-0026 — see
74
+ // #syncOptions) and stay there. The overlay controller sets popover="auto".
75
+ #listbox = null;
76
+ // The control-created "no matches" row — created ONCE, appended AFTER the moved-in options so it
77
+ // always renders last. Toggled `hidden` by `#syncEmptyState()` (2026-07-07 fix): without it, a
78
+ // filter that hides every option leaves the listbox panel open with ZERO visible children — the
79
+ // panel then collapses to its own 1px border top+bottom (no content, no min-block-size), painting
80
+ // as a stray ~2px horizontal line below the editor instead of a real panel or nothing at all.
81
+ #emptyRow = null;
82
+ /** Protected overlay handle — used for the C10 cleanup probe. */
83
+ _overlayHandle = null;
84
+ // Index of the currently highlighted (active-descendant) option within the VISIBLE option set.
85
+ // -1 = no option highlighted. Resets on every filter change and on commit.
86
+ #activeIdx = -1;
87
+ // IME composition guard — mirrors text-field: never update model or filter during composition.
88
+ #composing = false;
89
+ // Native-parity reset baseline — seeded ONCE from the initial `value` attribute
90
+ // (property-wins: upgradeProps runs first, attribute is the declarative default value).
91
+ #defaultValue = '';
92
+ #defaultCaptured = false;
93
+ // The user-invalid TIMING controller (ADR-0051), created per connection (re-arms on reconnect;
94
+ // released on disconnect) — the text-field precedent.
95
+ #userInvalid = null;
96
+ // TKT-0026 — the select.ts precedent: watches the HOST's own light-DOM childList so an Option
97
+ // added AFTER first connect still adopts into the panel (see #syncOptions below). Created fresh on
98
+ // every connect; disconnected + nulled in disconnected().
99
+ #optionObserver = null;
100
+ // ── Override `focus()` to forward to the editor part ────────────────────────────────────────
101
+ focus(options) {
102
+ if (this.#editor)
103
+ this.#editor.focus(options);
104
+ else
105
+ super.focus(options);
106
+ }
107
+ // ── Connect / disconnect lifecycle ──────────────────────────────────────────────────────────
108
+ connected() {
109
+ // Capture the reset baseline ONCE from the initial `value` attribute (see text-field pattern).
110
+ if (!this.#defaultCaptured) {
111
+ this.#defaultValue = this.getAttribute('value') ?? '';
112
+ this.#defaultCaptured = true;
113
+ }
114
+ const { editor, listbox } = this.#ensureParts();
115
+ // TKT-0026 — adopt any light-DOM children sitting as direct children of the host RIGHT NOW
116
+ // (catches anything appended while disconnected — no observer runs during that gap) and arm the
117
+ // observer for everything after. See #syncOptions for why this never double-moves on reconnect.
118
+ this.#syncOptions();
119
+ this.#optionObserver = new MutationObserver(() => this.#syncOptions());
120
+ this.#optionObserver.observe(this, { childList: true });
121
+ // ── ADR-0051 — the user-invalid TIMING controller ────────────────────────────────────────
122
+ // `blur` (capture) + `change` (emitted by #commitOption/#commitFreeText) — the text-field
123
+ // precedent. Reflects :state(user-invalid) + aria-invalid on the editor (the role-carrying
124
+ // part — role='combobox' rides the part, not internals.role, so the base's guarded default
125
+ // never fires).
126
+ const invalidController = trackUserInvalid(this, { invalid: () => !this.formValidity().valid });
127
+ this.#userInvalid = invalidController;
128
+ this.effect(() => {
129
+ if (invalidController.userInvalid()) {
130
+ this.internals.states?.add('user-invalid');
131
+ editor.setAttribute('aria-invalid', 'true');
132
+ }
133
+ else {
134
+ this.internals.states?.delete('user-invalid');
135
+ editor.removeAttribute('aria-invalid');
136
+ }
137
+ });
138
+ // ── Wire the overlay controller ──────────────────────────────────────────────────────────
139
+ // focusOnOpen: false is the CRITICAL combo-box difference: focus stays on the editor.
140
+ // The overlay controller still handles positioning, light-dismiss (Escape / outside-click),
141
+ // and emits `close` + `toggle` on light-dismiss (two-way bind, ADR-0019).
142
+ const handle = overlay(this, {
143
+ popup: listbox,
144
+ anchor: editor,
145
+ placement: 'bottom-start',
146
+ auto: true,
147
+ focusOnOpen: false,
148
+ });
149
+ this._overlayHandle = handle;
150
+ // ── Two-way `open` ───────────────────────────────────────────────────────────────────────
151
+ // overlay → model: the platform light-dismissed the panel (Escape / outside-click).
152
+ // The overlay controller already emitted `close` on the host; sync the prop back.
153
+ this.listen(this, 'close', () => {
154
+ this.open = false;
155
+ });
156
+ // model → overlay: a scope-owned effect drives open/close from the prop + keeps aria-expanded
157
+ // in sync. Eager first run (default open=false → handle.close() is a no-op, aria-expanded='false').
158
+ this.effect(() => {
159
+ const isOpen = this.open;
160
+ if (isOpen)
161
+ handle.open();
162
+ else
163
+ handle.close();
164
+ editor.setAttribute('aria-expanded', String(isOpen));
165
+ });
166
+ // ── Editor input → filter + open on type ─────────────────────────────────────────────────
167
+ this.listen(editor, 'input', () => {
168
+ if (this.#composing)
169
+ return;
170
+ const text = editor.textContent ?? '';
171
+ this.#filterOptions(text);
172
+ editor.toggleAttribute('data-empty', text === '');
173
+ // Open the panel on the first keystroke (the "open on type" behaviour).
174
+ if (!this.open && text.length > 0)
175
+ this.open = true;
176
+ });
177
+ // IME composition guard (prevent mid-composition filter churn / model writes).
178
+ this.listen(editor, 'compositionstart', () => {
179
+ this.#composing = true;
180
+ });
181
+ this.listen(editor, 'compositionend', () => {
182
+ this.#composing = false;
183
+ const text = editor.textContent ?? '';
184
+ this.#filterOptions(text);
185
+ editor.toggleAttribute('data-empty', text === '');
186
+ });
187
+ // ── Keyboard: Arrow / Enter / Escape ─────────────────────────────────────────────────────
188
+ this.listen(editor, 'keydown', (event) => {
189
+ if (this.#composing)
190
+ return;
191
+ const e = event;
192
+ const { key } = e;
193
+ if (key === 'ArrowDown' || key === 'ArrowUp') {
194
+ // Open the panel first (mirrors native select-box convention).
195
+ e.preventDefault();
196
+ if (!this.open)
197
+ this.open = true;
198
+ this.#moveActive(key === 'ArrowDown' ? 1 : -1);
199
+ return;
200
+ }
201
+ if (key === 'Enter') {
202
+ e.preventDefault();
203
+ if (this.open && this.#activeIdx >= 0) {
204
+ // Enter commits the highlighted option.
205
+ const opt = this.#getVisibleOptions()[this.#activeIdx];
206
+ if (opt)
207
+ this.#commitOption(editor, opt);
208
+ }
209
+ else if (!this.strict) {
210
+ // strict=false: Enter with no active option commits the typed text (free text).
211
+ const text = editor.textContent ?? '';
212
+ this.#commitFreeText(editor, text);
213
+ }
214
+ return;
215
+ }
216
+ // Escape is NOT handled here — it is a PLATFORM light-dismiss (the Popover API `popover=auto`
217
+ // close-signal is document-level, so it fires even though focus stays on the editor). Letting
218
+ // the platform close it (rather than a control-owned `this.open=false` here, which would race
219
+ // the platform's own dismiss) keeps a single close path per Escape press — the cross-engine bug
220
+ // the combo-box Escape smoke caught. The overlay trait announces `close`+`toggle` from that one
221
+ // path either way (ADR-0101 — every real hide announces, platform- or component-driven alike).
222
+ });
223
+ // ── Option click (listbox is in the top layer — listen on the panel itself) ──────────────
224
+ this.listen(listbox, 'click', (event) => {
225
+ const e = event;
226
+ const opt = e.target.closest('[role=option]');
227
+ // Guard: hidden OR disabled (aria-disabled='true' or the disabled HTML attribute) → no-op (M2).
228
+ if (!opt || opt.hidden
229
+ || opt.getAttribute('aria-disabled') === 'true'
230
+ || opt.hasAttribute('disabled'))
231
+ return;
232
+ this.#commitOption(editor, opt);
233
+ });
234
+ // ── model → surface: the caret guard (mirrors text-field's discipline) ────────────────────
235
+ //
236
+ // This effect tracks `this.value` (a prop signal) and runs ONLY when the committed value
237
+ // changes — NOT on every keystroke. The user's caret is never disturbed during typing because
238
+ // typing does NOT update `this.value` (only commit does).
239
+ //
240
+ // On commit: we call `editor.textContent = label` FIRST, then `this.value = key`. When the
241
+ // effect fires, editor.textContent already equals `#labelForValue(value)` → caret guard skips.
242
+ // On programmatic `el.value = 'x'`: the effect runs and writes the matching option's label
243
+ // (or `x` itself for free-text / unknown option) into the editor.
244
+ this.effect(() => {
245
+ const v = this.value;
246
+ if (this.#composing)
247
+ return;
248
+ const label = this.#labelForValue(v);
249
+ if (editor.textContent !== label)
250
+ editor.textContent = label;
251
+ editor.toggleAttribute('data-empty', editor.textContent === '');
252
+ });
253
+ // ── Placeholder text + the label seam + disabled channel ─────────────────────────────────
254
+ this.effect(() => {
255
+ editor.setAttribute('data-placeholder', this.placeholder);
256
+ });
257
+ // ADR-0085 — the label seam (bare usage): `label` prop → editor aria-label (text-field ADR-0014
258
+ // parity — the editor's text content IS a distinct accessible value, so aria-label does not erase
259
+ // it, unlike ui-select's button trigger). Yields to the fielded aria-labelledby the moment the
260
+ // combo-box associates with a ui-field (applyFieldLabelling below) — accname precedence, the
261
+ // ADR-0051 pattern (text-field.ts:425 is the identical guard).
262
+ this.effect(() => {
263
+ if (this.label && this.fieldLabelling === null)
264
+ editor.setAttribute('aria-label', this.label);
265
+ else
266
+ editor.removeAttribute('aria-label');
267
+ });
268
+ // TKT-0063: also mirrors text-field.ts's `:state(disabled)` CSS hook (the effective-disabled
269
+ // channel) — the editor's `aria-disabled` attribute is an AX signal, not a CSS-visible one, so
270
+ // combo-box.css's disabled row (:where(ui-combo-box[disabled])) missed the ancestor-fieldset case
271
+ // without this.
272
+ this.effect(() => {
273
+ if (this.effectiveDisabled()) {
274
+ editor.setAttribute('contenteditable', 'false');
275
+ editor.setAttribute('aria-disabled', 'true');
276
+ editor.removeAttribute('tabindex');
277
+ this.internals.states?.add('disabled');
278
+ }
279
+ else {
280
+ editor.setAttribute('contenteditable', 'plaintext-only');
281
+ editor.removeAttribute('aria-disabled');
282
+ // contenteditable is intrinsically focusable; no explicit tabindex needed.
283
+ this.internals.states?.delete('disabled');
284
+ }
285
+ });
286
+ // Edge-aware scroll fade (the gutter-exposure fix, 2026-07-04) — always on, no opt-in prop. The panel
287
+ // is now a bounded scroll viewport (combo-box.css: max-block-size: 40vh + overflow-y: auto, matching
288
+ // ui-select/ui-menu; it used to be overflow: hidden, unbounded and unscrollable).
289
+ scrollFade(this, { viewport: listbox });
290
+ }
291
+ // ── Parts creation (idempotent) ───────────────────────────────────────────────────────────────
292
+ /**
293
+ * Create the editor + listbox parts ONCE, persisting them across disconnect/reconnect.
294
+ *
295
+ * Editor: `<div data-part="editor" contenteditable="plaintext-only" role="combobox">`.
296
+ * Listbox: `<div data-part="listbox" role="listbox" popover="auto" id="...">`.
297
+ * The overlay controller sets popover="auto" on the listbox.
298
+ * Author-provided [role=option] children are moved into the listbox here;
299
+ * each option gets a stable id for aria-activedescendant.
300
+ */
301
+ #ensureParts() {
302
+ if (this.#editor && this.#listbox) {
303
+ return { editor: this.#editor, listbox: this.#listbox };
304
+ }
305
+ // ── Editor part ──────────────────────────────────────────────────────────────────────────
306
+ const editor = document.createElement('div');
307
+ editor.setAttribute('data-part', 'editor');
308
+ editor.setAttribute('contenteditable', 'plaintext-only');
309
+ // ARIA: the editor carries the combobox role (NOT the host, per FACE internals pattern).
310
+ // aria-haspopup="listbox" tells AT the popup type.
311
+ // aria-autocomplete="list" — filter mode (options shown + filtered by input, not completed).
312
+ // aria-expanded and aria-controls are wired below / by the scope-owned effect.
313
+ editor.setAttribute('role', 'combobox');
314
+ editor.setAttribute('aria-haspopup', 'listbox');
315
+ editor.setAttribute('aria-autocomplete', 'list');
316
+ editor.setAttribute('aria-expanded', 'false');
317
+ // Suppress the browser's OWN native text-assist UI (autocorrect/spell suggestion chip, autofill,
318
+ // autocapitalise). Without these the platform renders a second floating suggestion (e.g. macOS's
319
+ // "Banana ×" autocorrect chip) OVER the control's listbox — the "two dropdowns" bug. A combo-box
320
+ // filter picks from a fixed list; native word-completion/spellcheck is never wanted here.
321
+ editor.setAttribute('autocorrect', 'off');
322
+ editor.setAttribute('autocapitalize', 'off');
323
+ editor.setAttribute('autocomplete', 'off');
324
+ editor.setAttribute('spellcheck', 'false');
325
+ editor.toggleAttribute('data-empty', true); // placeholder visible on connect (empty by default)
326
+ this.#editor = editor;
327
+ // ── Listbox panel part ───────────────────────────────────────────────────────────────────
328
+ const panelId = ++_nextPanelId;
329
+ const listbox = document.createElement('div');
330
+ listbox.setAttribute('data-part', 'listbox');
331
+ listbox.setAttribute('data-box', ''); // adopt the shared container box-model (inset margins)
332
+ // role="listbox" on the part (NOT the host; FACE pattern).
333
+ listbox.setAttribute('role', 'listbox');
334
+ // tabindex="-1": the overlay controller's moveFocusIn() can land here if needed; in practice
335
+ // focusOnOpen=false means it never calls moveFocusIn() — but the attribute is harmless.
336
+ listbox.setAttribute('tabindex', '-1');
337
+ listbox.id = `ui-combo-listbox-${panelId}`;
338
+ this.#listbox = listbox;
339
+ // aria-controls on the editor links it to the listbox (the AT association).
340
+ editor.setAttribute('aria-controls', listbox.id);
341
+ // Adopt author-provided children (and other children) into the listbox — the child-move pattern
342
+ // (ADR-0017): the options live in the top-layer panel. #syncOptions (TKT-0026) is the single,
343
+ // reusable adopt pass shared with `connected()`'s first-connect call AND every later light-DOM
344
+ // mutation via `#optionObserver` — at this point `#emptyRow` is still null, so its own
345
+ // `insertBefore(node, null)` degrades to a plain append (the emptyRow-stays-last invariant below
346
+ // only matters once emptyRow exists).
347
+ this.#syncOptions();
348
+ // Assign stable ids to options for aria-activedescendant (done ONCE per connection).
349
+ let optSeq = 0;
350
+ for (const opt of listbox.querySelectorAll('[role=option]')) {
351
+ if (!opt.id)
352
+ opt.id = `ui-cb${panelId}-opt-${++optSeq}`;
353
+ }
354
+ // ── "No matches" row (2026-07-07 fix) — appended LAST so it always renders below any real
355
+ // options. `role="presentation"` (not an option — never navigable, never commit-able; the
356
+ // click/keyboard/active-descendant paths all key off `[role=option]` and skip it for free).
357
+ // Starts hidden; `#syncEmptyState()` reveals it exactly when zero options are visible (see the
358
+ // `#emptyRow` field comment for why this exists — the empty-panel collapse-to-a-line bug).
359
+ const emptyRow = document.createElement('div');
360
+ emptyRow.setAttribute('data-part', 'empty');
361
+ emptyRow.setAttribute('role', 'presentation');
362
+ emptyRow.textContent = 'No matches';
363
+ emptyRow.hidden = true;
364
+ listbox.append(emptyRow);
365
+ this.#emptyRow = emptyRow;
366
+ this.#syncEmptyState();
367
+ this.append(editor, listbox);
368
+ return { editor, listbox };
369
+ }
370
+ // ── Dynamic-options adoption (TKT-0026) ─────────────────────────────────────────────────────
371
+ /**
372
+ * Adopt every light-DOM node CURRENTLY sitting as a DIRECT child of the host — other than the
373
+ * control-created `editor`/`listbox` parts themselves — into the listbox panel, immediately
374
+ * BEFORE `#emptyRow` (so the "no matches" row stays the panel's last child regardless of how much
375
+ * gets adopted over time; `insertBefore(node, null)` degrades to a plain append when `#emptyRow`
376
+ * does not exist yet, i.e. the very first call from inside `#ensureParts()`). Runs once per connect
377
+ * (first-connect AND reconnect — the reconnect call catches anything appended while disconnected,
378
+ * when no observer was running) and again on every subsequent host `childList` mutation via
379
+ * `#optionObserver` (armed in connected()).
380
+ *
381
+ * Idempotent + self-terminating, no re-entrancy guard needed: an already-adopted node lives inside
382
+ * `#listbox`, not as a direct child of `this` — so `this.firstChild`/`nextSibling` traversal never
383
+ * re-visits it (the relocation law: no double-move on reconnect). `editor`/`listbox` are excluded
384
+ * by reference (they DO become direct children of `this` at the end of `#ensureParts()`, and must
385
+ * never be adopted into themselves).
386
+ *
387
+ * A late adoption re-runs the CURRENT filter (`#filterOptions`, which also refreshes the empty-state
388
+ * row) against the whole option set — a freshly-adopted option must obey whatever the user has
389
+ * already typed, not bypass it by arriving after the fact.
390
+ */
391
+ #syncOptions() {
392
+ const listbox = this.#listbox;
393
+ if (!listbox)
394
+ return;
395
+ const editor = this.#editor;
396
+ const emptyRow = this.#emptyRow;
397
+ let adopted = false;
398
+ let node = this.firstChild;
399
+ while (node) {
400
+ const next = node.nextSibling;
401
+ if (node !== editor && node !== listbox) {
402
+ listbox.insertBefore(node, emptyRow);
403
+ adopted = true;
404
+ }
405
+ node = next;
406
+ }
407
+ if (adopted && editor)
408
+ this.#filterOptions(editor.textContent ?? '');
409
+ }
410
+ // ── Active-descendant helpers ──────────────────────────────────────────────────────────────
411
+ /** All [role=option] children in the listbox panel. */
412
+ #getOptions() {
413
+ if (!this.#listbox)
414
+ return [];
415
+ return [...this.#listbox.querySelectorAll('[role=option]')];
416
+ }
417
+ /**
418
+ * The currently-navigable option set: visible (not hidden) AND not disabled.
419
+ * `aria-disabled='true'` and the `disabled` HTML attribute both opt out: such options
420
+ * are skipped by Arrow navigation AND rejected by the click + Enter commit path (M2).
421
+ */
422
+ #getVisibleOptions() {
423
+ return this.#getOptions().filter(o => !o.hidden
424
+ && o.getAttribute('aria-disabled') !== 'true'
425
+ && !o.hasAttribute('disabled'));
426
+ }
427
+ /**
428
+ * Set the active-descendant index. Paints [data-active] on the target option, sets
429
+ * aria-activedescendant on the editor (or removes it at idx=-1), and assigns a stable id
430
+ * to the option if it lacks one (for late-added options).
431
+ *
432
+ * DOM focus NEVER moves — the active option is highlighted only via ARIA + CSS [data-active].
433
+ */
434
+ #setActive(idx) {
435
+ const opts = this.#getVisibleOptions();
436
+ // Clear [data-active] from every visible option.
437
+ for (const opt of opts)
438
+ opt.removeAttribute('data-active');
439
+ if (idx < 0 || idx >= opts.length) {
440
+ this.#activeIdx = -1;
441
+ this.#editor?.removeAttribute('aria-activedescendant');
442
+ return;
443
+ }
444
+ this.#activeIdx = idx;
445
+ const opt = opts[idx];
446
+ opt.setAttribute('data-active', '');
447
+ // Lazily ensure a stable id (handles dynamically-added options not present at connect).
448
+ if (!opt.id)
449
+ opt.id = `ui-combo-opt-${++_nextOptionId}`;
450
+ this.#editor?.setAttribute('aria-activedescendant', opt.id);
451
+ }
452
+ /**
453
+ * Move the active-descendant index by `delta` (+1 = down, -1 = up), wrapping at the ends.
454
+ * When nothing is highlighted, ArrowDown starts at the first option; ArrowUp at the last.
455
+ */
456
+ #moveActive(delta) {
457
+ const opts = this.#getVisibleOptions();
458
+ if (opts.length === 0)
459
+ return;
460
+ let next;
461
+ if (this.#activeIdx < 0) {
462
+ next = delta === 1 ? 0 : opts.length - 1;
463
+ }
464
+ else {
465
+ next = (this.#activeIdx + delta + opts.length) % opts.length;
466
+ }
467
+ this.#setActive(next);
468
+ }
469
+ // ── Filter helpers ────────────────────────────────────────────────────────────────────────
470
+ /**
471
+ * Filter visible options to those whose textContent includes `text` (case-insensitive).
472
+ * Clears active-descendant because the visible set has changed.
473
+ * An empty `text` reveals all options (the initial state when first opened by Arrow).
474
+ */
475
+ #filterOptions(text) {
476
+ const lower = text.toLowerCase();
477
+ for (const opt of this.#getOptions()) {
478
+ opt.hidden = lower !== '' && !(opt.textContent ?? '').toLowerCase().includes(lower);
479
+ }
480
+ // Reset active since visible set changed (any stale index is now meaningless).
481
+ this.#setActive(-1);
482
+ this.#syncEmptyState();
483
+ }
484
+ /** Reveal all options (clear the filter). Called after commit so the next open shows all. */
485
+ #clearFilter() {
486
+ for (const opt of this.#getOptions()) {
487
+ opt.hidden = false;
488
+ }
489
+ this.#syncEmptyState();
490
+ }
491
+ /**
492
+ * Toggle the "no matches" row (2026-07-07 fix) so it is visible exactly when ZERO options are
493
+ * visible (either the filter matched nothing, or the author provided no options at all) — the
494
+ * root-cause fix for the empty-panel collapsing to a stray border-only line: an always-present,
495
+ * real content row gives the panel genuine height instead of nothing. Deliberately keys off
496
+ * "not hidden" rather than `#getVisibleOptions()` (which ALSO excludes disabled options) — a
497
+ * disabled-but-matching option should still suppress the "no matches" row; only the FILTER
498
+ * (hidden) outcome should.
499
+ */
500
+ #syncEmptyState() {
501
+ if (!this.#emptyRow)
502
+ return;
503
+ const anyVisible = this.#getOptions().some((opt) => !opt.hidden);
504
+ this.#emptyRow.hidden = anyVisible;
505
+ }
506
+ // ── Value / label helpers ─────────────────────────────────────────────────────────────────
507
+ /**
508
+ * The display label for the given committed value `v`.
509
+ * Looks for a [role=option] child whose `value` attribute equals `v`; returns its textContent.
510
+ * Falls back to `v` itself (free-text commit, or option not found — programmatic set before
511
+ * options are added). Returns '' when `v` is ''.
512
+ */
513
+ #labelForValue(v) {
514
+ if (v === '')
515
+ return '';
516
+ if (this.#listbox) {
517
+ for (const opt of this.#listbox.querySelectorAll('[role=option]')) {
518
+ if ((opt.getAttribute('value') ?? '') === v)
519
+ return opt.textContent ?? v;
520
+ }
521
+ }
522
+ return v; // free-text or no matching option
523
+ }
524
+ // ── Commit ────────────────────────────────────────────────────────────────────────────────
525
+ /**
526
+ * Commit the selected option `opt`.
527
+ * Sets editor text first (so the caret-guard in the model→surface effect sees a match → no-op),
528
+ * then updates `this.value`, closes the panel, clears the active-descendant + filter, and emits
529
+ * `select` (option-specific) then `change` (form-value change).
530
+ *
531
+ * aria-selected sweep (M1): marks the committed option as `aria-selected="true"` and all others
532
+ * as `aria-selected="false"`. This satisfies the WAI-ARIA combobox pattern requirement (the AT
533
+ * identifies the selected option when the listbox reopens) and activates the CSS selected-highlight
534
+ * rule (`[role='option'][aria-selected='true']`).
535
+ */
536
+ #commitOption(editor, opt) {
537
+ const key = opt.getAttribute('value') ?? opt.textContent ?? '';
538
+ const label = opt.textContent ?? key;
539
+ // Sweep aria-selected across ALL options (not just visible — the full set must be consistent).
540
+ for (const o of this.#getOptions()) {
541
+ o.setAttribute('aria-selected', o === opt ? 'true' : 'false');
542
+ }
543
+ // Write the label first so the caret guard in the model→surface effect is a no-op.
544
+ editor.textContent = label;
545
+ this.value = key;
546
+ this.open = false;
547
+ this.#setActive(-1);
548
+ this.#clearFilter();
549
+ editor.toggleAttribute('data-empty', label === '');
550
+ this.emit('select', key);
551
+ this.emit('change');
552
+ }
553
+ /**
554
+ * Commit the typed free text (strict=false, no active option on Enter).
555
+ * The text is already in editor.textContent (the user typed it), so no editor write is needed.
556
+ */
557
+ #commitFreeText(editor, text) {
558
+ this.value = text;
559
+ this.open = false;
560
+ this.#setActive(-1);
561
+ this.#clearFilter();
562
+ editor.toggleAttribute('data-empty', text === '');
563
+ this.emit('change');
564
+ }
565
+ // ── Form hooks ────────────────────────────────────────────────────────────────────────────
566
+ /**
567
+ * The value this combo-box contributes to its form.
568
+ * '' (nothing committed) → null (no form entry submitted, matching the native <select> convention).
569
+ */
570
+ formValue() {
571
+ return this.value === '' ? null : this.value;
572
+ }
573
+ /**
574
+ * The validity verdict:
575
+ * - disabled → always valid (native parity: disabled controls bypass constraint validation).
576
+ * - required && value==='' → valueMissing.
577
+ * - strict && value!=='' && no matching option → typeMismatch (the typed value is not in the set).
578
+ */
579
+ formValidity() {
580
+ if (this.effectiveDisabled())
581
+ return { valid: true };
582
+ if (this.required && this.value === '') {
583
+ return {
584
+ valid: false,
585
+ flags: { valueMissing: true },
586
+ message: 'Please select an option.',
587
+ anchor: this.#editor ?? undefined,
588
+ };
589
+ }
590
+ if (this.strict && this.value !== '') {
591
+ const opts = this.#getOptions();
592
+ const hasMatch = opts.some(o => (o.getAttribute('value') ?? '') === this.value);
593
+ if (!hasMatch) {
594
+ return {
595
+ valid: false,
596
+ flags: { typeMismatch: true },
597
+ message: 'Please select a valid option.',
598
+ anchor: this.#editor ?? undefined,
599
+ };
600
+ }
601
+ }
602
+ return { valid: true };
603
+ }
604
+ /** Form reset → restore value ← defaultValue, clear the editor, reset filter + active. */
605
+ formReset() {
606
+ this.#clearFilter();
607
+ const v = this.#defaultValue;
608
+ const label = this.#labelForValue(v);
609
+ // Write editor first (caret guard will see the match when the effect fires → no-op).
610
+ if (this.#editor) {
611
+ this.#editor.textContent = label;
612
+ this.#editor.toggleAttribute('data-empty', label === '');
613
+ }
614
+ this.value = v;
615
+ this.open = false;
616
+ this.#setActive(-1);
617
+ // ADR-0051 — a reset must not leave a required-empty combo-box showing :state(user-invalid)
618
+ // until the user re-interacts (the text-field formReset() precedent).
619
+ this.#userInvalid?.reset();
620
+ }
621
+ disconnected() {
622
+ this.#userInvalid?.release(); // idempotent — the listeners already die with the connection scope
623
+ this.#userInvalid = null;
624
+ this.#optionObserver?.disconnect(); // TKT-0026 — the observer does not outlive the connection
625
+ this.#optionObserver = null;
626
+ }
627
+ /** Feeds `FormConnectDetail.userInvalid` (ADR-0050) — the `trackUserInvalid` tracker IS the one
628
+ * timing source; this override just exposes its gate. */
629
+ formUserInvalid() {
630
+ return this.#userInvalid?.userInvalid() ?? false;
631
+ }
632
+ /** Restore value after navigation / autofill. */
633
+ formStateRestore(state) {
634
+ if (typeof state === 'string') {
635
+ this.value = state;
636
+ // model→surface effect will fire on the next flush and update the editor.
637
+ }
638
+ }
639
+ // ── ADR-0085 — the field-labelling seam wire (text-field parity) ────────────────────────────
640
+ /**
641
+ * The part-role override (ADR-0085 cl.3 · text-field.ts:662 precedent) — the combobox role rides the
642
+ * light-DOM editor PART, not `internals.role`, so the base's guarded internals-reflection default
643
+ * (dom/form.ts) never fires; id-reference the editor directly instead.
644
+ *
645
+ * `aria-labelledby` is this method's own, exclusive concern in both directions: set from `refs.label`
646
+ * when present, removed when `null` (matching text-field exactly).
647
+ *
648
+ * `aria-describedby` is ALSO this method's exclusive concern in both directions here — UNLIKE
649
+ * text-field, this control has no internal validity-MESSAGE node/effect competing for the
650
+ * attribute (the ADR-0051 user-invalid leg below writes only `:state(user-invalid)` +
651
+ * `aria-invalid`, never `aria-describedby`), so there is no dual-writer race to avoid by leaving
652
+ * it untouched on dissociation; it is written from `[refs.description, refs.error]` when fielded
653
+ * and cleared when not, in both branches below.
654
+ *
655
+ * Guards a not-yet-created editor (the LLD-C2 override contract) — cannot happen in practice
656
+ * (`#ensureParts()` runs synchronously at the top of `connected()`, before the base's forwarding effect
657
+ * installs), but the guard costs nothing and documents the contract.
658
+ */
659
+ applyFieldLabelling(refs) {
660
+ const editor = this.#editor;
661
+ if (!editor)
662
+ return;
663
+ if (refs === null) {
664
+ editor.removeAttribute('aria-labelledby');
665
+ editor.removeAttribute('aria-describedby');
666
+ return;
667
+ }
668
+ if (refs.label)
669
+ editor.setAttribute('aria-labelledby', refs.label.id);
670
+ else
671
+ editor.removeAttribute('aria-labelledby');
672
+ const described = [refs.description, refs.error].filter((el) => el !== null);
673
+ if (described.length > 0)
674
+ editor.setAttribute('aria-describedby', described.map((el) => el.id).join(' '));
675
+ else
676
+ editor.removeAttribute('aria-describedby');
677
+ }
678
+ }
679
+ if (!customElements.get('ui-combo-box'))
680
+ customElements.define('ui-combo-box', UIComboBoxElement);