@lo-tek/interfaces 0.0.0 → 0.37.0

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 (456) hide show
  1. package/CHANGELOG.md +3254 -0
  2. package/LICENSE +21 -0
  3. package/README.md +128 -1
  4. package/agent/astro.md +400 -0
  5. package/agent/bundles.md +429 -0
  6. package/agent/datastar.md +409 -0
  7. package/agent/html.md +388 -0
  8. package/agent/react.md +440 -0
  9. package/agent/templates.md +472 -0
  10. package/core/lotek-core.wasm +0 -0
  11. package/core/lotek.wasm +0 -0
  12. package/dist/cli.d.ts +12 -0
  13. package/dist/cli.d.ts.map +1 -0
  14. package/dist/cli.js +14 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/components/css/accordion/accordion.d.ts +37 -0
  17. package/dist/components/css/accordion/accordion.d.ts.map +1 -0
  18. package/dist/components/css/accordion/accordion.js +31 -0
  19. package/dist/components/css/accordion/accordion.js.map +1 -0
  20. package/dist/components/css/alert/alert.d.ts +35 -0
  21. package/dist/components/css/alert/alert.d.ts.map +1 -0
  22. package/dist/components/css/alert/alert.js +29 -0
  23. package/dist/components/css/alert/alert.js.map +1 -0
  24. package/dist/components/css/avatar/avatar.d.ts +40 -0
  25. package/dist/components/css/avatar/avatar.d.ts.map +1 -0
  26. package/dist/components/css/avatar/avatar.js +30 -0
  27. package/dist/components/css/avatar/avatar.js.map +1 -0
  28. package/dist/components/css/badge/badge.d.ts +24 -0
  29. package/dist/components/css/badge/badge.d.ts.map +1 -0
  30. package/dist/components/css/badge/badge.js +24 -0
  31. package/dist/components/css/badge/badge.js.map +1 -0
  32. package/dist/components/css/breadcrumbs/breadcrumbs.d.ts +26 -0
  33. package/dist/components/css/breadcrumbs/breadcrumbs.d.ts.map +1 -0
  34. package/dist/components/css/breadcrumbs/breadcrumbs.js +24 -0
  35. package/dist/components/css/breadcrumbs/breadcrumbs.js.map +1 -0
  36. package/dist/components/css/button/button.d.ts +26 -0
  37. package/dist/components/css/button/button.d.ts.map +1 -0
  38. package/dist/components/css/button/button.js +25 -0
  39. package/dist/components/css/button/button.js.map +1 -0
  40. package/dist/components/css/card/card.d.ts +36 -0
  41. package/dist/components/css/card/card.d.ts.map +1 -0
  42. package/dist/components/css/card/card.js +28 -0
  43. package/dist/components/css/card/card.js.map +1 -0
  44. package/dist/components/css/checkbox/checkbox.d.ts +32 -0
  45. package/dist/components/css/checkbox/checkbox.d.ts.map +1 -0
  46. package/dist/components/css/checkbox/checkbox.js +27 -0
  47. package/dist/components/css/checkbox/checkbox.js.map +1 -0
  48. package/dist/components/css/divider/divider.d.ts +29 -0
  49. package/dist/components/css/divider/divider.d.ts.map +1 -0
  50. package/dist/components/css/divider/divider.js +23 -0
  51. package/dist/components/css/divider/divider.js.map +1 -0
  52. package/dist/components/css/error-text/error-text.d.ts +24 -0
  53. package/dist/components/css/error-text/error-text.d.ts.map +1 -0
  54. package/dist/components/css/error-text/error-text.js +18 -0
  55. package/dist/components/css/error-text/error-text.js.map +1 -0
  56. package/dist/components/css/fieldset/fieldset.d.ts +55 -0
  57. package/dist/components/css/fieldset/fieldset.d.ts.map +1 -0
  58. package/dist/components/css/fieldset/fieldset.js +39 -0
  59. package/dist/components/css/fieldset/fieldset.js.map +1 -0
  60. package/dist/components/css/file-upload/file-upload.d.ts +41 -0
  61. package/dist/components/css/file-upload/file-upload.d.ts.map +1 -0
  62. package/dist/components/css/file-upload/file-upload.js +32 -0
  63. package/dist/components/css/file-upload/file-upload.js.map +1 -0
  64. package/dist/components/css/heading/heading.d.ts +29 -0
  65. package/dist/components/css/heading/heading.d.ts.map +1 -0
  66. package/dist/components/css/heading/heading.js +23 -0
  67. package/dist/components/css/heading/heading.js.map +1 -0
  68. package/dist/components/css/help-text/help-text.d.ts +24 -0
  69. package/dist/components/css/help-text/help-text.d.ts.map +1 -0
  70. package/dist/components/css/help-text/help-text.js +23 -0
  71. package/dist/components/css/help-text/help-text.js.map +1 -0
  72. package/dist/components/css/icon/icon.d.ts +34 -0
  73. package/dist/components/css/icon/icon.d.ts.map +1 -0
  74. package/dist/components/css/icon/icon.js +25 -0
  75. package/dist/components/css/icon/icon.js.map +1 -0
  76. package/dist/components/css/input/input.d.ts +40 -0
  77. package/dist/components/css/input/input.d.ts.map +1 -0
  78. package/dist/components/css/input/input.js +45 -0
  79. package/dist/components/css/input/input.js.map +1 -0
  80. package/dist/components/css/label/label.d.ts +33 -0
  81. package/dist/components/css/label/label.d.ts.map +1 -0
  82. package/dist/components/css/label/label.js +23 -0
  83. package/dist/components/css/label/label.js.map +1 -0
  84. package/dist/components/css/link/link.d.ts +28 -0
  85. package/dist/components/css/link/link.d.ts.map +1 -0
  86. package/dist/components/css/link/link.js +20 -0
  87. package/dist/components/css/link/link.js.map +1 -0
  88. package/dist/components/css/list/list.d.ts +31 -0
  89. package/dist/components/css/list/list.d.ts.map +1 -0
  90. package/dist/components/css/list/list.js +25 -0
  91. package/dist/components/css/list/list.js.map +1 -0
  92. package/dist/components/css/pagination/pagination.d.ts +27 -0
  93. package/dist/components/css/pagination/pagination.d.ts.map +1 -0
  94. package/dist/components/css/pagination/pagination.js +25 -0
  95. package/dist/components/css/pagination/pagination.js.map +1 -0
  96. package/dist/components/css/paragraph/paragraph.d.ts +36 -0
  97. package/dist/components/css/paragraph/paragraph.d.ts.map +1 -0
  98. package/dist/components/css/paragraph/paragraph.js +26 -0
  99. package/dist/components/css/paragraph/paragraph.js.map +1 -0
  100. package/dist/components/css/progress/progress.d.ts +53 -0
  101. package/dist/components/css/progress/progress.d.ts.map +1 -0
  102. package/dist/components/css/progress/progress.js +38 -0
  103. package/dist/components/css/progress/progress.js.map +1 -0
  104. package/dist/components/css/radio/radio.d.ts +32 -0
  105. package/dist/components/css/radio/radio.d.ts.map +1 -0
  106. package/dist/components/css/radio/radio.js +27 -0
  107. package/dist/components/css/radio/radio.js.map +1 -0
  108. package/dist/components/css/search/search.d.ts +34 -0
  109. package/dist/components/css/search/search.d.ts.map +1 -0
  110. package/dist/components/css/search/search.js +32 -0
  111. package/dist/components/css/search/search.js.map +1 -0
  112. package/dist/components/css/select/select.d.ts +40 -0
  113. package/dist/components/css/select/select.d.ts.map +1 -0
  114. package/dist/components/css/select/select.js +31 -0
  115. package/dist/components/css/select/select.js.map +1 -0
  116. package/dist/components/css/shared.d.ts +90 -0
  117. package/dist/components/css/shared.d.ts.map +1 -0
  118. package/dist/components/css/shared.js +167 -0
  119. package/dist/components/css/shared.js.map +1 -0
  120. package/dist/components/css/skeleton/skeleton.d.ts +29 -0
  121. package/dist/components/css/skeleton/skeleton.d.ts.map +1 -0
  122. package/dist/components/css/skeleton/skeleton.js +23 -0
  123. package/dist/components/css/skeleton/skeleton.js.map +1 -0
  124. package/dist/components/css/skip-link/skip-link.d.ts +18 -0
  125. package/dist/components/css/skip-link/skip-link.d.ts.map +1 -0
  126. package/dist/components/css/skip-link/skip-link.js +16 -0
  127. package/dist/components/css/skip-link/skip-link.js.map +1 -0
  128. package/dist/components/css/spinner/spinner.d.ts +33 -0
  129. package/dist/components/css/spinner/spinner.d.ts.map +1 -0
  130. package/dist/components/css/spinner/spinner.js +25 -0
  131. package/dist/components/css/spinner/spinner.js.map +1 -0
  132. package/dist/components/css/sr-only/sr-only.d.ts +17 -0
  133. package/dist/components/css/sr-only/sr-only.d.ts.map +1 -0
  134. package/dist/components/css/sr-only/sr-only.js +15 -0
  135. package/dist/components/css/sr-only/sr-only.js.map +1 -0
  136. package/dist/components/css/switch/switch.d.ts +37 -0
  137. package/dist/components/css/switch/switch.d.ts.map +1 -0
  138. package/dist/components/css/switch/switch.js +29 -0
  139. package/dist/components/css/switch/switch.js.map +1 -0
  140. package/dist/components/css/table/table.d.ts +76 -0
  141. package/dist/components/css/table/table.d.ts.map +1 -0
  142. package/dist/components/css/table/table.js +59 -0
  143. package/dist/components/css/table/table.js.map +1 -0
  144. package/dist/components/css/tag/tag.d.ts +35 -0
  145. package/dist/components/css/tag/tag.d.ts.map +1 -0
  146. package/dist/components/css/tag/tag.js +27 -0
  147. package/dist/components/css/tag/tag.js.map +1 -0
  148. package/dist/components/css/textarea/textarea.d.ts +23 -0
  149. package/dist/components/css/textarea/textarea.d.ts.map +1 -0
  150. package/dist/components/css/textarea/textarea.js +18 -0
  151. package/dist/components/css/textarea/textarea.js.map +1 -0
  152. package/dist/components/css/toggle-group/toggle-group.d.ts +31 -0
  153. package/dist/components/css/toggle-group/toggle-group.d.ts.map +1 -0
  154. package/dist/components/css/toggle-group/toggle-group.js +29 -0
  155. package/dist/components/css/toggle-group/toggle-group.js.map +1 -0
  156. package/dist/components/css/tooltip/tooltip.d.ts +31 -0
  157. package/dist/components/css/tooltip/tooltip.d.ts.map +1 -0
  158. package/dist/components/css/tooltip/tooltip.js +29 -0
  159. package/dist/components/css/tooltip/tooltip.js.map +1 -0
  160. package/dist/components/default-texts.d.ts +35 -0
  161. package/dist/components/default-texts.d.ts.map +1 -0
  162. package/dist/components/default-texts.js +35 -0
  163. package/dist/components/default-texts.js.map +1 -0
  164. package/dist/components/events.d.ts +77 -0
  165. package/dist/components/events.d.ts.map +1 -0
  166. package/dist/components/events.js +3 -0
  167. package/dist/components/events.js.map +1 -0
  168. package/dist/components/frame/dialog/dialog.d.ts +122 -0
  169. package/dist/components/frame/dialog/dialog.d.ts.map +1 -0
  170. package/dist/components/frame/dialog/dialog.js +112 -0
  171. package/dist/components/frame/dialog/dialog.js.map +1 -0
  172. package/dist/components/frame/dialog/lt-dialog.d.ts +104 -0
  173. package/dist/components/frame/dialog/lt-dialog.d.ts.map +1 -0
  174. package/dist/components/frame/dialog/lt-dialog.js +431 -0
  175. package/dist/components/frame/dialog/lt-dialog.js.map +1 -0
  176. package/dist/components/frame/error-summary/error-summary.d.ts +40 -0
  177. package/dist/components/frame/error-summary/error-summary.d.ts.map +1 -0
  178. package/dist/components/frame/error-summary/error-summary.js +32 -0
  179. package/dist/components/frame/error-summary/error-summary.js.map +1 -0
  180. package/dist/components/frame/error-summary/lt-error-summary.d.ts +82 -0
  181. package/dist/components/frame/error-summary/lt-error-summary.d.ts.map +1 -0
  182. package/dist/components/frame/error-summary/lt-error-summary.js +302 -0
  183. package/dist/components/frame/error-summary/lt-error-summary.js.map +1 -0
  184. package/dist/components/frame/field/field-core.d.ts +92 -0
  185. package/dist/components/frame/field/field-core.d.ts.map +1 -0
  186. package/dist/components/frame/field/field-core.js +63 -0
  187. package/dist/components/frame/field/field-core.js.map +1 -0
  188. package/dist/components/frame/field/lt-field.d.ts +173 -0
  189. package/dist/components/frame/field/lt-field.d.ts.map +1 -0
  190. package/dist/components/frame/field/lt-field.js +580 -0
  191. package/dist/components/frame/field/lt-field.js.map +1 -0
  192. package/dist/components/frame/popover/lt-popover.d.ts +132 -0
  193. package/dist/components/frame/popover/lt-popover.d.ts.map +1 -0
  194. package/dist/components/frame/popover/lt-popover.js +490 -0
  195. package/dist/components/frame/popover/lt-popover.js.map +1 -0
  196. package/dist/components/frame/popover/popover.d.ts +49 -0
  197. package/dist/components/frame/popover/popover.d.ts.map +1 -0
  198. package/dist/components/frame/popover/popover.js +64 -0
  199. package/dist/components/frame/popover/popover.js.map +1 -0
  200. package/dist/components/frame/session-timeout/lt-session-timeout.d.ts +198 -0
  201. package/dist/components/frame/session-timeout/lt-session-timeout.d.ts.map +1 -0
  202. package/dist/components/frame/session-timeout/lt-session-timeout.js +573 -0
  203. package/dist/components/frame/session-timeout/lt-session-timeout.js.map +1 -0
  204. package/dist/components/frame/session-timeout/session-timeout.d.ts +113 -0
  205. package/dist/components/frame/session-timeout/session-timeout.d.ts.map +1 -0
  206. package/dist/components/frame/session-timeout/session-timeout.js +101 -0
  207. package/dist/components/frame/session-timeout/session-timeout.js.map +1 -0
  208. package/dist/components/frame/suggestion/lt-suggestion.d.ts +361 -0
  209. package/dist/components/frame/suggestion/lt-suggestion.d.ts.map +1 -0
  210. package/dist/components/frame/suggestion/lt-suggestion.js +1152 -0
  211. package/dist/components/frame/suggestion/lt-suggestion.js.map +1 -0
  212. package/dist/components/frame/suggestion/suggestion.d.ts +143 -0
  213. package/dist/components/frame/suggestion/suggestion.d.ts.map +1 -0
  214. package/dist/components/frame/suggestion/suggestion.js +127 -0
  215. package/dist/components/frame/suggestion/suggestion.js.map +1 -0
  216. package/dist/components/frame/tabs/lt-tabs.d.ts +404 -0
  217. package/dist/components/frame/tabs/lt-tabs.d.ts.map +1 -0
  218. package/dist/components/frame/tabs/lt-tabs.js +1201 -0
  219. package/dist/components/frame/tabs/lt-tabs.js.map +1 -0
  220. package/dist/components/frame/tabs/tabs.d.ts +112 -0
  221. package/dist/components/frame/tabs/tabs.d.ts.map +1 -0
  222. package/dist/components/frame/tabs/tabs.js +107 -0
  223. package/dist/components/frame/tabs/tabs.js.map +1 -0
  224. package/dist/components/host-element.d.ts +135 -0
  225. package/dist/components/host-element.d.ts.map +1 -0
  226. package/dist/components/host-element.js +366 -0
  227. package/dist/components/host-element.js.map +1 -0
  228. package/dist/components/standalone/connection-status/connection-status.d.ts +28 -0
  229. package/dist/components/standalone/connection-status/connection-status.d.ts.map +1 -0
  230. package/dist/components/standalone/connection-status/connection-status.js +22 -0
  231. package/dist/components/standalone/connection-status/connection-status.js.map +1 -0
  232. package/dist/components/standalone/connection-status/lt-connection-status.d.ts +81 -0
  233. package/dist/components/standalone/connection-status/lt-connection-status.d.ts.map +1 -0
  234. package/dist/components/standalone/connection-status/lt-connection-status.js +216 -0
  235. package/dist/components/standalone/connection-status/lt-connection-status.js.map +1 -0
  236. package/dist/components/standalone/toast/lt-toast.d.ts +109 -0
  237. package/dist/components/standalone/toast/lt-toast.d.ts.map +1 -0
  238. package/dist/components/standalone/toast/lt-toast.js +256 -0
  239. package/dist/components/standalone/toast/lt-toast.js.map +1 -0
  240. package/dist/components/standalone/toast/toast.d.ts +52 -0
  241. package/dist/components/standalone/toast/toast.d.ts.map +1 -0
  242. package/dist/components/standalone/toast/toast.js +42 -0
  243. package/dist/components/standalone/toast/toast.js.map +1 -0
  244. package/dist/diagnostics/core.d.ts +137 -0
  245. package/dist/diagnostics/core.d.ts.map +1 -0
  246. package/dist/diagnostics/core.js +72 -0
  247. package/dist/diagnostics/core.js.map +1 -0
  248. package/dist/diagnostics/default-core.d.ts +10 -0
  249. package/dist/diagnostics/default-core.d.ts.map +1 -0
  250. package/dist/diagnostics/default-core.js +72 -0
  251. package/dist/diagnostics/default-core.js.map +1 -0
  252. package/dist/diagnostics/index.d.ts +84 -0
  253. package/dist/diagnostics/index.d.ts.map +1 -0
  254. package/dist/diagnostics/index.js +106 -0
  255. package/dist/diagnostics/index.js.map +1 -0
  256. package/dist/dom.d.ts +55 -0
  257. package/dist/dom.d.ts.map +1 -0
  258. package/dist/dom.js +162 -0
  259. package/dist/dom.js.map +1 -0
  260. package/dist/index.d.ts +97 -0
  261. package/dist/index.d.ts.map +1 -0
  262. package/dist/index.js +58 -0
  263. package/dist/index.js.map +1 -0
  264. package/dist/jsx/react.d.ts +180 -0
  265. package/dist/jsx/react.d.ts.map +1 -0
  266. package/dist/jsx/react.js +2 -0
  267. package/dist/jsx/react.js.map +1 -0
  268. package/dist/lotek.css +3617 -0
  269. package/dist/lt.d.ts +325 -0
  270. package/dist/lt.d.ts.map +1 -0
  271. package/dist/lt.js +183 -0
  272. package/dist/lt.js.map +1 -0
  273. package/dist/react.d.ts +492 -0
  274. package/dist/react.d.ts.map +1 -0
  275. package/dist/react.js +296 -0
  276. package/dist/react.js.map +1 -0
  277. package/dist/register.d.ts +17 -0
  278. package/dist/register.d.ts.map +1 -0
  279. package/dist/register.js +35 -0
  280. package/dist/register.js.map +1 -0
  281. package/dist/tokens/color.d.ts +43 -0
  282. package/dist/tokens/color.d.ts.map +1 -0
  283. package/dist/tokens/color.js +123 -0
  284. package/dist/tokens/color.js.map +1 -0
  285. package/dist/tokens/contract.d.ts +18 -0
  286. package/dist/tokens/contract.d.ts.map +1 -0
  287. package/dist/tokens/contract.js +18 -0
  288. package/dist/tokens/contract.js.map +1 -0
  289. package/dist/tokens/matrix-cells.d.ts +30 -0
  290. package/dist/tokens/matrix-cells.d.ts.map +1 -0
  291. package/dist/tokens/matrix-cells.js +273 -0
  292. package/dist/tokens/matrix-cells.js.map +1 -0
  293. package/dist/tokens/matrix.d.ts +18 -0
  294. package/dist/tokens/matrix.d.ts.map +1 -0
  295. package/dist/tokens/matrix.js +21 -0
  296. package/dist/tokens/matrix.js.map +1 -0
  297. package/dist/tokens/theme-check.d.ts +42 -0
  298. package/dist/tokens/theme-check.d.ts.map +1 -0
  299. package/dist/tokens/theme-check.js +10 -0
  300. package/dist/tokens/theme-check.js.map +1 -0
  301. package/dist/tokens/theme.d.ts +94 -0
  302. package/dist/tokens/theme.d.ts.map +1 -0
  303. package/dist/tokens/theme.js +14 -0
  304. package/dist/tokens/theme.js.map +1 -0
  305. package/dist/tokens/tokens.d.ts +122 -0
  306. package/dist/tokens/tokens.d.ts.map +1 -0
  307. package/dist/tokens/tokens.js +158 -0
  308. package/dist/tokens/tokens.js.map +1 -0
  309. package/dist/vocabulary/classes.d.ts +3 -0
  310. package/dist/vocabulary/classes.d.ts.map +1 -0
  311. package/dist/vocabulary/classes.js +1320 -0
  312. package/dist/vocabulary/classes.js.map +1 -0
  313. package/dist/vocabulary/elements.d.ts +3 -0
  314. package/dist/vocabulary/elements.d.ts.map +1 -0
  315. package/dist/vocabulary/elements.js +410 -0
  316. package/dist/vocabulary/elements.js.map +1 -0
  317. package/dist/vocabulary/types.d.ts +175 -0
  318. package/dist/vocabulary/types.d.ts.map +1 -0
  319. package/dist/vocabulary/types.js +11 -0
  320. package/dist/vocabulary/types.js.map +1 -0
  321. package/dist/wasi-host.d.ts +24 -0
  322. package/dist/wasi-host.d.ts.map +1 -0
  323. package/dist/wasi-host.js +140 -0
  324. package/dist/wasi-host.js.map +1 -0
  325. package/live-templates.xml +62 -0
  326. package/manifest/files.json +373 -0
  327. package/manifest/manifest.json +4965 -0
  328. package/manifest/manifest.schema.json +513 -0
  329. package/package.json +398 -5
  330. package/src/cli.ts +21 -0
  331. package/src/components/css/accordion/accordion.css +102 -0
  332. package/src/components/css/accordion/accordion.ts +50 -0
  333. package/src/components/css/alert/alert.css +53 -0
  334. package/src/components/css/alert/alert.ts +48 -0
  335. package/src/components/css/avatar/avatar.css +96 -0
  336. package/src/components/css/avatar/avatar.ts +57 -0
  337. package/src/components/css/badge/badge.css +42 -0
  338. package/src/components/css/badge/badge.ts +43 -0
  339. package/src/components/css/breadcrumbs/breadcrumbs.css +62 -0
  340. package/src/components/css/breadcrumbs/breadcrumbs.ts +30 -0
  341. package/src/components/css/button/button.css +128 -0
  342. package/src/components/css/button/button.ts +44 -0
  343. package/src/components/css/card/card.css +65 -0
  344. package/src/components/css/card/card.ts +50 -0
  345. package/src/components/css/checkbox/checkbox.css +114 -0
  346. package/src/components/css/checkbox/checkbox.ts +50 -0
  347. package/src/components/css/divider/divider.css +38 -0
  348. package/src/components/css/divider/divider.ts +42 -0
  349. package/src/components/css/error-text/error-text.css +21 -0
  350. package/src/components/css/error-text/error-text.ts +37 -0
  351. package/src/components/css/fieldset/fieldset.css +72 -0
  352. package/src/components/css/fieldset/fieldset.ts +87 -0
  353. package/src/components/css/file-upload/file-upload.css +102 -0
  354. package/src/components/css/file-upload/file-upload.ts +65 -0
  355. package/src/components/css/heading/heading.css +33 -0
  356. package/src/components/css/heading/heading.ts +42 -0
  357. package/src/components/css/help-text/help-text.css +24 -0
  358. package/src/components/css/help-text/help-text.ts +42 -0
  359. package/src/components/css/icon/icon.css +20 -0
  360. package/src/components/css/icon/icon.ts +44 -0
  361. package/src/components/css/input/input.css +145 -0
  362. package/src/components/css/input/input.ts +77 -0
  363. package/src/components/css/label/label.css +42 -0
  364. package/src/components/css/label/label.ts +51 -0
  365. package/src/components/css/link/link.css +38 -0
  366. package/src/components/css/link/link.ts +38 -0
  367. package/src/components/css/list/list.css +53 -0
  368. package/src/components/css/list/list.ts +44 -0
  369. package/src/components/css/pagination/pagination.css +95 -0
  370. package/src/components/css/pagination/pagination.ts +33 -0
  371. package/src/components/css/paragraph/paragraph.css +31 -0
  372. package/src/components/css/paragraph/paragraph.ts +53 -0
  373. package/src/components/css/progress/progress.css +161 -0
  374. package/src/components/css/progress/progress.ts +78 -0
  375. package/src/components/css/radio/radio.css +94 -0
  376. package/src/components/css/radio/radio.ts +50 -0
  377. package/src/components/css/search/search.css +50 -0
  378. package/src/components/css/search/search.ts +56 -0
  379. package/src/components/css/select/select.css +321 -0
  380. package/src/components/css/select/select.ts +63 -0
  381. package/src/components/css/shared.ts +199 -0
  382. package/src/components/css/skeleton/skeleton.css +62 -0
  383. package/src/components/css/skeleton/skeleton.ts +43 -0
  384. package/src/components/css/skip-link/skip-link.css +41 -0
  385. package/src/components/css/skip-link/skip-link.ts +22 -0
  386. package/src/components/css/spinner/spinner.css +48 -0
  387. package/src/components/css/spinner/spinner.ts +48 -0
  388. package/src/components/css/sr-only/sr-only.css +16 -0
  389. package/src/components/css/sr-only/sr-only.ts +21 -0
  390. package/src/components/css/switch/switch.css +105 -0
  391. package/src/components/css/switch/switch.ts +50 -0
  392. package/src/components/css/table/table.css +159 -0
  393. package/src/components/css/table/table.ts +107 -0
  394. package/src/components/css/tag/tag.css +58 -0
  395. package/src/components/css/tag/tag.ts +49 -0
  396. package/src/components/css/textarea/textarea.css +83 -0
  397. package/src/components/css/textarea/textarea.ts +40 -0
  398. package/src/components/css/toggle-group/toggle-group.css +92 -0
  399. package/src/components/css/toggle-group/toggle-group.ts +37 -0
  400. package/src/components/css/tooltip/tooltip.css +89 -0
  401. package/src/components/css/tooltip/tooltip.ts +37 -0
  402. package/src/components/default-texts.ts +34 -0
  403. package/src/components/events.ts +68 -0
  404. package/src/components/frame/dialog/dialog.css +306 -0
  405. package/src/components/frame/dialog/dialog.ts +166 -0
  406. package/src/components/frame/dialog/lt-dialog.ts +510 -0
  407. package/src/components/frame/error-summary/error-summary.css +47 -0
  408. package/src/components/frame/error-summary/error-summary.ts +56 -0
  409. package/src/components/frame/error-summary/lt-error-summary.ts +344 -0
  410. package/src/components/frame/field/field-core.ts +152 -0
  411. package/src/components/frame/field/field.css +17 -0
  412. package/src/components/frame/field/lt-field.ts +753 -0
  413. package/src/components/frame/popover/lt-popover.ts +555 -0
  414. package/src/components/frame/popover/popover.css +50 -0
  415. package/src/components/frame/popover/popover.ts +102 -0
  416. package/src/components/frame/session-timeout/lt-session-timeout.ts +681 -0
  417. package/src/components/frame/session-timeout/session-timeout.css +50 -0
  418. package/src/components/frame/session-timeout/session-timeout.ts +156 -0
  419. package/src/components/frame/suggestion/lt-suggestion.ts +1358 -0
  420. package/src/components/frame/suggestion/suggestion.css +132 -0
  421. package/src/components/frame/suggestion/suggestion.ts +172 -0
  422. package/src/components/frame/tabs/lt-tabs.ts +1319 -0
  423. package/src/components/frame/tabs/tabs.css +128 -0
  424. package/src/components/frame/tabs/tabs.ts +188 -0
  425. package/src/components/host-element.ts +400 -0
  426. package/src/components/standalone/connection-status/connection-status.css +41 -0
  427. package/src/components/standalone/connection-status/connection-status.ts +41 -0
  428. package/src/components/standalone/connection-status/lt-connection-status.ts +242 -0
  429. package/src/components/standalone/toast/lt-toast.ts +300 -0
  430. package/src/components/standalone/toast/toast.css +78 -0
  431. package/src/components/standalone/toast/toast.ts +70 -0
  432. package/src/diagnostics/core.ts +210 -0
  433. package/src/diagnostics/default-core.ts +81 -0
  434. package/src/diagnostics/index.ts +150 -0
  435. package/src/dom.ts +185 -0
  436. package/src/index.ts +218 -0
  437. package/src/jsx/react.ts +212 -0
  438. package/src/lt.ts +192 -0
  439. package/src/react.ts +411 -0
  440. package/src/register.ts +43 -0
  441. package/src/tailwind/tailwind.css +169 -0
  442. package/src/tokens/boundary.css +104 -0
  443. package/src/tokens/color-contract.json +231 -0
  444. package/src/tokens/color.ts +150 -0
  445. package/src/tokens/contract.ts +24 -0
  446. package/src/tokens/matrix-cells.ts +375 -0
  447. package/src/tokens/matrix.ts +32 -0
  448. package/src/tokens/theme-check.ts +51 -0
  449. package/src/tokens/theme.ts +104 -0
  450. package/src/tokens/tokens.css +569 -0
  451. package/src/tokens/tokens.ts +223 -0
  452. package/src/vocabulary/classes.ts +1322 -0
  453. package/src/vocabulary/elements.ts +412 -0
  454. package/src/vocabulary/types.ts +178 -0
  455. package/src/wasi-host.ts +155 -0
  456. package/web-types.json +800 -0
