@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.
- package/dist/base-styles.css +8 -0
- package/dist/component-styles.css +147 -0
- package/dist/controls/_base/index.d.ts +3 -0
- package/dist/controls/_base/index.js +9 -0
- package/dist/controls/_base/indicator-element.d.ts +132 -0
- package/dist/controls/_base/indicator-element.js +158 -0
- package/dist/controls/_base/listbox-element.d.ts +70 -0
- package/dist/controls/_base/listbox-element.js +74 -0
- package/dist/controls/_base/range-element.d.ts +120 -0
- package/dist/controls/_base/range-element.js +158 -0
- package/dist/controls/_surface/container-box.css +301 -0
- package/dist/controls/_surface/container.css +156 -0
- package/dist/controls/_token-surface/token-surface.d.ts +35 -0
- package/dist/controls/_token-surface/token-surface.js +97 -0
- package/dist/controls/attachment/attachment-meta.d.ts +8 -0
- package/dist/controls/attachment/attachment-meta.js +84 -0
- package/dist/controls/attachment/attachment.css +91 -0
- package/dist/controls/attachment/attachment.d.ts +40 -0
- package/dist/controls/attachment/attachment.js +70 -0
- package/dist/controls/avatar/avatar-initials.d.ts +4 -0
- package/dist/controls/avatar/avatar-initials.js +30 -0
- package/dist/controls/avatar/avatar.css +87 -0
- package/dist/controls/avatar/avatar.d.ts +40 -0
- package/dist/controls/avatar/avatar.js +77 -0
- package/dist/controls/badge/badge.css +175 -0
- package/dist/controls/badge/badge.d.ts +36 -0
- package/dist/controls/badge/badge.js +71 -0
- package/dist/controls/bar-chart/bar-chart.css +110 -0
- package/dist/controls/bar-chart/bar-chart.d.ts +26 -0
- package/dist/controls/bar-chart/bar-chart.js +80 -0
- package/dist/controls/bar-chart/bar-math.d.ts +31 -0
- package/dist/controls/bar-chart/bar-math.js +106 -0
- package/dist/controls/button/button.css +263 -0
- package/dist/controls/button/button.d.ts +61 -0
- package/dist/controls/button/button.js +115 -0
- package/dist/controls/calendar/calendar.css +495 -0
- package/dist/controls/calendar/calendar.d.ts +161 -0
- package/dist/controls/calendar/calendar.js +1004 -0
- package/dist/controls/card/card-content.d.ts +24 -0
- package/dist/controls/card/card-content.js +133 -0
- package/dist/controls/card/card-footer.d.ts +3 -0
- package/dist/controls/card/card-footer.js +16 -0
- package/dist/controls/card/card-header.d.ts +3 -0
- package/dist/controls/card/card-header.js +18 -0
- package/dist/controls/card/card.css +445 -0
- package/dist/controls/card/card.d.ts +51 -0
- package/dist/controls/card/card.js +61 -0
- package/dist/controls/checkbox/checkbox.css +204 -0
- package/dist/controls/checkbox/checkbox.d.ts +114 -0
- package/dist/controls/checkbox/checkbox.js +130 -0
- package/dist/controls/code/code.css +72 -0
- package/dist/controls/code/code.d.ts +23 -0
- package/dist/controls/code/code.js +40 -0
- package/dist/controls/color-picker/color-picker.css +209 -0
- package/dist/controls/color-picker/color-picker.d.ts +84 -0
- package/dist/controls/color-picker/color-picker.js +545 -0
- package/dist/controls/color-picker/color.d.ts +40 -0
- package/dist/controls/color-picker/color.js +159 -0
- package/dist/controls/column/column.css +151 -0
- package/dist/controls/column/column.d.ts +107 -0
- package/dist/controls/column/column.js +57 -0
- package/dist/controls/combo-box/combo-box.css +397 -0
- package/dist/controls/combo-box/combo-box.d.ts +133 -0
- package/dist/controls/combo-box/combo-box.js +680 -0
- package/dist/controls/command-modal/command-modal.css +226 -0
- package/dist/controls/command-modal/command-modal.d.ts +42 -0
- package/dist/controls/command-modal/command-modal.js +315 -0
- package/dist/controls/disclosure/disclosure.css +162 -0
- package/dist/controls/disclosure/disclosure.d.ts +37 -0
- package/dist/controls/disclosure/disclosure.js +207 -0
- package/dist/controls/field/field.css +140 -0
- package/dist/controls/field/field.d.ts +27 -0
- package/dist/controls/field/field.js +228 -0
- package/dist/controls/form-provider/form-provider.css +40 -0
- package/dist/controls/form-provider/form-provider.d.ts +42 -0
- package/dist/controls/form-provider/form-provider.js +105 -0
- package/dist/controls/grid/grid.css +72 -0
- package/dist/controls/grid/grid.d.ts +60 -0
- package/dist/controls/grid/grid.js +56 -0
- package/dist/controls/icon/icon.css +37 -0
- package/dist/controls/icon/icon.d.ts +25 -0
- package/dist/controls/icon/icon.js +55 -0
- package/dist/controls/index.d.ts +57 -0
- package/dist/controls/index.js +136 -0
- package/dist/controls/ladder/ladder.css +89 -0
- package/dist/controls/ladder/ladder.d.ts +27 -0
- package/dist/controls/ladder/ladder.js +78 -0
- package/dist/controls/list/list.css +118 -0
- package/dist/controls/list/list.d.ts +84 -0
- package/dist/controls/list/list.js +48 -0
- package/dist/controls/menu/menu.css +195 -0
- package/dist/controls/menu/menu.d.ts +44 -0
- package/dist/controls/menu/menu.js +248 -0
- package/dist/controls/modal/modal.css +99 -0
- package/dist/controls/modal/modal.d.ts +61 -0
- package/dist/controls/modal/modal.js +155 -0
- package/dist/controls/popover/popover.css +83 -0
- package/dist/controls/popover/popover.d.ts +44 -0
- package/dist/controls/popover/popover.js +164 -0
- package/dist/controls/progress/progress.css +106 -0
- package/dist/controls/progress/progress.d.ts +28 -0
- package/dist/controls/progress/progress.js +80 -0
- package/dist/controls/radio/radio-group.css +84 -0
- package/dist/controls/radio/radio-group.d.ts +152 -0
- package/dist/controls/radio/radio-group.js +350 -0
- package/dist/controls/radio/radio.css +219 -0
- package/dist/controls/radio/radio.d.ts +19 -0
- package/dist/controls/radio/radio.js +75 -0
- package/dist/controls/ramp/ramp.css +75 -0
- package/dist/controls/ramp/ramp.d.ts +29 -0
- package/dist/controls/ramp/ramp.js +72 -0
- package/dist/controls/row/row.css +152 -0
- package/dist/controls/row/row.d.ts +95 -0
- package/dist/controls/row/row.js +45 -0
- package/dist/controls/segment/segment.css +66 -0
- package/dist/controls/segment/segment.d.ts +3 -0
- package/dist/controls/segment/segment.js +26 -0
- package/dist/controls/segmented-control/segmented-control.css +189 -0
- package/dist/controls/segmented-control/segmented-control.d.ts +20 -0
- package/dist/controls/segmented-control/segmented-control.js +64 -0
- package/dist/controls/select/select.css +467 -0
- package/dist/controls/select/select.d.ts +147 -0
- package/dist/controls/select/select.js +538 -0
- package/dist/controls/slider/slider.css +231 -0
- package/dist/controls/slider/slider.d.ts +111 -0
- package/dist/controls/slider/slider.js +60 -0
- package/dist/controls/slider-multi/slider-multi.css +197 -0
- package/dist/controls/slider-multi/slider-multi.d.ts +149 -0
- package/dist/controls/slider-multi/slider-multi.js +380 -0
- package/dist/controls/sparkline/sparkline-math.d.ts +23 -0
- package/dist/controls/sparkline/sparkline-math.js +79 -0
- package/dist/controls/sparkline/sparkline.css +61 -0
- package/dist/controls/sparkline/sparkline.d.ts +27 -0
- package/dist/controls/sparkline/sparkline.js +74 -0
- package/dist/controls/split/constrain.d.ts +44 -0
- package/dist/controls/split/constrain.js +126 -0
- package/dist/controls/split/split-pane.css +63 -0
- package/dist/controls/split/split-pane.d.ts +49 -0
- package/dist/controls/split/split-pane.js +42 -0
- package/dist/controls/split/split.css +127 -0
- package/dist/controls/split/split.d.ts +42 -0
- package/dist/controls/split/split.js +364 -0
- package/dist/controls/stat/stat-model.d.ts +22 -0
- package/dist/controls/stat/stat-model.js +69 -0
- package/dist/controls/stat/stat.css +101 -0
- package/dist/controls/stat/stat.d.ts +30 -0
- package/dist/controls/stat/stat.js +73 -0
- package/dist/controls/status-stream/status-stream.css +151 -0
- package/dist/controls/status-stream/status-stream.d.ts +108 -0
- package/dist/controls/status-stream/status-stream.js +417 -0
- package/dist/controls/swatch/swatch.css +66 -0
- package/dist/controls/swatch/swatch.d.ts +28 -0
- package/dist/controls/swatch/swatch.js +74 -0
- package/dist/controls/swiper/swiper-item.css +17 -0
- package/dist/controls/swiper/swiper-item.d.ts +31 -0
- package/dist/controls/swiper/swiper-item.js +47 -0
- package/dist/controls/swiper/swiper-label.css +10 -0
- package/dist/controls/swiper/swiper-label.d.ts +4 -0
- package/dist/controls/swiper/swiper-label.js +20 -0
- package/dist/controls/swiper/swiper-paddles.css +26 -0
- package/dist/controls/swiper/swiper-paddles.d.ts +10 -0
- package/dist/controls/swiper/swiper-paddles.js +62 -0
- package/dist/controls/swiper/swiper-pagination.css +60 -0
- package/dist/controls/swiper/swiper-pagination.d.ts +31 -0
- package/dist/controls/swiper/swiper-pagination.js +78 -0
- package/dist/controls/swiper/swiper.css +153 -0
- package/dist/controls/swiper/swiper.d.ts +158 -0
- package/dist/controls/swiper/swiper.js +659 -0
- package/dist/controls/switch/switch.css +194 -0
- package/dist/controls/switch/switch.d.ts +5 -0
- package/dist/controls/switch/switch.js +18 -0
- package/dist/controls/table/table-model.d.ts +43 -0
- package/dist/controls/table/table-model.js +124 -0
- package/dist/controls/table/table.css +90 -0
- package/dist/controls/table/table.d.ts +29 -0
- package/dist/controls/table/table.js +178 -0
- package/dist/controls/tabs/tab-panel.d.ts +10 -0
- package/dist/controls/tabs/tab-panel.js +89 -0
- package/dist/controls/tabs/tab.d.ts +26 -0
- package/dist/controls/tabs/tab.js +67 -0
- package/dist/controls/tabs/tabs.css +181 -0
- package/dist/controls/tabs/tabs.d.ts +61 -0
- package/dist/controls/tabs/tabs.js +176 -0
- package/dist/controls/text/href.d.ts +26 -0
- package/dist/controls/text/href.js +48 -0
- package/dist/controls/text/text.css +328 -0
- package/dist/controls/text/text.d.ts +85 -0
- package/dist/controls/text/text.js +273 -0
- package/dist/controls/text-field/text-field.css +498 -0
- package/dist/controls/text-field/text-field.d.ts +226 -0
- package/dist/controls/text-field/text-field.js +905 -0
- package/dist/controls/textarea/textarea.css +251 -0
- package/dist/controls/textarea/textarea.d.ts +136 -0
- package/dist/controls/textarea/textarea.js +312 -0
- package/dist/controls/theme-provider/theme-provider.css +48 -0
- package/dist/controls/theme-provider/theme-provider.d.ts +59 -0
- package/dist/controls/theme-provider/theme-provider.js +40 -0
- package/dist/controls/timeline/timeline.css +33 -0
- package/dist/controls/timeline/timeline.d.ts +39 -0
- package/dist/controls/timeline/timeline.js +52 -0
- package/dist/controls/timeline-item/timeline-item.css +495 -0
- package/dist/controls/timeline-item/timeline-item.d.ts +79 -0
- package/dist/controls/timeline-item/timeline-item.js +360 -0
- package/dist/controls/toast/toast-region.css +56 -0
- package/dist/controls/toast/toast-region.d.ts +20 -0
- package/dist/controls/toast/toast-region.js +113 -0
- package/dist/controls/toast/toast.css +96 -0
- package/dist/controls/toast/toast.d.ts +28 -0
- package/dist/controls/toast/toast.js +191 -0
- package/dist/controls/toolbar/toolbar.css +125 -0
- package/dist/controls/toolbar/toolbar.d.ts +108 -0
- package/dist/controls/toolbar/toolbar.js +68 -0
- package/dist/controls/tooltip/tooltip.css +80 -0
- package/dist/controls/tooltip/tooltip.d.ts +57 -0
- package/dist/controls/tooltip/tooltip.js +205 -0
- package/dist/descriptor/component-descriptor.d.ts +112 -0
- package/dist/descriptor/component-descriptor.js +492 -0
- package/dist/descriptor/index.d.ts +1 -0
- package/dist/descriptor/index.js +5 -0
- package/dist/dom/container.d.ts +45 -0
- package/dist/dom/container.js +60 -0
- package/dist/dom/element.d.ts +98 -0
- package/dist/dom/element.js +221 -0
- package/dist/dom/form.d.ts +240 -0
- package/dist/dom/form.js +399 -0
- package/dist/dom/index.d.ts +10 -0
- package/dist/dom/index.js +31 -0
- package/dist/dom/props.d.ts +56 -0
- package/dist/dom/props.js +231 -0
- package/dist/dom/repeat.d.ts +10 -0
- package/dist/dom/repeat.js +202 -0
- package/dist/dom/template.d.ts +161 -0
- package/dist/dom/template.js +634 -0
- package/dist/dom/watch.d.ts +11 -0
- package/dist/dom/watch.js +82 -0
- package/dist/foundation-styles.css +9 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/reactive/graph.d.ts +65 -0
- package/dist/reactive/graph.js +310 -0
- package/dist/reactive/index.d.ts +3 -0
- package/dist/reactive/index.js +4 -0
- package/dist/reactive/scheduler.d.ts +13 -0
- package/dist/reactive/scheduler.js +49 -0
- package/dist/traits/area-drag.d.ts +12 -0
- package/dist/traits/area-drag.js +70 -0
- package/dist/traits/form-registry.d.ts +36 -0
- package/dist/traits/form-registry.js +108 -0
- package/dist/traits/index.d.ts +24 -0
- package/dist/traits/index.js +19 -0
- package/dist/traits/overlay.d.ts +50 -0
- package/dist/traits/overlay.js +277 -0
- package/dist/traits/pane-resize.d.ts +26 -0
- package/dist/traits/pane-resize.js +103 -0
- package/dist/traits/press-activation.d.ts +11 -0
- package/dist/traits/press-activation.js +55 -0
- package/dist/traits/roving-focus.d.ts +52 -0
- package/dist/traits/roving-focus.js +214 -0
- package/dist/traits/scroll-fade.d.ts +28 -0
- package/dist/traits/scroll-fade.js +120 -0
- package/dist/traits/selection-commit.d.ts +30 -0
- package/dist/traits/selection-commit.js +146 -0
- package/dist/traits/tabbable.d.ts +12 -0
- package/dist/traits/tabbable.js +59 -0
- package/dist/traits/track-user-invalid.d.ts +36 -0
- package/dist/traits/track-user-invalid.js +52 -0
- package/dist/traits/value-codec.d.ts +120 -0
- package/dist/traits/value-codec.js +497 -0
- package/dist/traits/value-drag.d.ts +18 -0
- package/dist/traits/value-drag.js +84 -0
- 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);
|