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