@@ -0,0 +1,1358 @@
1
+ import { LABEL_CLASS } from "../../css/label/label.js"
2
+ import { DEFAULT_TEXTS } from "../../default-texts.js"
3
+ import {
4
+ addClass,
5
+ defineElement,
6
+ derivedParts,
7
+ HostElement,
8
+ isServerControlled,
9
+ languageOf,
10
+ SERVER_CONTROLLED,
11
+ setAttr,
12
+ setFlag,
13
+ setText,
14
+ uniqueId,
15
+ warnAboutMarkup,
16
+ } from "../../host-element.js"
17
+ import {
18
+ SUGGESTION_EMPTY_CLASS,
19
+ SUGGESTION_LIST_CLASS,
20
+ SUGGESTION_OPTION_CLASS,
21
+ SUGGESTION_VALUE_CLASS,
22
+ } from "./suggestion.js"
23
+
24
+ /**
25
+ * The texts for the number of matches, one attribute per form. `count-none`
26
+ * is zero matches, and the rest are the forms `Intl.PluralRules` knows.
27
+ */
28
+ const COUNT_ATTRIBUTES = [
29
+ "count-none",
30
+ "count-zero",
31
+ "count-one",
32
+ "count-two",
33
+ "count-few",
34
+ "count-many",
35
+ "count-other",
36
+ ] as const
37
+
38
+ export const LT_SUGGESTION_TAG = "lt-suggestion" as const
39
+
40
+ /**
41
+ * The parts the suggestion field finds, as selectors relative to the element.
42
+ * The web component finds its parts with these, and the manifest describes
43
+ * them for whoever writes the markup by hand, so the two cannot disagree.
44
+ */
45
+ export const LT_SUGGESTION_PARTS = {
46
+ /** The field: the role when present, otherwise the first input. */
47
+ control: "[role='combobox'], input:not([type='hidden'])",
48
+ /** The list: the role when present, otherwise the class. */
49
+ list: `[role='listbox'], .${SUGGESTION_LIST_CLASS}`,
50
+ /** An option: the role when present, otherwise an `<li>` without a role in the list. */
51
+ option: `[role='option'], .${SUGGESTION_LIST_CLASS} > li:not([role])`,
52
+ empty: `.${SUGGESTION_EMPTY_CLASS}`,
53
+ status: "[role='status']",
54
+ /** The hidden field that receives the code of the option the user chose. */
55
+ value: `input.${SUGGESTION_VALUE_CLASS}`,
56
+ } as const
57
+
58
+ const CONTROL_SELECTOR = LT_SUGGESTION_PARTS.control
59
+ const LIST_SELECTOR = LT_SUGGESTION_PARTS.list
60
+ const OPTION_SELECTOR = LT_SUGGESTION_PARTS.option
61
+
62
+ /**
63
+ * The keyboard and the filtering in a field with a suggestion list.
64
+ *
65
+ * The server writes the field and the list. When the markup is made with
66
+ * JavaScript, `lt.suggestion()` writes the roles, the ids and the wiring.
67
+ * When it comes from a template without JavaScript, the template writes
68
+ * `for` on the label and `id` on the field, or wraps the field in the label,
69
+ * which HTML requires for the field to have a name without JavaScript. The
70
+ * rest is enough: a `.lt-suggestion__list` with `<li>` and a
71
+ * `[role="status"]`. The web component sets the roles, gives the list and the
72
+ * options ids, connects the list to the field, and closes the list until the
73
+ * user touches the field. Only what is missing, so what the server wrote
74
+ * stays.
75
+ *
76
+ * The web component never writes an id on the field. Datastar and idiomorph
77
+ * recognise a node by its id, and a field with an id the server did not send
78
+ * is replaced on the next update, along with focus and what the user has
79
+ * typed.
80
+ *
81
+ * The field always shows the option's text. If the server has written a
82
+ * hidden `.lt-suggestion__value`, it receives the code from `data-value`, or
83
+ * the text when the option has none. Without JavaScript the text is
84
+ * submitted, and the server looks it up. The web component renders nothing.
85
+ * It used to create the whole field itself, and then there was neither a
86
+ * label nor an input before the script had run, and nothing was included in
87
+ * the submission.
88
+ *
89
+ * What the web component does is what the browser does not: it filters the
90
+ * options while the user types, moves the highlight with the arrow keys, and
91
+ * keeps `aria-activedescendant` in sync so the screen reader announces the
92
+ * option without focus leaving the field.
93
+ *
94
+ * If something else has already filtered, a server or a React component that
95
+ * renders only the matches, `prefiltered` says so, and the web component
96
+ * leaves the options alone.
97
+ *
98
+ * ```html
99
+ * <lt-suggestion>
100
+ * <label class="lt-label" for="city">City</label>
101
+ * <input class="lt-input" id="city" role="combobox" aria-controls="city-list"
102
+ * aria-expanded="false" aria-autocomplete="list" autocomplete="off"
103
+ * aria-describedby="city-status">
104
+ * <ul class="lt-suggestion__list" id="city-list" role="listbox" hidden>
105
+ * <li class="lt-suggestion__option" id="city-option-0" role="option">Bergen</li>
106
+ * </ul>
107
+ * <p class="lt-suggestion__empty">No matches</p>
108
+ * <span class="lt-sr-only" id="city-status" role="status"></span>
109
+ * </lt-suggestion>
110
+ * ```
111
+ */
112
+ export class LtSuggestion extends HostElement {
113
+ static observedAttributes = [
114
+ "prefiltered",
115
+ SERVER_CONTROLLED,
116
+ ...COUNT_ATTRIBUTES,
117
+ ] as const
118
+
119
+ private observer?: MutationObserver
120
+ private control?: HTMLInputElement
121
+ private list?: HTMLElement
122
+ private empty?: HTMLElement
123
+ /** The options that already have their listener. */
124
+ private readonly bound = new WeakSet<HTMLElement>()
125
+ /** The ids the web component created, per node, so a patch gets the same ones back. */
126
+ private readonly ids = new WeakMap<Element, string>()
127
+ /** The roles the web component set. See `derivedParts`. */
128
+ private readonly roledByMe = new WeakSet<Element>()
129
+ /** True while the web component dispatches events, so it does not answer itself. */
130
+ private choosing = false
131
+ /**
132
+ * The text and code of the choice in the field, when the value field is in
133
+ * use.
134
+ *
135
+ * The pair is what the code in the value field applies to. If the user
136
+ * changes the text, the code no longer applies, and the value field is
137
+ * emptied; otherwise the form would submit the code for Bergen with the
138
+ * text "Bodø" in the field. If the text is still there after an update, the
139
+ * code is set back: the value field's `value` is an attribute, and a morph
140
+ * writes the server's over what the web component set.
141
+ */
142
+ private chosen?: { label: string; value: string }
143
+ /**
144
+ * What the server sent as text and code, the last time something other
145
+ * than the web component wrote the code. When the form is reset, the text
146
+ * goes back to the server's, and the code must follow: a hidden field has
147
+ * no default value of its own to go back to, since the web component
148
+ * writes the code as an attribute.
149
+ */
150
+ private serverChoice?: { label: string; value: string }
151
+ /** The code the web component wrote last. If something else is there, the server wrote it. */
152
+ private writtenCode?: string
153
+ /** The form listened to for `reset`. Follows the field, which a patch can move. */
154
+ private boundForm?: HTMLFormElement | null
155
+ /** The `for` the web component wrote on the label. */
156
+ private readonly forByMe = new WeakMap<Element, string>()
157
+ /**
158
+ * The name the web component gave the list, per list: `aria-labelledby`
159
+ * pointing to the label, or `aria-label` with its text. If something else
160
+ * is there, the server wrote it, and it stays.
161
+ */
162
+ private readonly listNameByMe = new WeakMap<
163
+ Element,
164
+ { name: "aria-labelledby" | "aria-label"; value: string }
165
+ >()
166
+ /**
167
+ * Labels where the server wrote a `for` pointing to another id. Reported
168
+ * once; the memory is not reset, since the rewritten `for` cannot be told
169
+ * apart from a server that corrected itself.
170
+ */
171
+ private readonly wrongFor = new WeakSet<Element>()
172
+ /**
173
+ * Whether the list is open, and which option is highlighted.
174
+ *
175
+ * Both come from the user: she typed something, and she moves with the
176
+ * arrow keys. None of it is in the HTML the server sent, so a morph strips
177
+ * it. The template used to list `aria-expanded`, `aria-activedescendant`
178
+ * and `hidden` in `data-preserve-attr`. Now the web component sets it back,
179
+ * as long as the morph keeps the field. It only does so when the id
180
+ * matches, see `warnAboutReplacedControl`.
181
+ */
182
+ private wantOpen?: boolean
183
+ /**
184
+ * The highlighted option, remembered by both id and text.
185
+ *
186
+ * The ids from `lt.suggestion()` are positional, so a new list from the
187
+ * server reuses them. With the id alone the web component put the
188
+ * highlight back on option number two in an entirely different list, and
189
+ * the screen reader announced a place the user never navigated to. The
190
+ * text is the identity; the id is only where `aria-activedescendant`
191
+ * points.
192
+ */
193
+ private active?: { id: string; label: string; value: string | null }
194
+ /**
195
+ * The count last announced, when something else filters.
196
+ *
197
+ * Only changes are announced, and the first count is the starting point,
198
+ * not news. See `announceFiltered`.
199
+ */
200
+ private announcedCount?: number
201
+ /**
202
+ * The text last written in the status element.
203
+ *
204
+ * The text is not in the server's version, so an update that sends the
205
+ * region again empties the element. `repair()` writes it back.
206
+ */
207
+ private announcedText?: string
208
+
209
+ connectedCallback(): void {
210
+ /*
211
+ * The attributes are included, not just the children. Every write below
212
+ * compares first, or the observer would trigger itself forever.
213
+ */
214
+ this.observer = new MutationObserver(() => this.sync())
215
+ this.observer.observe(this, {
216
+ childList: true,
217
+ subtree: true,
218
+ attributes: true,
219
+ attributeFilter: [
220
+ "hidden",
221
+ "aria-expanded",
222
+ "aria-selected",
223
+ "aria-activedescendant",
224
+ // The wiring the web component fills in when the markup arrived without it.
225
+ "role",
226
+ "id",
227
+ "for",
228
+ "aria-controls",
229
+ "autocomplete",
230
+ "aria-autocomplete",
231
+ "class",
232
+ // The value field's `value` is an attribute, and a morph can write
233
+ // the server's empty value over the code the user chose.
234
+ "value",
235
+ // `form` on the field picks which form the reset belongs to.
236
+ "form",
237
+ // A field that becomes `readonly` or `disabled` must close the list.
238
+ "readonly",
239
+ "disabled",
240
+ "aria-disabled",
241
+ // The list sits in the top layer. A morph that removes `popover`
242
+ // closes the popover, and the web component sets it back.
243
+ "popover",
244
+ // The list's name, from the label.
245
+ "aria-labelledby",
246
+ "aria-label",
247
+ ],
248
+ })
249
+ this.sync()
250
+ window.addEventListener("resize", this.reposition)
251
+ window.addEventListener("scroll", this.reposition, true)
252
+ // The list closes when focus leaves the web component, with Tab as with
253
+ // anything else. On the host and not on the field: if focus goes through
254
+ // a button inside the web component and then out, the list must close
255
+ // then too. Without this it stayed over the next field, with
256
+ // `aria-expanded` true on a field without focus.
257
+ this.addEventListener("focusout", this.handleFocusOut)
258
+ }
259
+
260
+ attributeChangedCallback(name: string): void {
261
+ if (name === SERVER_CONTROLLED && isServerControlled(this)) {
262
+ this.wantOpen = undefined
263
+ this.active = undefined
264
+ }
265
+
266
+ /*
267
+ * The attribute can come and go while the page is alive, and then the
268
+ * list must be corrected at once rather than on the next keystroke.
269
+ * `this.observer` signals that the web component is in the document: the
270
+ * callback also fires during the upgrade, before the children exist.
271
+ */
272
+ if (name === "prefiltered" && this.observer) {
273
+ // The count present when the attribute is set is the starting point,
274
+ // and nothing to announce. Without this the first change afterwards
275
+ // was taken as the starting point, and was lost.
276
+ this.announcedCount = this.prefiltered ? this.visible.length : undefined
277
+ this.filter()
278
+ }
279
+ }
280
+
281
+ private sync(): void {
282
+ this.wire()
283
+ this.bind()
284
+ this.repair()
285
+ this.announceFiltered()
286
+ this.mirrorPopover()
287
+ }
288
+
289
+ /**
290
+ * Opens and closes the popover the list sits in, following `hidden`.
291
+ *
292
+ * `hidden` still says whether the list is open, from the server as from
293
+ * the web component, and the popover follows. The list sits in the top
294
+ * layer, so a parent with `overflow`, such as the body of a dialog, does
295
+ * not clip it.
296
+ */
297
+ private mirrorPopover(): void {
298
+ const list = this.listElement
299
+ if (!list?.isConnected || !list.hasAttribute("popover")) return
300
+ const shown = list.matches(":popover-open")
301
+ try {
302
+ if (!list.hidden && !shown) list.showPopover()
303
+ else if (list.hidden && shown) list.hidePopover()
304
+ } catch {
305
+ // An element on its way out of the tree cannot be shown. The next
306
+ // round sees it again.
307
+ return
308
+ }
309
+ if (!list.hidden) this.reposition()
310
+ }
311
+
312
+ /**
313
+ * Places the list right below the field, as wide as it. If there is no
314
+ * room below, it goes above, and it is squeezed into the viewport either
315
+ * way, like the panel in `<lt-popover>`. The position is physical,
316
+ * computed from the field's box.
317
+ */
318
+ private reposition = (): void => {
319
+ const list = this.listElement
320
+ const control = this.querySelector<HTMLElement>(CONTROL_SELECTOR)
321
+ if (!list || !control || !list.matches(":popover-open")) return
322
+
323
+ const field = control.getBoundingClientRect()
324
+ const height = list.getBoundingClientRect().height
325
+ const space = 4
326
+
327
+ let top = field.bottom + space
328
+ if (top + height > window.innerHeight && field.top - height - space >= 0) {
329
+ top = field.top - height - space
330
+ }
331
+ top = Math.max(space, Math.min(top, window.innerHeight - height - space))
332
+
333
+ list.style.setProperty("--lt-suggestion-top", `${Math.round(top)}px`)
334
+ list.style.setProperty(
335
+ "--lt-suggestion-left",
336
+ `${Math.round(field.left)}px`,
337
+ )
338
+ list.style.setProperty(
339
+ "--lt-suggestion-width",
340
+ `${Math.round(field.width)}px`,
341
+ )
342
+ }
343
+
344
+ /**
345
+ * Names the list from the label, when the markup arrived without a name.
346
+ *
347
+ * With `aria-labelledby` when the label is next to the field; the web
348
+ * component gives it an id when it has none, since it holds nothing the
349
+ * user can lose. If the label wraps the field, it gets no id: a morph would
350
+ * then replace the label, and the field with it. In that case the list
351
+ * gets the label's text as `aria-label`, without the field's value.
352
+ */
353
+ private nameList(list: HTMLElement, control: HTMLInputElement): void {
354
+ const mine = this.listNameByMe.get(list)
355
+ const labelledby = list.getAttribute("aria-labelledby")
356
+ const label = list.getAttribute("aria-label")
357
+ const fromServer =
358
+ (labelledby !== null &&
359
+ !(mine?.name === "aria-labelledby" && mine.value === labelledby)) ||
360
+ (label !== null && !(mine?.name === "aria-label" && mine.value === label))
361
+ if (fromServer) return
362
+
363
+ const labelElement =
364
+ control.labels?.[0] ?? this.querySelector<HTMLLabelElement>("label")
365
+ if (!labelElement) return
366
+
367
+ if (labelElement.contains(control)) {
368
+ let text = ""
369
+ for (const node of labelElement.childNodes)
370
+ if (
371
+ node !== control &&
372
+ !(node instanceof Element && node.contains(control))
373
+ )
374
+ text += node.textContent ?? ""
375
+ text = text.replace(/\s+/g, " ").trim()
376
+ if (labelledby !== null) setAttr(list, "aria-labelledby", null)
377
+ if (text === "") {
378
+ if (label !== null) setAttr(list, "aria-label", null)
379
+ this.listNameByMe.delete(list)
380
+ return
381
+ }
382
+ setAttr(list, "aria-label", text)
383
+ this.listNameByMe.set(list, { name: "aria-label", value: text })
384
+ return
385
+ }
386
+
387
+ if (!labelElement.id) {
388
+ setAttr(
389
+ labelElement,
390
+ "id",
391
+ this.rememberedId(labelElement, "lt-suggestion-label"),
392
+ )
393
+ }
394
+ if (label !== null) setAttr(list, "aria-label", null)
395
+ setAttr(list, "aria-labelledby", labelElement.id)
396
+ this.listNameByMe.set(list, {
397
+ name: "aria-labelledby",
398
+ value: labelElement.id,
399
+ })
400
+ }
401
+
402
+ /** The id an element should have: the one it has, otherwise the one the web component created last. */
403
+ private rememberedId(element: Element, prefix: string): string {
404
+ if (element.id) return element.id
405
+ let id = this.ids.get(element)
406
+ if (!id) {
407
+ id = uniqueId(prefix)
408
+ this.ids.set(element, id)
409
+ }
410
+ return id
411
+ }
412
+
413
+ /**
414
+ * Fills in the wiring where the markup arrived without it. Only what is
415
+ * missing.
416
+ *
417
+ * The list is closed when the markup says nothing about it, that is when
418
+ * neither `aria-expanded` on the field nor `hidden` on the list is there.
419
+ * If the markup states one of them, that is the server's word on both, as
420
+ * with `lt.suggestion({ open: true })`.
421
+ */
422
+ private wire(): void {
423
+ const control = this.querySelector<HTMLInputElement>(CONTROL_SELECTOR)
424
+ if (!control) return
425
+
426
+ if (!control.hasAttribute("role")) setAttr(control, "role", "combobox")
427
+ if (!control.hasAttribute("autocomplete")) {
428
+ setAttr(control, "autocomplete", "off")
429
+ }
430
+ if (!control.hasAttribute("aria-autocomplete")) {
431
+ setAttr(control, "aria-autocomplete", "list")
432
+ }
433
+ const label = this.querySelector("label")
434
+ const root = this.getRootNode() as Document | ShadowRoot
435
+
436
+ if (label) {
437
+ addClass(label, LABEL_CLASS)
438
+ // `for` and `id` are the same information, and are written from the
439
+ // field's id, never the other way round: the web component does not
440
+ // write an id on the field. If the label wraps the field, the wiring
441
+ // is implicit. Same rule as in `<lt-field>`.
442
+ if (control.id && !label.contains(control)) {
443
+ const current = label.getAttribute("for")
444
+ if (
445
+ current !== null &&
446
+ current !== control.id &&
447
+ current !== this.forByMe.get(label)
448
+ ) {
449
+ this.wrongFor.add(label)
450
+ }
451
+ setAttr(label, "for", control.id)
452
+ this.forByMe.set(label, control.id)
453
+ }
454
+ warnAboutMarkup(
455
+ this,
456
+ "for on the label pointed to a different id than the field's. " +
457
+ "The component rewrote it, but without JavaScript the field has " +
458
+ "no label and a screen reader announces it without a name, so " +
459
+ "write the field's id in for in the template.",
460
+ () => {
461
+ const now = this.querySelector<HTMLInputElement>(CONTROL_SELECTOR)
462
+ const labelNow = this.querySelector("label")
463
+ return (
464
+ now !== null && labelNow !== null && this.wrongFor.has(labelNow)
465
+ )
466
+ },
467
+ )
468
+ warnAboutMarkup(
469
+ this,
470
+ "<label> is not connected to the field: the field has no id, " +
471
+ "and the label does not wrap it, so without JavaScript a screen " +
472
+ "reader announces the field without a name. Write an id on the " +
473
+ "field and the same value in for on the label, or wrap the " +
474
+ "field in the <label>; the component does not write the id " +
475
+ "itself, because an id the server did not send makes a server " +
476
+ "update replace the field.",
477
+ () => {
478
+ const now = this.querySelector<HTMLInputElement>(CONTROL_SELECTOR)
479
+ const labelNow = this.querySelector("label")
480
+ return (
481
+ now !== null &&
482
+ labelNow !== null &&
483
+ !now.id &&
484
+ !labelNow.contains(now) &&
485
+ !now.hasAttribute("aria-label") &&
486
+ !now.hasAttribute("aria-labelledby")
487
+ )
488
+ },
489
+ )
490
+ // `for` wins over the label wrapping the field. If it points to another
491
+ // id, the field has no label, with JavaScript as well.
492
+ warnAboutMarkup(
493
+ this,
494
+ "<label> wraps the field, but its for points to a different id " +
495
+ "than the field's, and for wins, so a screen reader announces " +
496
+ "the field without a name. Remove for, or give the field the " +
497
+ "same id.",
498
+ () => {
499
+ const now = this.querySelector<HTMLInputElement>(CONTROL_SELECTOR)
500
+ const labelNow = this.querySelector("label")
501
+ if (!now || !labelNow?.contains(now)) return false
502
+ const target = labelNow.getAttribute("for")
503
+ return target !== null && target !== now.id
504
+ },
505
+ )
506
+ } else {
507
+ // With no label inside, it can be outside, with `for` set to the field's
508
+ // id. It gets the class, as in `<lt-field>`; a patch of this region does
509
+ // not touch it, so it needs no repair.
510
+ if (control.id) {
511
+ const outside = (
512
+ this.getRootNode() as Document | ShadowRoot
513
+ ).querySelector?.(`label[for="${CSS.escape(control.id)}"]`)
514
+ if (outside) addClass(outside, LABEL_CLASS)
515
+ }
516
+ warnAboutMarkup(
517
+ this,
518
+ "found no <label>, so the field has no label and a screen " +
519
+ "reader announces it without a name. If the label is outside " +
520
+ "<lt-suggestion>, its for must point to the field's id.",
521
+ () => {
522
+ const now = this.querySelector<HTMLInputElement>(CONTROL_SELECTOR)
523
+ if (!now || this.querySelector("label")) return false
524
+ // A <label> around the whole <lt-suggestion> also names the field.
525
+ if ((now.labels?.length ?? 0) > 0) return false
526
+ if (
527
+ now.hasAttribute("aria-label") ||
528
+ now.hasAttribute("aria-labelledby")
529
+ )
530
+ return false
531
+ const rootNow = this.getRootNode() as Document | ShadowRoot
532
+ return !(
533
+ now.id &&
534
+ rootNow.querySelector?.(`label[for="${CSS.escape(now.id)}"]`)
535
+ )
536
+ },
537
+ )
538
+ }
539
+
540
+ const list = this.listElement
541
+ if (list) {
542
+ addClass(list, SUGGESTION_LIST_CLASS)
543
+ if (!list.hasAttribute("role")) setAttr(list, "role", "listbox")
544
+ if (!list.id) {
545
+ // Same rule as for the field: if `aria-controls` points to nothing,
546
+ // that is the id the new list should have.
547
+ const wanted = control.getAttribute("aria-controls")
548
+ const free = wanted && !root.getElementById?.(wanted)
549
+ if (free) this.ids.set(list, wanted)
550
+ setAttr(list, "id", this.rememberedId(list, "lt-suggestion-list"))
551
+ }
552
+ if (!control.hasAttribute("aria-controls")) {
553
+ setAttr(control, "aria-controls", list.id)
554
+ }
555
+ if (!list.hasAttribute("popover")) setAttr(list, "popover", "manual")
556
+ this.nameList(list, control)
557
+ if (!control.hasAttribute("aria-expanded")) {
558
+ if (!list.hasAttribute("hidden")) setFlag(list, "hidden", true)
559
+ setAttr(control, "aria-expanded", "false")
560
+ }
561
+ }
562
+
563
+ for (const option of this.options) {
564
+ addClass(option, SUGGESTION_OPTION_CLASS)
565
+ if (!option.hasAttribute("role")) {
566
+ setAttr(option, "role", "option")
567
+ this.roledByMe.add(option)
568
+ }
569
+ if (!option.id) {
570
+ setAttr(option, "id", this.rememberedId(option, "lt-suggestion-option"))
571
+ }
572
+ // From the start, not only when the list closes: otherwise the options
573
+ // the server sent were without it until then, while the rest of the list
574
+ // had it.
575
+ if (!option.hasAttribute("aria-selected")) {
576
+ setAttr(option, "aria-selected", "false")
577
+ }
578
+ }
579
+ }
580
+
581
+ /**
582
+ * Sets the user's state back after a patch.
583
+ *
584
+ * The filtering is recomputed from what is in the field, so it needs no
585
+ * memory. What the list shows and what is highlighted do.
586
+ */
587
+ private repair(): void {
588
+ if (!this.control) return
589
+
590
+ // The code is set back under `server-controlled` too. The attribute is
591
+ // about whether the list is open and what is highlighted, not what the user
592
+ // chose.
593
+ const valueField = this.valueField
594
+ if (
595
+ valueField &&
596
+ this.chosen &&
597
+ this.control.value === this.chosen.label &&
598
+ valueField.value === ""
599
+ ) {
600
+ this.writeCode(valueField, this.chosen.value)
601
+ }
602
+
603
+ // An emptied status element gets its text back while the list is open,
604
+ // where the count is what the user is working on. Not otherwise: every
605
+ // patch of the region would announce the old count again. And not when
606
+ // something else filters: then a patch is the answer to a keystroke, and
607
+ // `announceFiltered` announces the new count right after.
608
+ const status = this.querySelector<HTMLElement>(LT_SUGGESTION_PARTS.status)
609
+ if (
610
+ status &&
611
+ this.wantOpen === true &&
612
+ !this.prefiltered &&
613
+ this.announcedText !== undefined &&
614
+ (status.textContent ?? "").trim() === ""
615
+ ) {
616
+ setText(status, this.announcedText)
617
+ }
618
+
619
+ if (isServerControlled(this)) return
620
+
621
+ // A field that became `readonly` or `disabled` while the list was open
622
+ // closes it. Otherwise the repair kept it open, and Escape does nothing in
623
+ // a locked field.
624
+ if (this.locked && this.wantOpen) this.setOpen(false)
625
+
626
+ if (this.wantOpen !== undefined) {
627
+ /*
628
+ * Each half is checked separately. A patch that touched only the field left
629
+ * `aria-expanded="false"` while the options were visible, and the screen
630
+ * reader said the list was closed while it was on screen.
631
+ */
632
+ const list = this.listElement
633
+ const expanded = this.control.getAttribute("aria-expanded")
634
+ if (
635
+ (list && list.hidden === this.wantOpen) ||
636
+ expanded !== String(this.wantOpen)
637
+ ) {
638
+ this.applyOpen(this.wantOpen)
639
+ }
640
+ if (this.wantOpen) this.filter()
641
+ }
642
+
643
+ if (this.active) {
644
+ /*
645
+ * The text is what counts, and the option must be visible. Otherwise the
646
+ * web component highlighted something the server had just filtered out, and
647
+ * `aria-activedescendant` pointed to a hidden element. The id is a
648
+ * fallback: if the server sends the list again, the options are replaced
649
+ * and get new ids, and the highlight is found again by its text. The code
650
+ * counts too, since two places can have the same name.
651
+ */
652
+ const sameText = (o: HTMLElement) =>
653
+ (o.textContent ?? "").trim() === this.active?.label &&
654
+ o.getAttribute("data-value") === this.active?.value
655
+ const option =
656
+ this.visible.find((o) => o.id === this.active?.id && sameText(o)) ??
657
+ this.visible.find(sameText)
658
+ if (!option) {
659
+ // The pointer must go together with the highlight. Without this
660
+ // `aria-activedescendant` pointed to an option that is now called
661
+ // something else, and the screen reader announced a place the user never
662
+ // navigated to.
663
+ this.active = undefined
664
+ this.control.removeAttribute("aria-activedescendant")
665
+ } else if (
666
+ option.getAttribute("aria-selected") !== "true" ||
667
+ this.control.getAttribute("aria-activedescendant") !== option.id
668
+ ) {
669
+ // Both sides of the wiring are checked. If the patch stripped only
670
+ // `aria-activedescendant` while the highlight remained, the screen reader
671
+ // lost its reading position without anything else looking wrong.
672
+ this.markOption(option)
673
+ }
674
+ }
675
+ }
676
+
677
+ disconnectedCallback(): void {
678
+ window.removeEventListener("resize", this.reposition)
679
+ window.removeEventListener("scroll", this.reposition, true)
680
+ this.removeEventListener("focusout", this.handleFocusOut)
681
+ this.observer?.disconnect()
682
+ this.observer = undefined
683
+ this.unbind()
684
+ }
685
+
686
+ /**
687
+ * Something else has already filtered, so the web component leaves the
688
+ * options alone.
689
+ *
690
+ * The name used to be `server-filtered`, and that was misleading: it is not
691
+ * about servers. A React app that renders only the matches has filtered all
692
+ * the same, without any server involved.
693
+ *
694
+ * The web component hides an option when its text does not contain what is
695
+ * in the field. If you filter on something else, without diacritics, on a
696
+ * code that is not shown, or fuzzily, the two disagree, and then your filter
697
+ * should win. The attribute turns off both the hiding and the empty
698
+ * message, that is everything about what the list shows. The announcement
699
+ * of the number of matches remains, since it is not markup.
700
+ */
701
+ get prefiltered(): boolean {
702
+ return this.hasAttribute("prefiltered")
703
+ }
704
+
705
+ set prefiltered(on: boolean) {
706
+ /*
707
+ * The setter is not decoration. React 19 writes the property rather than
708
+ * the attribute when a web component has one with that name, and a getter
709
+ * alone throws "Cannot set property prefiltered". The attribute never
710
+ * landed, and the web component hid what React had just rendered. The
711
+ * hyphen in the old name hid the problem: `server-filtered` cannot be a
712
+ * property name, so React sent it as an attribute.
713
+ */
714
+ setFlag(this, "prefiltered", on)
715
+ }
716
+
717
+ private get listElement(): HTMLElement | null {
718
+ return this.querySelector<HTMLElement>(LIST_SELECTOR)
719
+ }
720
+
721
+ private get emptyElement(): HTMLElement | null {
722
+ return this.querySelector<HTMLElement>(LT_SUGGESTION_PARTS.empty)
723
+ }
724
+
725
+ private get valueField(): HTMLInputElement | null {
726
+ return this.querySelector<HTMLInputElement>(LT_SUGGESTION_PARTS.value)
727
+ }
728
+
729
+ /** The field accepts no input: `readonly` or `disabled`. */
730
+ private get locked(): boolean {
731
+ return Boolean(this.control?.readOnly || this.control?.disabled)
732
+ }
733
+
734
+ /**
735
+ * The options, recognised by the role and not by the class.
736
+ *
737
+ * The role must be there anyway, for the screen reader. A template that
738
+ * wrote `<li role="option">` with its own styling got neither filtering nor
739
+ * arrow keys when the web component looked for the class, and nothing said
740
+ * so.
741
+ */
742
+ private get options(): HTMLElement[] {
743
+ return derivedParts<HTMLElement>(
744
+ this,
745
+ OPTION_SELECTOR,
746
+ "option",
747
+ this.roledByMe,
748
+ )
749
+ }
750
+
751
+ /**
752
+ * Which visible option is highlighted now.
753
+ *
754
+ * It is read from `aria-selected` in the markup, not from a counter inside
755
+ * the web component. The server can set the highlight itself with
756
+ * `lt.suggestion({ activeIndex })`, and a parallel counter would have jumped
757
+ * to the top of the list on the first arrow key instead of to the next
758
+ * option. The filtering also moves the options around, and then the lookup
759
+ * gives the right answer by itself.
760
+ */
761
+ private get activeIndex(): number {
762
+ return this.selectable.findIndex(
763
+ (option) => option.getAttribute("aria-selected") === "true",
764
+ )
765
+ }
766
+
767
+ /** The options that are visible now. */
768
+ private get visible(): HTMLElement[] {
769
+ return this.options.filter((option) => !option.hidden)
770
+ }
771
+
772
+ /**
773
+ * The visible options that can be chosen. An option with
774
+ * `aria-disabled="true"` is shown, but the arrow keys skip it and it cannot
775
+ * be chosen, with neither keyboard nor mouse.
776
+ */
777
+ private get selectable(): HTMLElement[] {
778
+ return this.visible.filter(
779
+ (option) => option.getAttribute("aria-disabled") !== "true",
780
+ )
781
+ }
782
+
783
+ private unbind(): void {
784
+ const control = this.control
785
+ if (control) {
786
+ control.removeEventListener("input", this.handleInput)
787
+ control.removeEventListener("keydown", this.handleKeydown)
788
+ control.removeEventListener("focus", this.handleFocus)
789
+ this.control = undefined
790
+ }
791
+ this.boundForm?.removeEventListener("reset", this.handleReset)
792
+ this.boundForm = undefined
793
+ this.list?.removeEventListener("mousedown", this.handleListMouseDown)
794
+ this.list = undefined
795
+ this.empty?.removeEventListener("mousedown", this.handleListMouseDown)
796
+ this.empty = undefined
797
+ }
798
+
799
+ /**
800
+ * Reports when an update replaced the field because the id from the server
801
+ * changed.
802
+ *
803
+ * The message comes only when the user had something to lose: the old field
804
+ * is out of the tree, both had an id from the server, the ids differ, `name`
805
+ * is the same, and the old field held something other than the new one. A
806
+ * server that sends the text back has not lost it.
807
+ */
808
+ private warnAboutReplacedControl(
809
+ lost: HTMLInputElement,
810
+ control: HTMLInputElement,
811
+ ): void {
812
+ if (lost.isConnected || !lost.id || !control.id || lost.id === control.id)
813
+ return
814
+ if (lost.getAttribute("name") !== control.getAttribute("name")) return
815
+ if (lost.value === control.value) return
816
+
817
+ warnAboutMarkup(
818
+ this,
819
+ "a server update replaced the field and lost what the user had " +
820
+ "typed, along with focus and the list, because the field's id " +
821
+ "was different from last time and the update did not recognise " +
822
+ "it. Build the id from something that is the same every time, " +
823
+ "such as the field name or a key in the data, not from a random " +
824
+ "number or a counter.",
825
+ // The replacement has happened and cannot undo itself, unless another
826
+ // update has already replaced the field again.
827
+ () =>
828
+ !lost.isConnected &&
829
+ this.querySelector<HTMLInputElement>(CONTROL_SELECTOR) === control,
830
+ )
831
+ }
832
+
833
+ private bind(): void {
834
+ const control = this.querySelector<HTMLInputElement>(CONTROL_SELECTOR)
835
+ if (!control) {
836
+ warnAboutMarkup(
837
+ this,
838
+ 'found no field: neither an <input> nor a [role="combobox"]. ' +
839
+ "Without one the component does not know which field to listen " +
840
+ "to, and neither filtering nor the arrow keys work.",
841
+ // An empty element is a region the server has not filled yet, and not
842
+ // an error in the markup.
843
+ () =>
844
+ this.childElementCount > 0 &&
845
+ this.querySelector(CONTROL_SELECTOR) === null,
846
+ )
847
+ // The field is gone. Firefox sends no `focusout` when the focused node is
848
+ // removed, so the list would otherwise stay open without a field.
849
+ this.wantOpen = undefined
850
+ this.active = undefined
851
+ const orphan = this.listElement
852
+ if (orphan) setFlag(orphan, "hidden", true)
853
+ return
854
+ }
855
+
856
+ warnAboutMarkup(
857
+ this,
858
+ 'found no list: neither a [role="listbox"] nor a ' +
859
+ ".lt-suggestion__list. The options cannot be shown, filtered or " +
860
+ "chosen with the keyboard.",
861
+ () => this.querySelector(CONTROL_SELECTOR) !== null && !this.listElement,
862
+ )
863
+
864
+ // The number of matches is announced in a status element the server
865
+ // sends. Without it, people who cannot see the screen get no word that the
866
+ // list narrowed, and the web component used to stay silent then.
867
+ warnAboutMarkup(
868
+ this,
869
+ 'found no [role="status"], so the number of matches is not ' +
870
+ "announced to people who cannot see the screen. Write an empty " +
871
+ "element with that role, as `lt.suggestion()` does.",
872
+ () =>
873
+ this.querySelector(CONTROL_SELECTOR) !== null &&
874
+ this.listElement !== null &&
875
+ this.querySelector(LT_SUGGESTION_PARTS.status) === null,
876
+ )
877
+
878
+ if (control !== this.control) {
879
+ if (this.control) this.warnAboutReplacedControl(this.control, control)
880
+ // The new field has no focus. The open list and the highlight belonged to
881
+ // the old one.
882
+ this.wantOpen = undefined
883
+ this.active = undefined
884
+ // A value field the server filled applies to the text in the field now.
885
+ const valueField = this.valueField
886
+ this.serverChoice =
887
+ valueField && valueField.value !== ""
888
+ ? { label: control.value, value: valueField.value }
889
+ : undefined
890
+ this.chosen = this.serverChoice
891
+ this.control?.removeEventListener("input", this.handleInput)
892
+ this.control?.removeEventListener("keydown", this.handleKeydown)
893
+ this.control?.removeEventListener("focus", this.handleFocus)
894
+ control.addEventListener("input", this.handleInput)
895
+ control.addEventListener("keydown", this.handleKeydown)
896
+ control.addEventListener("focus", this.handleFocus)
897
+ this.control = control
898
+ }
899
+ // The form is read every time: `form` on the field can point to another
900
+ // one without anything moving.
901
+ const form = control.form
902
+ if (form !== this.boundForm) {
903
+ this.boundForm?.removeEventListener("reset", this.handleReset)
904
+ form?.addEventListener("reset", this.handleReset)
905
+ this.boundForm = form
906
+ }
907
+ const code = this.valueField?.value ?? ""
908
+ if (
909
+ code !== "" &&
910
+ code !== this.writtenCode &&
911
+ code !== this.chosen?.value
912
+ ) {
913
+ // Something other than the web component wrote the code: the server's word.
914
+ this.serverChoice = { label: control.value, value: code }
915
+ this.chosen = this.serverChoice
916
+ }
917
+
918
+ /*
919
+ * A press in the list or on the empty message must not take focus from the
920
+ * field, not on the scrollbar either. Otherwise `focusout` closed the list
921
+ * before the choice could happen. Both can be replaced by a patch, so the
922
+ * listeners follow the elements.
923
+ */
924
+ const list = this.listElement
925
+ if (list !== this.list) {
926
+ this.list?.removeEventListener("mousedown", this.handleListMouseDown)
927
+ list?.addEventListener("mousedown", this.handleListMouseDown)
928
+ this.list = list ?? undefined
929
+ }
930
+ const empty = this.emptyElement
931
+ if (empty !== this.empty) {
932
+ this.empty?.removeEventListener("mousedown", this.handleListMouseDown)
933
+ empty?.addEventListener("mousedown", this.handleListMouseDown)
934
+ this.empty = empty ?? undefined
935
+ }
936
+
937
+ // The options are replaced independently of the field: in a Datastar app
938
+ // the server sends a new list while the user types. Had this been behind
939
+ // the check above, the new options would never have got their listener,
940
+ // and choosing with the mouse would have stopped working after the first
941
+ // update.
942
+ for (const option of this.options) {
943
+ if (this.bound.has(option)) continue
944
+ option.addEventListener("mousedown", this.handleOptionMouseDown)
945
+ option.addEventListener("click", this.handleOptionClick)
946
+ this.bound.add(option)
947
+ }
948
+ }
949
+
950
+ private setOpen(open: boolean): void {
951
+ /*
952
+ * A list without options below an empty field has nothing to show, and is
953
+ * not opened. Without this, focus opened an empty list, and a field waiting
954
+ * for its first search showed "No matches" before the user had typed a
955
+ * character: the empty message follows the list, and the list was open.
956
+ */
957
+ const show = open && this.hasSomethingToShow()
958
+ this.wantOpen = show
959
+ this.applyOpen(show)
960
+ }
961
+
962
+ private hasSomethingToShow(): boolean {
963
+ return this.options.length > 0 || (this.control?.value ?? "").trim() !== ""
964
+ }
965
+
966
+ /** Writes the state into the markup, without changing what the user wanted. */
967
+ private applyOpen(open: boolean): void {
968
+ const list = this.listElement
969
+ if (!list || !this.control) return
970
+
971
+ setFlag(list, "hidden", !open)
972
+ setAttr(this.control, "aria-expanded", String(open))
973
+ this.syncEmpty()
974
+
975
+ if (!open) {
976
+ this.active = undefined
977
+ this.control.removeAttribute("aria-activedescendant")
978
+ for (const option of this.options) {
979
+ setAttr(option, "aria-selected", "false")
980
+ }
981
+ }
982
+ }
983
+
984
+ /**
985
+ * The empty message follows the list: visible only when the list is open and
986
+ * empty.
987
+ *
988
+ * It is a sibling of the list and not a child, so `hidden` on the list did
989
+ * not hide it, and "No matches" stayed below a closed field after Escape.
990
+ * If something else has filtered, it also controls the empty message.
991
+ */
992
+ private syncEmpty(): void {
993
+ if (this.prefiltered) return
994
+ const empty = this.emptyElement
995
+ const list = this.listElement
996
+ if (!empty || !list) return
997
+ setFlag(empty, "hidden", Boolean(list.hidden) || this.visible.length > 0)
998
+ }
999
+
1000
+ /**
1001
+ * Hides what does not match, and announces how many are left.
1002
+ *
1003
+ * Runs when the user types or focuses the field, and again after a patch
1004
+ * while the list is open, since the patch may have replaced the options.
1005
+ */
1006
+ private filter(): void {
1007
+ /*
1008
+ * If something else has filtered, it decides what the list shows, and the
1009
+ * empty message is part of that. The web component therefore touches
1010
+ * nothing here, and the announcement comes from `announceFiltered`
1011
+ * instead.
1012
+ *
1013
+ * Two versions before this one were wrong. The first returned at once, and
1014
+ * then a Datastar app had no announcement of the number of matches at all.
1015
+ * The second counted here: then an empty field announced "No matches"
1016
+ * already on focus, before the user had typed a character, and an
1017
+ * asynchronous search announced it again on every keystroke while the
1018
+ * answer was still on its way. The web component also made the empty
1019
+ * message visible again after the app had hidden it, and there was no way
1020
+ * around it.
1021
+ */
1022
+ if (this.prefiltered) return
1023
+
1024
+ const query = (this.control?.value ?? "").trim().toLowerCase()
1025
+ for (const option of this.options) {
1026
+ const label = (option.textContent ?? "").trim().toLowerCase()
1027
+ setFlag(option, "hidden", query !== "" && !label.includes(query))
1028
+ }
1029
+
1030
+ this.dropHiddenMark()
1031
+ this.syncEmpty()
1032
+ this.announce(this.visible.length)
1033
+ }
1034
+
1035
+ /**
1036
+ * Removes the highlight from an option the filtering just hid.
1037
+ *
1038
+ * The cleanup belongs here, where the hiding happens, and not only in
1039
+ * `repair()`: that returns at once under `server-controlled`, and then
1040
+ * `aria-activedescendant` pointed to a hidden option, which the screen
1041
+ * reader announced anyway.
1042
+ */
1043
+ private dropHiddenMark(): void {
1044
+ const control = this.control
1045
+ if (!control) return
1046
+
1047
+ const marked = this.options.find(
1048
+ (option) => option.getAttribute("aria-selected") === "true",
1049
+ )
1050
+ if (marked?.hidden) {
1051
+ setAttr(marked, "aria-selected", "false")
1052
+ if (this.active?.id === marked.id) this.active = undefined
1053
+ }
1054
+
1055
+ const pointed = control.getAttribute("aria-activedescendant")
1056
+ if (pointed) {
1057
+ const target = this.options.find((option) => option.id === pointed)
1058
+ if (!target || target.hidden) {
1059
+ control.removeAttribute("aria-activedescendant")
1060
+ }
1061
+ }
1062
+ }
1063
+
1064
+ /**
1065
+ * Announces the count when something else has filtered.
1066
+ *
1067
+ * The list then changes not from a keystroke but from the app rendering
1068
+ * again, and that is exactly the change the observer sees. The message
1069
+ * belongs here and not in `filter()`, because half a second over the
1070
+ * network can pass between the keystroke and the new answer.
1071
+ *
1072
+ * Only changes are announced. The first count is the starting point and not
1073
+ * news, and it is often 0 because the app has not rendered the list yet.
1074
+ */
1075
+ private announceFiltered(): void {
1076
+ if (!this.prefiltered) return
1077
+
1078
+ const hits = this.visible.length
1079
+ if (hits === this.announcedCount) return
1080
+
1081
+ const first = this.announcedCount === undefined
1082
+ this.announcedCount = hits
1083
+ if (!first) this.announce(hits)
1084
+ }
1085
+
1086
+ /**
1087
+ * Announces the number of matches.
1088
+ *
1089
+ * Without this, people who cannot see the screen get no word that the list
1090
+ * narrowed while they typed. The text is generated on the client, and
1091
+ * `repair()` writes it back when an update empties the element. If the
1092
+ * element is missing, `bind()` has already reported it.
1093
+ */
1094
+ private announce(hits: number): void {
1095
+ const status = this.querySelector<HTMLElement>(LT_SUGGESTION_PARTS.status)
1096
+ if (!status) return
1097
+
1098
+ this.announcedText = this.countText(hits)
1099
+ setText(status, this.announcedText)
1100
+ }
1101
+
1102
+ /**
1103
+ * The text for a count, in the page's language.
1104
+ *
1105
+ * `count-none` applies to zero matches, since "No matches" is a sentence of
1106
+ * its own and not an inflection. Otherwise `Intl.PluralRules` picks the form
1107
+ * the language requires, and `count-<form>` gives the text, with `{n}` for
1108
+ * the number. If the form is missing, `count-other` applies. If that is
1109
+ * missing too, the default text from `DEFAULT_TEXTS` applies.
1110
+ */
1111
+ private countText(hits: number): string {
1112
+ const lang = languageOf(this)
1113
+ const written = (name: string) => this.getAttribute(name)
1114
+ const form = new Intl.PluralRules(lang).select(hits)
1115
+ const template =
1116
+ (hits === 0 ? written("count-none") : null) ??
1117
+ written(`count-${form}`) ??
1118
+ written("count-other")
1119
+ const number = new Intl.NumberFormat(lang).format(hits)
1120
+ if (template !== null) return template.replaceAll("{n}", number)
1121
+ const fallback =
1122
+ hits === 0
1123
+ ? DEFAULT_TEXTS.suggestionNone
1124
+ : hits === 1
1125
+ ? DEFAULT_TEXTS.suggestionOne
1126
+ : DEFAULT_TEXTS.suggestionOther
1127
+ return fallback.replaceAll("{n}", number)
1128
+ }
1129
+
1130
+ private markActive(index: number): void {
1131
+ const shown = this.selectable
1132
+ if (shown.length === 0 || !this.control) return
1133
+
1134
+ const next = (index + shown.length) % shown.length
1135
+
1136
+ this.markOption(shown[next])
1137
+ shown[next].scrollIntoView({ block: "nearest" })
1138
+ }
1139
+
1140
+ /** Highlights one option, and remembers which. */
1141
+ private markOption(option: HTMLElement): void {
1142
+ if (!this.control) return
1143
+
1144
+ for (const other of this.options) {
1145
+ setAttr(other, "aria-selected", other === option ? "true" : "false")
1146
+ }
1147
+
1148
+ this.active = {
1149
+ id: option.id,
1150
+ label: (option.textContent ?? "").trim(),
1151
+ value: option.getAttribute("data-value"),
1152
+ }
1153
+ // aria-activedescendant moves the screen reader's reading position without
1154
+ // focus leaving the field. Without it the option is never announced.
1155
+ setAttr(this.control, "aria-activedescendant", option.id)
1156
+ }
1157
+
1158
+ private choose(option: HTMLElement): void {
1159
+ if (!this.control || this.locked) return
1160
+ if (option.getAttribute("aria-disabled") === "true") return
1161
+
1162
+ const label = (option.textContent ?? "").trim()
1163
+ const value = option.getAttribute("data-value") ?? label
1164
+ /*
1165
+ * The setter from the prototype chain, past a `value` React has put on the
1166
+ * node itself to track it. Set directly on the node, React saw no change,
1167
+ * `onChange` never came, and on the next render React wrote the old text
1168
+ * back into the field. The chain and not just the nearest prototype, so
1169
+ * `<textarea>` and a subclass of `<input>` work too.
1170
+ */
1171
+ let setter: ((v: string) => void) | undefined
1172
+ for (
1173
+ let proto = Object.getPrototypeOf(this.control);
1174
+ proto && !setter;
1175
+ proto = Object.getPrototypeOf(proto)
1176
+ ) {
1177
+ setter = Object.getOwnPropertyDescriptor(proto, "value")?.set
1178
+ }
1179
+ // The field shows the text. The code goes to the value field, when the
1180
+ // server has written one, and in the event. `data-value` used to end up in
1181
+ // the field, and the user who chose Bergen saw "4601".
1182
+ if (setter) setter.call(this.control, label)
1183
+ else this.control.value = label
1184
+
1185
+ const valueField = this.valueField
1186
+ if (valueField) {
1187
+ this.writeCode(valueField, value)
1188
+ this.chosen = { label, value }
1189
+ }
1190
+
1191
+ // The flag must be set before the events are dispatched. `input` is the
1192
+ // same event the web component listens to, so without this the list opens
1193
+ // again the moment the user has chosen something.
1194
+ this.choosing = true
1195
+ this.control.dispatchEvent(new Event("input", { bubbles: true }))
1196
+ this.control.dispatchEvent(new Event("change", { bubbles: true }))
1197
+ this.choosing = false
1198
+
1199
+ this.setOpen(false)
1200
+ this.dispatchEvent(
1201
+ new CustomEvent("suggestion-select", {
1202
+ detail: { value, label },
1203
+ bubbles: true,
1204
+ composed: true,
1205
+ }),
1206
+ )
1207
+ }
1208
+
1209
+ /**
1210
+ * Writes the code in the value field and dispatches `input` and `change` on
1211
+ * it, so `data-bind` in Datastar sees the code. The events do not bubble: a
1212
+ * `data-on:input` on the host, which the recipe uses to fetch suggestions,
1213
+ * must not trigger a search for the code every time the user chooses.
1214
+ */
1215
+ private writeCode(valueField: HTMLInputElement, code: string): void {
1216
+ if (valueField.value === code) return
1217
+ this.writtenCode = code
1218
+ setAttr(valueField, "value", code)
1219
+ // A hidden field reads the attribute. If the field has another type and a
1220
+ // morph has already set the property, the attribute no longer counts.
1221
+ if (valueField.value !== code) valueField.value = code
1222
+ valueField.dispatchEvent(new Event("input"))
1223
+ valueField.dispatchEvent(new Event("change"))
1224
+ }
1225
+
1226
+ /**
1227
+ * The form is reset. The browser sets the text back to the server's after
1228
+ * the event has been dispatched, so the code is set in the next turn, after
1229
+ * the reset is done. The text there then decides: if it is the server's,
1230
+ * the server's code applies; if it is the chosen one, the code stays;
1231
+ * otherwise the code of the option with that text, otherwise none. The
1232
+ * server's comes first because two options can have the same name: if the
1233
+ * user chose the second Våler, the reset goes back to the server's. The
1234
+ * server may also have sent the chosen one back, and then it is as much the
1235
+ * server's word as the user's.
1236
+ */
1237
+ private handleReset = (): void => {
1238
+ setTimeout(() => {
1239
+ const valueField = this.valueField
1240
+ if (!valueField || !this.control) return
1241
+ const text = this.control.value
1242
+ const option = this.options.find(
1243
+ (o) => (o.textContent ?? "").trim() === text,
1244
+ )
1245
+ const code =
1246
+ this.serverChoice && text === this.serverChoice.label
1247
+ ? this.serverChoice.value
1248
+ : this.chosen && text === this.chosen.label
1249
+ ? this.chosen.value
1250
+ : option
1251
+ ? (option.getAttribute("data-value") ?? text)
1252
+ : ""
1253
+ this.writeCode(valueField, code)
1254
+ this.chosen = code !== "" ? { label: text, value: code } : undefined
1255
+ }, 0)
1256
+ }
1257
+
1258
+ private handleInput = (): void => {
1259
+ if (this.choosing) return
1260
+ // The user changed the text after a choice. The code no longer applies.
1261
+ // If the user types the chosen text again, after an update emptied both,
1262
+ // the code comes back at once.
1263
+ const valueField = this.valueField
1264
+ if (valueField && this.chosen) {
1265
+ if (this.control?.value !== this.chosen.label) {
1266
+ this.writeCode(valueField, "")
1267
+ this.chosen = undefined
1268
+ } else if (valueField.value === "") {
1269
+ this.writeCode(valueField, this.chosen.value)
1270
+ }
1271
+ }
1272
+ if (this.locked) return
1273
+ this.filter()
1274
+ this.setOpen(true)
1275
+ }
1276
+
1277
+ private handleFocus = (): void => {
1278
+ if (this.locked) return
1279
+ this.filter()
1280
+ this.setOpen(true)
1281
+ }
1282
+
1283
+ private handleFocusOut = (event: FocusEvent): void => {
1284
+ // If focus goes to something inside the web component, such as a button
1285
+ // next to the field, the list stays.
1286
+ const next = event.relatedTarget
1287
+ if (next instanceof Node && this.contains(next)) return
1288
+ this.setOpen(false)
1289
+ }
1290
+
1291
+ private handleKeydown = (event: KeyboardEvent): void => {
1292
+ // During IME composition the keys belong to the composition. Enter
1293
+ // confirms the characters, and must not choose the highlighted option.
1294
+ if (event.isComposing || event.keyCode === 229) return
1295
+ if (this.locked) return
1296
+ const open = this.listElement?.hidden === false
1297
+
1298
+ if (event.key === "ArrowDown") {
1299
+ event.preventDefault()
1300
+ if (!open) {
1301
+ this.filter()
1302
+ this.setOpen(true)
1303
+ }
1304
+ this.markActive(this.activeIndex + 1)
1305
+ } else if (event.key === "ArrowUp") {
1306
+ event.preventDefault()
1307
+ // The list must open first, as with arrow down. Without that
1308
+ // `aria-activedescendant` pointed to an option in a list the field at the
1309
+ // same time reported as closed, and the screen reader announced something
1310
+ // that was not on screen.
1311
+ if (!open) {
1312
+ this.filter()
1313
+ this.setOpen(true)
1314
+ }
1315
+ // If nothing is highlighted, arrow up goes to the last one.
1316
+ // `activeIndex - 1` would have given the second to last, since nothing
1317
+ // highlighted is -1.
1318
+ this.markActive(this.activeIndex < 0 ? -1 : this.activeIndex - 1)
1319
+ } else if (
1320
+ (event.key === "PageUp" || event.key === "PageDown") &&
1321
+ open &&
1322
+ this.selectable.length > 0
1323
+ ) {
1324
+ // Home and End move the caret in the text, and are left alone. PgUp and
1325
+ // PgDn go to the first and last option.
1326
+ event.preventDefault()
1327
+ this.markActive(event.key === "PageUp" ? 0 : this.selectable.length - 1)
1328
+ } else if (event.key === "Enter" && open && this.activeIndex >= 0) {
1329
+ event.preventDefault()
1330
+ const chosen = this.selectable[this.activeIndex]
1331
+ if (chosen) this.choose(chosen)
1332
+ } else if (event.key === "Escape" && open) {
1333
+ event.preventDefault()
1334
+ this.setOpen(false)
1335
+ }
1336
+ }
1337
+
1338
+ private handleListMouseDown = (event: Event): void => {
1339
+ event.preventDefault()
1340
+ }
1341
+
1342
+ private handleOptionMouseDown = (event: Event): void => {
1343
+ // Keeps focus in the field. Without this the field lost focus before
1344
+ // `click`, and `focusout` closed the list before the choice could happen.
1345
+ event.preventDefault()
1346
+ }
1347
+
1348
+ private handleOptionClick = (event: Event): void => {
1349
+ // The choice happens on `click`, which assistive technology also sends
1350
+ // when it activates an element. It used to happen on `mousedown`, and a
1351
+ // `click` alone chose nothing.
1352
+ this.choose(event.currentTarget as HTMLElement)
1353
+ }
1354
+ }
1355
+
1356
+ export function defineLtSuggestion(tagName: string = LT_SUGGESTION_TAG): void {
1357
+ defineElement(tagName, LtSuggestion)
1358
+ }