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