@adia-ai/web-components 0.4.5 → 0.4.7

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 (316) hide show
  1. package/README.md +63 -24
  2. package/USAGE.md +604 -0
  3. package/components/accordion/accordion.d.ts +17 -0
  4. package/components/accordion/accordion.js +10 -117
  5. package/components/accordion/class.js +132 -0
  6. package/components/action-list/action-list.d.ts +15 -0
  7. package/components/action-list/action-list.js +9 -140
  8. package/components/action-list/class.js +156 -0
  9. package/components/agent-artifact/agent-artifact.d.ts +25 -0
  10. package/components/agent-artifact/agent-artifact.js +8 -181
  11. package/components/agent-artifact/class.js +200 -0
  12. package/components/agent-feedback-bar/agent-feedback-bar.d.ts +21 -0
  13. package/components/agent-feedback-bar/agent-feedback-bar.js +8 -143
  14. package/components/agent-feedback-bar/class.js +162 -0
  15. package/components/agent-questions/agent-questions.d.ts +23 -0
  16. package/components/agent-questions/agent-questions.js +8 -180
  17. package/components/agent-questions/class.js +199 -0
  18. package/components/agent-reasoning/agent-reasoning.d.ts +23 -0
  19. package/components/agent-reasoning/agent-reasoning.js +8 -494
  20. package/components/agent-reasoning/class.js +513 -0
  21. package/components/agent-suggestions/agent-suggestions.d.ts +21 -0
  22. package/components/agent-suggestions/agent-suggestions.js +8 -78
  23. package/components/agent-suggestions/class.js +97 -0
  24. package/components/agent-trace/agent-trace.d.ts +19 -0
  25. package/components/alert/alert.d.ts +29 -0
  26. package/components/alert/alert.js +8 -175
  27. package/components/alert/class.js +194 -0
  28. package/components/avatar/avatar.d.ts +27 -0
  29. package/components/avatar/avatar.js +9 -159
  30. package/components/avatar/class.js +173 -0
  31. package/components/badge/badge.d.ts +27 -0
  32. package/components/badge/badge.js +9 -75
  33. package/components/badge/class.js +93 -0
  34. package/components/block/block.d.ts +19 -0
  35. package/components/block/block.js +9 -15
  36. package/components/block/class.js +33 -0
  37. package/components/breadcrumb/breadcrumb.d.ts +23 -0
  38. package/components/breadcrumb/breadcrumb.js +8 -113
  39. package/components/breadcrumb/class.js +132 -0
  40. package/components/button/button.d.ts +34 -0
  41. package/components/button/button.js +15 -66
  42. package/components/button/class.js +80 -0
  43. package/components/calendar-picker/calendar-picker.a2ui.json +6 -1
  44. package/components/calendar-picker/calendar-picker.d.ts +27 -0
  45. package/components/calendar-picker/calendar-picker.js +8 -332
  46. package/components/calendar-picker/calendar-picker.yaml +51 -177
  47. package/components/calendar-picker/class.js +351 -0
  48. package/components/canvas/canvas.a2ui.json +6 -1
  49. package/components/canvas/canvas.d.ts +17 -0
  50. package/components/canvas/canvas.yaml +19 -36
  51. package/components/card/card.a2ui.json +3 -0
  52. package/components/card/card.d.ts +27 -0
  53. package/components/card/card.js +9 -50
  54. package/components/card/card.yaml +171 -433
  55. package/components/card/class.js +68 -0
  56. package/components/chart/chart.d.ts +41 -0
  57. package/components/chart/chart.js +8 -2131
  58. package/components/chart/class.js +2150 -0
  59. package/components/chart-legend/chart-legend.d.ts +27 -0
  60. package/components/chart-legend/chart-legend.js +8 -197
  61. package/components/chart-legend/class.js +215 -0
  62. package/components/chat-thread/chat-thread.d.ts +17 -0
  63. package/components/chat-thread/chat-thread.js +8 -157
  64. package/components/chat-thread/class.js +176 -0
  65. package/components/check/check.d.ts +30 -0
  66. package/components/check/check.js +11 -52
  67. package/components/check/class.js +68 -0
  68. package/components/code/class.js +501 -0
  69. package/components/code/code.d.ts +39 -0
  70. package/components/code/code.js +8 -482
  71. package/components/col/class.js +30 -0
  72. package/components/col/col.d.ts +23 -0
  73. package/components/col/col.js +10 -13
  74. package/components/color-picker/class.js +550 -0
  75. package/components/color-picker/color-picker.d.ts +37 -0
  76. package/components/color-picker/color-picker.js +8 -531
  77. package/components/command/class.js +364 -0
  78. package/components/command/command.a2ui.json +3 -0
  79. package/components/command/command.d.ts +19 -0
  80. package/components/command/command.js +8 -345
  81. package/components/command/command.yaml +105 -124
  82. package/components/demo-toggle/class.js +153 -0
  83. package/components/demo-toggle/demo-toggle.d.ts +23 -0
  84. package/components/demo-toggle/demo-toggle.js +8 -135
  85. package/components/description-list/class.js +86 -0
  86. package/components/description-list/description-list.d.ts +21 -0
  87. package/components/description-list/description-list.js +8 -67
  88. package/components/divider/class.js +57 -0
  89. package/components/divider/divider.d.ts +19 -0
  90. package/components/divider/divider.js +10 -40
  91. package/components/drawer/class.js +306 -0
  92. package/components/drawer/drawer.d.ts +25 -0
  93. package/components/drawer/drawer.js +8 -287
  94. package/components/embed/class.js +73 -0
  95. package/components/embed/embed.d.ts +23 -0
  96. package/components/embed/embed.js +9 -55
  97. package/components/empty-state/class.js +108 -0
  98. package/components/empty-state/empty-state.d.ts +21 -0
  99. package/components/empty-state/empty-state.js +9 -90
  100. package/components/feed/class.js +381 -0
  101. package/components/feed/feed.d.ts +19 -0
  102. package/components/feed/feed.js +9 -367
  103. package/components/field/class.js +266 -0
  104. package/components/field/field.d.ts +23 -0
  105. package/components/field/field.js +8 -247
  106. package/components/fields/class.js +106 -0
  107. package/components/fields/fields.d.ts +19 -0
  108. package/components/fields/fields.js +8 -87
  109. package/components/grid/class.js +31 -0
  110. package/components/grid/grid.d.ts +23 -0
  111. package/components/grid/grid.js +10 -14
  112. package/components/heatmap/class.js +305 -0
  113. package/components/heatmap/heatmap.d.ts +31 -0
  114. package/components/heatmap/heatmap.js +8 -286
  115. package/components/icon/class.js +54 -0
  116. package/components/icon/icon.d.ts +23 -0
  117. package/components/icon/icon.js +13 -40
  118. package/components/image/class.js +112 -0
  119. package/components/image/image.d.ts +33 -0
  120. package/components/image/image.js +9 -94
  121. package/components/index.js +1 -0
  122. package/components/input/class.js +773 -0
  123. package/components/input/input.a2ui.json +3 -0
  124. package/components/input/input.d.ts +61 -0
  125. package/components/input/input.js +8 -755
  126. package/components/input/input.yaml +171 -442
  127. package/components/inspector/class.js +142 -0
  128. package/components/inspector/inspector.a2ui.json +8 -1
  129. package/components/inspector/inspector.d.ts +17 -0
  130. package/components/inspector/inspector.js +8 -124
  131. package/components/inspector/inspector.yaml +15 -30
  132. package/components/kbd/class.js +34 -0
  133. package/components/kbd/kbd.a2ui.json +3 -0
  134. package/components/kbd/kbd.d.ts +17 -0
  135. package/components/kbd/kbd.js +10 -17
  136. package/components/kbd/kbd.yaml +54 -185
  137. package/components/link/class.js +187 -0
  138. package/components/link/link.d.ts +55 -0
  139. package/components/link/link.js +8 -168
  140. package/components/list/class.js +249 -0
  141. package/components/list/list.d.ts +23 -0
  142. package/components/list/list.js +9 -231
  143. package/components/menu/class.js +332 -0
  144. package/components/menu/menu.d.ts +21 -0
  145. package/components/menu/menu.js +11 -316
  146. package/components/modal/class.js +231 -0
  147. package/components/modal/modal.a2ui.json +5 -1
  148. package/components/modal/modal.d.ts +23 -0
  149. package/components/modal/modal.js +8 -212
  150. package/components/modal/modal.yaml +19 -39
  151. package/components/nav/class.js +150 -0
  152. package/components/nav/nav.d.ts +31 -0
  153. package/components/nav/nav.js +8 -131
  154. package/components/nav-group/class.js +152 -0
  155. package/components/nav-group/nav-group.d.ts +35 -0
  156. package/components/nav-group/nav-group.js +9 -134
  157. package/components/nav-item/class.js +86 -0
  158. package/components/nav-item/nav-item.d.ts +37 -0
  159. package/components/nav-item/nav-item.js +10 -69
  160. package/components/noodles/class.js +510 -0
  161. package/components/noodles/noodles.d.ts +33 -0
  162. package/components/noodles/noodles.js +9 -493
  163. package/components/option-card/class.js +167 -0
  164. package/components/option-card/option-card.d.ts +30 -0
  165. package/components/option-card/option-card.js +8 -149
  166. package/components/otp-input/class.js +180 -0
  167. package/components/otp-input/otp-input.a2ui.json +5 -1
  168. package/components/otp-input/otp-input.d.ts +25 -0
  169. package/components/otp-input/otp-input.js +9 -162
  170. package/components/otp-input/otp-input.yaml +45 -174
  171. package/components/page/class.js +97 -0
  172. package/components/page/page.d.ts +46 -0
  173. package/components/page/page.js +8 -79
  174. package/components/pagination/class.js +195 -0
  175. package/components/pagination/pagination.d.ts +23 -0
  176. package/components/pagination/pagination.js +9 -177
  177. package/components/pane/class.js +186 -0
  178. package/components/pane/pane.a2ui.json +12 -1
  179. package/components/pane/pane.css +10 -0
  180. package/components/pane/pane.d.ts +31 -0
  181. package/components/pane/pane.js +8 -143
  182. package/components/pane/pane.yaml +57 -157
  183. package/components/pipeline-status/class.js +189 -0
  184. package/components/pipeline-status/pipeline-status.a2ui.json +7 -1
  185. package/components/pipeline-status/pipeline-status.d.ts +21 -0
  186. package/components/pipeline-status/pipeline-status.js +9 -172
  187. package/components/pipeline-status/pipeline-status.yaml +34 -72
  188. package/components/popover/class.js +194 -0
  189. package/components/popover/popover.d.ts +23 -0
  190. package/components/popover/popover.js +9 -176
  191. package/components/progress/class.js +74 -0
  192. package/components/progress/progress.a2ui.json +3 -0
  193. package/components/progress/progress.d.ts +19 -0
  194. package/components/progress/progress.js +10 -57
  195. package/components/progress/progress.yaml +124 -287
  196. package/components/progress-row/class.js +110 -0
  197. package/components/progress-row/progress-row.d.ts +23 -0
  198. package/components/progress-row/progress-row.js +8 -92
  199. package/components/radio/class.js +83 -0
  200. package/components/radio/radio.d.ts +28 -0
  201. package/components/radio/radio.js +11 -67
  202. package/components/range/class.js +194 -0
  203. package/components/range/range.d.ts +31 -0
  204. package/components/range/range.js +9 -176
  205. package/components/rating/class.js +148 -0
  206. package/components/rating/rating.d.ts +33 -0
  207. package/components/rating/rating.js +9 -130
  208. package/components/richtext/class.js +87 -0
  209. package/components/richtext/richtext.a2ui.json +7 -1
  210. package/components/richtext/richtext.d.ts +19 -0
  211. package/components/richtext/richtext.js +8 -68
  212. package/components/richtext/richtext.yaml +30 -65
  213. package/components/row/class.js +50 -0
  214. package/components/row/row.d.ts +27 -0
  215. package/components/row/row.js +10 -33
  216. package/components/search/class.js +134 -0
  217. package/components/search/search.d.ts +35 -0
  218. package/components/search/search.js +10 -117
  219. package/components/segment/class.js +62 -0
  220. package/components/segment/segment.d.ts +25 -0
  221. package/components/segment/segment.js +10 -45
  222. package/components/segmented/class.js +165 -0
  223. package/components/segmented/segmented.a2ui.json +4 -0
  224. package/components/segmented/segmented.d.ts +24 -0
  225. package/components/segmented/segmented.js +10 -148
  226. package/components/segmented/segmented.yaml +41 -59
  227. package/components/select/class.js +408 -0
  228. package/components/select/select.d.ts +57 -0
  229. package/components/select/select.js +15 -396
  230. package/components/skeleton/class.js +52 -0
  231. package/components/skeleton/skeleton.d.ts +23 -0
  232. package/components/skeleton/skeleton.js +8 -34
  233. package/components/slider/class.js +184 -0
  234. package/components/slider/slider.d.ts +31 -0
  235. package/components/slider/slider.js +9 -166
  236. package/components/stack/class.js +28 -0
  237. package/components/stack/stack.d.ts +17 -0
  238. package/components/stack/stack.js +10 -11
  239. package/components/step-progress/class.js +98 -0
  240. package/components/step-progress/step-progress.d.ts +27 -0
  241. package/components/step-progress/step-progress.js +8 -79
  242. package/components/stepper/class.js +126 -0
  243. package/components/stepper/stepper.d.ts +19 -0
  244. package/components/stepper/stepper.js +9 -112
  245. package/components/stream/class.js +109 -0
  246. package/components/stream/stream.d.ts +19 -0
  247. package/components/stream/stream.js +8 -90
  248. package/components/swatch/class.js +131 -0
  249. package/components/swatch/swatch.d.ts +28 -0
  250. package/components/swatch/swatch.js +8 -112
  251. package/components/swiper/class.js +373 -0
  252. package/components/swiper/swiper.a2ui.json +4 -0
  253. package/components/swiper/swiper.d.ts +31 -0
  254. package/components/swiper/swiper.js +8 -354
  255. package/components/swiper/swiper.yaml +68 -212
  256. package/components/switch/class.js +63 -0
  257. package/components/switch/switch.a2ui.json +6 -1
  258. package/components/switch/switch.d.ts +30 -0
  259. package/components/switch/switch.js +11 -47
  260. package/components/switch/switch.yaml +70 -265
  261. package/components/table/class.js +1453 -0
  262. package/components/table/table.d.ts +37 -0
  263. package/components/table/table.js +8 -1435
  264. package/components/table-toolbar/class.js +680 -0
  265. package/components/table-toolbar/table-toolbar.d.ts +33 -0
  266. package/components/table-toolbar/table-toolbar.js +8 -689
  267. package/components/tabs/class.js +242 -0
  268. package/components/tabs/tabs.d.ts +21 -0
  269. package/components/tabs/tabs.js +8 -223
  270. package/components/tag/class.js +99 -0
  271. package/components/tag/tag.d.ts +27 -0
  272. package/components/tag/tag.js +8 -80
  273. package/components/text/class.js +46 -0
  274. package/components/text/text.d.ts +25 -0
  275. package/components/text/text.js +9 -28
  276. package/components/textarea/class.js +134 -0
  277. package/components/textarea/textarea.d.ts +31 -0
  278. package/components/textarea/textarea.js +11 -118
  279. package/components/timeline/class.js +176 -0
  280. package/components/timeline/timeline.d.ts +19 -0
  281. package/components/timeline/timeline.js +9 -162
  282. package/components/toast/class.js +92 -0
  283. package/components/toast/toast.d.ts +23 -0
  284. package/components/toast/toast.js +9 -76
  285. package/components/toggle-group/class.js +154 -0
  286. package/components/toggle-group/toggle-group.d.ts +19 -0
  287. package/components/toggle-group/toggle-group.js +11 -140
  288. package/components/toggle-scheme/class.js +286 -0
  289. package/components/toggle-scheme/toggle-scheme.a2ui.json +197 -0
  290. package/components/toggle-scheme/toggle-scheme.css +20 -0
  291. package/components/toggle-scheme/toggle-scheme.d.ts +41 -0
  292. package/components/toggle-scheme/toggle-scheme.js +17 -0
  293. package/components/toggle-scheme/toggle-scheme.yaml +173 -0
  294. package/components/toolbar/class.js +388 -0
  295. package/components/toolbar/toolbar.d.ts +23 -0
  296. package/components/toolbar/toolbar.js +10 -376
  297. package/components/tooltip/class.js +299 -0
  298. package/components/tooltip/tooltip.d.ts +27 -0
  299. package/components/tooltip/tooltip.js +8 -280
  300. package/components/tree/class.js +245 -0
  301. package/components/tree/tree.d.ts +15 -0
  302. package/components/tree/tree.js +9 -244
  303. package/components/upload/class.js +199 -0
  304. package/components/upload/upload.d.ts +27 -0
  305. package/components/upload/upload.js +11 -183
  306. package/core/element.d.ts +174 -0
  307. package/core/form.d.ts +108 -0
  308. package/core/index.d.ts +11 -0
  309. package/core/index.js +1 -0
  310. package/core/register.d.ts +25 -0
  311. package/core/register.js +58 -0
  312. package/core/signals.d.ts +94 -0
  313. package/core/template.d.ts +70 -0
  314. package/index.d.ts +315 -0
  315. package/package.json +25 -6
  316. package/traits/CATEGORIES.md +1 -1
