@adia-ai/web-components 0.4.6 → 0.4.8

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 (399) hide show
  1. package/README.md +39 -0
  2. package/USAGE.md +29 -9
  3. package/components/accordion/accordion.a2ui.json +3 -0
  4. package/components/accordion/accordion.d.ts +27 -0
  5. package/components/accordion/accordion.js +10 -117
  6. package/components/accordion/accordion.yaml +4 -0
  7. package/components/accordion/class.js +132 -0
  8. package/components/action-list/action-list.a2ui.json +3 -0
  9. package/components/action-list/action-list.d.ts +25 -0
  10. package/components/action-list/action-list.js +9 -140
  11. package/components/action-list/action-list.yaml +4 -0
  12. package/components/action-list/class.js +156 -0
  13. package/components/agent-artifact/agent-artifact.a2ui.json +4 -0
  14. package/components/agent-artifact/agent-artifact.d.ts +35 -0
  15. package/components/agent-artifact/agent-artifact.js +8 -181
  16. package/components/agent-artifact/agent-artifact.yaml +5 -0
  17. package/components/agent-artifact/class.js +200 -0
  18. package/components/agent-feedback-bar/agent-feedback-bar.a2ui.json +3 -0
  19. package/components/agent-feedback-bar/agent-feedback-bar.d.ts +33 -0
  20. package/components/agent-feedback-bar/agent-feedback-bar.js +8 -143
  21. package/components/agent-feedback-bar/agent-feedback-bar.yaml +4 -0
  22. package/components/agent-feedback-bar/class.js +162 -0
  23. package/components/agent-questions/agent-questions.a2ui.json +3 -0
  24. package/components/agent-questions/agent-questions.d.ts +33 -0
  25. package/components/agent-questions/agent-questions.js +8 -180
  26. package/components/agent-questions/agent-questions.yaml +4 -0
  27. package/components/agent-questions/class.js +199 -0
  28. package/components/agent-reasoning/agent-reasoning.a2ui.json +4 -0
  29. package/components/agent-reasoning/agent-reasoning.d.ts +37 -0
  30. package/components/agent-reasoning/agent-reasoning.js +8 -494
  31. package/components/agent-reasoning/agent-reasoning.yaml +5 -0
  32. package/components/agent-reasoning/class.js +513 -0
  33. package/components/agent-suggestions/agent-suggestions.a2ui.json +3 -0
  34. package/components/agent-suggestions/agent-suggestions.d.ts +31 -0
  35. package/components/agent-suggestions/agent-suggestions.js +8 -78
  36. package/components/agent-suggestions/agent-suggestions.yaml +4 -0
  37. package/components/agent-suggestions/class.js +97 -0
  38. package/components/agent-trace/agent-trace.a2ui.json +1 -0
  39. package/components/agent-trace/agent-trace.d.ts +29 -0
  40. package/components/alert/alert.a2ui.json +1 -0
  41. package/components/alert/alert.d.ts +39 -0
  42. package/components/alert/alert.js +8 -175
  43. package/components/alert/class.js +194 -0
  44. package/components/aside/aside.a2ui.json +1 -0
  45. package/components/avatar/avatar.a2ui.json +3 -0
  46. package/components/avatar/avatar.d.ts +28 -0
  47. package/components/avatar/avatar.js +9 -159
  48. package/components/avatar/avatar.yaml +4 -0
  49. package/components/avatar/class.js +173 -0
  50. package/components/badge/badge.a2ui.json +3 -0
  51. package/components/badge/badge.d.ts +28 -0
  52. package/components/badge/badge.js +9 -75
  53. package/components/badge/badge.yaml +4 -0
  54. package/components/badge/class.js +93 -0
  55. package/components/block/block.a2ui.json +1 -0
  56. package/components/block/block.d.ts +20 -0
  57. package/components/block/block.js +9 -15
  58. package/components/block/class.js +33 -0
  59. package/components/breadcrumb/breadcrumb.a2ui.json +5 -0
  60. package/components/breadcrumb/breadcrumb.d.ts +24 -0
  61. package/components/breadcrumb/breadcrumb.js +8 -113
  62. package/components/breadcrumb/breadcrumb.yaml +6 -0
  63. package/components/breadcrumb/class.js +132 -0
  64. package/components/button/button.a2ui.json +3 -0
  65. package/components/button/button.d.ts +44 -0
  66. package/components/button/button.js +15 -66
  67. package/components/button/button.yaml +5 -0
  68. package/components/button/class.js +80 -0
  69. package/components/calendar-picker/calendar-picker.a2ui.json +7 -1
  70. package/components/calendar-picker/calendar-picker.js +8 -332
  71. package/components/calendar-picker/calendar-picker.yaml +51 -177
  72. package/components/calendar-picker/class.js +351 -0
  73. package/components/canvas/canvas.a2ui.json +7 -1
  74. package/components/canvas/canvas.d.ts +33 -0
  75. package/components/canvas/canvas.yaml +29 -36
  76. package/components/card/card.a2ui.json +4 -0
  77. package/components/card/card.d.ts +37 -0
  78. package/components/card/card.js +9 -50
  79. package/components/card/card.yaml +171 -433
  80. package/components/card/class.js +68 -0
  81. package/components/chart/chart.a2ui.json +1 -0
  82. package/components/chart/chart.d.ts +55 -0
  83. package/components/chart/chart.js +8 -2131
  84. package/components/chart/class.js +2150 -0
  85. package/components/chart-legend/chart-legend.a2ui.json +4 -0
  86. package/components/chart-legend/chart-legend.d.ts +37 -0
  87. package/components/chart-legend/chart-legend.js +8 -197
  88. package/components/chart-legend/chart-legend.yaml +5 -0
  89. package/components/chart-legend/class.js +215 -0
  90. package/components/chat-thread/chat-thread.a2ui.json +1 -0
  91. package/components/chat-thread/chat-thread.d.ts +27 -0
  92. package/components/chat-thread/chat-thread.js +8 -157
  93. package/components/chat-thread/class.js +176 -0
  94. package/components/check/check.a2ui.json +1 -0
  95. package/components/check/check.js +11 -52
  96. package/components/check/class.js +68 -0
  97. package/components/code/class.js +501 -0
  98. package/components/code/code.a2ui.json +1 -0
  99. package/components/code/code.js +8 -482
  100. package/components/col/class.js +30 -0
  101. package/components/col/col.a2ui.json +1 -0
  102. package/components/col/col.d.ts +24 -0
  103. package/components/col/col.js +10 -13
  104. package/components/color-picker/class.js +550 -0
  105. package/components/color-picker/color-picker.a2ui.json +3 -0
  106. package/components/color-picker/color-picker.js +8 -531
  107. package/components/color-picker/color-picker.yaml +4 -0
  108. package/components/command/class.js +364 -0
  109. package/components/command/command.a2ui.json +4 -0
  110. package/components/command/command.d.ts +31 -0
  111. package/components/command/command.js +8 -345
  112. package/components/command/command.yaml +105 -124
  113. package/components/demo-toggle/class.js +153 -0
  114. package/components/demo-toggle/demo-toggle.a2ui.json +1 -0
  115. package/components/demo-toggle/demo-toggle.d.ts +33 -0
  116. package/components/demo-toggle/demo-toggle.js +8 -135
  117. package/components/description-list/class.js +86 -0
  118. package/components/description-list/description-list.a2ui.json +1 -0
  119. package/components/description-list/description-list.d.ts +22 -0
  120. package/components/description-list/description-list.js +8 -67
  121. package/components/divider/class.js +57 -0
  122. package/components/divider/divider.a2ui.json +1 -0
  123. package/components/divider/divider.d.ts +20 -0
  124. package/components/divider/divider.js +10 -40
  125. package/components/drawer/class.js +306 -0
  126. package/components/drawer/drawer.a2ui.json +1 -0
  127. package/components/drawer/drawer.d.ts +35 -0
  128. package/components/drawer/drawer.js +8 -287
  129. package/components/embed/class.js +73 -0
  130. package/components/embed/embed.a2ui.json +1 -0
  131. package/components/embed/embed.d.ts +24 -0
  132. package/components/embed/embed.js +9 -55
  133. package/components/empty-state/class.js +108 -0
  134. package/components/empty-state/empty-state.a2ui.json +3 -0
  135. package/components/empty-state/empty-state.d.ts +22 -0
  136. package/components/empty-state/empty-state.js +9 -90
  137. package/components/empty-state/empty-state.yaml +4 -0
  138. package/components/feed/class.js +381 -0
  139. package/components/feed/feed.a2ui.json +9 -1
  140. package/components/feed/feed.d.ts +29 -0
  141. package/components/feed/feed.js +9 -367
  142. package/components/feed/feed.yaml +8 -1
  143. package/components/field/class.js +266 -0
  144. package/components/field/field.a2ui.json +1 -0
  145. package/components/field/field.d.ts +24 -0
  146. package/components/field/field.js +8 -247
  147. package/components/fields/class.js +106 -0
  148. package/components/fields/fields.a2ui.json +1 -0
  149. package/components/fields/fields.d.ts +20 -0
  150. package/components/fields/fields.js +8 -87
  151. package/components/footer/footer.a2ui.json +1 -0
  152. package/components/grid/class.js +31 -0
  153. package/components/grid/grid.a2ui.json +1 -0
  154. package/components/grid/grid.d.ts +24 -0
  155. package/components/grid/grid.js +10 -14
  156. package/components/header/header.a2ui.json +1 -0
  157. package/components/heatmap/class.js +305 -0
  158. package/components/heatmap/heatmap.a2ui.json +1 -0
  159. package/components/heatmap/heatmap.d.ts +43 -0
  160. package/components/heatmap/heatmap.js +8 -286
  161. package/components/icon/class.js +54 -0
  162. package/components/icon/icon.a2ui.json +1 -0
  163. package/components/icon/icon.d.ts +24 -0
  164. package/components/icon/icon.js +13 -40
  165. package/components/image/class.js +112 -0
  166. package/components/image/image.a2ui.json +3 -0
  167. package/components/image/image.d.ts +34 -0
  168. package/components/image/image.js +9 -94
  169. package/components/image/image.yaml +4 -0
  170. package/components/index.js +8 -0
  171. package/components/input/class.js +773 -0
  172. package/components/input/input.a2ui.json +7 -0
  173. package/components/input/input.js +8 -755
  174. package/components/input/input.yaml +177 -442
  175. package/components/inspector/class.js +142 -0
  176. package/components/inspector/inspector.a2ui.json +13 -1
  177. package/components/inspector/inspector.d.ts +18 -0
  178. package/components/inspector/inspector.js +8 -124
  179. package/components/inspector/inspector.yaml +21 -30
  180. package/components/kbd/class.js +34 -0
  181. package/components/kbd/kbd.a2ui.json +4 -0
  182. package/components/kbd/kbd.d.ts +18 -0
  183. package/components/kbd/kbd.js +10 -17
  184. package/components/kbd/kbd.yaml +54 -185
  185. package/components/link/class.js +187 -0
  186. package/components/link/link.a2ui.json +1 -0
  187. package/components/link/link.d.ts +65 -0
  188. package/components/link/link.js +8 -168
  189. package/components/list/class.js +249 -0
  190. package/components/list/list.a2ui.json +3 -0
  191. package/components/list/list.d.ts +33 -0
  192. package/components/list/list.js +9 -231
  193. package/components/list/list.yaml +4 -0
  194. package/components/menu/class.js +332 -0
  195. package/components/menu/menu.a2ui.json +3 -0
  196. package/components/menu/menu.d.ts +31 -0
  197. package/components/menu/menu.js +11 -316
  198. package/components/menu/menu.yaml +4 -0
  199. package/components/modal/class.js +231 -0
  200. package/components/modal/modal.a2ui.json +6 -1
  201. package/components/modal/modal.d.ts +33 -0
  202. package/components/modal/modal.js +8 -212
  203. package/components/modal/modal.yaml +19 -39
  204. package/components/nav/class.js +150 -0
  205. package/components/nav/nav.a2ui.json +1 -0
  206. package/components/nav/nav.d.ts +41 -0
  207. package/components/nav/nav.js +8 -131
  208. package/components/nav-group/class.js +152 -0
  209. package/components/nav-group/nav-group.a2ui.json +1 -0
  210. package/components/nav-group/nav-group.d.ts +45 -0
  211. package/components/nav-group/nav-group.js +9 -134
  212. package/components/nav-item/class.js +86 -0
  213. package/components/nav-item/nav-item.a2ui.json +1 -0
  214. package/components/nav-item/nav-item.d.ts +47 -0
  215. package/components/nav-item/nav-item.js +10 -69
  216. package/components/noodles/class.js +510 -0
  217. package/components/noodles/noodles.a2ui.json +1 -0
  218. package/components/noodles/noodles.d.ts +47 -0
  219. package/components/noodles/noodles.js +9 -493
  220. package/components/option-card/class.js +167 -0
  221. package/components/option-card/option-card.a2ui.json +3 -0
  222. package/components/option-card/option-card.js +8 -149
  223. package/components/option-card/option-card.yaml +4 -0
  224. package/components/otp-input/class.js +180 -0
  225. package/components/otp-input/otp-input.a2ui.json +6 -1
  226. package/components/otp-input/otp-input.js +9 -162
  227. package/components/otp-input/otp-input.yaml +45 -174
  228. package/components/page/class.js +97 -0
  229. package/components/page/page.a2ui.json +1 -0
  230. package/components/page/page.d.ts +47 -0
  231. package/components/page/page.js +8 -79
  232. package/components/pagination/class.js +195 -0
  233. package/components/pagination/pagination.a2ui.json +1 -0
  234. package/components/pagination/pagination.d.ts +33 -0
  235. package/components/pagination/pagination.js +9 -177
  236. package/components/pane/class.js +186 -0
  237. package/components/pane/pane.a2ui.json +20 -2
  238. package/components/pane/pane.d.ts +41 -0
  239. package/components/pane/pane.js +8 -167
  240. package/components/pane/pane.yaml +64 -158
  241. package/components/pipeline-status/class.js +189 -0
  242. package/components/pipeline-status/pipeline-status.a2ui.json +8 -1
  243. package/components/pipeline-status/pipeline-status.d.ts +22 -0
  244. package/components/pipeline-status/pipeline-status.js +9 -172
  245. package/components/pipeline-status/pipeline-status.yaml +34 -72
  246. package/components/popover/class.js +194 -0
  247. package/components/popover/popover.a2ui.json +1 -0
  248. package/components/popover/popover.d.ts +24 -0
  249. package/components/popover/popover.js +9 -176
  250. package/components/progress/class.js +74 -0
  251. package/components/progress/progress.a2ui.json +4 -0
  252. package/components/progress/progress.d.ts +20 -0
  253. package/components/progress/progress.js +10 -57
  254. package/components/progress/progress.yaml +124 -287
  255. package/components/progress-row/class.js +110 -0
  256. package/components/progress-row/progress-row.a2ui.json +3 -0
  257. package/components/progress-row/progress-row.d.ts +24 -0
  258. package/components/progress-row/progress-row.js +8 -92
  259. package/components/progress-row/progress-row.yaml +4 -0
  260. package/components/radio/class.js +83 -0
  261. package/components/radio/radio.a2ui.json +1 -0
  262. package/components/radio/radio.js +11 -67
  263. package/components/range/class.js +194 -0
  264. package/components/range/range.a2ui.json +1 -0
  265. package/components/range/range.js +9 -176
  266. package/components/rating/class.js +148 -0
  267. package/components/rating/rating.a2ui.json +1 -0
  268. package/components/rating/rating.js +9 -130
  269. package/components/richtext/class.js +87 -0
  270. package/components/richtext/richtext.a2ui.json +8 -1
  271. package/components/richtext/richtext.d.ts +20 -0
  272. package/components/richtext/richtext.js +8 -68
  273. package/components/richtext/richtext.yaml +30 -65
  274. package/components/row/class.js +50 -0
  275. package/components/row/row.a2ui.json +1 -0
  276. package/components/row/row.d.ts +37 -0
  277. package/components/row/row.js +10 -33
  278. package/components/search/class.js +134 -0
  279. package/components/search/search.a2ui.json +1 -0
  280. package/components/search/search.js +10 -117
  281. package/components/section/section.a2ui.json +1 -0
  282. package/components/segment/class.js +62 -0
  283. package/components/segment/segment.a2ui.json +3 -0
  284. package/components/segment/segment.d.ts +26 -0
  285. package/components/segment/segment.js +10 -45
  286. package/components/segment/segment.yaml +4 -0
  287. package/components/segmented/class.js +165 -0
  288. package/components/segmented/segmented.a2ui.json +5 -0
  289. package/components/segmented/segmented.js +10 -148
  290. package/components/segmented/segmented.yaml +41 -59
  291. package/components/select/class.js +408 -0
  292. package/components/select/select.a2ui.json +3 -0
  293. package/components/select/select.js +15 -396
  294. package/components/select/select.yaml +4 -0
  295. package/components/skeleton/class.js +52 -0
  296. package/components/skeleton/skeleton.a2ui.json +1 -0
  297. package/components/skeleton/skeleton.d.ts +24 -0
  298. package/components/skeleton/skeleton.js +8 -34
  299. package/components/slider/class.js +184 -0
  300. package/components/slider/slider.a2ui.json +1 -0
  301. package/components/slider/slider.js +9 -166
  302. package/components/stack/class.js +28 -0
  303. package/components/stack/stack.a2ui.json +1 -0
  304. package/components/stack/stack.d.ts +18 -0
  305. package/components/stack/stack.js +10 -11
  306. package/components/stat/stat.a2ui.json +1 -0
  307. package/components/step-progress/class.js +98 -0
  308. package/components/step-progress/step-progress.a2ui.json +1 -0
  309. package/components/step-progress/step-progress.d.ts +28 -0
  310. package/components/step-progress/step-progress.js +8 -79
  311. package/components/stepper/class.js +126 -0
  312. package/components/stepper/stepper.a2ui.json +3 -0
  313. package/components/stepper/stepper.d.ts +20 -0
  314. package/components/stepper/stepper.js +9 -112
  315. package/components/stepper/stepper.yaml +4 -0
  316. package/components/stream/class.js +109 -0
  317. package/components/stream/stream.a2ui.json +1 -0
  318. package/components/stream/stream.d.ts +33 -0
  319. package/components/stream/stream.js +8 -90
  320. package/components/swatch/class.js +131 -0
  321. package/components/swatch/swatch.a2ui.json +1 -0
  322. package/components/swatch/swatch.d.ts +29 -0
  323. package/components/swatch/swatch.js +8 -112
  324. package/components/swiper/class.js +373 -0
  325. package/components/swiper/swiper.a2ui.json +7 -0
  326. package/components/swiper/swiper.d.ts +45 -0
  327. package/components/swiper/swiper.js +8 -354
  328. package/components/swiper/swiper.yaml +72 -212
  329. package/components/switch/class.js +63 -0
  330. package/components/switch/switch.a2ui.json +7 -1
  331. package/components/switch/switch.js +11 -47
  332. package/components/switch/switch.yaml +70 -265
  333. package/components/table/class.js +1453 -0
  334. package/components/table/table.a2ui.json +7 -0
  335. package/components/table/table.d.ts +55 -0
  336. package/components/table/table.js +8 -1435
  337. package/components/table/table.yaml +8 -0
  338. package/components/table-toolbar/class.js +680 -0
  339. package/components/table-toolbar/table-toolbar.a2ui.json +12 -0
  340. package/components/table-toolbar/table-toolbar.d.ts +49 -0
  341. package/components/table-toolbar/table-toolbar.js +8 -689
  342. package/components/table-toolbar/table-toolbar.yaml +13 -0
  343. package/components/tabs/class.js +242 -0
  344. package/components/tabs/tabs.a2ui.json +3 -0
  345. package/components/tabs/tabs.d.ts +31 -0
  346. package/components/tabs/tabs.js +8 -223
  347. package/components/tabs/tabs.yaml +4 -0
  348. package/components/tag/class.js +99 -0
  349. package/components/tag/tag.a2ui.json +1 -0
  350. package/components/tag/tag.d.ts +37 -0
  351. package/components/tag/tag.js +8 -80
  352. package/components/text/class.js +46 -0
  353. package/components/text/text.a2ui.json +1 -0
  354. package/components/text/text.d.ts +26 -0
  355. package/components/text/text.js +9 -28
  356. package/components/textarea/class.js +134 -0
  357. package/components/textarea/textarea.a2ui.json +1 -0
  358. package/components/textarea/textarea.js +11 -118
  359. package/components/timeline/class.js +176 -0
  360. package/components/timeline/timeline.a2ui.json +18 -1
  361. package/components/timeline/timeline.d.ts +36 -0
  362. package/components/timeline/timeline.js +9 -162
  363. package/components/timeline/timeline.yaml +14 -1
  364. package/components/toast/class.js +92 -0
  365. package/components/toast/toast.a2ui.json +1 -0
  366. package/components/toast/toast.d.ts +33 -0
  367. package/components/toast/toast.js +9 -76
  368. package/components/toggle-group/class.js +154 -0
  369. package/components/toggle-group/toggle-group.a2ui.json +1 -0
  370. package/components/toggle-group/toggle-group.d.ts +29 -0
  371. package/components/toggle-group/toggle-group.js +11 -140
  372. package/components/toggle-scheme/class.js +286 -0
  373. package/components/toggle-scheme/toggle-scheme.a2ui.json +3 -0
  374. package/components/toggle-scheme/toggle-scheme.d.ts +51 -0
  375. package/components/toggle-scheme/toggle-scheme.js +8 -268
  376. package/components/toggle-scheme/toggle-scheme.yaml +4 -0
  377. package/components/toolbar/class.js +388 -0
  378. package/components/toolbar/toolbar.a2ui.json +3 -0
  379. package/components/toolbar/toolbar.d.ts +24 -0
  380. package/components/toolbar/toolbar.js +10 -376
  381. package/components/toolbar/toolbar.yaml +4 -0
  382. package/components/tooltip/class.js +299 -0
  383. package/components/tooltip/tooltip.a2ui.json +1 -0
  384. package/components/tooltip/tooltip.d.ts +28 -0
  385. package/components/tooltip/tooltip.js +8 -280
  386. package/components/tree/class.js +245 -0
  387. package/components/tree/tree.a2ui.json +3 -0
  388. package/components/tree/tree.d.ts +25 -0
  389. package/components/tree/tree.js +9 -244
  390. package/components/tree/tree.yaml +4 -0
  391. package/components/upload/class.js +199 -0
  392. package/components/upload/upload.a2ui.json +1 -0
  393. package/components/upload/upload.js +11 -183
  394. package/core/icons-phosphor.js +93 -0
  395. package/core/icons.js +92 -90
  396. package/core/index.js +5 -0
  397. package/index.d.ts +160 -5
  398. package/index.js +7 -0
  399. package/package.json +7 -2
@@ -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
+ }