@agent-ui-kit/components 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/dist/base-styles.css +8 -0
  2. package/dist/component-styles.css +147 -0
  3. package/dist/controls/_base/index.d.ts +3 -0
  4. package/dist/controls/_base/index.js +9 -0
  5. package/dist/controls/_base/indicator-element.d.ts +132 -0
  6. package/dist/controls/_base/indicator-element.js +158 -0
  7. package/dist/controls/_base/listbox-element.d.ts +70 -0
  8. package/dist/controls/_base/listbox-element.js +74 -0
  9. package/dist/controls/_base/range-element.d.ts +120 -0
  10. package/dist/controls/_base/range-element.js +158 -0
  11. package/dist/controls/_surface/container-box.css +301 -0
  12. package/dist/controls/_surface/container.css +156 -0
  13. package/dist/controls/_token-surface/token-surface.d.ts +35 -0
  14. package/dist/controls/_token-surface/token-surface.js +97 -0
  15. package/dist/controls/attachment/attachment-meta.d.ts +8 -0
  16. package/dist/controls/attachment/attachment-meta.js +84 -0
  17. package/dist/controls/attachment/attachment.css +91 -0
  18. package/dist/controls/attachment/attachment.d.ts +40 -0
  19. package/dist/controls/attachment/attachment.js +70 -0
  20. package/dist/controls/avatar/avatar-initials.d.ts +4 -0
  21. package/dist/controls/avatar/avatar-initials.js +30 -0
  22. package/dist/controls/avatar/avatar.css +87 -0
  23. package/dist/controls/avatar/avatar.d.ts +40 -0
  24. package/dist/controls/avatar/avatar.js +77 -0
  25. package/dist/controls/badge/badge.css +175 -0
  26. package/dist/controls/badge/badge.d.ts +36 -0
  27. package/dist/controls/badge/badge.js +71 -0
  28. package/dist/controls/bar-chart/bar-chart.css +110 -0
  29. package/dist/controls/bar-chart/bar-chart.d.ts +26 -0
  30. package/dist/controls/bar-chart/bar-chart.js +80 -0
  31. package/dist/controls/bar-chart/bar-math.d.ts +31 -0
  32. package/dist/controls/bar-chart/bar-math.js +106 -0
  33. package/dist/controls/button/button.css +263 -0
  34. package/dist/controls/button/button.d.ts +61 -0
  35. package/dist/controls/button/button.js +115 -0
  36. package/dist/controls/calendar/calendar.css +495 -0
  37. package/dist/controls/calendar/calendar.d.ts +161 -0
  38. package/dist/controls/calendar/calendar.js +1004 -0
  39. package/dist/controls/card/card-content.d.ts +24 -0
  40. package/dist/controls/card/card-content.js +133 -0
  41. package/dist/controls/card/card-footer.d.ts +3 -0
  42. package/dist/controls/card/card-footer.js +16 -0
  43. package/dist/controls/card/card-header.d.ts +3 -0
  44. package/dist/controls/card/card-header.js +18 -0
  45. package/dist/controls/card/card.css +445 -0
  46. package/dist/controls/card/card.d.ts +51 -0
  47. package/dist/controls/card/card.js +61 -0
  48. package/dist/controls/checkbox/checkbox.css +204 -0
  49. package/dist/controls/checkbox/checkbox.d.ts +114 -0
  50. package/dist/controls/checkbox/checkbox.js +130 -0
  51. package/dist/controls/code/code.css +72 -0
  52. package/dist/controls/code/code.d.ts +23 -0
  53. package/dist/controls/code/code.js +40 -0
  54. package/dist/controls/color-picker/color-picker.css +209 -0
  55. package/dist/controls/color-picker/color-picker.d.ts +84 -0
  56. package/dist/controls/color-picker/color-picker.js +545 -0
  57. package/dist/controls/color-picker/color.d.ts +40 -0
  58. package/dist/controls/color-picker/color.js +159 -0
  59. package/dist/controls/column/column.css +151 -0
  60. package/dist/controls/column/column.d.ts +107 -0
  61. package/dist/controls/column/column.js +57 -0
  62. package/dist/controls/combo-box/combo-box.css +397 -0
  63. package/dist/controls/combo-box/combo-box.d.ts +133 -0
  64. package/dist/controls/combo-box/combo-box.js +680 -0
  65. package/dist/controls/command-modal/command-modal.css +226 -0
  66. package/dist/controls/command-modal/command-modal.d.ts +42 -0
  67. package/dist/controls/command-modal/command-modal.js +315 -0
  68. package/dist/controls/disclosure/disclosure.css +162 -0
  69. package/dist/controls/disclosure/disclosure.d.ts +37 -0
  70. package/dist/controls/disclosure/disclosure.js +207 -0
  71. package/dist/controls/field/field.css +140 -0
  72. package/dist/controls/field/field.d.ts +27 -0
  73. package/dist/controls/field/field.js +228 -0
  74. package/dist/controls/form-provider/form-provider.css +40 -0
  75. package/dist/controls/form-provider/form-provider.d.ts +42 -0
  76. package/dist/controls/form-provider/form-provider.js +105 -0
  77. package/dist/controls/grid/grid.css +72 -0
  78. package/dist/controls/grid/grid.d.ts +60 -0
  79. package/dist/controls/grid/grid.js +56 -0
  80. package/dist/controls/icon/icon.css +37 -0
  81. package/dist/controls/icon/icon.d.ts +25 -0
  82. package/dist/controls/icon/icon.js +55 -0
  83. package/dist/controls/index.d.ts +57 -0
  84. package/dist/controls/index.js +136 -0
  85. package/dist/controls/ladder/ladder.css +89 -0
  86. package/dist/controls/ladder/ladder.d.ts +27 -0
  87. package/dist/controls/ladder/ladder.js +78 -0
  88. package/dist/controls/list/list.css +118 -0
  89. package/dist/controls/list/list.d.ts +84 -0
  90. package/dist/controls/list/list.js +48 -0
  91. package/dist/controls/menu/menu.css +195 -0
  92. package/dist/controls/menu/menu.d.ts +44 -0
  93. package/dist/controls/menu/menu.js +248 -0
  94. package/dist/controls/modal/modal.css +99 -0
  95. package/dist/controls/modal/modal.d.ts +61 -0
  96. package/dist/controls/modal/modal.js +155 -0
  97. package/dist/controls/popover/popover.css +83 -0
  98. package/dist/controls/popover/popover.d.ts +44 -0
  99. package/dist/controls/popover/popover.js +164 -0
  100. package/dist/controls/progress/progress.css +106 -0
  101. package/dist/controls/progress/progress.d.ts +28 -0
  102. package/dist/controls/progress/progress.js +80 -0
  103. package/dist/controls/radio/radio-group.css +84 -0
  104. package/dist/controls/radio/radio-group.d.ts +152 -0
  105. package/dist/controls/radio/radio-group.js +350 -0
  106. package/dist/controls/radio/radio.css +219 -0
  107. package/dist/controls/radio/radio.d.ts +19 -0
  108. package/dist/controls/radio/radio.js +75 -0
  109. package/dist/controls/ramp/ramp.css +75 -0
  110. package/dist/controls/ramp/ramp.d.ts +29 -0
  111. package/dist/controls/ramp/ramp.js +72 -0
  112. package/dist/controls/row/row.css +152 -0
  113. package/dist/controls/row/row.d.ts +95 -0
  114. package/dist/controls/row/row.js +45 -0
  115. package/dist/controls/segment/segment.css +66 -0
  116. package/dist/controls/segment/segment.d.ts +3 -0
  117. package/dist/controls/segment/segment.js +26 -0
  118. package/dist/controls/segmented-control/segmented-control.css +189 -0
  119. package/dist/controls/segmented-control/segmented-control.d.ts +20 -0
  120. package/dist/controls/segmented-control/segmented-control.js +64 -0
  121. package/dist/controls/select/select.css +467 -0
  122. package/dist/controls/select/select.d.ts +147 -0
  123. package/dist/controls/select/select.js +538 -0
  124. package/dist/controls/slider/slider.css +231 -0
  125. package/dist/controls/slider/slider.d.ts +111 -0
  126. package/dist/controls/slider/slider.js +60 -0
  127. package/dist/controls/slider-multi/slider-multi.css +197 -0
  128. package/dist/controls/slider-multi/slider-multi.d.ts +149 -0
  129. package/dist/controls/slider-multi/slider-multi.js +380 -0
  130. package/dist/controls/sparkline/sparkline-math.d.ts +23 -0
  131. package/dist/controls/sparkline/sparkline-math.js +79 -0
  132. package/dist/controls/sparkline/sparkline.css +61 -0
  133. package/dist/controls/sparkline/sparkline.d.ts +27 -0
  134. package/dist/controls/sparkline/sparkline.js +74 -0
  135. package/dist/controls/split/constrain.d.ts +44 -0
  136. package/dist/controls/split/constrain.js +126 -0
  137. package/dist/controls/split/split-pane.css +63 -0
  138. package/dist/controls/split/split-pane.d.ts +49 -0
  139. package/dist/controls/split/split-pane.js +42 -0
  140. package/dist/controls/split/split.css +127 -0
  141. package/dist/controls/split/split.d.ts +42 -0
  142. package/dist/controls/split/split.js +364 -0
  143. package/dist/controls/stat/stat-model.d.ts +22 -0
  144. package/dist/controls/stat/stat-model.js +69 -0
  145. package/dist/controls/stat/stat.css +101 -0
  146. package/dist/controls/stat/stat.d.ts +30 -0
  147. package/dist/controls/stat/stat.js +73 -0
  148. package/dist/controls/status-stream/status-stream.css +151 -0
  149. package/dist/controls/status-stream/status-stream.d.ts +108 -0
  150. package/dist/controls/status-stream/status-stream.js +417 -0
  151. package/dist/controls/swatch/swatch.css +66 -0
  152. package/dist/controls/swatch/swatch.d.ts +28 -0
  153. package/dist/controls/swatch/swatch.js +74 -0
  154. package/dist/controls/swiper/swiper-item.css +17 -0
  155. package/dist/controls/swiper/swiper-item.d.ts +31 -0
  156. package/dist/controls/swiper/swiper-item.js +47 -0
  157. package/dist/controls/swiper/swiper-label.css +10 -0
  158. package/dist/controls/swiper/swiper-label.d.ts +4 -0
  159. package/dist/controls/swiper/swiper-label.js +20 -0
  160. package/dist/controls/swiper/swiper-paddles.css +26 -0
  161. package/dist/controls/swiper/swiper-paddles.d.ts +10 -0
  162. package/dist/controls/swiper/swiper-paddles.js +62 -0
  163. package/dist/controls/swiper/swiper-pagination.css +60 -0
  164. package/dist/controls/swiper/swiper-pagination.d.ts +31 -0
  165. package/dist/controls/swiper/swiper-pagination.js +78 -0
  166. package/dist/controls/swiper/swiper.css +153 -0
  167. package/dist/controls/swiper/swiper.d.ts +158 -0
  168. package/dist/controls/swiper/swiper.js +659 -0
  169. package/dist/controls/switch/switch.css +194 -0
  170. package/dist/controls/switch/switch.d.ts +5 -0
  171. package/dist/controls/switch/switch.js +18 -0
  172. package/dist/controls/table/table-model.d.ts +43 -0
  173. package/dist/controls/table/table-model.js +124 -0
  174. package/dist/controls/table/table.css +90 -0
  175. package/dist/controls/table/table.d.ts +29 -0
  176. package/dist/controls/table/table.js +178 -0
  177. package/dist/controls/tabs/tab-panel.d.ts +10 -0
  178. package/dist/controls/tabs/tab-panel.js +89 -0
  179. package/dist/controls/tabs/tab.d.ts +26 -0
  180. package/dist/controls/tabs/tab.js +67 -0
  181. package/dist/controls/tabs/tabs.css +181 -0
  182. package/dist/controls/tabs/tabs.d.ts +61 -0
  183. package/dist/controls/tabs/tabs.js +176 -0
  184. package/dist/controls/text/href.d.ts +26 -0
  185. package/dist/controls/text/href.js +48 -0
  186. package/dist/controls/text/text.css +328 -0
  187. package/dist/controls/text/text.d.ts +85 -0
  188. package/dist/controls/text/text.js +273 -0
  189. package/dist/controls/text-field/text-field.css +498 -0
  190. package/dist/controls/text-field/text-field.d.ts +226 -0
  191. package/dist/controls/text-field/text-field.js +905 -0
  192. package/dist/controls/textarea/textarea.css +251 -0
  193. package/dist/controls/textarea/textarea.d.ts +136 -0
  194. package/dist/controls/textarea/textarea.js +312 -0
  195. package/dist/controls/theme-provider/theme-provider.css +48 -0
  196. package/dist/controls/theme-provider/theme-provider.d.ts +59 -0
  197. package/dist/controls/theme-provider/theme-provider.js +40 -0
  198. package/dist/controls/timeline/timeline.css +33 -0
  199. package/dist/controls/timeline/timeline.d.ts +39 -0
  200. package/dist/controls/timeline/timeline.js +52 -0
  201. package/dist/controls/timeline-item/timeline-item.css +495 -0
  202. package/dist/controls/timeline-item/timeline-item.d.ts +79 -0
  203. package/dist/controls/timeline-item/timeline-item.js +360 -0
  204. package/dist/controls/toast/toast-region.css +56 -0
  205. package/dist/controls/toast/toast-region.d.ts +20 -0
  206. package/dist/controls/toast/toast-region.js +113 -0
  207. package/dist/controls/toast/toast.css +96 -0
  208. package/dist/controls/toast/toast.d.ts +28 -0
  209. package/dist/controls/toast/toast.js +191 -0
  210. package/dist/controls/toolbar/toolbar.css +125 -0
  211. package/dist/controls/toolbar/toolbar.d.ts +108 -0
  212. package/dist/controls/toolbar/toolbar.js +68 -0
  213. package/dist/controls/tooltip/tooltip.css +80 -0
  214. package/dist/controls/tooltip/tooltip.d.ts +57 -0
  215. package/dist/controls/tooltip/tooltip.js +205 -0
  216. package/dist/descriptor/component-descriptor.d.ts +112 -0
  217. package/dist/descriptor/component-descriptor.js +492 -0
  218. package/dist/descriptor/index.d.ts +1 -0
  219. package/dist/descriptor/index.js +5 -0
  220. package/dist/dom/container.d.ts +45 -0
  221. package/dist/dom/container.js +60 -0
  222. package/dist/dom/element.d.ts +98 -0
  223. package/dist/dom/element.js +221 -0
  224. package/dist/dom/form.d.ts +240 -0
  225. package/dist/dom/form.js +399 -0
  226. package/dist/dom/index.d.ts +10 -0
  227. package/dist/dom/index.js +31 -0
  228. package/dist/dom/props.d.ts +56 -0
  229. package/dist/dom/props.js +231 -0
  230. package/dist/dom/repeat.d.ts +10 -0
  231. package/dist/dom/repeat.js +202 -0
  232. package/dist/dom/template.d.ts +161 -0
  233. package/dist/dom/template.js +634 -0
  234. package/dist/dom/watch.d.ts +11 -0
  235. package/dist/dom/watch.js +82 -0
  236. package/dist/foundation-styles.css +9 -0
  237. package/dist/index.d.ts +2 -0
  238. package/dist/index.js +3 -0
  239. package/dist/reactive/graph.d.ts +65 -0
  240. package/dist/reactive/graph.js +310 -0
  241. package/dist/reactive/index.d.ts +3 -0
  242. package/dist/reactive/index.js +4 -0
  243. package/dist/reactive/scheduler.d.ts +13 -0
  244. package/dist/reactive/scheduler.js +49 -0
  245. package/dist/traits/area-drag.d.ts +12 -0
  246. package/dist/traits/area-drag.js +70 -0
  247. package/dist/traits/form-registry.d.ts +36 -0
  248. package/dist/traits/form-registry.js +108 -0
  249. package/dist/traits/index.d.ts +24 -0
  250. package/dist/traits/index.js +19 -0
  251. package/dist/traits/overlay.d.ts +50 -0
  252. package/dist/traits/overlay.js +277 -0
  253. package/dist/traits/pane-resize.d.ts +26 -0
  254. package/dist/traits/pane-resize.js +103 -0
  255. package/dist/traits/press-activation.d.ts +11 -0
  256. package/dist/traits/press-activation.js +55 -0
  257. package/dist/traits/roving-focus.d.ts +52 -0
  258. package/dist/traits/roving-focus.js +214 -0
  259. package/dist/traits/scroll-fade.d.ts +28 -0
  260. package/dist/traits/scroll-fade.js +120 -0
  261. package/dist/traits/selection-commit.d.ts +30 -0
  262. package/dist/traits/selection-commit.js +146 -0
  263. package/dist/traits/tabbable.d.ts +12 -0
  264. package/dist/traits/tabbable.js +59 -0
  265. package/dist/traits/track-user-invalid.d.ts +36 -0
  266. package/dist/traits/track-user-invalid.js +52 -0
  267. package/dist/traits/value-codec.d.ts +120 -0
  268. package/dist/traits/value-codec.js +497 -0
  269. package/dist/traits/value-drag.d.ts +18 -0
  270. package/dist/traits/value-drag.js +84 -0
  271. package/package.json +263 -0
@@ -0,0 +1,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);