@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,905 @@
|
|
|
1
|
+
// text-field.ts — UITextFieldElement, the first FACE form-associated control (goals.md §G6 / ADR-0014).
|
|
2
|
+
// BEHAVIOUR + props + the contenteditable editor part + self-define ONLY; geometry/colour live in
|
|
3
|
+
// text-field.css (s6), the public contract in text-field.md (s7).
|
|
4
|
+
//
|
|
5
|
+
// Editable surface — a stable contenteditable editor PART (ADR-0014 cl.1): a control-created light-DOM
|
|
6
|
+
// `<div data-part="editor" contenteditable="plaintext-only" role="textbox" aria-multiline="false">`, appended
|
|
7
|
+
// ONCE (an idempotent guard) and NEVER re-rendered — `render()` stays the inherited void, so the text-field
|
|
8
|
+
// has no `html\`\``/G3 dependency (re-committing a contenteditable subtree under the caret would destroy the
|
|
9
|
+
// selection on every keystroke). `value`↔surface is two scope-owned wires: surface→model (an editor `input`
|
|
10
|
+
// listener → `this.value` + a host `input`) and model→surface (an effect writing `editor.textContent` ONLY
|
|
11
|
+
// when the model diverges — the CARET GUARD: equal ⇒ skip), both suppressed during IME composition.
|
|
12
|
+
//
|
|
13
|
+
// The HOST carries no `role`/`aria-*` attribute — form semantics ride `internals` (the FACE pattern, ADR-0013)
|
|
14
|
+
// and the editor PART carries `role=textbox`. ARIA + the custom states are set THROUGH the protected
|
|
15
|
+
// `internals` (a trait cannot reach it). Disabled rides the editor + the platform form-disabled channel
|
|
16
|
+
// (`effectiveDisabled = own || form`, ADR-0014 dev#b), NOT host `ariaDisabled`; `readonly` is editable=false
|
|
17
|
+
// but focusable (still submits). `user-invalid` timing comes from the `trackUserInvalid` controller (the
|
|
18
|
+
// danger treatment surfaces only after the first blur/change), reinforced by a non-colour `aria-describedby`
|
|
19
|
+
// message node (WCAG 1.4.1, ADR-0014 cl.4). `controls → dom + traits` is the allowed import direction.
|
|
20
|
+
//
|
|
21
|
+
// Wave 3 growth (ADR-0044): a `type` prop (8 variants, reflected) drives a static type-resolver (TYPE_CONFIG,
|
|
22
|
+
// an as-const map — erasableSyntaxOnly: no enum) that maps type → inputmode + auto-adornments + codec +
|
|
23
|
+
// validation. type='text' resolves to the identity config — no adornments, no codec, no extra validation —
|
|
24
|
+
// preserving byte-identical behaviour with the shipped text-field. Number/currency types use the valueCodec
|
|
25
|
+
// trait (traits/value-codec.ts) for the display↔canonical split (formValue() returns canonical, not display).
|
|
26
|
+
//
|
|
27
|
+
// Wave 5A growth (ADR-0047): TYPE_CONFIG v2 — splits the single `trailing` role into
|
|
28
|
+
// `{ leading, suffix, affordance, codec }`. NEW types `unit` and `percent`. Multi-currency fraction digits,
|
|
29
|
+
// generalized steppers with `step`/`min`/`max`, suffix spans, ArrowUp/Down stepping, and range validity
|
|
30
|
+
// (rangeUnderflow/rangeOverflow). type='text' stays byte-identical.
|
|
31
|
+
//
|
|
32
|
+
// Wave 5B growth (ADR-0048): date/time types. `type=date` — display↔ISO-canonical split via dateCodecOptions()
|
|
33
|
+
// + a trailing calendar-button affordance that opens a lazily-imported `<ui-calendar>` in an overlay popup.
|
|
34
|
+
// `type=time` — display↔HH:MM-canonical split via timeCodecOptions(). Both use `typeMismatch` (not
|
|
35
|
+
// `customError`) for parse failures. The dynamic `import('../calendar/calendar.ts')` on first open keeps
|
|
36
|
+
// the calendar out of the text-field's STATIC import graph (tree-shake safe — the static regex crawl
|
|
37
|
+
// does not match `import()` expressions). date and time have codec but NO steppers (ADR-0047 "codec ⇒ steppers"
|
|
38
|
+
// applies to numeric types only). datetime-local/month = deferred (STRETCH, not this wave).
|
|
39
|
+
//
|
|
40
|
+
// G7 growth (ADR-0051 — the reference part-role override of the base's field-labelling seam): the editor
|
|
41
|
+
// carries `role=textbox` on a PART, not `internals.role`, so the base's guarded internals-reflection default
|
|
42
|
+
// (dom/form.ts) never fires here — this override id-references the editor's `aria-labelledby`/
|
|
43
|
+
// `aria-describedby` directly against the `ui-field` part elements it receives. Under association the
|
|
44
|
+
// internal message node (ADR-0014 cl.4/ADR-0029 A1) YIELDS — empty, hidden, dropped from
|
|
45
|
+
// `aria-describedby` — so the field's error node is the ONE AT-announced error. `label` prop → `aria-label`
|
|
46
|
+
// stays the bare-usage stand-in, yielding to `aria-labelledby` (accname precedence) the moment a field
|
|
47
|
+
// associates.
|
|
48
|
+
import { prop } from "../../dom/index.js";
|
|
49
|
+
import { untracked } from "../../reactive/index.js";
|
|
50
|
+
import { UIFormElement } from "../../dom/form.js";
|
|
51
|
+
import { trackUserInvalid } from "../../traits/track-user-invalid.js";
|
|
52
|
+
import { valueCodec, numberCodecOptions, currencyCodecOptions, unitCodecOptions, dateCodecOptions, timeCodecOptions, currencySymbol, unitLabel, } from "../../traits/value-codec.js";
|
|
53
|
+
import { overlay } from "../../traits/overlay.js";
|
|
54
|
+
// ADR-0123 LLD-C9 — the color codec (pure, zero-DOM; color.ts is import-free) reused verbatim by the
|
|
55
|
+
// type=color leg. NOT the color-picker.ts CONTROL itself (that stays a LAZY dynamic import, below, so a
|
|
56
|
+
// type=text field ships no picker bytes — the tree-shake proof, SPEC-R11 AC1). ui-swatch IS imported
|
|
57
|
+
// statically (a tiny, zero-dep Display-class leaf, not the picker's pad/canvas/codec machinery) so the
|
|
58
|
+
// trailing swatch-button preview renders immediately, before the lazy picker has ever loaded.
|
|
59
|
+
import { colorCodecOptions } from "../color-picker/color.js";
|
|
60
|
+
import { UISwatchElement } from "../swatch/swatch.js";
|
|
61
|
+
import { setIcon } from '@agent-ui-kit/icons';
|
|
62
|
+
// The editor's editable mode (ADR-0014 cl.1) and a per-instance id seed for aria-describedby.
|
|
63
|
+
const EDITABLE = 'plaintext-only';
|
|
64
|
+
let messageSeq = 0;
|
|
65
|
+
// ── email / URL validation patterns ──────────────────────────────────────────
|
|
66
|
+
// A practical email pattern (RFC 5321 simple form): local@domain.tld — no embedded whitespace or @.
|
|
67
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
68
|
+
const TYPE_CONFIG = {
|
|
69
|
+
// inputmode leading suffix affordance validation codec
|
|
70
|
+
text: { inputmode: 'text', leading: null, suffix: null, affordance: null, validation: null, codec: null },
|
|
71
|
+
email: { inputmode: 'email', leading: null, suffix: null, affordance: null, validation: 'email', codec: null },
|
|
72
|
+
url: { inputmode: 'url', leading: null, suffix: null, affordance: null, validation: 'url', codec: null },
|
|
73
|
+
tel: { inputmode: 'tel', leading: null, suffix: null, affordance: null, validation: null, codec: null },
|
|
74
|
+
search: { inputmode: 'search', leading: 'magnifier', suffix: null, affordance: 'clear', validation: null, codec: null },
|
|
75
|
+
password: { inputmode: 'text', leading: null, suffix: null, affordance: 'reveal', validation: null, codec: null },
|
|
76
|
+
number: { inputmode: 'numeric', leading: null, suffix: null, affordance: null, validation: 'number', codec: 'number' },
|
|
77
|
+
currency: { inputmode: 'decimal', leading: 'currency', suffix: null, affordance: null, validation: 'number', codec: 'currency' },
|
|
78
|
+
unit: { inputmode: 'decimal', leading: null, suffix: 'unit', affordance: null, validation: 'number', codec: 'unit' },
|
|
79
|
+
percent: { inputmode: 'decimal', leading: null, suffix: 'percent', affordance: null, validation: 'number', codec: 'number' },
|
|
80
|
+
date: { inputmode: 'text', leading: null, suffix: null, affordance: 'calendar', validation: 'date', codec: 'date' },
|
|
81
|
+
time: { inputmode: 'text', leading: null, suffix: null, affordance: null, validation: 'time', codec: 'time' },
|
|
82
|
+
color: { inputmode: 'text', leading: null, suffix: null, affordance: 'swatch', validation: 'color', codec: 'color' },
|
|
83
|
+
};
|
|
84
|
+
// ── props ─────────────────────────────────────────────────────────────────────
|
|
85
|
+
const props = {
|
|
86
|
+
// The universal form attributes (name / disabled(reflect) / required(reflect)) are SPREAD, not inherited —
|
|
87
|
+
// props.ts has no static-props prototype merge, so UIFormElement exposes them as a spreadable bag (ADR-0013).
|
|
88
|
+
...UIFormElement.formProps,
|
|
89
|
+
// `value` is OBSERVED (its initial attribute seeds the reset baseline) but NOT reflected — the live value
|
|
90
|
+
// rides the editor surface, never a host attribute.
|
|
91
|
+
value: prop.string(),
|
|
92
|
+
label: { ...prop.string(), reflect: true }, // → the editor's aria-label (the labelling SEAM; the visible wrapper is ui-field at G7)
|
|
93
|
+
placeholder: prop.string(), // shown via [data-empty]::before { content: attr(data-placeholder) } when empty
|
|
94
|
+
// size/readonly reflect so the [size] dimensional-ramp repoint + the [readonly]/[disabled] CSS hooks apply
|
|
95
|
+
// to JS-set values too, not only author-set attributes.
|
|
96
|
+
size: { ...prop.enum(['sm', 'md', 'lg'], 'md'), reflect: true },
|
|
97
|
+
// type reflects so [type] CSS selectors (e.g. [type=password] for masking) and the type-resolver apply
|
|
98
|
+
// to JS-set values; 'text' is the identity config (byte-identical to the pre-Wave-3 shipped control).
|
|
99
|
+
type: { ...prop.enum(['text', 'email', 'url', 'tel', 'password', 'search', 'number', 'currency', 'unit', 'percent', 'date', 'time', 'color'], 'text'), reflect: true },
|
|
100
|
+
readonly: { ...prop.boolean(false), reflect: true },
|
|
101
|
+
// Wave 5A — the five new numeric-type props (ADR-0047). All reflected for native attribute-IDL parity.
|
|
102
|
+
// Reading this.currency / this.unit inside the type-effect's currency/unit branch makes the effect reactive
|
|
103
|
+
// ONLY for currency/unit types — a plain field never reads them, never re-runs (the kernel's tracking law).
|
|
104
|
+
currency: { ...prop.string('USD'), reflect: true }, // ISO 4217; drives leading symbol + codec fraction digits
|
|
105
|
+
unit: { ...prop.string(''), reflect: true }, // CLDR unit id → localized short label (type=unit)
|
|
106
|
+
step: { ...prop.number(1), reflect: true }, // stepper/Arrow increment; null (unset attr) → 1
|
|
107
|
+
min: { ...prop.string(''), reflect: true }, // '' = unconstrained; numeric → rangeUnderflow guard
|
|
108
|
+
max: { ...prop.string(''), reflect: true }, // '' = unconstrained; numeric → rangeOverflow guard
|
|
109
|
+
// ADR-0123 LLD-C9 — the type=color leg's serialization syntax: mirrors ui-color-picker's own `format`
|
|
110
|
+
// prop exactly (hex default, oklch opt-in). Reading this.format inside the color branch makes the effect
|
|
111
|
+
// reactive ONLY for type=color (the currency/unit precedent).
|
|
112
|
+
format: { ...prop.enum(['hex', 'oklch'], 'hex'), reflect: true },
|
|
113
|
+
};
|
|
114
|
+
export class UITextFieldElement extends UIFormElement {
|
|
115
|
+
static props = props;
|
|
116
|
+
// The stable contenteditable editor PART + the aria-describedby message node. Light-DOM children, so they
|
|
117
|
+
// persist across disconnect/reconnect — created ONCE and never re-appended (the idempotent guard below).
|
|
118
|
+
#editor = null;
|
|
119
|
+
#message = null;
|
|
120
|
+
// The native-parity reset baseline — seeded ONCE from the initial `value` attribute (native `defaultValue`).
|
|
121
|
+
#defaultValue = '';
|
|
122
|
+
#defaultCaptured = false;
|
|
123
|
+
// The `change`-on-commit baseline (value at the last focus/commit) + the IME-composition guard.
|
|
124
|
+
#committed = '';
|
|
125
|
+
#composing = false;
|
|
126
|
+
// The user-invalid TIMING controller, created per connection (re-arms on reconnect; released on disconnect).
|
|
127
|
+
#userInvalid = null;
|
|
128
|
+
// The active value-codec controller for number/currency/unit/percent types. Null for all other types.
|
|
129
|
+
// formValue() returns codec.canonical.value when set; formValidity() checks codec.hasError.
|
|
130
|
+
#codec = null;
|
|
131
|
+
// Password reveal state: tracks whether -webkit-text-security is suppressed.
|
|
132
|
+
// Drives :state(revealed) on internals and aria-pressed on the reveal button.
|
|
133
|
+
#revealed = false;
|
|
134
|
+
// ADR-0123 LLD-C9 — the type=color swatch-button's composed ui-swatch preview, for the CURRENT type-effect
|
|
135
|
+
// run only (torn down on type change/disconnect via the effect cleanup, the calendar-popup precedent).
|
|
136
|
+
// The model→surface effect below keeps its `value` synced to `this.value`.
|
|
137
|
+
#colorSwatchPreview = null;
|
|
138
|
+
connected() {
|
|
139
|
+
// Seed the reset baseline ONCE from the INITIAL `value` attribute (native `defaultValue` — the value
|
|
140
|
+
// attribute is never reflected, so it stays the markup value and survives later property writes).
|
|
141
|
+
if (!this.#defaultCaptured) {
|
|
142
|
+
this.#defaultValue = this.getAttribute('value') ?? '';
|
|
143
|
+
this.#defaultCaptured = true;
|
|
144
|
+
}
|
|
145
|
+
const editor = this.#ensureParts();
|
|
146
|
+
const message = this.#message;
|
|
147
|
+
// ── surface → model (ADR-0014 cl.1) — the editor's edits flow into `value`, IME-guarded ──
|
|
148
|
+
this.listen(editor, 'input', (event) => {
|
|
149
|
+
if (this.#composing)
|
|
150
|
+
return; // never mid-composition — compositionend commits the final composed text
|
|
151
|
+
event.stopPropagation(); // suppress the raw editor input; the host re-emits ONE composed `input` (target = host)
|
|
152
|
+
this.value = editor.textContent ?? ''; // model ← surface (the caret guard below then skips the echo write)
|
|
153
|
+
this.emit('input');
|
|
154
|
+
});
|
|
155
|
+
this.listen(editor, 'compositionstart', () => {
|
|
156
|
+
this.#composing = true;
|
|
157
|
+
});
|
|
158
|
+
this.listen(editor, 'compositionend', () => {
|
|
159
|
+
this.#composing = false;
|
|
160
|
+
this.value = editor.textContent ?? ''; // catch the model up to the composed result (the suppressed inputs)
|
|
161
|
+
});
|
|
162
|
+
// ── change on commit — blur-with-change or Enter (the value-at-focus baseline gates the blur) ──
|
|
163
|
+
this.listen(editor, 'focus', () => {
|
|
164
|
+
this.#committed = this.value;
|
|
165
|
+
});
|
|
166
|
+
this.listen(editor, 'blur', () => {
|
|
167
|
+
if (this.value === this.#committed)
|
|
168
|
+
return;
|
|
169
|
+
this.#committed = this.value;
|
|
170
|
+
this.emit('change');
|
|
171
|
+
});
|
|
172
|
+
this.listen(editor, 'keydown', (event) => {
|
|
173
|
+
const key = event.key;
|
|
174
|
+
if (key === 'Enter') {
|
|
175
|
+
if (this.#composing)
|
|
176
|
+
return;
|
|
177
|
+
event.preventDefault(); // single-line field — Enter never inserts a newline
|
|
178
|
+
this.#committed = this.value;
|
|
179
|
+
this.emit('change'); // Enter commits (resets the baseline, so a following blur does not double-fire)
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// ArrowUp/Down: step the numeric value when a codec is active (native type=number parity).
|
|
183
|
+
// Never mid-composition; preventDefault prevents cursor movement / page scroll during stepping.
|
|
184
|
+
if ((key === 'ArrowUp' || key === 'ArrowDown') && this.#codec !== null && !this.#composing) {
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
this.#step(key === 'ArrowUp' ? 1 : -1);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
// ── the user-invalid TIMING controller — gates the danger treatment until the first blur/change ──
|
|
190
|
+
const controller = trackUserInvalid(this, { invalid: () => !this.formValidity().valid });
|
|
191
|
+
this.#userInvalid = controller;
|
|
192
|
+
// ── type-dependent behavior: inputmode + auto-adornments + codec + validation (Wave 3/5A) ──
|
|
193
|
+
// The effect re-runs when this.type changes, running the cleanup from the previous run first.
|
|
194
|
+
// type='text' is the identity config (no adornments, no codec). Wave 5A: reading this.currency
|
|
195
|
+
// inside the currency branch / this.unit inside the unit branch makes the effect reactive to
|
|
196
|
+
// those props ONLY for the matching types — a plain field never reads them (kernel tracking law).
|
|
197
|
+
this.effect(() => {
|
|
198
|
+
const type = this.type;
|
|
199
|
+
const config = TYPE_CONFIG[type];
|
|
200
|
+
// inputmode: virtual-keyboard hint (omit for 'text' to stay byte-identical to the pre-Wave-3 editor).
|
|
201
|
+
if (config.inputmode !== 'text') {
|
|
202
|
+
editor.setAttribute('inputmode', config.inputmode);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
editor.removeAttribute('inputmode');
|
|
206
|
+
}
|
|
207
|
+
// Per-type AbortController: adornment-button click listeners live for one type config lifetime
|
|
208
|
+
// (aborted in the effect cleanup on type change or scope.dispose() on disconnect).
|
|
209
|
+
const typeAc = new AbortController();
|
|
210
|
+
// Leading adornment: magnifier glyph for search (Phosphor, injected inside #createLeadingAdornment),
|
|
211
|
+
// currency symbol text for currency. For currency: reading this.currency makes the effect re-run when
|
|
212
|
+
// currency changes (only while type=currency — a non-currency field never reads this.currency, so it
|
|
213
|
+
// never becomes a dep).
|
|
214
|
+
const leadingText = config.leading === 'currency' ? currencySymbol(this.currency ?? 'USD') : '';
|
|
215
|
+
const leadingEl = config.leading ? this.#createLeadingAdornment(config.leading, leadingText) : null;
|
|
216
|
+
// Trailing adornment: affordance (search/password/calendar) XOR numeric steppers ± suffix.
|
|
217
|
+
// These two never coexist — affordance 'calendar' is the one exception that ALSO carries a codec
|
|
218
|
+
// (type=date: affordance='calendar' + codec='date'), but STILL no steppers (ADR-0048).
|
|
219
|
+
let trailingEl = null;
|
|
220
|
+
if (config.affordance !== null) {
|
|
221
|
+
// Exclusive interactive button (clear / reveal / calendar) — no steppers alongside.
|
|
222
|
+
trailingEl = this.#createAffordanceAdornment(config.affordance, typeAc.signal);
|
|
223
|
+
}
|
|
224
|
+
else if (config.codec !== null && config.codec !== 'time') {
|
|
225
|
+
// Steppers, optionally with a suffix span (NUMERIC types only — type=time has codec but no steppers;
|
|
226
|
+
// type=date never reaches this branch because its affordance='calendar' takes the first branch).
|
|
227
|
+
// For unit: reading this.unit makes the effect re-run when unit changes (only while type=unit).
|
|
228
|
+
const suffixText = config.suffix === 'percent'
|
|
229
|
+
? '%'
|
|
230
|
+
: config.suffix === 'unit'
|
|
231
|
+
? unitLabel(this.unit ?? '')
|
|
232
|
+
: null;
|
|
233
|
+
trailingEl = this.#createNumericAdornment(suffixText, typeAc.signal);
|
|
234
|
+
}
|
|
235
|
+
// Calendar overlay (type=date only): a lazily-imported `<ui-calendar>` in a popover popup.
|
|
236
|
+
// The popup + overlay handle are per-type-effect-run; cleanup below tears them down on type-change.
|
|
237
|
+
// ADR-0048 decision 3 + its first-open follow-up: BOTH the calendar MODULE (the dynamic `import()`)
|
|
238
|
+
// AND the calendar ELEMENT (the popup wrapper + `<ui-calendar>` + its overlay wiring) are deferred
|
|
239
|
+
// to the SAME activation moment — the calendar button's first click. Until then a type=date field
|
|
240
|
+
// carries no popup subtree at all: no extra light-DOM children, nothing to upgrade, no overlay
|
|
241
|
+
// listeners — a first-paint cost win, and defense-in-depth alongside dom/form.ts's
|
|
242
|
+
// `#hasFormElementAncestor` guard for the nested-`UIFormElement` phantom-member hazard (the
|
|
243
|
+
// internal `<ui-calendar>` is now built even later than before). `ensureCalendar()` is the ONE
|
|
244
|
+
// creation choke point — idempotent, so the 2nd+ click reads the already-built refs back.
|
|
245
|
+
let calendarPopup = null;
|
|
246
|
+
let calendarHandle = null;
|
|
247
|
+
if (config.affordance === 'calendar' && trailingEl !== null) {
|
|
248
|
+
const calBtn = trailingEl.querySelector('[data-part="calendar-button"]');
|
|
249
|
+
let calEl = null;
|
|
250
|
+
let calendarLoaded = false;
|
|
251
|
+
// Build the popup + `<ui-calendar>` + overlay wiring + the calendar's own listeners ONCE, on
|
|
252
|
+
// first activation. The `<ui-calendar>` is an UNKNOWN element until the lazy import resolves —
|
|
253
|
+
// it upgrades in place without any re-render (the overlay popup doesn't re-mount on upgrade).
|
|
254
|
+
const ensureCalendar = () => {
|
|
255
|
+
if (calendarPopup !== null)
|
|
256
|
+
return { calEl: calEl, handle: calendarHandle };
|
|
257
|
+
calEl = document.createElement('ui-calendar');
|
|
258
|
+
calendarPopup = document.createElement('div');
|
|
259
|
+
calendarPopup.setAttribute('data-part', 'calendar-popup');
|
|
260
|
+
calendarPopup.append(calEl);
|
|
261
|
+
this.append(calendarPopup);
|
|
262
|
+
// Wire the overlay controller. The connection-scoped `host.listen(popup, 'toggle', …)` inside
|
|
263
|
+
// overlay() persists until disconnect; the explicit `calendarHandle.cleanup()` in the effect
|
|
264
|
+
// cleanup fires on type-change (setting `cleaned=true` so the toggle handler becomes a no-op).
|
|
265
|
+
calendarHandle = overlay(this, {
|
|
266
|
+
popup: calendarPopup,
|
|
267
|
+
anchor: calBtn,
|
|
268
|
+
placement: 'bottom-start',
|
|
269
|
+
auto: true,
|
|
270
|
+
focusOnOpen: true,
|
|
271
|
+
});
|
|
272
|
+
// Event-boundary guard (ADR-0048 §3 — B1): UIElement.emit() fires events with bubbles:true
|
|
273
|
+
// composed:true, so the calendar's own `change` event bubbles out of <ui-calendar> and reaches
|
|
274
|
+
// ui-text-field's external listeners BEFORE the field's select→re-emit fires — doubling the
|
|
275
|
+
// event (consumer sees 2 change per pick; native <input type=date> emits 1). Stop the calendar's
|
|
276
|
+
// `change` at the calEl boundary; the field remains the sole emitter for its own events.
|
|
277
|
+
calEl.addEventListener('change', (e) => { e.stopPropagation(); }, { signal: typeAc.signal });
|
|
278
|
+
// Calendar selection: `select` fires with the chosen ISO date in event.detail (ADR-0048 §2).
|
|
279
|
+
// The codec's setCanonical keeps the canonical in sync so blur-formatting starts from the new date.
|
|
280
|
+
calEl.addEventListener('select', (event) => {
|
|
281
|
+
const iso = event.detail;
|
|
282
|
+
if (iso) {
|
|
283
|
+
this.value = iso;
|
|
284
|
+
this.#codec?.setCanonical(iso);
|
|
285
|
+
this.emit('input');
|
|
286
|
+
this.emit('change');
|
|
287
|
+
}
|
|
288
|
+
calendarHandle.close();
|
|
289
|
+
}, { signal: typeAc.signal });
|
|
290
|
+
return { calEl, handle: calendarHandle };
|
|
291
|
+
};
|
|
292
|
+
// Button click: build the calendar on first activation (ensureCalendar, idempotent), sync
|
|
293
|
+
// field value/bounds → calendar, then open.
|
|
294
|
+
// Fast path: if ui-calendar is ALREADY REGISTERED (e.g., statically imported via the barrel or
|
|
295
|
+
// by another part of the app), open synchronously — `customElements.get` is a runtime check,
|
|
296
|
+
// not a static import, so the tree-shaker still excludes calendar.ts from the text-field graph.
|
|
297
|
+
// Slow path: dynamic import on first open; after that, `calendarLoaded` keeps it synchronous.
|
|
298
|
+
//
|
|
299
|
+
// NOTE — M1 test gap: the slow path (dynamic import) is NOT exercised by the test suite because
|
|
300
|
+
// both the barrel (controls/index.ts) and the browser test harness pre-register <ui-calendar>,
|
|
301
|
+
// making customElements.get('ui-calendar') always truthy. Isolating an unregistered-calendar
|
|
302
|
+
// context would require unregistering a custom element, which the spec forbids. The slow path
|
|
303
|
+
// is correct by code-review (same try-free import chain as `calendarLoaded=true → open()`); the
|
|
304
|
+
// limitation is documented in text-field.md.
|
|
305
|
+
calBtn.addEventListener('click', () => {
|
|
306
|
+
const { calEl, handle } = ensureCalendar();
|
|
307
|
+
calEl.setAttribute('value', this.value);
|
|
308
|
+
if (this.min)
|
|
309
|
+
calEl.setAttribute('min', this.min);
|
|
310
|
+
if (this.max)
|
|
311
|
+
calEl.setAttribute('max', this.max);
|
|
312
|
+
if (calendarLoaded || customElements.get('ui-calendar') !== undefined) {
|
|
313
|
+
calendarLoaded = true;
|
|
314
|
+
handle.open();
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
// First open: dynamic import (NOT a static import — keeps ui-calendar out of the static graph).
|
|
318
|
+
import("../calendar/calendar.js").then(() => {
|
|
319
|
+
calendarLoaded = true;
|
|
320
|
+
handle.open();
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}, { signal: typeAc.signal });
|
|
324
|
+
}
|
|
325
|
+
// Color-picker overlay (type=color only): a lazily-imported `<ui-color-picker>` in a popover popup —
|
|
326
|
+
// the ADR-0048 type=date→ui-calendar seam verbatim (LLD-C9). BOTH the picker MODULE (dynamic
|
|
327
|
+
// `import()`) AND the picker ELEMENT (popup wrapper + `<ui-color-picker>` + overlay wiring) are
|
|
328
|
+
// deferred to the swatch button's FIRST activation — a type=text field ships no picker bytes
|
|
329
|
+
// (SPEC-R11 AC1). `ensureColorPicker()` is the ONE creation choke point — idempotent.
|
|
330
|
+
let colorPickerPopup = null;
|
|
331
|
+
let colorPickerHandle = null;
|
|
332
|
+
if (config.affordance === 'swatch' && trailingEl !== null) {
|
|
333
|
+
const swatchBtn = trailingEl.querySelector('[data-part="swatch-button"]');
|
|
334
|
+
let colorPickerEl = null;
|
|
335
|
+
let colorPickerLoaded = false;
|
|
336
|
+
const ensureColorPicker = () => {
|
|
337
|
+
if (colorPickerPopup !== null)
|
|
338
|
+
return { colorPickerEl: colorPickerEl, handle: colorPickerHandle };
|
|
339
|
+
colorPickerEl = document.createElement('ui-color-picker');
|
|
340
|
+
colorPickerPopup = document.createElement('div');
|
|
341
|
+
colorPickerPopup.setAttribute('data-part', 'color-picker-popup');
|
|
342
|
+
colorPickerPopup.append(colorPickerEl);
|
|
343
|
+
this.append(colorPickerPopup);
|
|
344
|
+
colorPickerHandle = overlay(this, {
|
|
345
|
+
popup: colorPickerPopup,
|
|
346
|
+
anchor: swatchBtn,
|
|
347
|
+
placement: 'bottom-start',
|
|
348
|
+
auto: true,
|
|
349
|
+
focusOnOpen: true,
|
|
350
|
+
});
|
|
351
|
+
// Event-boundary guard (the ADR-0048 §3 B1 precedent) — the picker's own input/change bubble
|
|
352
|
+
// (UIElement.emit() is bubbles:true composed:true); stop both at the picker boundary and
|
|
353
|
+
// re-derive the field's OWN input/change from its committed value, so the field remains the
|
|
354
|
+
// sole emitter for its own events (no doubling).
|
|
355
|
+
colorPickerEl.addEventListener('input', (e) => { e.stopPropagation(); }, { signal: typeAc.signal });
|
|
356
|
+
colorPickerEl.addEventListener('change', (event) => {
|
|
357
|
+
event.stopPropagation();
|
|
358
|
+
const v = colorPickerEl.value;
|
|
359
|
+
this.value = v;
|
|
360
|
+
this.#codec?.setCanonical(v);
|
|
361
|
+
this.emit('input');
|
|
362
|
+
this.emit('change');
|
|
363
|
+
// Deliberately does NOT close the overlay — unlike a calendar day-click (one atomic pick),
|
|
364
|
+
// a color-picker `change` is one channel/gesture commit, not "the user is done"; the overlay
|
|
365
|
+
// stays open for further adjustment and closes via its own light-dismiss (Escape/outside-click).
|
|
366
|
+
}, { signal: typeAc.signal });
|
|
367
|
+
return { colorPickerEl, handle: colorPickerHandle };
|
|
368
|
+
};
|
|
369
|
+
// Button click: build the picker on first activation (idempotent), sync field value/format →
|
|
370
|
+
// picker, then open. Fast path (already registered) vs slow path (dynamic import) — the
|
|
371
|
+
// ui-calendar precedent verbatim.
|
|
372
|
+
swatchBtn.addEventListener('click', () => {
|
|
373
|
+
const { colorPickerEl, handle } = ensureColorPicker();
|
|
374
|
+
colorPickerEl.setAttribute('value', this.value);
|
|
375
|
+
colorPickerEl.setAttribute('format', this.format ?? 'hex');
|
|
376
|
+
if (colorPickerLoaded || customElements.get('ui-color-picker') !== undefined) {
|
|
377
|
+
colorPickerLoaded = true;
|
|
378
|
+
handle.open();
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
// First open: dynamic import (NOT static — keeps ui-color-picker out of the static graph).
|
|
382
|
+
import("../color-picker/color-picker.js").then(() => {
|
|
383
|
+
colorPickerLoaded = true;
|
|
384
|
+
handle.open();
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}, { signal: typeAc.signal });
|
|
388
|
+
}
|
|
389
|
+
// Codec: number/currency/unit/percent get the numeric display↔canonical split (ADR-0047).
|
|
390
|
+
// date/time get the locale display↔ISO-canonical split (ADR-0048). color gets the hex/oklch
|
|
391
|
+
// display↔canonical split (ADR-0123, reusing colorCodecOptions verbatim — the standalone control's
|
|
392
|
+
// own dialect). typeAc.signal is passed so the codec's focus/blur listeners die on the NEXT type
|
|
393
|
+
// change (the M1-fix seam). Local variable captures config.codec so TypeScript can narrow the
|
|
394
|
+
// literal union correctly through the ternary chain (narrowing through `config.codec` on a union
|
|
395
|
+
// config type is fragile).
|
|
396
|
+
const codecKind = config.codec;
|
|
397
|
+
const codec = codecKind === 'number'
|
|
398
|
+
? valueCodec(this, numberCodecOptions(), typeAc.signal)
|
|
399
|
+
: codecKind === 'currency'
|
|
400
|
+
? valueCodec(this, currencyCodecOptions(this.currency ?? 'USD'), typeAc.signal)
|
|
401
|
+
: codecKind === 'unit'
|
|
402
|
+
? valueCodec(this, unitCodecOptions(this.unit ?? ''), typeAc.signal)
|
|
403
|
+
: codecKind === 'date'
|
|
404
|
+
? valueCodec(this, dateCodecOptions(), typeAc.signal)
|
|
405
|
+
: codecKind === 'time'
|
|
406
|
+
? valueCodec(this, timeCodecOptions(), typeAc.signal)
|
|
407
|
+
: codecKind === 'color'
|
|
408
|
+
? valueCodec(this, colorCodecOptions(this.format ?? 'hex'), typeAc.signal)
|
|
409
|
+
: null;
|
|
410
|
+
this.#codec = codec;
|
|
411
|
+
// Clear reveal state on type change so switching away from 'password' doesn't leave ghost state.
|
|
412
|
+
if (type !== 'password') {
|
|
413
|
+
this.#revealed = false;
|
|
414
|
+
this.internals.states?.delete('revealed');
|
|
415
|
+
}
|
|
416
|
+
// Effect cleanup: runs before the next type-change run AND on scope.dispose() (disconnect).
|
|
417
|
+
return () => {
|
|
418
|
+
typeAc.abort(); // removes adornment-button listeners (clear/reveal/calendar/swatch) registered with typeAc.signal
|
|
419
|
+
leadingEl?.remove();
|
|
420
|
+
trailingEl?.remove();
|
|
421
|
+
calendarPopup?.remove(); // remove the calendar popup panel from the host on type-change or disconnect
|
|
422
|
+
calendarHandle?.cleanup(); // early-teardown of the overlay controller (closed, cleaned=true — idempotent)
|
|
423
|
+
colorPickerPopup?.remove();
|
|
424
|
+
colorPickerHandle?.cleanup();
|
|
425
|
+
this.#colorSwatchPreview = null;
|
|
426
|
+
codec?.release();
|
|
427
|
+
this.#codec = null;
|
|
428
|
+
};
|
|
429
|
+
});
|
|
430
|
+
// ── model → surface (ADR-0014 cl.1: the CARET GUARD) + the placeholder presence flag ──
|
|
431
|
+
this.effect(() => {
|
|
432
|
+
const value = this.value; // tracked — re-runs on every value change (typed OR programmatic/reset/restore)
|
|
433
|
+
if (this.#composing)
|
|
434
|
+
return; // never write mid-composition
|
|
435
|
+
// CARET GUARD: rewrite the editor ONLY when the model diverges from the surface, so a keystroke (which
|
|
436
|
+
// already updated textContent) never resets the caret; a programmatic write/reset/restore DOES flow.
|
|
437
|
+
if (editor.textContent !== value)
|
|
438
|
+
editor.textContent = value;
|
|
439
|
+
editor.toggleAttribute('data-empty', value === ''); // keys the CSS placeholder (not :empty — see ADR cl.1)
|
|
440
|
+
// ADR-0123 LLD-C9 — the type=color swatch-button preview tracks the field's own value (a no-op for
|
|
441
|
+
// every other type, since the ref is only ever set inside the type=color branch).
|
|
442
|
+
if (this.#colorSwatchPreview)
|
|
443
|
+
this.#colorSwatchPreview.color = value;
|
|
444
|
+
});
|
|
445
|
+
// ── editor attribute mirror — the label seam, the placeholder text, the required mirror ──
|
|
446
|
+
this.effect(() => {
|
|
447
|
+
// ADR-0051 yield: aria-label is the BARE stand-in only — a `ui-field` association carries the name
|
|
448
|
+
// via aria-labelledby (applyFieldLabelling below), which beats aria-label in accname resolution
|
|
449
|
+
// anyway; clearing it here keeps the editor's AX tree clean under association.
|
|
450
|
+
if (this.label && this.fieldLabelling === null)
|
|
451
|
+
editor.setAttribute('aria-label', this.label);
|
|
452
|
+
else
|
|
453
|
+
editor.removeAttribute('aria-label');
|
|
454
|
+
editor.setAttribute('data-placeholder', this.placeholder); // the CSS placeholder reads attr(data-placeholder)
|
|
455
|
+
if (this.required)
|
|
456
|
+
editor.setAttribute('aria-required', 'true');
|
|
457
|
+
else
|
|
458
|
+
editor.removeAttribute('aria-required');
|
|
459
|
+
});
|
|
460
|
+
// ── the disabled / readonly channel (ADR-0014 dev#b) — effectiveDisabled = own || form-disabled ──
|
|
461
|
+
this.effect(() => {
|
|
462
|
+
if (this.effectiveDisabled()) {
|
|
463
|
+
editor.setAttribute('contenteditable', 'false');
|
|
464
|
+
editor.removeAttribute('tabindex'); // not focusable (out of the tab order, like <input disabled>)
|
|
465
|
+
editor.setAttribute('aria-disabled', 'true');
|
|
466
|
+
editor.removeAttribute('aria-readonly');
|
|
467
|
+
this.internals.states?.add('disabled'); // the :state(disabled) CSS hook (NOT host ariaDisabled)
|
|
468
|
+
}
|
|
469
|
+
else if (this.readonly) {
|
|
470
|
+
editor.setAttribute('contenteditable', 'false'); // not editable …
|
|
471
|
+
editor.setAttribute('tabindex', '0'); // … but still focusable / selectable (and still submits)
|
|
472
|
+
editor.removeAttribute('aria-disabled');
|
|
473
|
+
editor.setAttribute('aria-readonly', 'true');
|
|
474
|
+
this.internals.states?.delete('disabled');
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
editor.setAttribute('contenteditable', EDITABLE); // editable; a contenteditable region is intrinsically focusable
|
|
478
|
+
editor.removeAttribute('tabindex');
|
|
479
|
+
editor.removeAttribute('aria-disabled');
|
|
480
|
+
editor.removeAttribute('aria-readonly');
|
|
481
|
+
this.internals.states?.delete('disabled');
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
// ── user-invalid → aria-invalid + the non-colour message cue + :state(user-invalid) (ADR-0014 cl.2c/4) ──
|
|
485
|
+
// ADR-0029 A1 (user-ratified): when carrying a message the message node is VISIBLE — `message.hidden`
|
|
486
|
+
// toggled false (dangerous treatment: --md-sys-color-danger ink, small type, gated by :state(user-invalid) in CSS).
|
|
487
|
+
// The `aria-describedby` wiring is unchanged; making the node visible is an extension only.
|
|
488
|
+
// ADR-0051 cl.4 yield: under a `ui-field` association the internal message stays empty + hidden and OUT
|
|
489
|
+
// of aria-describedby (that attribute is owned by applyFieldLabelling below) — the field's own error
|
|
490
|
+
// part is the ONE AT-announced error; no double-announce.
|
|
491
|
+
// F3 (review-caught dual-writer): `aria-describedby` ownership transfers WHOLLY to `applyFieldLabelling`
|
|
492
|
+
// during association and reverts to this effect only when `fieldLabelling` is `null` — so BOTH branches
|
|
493
|
+
// below gate every `aria-describedby` set/remove on `!fielded`; while fielded, this effect never sets OR
|
|
494
|
+
// removes it, in EITHER validity leg (an unconditional `removeAttribute` on the invalid→valid leg would
|
|
495
|
+
// otherwise clobber the field's ids the moment a fielded control becomes valid).
|
|
496
|
+
this.effect(() => {
|
|
497
|
+
const fielded = this.fieldLabelling !== null;
|
|
498
|
+
if (controller.userInvalid()) {
|
|
499
|
+
// userInvalid ⇒ invalid, so formValidity() is the invalid branch carrying the message + flags.
|
|
500
|
+
const verdict = this.formValidity();
|
|
501
|
+
const text = verdict.valid ? '' : verdict.message; // the WCAG 1.4.1 non-colour reinforcement
|
|
502
|
+
editor.setAttribute('aria-invalid', 'true');
|
|
503
|
+
if (fielded) {
|
|
504
|
+
message.textContent = '';
|
|
505
|
+
message.hidden = true;
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
editor.setAttribute('aria-describedby', message.id);
|
|
509
|
+
message.textContent = text;
|
|
510
|
+
message.hidden = text === ''; // visible when there is a message (ADR-0029 A1)
|
|
511
|
+
}
|
|
512
|
+
this.internals.states?.add('user-invalid');
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
editor.removeAttribute('aria-invalid');
|
|
516
|
+
if (!fielded)
|
|
517
|
+
editor.removeAttribute('aria-describedby'); // F3 — never touch it while fielded
|
|
518
|
+
message.textContent = '';
|
|
519
|
+
message.hidden = true; // no message → out of flow
|
|
520
|
+
this.internals.states?.delete('user-invalid');
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
// Motion gate (interaction-states standard) — arm `ready` ONE frame past first paint so the upgrade/first
|
|
524
|
+
// paint SNAPS and only subsequent state changes animate (the text-field.css transition is gated behind
|
|
525
|
+
// :state(ready)). `states` optional-chained — jsdom has no CustomStateSet (the real motion is the s11 smoke).
|
|
526
|
+
requestAnimationFrame(() => this.internals.states?.add('ready'));
|
|
527
|
+
}
|
|
528
|
+
disconnected() {
|
|
529
|
+
this.#userInvalid?.release(); // idempotent — the controller's listeners already die with the connection scope
|
|
530
|
+
this.#userInvalid = null;
|
|
531
|
+
// #codec is released + nulled by the type-effect's cleanup (which fires on scope.dispose()); explicit
|
|
532
|
+
// null here is defensive for any code that reads #codec after disconnect.
|
|
533
|
+
this.#codec = null;
|
|
534
|
+
}
|
|
535
|
+
/** Forward host focus to the editor PART (label-association + native `.focus()` parity). */
|
|
536
|
+
focus(options) {
|
|
537
|
+
if (this.#editor)
|
|
538
|
+
this.#editor.focus(options);
|
|
539
|
+
else
|
|
540
|
+
super.focus(options);
|
|
541
|
+
}
|
|
542
|
+
// ── form hooks (overrides of the UIFormElement seams) ─────────────────────────
|
|
543
|
+
/**
|
|
544
|
+
* The value contributed to the owning form (FACE — the base publishes it via internals.setFormValue).
|
|
545
|
+
* When a value codec is active (number/currency/unit/percent types), the CANONICAL parsed value is the
|
|
546
|
+
* form value, not `this.value` which holds the formatted display string after blur. For all other types,
|
|
547
|
+
* `this.value` IS the form value (the editor textContent is the submission value).
|
|
548
|
+
*/
|
|
549
|
+
formValue() {
|
|
550
|
+
return this.#codec?.canonical.value ?? this.value;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* The validity verdict: `required && value === ''` → `valueMissing`; codec parse error → `customError`
|
|
554
|
+
* (numeric) or `typeMismatch` (date/time — ADR-0048); range check (min/max) → `rangeUnderflow`/
|
|
555
|
+
* `rangeOverflow` (numeric types only); email/url format error → `typeMismatch`; valid otherwise.
|
|
556
|
+
* A disabled field is barred from constraint validation (native parity).
|
|
557
|
+
* `stepMismatch` is NOT enforced — too strict for free numeric entry (ADR-0047).
|
|
558
|
+
*/
|
|
559
|
+
formValidity() {
|
|
560
|
+
if (this.effectiveDisabled())
|
|
561
|
+
return { valid: true };
|
|
562
|
+
// valueMissing: required + empty (gated on this.value, the display; an empty field is empty in all types).
|
|
563
|
+
if (this.required && this.value === '') {
|
|
564
|
+
return {
|
|
565
|
+
valid: false,
|
|
566
|
+
flags: { valueMissing: true },
|
|
567
|
+
message: 'Please fill out this field.',
|
|
568
|
+
anchor: this.#editor ?? undefined,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
// Read type once — used for both codec-error flag discrimination and type-specific validation below.
|
|
572
|
+
const type = this.type;
|
|
573
|
+
// Codec parse error: hasError is set by the valueCodec controller on blur (all codec types).
|
|
574
|
+
// date/time/color → typeMismatch (the platform validity flag for wrong-format entry — ADR-0048/ADR-0123);
|
|
575
|
+
// numeric types → customError (a free-form parse failure, not a native input-type mismatch).
|
|
576
|
+
if (this.#codec?.hasError.value) {
|
|
577
|
+
return {
|
|
578
|
+
valid: false,
|
|
579
|
+
flags: type === 'date' || type === 'time' || type === 'color' ? { typeMismatch: true } : { customError: true },
|
|
580
|
+
message: this.#codec.errorMessage,
|
|
581
|
+
anchor: this.#editor ?? undefined,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
// Type-specific validation (non-empty values only — empty passes to avoid double-reporting).
|
|
585
|
+
const v = this.value;
|
|
586
|
+
if (v !== '') {
|
|
587
|
+
// Range validity for NUMERIC types only (ADR-0047): rangeUnderflow / rangeOverflow from min/max.
|
|
588
|
+
// date/time/color are excluded — their min/max are not numeric bounds (parseFloat would silently
|
|
589
|
+
// truncate a date to its year portion, or NaN a hex/oklch string, producing wrong comparisons).
|
|
590
|
+
if (this.#codec !== null && type !== 'date' && type !== 'time' && type !== 'color') {
|
|
591
|
+
const canonical = this.#codec.canonical.value;
|
|
592
|
+
if (canonical !== '') {
|
|
593
|
+
const numVal = parseFloat(canonical);
|
|
594
|
+
if (Number.isFinite(numVal)) {
|
|
595
|
+
if (this.min !== '') {
|
|
596
|
+
const minNum = parseFloat(this.min);
|
|
597
|
+
if (Number.isFinite(minNum) && numVal < minNum) {
|
|
598
|
+
return {
|
|
599
|
+
valid: false,
|
|
600
|
+
flags: { rangeUnderflow: true },
|
|
601
|
+
message: `Value must be greater than or equal to ${minNum}.`,
|
|
602
|
+
anchor: this.#editor ?? undefined,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (this.max !== '') {
|
|
607
|
+
const maxNum = parseFloat(this.max);
|
|
608
|
+
if (Number.isFinite(maxNum) && numVal > maxNum) {
|
|
609
|
+
return {
|
|
610
|
+
valid: false,
|
|
611
|
+
flags: { rangeOverflow: true },
|
|
612
|
+
message: `Value must be less than or equal to ${maxNum}.`,
|
|
613
|
+
anchor: this.#editor ?? undefined,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (type === 'email' && !EMAIL_PATTERN.test(v)) {
|
|
621
|
+
return {
|
|
622
|
+
valid: false,
|
|
623
|
+
flags: { typeMismatch: true },
|
|
624
|
+
message: 'Please enter a valid email address.',
|
|
625
|
+
anchor: this.#editor ?? undefined,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
if (type === 'url') {
|
|
629
|
+
try {
|
|
630
|
+
new URL(v); // throws for invalid URLs
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
return {
|
|
634
|
+
valid: false,
|
|
635
|
+
flags: { typeMismatch: true },
|
|
636
|
+
message: 'Please enter a valid URL.',
|
|
637
|
+
anchor: this.#editor ?? undefined,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return { valid: true };
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Form reset → value ← the initial `value` attribute (native-parity defaultValue) + clear the touched state.
|
|
646
|
+
* A reset must not leave a required-empty field showing `:state(user-invalid)` until the user re-interacts, so
|
|
647
|
+
* the timing controller is reset to its first-paint suppression (the danger treatment re-arms on the next
|
|
648
|
+
* blur/change). The optional chain is defensive — `formReset` only fires while connected (the controller is live).
|
|
649
|
+
*/
|
|
650
|
+
formReset() {
|
|
651
|
+
this.value = this.#defaultValue;
|
|
652
|
+
this.#codec?.setCanonical(this.#defaultValue); // keep canonical in sync on reset
|
|
653
|
+
this.#userInvalid?.reset();
|
|
654
|
+
}
|
|
655
|
+
/** Restore the value after navigation/autofill (FACE state restore). */
|
|
656
|
+
formStateRestore(state) {
|
|
657
|
+
if (typeof state === 'string')
|
|
658
|
+
this.value = state;
|
|
659
|
+
}
|
|
660
|
+
// ── ADR-0051 — the field-labelling seam wire (the reference part-role override) ──────────────
|
|
661
|
+
/**
|
|
662
|
+
* The part-role override (ADR-0051 cl.2/LLD-C2 §wire) — the editor's `role=textbox` rides a light-DOM
|
|
663
|
+
* PART, not `internals.role`, so the base's guarded internals-reflection default (dom/form.ts) never
|
|
664
|
+
* fires for this control; id-reference the editor directly instead.
|
|
665
|
+
*
|
|
666
|
+
* `aria-labelledby` is this method's OWN, exclusive concern in both directions (no other effect ever
|
|
667
|
+
* touches it): set from `refs.label` when present, removed when `null`.
|
|
668
|
+
*
|
|
669
|
+
* `aria-describedby` is only WRITTEN here on association (`refs !== null`) — on dissociation it is
|
|
670
|
+
* deliberately left untouched, NOT unconditionally cleared. Why: the user-invalid/message effect above
|
|
671
|
+
* is the exclusive, correct owner of `aria-describedby` whenever `this.fieldLabelling === null` (it
|
|
672
|
+
* fully recomputes the bare-mode value — set to the message id when invalid, removed otherwise) — and
|
|
673
|
+
* BOTH effects re-run in the SAME flush wave on a `setFieldLabelling` write. Two effects racing to write
|
|
674
|
+
* one attribute makes the OUTCOME depend on which one the kernel happens to run last (subscriber-order,
|
|
675
|
+
* an incidental scheduling detail, not a documented invariant) — clearing it here too would sometimes
|
|
676
|
+
* win that race and erase what the message effect just (correctly) set. Touching only what is
|
|
677
|
+
* EXCLUSIVELY this method's own — never the other effect's — makes the result order-independent.
|
|
678
|
+
*
|
|
679
|
+
* Guards a not-yet-created editor (the LLD-C2 override contract) — cannot happen in practice
|
|
680
|
+
* (`#ensureParts()` runs synchronously at the top of `connected()`, before the base's forwarding effect
|
|
681
|
+
* installs), but the guard costs nothing and documents the contract.
|
|
682
|
+
*/
|
|
683
|
+
applyFieldLabelling(refs) {
|
|
684
|
+
const editor = this.#editor;
|
|
685
|
+
if (!editor)
|
|
686
|
+
return;
|
|
687
|
+
if (refs === null) {
|
|
688
|
+
editor.removeAttribute('aria-labelledby'); // aria-describedby is NOT this branch's concern — see above
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (refs.label)
|
|
692
|
+
editor.setAttribute('aria-labelledby', refs.label.id);
|
|
693
|
+
else
|
|
694
|
+
editor.removeAttribute('aria-labelledby');
|
|
695
|
+
const described = [refs.description, refs.error].filter((el) => el !== null);
|
|
696
|
+
if (described.length > 0)
|
|
697
|
+
editor.setAttribute('aria-describedby', described.map((el) => el.id).join(' '));
|
|
698
|
+
else
|
|
699
|
+
editor.removeAttribute('aria-describedby');
|
|
700
|
+
}
|
|
701
|
+
/** Feeds `FormConnectDetail.userInvalid` (ADR-0050) — the existing `trackUserInvalid` tracker IS the one
|
|
702
|
+
* timing source; this override just exposes its gate. */
|
|
703
|
+
formUserInvalid() {
|
|
704
|
+
return this.#userInvalid?.userInvalid() ?? false;
|
|
705
|
+
}
|
|
706
|
+
// ── auto-adornment creation (Wave 3 + Wave 5A) ─────────────────────────────────
|
|
707
|
+
/**
|
|
708
|
+
* Create and prepend a control-injected LEADING adornment. Placed BEFORE the editor in the DOM so
|
|
709
|
+
* the host-as-grid's `order` property (slot leading=0 · editor=1 · trailing=2) correctly positions
|
|
710
|
+
* it in the leading cell. Returns the element for the effect cleanup to remove.
|
|
711
|
+
*
|
|
712
|
+
* @param role The TYPE_CONFIG leading role ('magnifier' or 'currency').
|
|
713
|
+
* @param text The glyph TEXT — currencySymbol(this.currency) for currency; unused for magnifier
|
|
714
|
+
* (that role injects the Phosphor `magnifying-glass` icon instead, via setIcon).
|
|
715
|
+
*/
|
|
716
|
+
#createLeadingAdornment(role, text) {
|
|
717
|
+
const el = document.createElement('span');
|
|
718
|
+
el.setAttribute('slot', 'leading');
|
|
719
|
+
el.setAttribute('data-part', 'leading-adornment');
|
|
720
|
+
el.setAttribute('data-role', role);
|
|
721
|
+
el.setAttribute('aria-hidden', 'true'); // decorative — the editor carries the accessible name
|
|
722
|
+
if (role === 'magnifier') {
|
|
723
|
+
setIcon(el, 'magnifying-glass'); // Phosphor, via @agent-ui-kit/icons
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
el.textContent = text; // currency symbol — locale text, not an icon
|
|
727
|
+
}
|
|
728
|
+
// prepend: appears before the editor in DOM order; slot+order CSS positions it correctly.
|
|
729
|
+
this.prepend(el);
|
|
730
|
+
return el;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Create and append a control-injected TRAILING affordance adornment (clear / reveal / calendar).
|
|
734
|
+
* The clear and reveal buttons register their click listeners with `typeAc` so they are aborted on
|
|
735
|
+
* type change and on disconnect (the cleanup's typeAc.abort()). The calendar button's click listener
|
|
736
|
+
* is wired by the CALLER (the type-effect) after overlay creation — this method only creates the
|
|
737
|
+
* button element. Returns the container for the effect cleanup to remove.
|
|
738
|
+
*/
|
|
739
|
+
#createAffordanceAdornment(role, typeAc) {
|
|
740
|
+
const container = document.createElement('span');
|
|
741
|
+
container.setAttribute('slot', 'trailing');
|
|
742
|
+
container.setAttribute('data-part', 'trailing-adornment');
|
|
743
|
+
container.setAttribute('data-role', role);
|
|
744
|
+
if (role === 'clear') {
|
|
745
|
+
const btn = document.createElement('button');
|
|
746
|
+
btn.type = 'button';
|
|
747
|
+
btn.setAttribute('data-part', 'clear-button');
|
|
748
|
+
btn.setAttribute('aria-label', 'Clear');
|
|
749
|
+
setIcon(btn, 'x'); // Phosphor, via @agent-ui-kit/icons
|
|
750
|
+
btn.addEventListener('click', () => {
|
|
751
|
+
if (this.value === '')
|
|
752
|
+
return; // nothing to clear
|
|
753
|
+
this.value = '';
|
|
754
|
+
this.#codec?.setCanonical('');
|
|
755
|
+
this.emit('input');
|
|
756
|
+
this.emit('change');
|
|
757
|
+
this.#editor?.focus();
|
|
758
|
+
}, { signal: typeAc });
|
|
759
|
+
container.append(btn);
|
|
760
|
+
}
|
|
761
|
+
else if (role === 'reveal') {
|
|
762
|
+
// reveal: toggle password masking via :state(revealed) (ADR-0044). The glyph itself carries the
|
|
763
|
+
// masked/revealed distinction (Phosphor eye ↔ eye-slash) — the aria-label carries the same in text.
|
|
764
|
+
const btn = document.createElement('button');
|
|
765
|
+
btn.type = 'button';
|
|
766
|
+
btn.setAttribute('data-part', 'reveal-button');
|
|
767
|
+
btn.setAttribute('aria-label', 'Show password');
|
|
768
|
+
btn.setAttribute('aria-pressed', 'false');
|
|
769
|
+
setIcon(btn, 'eye'); // masked (click to reveal)
|
|
770
|
+
btn.addEventListener('click', () => {
|
|
771
|
+
this.#revealed = !this.#revealed;
|
|
772
|
+
btn.setAttribute('aria-pressed', String(this.#revealed));
|
|
773
|
+
btn.setAttribute('aria-label', this.#revealed ? 'Hide password' : 'Show password');
|
|
774
|
+
setIcon(btn, this.#revealed ? 'eye-slash' : 'eye');
|
|
775
|
+
if (this.#revealed) {
|
|
776
|
+
this.internals.states?.add('revealed');
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
this.internals.states?.delete('revealed');
|
|
780
|
+
}
|
|
781
|
+
this.emit('toggle');
|
|
782
|
+
}, { signal: typeAc });
|
|
783
|
+
container.append(btn);
|
|
784
|
+
}
|
|
785
|
+
else if (role === 'calendar') {
|
|
786
|
+
// trailing icon button that opens the date picker popup.
|
|
787
|
+
// The click listener + overlay wiring are done by the type-effect AFTER overlay() is called —
|
|
788
|
+
// the button must exist first so it can serve as the overlay anchor.
|
|
789
|
+
const btn = document.createElement('button');
|
|
790
|
+
btn.type = 'button';
|
|
791
|
+
btn.setAttribute('data-part', 'calendar-button');
|
|
792
|
+
btn.setAttribute('aria-label', 'Open date picker');
|
|
793
|
+
btn.setAttribute('aria-haspopup', 'dialog');
|
|
794
|
+
setIcon(btn, 'calendar-blank'); // Phosphor, via @agent-ui-kit/icons
|
|
795
|
+
container.append(btn);
|
|
796
|
+
}
|
|
797
|
+
else {
|
|
798
|
+
// 'swatch' (ADR-0123 LLD-C9): trailing button that opens the color picker popup, showing the
|
|
799
|
+
// CURRENT color via a composed ui-swatch (never a bespoke color div — the ADR-0118 fence). The
|
|
800
|
+
// click listener + overlay wiring are done by the type-effect AFTER overlay() is called; the model
|
|
801
|
+
// → surface effect keeps the swatch's `value` synced to `this.value` (`#colorSwatchPreview`).
|
|
802
|
+
const btn = document.createElement('button');
|
|
803
|
+
btn.type = 'button';
|
|
804
|
+
btn.setAttribute('data-part', 'swatch-button');
|
|
805
|
+
btn.setAttribute('aria-label', 'Open color picker');
|
|
806
|
+
btn.setAttribute('aria-haspopup', 'dialog');
|
|
807
|
+
const swatchPreview = document.createElement('ui-swatch');
|
|
808
|
+
// untracked: a one-time creation-time seed, not a live binding — a plain read here would make
|
|
809
|
+
// `value` a dependency of the WHOLE type-effect (this call runs synchronously inside its scope),
|
|
810
|
+
// rebuilding the entire adornment/codec/overlay wiring on every keystroke (the bug this untracked()
|
|
811
|
+
// read closes). The model→surface effect keeps `#colorSwatchPreview.color` live going forward.
|
|
812
|
+
swatchPreview.color = untracked(() => this.value);
|
|
813
|
+
btn.append(swatchPreview);
|
|
814
|
+
this.#colorSwatchPreview = swatchPreview;
|
|
815
|
+
container.append(btn);
|
|
816
|
+
}
|
|
817
|
+
this.append(container);
|
|
818
|
+
return container;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Create and append a control-injected TRAILING numeric adornment: steppers (Phosphor caret-up/caret-down)
|
|
822
|
+
* with an optional suffix span. Used for ALL numeric types (number · currency · unit · percent — ADR-0047: codec ≠ null
|
|
823
|
+
* implies steppers). `suffixText` is non-null for unit (the localized label) and percent ('%').
|
|
824
|
+
* The trailing container's data-role is 'numeric' when a suffix is present, 'stepper' otherwise —
|
|
825
|
+
* the two roles drive different CSS layout (row vs. column; inline-size auto vs. icon-sized).
|
|
826
|
+
*/
|
|
827
|
+
#createNumericAdornment(suffixText, typeAc) {
|
|
828
|
+
const container = document.createElement('span');
|
|
829
|
+
container.setAttribute('slot', 'trailing');
|
|
830
|
+
container.setAttribute('data-part', 'trailing-adornment');
|
|
831
|
+
// 'numeric' has suffix+steppers in a flex row; 'stepper' has steppers only in an icon-sized column.
|
|
832
|
+
container.setAttribute('data-role', suffixText !== null ? 'numeric' : 'stepper');
|
|
833
|
+
if (suffixText !== null) {
|
|
834
|
+
const suffix = document.createElement('span');
|
|
835
|
+
suffix.setAttribute('data-part', 'suffix');
|
|
836
|
+
suffix.setAttribute('aria-hidden', 'true'); // decorative label — the editor carries the value
|
|
837
|
+
suffix.textContent = suffixText;
|
|
838
|
+
container.append(suffix);
|
|
839
|
+
}
|
|
840
|
+
const up = document.createElement('button');
|
|
841
|
+
up.type = 'button';
|
|
842
|
+
up.setAttribute('data-part', 'step-up');
|
|
843
|
+
up.setAttribute('aria-label', 'Increase');
|
|
844
|
+
setIcon(up, 'caret-up'); // Phosphor, via @agent-ui-kit/icons
|
|
845
|
+
up.addEventListener('click', () => { this.#step(1); }, { signal: typeAc });
|
|
846
|
+
const down = document.createElement('button');
|
|
847
|
+
down.type = 'button';
|
|
848
|
+
down.setAttribute('data-part', 'step-down');
|
|
849
|
+
down.setAttribute('aria-label', 'Decrease');
|
|
850
|
+
setIcon(down, 'caret-down'); // Phosphor, via @agent-ui-kit/icons
|
|
851
|
+
down.addEventListener('click', () => { this.#step(-1); }, { signal: typeAc });
|
|
852
|
+
container.append(up, down);
|
|
853
|
+
this.append(container);
|
|
854
|
+
return container;
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Step the numeric value by `dir * step` (clamped to [min, max]). Called by stepper buttons AND by
|
|
858
|
+
* ArrowUp/Down on the editor (native type=number parity). `this.step ?? 1` guards against a null step
|
|
859
|
+
* (cleared attribute). Empty min/max = unbounded (native .min/.max parity — ADR-0047).
|
|
860
|
+
*/
|
|
861
|
+
#step(dir) {
|
|
862
|
+
// Prefer codec canonical (the last successfully parsed value); fall back to this.value for cases
|
|
863
|
+
// where the user hasn't blurred yet (canonical is still the initial empty seed). Use || not ??
|
|
864
|
+
// so an empty-string canonical (never blurred) falls through to this.value.
|
|
865
|
+
const current = parseFloat(this.#codec?.canonical.value || this.value);
|
|
866
|
+
const stepSize = this.step ?? 1;
|
|
867
|
+
const raw = Number.isFinite(current) ? current + dir * stepSize : dir * stepSize;
|
|
868
|
+
// Clamp to [min, max]: '' = unconstrained; invalid parse → treat as unbounded.
|
|
869
|
+
const minNum = this.min !== '' ? parseFloat(this.min) : -Infinity;
|
|
870
|
+
const maxNum = this.max !== '' ? parseFloat(this.max) : Infinity;
|
|
871
|
+
const clamped = Math.max(Number.isFinite(minNum) ? minNum : -Infinity, Math.min(Number.isFinite(maxNum) ? maxNum : Infinity, raw));
|
|
872
|
+
const nextStr = String(clamped);
|
|
873
|
+
this.value = nextStr; // raw numeric string (will format on next blur via the codec)
|
|
874
|
+
this.#codec?.setCanonical(nextStr); // keep canonical in sync immediately
|
|
875
|
+
this.emit('input');
|
|
876
|
+
this.emit('change');
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Create the editor PART + the aria message node ONCE (idempotent across reconnect — both are light-DOM
|
|
880
|
+
* children that persist through disconnect), returning the editor. The message node is NOT a public part (the
|
|
881
|
+
* descriptor lists only `editor` under `parts`; it is the control-managed `aria-describedby` node, ADR-0014
|
|
882
|
+
* cl.4) — it is `hidden` (out of the host-as-grid + visual layout in G6; the VISIBLE error wrapper is ui-field
|
|
883
|
+
* at G7 — aria-describedby still reads a hidden node's text), carrying `validity().message` only under
|
|
884
|
+
* `user-invalid`.
|
|
885
|
+
*/
|
|
886
|
+
#ensureParts() {
|
|
887
|
+
if (this.#editor)
|
|
888
|
+
return this.#editor;
|
|
889
|
+
const editor = document.createElement('div');
|
|
890
|
+
editor.setAttribute('data-part', 'editor');
|
|
891
|
+
editor.setAttribute('contenteditable', EDITABLE);
|
|
892
|
+
editor.setAttribute('role', 'textbox'); // the role rides the PART — the host carries NO role/aria-* attribute
|
|
893
|
+
editor.setAttribute('aria-multiline', 'false');
|
|
894
|
+
this.#editor = editor;
|
|
895
|
+
const message = document.createElement('div');
|
|
896
|
+
message.className = 'ui-text-field-message'; // a queryable hook, NOT a [data-part] (it is not a public part)
|
|
897
|
+
message.id = `ui-text-field-message-${++messageSeq}`;
|
|
898
|
+
message.hidden = true;
|
|
899
|
+
this.#message = message;
|
|
900
|
+
this.append(editor, message);
|
|
901
|
+
return editor;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
if (!customElements.get('ui-text-field'))
|
|
905
|
+
customElements.define('ui-text-field', UITextFieldElement);
|