@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,1395 @@
1
+ import {
2
+ defineElement,
3
+ derivedParts,
4
+ HostElement,
5
+ isServerControlled,
6
+ markWritten,
7
+ SERVER_CONTROLLED,
8
+ setAttr,
9
+ setFlag,
10
+ stillMarked,
11
+ uniqueId,
12
+ WRITTEN,
13
+ warnAboutCopiedMark,
14
+ warnAboutMarkup,
15
+ } from "../../host-element.js"
16
+ export const LT_TABS_TAG = "lt-tabs" as const
17
+
18
+ /**
19
+ * The parts the tabs look for, as selectors relative to the element. The
20
+ * web component finds its parts with these, and the manifest describes them
21
+ * for anyone writing the markup by hand, so the two cannot disagree.
22
+ */
23
+ export const LT_TABS_PARTS = {
24
+ /** The tab list: the one the server gave the role, or the class from `tabs.css`. */
25
+ list: "[role='tablist'], .lt-tabs__list",
26
+ /**
27
+ * A tab is a link to a panel in the tab list. A link is the only tab
28
+ * pattern that works without JavaScript: without the script the tab list is
29
+ * a table of contents, and the links lead to the panels. If the server has
30
+ * written the roles, only the links with `role="tab"` are tabs.
31
+ */
32
+ tab: "a[role='tab'][href^='#'], .lt-tabs__list a[href^='#']:not([role])",
33
+ /** A panel: the role when it is there, otherwise the class without a role. */
34
+ panel: "[role='tabpanel'], .lt-tabs__panel:not([role])",
35
+ } as const
36
+
37
+ const LIST_SELECTOR = LT_TABS_PARTS.list
38
+ const TAB_SELECTOR = LT_TABS_PARTS.tab
39
+ /** What the server has called a tab, including what is not a link. */
40
+ const ANY_TAB_SELECTOR = "[role='tab']"
41
+ /** The heading in a panel, which gives the panel its name. */
42
+ const HEADING_SELECTOR = ":scope > :is(h1, h2, h3, h4, h5, h6)"
43
+
44
+ /**
45
+ * The id a tab points to, or `null` when it is not a link to a fragment.
46
+ * `href="#"` on its own points to nothing.
47
+ *
48
+ * `href` is the truth, and `aria-controls` is written from it. The fragment
49
+ * is looked up the way the browser does: as written, and otherwise decoded,
50
+ * so both `#attachments` and `#s%C3%B8knaden` find their panel.
51
+ */
52
+ function linkTarget(tab: Element): string | null {
53
+ if (tab.localName !== "a") return null
54
+ const href = tab.getAttribute("href") ?? ""
55
+ if (!href.startsWith("#") || href.length < 2) return null
56
+ return href.slice(1)
57
+ }
58
+
59
+ function decoded(fragment: string): string | null {
60
+ try {
61
+ const plain = decodeURIComponent(fragment)
62
+ return plain === fragment ? null : plain
63
+ } catch {
64
+ return null
65
+ }
66
+ }
67
+
68
+ /** Text as a name is computed: whitespace collapsed, and trimmed. */
69
+ function flat(text: string): string {
70
+ return text.replace(/\s+/g, " ").trim()
71
+ }
72
+
73
+ /** Is the element hidden from assistive technology: `hidden`, `aria-hidden` or CSS? */
74
+ function hiddenFromAT(element: Element): boolean {
75
+ if (element.getAttribute("aria-hidden") === "true") return true
76
+ if (element.hasAttribute("hidden")) return true
77
+ if (!(element instanceof HTMLElement) || !element.isConnected) return false
78
+ const style = getComputedStyle(element)
79
+ return style.display === "none" || style.visibility === "hidden"
80
+ }
81
+
82
+ /**
83
+ * The text an element contributes to a name, as AccName 1.2 computes it from
84
+ * step 2.
85
+ *
86
+ * `aria-labelledby` (2B), `aria-label` on something inside, `alt` on an
87
+ * image, and the text, but not what is hidden from assistive technology.
88
+ * `textContent` included hidden text, such as "Messages (3)", and gave an
89
+ * empty name to a tab with only an icon and `aria-label`.
90
+ *
91
+ * - `includeHidden`: when a hidden element is pointed to directly with
92
+ * `aria-labelledby`, it is included anyway, with everything inside it (2A).
93
+ * - `inReference`: the node is inside an `aria-labelledby` that is already
94
+ * being followed, and then another one is not followed (2B).
95
+ *
96
+ * Text from CSS, `::before` and `::after` (2F.ii), is not included. LO-TEK's
97
+ * tabs do not use it, and reading `content` correctly is a lot of code for
98
+ * something nobody needs (decided by the maintainer on 23 October).
99
+ */
100
+ function textOf(
101
+ node: Node,
102
+ includeHidden = false,
103
+ inReference = false,
104
+ ): string {
105
+ if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? ""
106
+ if (!(node instanceof Element)) return ""
107
+ if (!includeHidden && hiddenFromAT(node)) return ""
108
+ if (!inReference) {
109
+ const referenced = referencedText(node)
110
+ if (referenced !== "") return ` ${referenced} `
111
+ }
112
+ const label = node.getAttribute("aria-label")
113
+ if (label && flat(label) !== "") return ` ${label} `
114
+ if (node.localName === "img") return ` ${node.getAttribute("alt") ?? ""} `
115
+ let text = ""
116
+ for (const child of node.childNodes)
117
+ text += textOf(child, includeHidden, inReference)
118
+ return text
119
+ }
120
+
121
+ /** The text from the elements `aria-labelledby` points to, or empty (AccName 2B). */
122
+ function referencedText(element: Element): string {
123
+ const ids = element.getAttribute("aria-labelledby")
124
+ if (!ids) return ""
125
+ const root = element.getRootNode() as Document | ShadowRoot
126
+ return flat(
127
+ ids
128
+ .split(/\s+/)
129
+ .map((id) => (id === "" ? null : root.getElementById?.(id)))
130
+ .filter((e): e is HTMLElement => Boolean(e))
131
+ .map((e) => textOf(e, hiddenFromAT(e), true))
132
+ .join(" "),
133
+ )
134
+ }
135
+
136
+ /** The name of an element, as a screen reader computes it. */
137
+ function accessibleName(element: Element): string {
138
+ const referenced = referencedText(element)
139
+ if (referenced !== "") return referenced
140
+ const label = element.getAttribute("aria-label")
141
+ if (label && flat(label) !== "") return flat(label)
142
+ let text = ""
143
+ for (const child of element.childNodes) text += textOf(child)
144
+ if (flat(text) !== "") return flat(text)
145
+ return flat(element.getAttribute("title") ?? "")
146
+ }
147
+
148
+ /**
149
+ * The name a panel gets when it points to the element with `aria-labelledby`.
150
+ *
151
+ * Not the same as the element's name: inside an `aria-labelledby`, the
152
+ * element's `aria-labelledby` is not followed (AccName 2B), and a hidden
153
+ * element is included (2A). A heading with `aria-labelledby` pointing to
154
+ * "Attachments" and the text "Enclosures" gave the panel the name
155
+ * "Enclosures", while the web component thought it was "Attachments".
156
+ */
157
+ function nameAsReferenced(element: Element): string {
158
+ return flat(textOf(element, hiddenFromAT(element), true))
159
+ }
160
+
161
+ /**
162
+ * What the web component last wrote on a node, and what the server last said.
163
+ *
164
+ * `aria-selected` on the tabs and `hidden` on the panels are both read and
165
+ * written. A naive read is the web component's echo of itself: bare markup
166
+ * got `aria-selected="true"` on the first tab from the web component, and
167
+ * when the server switched tab with a patch that only touched the panels,
168
+ * the web component read its earlier selection back and hid the panel the
169
+ * server had just shown. If something other than what the web component
170
+ * wrote is there, the server has touched it, and it is the server's word. If
171
+ * the same is there, what the server said last applies.
172
+ */
173
+ type Word = { written: string | null; server: string | null }
174
+
175
+ /**
176
+ * Whether a morph from the server has been there since the web component
177
+ * wrote the attribute: the mark it wrote is gone. Only a value that differed
178
+ * from the server's word was marked (`markWritten`); one that did not has
179
+ * no mark to lose, and its absence says nothing. Read as a sign of the
180
+ * server, every unmarked tab in a row of three released a click under
181
+ * `server-controlled` at once.
182
+ */
183
+ function markGone(element: Element, name: string, word: Word): boolean {
184
+ return word.written !== word.server && !stillMarked(element, name)
185
+ }
186
+
187
+ /**
188
+ * Keyboard behaviour in a tab list, and the wiring when the markup comes
189
+ * without it.
190
+ *
191
+ * The tabs are links to their panels, the way GOV.UK does it:
192
+ *
193
+ * ```html
194
+ * <lt-tabs>
195
+ * <ul class="lt-tabs__list" aria-label="Parts of the case">
196
+ * <li><a href="#application">Application</a></li>
197
+ * <li><a href="#attachments">Attachments</a></li>
198
+ * </ul>
199
+ * <section class="lt-tabs__panel" id="application"><h2>Application</h2>…</section>
200
+ * <section class="lt-tabs__panel" id="attachments"><h2>Attachments</h2>…</section>
201
+ * </lt-tabs>
202
+ * ```
203
+ *
204
+ * Without JavaScript this is a table of contents with all the panels one
205
+ * after another, and the links work. What the template writes is an id on
206
+ * the panels, and it needs the links anyway.
207
+ *
208
+ * The web component never writes an id on a tab or a panel. Datastar and
209
+ * idiomorph recognise an element by its id, and an element with an id the
210
+ * server did not send is replaced at the next update, along with the tab
211
+ * selection and focus. The panels' ids come from the server, so they are
212
+ * kept. What the web component fills in, it computes from the markup: the
213
+ * roles, `aria-controls` from `href`, `role="presentation"` on the list
214
+ * items, and the panel's name, which is the tab's. The heading in the panel
215
+ * is the name when it says the same as the tab. Only the selected panel is a
216
+ * tab stop.
217
+ *
218
+ * The panels that are not selected are hidden with `hidden="until-found"`, so
219
+ * find in page and a link to something in a panel reach them, and the
220
+ * browser fires `beforematch`, which the web component selects the tab from.
221
+ * If the server wrote `hidden` itself, it is written back in the server's
222
+ * form, across the whole tab list: `lt.tabs()` from
223
+ * `@lo-tek/interfaces/react` gives `hidden=""`, because React cannot write
224
+ * `until-found`. The address selects the tab when the page loads and when it
225
+ * changes, and each selection is written to the address, unless it already
226
+ * points to the selected panel or something in it. On load the server's
227
+ * `aria-selected` comes before the address, and under `server-controlled`
228
+ * the address never selects.
229
+ *
230
+ * What the server has written stays. Which tab is selected is read from
231
+ * `aria-selected`, and when that is missing, from which panel is not hidden;
232
+ * if nothing is said, it is the first. Before the web component is
233
+ * registered, `tabs.css` shows only the panel the web component is going to
234
+ * select: the first visible one, or the one the address points to, so the
235
+ * content does not jump when the rest are hidden.
236
+ *
237
+ * What the web component then does is what the browser does not do by
238
+ * itself: Tab skips past the whole tab list and into the panel, while the
239
+ * arrow keys move between the tabs. With `activation="manual"` the arrow keys
240
+ * only move focus, and Enter or Space selects.
241
+ *
242
+ * The tab selection belongs to the user, and the web component restores it
243
+ * when a patch strips it. If the server needs to move the tab, as in
244
+ * "continue to step 2", set `server-controlled` on the host, and then every
245
+ * patch decides.
246
+ */
247
+ export class LtTabs extends HostElement {
248
+ /**
249
+ * `server-controlled` and `activation`. Which tab is selected is in the
250
+ * markup the server sent, as `aria-selected` on the tab and `hidden` on the
251
+ * panels, and is read from there. A separate `selected` would have been a
252
+ * parallel copy of the same thing.
253
+ */
254
+ static observedAttributes = [SERVER_CONTROLLED, "activation"] as const
255
+
256
+ private observer?: MutationObserver
257
+ /**
258
+ * The panel the user's tab points to, remembered so a patch cannot take
259
+ * the selection.
260
+ *
261
+ * Not the index and not the node. An index is not an identity: if the
262
+ * server inserts a tab first in the list, the same index points to
263
+ * something else. The node is not one either: the links have no id, so
264
+ * Datastar and idiomorph match them by position, and the node the user
265
+ * pressed became the inserted tab. The panel's id is the server's and
266
+ * stays; a tab pointing to another panel is another tab.
267
+ *
268
+ * Remembered with the link, `chosenTab`.
269
+ *
270
+ * `undefined` means the user has not selected anything yet, and then the
271
+ * server's markup applies.
272
+ */
273
+ private chosenTarget?: string
274
+ /**
275
+ * The link the user pressed, with `chosenTarget`. While it is in the tab
276
+ * list and points to the same panel, it is the user's tab, also when
277
+ * another tab points to that panel too. When a morph has turned it into
278
+ * another tab, or dropped it while it kept other links, the tab that points
279
+ * to the panel is found. When the links were replaced, none of the row is
280
+ * left, as `innerHTML` does, and the selection is forgotten rather than
281
+ * guessed; the address still has it, see `selectFromAddress`.
282
+ */
283
+ private chosenTab?: HTMLElement
284
+ /** The tab list as it was when the user's tab was last found. */
285
+ private chosenRow: HTMLElement[] = []
286
+ /**
287
+ * Roles the web component set itself. Tells a bare tab list, where every
288
+ * link is a tab, from one where the server wrote the roles, where a link
289
+ * without a role is something else, such as a help link.
290
+ */
291
+ private readonly roledByMe = new WeakSet<Element>()
292
+ private selectedMemory = new WeakMap<Element, Word>()
293
+ private hiddenMemory = new WeakMap<Element, Word>()
294
+ /**
295
+ * The form the server hides panels in, the last time it hid something:
296
+ * `""` from `lt.tabs()`, `"until-found"` or something else. The web
297
+ * component writes `hidden` in that form on every panel in the tab list,
298
+ * including a panel the server never hid. Otherwise one panel got
299
+ * `until-found` and the others `""` in the same tab list. If the server has
300
+ * never hidden anything, it is `until-found`, so find in page reaches the
301
+ * panel.
302
+ */
303
+ private hiddenForm = "until-found"
304
+ /**
305
+ * The id the web component gave the heading in a panel, per panel. A morph
306
+ * strips the id from the heading and `aria-labelledby` from the panel,
307
+ * since neither was in the server's HTML, and the panel must get the same
308
+ * one back, so a screen reader does not follow a reference that changes
309
+ * under it.
310
+ */
311
+ private readonly headingIds = new WeakMap<Element, string>()
312
+ /** The name the web component wrote in `aria-label` on a panel without a heading. */
313
+ private readonly labelByMe = new WeakMap<Element, string>()
314
+ /**
315
+ * The tab the user clicked under `server-controlled`, until the server
316
+ * answers.
317
+ *
318
+ * It is not the user's selection in the sense of `chosenTarget`: it is never
319
+ * restored against the server's word. It only lasts until someone else has
320
+ * touched `aria-selected` or `hidden`, added a tab or a panel, removed the
321
+ * tab, or removed `server-controlled`. Without it, `repair()` read the
322
+ * server's old word from memory in the next microtask and undid the click,
323
+ * so the tabs could not be switched, and a `data-attr:hidden` that wrote the
324
+ * same as the web component had just written was never seen.
325
+ */
326
+ private pendingTab?: HTMLElement
327
+ /**
328
+ * The panel the browser is revealing after `beforematch`.
329
+ *
330
+ * The browser removes `hidden` itself after the event, and then moves on to
331
+ * the ancestors, such as an outer panel or a closed `<details>`. But only as
332
+ * long as the panel still has `hidden="until-found"` when the event is done.
333
+ * When the search comes from the user, the browser runs the microtasks after
334
+ * each listener, the observer included, and when the web component removed
335
+ * `hidden` in that round, Chromium and WebKit stopped. The match in a tab
336
+ * list inside a hidden panel stayed invisible. The panel is therefore not
337
+ * touched until the next task, when the browser is done, see `revealed()`.
338
+ */
339
+ private revealing?: Element
340
+ /**
341
+ * Whether the tab list has loaded: whether the address or the server's
342
+ * selection has been read. That happens the first time the tab list has
343
+ * tabs and the address can be resolved, see `sync()`, not when the element
344
+ * is connected. An `<lt-tabs>` the server fills in later otherwise always
345
+ * got the first tab.
346
+ */
347
+ private started = false
348
+ /**
349
+ * The root `beforematch` is listened for on. The event bubbles, but is not
350
+ * `composed`, so from a panel in a shadow root it never reaches the
351
+ * document.
352
+ */
353
+ private root?: Document | ShadowRoot
354
+
355
+ connectedCallback(): void {
356
+ /*
357
+ * The attributes are included, not just the children. A morph strips what
358
+ * is not in the server's HTML, and the tab selection is exactly that:
359
+ * something the user did after the page arrived. Every write compares
360
+ * first, or the observer would trigger itself.
361
+ */
362
+ // One listener on the host, not one per tab. Capture phase, so a listener
363
+ // on the tab list that stops the event does not take clicks and arrow
364
+ // keys from the tabs.
365
+ this.addEventListener("click", this.handleClick, true)
366
+ this.addEventListener("keydown", this.handleKeydown, true)
367
+ // Find in page, or a link to something in a panel hidden with
368
+ // `until-found`. On the document, not on the host: a panel can be outside
369
+ // `<lt-tabs>`, and then the event never reaches the host.
370
+ document.addEventListener("beforematch", this.handleBeforematch, true)
371
+ const root = this.getRootNode() as Document | ShadowRoot
372
+ if (root !== document) {
373
+ this.root = root
374
+ root.addEventListener("beforematch", this.handleBeforematch, true)
375
+ }
376
+ window.addEventListener("hashchange", this.handleHashchange)
377
+ this.observer = new MutationObserver((records) => {
378
+ if (this.concerns(records)) this.sync()
379
+ })
380
+ this.observer.observe(this, {
381
+ childList: true,
382
+ subtree: true,
383
+ attributes: true,
384
+ // The panel's name is the tab's. idiomorph changes the text in a text
385
+ // node, with nothing else the observer sees.
386
+ characterData: true,
387
+ // `disabled` is included because the web component reads it: if a patch
388
+ // disables the selected tab, the tab stop moves to one that can take
389
+ // focus. `alt`, `aria-hidden` and `title` change the name of a tab.
390
+ attributeFilter: [
391
+ // A morph from the server removes the mark, and the web component
392
+ // puts it back: the same markup must give the same page.
393
+ WRITTEN,
394
+ "aria-selected",
395
+ "tabindex",
396
+ "hidden",
397
+ "disabled",
398
+ "aria-disabled",
399
+ // The wiring the web component sets when the markup came without it,
400
+ // and restores when a patch removes it.
401
+ "role",
402
+ "id",
403
+ "href",
404
+ "aria-controls",
405
+ "aria-labelledby",
406
+ "aria-label",
407
+ "alt",
408
+ "aria-hidden",
409
+ "title",
410
+ ],
411
+ })
412
+ this.sync()
413
+ }
414
+
415
+ attributeChangedCallback(name: string): void {
416
+ // `server-controlled` switched on midway: the web component lets go, and
417
+ // the next patch decides.
418
+ if (name === SERVER_CONTROLLED && isServerControlled(this)) {
419
+ this.choose(undefined)
420
+ // What is there now is the starting point. Without this the tab list
421
+ // jumped back to the tab the server last named, before the user chose
422
+ // another. But as when the server answers a click: `aria-selected` the
423
+ // web component wrote is not the server's. When everything counted as
424
+ // the server's, it took precedence over `hidden`, and a server that
425
+ // moves the tab with `hidden` alone was not heard.
426
+ this.releasePending()
427
+ }
428
+ }
429
+
430
+ disconnectedCallback(): void {
431
+ this.observer?.disconnect()
432
+ this.observer = undefined
433
+ this.removeEventListener("click", this.handleClick, true)
434
+ this.removeEventListener("keydown", this.handleKeydown, true)
435
+ document.removeEventListener("beforematch", this.handleBeforematch, true)
436
+ this.root?.removeEventListener("beforematch", this.handleBeforematch, true)
437
+ this.root = undefined
438
+ window.removeEventListener("hashchange", this.handleHashchange)
439
+ // If the element is connected again, the address is read again.
440
+ this.started = false
441
+ }
442
+
443
+ /**
444
+ * The tabs that belong to this tab list, and only those.
445
+ *
446
+ * Tabs inside tabs are ordinary markup. Without the boundary an inner tab
447
+ * list became part of the outer one: a click on an inner tab hid the outer
448
+ * panel it was in, and both web components attached listeners to the same
449
+ * tabs.
450
+ */
451
+ private get tabs(): HTMLElement[] {
452
+ return derivedParts<HTMLElement>(
453
+ this,
454
+ TAB_SELECTOR,
455
+ "tab",
456
+ this.roledByMe,
457
+ ).filter((tab) => linkTarget(tab) !== null)
458
+ }
459
+
460
+ /** Remembers the user's tab: the link, and the panel it points to. */
461
+ private choose(tab: HTMLElement | undefined): void {
462
+ this.chosenTab = tab
463
+ this.chosenTarget = tab ? (linkTarget(tab) ?? undefined) : undefined
464
+ this.chosenRow = tab ? this.tabs : []
465
+ }
466
+
467
+ /**
468
+ * Where the user's tab is now, or -1: the link while it points to the same
469
+ * panel; the tab that points to that panel when a morph has turned the
470
+ * link into another tab, or dropped it and kept others (the morphers match
471
+ * links without ids by position, and look ahead when one is removed);
472
+ * none when the links were replaced, with nothing of the row left.
473
+ */
474
+ private chosenIndex(): number {
475
+ const tabs = this.tabs
476
+ const link = this.chosenTab
477
+ if (!link || this.chosenTarget === undefined) return -1
478
+ const at = tabs.indexOf(link)
479
+ if (at >= 0 && linkTarget(link) === this.chosenTarget) {
480
+ this.chosenRow = tabs
481
+ return at
482
+ }
483
+ const morphed = at >= 0 || this.chosenRow.some((tab) => tabs.includes(tab))
484
+ if (!morphed) return -1
485
+ const moved = tabs.findIndex((tab) => linkTarget(tab) === this.chosenTarget)
486
+ if (moved >= 0) {
487
+ this.chosenTab = tabs[moved]
488
+ this.chosenRow = tabs
489
+ }
490
+ return moved
491
+ }
492
+
493
+ /** The server's word about an attribute, against what the web component last wrote. */
494
+ private serverWord(
495
+ memory: WeakMap<Element, Word>,
496
+ element: Element,
497
+ name: string,
498
+ ): string | null {
499
+ const now = element.getAttribute(name)
500
+ const known = memory.get(element)
501
+ if (!known) {
502
+ warnAboutCopiedMark(this, element)
503
+ return now
504
+ }
505
+ // The server's when the value is another, or when a morph from the
506
+ // server has been there since the web component wrote it (`WRITTEN`).
507
+ return now !== known.written || !stillMarked(element, name)
508
+ ? now
509
+ : known.server
510
+ }
511
+
512
+ /** Is there something on a tab or a panel other than what the web component wrote? */
513
+ private touchedByOthers(): boolean {
514
+ return this.tabs.some((tab) => {
515
+ const selected = this.selectedMemory.get(tab)
516
+ if (
517
+ !selected ||
518
+ tab.getAttribute("aria-selected") !== selected.written ||
519
+ markGone(tab, "aria-selected", selected)
520
+ ) {
521
+ return true
522
+ }
523
+ const panel = this.panelFor(tab)
524
+ if (!panel) return false
525
+ const hidden = this.hiddenMemory.get(panel)
526
+ return (
527
+ !hidden ||
528
+ panel.getAttribute("hidden") !== hidden.written ||
529
+ markGone(panel, "hidden", hidden)
530
+ )
531
+ })
532
+ }
533
+
534
+ /**
535
+ * Releases the click, and makes what is in the markup now the server's
536
+ * word.
537
+ *
538
+ * The memory holds the server's word from before the click, and it is
539
+ * stale: the server has answered. An attribute the server wrote the same as
540
+ * the web component cannot be told from one it did not touch, so the old
541
+ * words won, and the tab list jumped back to the tab from before the click
542
+ * just as the server confirmed the new one.
543
+ *
544
+ * `hidden` on the panels is therefore read as it is. `aria-selected` is read
545
+ * as it is when the server has touched at least one tab, that is, when the
546
+ * server speaks through `aria-selected`. If it has not, the values are the
547
+ * ones the web component wrote for the click, and then the panels say which
548
+ * tab applies.
549
+ */
550
+ private releasePending(): void {
551
+ this.pendingTab = undefined
552
+ const tabs = this.tabs
553
+ const serverSpoke = tabs.some((tab) => {
554
+ const now = tab.getAttribute("aria-selected")
555
+ const known = this.selectedMemory.get(tab)
556
+ // A new, bare tab has said nothing. Had it counted, the click's
557
+ // `aria-selected` was read as the server's, and a later move with
558
+ // `hidden` alone was undone.
559
+ return known
560
+ ? now !== known.written || markGone(tab, "aria-selected", known)
561
+ : now !== null
562
+ })
563
+ this.hiddenMemory = new WeakMap()
564
+ if (serverSpoke) {
565
+ this.selectedMemory = new WeakMap()
566
+ return
567
+ }
568
+ for (const tab of tabs) {
569
+ const written = tab.getAttribute("aria-selected")
570
+ this.selectedMemory.set(tab, { written, server: null })
571
+ markWritten(tab, "aria-selected", written !== null)
572
+ }
573
+ }
574
+
575
+ private serverSelected(tab: Element): string | null {
576
+ return this.serverWord(this.selectedMemory, tab, "aria-selected")
577
+ }
578
+
579
+ /** The server's word about `hidden` on the panel: the form, or `null` for visible. */
580
+ private serverHidden(panel: Element): string | null {
581
+ return this.serverWord(this.hiddenMemory, panel, "hidden")
582
+ }
583
+
584
+ /**
585
+ * Can any of the changes concern the tab list?
586
+ *
587
+ * The observer sees the whole tree, and a suggestion field, a counter or an
588
+ * inner tab list in a panel changes `aria-selected`, `hidden` and text on
589
+ * every keystroke. Each of them gave a full round, with styles read for the
590
+ * names. Only what is deep inside one of the tab list's panels is skipped,
591
+ * and only when it is neither a heading in the panel, something
592
+ * `aria-labelledby` points to, nor an id, which can make an element
593
+ * something a tab points to. Everything else gives a round, as before.
594
+ */
595
+ private concerns(records: MutationRecord[]): boolean {
596
+ let panels: HTMLElement[] | undefined
597
+ let names: Set<string> | undefined
598
+ for (const record of records) {
599
+ if (record.type === "attributes" && record.attributeName === "id")
600
+ return true
601
+ const target =
602
+ record.target instanceof Element
603
+ ? record.target
604
+ : record.target.parentElement
605
+ if (!target) return true
606
+ panels ??= this.tabs.flatMap((tab) => this.panelFor(tab) ?? [])
607
+ const panel = panels.find((p) => p !== target && p.contains(target))
608
+ if (!panel) return true
609
+ const heading = target.closest("h1, h2, h3, h4, h5, h6")
610
+ if (heading && heading.parentElement === panel) return true
611
+ names ??= this.watchedIds(panels)
612
+ if (this.within(target, names)) return true
613
+ if (record.type === "childList") {
614
+ for (const node of [...record.addedNodes, ...record.removedNodes])
615
+ if (this.carries(node, names)) return true
616
+ }
617
+ }
618
+ return false
619
+ }
620
+
621
+ /**
622
+ * The ids `aria-labelledby` points to from the tabs, what is inside them,
623
+ * and the panels. Before the tab list has loaded, also the id the address
624
+ * points to: the tab list waits for that element, see `sync()`, and it can
625
+ * arrive deep inside a panel.
626
+ */
627
+ private watchedIds(panels: HTMLElement[]): Set<string> {
628
+ const ids = new Set<string>()
629
+ if (!this.started) {
630
+ const hash = location.hash.slice(1)
631
+ if (hash !== "") ids.add(hash)
632
+ const plain = decoded(hash)
633
+ if (plain !== null) ids.add(plain)
634
+ }
635
+ const from = (element: Element) => {
636
+ for (const id of (element.getAttribute("aria-labelledby") ?? "").split(
637
+ /\s+/,
638
+ ))
639
+ if (id !== "") ids.add(id)
640
+ }
641
+ for (const tab of this.tabs) {
642
+ from(tab)
643
+ for (const inner of tab.querySelectorAll("[aria-labelledby]")) from(inner)
644
+ }
645
+ for (const panel of panels) from(panel)
646
+ return ids
647
+ }
648
+
649
+ /** Is the element inside an element with one of the ids, or is it one itself? */
650
+ private within(element: Element, ids: Set<string>): boolean {
651
+ if (ids.size === 0) return false
652
+ for (let e: Element | null = element; e; e = e.parentElement)
653
+ if (e.id && ids.has(e.id)) return true
654
+ return false
655
+ }
656
+
657
+ /** Does an added or removed node carry one of the ids, itself or inside it? */
658
+ private carries(node: Node, ids: Set<string>): boolean {
659
+ if (!(node instanceof Element) || ids.size === 0) return false
660
+ if (node.id && ids.has(node.id)) return true
661
+ for (const inner of node.querySelectorAll("[id]"))
662
+ if (ids.has(inner.id)) return true
663
+ return false
664
+ }
665
+
666
+ /** The tab list the tabs are in, when it belongs to this web component. */
667
+ private get list(): HTMLElement | null {
668
+ // The first tab list that belongs to this web component, not the first in
669
+ // the tree: when the panels with an inner tab list come before the outer
670
+ // tab list, the inner one was first.
671
+ for (const list of this.querySelectorAll<HTMLElement>(LIST_SELECTOR))
672
+ if (list.closest(this.localName) === this) return list
673
+ return null
674
+ }
675
+
676
+ /**
677
+ * The panel a tab controls, through the id it points to.
678
+ *
679
+ * The panel can be outside the host. It is then not in what the web
680
+ * component observes, so if a patch strips `hidden` from it, it does not
681
+ * come back by itself. If the tab points to nothing, the answer is nothing,
682
+ * and the web component warns. The order is not a fallback: two tabs could
683
+ * then have got the same panel, and the two wrote opposite `hidden` on it in
684
+ * every single round.
685
+ */
686
+ private panelFor(tab: HTMLElement): HTMLElement | null {
687
+ const target = linkTarget(tab)
688
+ return target === null ? null : this.byFragment(target)
689
+ }
690
+
691
+ /** The element a fragment points to, as written or decoded. */
692
+ private byFragment(fragment: string): HTMLElement | null {
693
+ const root = this.getRootNode() as Document | ShadowRoot
694
+ const found = root.getElementById?.(fragment)
695
+ if (found) return found
696
+ // A fragment can be encoded, such as `#s%C3%B8knaden`.
697
+ const plain = decoded(fragment)
698
+ return plain === null ? null : (root.getElementById?.(plain) ?? null)
699
+ }
700
+
701
+ /**
702
+ * A tab that cannot be selected, with either mouse or keyboard: disabled,
703
+ * or hidden with `hidden`. A hidden tab cannot take focus, so when an arrow
704
+ * key selected it, the tab stop was left on something Tab never reaches.
705
+ */
706
+ private isDisabled(tab: HTMLElement): boolean {
707
+ return (
708
+ tab.hasAttribute("disabled") ||
709
+ tab.getAttribute("aria-disabled") === "true" ||
710
+ tab.hasAttribute("hidden")
711
+ )
712
+ }
713
+
714
+ /** The arrow keys only move focus, and Enter or Space selects. */
715
+ private get manual(): boolean {
716
+ return this.getAttribute("activation") === "manual"
717
+ }
718
+
719
+ /**
720
+ * The index of the marked tab, or `-1` when none is.
721
+ *
722
+ * `selected` answers 0 in that case, because it is public API and a tab
723
+ * list in practice always has a selected tab. But that lie cannot be used
724
+ * internally: when it said 0 with nothing marked, `select(0)` returned at
725
+ * once, and the tab list did not respond to a click at all.
726
+ */
727
+ private get markedIndex(): number {
728
+ return this.tabs.findIndex(
729
+ (tab) => tab.getAttribute("aria-selected") === "true",
730
+ )
731
+ }
732
+
733
+ /** The index of the selected tab. */
734
+ get selected(): number {
735
+ const index = this.markedIndex
736
+ return index < 0 ? 0 : index
737
+ }
738
+
739
+ set selected(index: number) {
740
+ // React 19 writes the property when it exists, and the documentation
741
+ // shows `tabs.selected` as API. A getter alone threw on assignment.
742
+ this.select(index)
743
+ }
744
+
745
+ private sync(): void {
746
+ /*
747
+ * The first time the tab list has tabs, it has loaded. If the server has
748
+ * said which tab is selected, that applies: the server cannot see the
749
+ * address, and the React entry's `lt.tabs()` writes `aria-selected`.
750
+ * When the web component selected from the address before React
751
+ * hydrated, React reported a mismatch for every attribute it changed.
752
+ * Otherwise the address selects. The server's word is read before the
753
+ * web component writes anything.
754
+ */
755
+ // Loaded when the tab list has tabs and the address can be resolved: it
756
+ // is empty, or the element it points to exists. When the tab list came
757
+ // before the panels, or the content after the panel, the address
758
+ // otherwise found nothing, and the first tab was selected over the one
759
+ // the user asked for. Waiting for every tab to have a panel was not
760
+ // enough: with one link pointing to nothing, the tab list never loaded.
761
+ // If the address points to something that never comes, such as
762
+ // `#/case/1`, there is nothing to select either. The server's word is read
763
+ // through the memory, so a new connection does not take the web
764
+ // component's writes for the server's.
765
+ const tabs = this.tabs
766
+ const hash = location.hash.slice(1)
767
+ const loading =
768
+ !this.started &&
769
+ tabs.length > 0 &&
770
+ (hash === "" || this.byFragment(hash) !== null)
771
+ const serverChose =
772
+ loading && tabs.some((tab) => this.serverSelected(tab) === "true")
773
+ this.wire()
774
+ this.bind()
775
+ this.repair()
776
+ if (!loading) return
777
+ this.started = true
778
+ if (serverChose) this.alignAddress(this.markedIndex)
779
+ else this.selectFromAddress()
780
+ }
781
+
782
+ /**
783
+ * Fills in the wiring where the markup came without it.
784
+ *
785
+ * Only what is missing, with one exception: `aria-controls` is written from
786
+ * `href`, since the two are the same information and `href` is the one that
787
+ * works without JavaScript. Any other attribute the server has written is
788
+ * the server's word, even when it says something other than the web
789
+ * component would have said.
790
+ */
791
+ private wire(): void {
792
+ const list = this.list
793
+ if (list && !list.hasAttribute("role")) setAttr(list, "role", "tablist")
794
+ // In a list each list item is only a wrapper around the tab. Without the
795
+ // role a screen reader says "list, 2 items" before each tab.
796
+ if (list && (list.localName === "ul" || list.localName === "ol")) {
797
+ for (const item of list.children) {
798
+ if (item.localName === "li" && !item.hasAttribute("role")) {
799
+ setAttr(item, "role", "presentation")
800
+ }
801
+ }
802
+ }
803
+
804
+ /*
805
+ * Each panel gets its name once, from the first tab that points to it.
806
+ * When two tabs pointed to the same one, through a mistake in the markup,
807
+ * each of them gave the panel its name in every round, and the observer
808
+ * called itself forever.
809
+ */
810
+ const named = new Set<HTMLElement>()
811
+ for (const tab of this.tabs) {
812
+ if (!tab.hasAttribute("role")) {
813
+ setAttr(tab, "role", "tab")
814
+ this.roledByMe.add(tab)
815
+ }
816
+ const panel = this.panelFor(tab)
817
+ setAttr(tab, "aria-controls", panel?.id ?? linkTarget(tab))
818
+ if (!panel) continue
819
+
820
+ if (!panel.hasAttribute("role")) {
821
+ setAttr(panel, "role", "tabpanel")
822
+ this.roledByMe.add(panel)
823
+ }
824
+ if (named.has(panel)) continue
825
+ named.add(panel)
826
+ this.name(panel, tab)
827
+ }
828
+ }
829
+
830
+ /**
831
+ * Gives the panel a name, when the server has not given it one.
832
+ *
833
+ * The panel is named what the tab is named, as the APG does it. The APG
834
+ * lets the panel point to the tab, but the tab has no id here, and the web
835
+ * component writes none. If the heading in the panel says the same as the
836
+ * tab, the heading is the name, and it can get an id from the web
837
+ * component: it has no state the user can lose. Otherwise the name is the
838
+ * tab's, as `aria-label`.
839
+ *
840
+ * An `aria-label` or `aria-labelledby` the server wrote is never changed,
841
+ * even when the reference points to something that does not exist yet.
842
+ * Until it arrives, the tab's name stands as `aria-label`: a reference
843
+ * without a match is ignored when the name is computed, and then
844
+ * `aria-label` applies.
845
+ */
846
+ private name(panel: HTMLElement, tab: HTMLElement): void {
847
+ const ownLabel = this.labelByMe.get(panel)
848
+ const label = panel.getAttribute("aria-label")
849
+ if (label !== null && label !== ownLabel) return
850
+
851
+ const tabName = accessibleName(tab)
852
+ const named = panel.getAttribute("aria-labelledby")
853
+ const remembered = this.headingIds.get(panel)
854
+ if (named !== null && named !== remembered) {
855
+ if (this.resolves(named)) this.dropOwnLabel(panel)
856
+ else this.setOwnLabel(panel, tabName)
857
+ return
858
+ }
859
+
860
+ // Without a name on the tab there is nothing to give the panel. An empty
861
+ // `aria-label` would also have taken the name from the heading.
862
+ if (tabName === "") {
863
+ this.dropOwnLabel(panel)
864
+ return
865
+ }
866
+ const heading = panel.querySelector<HTMLElement>(HEADING_SELECTOR)
867
+ if (heading && nameAsReferenced(heading) === tabName) {
868
+ this.dropOwnLabel(panel)
869
+ // If the reference is the web component's, and the heading lost its id
870
+ // in a morph, it gets the same one back.
871
+ let id = heading.id
872
+ if (!id) {
873
+ id = remembered ?? uniqueId("lt-tabs-heading")
874
+ setAttr(heading, "id", id)
875
+ }
876
+ this.headingIds.set(panel, id)
877
+ setAttr(panel, "aria-labelledby", id)
878
+ return
879
+ }
880
+
881
+ if (named !== null) {
882
+ setAttr(panel, "aria-labelledby", null)
883
+ this.headingIds.delete(panel)
884
+ }
885
+ this.setOwnLabel(panel, tabName)
886
+ }
887
+
888
+ /** Writes the tab's name as `aria-label`, and remembers that the web component wrote it. */
889
+ private setOwnLabel(panel: HTMLElement, text: string): void {
890
+ setAttr(panel, "aria-label", text)
891
+ this.labelByMe.set(panel, text)
892
+ }
893
+
894
+ /** Removes the fallback, when a name that exists has taken over. */
895
+ private dropOwnLabel(panel: HTMLElement): void {
896
+ if (!this.labelByMe.has(panel)) return
897
+ setAttr(panel, "aria-label", null)
898
+ this.labelByMe.delete(panel)
899
+ }
900
+
901
+ /** Does at least one of the ids point to an element that exists? */
902
+ private resolves(ids: string): boolean {
903
+ const root = this.getRootNode() as Document | ShadowRoot
904
+ return ids
905
+ .split(/\s+/)
906
+ .some((id) => id !== "" && (root.getElementById?.(id) ?? null) !== null)
907
+ }
908
+
909
+ /**
910
+ * The tab the server says is selected, when the user has not selected
911
+ * anything.
912
+ *
913
+ * The server's word, not what is there: what the web component wrote does
914
+ * not count, see `Word`. `aria-selected` first. When that is missing, as in
915
+ * markup written without JavaScript, `hidden` on the panels says the same:
916
+ * the first panel the server shows belongs to the selected tab. If the
917
+ * markup says nothing, or hides every panel, it is the first, which is what
918
+ * a tab list starts on anyway.
919
+ */
920
+ private get derivedIndex(): number {
921
+ const tabs = this.tabs
922
+ const marked = tabs.findIndex((tab) => this.serverSelected(tab) === "true")
923
+ if (marked >= 0) return marked
924
+
925
+ const shown = tabs.findIndex((tab) => {
926
+ const panel = this.panelFor(tab)
927
+ return panel !== null && this.serverHidden(panel) === null
928
+ })
929
+ return shown < 0 ? 0 : shown
930
+ }
931
+
932
+ /**
933
+ * Restores the user's selection after a patch.
934
+ *
935
+ * Without this the template had to write
936
+ * `data-preserve-attr="aria-selected tabindex"` on every tab and `hidden` on
937
+ * every panel, that is, list the attributes the web component was going to
938
+ * touch. No compiler looked at that list.
939
+ *
940
+ * `apply()` is called whether or not the tabs look right, because a patch
941
+ * may have touched only the panels. That is the narrow patch the
942
+ * documentation itself recommends, and a check on `aria-selected` alone did
943
+ * not see it: the tab said one thing and the screen another.
944
+ */
945
+ private repair(): void {
946
+ if (this.pendingTab) {
947
+ const index = this.tabs.indexOf(this.pendingTab)
948
+ if (isServerControlled(this) && index >= 0 && !this.touchedByOthers()) {
949
+ this.apply(index)
950
+ return
951
+ }
952
+ this.releasePending()
953
+ }
954
+
955
+ if (this.chosenTarget === undefined || isServerControlled(this)) {
956
+ /*
957
+ * No user selection to restore, so the server's word decides, and it
958
+ * is written out in full: `aria-selected`, the tab stop and `hidden` in
959
+ * step. Bare structure without `aria-selected` thus gets the first tab
960
+ * selected and the other panels hidden, and a patch that only touched
961
+ * the panels also moves the selection. That applies under
962
+ * `server-controlled` too: the web component does not restore the
963
+ * user's selection there, but still writes out what the server's
964
+ * markup says. If everything already matches, `apply()` writes nothing.
965
+ */
966
+ if (this.tabs.length > 0) {
967
+ const index = this.derivedIndex
968
+ this.apply(index)
969
+ if (this.started) this.alignAddress(index)
970
+ }
971
+ return
972
+ }
973
+
974
+ const chosen = this.chosenTarget
975
+ const index = this.chosenIndex()
976
+ if (index < 0) {
977
+ /*
978
+ * The tab no longer exists. The server has sent something else, and
979
+ * then the server's markup applies.
980
+ *
981
+ * With one exception: the markup must hang together afterwards. When
982
+ * the patch removed the marked tab, the tab list is left without a
983
+ * single `aria-selected="true"`, every panel is hidden, and a click
984
+ * does nothing. The web component then selects what the server's
985
+ * markup says, the same way as when the user had not selected
986
+ * anything: `aria-selected`, then the first panel the server shows,
987
+ * then the first tab.
988
+ *
989
+ * The selection is announced with an event, because it was made by the
990
+ * web component and not by the user. Without the event an app that
991
+ * loads the panel's content or writes the selection to the address
992
+ * would never learn that it moved.
993
+ */
994
+ const lostPanel = chosen === undefined ? null : this.byFragment(chosen)
995
+ this.choose(undefined)
996
+ // The server marked a tab itself. Then that applies, and the panels and
997
+ // the tab stop are written to match it, even when nothing else gave the
998
+ // observer another round.
999
+ if (this.markedIndex >= 0) {
1000
+ const marked = this.derivedIndex
1001
+ this.apply(marked)
1002
+ this.alignAddress(marked)
1003
+ return
1004
+ }
1005
+ if (this.tabs.length > 0) {
1006
+ // The address says which tab the user was on, and is the only thing
1007
+ // that survives the links being replaced. If it is the same panel as
1008
+ // before, the selection has not changed, only the links, and then
1009
+ // nothing is announced.
1010
+ const fromAddress = this.addressIndex
1011
+ const index = fromAddress >= 0 ? fromAddress : this.derivedIndex
1012
+ const tab = this.tabs[index]
1013
+ this.apply(index)
1014
+ if (this.panelFor(tab) === lostPanel) {
1015
+ this.choose(tab)
1016
+ return
1017
+ }
1018
+ if (fromAddress >= 0) this.choose(tab)
1019
+ this.showInAddress(index)
1020
+ this.notify(index)
1021
+ }
1022
+ return
1023
+ }
1024
+
1025
+ this.apply(index)
1026
+ }
1027
+
1028
+ private bind(): void {
1029
+ const tabs = this.tabs
1030
+
1031
+ if (tabs.length === 0) {
1032
+ warnAboutMarkup(
1033
+ this,
1034
+ "found no tabs. The tab list needs a .lt-tabs__list with links " +
1035
+ 'to the panels, such as <a href="#attachments">; without them a ' +
1036
+ 'screen reader says "link" where it should say "tab, 2 of 3, ' +
1037
+ 'selected", and the arrow keys do nothing.',
1038
+ // An empty `<lt-tabs>` is a region the server has not filled yet,
1039
+ // and that is not a mistake in the markup.
1040
+ () => this.childElementCount > 0 && this.tabs.length === 0,
1041
+ )
1042
+ return
1043
+ }
1044
+
1045
+ // Without the numbers in the message. If it interpolates a number, every
1046
+ // round is a new message, and the warning comes again for every panel
1047
+ // that appears instead of once.
1048
+ warnAboutMarkup(
1049
+ this,
1050
+ "has tabs without a panel: a link points to an id that does not " +
1051
+ "exist. Write an id on each .lt-tabs__panel, the same one the " +
1052
+ "link points to, or the tabs without a panel can be selected " +
1053
+ "without anything being shown.",
1054
+ () => this.tabs.some((tab) => this.panelFor(tab) === null),
1055
+ )
1056
+
1057
+ warnAboutMarkup(
1058
+ this,
1059
+ 'has a tab without href: role="tab" is on something that is not ' +
1060
+ "a link to a panel. Tabs are links, such as <a " +
1061
+ 'href="#attachments">, so they work without JavaScript, and the ' +
1062
+ "component leaves anything that is not a link alone.",
1063
+ () => this.nonLinkTabs().length > 0,
1064
+ )
1065
+
1066
+ warnAboutMarkup(
1067
+ this,
1068
+ "has a tab list without a name. Write aria-label on " +
1069
+ '.lt-tabs__list, such as aria-label="Parts of the case", or ' +
1070
+ "aria-labelledby pointing to a heading; without a name a screen " +
1071
+ 'reader only says "tab list".',
1072
+ () => {
1073
+ const list = this.list
1074
+ if (!list || this.tabs.length === 0) return false
1075
+ const label = list.getAttribute("aria-label")
1076
+ const labelledby = list.getAttribute("aria-labelledby")
1077
+ return (
1078
+ (label === null || flat(label) === "") &&
1079
+ (labelledby === null || !this.resolves(labelledby))
1080
+ )
1081
+ },
1082
+ )
1083
+ }
1084
+
1085
+ /** What the server has called a tab in the tab list, but is not a link to a panel. */
1086
+ private nonLinkTabs(): Element[] {
1087
+ const list = this.list
1088
+ if (!list) return []
1089
+ const tabs = new Set<Element>(this.tabs)
1090
+ return [...list.querySelectorAll(ANY_TAB_SELECTOR)].filter(
1091
+ (e) => e.closest(this.localName) === this && !tabs.has(e),
1092
+ )
1093
+ }
1094
+
1095
+ /**
1096
+ * The tab in this tab list the event came from, or -1.
1097
+ *
1098
+ * `indexOf` and not just `closest`: a tab list inside a panel bubbles its
1099
+ * clicks up here too, and they do not belong to this tab list.
1100
+ */
1101
+ private tabIndexOf(event: Event): number {
1102
+ const target = event.target
1103
+ if (!(target instanceof Element)) return -1
1104
+ const tab = target.closest("[role='tab']")
1105
+ return tab ? this.tabs.indexOf(tab as HTMLElement) : -1
1106
+ }
1107
+
1108
+ private handleClick = (event: MouseEvent): void => {
1109
+ const index = this.tabIndexOf(event)
1110
+ if (index < 0) return
1111
+ const tab = this.tabs[index]
1112
+ // With a modifier the browser opens the link somewhere else, and that is
1113
+ // the user's choice, not a tab selection.
1114
+ if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
1115
+ return
1116
+ }
1117
+ // Without this the page jumped to the panel.
1118
+ event.preventDefault()
1119
+ if (this.isDisabled(tab)) return
1120
+ this.select(index)
1121
+ }
1122
+
1123
+ /** Find in page or a link revealed a hidden panel. */
1124
+ private handleBeforematch = (event: Event): void => {
1125
+ // Also under `server-controlled`, like a click: the selection stands until
1126
+ // the server answers, and `tab-select` tells the app. Otherwise the web
1127
+ // component hid the match again in the next round.
1128
+ const panel = event.target
1129
+ const index = this.tabs.findIndex((tab) => this.panelFor(tab) === panel)
1130
+ if (index < 0 || !(panel instanceof Element)) return
1131
+ // The browser removes `hidden` on the panel itself, see `revealing`.
1132
+ this.revealing = panel
1133
+ try {
1134
+ this.select(index)
1135
+ } finally {
1136
+ // After an event from a script, the browser reveals nothing.
1137
+ if (event.isTrusted) setTimeout(() => this.revealed(panel), 0)
1138
+ else this.revealing = undefined
1139
+ }
1140
+ }
1141
+
1142
+ /**
1143
+ * Releases the panel after `beforematch`, in the task after the browser has
1144
+ * finished revealing, and writes the tab list.
1145
+ *
1146
+ * Usually the browser has removed `hidden` by then. It has not when the
1147
+ * panel was taken out of the document during the event, and without this
1148
+ * round the panel stayed hidden even though the tab was selected.
1149
+ */
1150
+ private revealed(panel: Element): void {
1151
+ if (this.revealing !== panel) return
1152
+ this.revealing = undefined
1153
+ if (this.isConnected) this.sync()
1154
+ }
1155
+
1156
+ private handleHashchange = (): void => {
1157
+ this.selectFromAddress()
1158
+ }
1159
+
1160
+ /**
1161
+ * The index of the tab the address points to, or -1: the panel itself, such
1162
+ * as `#attachments`, or something inside it, such as a heading in the panel.
1163
+ */
1164
+ private get addressIndex(): number {
1165
+ const hash = location.hash.slice(1)
1166
+ if (!hash) return -1
1167
+ const target = this.byFragment(hash)
1168
+ if (!target) return -1
1169
+ return this.tabs.findIndex((tab) => {
1170
+ const panel = this.panelFor(tab)
1171
+ return panel !== null && (panel === target || panel.contains(target))
1172
+ })
1173
+ }
1174
+
1175
+ /**
1176
+ * Corrects the address when it points to another tab in the tab list than
1177
+ * the selected one, as when the server has moved the selection. An address
1178
+ * to something outside the tab list stays.
1179
+ */
1180
+ private alignAddress(index: number): void {
1181
+ const pointsAt = this.addressIndex
1182
+ if (pointsAt >= 0 && pointsAt !== index) this.showInAddress(index)
1183
+ }
1184
+
1185
+ /**
1186
+ * Writes the tab to the address, so it can be shared, and so the selection
1187
+ * survives the server replacing the links.
1188
+ *
1189
+ * If the address already points to the selected panel or something in it,
1190
+ * it stays: a link into the content must not be lost. `replaceState`, so
1191
+ * the selection does not become an entry in the history.
1192
+ */
1193
+ private showInAddress(index: number): void {
1194
+ const tab = this.tabs[index]
1195
+ const panel = tab ? this.panelFor(tab) : null
1196
+ if (!tab || !panel) return
1197
+ if (this.addressIndex === index) return
1198
+ // Resolved against the page's address, not against `<base>`: with
1199
+ // `<base href="/">`, which Angular has, the path was replaced silently,
1200
+ // and with a `<base>` to another origin `replaceState` threw.
1201
+ const url = new URL(tab.getAttribute("href") ?? "", location.href).href
1202
+ try {
1203
+ history.replaceState(history.state, "", url)
1204
+ } catch {
1205
+ // Safari throws after a hundred calls in ten seconds. The address is an
1206
+ // extra, so the selection applies anyway.
1207
+ }
1208
+ }
1209
+
1210
+ /**
1211
+ * Selects the tab the address points to.
1212
+ *
1213
+ * On load, on `hashchange`, and when the server replaces the links without
1214
+ * saying which one is selected: the selection is then still in the
1215
+ * address. Not under `server-controlled`, where the server decides.
1216
+ */
1217
+ private selectFromAddress(): void {
1218
+ if (isServerControlled(this)) return
1219
+ const index = this.addressIndex
1220
+ if (index >= 0) this.select(index)
1221
+ }
1222
+
1223
+ private handleKeydown = (event: KeyboardEvent): void => {
1224
+ // Every keystroke in a field in a panel comes here. The cheap checks first.
1225
+ if (!(event.target instanceof Element)) return
1226
+ if (!event.target.closest("[role='tab']")) return
1227
+ const tabs = this.tabs
1228
+ const current = this.tabIndexOf(event)
1229
+ if (current < 0) return
1230
+ // Alt+Left is "back" in the browser, and Cmd+arrow moves through the
1231
+ // history on macOS. With a modifier the key is not for the tabs.
1232
+ if (event.altKey || event.ctrlKey || event.metaKey) return
1233
+
1234
+ // Space selects with manual activation. A link does nothing on Space by
1235
+ // itself, and Enter sends a click, which `handleClick` handles.
1236
+ if (this.manual && event.key === " ") {
1237
+ event.preventDefault()
1238
+ this.select(current)
1239
+ return
1240
+ }
1241
+
1242
+ // On a page read from right to left the next tab is to the left. Without
1243
+ // this the right arrow moved focus visually backwards.
1244
+ const rtl = getComputedStyle(this).direction === "rtl"
1245
+ // Up and down arrows stole page scrolling in a horizontal tab list and
1246
+ // departed from WAI-ARIA. They apply only when the tab list says it is
1247
+ // vertical.
1248
+ const vertical =
1249
+ tabs[current]
1250
+ .closest("[role='tablist']")
1251
+ ?.getAttribute("aria-orientation") === "vertical"
1252
+ const steps: Record<string, number> = vertical
1253
+ ? { ArrowDown: 1, ArrowUp: -1 }
1254
+ : { ArrowRight: rtl ? -1 : 1, ArrowLeft: rtl ? 1 : -1 }
1255
+
1256
+ let next: number | undefined
1257
+
1258
+ if (event.key in steps) {
1259
+ next = this.nextEnabled(current, steps[event.key])
1260
+ } else if (event.key === "Home") {
1261
+ next = this.nextEnabled(-1, 1)
1262
+ } else if (event.key === "End") {
1263
+ next = this.nextEnabled(tabs.length, -1)
1264
+ }
1265
+
1266
+ if (next === undefined) return
1267
+
1268
+ event.preventDefault()
1269
+ if (!this.manual) this.select(next)
1270
+ tabs[next].focus()
1271
+ }
1272
+
1273
+ /**
1274
+ * The next tab that can be selected, in one direction, wrapping round if
1275
+ * needed.
1276
+ *
1277
+ * A tab that cannot be selected is skipped. When a hidden tab was selected,
1278
+ * it got the tab stop, and Tab never reached the tab list. The keyboard
1279
+ * user was locked out.
1280
+ */
1281
+ private nextEnabled(from: number, step: number): number | undefined {
1282
+ const tabs = this.tabs
1283
+ for (let i = 1; i <= tabs.length; i++) {
1284
+ const index = (from + step * i + tabs.length * i) % tabs.length
1285
+ if (!this.isDisabled(tabs[index])) return index
1286
+ }
1287
+ return undefined
1288
+ }
1289
+
1290
+ /** Selects a tab and fires the event. */
1291
+ select(index: number): void {
1292
+ const tabs = this.tabs
1293
+ if (index < 0 || index >= tabs.length) return
1294
+ if (this.isDisabled(tabs[index])) return
1295
+ // `markedIndex` and not `selected`: the latter answers 0 also when nothing
1296
+ // is marked, and then the first tab could never be selected.
1297
+ if (index === this.markedIndex) return
1298
+
1299
+ // No memory when the server decides the selection. Without this a
1300
+ // selection made while the attribute was there would be restored the
1301
+ // moment someone removed it again.
1302
+ if (!isServerControlled(this)) this.choose(tabs[index])
1303
+ this.apply(index)
1304
+ if (isServerControlled(this)) this.pendingTab = tabs[index]
1305
+ this.showInAddress(index)
1306
+ this.notify(index)
1307
+ }
1308
+
1309
+ /** Announces which tab is selected now. */
1310
+ private notify(index: number): void {
1311
+ this.dispatchEvent(
1312
+ new CustomEvent("tab-select", {
1313
+ detail: { index },
1314
+ bubbles: true,
1315
+ composed: true,
1316
+ }),
1317
+ )
1318
+ }
1319
+
1320
+ /**
1321
+ * Writes the selection out into the markup.
1322
+ *
1323
+ * Every write compares first. The web component observes the same
1324
+ * attributes it sets, so a write without a comparison would trigger the
1325
+ * observer, which would write again, forever.
1326
+ */
1327
+ private apply(index: number): void {
1328
+ const tabs = this.tabs
1329
+ /*
1330
+ * The tab stop is the selected tab, unless a patch has disabled or hidden
1331
+ * it. Then the next tab that can be selected gets it, or Tab skips past
1332
+ * the whole tab list.
1333
+ */
1334
+ const stop = this.isDisabled(tabs[index])
1335
+ ? (this.nextEnabled(index, 1) ?? index)
1336
+ : index
1337
+ /*
1338
+ * A panel is visible when any tab pointing to it is selected, and is
1339
+ * written once. When two tabs point to the same one, through a mistake in
1340
+ * the markup, they would otherwise write opposite `hidden` on it in every
1341
+ * round, and the page would freeze.
1342
+ */
1343
+ const panels = new Map<HTMLElement, boolean>()
1344
+
1345
+ tabs.forEach((tab, i) => {
1346
+ const chosen = i === index
1347
+ // The server's word is noted before the write, or the write was read as
1348
+ // the server's in the next round.
1349
+ const server = this.serverSelected(tab)
1350
+ setAttr(tab, "aria-selected", String(chosen))
1351
+ this.selectedMemory.set(tab, { written: String(chosen), server })
1352
+ markWritten(tab, "aria-selected", String(chosen) !== server)
1353
+ setAttr(tab, "tabindex", i === stop ? "0" : "-1")
1354
+
1355
+ const panel = this.panelFor(tab)
1356
+ if (panel) panels.set(panel, (panels.get(panel) ?? false) || chosen)
1357
+ })
1358
+
1359
+ // The form is read from a panel the server hides before anything is
1360
+ // written, or a panel to be hidden first in the tab list was written in a
1361
+ // different form from the ones the server hides.
1362
+ for (const panel of panels.keys()) {
1363
+ const form = this.serverHidden(panel)
1364
+ if (form !== null) {
1365
+ this.hiddenForm = form
1366
+ break
1367
+ }
1368
+ }
1369
+
1370
+ for (const [panel, shown] of panels) {
1371
+ const server = this.serverHidden(panel)
1372
+ const written = shown ? null : this.hiddenForm
1373
+ if (panel !== this.revealing) {
1374
+ if (written === null) setFlag(panel, "hidden", false)
1375
+ else setAttr(panel, "hidden", written)
1376
+ this.hiddenMemory.set(panel, { written, server })
1377
+ markWritten(panel, "hidden", written !== server)
1378
+ }
1379
+ /*
1380
+ * Only the selected panel is a tab stop, so Tab from the tab list goes
1381
+ * straight into the content that was just shown. A panel with
1382
+ * `hidden="until-found"` keeps its box, so with `tabindex="0"` it was
1383
+ * an invisible stop for the keyboard. Another value the server wrote,
1384
+ * such as `-1`, stays.
1385
+ */
1386
+ const tabindex = panel.getAttribute("tabindex")
1387
+ if (shown && tabindex === null) setAttr(panel, "tabindex", "0")
1388
+ else if (!shown && tabindex === "0") setAttr(panel, "tabindex", null)
1389
+ }
1390
+ }
1391
+ }
1392
+
1393
+ export function defineLtTabs(tagName: string = LT_TABS_TAG): void {
1394
+ defineElement(tagName, LtTabs)
1395
+ }