@@ -0,0 +1,773 @@
1
+ /**
2
+ * Non-side-effect class export for `<input-ui>`.
3
+ *
4
+ * Importing this file gives you the class(es) without auto-registering the tag.
5
+ * Useful for test isolation, subclassing with tag-name override, or selective
6
+ * composition.
7
+ *
8
+ * The auto-register path stays at `@adia-ai/web-components/components/input`
9
+ * (which imports this file + calls `defineIfFree()`).
10
+ *
11
+ * @see ../../USAGE.md#registration--auto-vs-explicit
12
+ */
13
+
14
+ /**
15
+ * <input-ui> — Text input. The host IS the interactive surface.
16
+ * Uses contenteditable for text entry, ElementInternals for form participation.
17
+ *
18
+ * Slots inside [slot="field"]:
19
+ * prefix → label → text → suffix → controls (number mode)
20
+ *
21
+ * <input-ui label="Email" placeholder="you@acme.com"></input-ui>
22
+ * <input-ui label="Email" prefix="user" placeholder="you@acme.com"></input-ui>
23
+ * <input-ui placeholder="Search" prefix="magnifying-glass"></input-ui>
24
+ * <input-ui prefix="@" value="kim"></input-ui>
25
+ *
26
+ * <input-ui type="number" value="42" min="0" max="100" step="1"></input-ui>
27
+ * <input-ui type="number" value="9.99" step="0.01" precision="2" prefix="$"></input-ui>
28
+ *
29
+ * type="number" renders a contenteditable surface + [+]/[-] stepper buttons,
30
+ * filters input to digits / minus / decimal, snaps to step, clamps to min/max,
31
+ * and exposes ARIA spinbutton semantics. No native <input type=number>.
32
+ *
33
+ * type="password" still wraps a native <input> — only path that needs
34
+ * `-webkit-text-security` disc masking, which only works on native inputs.
35
+ *
36
+ * label renders as a dim leading caption inside the chrome (next to the
37
+ * value, sharing the input's border) — for stacked label / hint / error
38
+ * compositions, wrap with field-ui.
39
+ */
40
+
41
+ import { UIFormElement } from '../../core/form.js';
42
+ import { isIconName, whenIconRegistryReady } from '../../core/icons.js';
43
+
44
+ const renderAffix = (v) => isIconName(v)
45
+ ? `<icon-ui name="${v}"></icon-ui>`
46
+ : v;
47
+
48
+ export class UIInput extends UIFormElement {
49
+ // Opt out of UIFormElement's per-control `label` deprecation warning.
50
+ // input-ui's `label` is a first-class API rendering an inline-leading
51
+ // caption inside the chrome with `aria-labelledby` wiring on the
52
+ // editable surface — not the inert above-the-field rendering that
53
+ // motivated the deprecation.
54
+ static labelDeprecated = false;
55
+
56
+ static properties = {
57
+ ...UIFormElement.properties,
58
+ placeholder: { type: String, default: '', reflect: true },
59
+ type: { type: String, default: 'text', reflect: true },
60
+ label: { type: String, default: '', reflect: true },
61
+ prefix: { type: String, default: '', reflect: true },
62
+ suffix: { type: String, default: '', reflect: true },
63
+ raw: { type: Boolean, default: false, reflect: true },
64
+ // ── Number mode ──
65
+ min: { type: Number, default: null, reflect: true },
66
+ max: { type: Number, default: null, reflect: true },
67
+ step: { type: Number, default: 1, reflect: true },
68
+ precision: { type: Number, default: null, reflect: true },
69
+ // BCP-47 locale tag, e.g. "de-DE" / "fr-FR" / "en-IN". Default empty =
70
+ // en-US (`.` decimal separator, no thousands grouping). When set, the
71
+ // input accepts both `.` AND the locale's decimal separator (so en-US-
72
+ // formatted programmatic values still parse), and `#format` uses
73
+ // `Intl.NumberFormat` for display. Internal storage stays in JS-Number
74
+ // canonical form so `.value` round-trips through `Number(v)` unchanged.
75
+ locale: { type: String, default: '', reflect: true },
76
+ };
77
+
78
+ static template = () => null;
79
+
80
+ #textEl = null;
81
+ #labelEl = null;
82
+ #upBtn = null;
83
+ #downBtn = null;
84
+ #valueAtFocus = '';
85
+ #repeatTimer = null;
86
+ #repeatDelayTimer = null;
87
+ #cachedSep = '.';
88
+ #cachedGroup = '';
89
+ #cachedSepFor = null;
90
+ static #labelSeq = 0;
91
+
92
+ // Hold-to-repeat tuning. Initial delay before autorepeat begins, and the
93
+ // interval between repeats. Values match the cadence of the native
94
+ // <input type="number"> spinner behavior in Chromium/Safari.
95
+ static #REPEAT_INITIAL_MS = 400;
96
+ static #REPEAT_INTERVAL_MS = 60;
97
+
98
+ get #isNativePassword() { return this.type === 'password'; }
99
+ get #isNumberMode() { return this.type === 'number'; }
100
+
101
+ /** Parsed numeric value. NaN when empty or unparseable. When `locale` is
102
+ * set, the value may carry the locale's decimal separator (e.g. "1,5" in
103
+ * de-DE); we canonicalize to JS form before `Number(…)`. */
104
+ get valueAsNumber() {
105
+ const raw = String(this.value ?? '').trim();
106
+ if (!raw) return NaN;
107
+ const s = this.#toCanonical(raw);
108
+ if (s === '-' || s === '.' || s === '-.') return NaN;
109
+ const n = Number(s);
110
+ return Number.isFinite(n) ? n : NaN;
111
+ }
112
+ set valueAsNumber(n) {
113
+ if (!Number.isFinite(n)) { this.value = ''; return; }
114
+ this.value = this.#format(n);
115
+ }
116
+
117
+ connected() {
118
+ super.connected();
119
+ this.setAttribute('role', this.#isNumberMode ? 'spinbutton' : 'textbox');
120
+
121
+ if (!this.querySelector('[slot="text"]')) {
122
+ const labelId = this.label ? `input-label-${++UIInput.#labelSeq}` : '';
123
+ this.innerHTML = this.#shellHTML(labelId);
124
+ }
125
+
126
+ this.#textEl = this.querySelector('[slot="text"]');
127
+ this.#labelEl = this.querySelector('[slot="label"]');
128
+ this.#upBtn = this.querySelector('[data-step="up"]');
129
+ this.#downBtn = this.querySelector('[data-step="down"]');
130
+
131
+ if (!this.#isNativePassword && this.value) {
132
+ this.#textEl.textContent = this.#isNumberMode
133
+ ? this.#formatStored(this.value)
134
+ : this.value;
135
+ }
136
+
137
+ if (this.#textEl) {
138
+ this.#textEl.addEventListener('input', this.#onInput);
139
+ this.#textEl.addEventListener('keydown', this.#onKeydown);
140
+ this.#textEl.addEventListener('blur', this.#onBlur);
141
+ this.#textEl.addEventListener('focus', this.#onFocus);
142
+ this.#textEl.addEventListener('paste', this.#onPaste);
143
+ if (this.#isNumberMode) {
144
+ this.#textEl.addEventListener('beforeinput', this.#onBeforeInput);
145
+ }
146
+ }
147
+
148
+ // pointerdown.preventDefault keeps focus on the contenteditable surface
149
+ // when the user pokes a stepper button with a pointing device. Same
150
+ // handler fires the initial step + arms hold-to-repeat; pointerup/leave/
151
+ // cancel on document stops it (the user can drag off the button to
152
+ // abort the repeat without lifting their finger first).
153
+ this.#upBtn?.addEventListener('pointerdown', this.#onStepperUpDown);
154
+ this.#downBtn?.addEventListener('pointerdown', this.#onStepperDownDown);
155
+ // Stop autorepeat on any pointer release, anywhere — captures the
156
+ // "drag-off-then-lift" abort path without per-button leave/cancel
157
+ // bookkeeping. Cheap; runs only while a stepper is held.
158
+
159
+ // In non-Vite static deploys, the icon registry loads asynchronously
160
+ // after the manifest fetch resolves. If our prefix/suffix were checked
161
+ // by isIconName() during that window, kebab-case icon names like
162
+ // "magnifying-glass" got baked into the DOM as literal text. Re-evaluate
163
+ // once the registry is ready and promote text-rendered affixes to
164
+ // <icon-ui>. (No-op on Vite dev where the promise resolves synchronously.)
165
+ if (this.prefix || this.suffix) {
166
+ whenIconRegistryReady.then(() => this.#promoteAffixes());
167
+ }
168
+ }
169
+
170
+ #shellHTML(labelId) {
171
+ const prefix = this.prefix ? `<span slot="prefix">${renderAffix(this.prefix)}</span>` : '';
172
+ const label = this.label ? `<span slot="label" id="${labelId}">${this.label}</span>` : '';
173
+ const suffix = this.suffix ? `<span slot="suffix">${renderAffix(this.suffix)}</span>` : '';
174
+ const labelby = labelId ? `aria-labelledby="${labelId}"` : '';
175
+
176
+ if (this.#isNativePassword) {
177
+ return `
178
+ <div slot="field">
179
+ ${prefix}${label}
180
+ <input slot="text" type="password" tabindex="0"
181
+ placeholder="${this.placeholder}" value="${this.value || ''}"
182
+ autocomplete="current-password" ${labelby}
183
+ ${this.disabled ? 'disabled' : ''} ${this.readonly ? 'readonly' : ''} />
184
+ ${suffix}
185
+ </div>
186
+ `;
187
+ }
188
+
189
+ const editable = `
190
+ <span slot="text" contenteditable="plaintext-only" tabindex="0"
191
+ ${this.value ? '' : 'data-empty=""'}
192
+ ${labelby}
193
+ data-placeholder="${this.placeholder}"
194
+ ${this.#isNumberMode ? 'inputmode="decimal"' : ''}></span>`;
195
+
196
+ const controls = this.#isNumberMode ? `
197
+ <span slot="controls" data-controls aria-hidden="true">
198
+ <button-ui type="button" tabindex="-1" variant="ghost" size="xs"
199
+ icon="caret-up" data-step="up" aria-label="Increase"></button-ui>
200
+ <button-ui type="button" tabindex="-1" variant="ghost" size="xs"
201
+ icon="caret-down" data-step="down" aria-label="Decrease"></button-ui>
202
+ </span>` : '';
203
+
204
+ return `
205
+ <div slot="field"${this.#isNumberMode ? ' data-number' : ''}>
206
+ ${prefix}${label}${editable}${suffix}${controls}
207
+ </div>
208
+ `;
209
+ }
210
+
211
+ #promoteAffixes() {
212
+ if (!this.isConnected) return;
213
+ for (const which of ['prefix', 'suffix']) {
214
+ const value = this[which];
215
+ if (!value) continue;
216
+ const slot = this.querySelector(`:scope [slot="${which}"]`);
217
+ if (!slot) continue;
218
+ // Already an <icon-ui> — nothing to do.
219
+ if (slot.querySelector(':scope > icon-ui')) continue;
220
+ // Was rendered as text and the value is now a known icon — replace.
221
+ if (isIconName(value)) {
222
+ slot.replaceChildren();
223
+ const icon = document.createElement('icon-ui');
224
+ icon.setAttribute('name', value);
225
+ slot.appendChild(icon);
226
+ }
227
+ }
228
+ }
229
+
230
+ render() {
231
+ if (!this.#textEl) return;
232
+
233
+ const text = this.value ?? '';
234
+
235
+ if (this.#isNativePassword) {
236
+ this.#textEl.placeholder = this.placeholder;
237
+ this.#textEl.disabled = this.disabled;
238
+ this.#textEl.readOnly = this.readonly;
239
+ if (this.#textEl.value !== text) this.#textEl.value = text;
240
+ } else {
241
+ this.#textEl.setAttribute('data-placeholder', this.placeholder);
242
+ if (this.disabled || this.readonly) {
243
+ this.#textEl.contentEditable = 'false';
244
+ } else {
245
+ this.#textEl.contentEditable = 'plaintext-only';
246
+ }
247
+ // Sync programmatic value writes into the contenteditable surface.
248
+ // Skip when already in sync to avoid clobbering an in-flight edit's
249
+ // caret position. For number mode, render the formatted display, but
250
+ // only when the surface DOESN'T have focus (mid-edit reformat would
251
+ // wipe caret + lose the user's transient state like "9." → "9").
252
+ const display = this.#isNumberMode && document.activeElement !== this.#textEl
253
+ ? this.#formatStored(text)
254
+ : String(text);
255
+ if (this.#textEl.textContent !== display) {
256
+ this.#textEl.textContent = display;
257
+ this.#textEl.toggleAttribute('data-empty', !display);
258
+ }
259
+ }
260
+
261
+ if (this.#labelEl) this.#labelEl.textContent = this.label || '';
262
+
263
+ if (this.label) {
264
+ this.removeAttribute('aria-label');
265
+ } else if (this.placeholder) {
266
+ this.setAttribute('aria-label', this.placeholder);
267
+ } else {
268
+ this.removeAttribute('aria-label');
269
+ }
270
+
271
+ if (this.#isNumberMode) {
272
+ const n = this.valueAsNumber;
273
+ if (Number.isFinite(n)) {
274
+ this.setAttribute('aria-valuenow', String(n));
275
+ this.setAttribute('aria-valuetext', `${this.#format(n)}${this.suffix ? ' ' + this.suffix : ''}`);
276
+ } else {
277
+ this.removeAttribute('aria-valuenow');
278
+ this.removeAttribute('aria-valuetext');
279
+ }
280
+ if (this.min != null) this.setAttribute('aria-valuemin', String(this.min));
281
+ else this.removeAttribute('aria-valuemin');
282
+ if (this.max != null) this.setAttribute('aria-valuemax', String(this.max));
283
+ else this.removeAttribute('aria-valuemax');
284
+
285
+ const disableUp = this.disabled || this.readonly || (this.max != null && Number.isFinite(n) && n >= this.max);
286
+ const disableDown = this.disabled || this.readonly || (this.min != null && Number.isFinite(n) && n <= this.min);
287
+ this.#upBtn?.toggleAttribute('disabled', !!disableUp);
288
+ this.#downBtn?.toggleAttribute('disabled', !!disableDown);
289
+ }
290
+ }
291
+
292
+ // ── Value sync + validation override ──
293
+
294
+ syncValue(val) {
295
+ val = val ?? this.value ?? '';
296
+ super.syncValue(String(val));
297
+ if (this.#isNumberMode) this.#runNumberConstraints(String(val));
298
+ }
299
+
300
+ validate() {
301
+ const baseValid = super.validate();
302
+ if (!this.#isNumberMode) return baseValid;
303
+ // super.validate cleared validity if all base constraints passed; layer
304
+ // number-specific checks on top.
305
+ if (!baseValid) return false;
306
+ const numValid = this.#runNumberConstraints(this.value ?? '');
307
+ if (!numValid) {
308
+ this.setAttribute('aria-invalid', 'true');
309
+ this.error = this.validationMessage;
310
+ }
311
+ return numValid;
312
+ }
313
+
314
+ #runNumberConstraints(val) {
315
+ const raw = String(val ?? '').trim();
316
+ // Empty is handled by `required` in the base class; nothing to check here.
317
+ if (!raw) return true;
318
+ // Canonicalize for `Number(…)` parse — when `locale` is set the raw
319
+ // value may carry the locale's decimal separator.
320
+ const s = this.#toCanonical(raw);
321
+ const n = Number(s);
322
+ if (!Number.isFinite(n)) {
323
+ this.internals.setValidity(
324
+ { badInput: true },
325
+ this.getAttribute('data-msg-bad-input') || 'Please enter a valid number.',
326
+ this,
327
+ );
328
+ return false;
329
+ }
330
+ if (this.min != null && n < this.min) {
331
+ this.internals.setValidity(
332
+ { rangeUnderflow: true },
333
+ this.getAttribute('data-msg-min') || `Value must be ${this.min} or greater.`,
334
+ this,
335
+ );
336
+ return false;
337
+ }
338
+ if (this.max != null && n > this.max) {
339
+ this.internals.setValidity(
340
+ { rangeOverflow: true },
341
+ this.getAttribute('data-msg-max') || `Value must be ${this.max} or less.`,
342
+ this,
343
+ );
344
+ return false;
345
+ }
346
+ return true;
347
+ }
348
+
349
+ // ── Number helpers ──
350
+
351
+ #decimals() {
352
+ if (this.precision != null) return Math.max(0, this.precision | 0);
353
+ const stepStr = String(this.step ?? 1);
354
+ return (stepStr.split('.')[1] || '').length;
355
+ }
356
+
357
+ /** Locale's decimal separator, or '.' for the default en-US-equivalent path.
358
+ * Result cached per-locale on the host so `Intl.NumberFormat.formatToParts`
359
+ * isn't called per keystroke. */
360
+ #decimalSep() {
361
+ if (!this.locale) return '.';
362
+ if (this.#cachedSepFor === this.locale) return this.#cachedSep;
363
+ this.#refreshSepCache();
364
+ return this.#cachedSep;
365
+ }
366
+
367
+ /** Locale's thousands/grouping separator (e.g. `,` in en-US, `.` in de-DE).
368
+ * Returns '' for the default path (no locale → no grouping). Cached
369
+ * alongside the decimal separator. */
370
+ #groupSep() {
371
+ if (!this.locale) return '';
372
+ if (this.#cachedSepFor === this.locale) return this.#cachedGroup;
373
+ this.#refreshSepCache();
374
+ return this.#cachedGroup;
375
+ }
376
+
377
+ #refreshSepCache() {
378
+ try {
379
+ const parts = new Intl.NumberFormat(this.locale).formatToParts(1234567.89);
380
+ this.#cachedSep = parts.find((p) => p.type === 'decimal')?.value || '.';
381
+ this.#cachedGroup = parts.find((p) => p.type === 'group')?.value || '';
382
+ } catch {
383
+ this.#cachedSep = '.';
384
+ this.#cachedGroup = '';
385
+ }
386
+ this.#cachedSepFor = this.locale;
387
+ }
388
+
389
+ /** Convert a locale-formatted numeric string to the JS-canonical form
390
+ * (decimal `.`, no thousands grouping). Strips group separators first so
391
+ * "1.234,5" (de-DE) → "1234.5", "1,234.5" (en-US) → "1234.5". Pure string
392
+ * transform; no validation. */
393
+ #toCanonical(s) {
394
+ const sep = this.#decimalSep();
395
+ const group = this.#groupSep();
396
+ let out = String(s);
397
+ if (group) out = out.split(group).join('');
398
+ if (sep !== '.') out = out.replace(new RegExp(`\\${sep}`, 'g'), '.');
399
+ return out;
400
+ }
401
+
402
+ /** Internal/edit-mode format: locale decimal separator, NO thousands
403
+ * grouping. Used for `this.value` storage and for the textContent
404
+ * rendering while the input is focused (so the user can edit without
405
+ * the group separator jumping around as they type). */
406
+ #format(n) {
407
+ if (!Number.isFinite(n)) return '';
408
+ const d = this.#decimals();
409
+ if (this.locale) {
410
+ try {
411
+ return new Intl.NumberFormat(this.locale, {
412
+ minimumFractionDigits: d,
413
+ maximumFractionDigits: d,
414
+ useGrouping: false,
415
+ }).format(n);
416
+ } catch { /* fall through to JS toFixed */ }
417
+ }
418
+ return d > 0 ? n.toFixed(d) : String(Math.round(n));
419
+ }
420
+
421
+ /** Display-mode format: locale decimal separator + thousands grouping when
422
+ * the locale supports it. Used for the textContent rendering when the
423
+ * input is NOT focused (initial render + post-blur). Returns the same as
424
+ * `#format` when no `locale` is set. */
425
+ #formatDisplay(n) {
426
+ if (!Number.isFinite(n)) return '';
427
+ if (!this.locale) return this.#format(n);
428
+ const d = this.#decimals();
429
+ try {
430
+ return new Intl.NumberFormat(this.locale, {
431
+ minimumFractionDigits: d,
432
+ maximumFractionDigits: d,
433
+ useGrouping: true,
434
+ }).format(n);
435
+ } catch { return this.#format(n); }
436
+ }
437
+
438
+ /** Display value derived from the stored string. During focus we leave
439
+ * the user's raw text alone; otherwise reformat (e.g. "9.9" → "9.90"
440
+ * for precision=2). Non-numeric stored strings pass through unchanged
441
+ * so error-state visuals can echo what the user typed. */
442
+ #formatStored(stored) {
443
+ const s = String(stored ?? '');
444
+ if (!s) return '';
445
+ // Canonicalize before Number() — `.value` may carry the locale's
446
+ // decimal separator if the host has `locale` set.
447
+ const n = Number(this.#toCanonical(s));
448
+ if (!Number.isFinite(n)) return s;
449
+ // If the input is currently focused, render without grouping so the
450
+ // user can edit naturally; otherwise group when locale is set. Falls
451
+ // back to #format (ungrouped) when there's no locale.
452
+ return document.activeElement === this.#textEl
453
+ ? this.#format(n)
454
+ : this.#formatDisplay(n);
455
+ }
456
+
457
+ #snap(raw) {
458
+ const step = this.step || 1;
459
+ const base = this.min != null ? this.min : 0;
460
+ const stepped = Math.round((raw - base) / step) * step + base;
461
+ const clamped = Math.max(
462
+ this.min != null ? this.min : -Infinity,
463
+ Math.min(this.max != null ? this.max : Infinity, stepped),
464
+ );
465
+ return parseFloat(clamped.toFixed(10));
466
+ }
467
+
468
+ #stepBy(multiplier) {
469
+ if (this.disabled || this.readonly) return;
470
+ const step = (this.step || 1) * multiplier;
471
+ const current = Number.isFinite(this.valueAsNumber)
472
+ ? this.valueAsNumber
473
+ : (this.min != null ? this.min : 0);
474
+ const next = this.#snap(current + step);
475
+ if (next === this.valueAsNumber) return;
476
+ this.value = this.#format(next);
477
+ this.syncValue(this.value);
478
+ this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
479
+ this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
480
+ }
481
+
482
+ // ── Event handlers ──
483
+
484
+ #onInput = () => {
485
+ let text;
486
+ if (this.#isNativePassword) {
487
+ text = this.#textEl.value || '';
488
+ } else if (this.#isNumberMode) {
489
+ // beforeinput filtered the keystroke; some browsers still let through
490
+ // composition or paste events that bypass beforeinput. Re-sanitize.
491
+ const raw = this.#textEl.textContent || '';
492
+ text = this.#sanitizeNumeric(raw);
493
+ if (text !== raw) {
494
+ // Soft-revert: restore filtered text + put caret at end. Rare path.
495
+ this.#textEl.textContent = text;
496
+ this.#placeCaretAtEnd();
497
+ }
498
+ } else {
499
+ text = this.#textEl.textContent || '';
500
+ }
501
+ this.value = text;
502
+ if (!this.#isNativePassword) this.#textEl.toggleAttribute('data-empty', !text);
503
+ this.syncValue(text);
504
+ this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
505
+ };
506
+
507
+ #onBeforeInput = (e) => {
508
+ // Allow deletions, formatting, composition — only gate text insertions.
509
+ const t = e.inputType;
510
+ if (!t || !t.startsWith('insert')) return;
511
+ if (t === 'insertCompositionText') return; // IME — let through, #onInput cleans up
512
+ const incoming = (e.data ?? '');
513
+ if (!incoming) return;
514
+ const current = this.#textEl.textContent || '';
515
+ const sel = window.getSelection();
516
+ // Build prospective string: replace selection (or insert at caret).
517
+ let start = current.length, end = current.length;
518
+ if (sel && sel.rangeCount && this.#textEl.contains(sel.anchorNode)) {
519
+ const r = sel.getRangeAt(0);
520
+ start = this.#offsetFromTextStart(r.startContainer, r.startOffset);
521
+ end = this.#offsetFromTextStart(r.endContainer, r.endOffset);
522
+ if (start > end) [start, end] = [end, start];
523
+ }
524
+ const prospective = current.slice(0, start) + incoming + current.slice(end);
525
+ if (!this.#isNumericProspect(prospective)) e.preventDefault();
526
+ };
527
+
528
+ #isNumericProspect(s) {
529
+ // Permissive while typing: allow lone '-', lone '.', and trailing '.'.
530
+ // Reject scientific notation, multiple decimals, multiple signs.
531
+ // When `locale` is set, accept both '.' AND the locale's decimal
532
+ // separator, and silently strip thousands-group separators (paste of
533
+ // "1,234.5" or "1.234,5" both validate).
534
+ const c = this.#toCanonical(s);
535
+ if (c === '' || c === '-' || c === '.' || c === '-.') {
536
+ return c === '' || c === '-' || (this.min == null || this.min < 0) ? true : false;
537
+ }
538
+ if (!/^-?\d*\.?\d*$/.test(c)) return false;
539
+ if (c.startsWith('-') && this.min != null && this.min >= 0) return false;
540
+ return true;
541
+ }
542
+
543
+ #sanitizeNumeric(s) {
544
+ // Strip everything but digits / one leading minus / one decimal point.
545
+ // The decimal mark is the locale's separator; characters that match the
546
+ // locale's group separator (e.g. `.` in de-DE, `,` in en-US) are silently
547
+ // dropped — never preserved in `this.value`. The blur handler re-renders
548
+ // with grouping for display via `#formatDisplay`.
549
+ //
550
+ // Note on programmatic `.value = "1.5"` in de-DE: that path doesn't run
551
+ // through sanitization (UIFormElement.value setter is string-only), so
552
+ // canonical-form programmatic values still parse correctly via
553
+ // `valueAsNumber` (which canonicalizes through `#toCanonical`). Only
554
+ // user-typed/-pasted input flows through this sanitizer, and there the
555
+ // locale interpretation (`.` = group when sep=`,`) is the correct read.
556
+ const sep = this.#decimalSep();
557
+ let out = '';
558
+ let sawDecimal = false;
559
+ for (let i = 0; i < s.length; i++) {
560
+ const c = s[i];
561
+ if (c >= '0' && c <= '9') out += c;
562
+ else if (c === '-' && out === '' && (this.min == null || this.min < 0)) out += c;
563
+ else if (c === sep && !sawDecimal) { out += sep; sawDecimal = true; }
564
+ // group separator and other punctuation silently dropped
565
+ }
566
+ return out;
567
+ }
568
+
569
+ #offsetFromTextStart(node, offset) {
570
+ // Walk the text descendants until we reach `node`, accumulating chars.
571
+ if (!this.#textEl.contains(node)) return 0;
572
+ let acc = 0;
573
+ const walker = document.createTreeWalker(this.#textEl, NodeFilter.SHOW_TEXT);
574
+ let n;
575
+ while ((n = walker.nextNode())) {
576
+ if (n === node) return acc + offset;
577
+ acc += n.textContent.length;
578
+ }
579
+ return node === this.#textEl ? offset : acc;
580
+ }
581
+
582
+ #placeCaretAtEnd() {
583
+ const sel = window.getSelection();
584
+ const range = document.createRange();
585
+ range.selectNodeContents(this.#textEl);
586
+ range.collapse(false);
587
+ sel.removeAllRanges();
588
+ sel.addRange(range);
589
+ }
590
+
591
+ #onKeydown = (e) => {
592
+ if (this.#isNumberMode) {
593
+ switch (e.key) {
594
+ case 'ArrowUp': e.preventDefault(); this.#stepBy( 1); return;
595
+ case 'ArrowDown': e.preventDefault(); this.#stepBy(-1); return;
596
+ case 'PageUp': e.preventDefault(); this.#stepBy( 10); return;
597
+ case 'PageDown': e.preventDefault(); this.#stepBy(-10); return;
598
+ case 'Home':
599
+ if (this.min != null) { e.preventDefault(); this.#commitNumeric(this.min); }
600
+ return;
601
+ case 'End':
602
+ if (this.max != null) { e.preventDefault(); this.#commitNumeric(this.max); }
603
+ return;
604
+ case 'Escape':
605
+ e.preventDefault();
606
+ this.value = this.#valueAtFocus;
607
+ this.#textEl.textContent = this.#formatStored(this.value);
608
+ this.#textEl.toggleAttribute('data-empty', !this.value);
609
+ this.syncValue(this.value);
610
+ this.#textEl.blur();
611
+ return;
612
+ case 'Enter':
613
+ e.preventDefault();
614
+ // Commit normalized value before firing form events.
615
+ this.#commitOnBlur();
616
+ this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
617
+ this.dispatchEvent(new Event('submit', { bubbles: true }));
618
+ return;
619
+ }
620
+ return;
621
+ }
622
+ if (e.key === 'Enter') {
623
+ e.preventDefault();
624
+ this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
625
+ this.dispatchEvent(new Event('submit', { bubbles: true }));
626
+ }
627
+ };
628
+
629
+ #onFocus = () => {
630
+ this.#valueAtFocus = this.value ?? '';
631
+ // When focused: re-render textContent without thousands grouping so the
632
+ // user can edit naturally — group separators jumping mid-keystroke is
633
+ // disorienting. Only matters when `locale` is set AND the post-blur
634
+ // render added grouping; no-op for the default `.` path.
635
+ if (this.#isNumberMode && this.locale) {
636
+ const raw = String(this.value ?? '').trim();
637
+ if (!raw) return;
638
+ const n = Number(this.#toCanonical(raw));
639
+ if (!Number.isFinite(n)) return;
640
+ const ungrouped = this.#format(n);
641
+ if (this.#textEl.textContent !== ungrouped) this.#textEl.textContent = ungrouped;
642
+ }
643
+ };
644
+
645
+ #onBlur = () => {
646
+ if (this.#isNumberMode) this.#commitOnBlur();
647
+ this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
648
+ };
649
+
650
+ #commitOnBlur() {
651
+ const raw = String(this.value ?? '').trim();
652
+ if (!raw) return;
653
+ // Canonicalize before Number() — `this.value` may carry the locale's
654
+ // decimal separator (e.g. "1,5" in de-DE).
655
+ const n = Number(this.#toCanonical(raw));
656
+ if (!Number.isFinite(n)) return; // leave the bad input visible for the error UX
657
+ const snapped = this.#snap(n);
658
+ // `this.value` stores the ungrouped, locale-decimal form (round-trippable
659
+ // through #toCanonical → Number → #format). textContent shows the
660
+ // grouped display form when `locale` is set.
661
+ const stored = this.#format(snapped);
662
+ const displayed = this.#formatDisplay(snapped);
663
+ if (this.value !== stored) {
664
+ this.value = stored;
665
+ this.syncValue(stored);
666
+ this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
667
+ }
668
+ if (this.#textEl.textContent !== displayed) {
669
+ this.#textEl.textContent = displayed;
670
+ this.#textEl.toggleAttribute('data-empty', !displayed);
671
+ }
672
+ }
673
+
674
+ #commitNumeric(n) {
675
+ const snapped = this.#snap(n);
676
+ if (snapped === this.valueAsNumber) return;
677
+ this.value = this.#format(snapped);
678
+ this.syncValue(this.value);
679
+ this.#textEl.textContent = this.value;
680
+ this.#textEl.toggleAttribute('data-empty', !this.value);
681
+ this.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: this.value } }));
682
+ this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: this.value } }));
683
+ }
684
+
685
+ #onPaste = (e) => {
686
+ e.preventDefault();
687
+ const raw = e.clipboardData?.getData('text/plain') || '';
688
+ const text = this.#isNumberMode ? this.#sanitizeNumeric(raw) : raw;
689
+ document.execCommand('insertText', false, text);
690
+ };
691
+
692
+ // Hold-to-repeat: pointerdown fires the initial step + arms an autorepeat
693
+ // timer. The first repeat fires after REPEAT_INITIAL_MS; subsequent ones
694
+ // every REPEAT_INTERVAL_MS. pointerup on document stops everything. We
695
+ // also stop on a stale value (disabled at min/max boundary) so the
696
+ // browser doesn't keep firing input events for no-op increments.
697
+ #onStepperUpDown = (e) => this.#startStepperHold(e, 1);
698
+ #onStepperDownDown = (e) => this.#startStepperHold(e, -1);
699
+
700
+ #startStepperHold(e, multiplier) {
701
+ // Keep focus on the editable surface when the button is pressed.
702
+ e.preventDefault();
703
+ if (this.disabled || this.readonly) return;
704
+ // Initial step fires immediately on press.
705
+ this.#stepBy(multiplier);
706
+ this.#stopStepperHold();
707
+ // Listen for release on document (cheap; only while held).
708
+ document.addEventListener('pointerup', this.#onStepperRelease, { once: true });
709
+ document.addEventListener('pointercancel', this.#onStepperRelease, { once: true });
710
+ // Initial delay → then continuous repeat.
711
+ this.#repeatDelayTimer = window.setTimeout(() => {
712
+ this.#repeatDelayTimer = null;
713
+ this.#repeatTimer = window.setInterval(() => {
714
+ const before = this.valueAsNumber;
715
+ this.#stepBy(multiplier);
716
+ // Boundary hit → no-op; cancel to avoid wasted intervals + event spam.
717
+ if (this.valueAsNumber === before) this.#stopStepperHold();
718
+ }, UIInput.#REPEAT_INTERVAL_MS);
719
+ }, UIInput.#REPEAT_INITIAL_MS);
720
+ }
721
+
722
+ #onStepperRelease = () => this.#stopStepperHold();
723
+
724
+ #stopStepperHold() {
725
+ if (this.#repeatDelayTimer != null) {
726
+ window.clearTimeout(this.#repeatDelayTimer);
727
+ this.#repeatDelayTimer = null;
728
+ }
729
+ if (this.#repeatTimer != null) {
730
+ window.clearInterval(this.#repeatTimer);
731
+ this.#repeatTimer = null;
732
+ }
733
+ document.removeEventListener('pointerup', this.#onStepperRelease);
734
+ document.removeEventListener('pointercancel', this.#onStepperRelease);
735
+ }
736
+
737
+ focus() { this.#textEl?.focus(); }
738
+
739
+ clear() {
740
+ this.value = '';
741
+ if (this.#textEl) {
742
+ if (this.#isNativePassword) {
743
+ this.#textEl.value = '';
744
+ } else {
745
+ this.#textEl.textContent = '';
746
+ this.#textEl.setAttribute('data-empty', '');
747
+ }
748
+ }
749
+ this.syncValue('');
750
+ }
751
+
752
+ disconnected() {
753
+ super.disconnected();
754
+ if (this.#textEl) {
755
+ this.#textEl.removeEventListener('input', this.#onInput);
756
+ this.#textEl.removeEventListener('keydown', this.#onKeydown);
757
+ this.#textEl.removeEventListener('blur', this.#onBlur);
758
+ this.#textEl.removeEventListener('focus', this.#onFocus);
759
+ this.#textEl.removeEventListener('paste', this.#onPaste);
760
+ this.#textEl.removeEventListener('beforeinput', this.#onBeforeInput);
761
+ }
762
+ this.#upBtn?.removeEventListener('pointerdown', this.#onStepperUpDown);
763
+ this.#downBtn?.removeEventListener('pointerdown', this.#onStepperDownDown);
764
+ // Cancel any in-flight hold (the document-level pointerup listener
765
+ // is `{once: true}` so it self-cleans on fire; this also clears the
766
+ // timers if the host disconnects mid-hold).
767
+ this.#stopStepperHold();
768
+ this.#textEl = null;
769
+ this.#labelEl = null;
770
+ this.#upBtn = null;
771
+ this.#downBtn = null;
772
+ }
773
+ }