@zeix/le-truc 0.15.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 (406) hide show
  1. package/.ai-context.md +234 -0
  2. package/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +111 -0
  3. package/.editorconfig +12 -0
  4. package/.github/copilot-instructions.md +62 -0
  5. package/.github/workflows/codeql.yml +108 -0
  6. package/.github/workflows/static.yml +43 -0
  7. package/.prettierrc +17 -0
  8. package/CLAUDE.md +215 -0
  9. package/CODE_OF_CONDUCT.md +128 -0
  10. package/CONTRIBUTING.md +160 -0
  11. package/LICENSE +21 -0
  12. package/README.md +474 -0
  13. package/biome.json +295 -0
  14. package/bun.lock +239 -0
  15. package/docs/about.html +105 -0
  16. package/docs/assets/main.css +1 -0
  17. package/docs/assets/main.js +10 -0
  18. package/docs/assets/main.js.map +66 -0
  19. package/docs/components.html +293 -0
  20. package/docs/data-flow.html +308 -0
  21. package/docs/examples/basic-button.html +367 -0
  22. package/docs/examples/basic-counter.html +188 -0
  23. package/docs/examples/basic-hello.html +138 -0
  24. package/docs/examples/basic-number.html +271 -0
  25. package/docs/examples/basic-pluralize.html +214 -0
  26. package/docs/examples/card-callout.html +152 -0
  27. package/docs/examples/card-mediaqueries.html +138 -0
  28. package/docs/examples/context-media.html +198 -0
  29. package/docs/examples/empty.html +37 -0
  30. package/docs/examples/form-checkbox.html +233 -0
  31. package/docs/examples/form-combobox.html +420 -0
  32. package/docs/examples/form-listbox.html +434 -0
  33. package/docs/examples/form-radiogroup.html +296 -0
  34. package/docs/examples/form-spinbutton.html +402 -0
  35. package/docs/examples/form-textbox.html +361 -0
  36. package/docs/examples/layout.html +67 -0
  37. package/docs/examples/module-carousel.html +552 -0
  38. package/docs/examples/module-catalog.html +241 -0
  39. package/docs/examples/module-codeblock.html +270 -0
  40. package/docs/examples/module-dialog.html +343 -0
  41. package/docs/examples/module-lazyload.html +289 -0
  42. package/docs/examples/module-list.html +197 -0
  43. package/docs/examples/module-pagination.html +283 -0
  44. package/docs/examples/module-scrollarea.html +447 -0
  45. package/docs/examples/module-tabgroup.html +526 -0
  46. package/docs/examples/module-todo.html +367 -0
  47. package/docs/examples/module-with-type.html +63 -0
  48. package/docs/examples/nested-components.html +88 -0
  49. package/docs/examples/recursive.html +56 -0
  50. package/docs/examples/simple-text.html +39 -0
  51. package/docs/examples/snippet.html +93 -0
  52. package/docs/examples/with-styles.html +75 -0
  53. package/docs/getting-started.html +143 -0
  54. package/docs/index.html +112 -0
  55. package/docs/sitemap.xml +28 -0
  56. package/docs/styling.html +160 -0
  57. package/docs/sw.js +112 -0
  58. package/docs-src/api/README.md +478 -0
  59. package/docs-src/api/_media/LICENSE +21 -0
  60. package/docs-src/api/classes/CircularDependencyError.md +299 -0
  61. package/docs-src/api/classes/CircularMutationError.md +301 -0
  62. package/docs-src/api/classes/ContextRequestEvent.md +590 -0
  63. package/docs-src/api/classes/DependencyTimeoutError.md +301 -0
  64. package/docs-src/api/classes/InvalidCallbackError.md +303 -0
  65. package/docs-src/api/classes/InvalidComponentNameError.md +295 -0
  66. package/docs-src/api/classes/InvalidCustomElementError.md +301 -0
  67. package/docs-src/api/classes/InvalidEffectsError.md +301 -0
  68. package/docs-src/api/classes/InvalidPropertyNameError.md +307 -0
  69. package/docs-src/api/classes/InvalidReactivesError.md +307 -0
  70. package/docs-src/api/classes/InvalidSignalValueError.md +303 -0
  71. package/docs-src/api/classes/MissingElementError.md +307 -0
  72. package/docs-src/api/classes/NullishSignalValueError.md +299 -0
  73. package/docs-src/api/classes/StoreKeyExistsError.md +303 -0
  74. package/docs-src/api/classes/StoreKeyRangeError.md +299 -0
  75. package/docs-src/api/classes/StoreKeyReadonlyError.md +303 -0
  76. package/docs-src/api/functions/asBoolean.md +21 -0
  77. package/docs-src/api/functions/asEnum.md +31 -0
  78. package/docs-src/api/functions/asInteger.md +39 -0
  79. package/docs-src/api/functions/asJSON.md +49 -0
  80. package/docs-src/api/functions/asNumber.md +37 -0
  81. package/docs-src/api/functions/asString.md +37 -0
  82. package/docs-src/api/functions/createCollection.md +83 -0
  83. package/docs-src/api/functions/createSensor.md +71 -0
  84. package/docs-src/api/functions/dangerouslySetInnerHTML.md +48 -0
  85. package/docs-src/api/functions/defineComponent.md +65 -0
  86. package/docs-src/api/functions/isCollection.md +37 -0
  87. package/docs-src/api/functions/isParser.md +41 -0
  88. package/docs-src/api/functions/match.md +47 -0
  89. package/docs-src/api/functions/on.md +58 -0
  90. package/docs-src/api/functions/pass.md +53 -0
  91. package/docs-src/api/functions/provideContexts.md +47 -0
  92. package/docs-src/api/functions/read.md +47 -0
  93. package/docs-src/api/functions/requestContext.md +51 -0
  94. package/docs-src/api/functions/resolve.md +40 -0
  95. package/docs-src/api/functions/runEffects.md +51 -0
  96. package/docs-src/api/functions/runElementEffects.md +57 -0
  97. package/docs-src/api/functions/schedule.md +33 -0
  98. package/docs-src/api/functions/setAttribute.md +48 -0
  99. package/docs-src/api/functions/setProperty.md +52 -0
  100. package/docs-src/api/functions/setStyle.md +48 -0
  101. package/docs-src/api/functions/setText.md +42 -0
  102. package/docs-src/api/functions/show.md +42 -0
  103. package/docs-src/api/functions/toSignal.md +37 -0
  104. package/docs-src/api/functions/toggleAttribute.md +48 -0
  105. package/docs-src/api/functions/toggleClass.md +48 -0
  106. package/docs-src/api/functions/updateElement.md +53 -0
  107. package/docs-src/api/globals.md +131 -0
  108. package/docs-src/api/type-aliases/Cleanup.md +27 -0
  109. package/docs-src/api/type-aliases/Collection.md +91 -0
  110. package/docs-src/api/type-aliases/CollectionListener.md +27 -0
  111. package/docs-src/api/type-aliases/Component.md +17 -0
  112. package/docs-src/api/type-aliases/ComponentProp.md +11 -0
  113. package/docs-src/api/type-aliases/ComponentProps.md +11 -0
  114. package/docs-src/api/type-aliases/ComponentSetup.md +31 -0
  115. package/docs-src/api/type-aliases/ComponentUI.md +27 -0
  116. package/docs-src/api/type-aliases/Computed.md +49 -0
  117. package/docs-src/api/type-aliases/ComputedCallback.md +29 -0
  118. package/docs-src/api/type-aliases/Context.md +33 -0
  119. package/docs-src/api/type-aliases/ContextType.md +19 -0
  120. package/docs-src/api/type-aliases/DangerouslySetInnerHTMLOptions.md +27 -0
  121. package/docs-src/api/type-aliases/DiffResult.md +61 -0
  122. package/docs-src/api/type-aliases/Effect.md +35 -0
  123. package/docs-src/api/type-aliases/EffectCallback.md +23 -0
  124. package/docs-src/api/type-aliases/Effects.md +21 -0
  125. package/docs-src/api/type-aliases/ElementEffects.md +21 -0
  126. package/docs-src/api/type-aliases/ElementFromKey.md +21 -0
  127. package/docs-src/api/type-aliases/ElementQueries.md +27 -0
  128. package/docs-src/api/type-aliases/ElementUpdater.md +131 -0
  129. package/docs-src/api/type-aliases/EventHandler.md +31 -0
  130. package/docs-src/api/type-aliases/EventType.md +17 -0
  131. package/docs-src/api/type-aliases/Fallback.md +21 -0
  132. package/docs-src/api/type-aliases/Initializers.md +21 -0
  133. package/docs-src/api/type-aliases/LooseReader.md +31 -0
  134. package/docs-src/api/type-aliases/MatchHandlers.md +77 -0
  135. package/docs-src/api/type-aliases/MaybeCleanup.md +23 -0
  136. package/docs-src/api/type-aliases/MaybeSignal.md +17 -0
  137. package/docs-src/api/type-aliases/Parser.md +39 -0
  138. package/docs-src/api/type-aliases/ParserOrFallback.md +21 -0
  139. package/docs-src/api/type-aliases/PassedProp.md +25 -0
  140. package/docs-src/api/type-aliases/PassedProps.md +21 -0
  141. package/docs-src/api/type-aliases/Reactive.md +25 -0
  142. package/docs-src/api/type-aliases/Reader.md +31 -0
  143. package/docs-src/api/type-aliases/ReservedWords.md +11 -0
  144. package/docs-src/api/type-aliases/ResolveResult.md +29 -0
  145. package/docs-src/api/type-aliases/SensorEvents.md +25 -0
  146. package/docs-src/api/type-aliases/Signal.md +41 -0
  147. package/docs-src/api/type-aliases/State.md +85 -0
  148. package/docs-src/api/type-aliases/Store.md +29 -0
  149. package/docs-src/api/type-aliases/UI.md +11 -0
  150. package/docs-src/api/type-aliases/UnknownContext.md +13 -0
  151. package/docs-src/api/variables/CONTEXT_REQUEST.md +11 -0
  152. package/docs-src/api/variables/UNSET.md +23 -0
  153. package/docs-src/api/variables/batch.md +25 -0
  154. package/docs-src/api/variables/createComputed.md +41 -0
  155. package/docs-src/api/variables/createEffect.md +35 -0
  156. package/docs-src/api/variables/createState.md +37 -0
  157. package/docs-src/api/variables/createStore.md +42 -0
  158. package/docs-src/api/variables/diff.md +43 -0
  159. package/docs-src/api/variables/isAbortError.md +33 -0
  160. package/docs-src/api/variables/isAsyncFunction.md +39 -0
  161. package/docs-src/api/variables/isComputed.md +37 -0
  162. package/docs-src/api/variables/isEqual.md +49 -0
  163. package/docs-src/api/variables/isFunction.md +39 -0
  164. package/docs-src/api/variables/isMutableSignal.md +37 -0
  165. package/docs-src/api/variables/isNumber.md +33 -0
  166. package/docs-src/api/variables/isRecord.md +39 -0
  167. package/docs-src/api/variables/isRecordOrArray.md +39 -0
  168. package/docs-src/api/variables/isSignal.md +37 -0
  169. package/docs-src/api/variables/isState.md +37 -0
  170. package/docs-src/api/variables/isStore.md +37 -0
  171. package/docs-src/api/variables/isString.md +33 -0
  172. package/docs-src/api/variables/isSymbol.md +33 -0
  173. package/docs-src/api/variables/toError.md +33 -0
  174. package/docs-src/api/variables/valueString.md +33 -0
  175. package/docs-src/includes/menu.html +44 -0
  176. package/docs-src/pages/about.md +89 -0
  177. package/docs-src/pages/components.md +437 -0
  178. package/docs-src/pages/data-flow.md +449 -0
  179. package/docs-src/pages/getting-started.md +170 -0
  180. package/docs-src/pages/index.md +98 -0
  181. package/docs-src/pages/styling.md +165 -0
  182. package/eslint.config.js +64 -0
  183. package/examples/_common/clear.ts +49 -0
  184. package/examples/_common/fetch.ts +160 -0
  185. package/examples/_common/focus.ts +45 -0
  186. package/examples/_common/highlight.ts +5 -0
  187. package/examples/_global.css +463 -0
  188. package/examples/basic-button/basic-button.css +176 -0
  189. package/examples/basic-button/basic-button.html +46 -0
  190. package/examples/basic-button/basic-button.spec.ts +160 -0
  191. package/examples/basic-button/basic-button.ts +45 -0
  192. package/examples/basic-button/copyToClipboard.ts +37 -0
  193. package/examples/basic-counter/basic-counter.css +21 -0
  194. package/examples/basic-counter/basic-counter.html +24 -0
  195. package/examples/basic-counter/basic-counter.spec.ts +85 -0
  196. package/examples/basic-counter/basic-counter.ts +43 -0
  197. package/examples/basic-hello/basic-hello.html +34 -0
  198. package/examples/basic-hello/basic-hello.spec.ts +110 -0
  199. package/examples/basic-hello/basic-hello.ts +36 -0
  200. package/examples/basic-number/basic-number.html +79 -0
  201. package/examples/basic-number/basic-number.spec.ts +175 -0
  202. package/examples/basic-number/basic-number.ts +124 -0
  203. package/examples/basic-pluralize/basic-pluralize.html +64 -0
  204. package/examples/basic-pluralize/basic-pluralize.spec.ts +258 -0
  205. package/examples/basic-pluralize/basic-pluralize.ts +82 -0
  206. package/examples/card-callout/card-callout.css +79 -0
  207. package/examples/card-callout/card-callout.html +5 -0
  208. package/examples/card-mediaqueries/card-mediaqueries.html +29 -0
  209. package/examples/card-mediaqueries/card-mediaqueries.spec.ts +300 -0
  210. package/examples/card-mediaqueries/card-mediaqueries.ts +41 -0
  211. package/examples/context-media/context-media.html +3 -0
  212. package/examples/context-media/context-media.ts +127 -0
  213. package/examples/form-checkbox/form-checkbox.css +70 -0
  214. package/examples/form-checkbox/form-checkbox.html +13 -0
  215. package/examples/form-checkbox/form-checkbox.spec.ts +357 -0
  216. package/examples/form-checkbox/form-checkbox.ts +50 -0
  217. package/examples/form-checkbox/vanilla-checkbox.ts +101 -0
  218. package/examples/form-combobox/form-combobox.css +118 -0
  219. package/examples/form-combobox/form-combobox.html +74 -0
  220. package/examples/form-combobox/form-combobox.spec.ts +977 -0
  221. package/examples/form-combobox/form-combobox.ts +128 -0
  222. package/examples/form-listbox/form-listbox.css +71 -0
  223. package/examples/form-listbox/form-listbox.html +67 -0
  224. package/examples/form-listbox/form-listbox.spec.ts +1050 -0
  225. package/examples/form-listbox/form-listbox.ts +196 -0
  226. package/examples/form-listbox/mocks/timezones.json +495 -0
  227. package/examples/form-radiogroup/form-radiogroup.css +87 -0
  228. package/examples/form-radiogroup/form-radiogroup.html +51 -0
  229. package/examples/form-radiogroup/form-radiogroup.spec.ts +515 -0
  230. package/examples/form-radiogroup/form-radiogroup.ts +58 -0
  231. package/examples/form-spinbutton/form-spinbutton.css +95 -0
  232. package/examples/form-spinbutton/form-spinbutton.html +96 -0
  233. package/examples/form-spinbutton/form-spinbutton.spec.ts +688 -0
  234. package/examples/form-spinbutton/form-spinbutton.ts +111 -0
  235. package/examples/form-textbox/form-textbox.css +104 -0
  236. package/examples/form-textbox/form-textbox.html +53 -0
  237. package/examples/form-textbox/form-textbox.spec.ts +542 -0
  238. package/examples/form-textbox/form-textbox.ts +104 -0
  239. package/examples/main.css +22 -0
  240. package/examples/main.ts +23 -0
  241. package/examples/module-carousel/module-carousel.css +113 -0
  242. package/examples/module-carousel/module-carousel.html +208 -0
  243. package/examples/module-carousel/module-carousel.spec.ts +523 -0
  244. package/examples/module-carousel/module-carousel.ts +131 -0
  245. package/examples/module-catalog/module-catalog.css +22 -0
  246. package/examples/module-catalog/module-catalog.html +82 -0
  247. package/examples/module-catalog/module-catalog.spec.ts +396 -0
  248. package/examples/module-catalog/module-catalog.ts +37 -0
  249. package/examples/module-codeblock/module-codeblock.css +95 -0
  250. package/examples/module-codeblock/module-codeblock.html +28 -0
  251. package/examples/module-codeblock/module-codeblock.ts +47 -0
  252. package/examples/module-demo/module-demo.css +13 -0
  253. package/examples/module-dialog/module-dialog.css +96 -0
  254. package/examples/module-dialog/module-dialog.html +66 -0
  255. package/examples/module-dialog/module-dialog.spec.ts +557 -0
  256. package/examples/module-dialog/module-dialog.ts +81 -0
  257. package/examples/module-lazyload/mocks/empty.html +1 -0
  258. package/examples/module-lazyload/mocks/module-with-type.html +27 -0
  259. package/examples/module-lazyload/mocks/nested-components.html +52 -0
  260. package/examples/module-lazyload/mocks/recursive.html +20 -0
  261. package/examples/module-lazyload/mocks/simple-text.html +3 -0
  262. package/examples/module-lazyload/mocks/snippet.html +57 -0
  263. package/examples/module-lazyload/mocks/with-styles.html +39 -0
  264. package/examples/module-lazyload/module-lazyload.html +132 -0
  265. package/examples/module-lazyload/module-lazyload.spec.ts +734 -0
  266. package/examples/module-lazyload/module-lazyload.ts +89 -0
  267. package/examples/module-list/module-list.html +30 -0
  268. package/examples/module-list/module-list.spec.ts +592 -0
  269. package/examples/module-list/module-list.ts +99 -0
  270. package/examples/module-pagination/module-pagination.css +79 -0
  271. package/examples/module-pagination/module-pagination.html +16 -0
  272. package/examples/module-pagination/module-pagination.spec.ts +701 -0
  273. package/examples/module-pagination/module-pagination.ts +88 -0
  274. package/examples/module-scrollarea/module-scrollarea.css +77 -0
  275. package/examples/module-scrollarea/module-scrollarea.html +189 -0
  276. package/examples/module-scrollarea/module-scrollarea.spec.ts +445 -0
  277. package/examples/module-scrollarea/module-scrollarea.ts +81 -0
  278. package/examples/module-tabgroup/module-tabgroup.css +55 -0
  279. package/examples/module-tabgroup/module-tabgroup.html +269 -0
  280. package/examples/module-tabgroup/module-tabgroup.spec.ts +631 -0
  281. package/examples/module-tabgroup/module-tabgroup.ts +102 -0
  282. package/examples/module-toc/module-toc.css +34 -0
  283. package/examples/module-todo/module-todo.css +84 -0
  284. package/examples/module-todo/module-todo.html +92 -0
  285. package/examples/module-todo/module-todo.spec.ts +528 -0
  286. package/examples/module-todo/module-todo.ts +91 -0
  287. package/examples/section-hero/section-hero.css +37 -0
  288. package/examples/section-menu/section-menu.css +81 -0
  289. package/examples/server.ts +95 -0
  290. package/examples/test-setup.md +314 -0
  291. package/index.dev.js +1688 -0
  292. package/index.dev.ts +127 -0
  293. package/index.js +3 -0
  294. package/index.js.map +42 -0
  295. package/index.ts +127 -0
  296. package/package.json +64 -0
  297. package/playwright.config.ts +31 -0
  298. package/server/BUILD_SYSTEM.md +428 -0
  299. package/server/SERVER.md +286 -0
  300. package/server/build.ts +91 -0
  301. package/server/config.ts +130 -0
  302. package/server/effects/api.ts +28 -0
  303. package/server/effects/css.ts +31 -0
  304. package/server/effects/examples.ts +109 -0
  305. package/server/effects/js.ts +32 -0
  306. package/server/effects/menu.ts +34 -0
  307. package/server/effects/pages.ts +178 -0
  308. package/server/effects/service-worker.ts +57 -0
  309. package/server/effects/sitemap.ts +27 -0
  310. package/server/file-signals.ts +361 -0
  311. package/server/file-watcher.ts +77 -0
  312. package/server/io.ts +174 -0
  313. package/server/layout-engine.ts +470 -0
  314. package/server/layout-utils.ts +615 -0
  315. package/server/layouts/api.html +76 -0
  316. package/server/layouts/base.html +37 -0
  317. package/server/layouts/blog.html +115 -0
  318. package/server/layouts/example.html +104 -0
  319. package/server/layouts/overview.html +165 -0
  320. package/server/layouts/page.html +36 -0
  321. package/server/layouts/test.html +24 -0
  322. package/server/markdoc-helpers.ts +217 -0
  323. package/server/markdoc.config.ts +29 -0
  324. package/server/schema/callout.markdoc.ts +17 -0
  325. package/server/schema/carousel.markdoc.ts +118 -0
  326. package/server/schema/demo.markdoc.ts +74 -0
  327. package/server/schema/fence.markdoc.ts +84 -0
  328. package/server/schema/heading.markdoc.ts +23 -0
  329. package/server/schema/hero.markdoc.ts +59 -0
  330. package/server/schema/section.markdoc.ts +10 -0
  331. package/server/schema/slide.markdoc.ts +17 -0
  332. package/server/schema/source.markdoc.ts +53 -0
  333. package/server/schema/tabgroup.markdoc.ts +102 -0
  334. package/server/serve.ts +635 -0
  335. package/server/templates/README.md +352 -0
  336. package/server/templates/constants.ts +236 -0
  337. package/server/templates/fragments.ts +159 -0
  338. package/server/templates/hmr.ts +269 -0
  339. package/server/templates/menu.ts +33 -0
  340. package/server/templates/performance-hints.ts +94 -0
  341. package/server/templates/service-worker.ts +403 -0
  342. package/server/templates/sitemap.ts +57 -0
  343. package/server/templates/toc.ts +41 -0
  344. package/server/templates/utils.ts +378 -0
  345. package/src/component.ts +215 -0
  346. package/src/context.ts +156 -0
  347. package/src/effects/attribute.ts +82 -0
  348. package/src/effects/class.ts +28 -0
  349. package/src/effects/event.ts +67 -0
  350. package/src/effects/html.ts +60 -0
  351. package/src/effects/method.ts +57 -0
  352. package/src/effects/pass.ts +103 -0
  353. package/src/effects/property.ts +57 -0
  354. package/src/effects/style.ts +34 -0
  355. package/src/effects/text.ts +28 -0
  356. package/src/effects.ts +412 -0
  357. package/src/errors.ts +160 -0
  358. package/src/parsers/boolean.ts +14 -0
  359. package/src/parsers/json.ts +33 -0
  360. package/src/parsers/number.ts +55 -0
  361. package/src/parsers/string.ts +32 -0
  362. package/src/parsers.ts +90 -0
  363. package/src/scheduler.ts +47 -0
  364. package/src/signals/collection.ts +253 -0
  365. package/src/signals/sensor.ts +131 -0
  366. package/src/ui.ts +236 -0
  367. package/src/util.ts +187 -0
  368. package/tsconfig.json +34 -0
  369. package/types/examples/basic-button/basic-button.d.ts +16 -0
  370. package/types/examples/basic-hello/basic-hello.d.ts +18 -0
  371. package/types/index.d.ts +27 -0
  372. package/types/index.dev.d.ts +27 -0
  373. package/types/src/collection.d.ts +27 -0
  374. package/types/src/component.d.ts +32 -0
  375. package/types/src/context.d.ts +85 -0
  376. package/types/src/effects/attribute.d.ts +23 -0
  377. package/types/src/effects/callMethod.d.ts +23 -0
  378. package/types/src/effects/class.d.ts +13 -0
  379. package/types/src/effects/dangerouslySetInnerHTML.d.ts +18 -0
  380. package/types/src/effects/event.d.ts +18 -0
  381. package/types/src/effects/html.d.ts +17 -0
  382. package/types/src/effects/method.d.ts +22 -0
  383. package/types/src/effects/pass.d.ts +18 -0
  384. package/types/src/effects/property.d.ts +22 -0
  385. package/types/src/effects/setAttribute.d.ts +24 -0
  386. package/types/src/effects/setProperty.d.ts +23 -0
  387. package/types/src/effects/setStyle.d.ts +14 -0
  388. package/types/src/effects/setText.d.ts +13 -0
  389. package/types/src/effects/style.d.ts +13 -0
  390. package/types/src/effects/text.d.ts +12 -0
  391. package/types/src/effects/toggleClass.d.ts +14 -0
  392. package/types/src/effects.d.ts +153 -0
  393. package/types/src/errors.d.ts +99 -0
  394. package/types/src/events.d.ts +27 -0
  395. package/types/src/extractors.d.ts +23 -0
  396. package/types/src/parsers/boolean.d.ts +10 -0
  397. package/types/src/parsers/json.d.ts +13 -0
  398. package/types/src/parsers/number.d.ts +21 -0
  399. package/types/src/parsers/string.d.ts +19 -0
  400. package/types/src/parsers.d.ts +41 -0
  401. package/types/src/scheduler.d.ts +11 -0
  402. package/types/src/sensor.d.ts +27 -0
  403. package/types/src/signals/collection.d.ts +32 -0
  404. package/types/src/signals/sensor.d.ts +27 -0
  405. package/types/src/ui.d.ts +37 -0
  406. package/types/src/util.d.ts +65 -0
@@ -0,0 +1,1050 @@
1
+ import { expect, test } from '@playwright/test'
2
+
3
+ /**
4
+ * Test Suite: form-listbox Component
5
+ *
6
+ * Comprehensive tests for the Le Truc form-listbox component, which provides
7
+ * a filterable listbox with keyboard navigation and remote data loading.
8
+ *
9
+ * Key Features Tested:
10
+ * - ✅ Basic rendering and initialization
11
+ * - ✅ Remote JSON data loading (both flat arrays and grouped objects)
12
+ * - ✅ Loading states and error handling
13
+ * - ✅ Option selection and value management
14
+ * - ✅ Keyboard navigation (arrow keys, Enter, Escape)
15
+ * - ✅ Filtering functionality
16
+ * - ✅ Focus management and accessibility
17
+ * - ✅ Event emission on value changes
18
+ * - ✅ Dynamic src and property updates
19
+ * - ✅ Form integration and data submission
20
+ * - ✅ Edge cases and performance scenarios
21
+ *
22
+ * Architecture Notes:
23
+ * - Uses `asString` parser for src URL validation
24
+ * - Implements `fetchWithCache` for HTTP request caching
25
+ * - Uses Collections for reactive option tracking
26
+ * - Manages focus via `manageFocus` utility
27
+ * - Renders content via `dangerouslySetInnerHTML`
28
+ * - Emits custom 'form-listbox.change' events
29
+ *
30
+ * Test Coverage: 80+ comprehensive test cases covering all major functionality,
31
+ * error states, accessibility features, and edge cases. Tests validate both
32
+ * user interactions and programmatic property changes following Le Truc's
33
+ * reactive property model.
34
+ */
35
+
36
+ test.describe('form-listbox component', () => {
37
+ test.beforeEach(async ({ page }) => {
38
+ page.on('console', msg => {
39
+ console.log(`[browser] ${msg.type()}: ${msg.text()}`)
40
+ })
41
+
42
+ await page.goto('http://localhost:3000/test/form-listbox.html')
43
+ await page.waitForSelector('form-listbox')
44
+ })
45
+
46
+ test.describe('Basic Rendering and Initialization', () => {
47
+ test('loads and displays timezone data successfully', async ({ page }) => {
48
+ const listbox = page.locator('form-listbox').first()
49
+ const loading = listbox.locator('.loading')
50
+ const error = listbox.locator('.error')
51
+ const callout = listbox.locator('card-callout')
52
+ const listboxElement = listbox.locator('[role="listbox"]')
53
+
54
+ // Wait for content to load
55
+ await expect(listboxElement).toBeVisible({ timeout: 1000 })
56
+
57
+ // After loading, should hide loading and error states
58
+ await expect(loading).toBeHidden()
59
+ await expect(error).toBeHidden()
60
+ await expect(callout).toBeHidden()
61
+
62
+ // Should have loaded timezone options
63
+ const options = listbox.locator('button[role="option"]')
64
+ const optionCount = await options.count()
65
+ expect(optionCount).toBeGreaterThan(100) // Should have many timezone options
66
+
67
+ // Should have grouped content
68
+ const groups = listbox.locator('[role="group"]')
69
+ const groupCount = await groups.count()
70
+ expect(groupCount).toBeGreaterThan(5) // Should have continent groups
71
+ })
72
+
73
+ test('displays grouped timezone data with proper structure', async ({
74
+ page,
75
+ }) => {
76
+ const listbox = page.locator('form-listbox').first()
77
+ const listboxElement = listbox.locator('[role="listbox"]')
78
+
79
+ await expect(listboxElement).toBeVisible({ timeout: 1000 })
80
+
81
+ // Check for Africa group
82
+ const africaGroup = listbox.locator('[role="group"]').first()
83
+ await expect(africaGroup).toBeVisible()
84
+
85
+ const africaLabel = africaGroup.locator('[role="presentation"]').first()
86
+ await expect(africaLabel).toContainText('Africa')
87
+
88
+ // Check for options within Africa group
89
+ const africaOptions = africaGroup.locator('button[role="option"]')
90
+ const africaCount = await africaOptions.count()
91
+ expect(africaCount).toBeGreaterThan(10)
92
+
93
+ // Verify specific African cities
94
+ await expect(africaOptions).toContainText(['Abidjan', 'Cairo', 'Lagos'])
95
+ })
96
+ })
97
+
98
+ test.describe('Error Handling', () => {
99
+ test('shows error for invalid URL', async ({ page }) => {
100
+ // Add a listbox with invalid src (using an actual invalid URL format)
101
+ await page.evaluate(() => {
102
+ const listbox = document.createElement('form-listbox')
103
+ listbox.setAttribute('src', 'ftp://invalid-protocol.example')
104
+ listbox.innerHTML = `
105
+ <input type="hidden" name="timezone" />
106
+ <card-callout>
107
+ <p class="loading" role="status">Loading...</p>
108
+ <p class="error" role="alert" aria-live="assertive" hidden></p>
109
+ </card-callout>
110
+ <module-scrollarea orientation="vertical">
111
+ <div role="listbox" aria-label="Test" hidden></div>
112
+ </module-scrollarea>
113
+ `
114
+ document.body.appendChild(listbox)
115
+ })
116
+
117
+ const invalidListbox = page.locator('form-listbox').last()
118
+ const loading = invalidListbox.locator('.loading')
119
+ const error = invalidListbox.locator('.error')
120
+ const callout = invalidListbox.locator('card-callout')
121
+
122
+ await expect(callout).toBeVisible({ timeout: 1000 })
123
+ await expect(error).toBeVisible()
124
+ await expect(loading).toBeHidden()
125
+ await expect(callout).toHaveClass(/danger/)
126
+
127
+ const errorText = await error.textContent()
128
+ expect(errorText).toContain('Invalid URL')
129
+ })
130
+
131
+ test('shows error for 404 not found', async ({ page }) => {
132
+ await page.evaluate(() => {
133
+ const listbox = document.createElement('form-listbox')
134
+ listbox.setAttribute('src', '/nonexistent-file.json')
135
+ listbox.innerHTML = `
136
+ <input type="hidden" name="timezone" />
137
+ <card-callout>
138
+ <p class="loading" role="status">Loading...</p>
139
+ <p class="error" role="alert" aria-live="assertive" hidden></p>
140
+ </card-callout>
141
+ <module-scrollarea orientation="vertical">
142
+ <div role="listbox" aria-label="Test" hidden></div>
143
+ </module-scrollarea>
144
+ `
145
+ document.body.appendChild(listbox)
146
+ })
147
+
148
+ const notFoundListbox = page.locator('form-listbox').last()
149
+ const error = notFoundListbox.locator('.error')
150
+ const callout = notFoundListbox.locator('card-callout')
151
+
152
+ await expect(error).toBeVisible({ timeout: 1000 })
153
+ await expect(callout).toHaveClass(/danger/)
154
+ })
155
+
156
+ test('handles component without src attribute correctly', async ({
157
+ page,
158
+ }) => {
159
+ await page.evaluate(() => {
160
+ const listbox = document.createElement('form-listbox')
161
+ listbox.innerHTML = `
162
+ <input type="hidden" name="timezone" />
163
+ <div role="listbox" aria-label="Test">
164
+ <button type="button" role="option" tabindex="-1" value="test">Test Option</button>
165
+ </div>
166
+ `
167
+ document.body.appendChild(listbox)
168
+ })
169
+
170
+ const noSrcListbox = page.locator('form-listbox').last()
171
+ const listboxElement = noSrcListbox.locator('[role="listbox"]')
172
+ const options = listboxElement.locator('button[role="option"]')
173
+
174
+ // Should show listbox and options when no src but inline HTML exists
175
+ await expect(listboxElement).toBeVisible()
176
+ await expect(options).toHaveCount(1)
177
+
178
+ // Should not show any error callout
179
+ const callout = noSrcListbox.locator('card-callout')
180
+ if ((await callout.count()) > 0) {
181
+ await expect(callout).toBeHidden()
182
+ }
183
+ })
184
+ })
185
+
186
+ test.describe('Option Selection and Value Management', () => {
187
+ test('selects option when clicked', async ({ page }) => {
188
+ const listbox = page.locator('form-listbox').first()
189
+ const listboxElement = listbox.locator('[role="listbox"]')
190
+
191
+ await expect(listboxElement).toBeVisible({ timeout: 1000 })
192
+
193
+ // Find and click an option
194
+ const firstOption = listbox.locator('button[role="option"]').first()
195
+ await expect(firstOption).toBeVisible()
196
+
197
+ const optionValue = await firstOption.getAttribute('value')
198
+
199
+ await firstOption.click()
200
+
201
+ // Verify selection
202
+ await expect(firstOption).toHaveAttribute('aria-selected', 'true')
203
+ await expect(firstOption).toHaveAttribute('tabindex', '0')
204
+
205
+ // Check component value property
206
+ const componentValue = await page.evaluate(() => {
207
+ const element = document.querySelector('form-listbox') as any
208
+ return element.value
209
+ })
210
+ expect(componentValue).toBe(optionValue)
211
+
212
+ // Check value attribute on host element
213
+ await expect(listbox).toHaveAttribute('value', optionValue || '')
214
+ })
215
+
216
+ test('updates selection when value property is changed programmatically', async ({
217
+ page,
218
+ }) => {
219
+ const listbox = page.locator('form-listbox').first()
220
+ await expect(listbox.locator('[role="listbox"]')).toBeVisible({
221
+ timeout: 1000,
222
+ })
223
+
224
+ // Get a specific option value
225
+ const targetOption = listbox.locator('button[role="option"]').nth(5)
226
+ const targetValue = await targetOption.getAttribute('value')
227
+
228
+ // Set value programmatically
229
+ await page.evaluate(value => {
230
+ const element = document.querySelector('form-listbox') as any
231
+ element.value = value
232
+ }, targetValue)
233
+
234
+ // Verify the option is now selected
235
+ await expect(targetOption).toHaveAttribute('aria-selected', 'true')
236
+ await expect(targetOption).toHaveAttribute('tabindex', '0')
237
+ await expect(listbox).toHaveAttribute('value', targetValue || '')
238
+ })
239
+
240
+ test('deselects previous option when new option is selected', async ({
241
+ page,
242
+ }) => {
243
+ const listbox = page.locator('form-listbox').first()
244
+ await expect(listbox.locator('[role="listbox"]')).toBeVisible({
245
+ timeout: 1000,
246
+ })
247
+
248
+ const firstOption = listbox.locator('button[role="option"]').first()
249
+ const secondOption = listbox.locator('button[role="option"]').nth(1)
250
+
251
+ // Select first option
252
+ await firstOption.click()
253
+ await expect(firstOption).toHaveAttribute('aria-selected', 'true')
254
+ await expect(firstOption).toHaveAttribute('tabindex', '0')
255
+
256
+ // Select second option
257
+ await secondOption.click()
258
+ await expect(secondOption).toHaveAttribute('aria-selected', 'true')
259
+ await expect(secondOption).toHaveAttribute('tabindex', '0')
260
+
261
+ // First option should be deselected
262
+ await expect(firstOption).toHaveAttribute('aria-selected', 'false')
263
+ await expect(firstOption).toHaveAttribute('tabindex', '-1')
264
+ })
265
+ })
266
+
267
+ test.describe('Keyboard Navigation', () => {
268
+ test('focuses first option when listbox receives focus', async ({
269
+ page,
270
+ }) => {
271
+ const listbox = page.locator('form-listbox').first()
272
+ const listboxElement = listbox.locator('[role="listbox"]')
273
+
274
+ await expect(listboxElement).toBeVisible({ timeout: 1000 })
275
+
276
+ // First select an option to set up initial focus management
277
+ const firstOption = listbox.locator('button[role="option"]').first()
278
+ await firstOption.click()
279
+
280
+ // Now test focus behavior
281
+ await listboxElement.focus()
282
+ await expect(firstOption).toBeFocused()
283
+ })
284
+
285
+ test('navigates options with arrow keys', async ({ page }) => {
286
+ const listbox = page.locator('form-listbox').first()
287
+ const listboxElement = listbox.locator('[role="listbox"]')
288
+
289
+ await expect(listboxElement).toBeVisible({ timeout: 1000 })
290
+
291
+ // First select an option to establish focus management
292
+ const firstOption = listbox.locator('button[role="option"]').first()
293
+ const secondOption = listbox.locator('button[role="option"]').nth(1)
294
+
295
+ await firstOption.click()
296
+ await listboxElement.focus()
297
+ await expect(firstOption).toBeFocused()
298
+
299
+ // Navigate down
300
+ await page.keyboard.press('ArrowDown')
301
+ await expect(secondOption).toBeFocused()
302
+
303
+ // Navigate back up
304
+ await page.keyboard.press('ArrowUp')
305
+ await expect(firstOption).toBeFocused()
306
+ })
307
+
308
+ test('selects focused option with Enter key', async ({ page }) => {
309
+ const listbox = page.locator('form-listbox').first()
310
+ const listboxElement = listbox.locator('[role="listbox"]')
311
+
312
+ await expect(listboxElement).toBeVisible({ timeout: 1000 })
313
+
314
+ const firstOption = listbox.locator('button[role="option"]').first()
315
+ const firstValue = await firstOption.getAttribute('value')
316
+
317
+ // First click to establish focus, then clear selection
318
+ await firstOption.click()
319
+ await page.evaluate(() => {
320
+ const element = document.querySelector('form-listbox') as any
321
+ element.value = ''
322
+ })
323
+
324
+ await listboxElement.focus()
325
+ await expect(firstOption).toBeFocused()
326
+
327
+ // Press Enter to select
328
+ await page.keyboard.press('Enter')
329
+
330
+ await expect(firstOption).toHaveAttribute('aria-selected', 'true')
331
+ await expect(listbox).toHaveAttribute('value', firstValue || '')
332
+ })
333
+
334
+ test('wraps navigation at boundaries', async ({ page }) => {
335
+ const listbox = page.locator('form-listbox').first()
336
+ const listboxElement = listbox.locator('[role="listbox"]')
337
+
338
+ await expect(listboxElement).toBeVisible({ timeout: 1000 })
339
+
340
+ // First establish focus on first option
341
+ const firstOption = listbox.locator('button[role="option"]').first()
342
+ await firstOption.click()
343
+ await listboxElement.focus()
344
+
345
+ // Navigate up from first option should wrap to last
346
+ await page.keyboard.press('ArrowUp')
347
+
348
+ const lastOptionValue = await page.evaluate(() => {
349
+ const listbox = document.querySelector('form-listbox')
350
+ const options = Array.from(
351
+ listbox?.querySelectorAll('button[role="option"]:not([hidden])')
352
+ || [],
353
+ )
354
+ return (options[options.length - 1] as HTMLElement)?.getAttribute(
355
+ 'value',
356
+ )
357
+ })
358
+
359
+ const focusedValue = await page.evaluate(() =>
360
+ (document.activeElement as HTMLElement)?.getAttribute('value'),
361
+ )
362
+ expect(focusedValue).toBe(lastOptionValue)
363
+ })
364
+ })
365
+
366
+ test.describe('Filtering Functionality', () => {
367
+ test('filters options based on filter property', async ({ page }) => {
368
+ const listbox = page.locator('form-listbox').first()
369
+ await expect(listbox.locator('[role="listbox"]')).toBeVisible({
370
+ timeout: 1000,
371
+ })
372
+
373
+ const allOptions = listbox.locator('button[role="option"]')
374
+ const initialCount = await allOptions.count()
375
+
376
+ // Set filter to show options containing 'af' (should match Africa cities)
377
+ await page.evaluate(() => {
378
+ const element = document.querySelector('form-listbox') as any
379
+ element.filter = 'af'
380
+ })
381
+
382
+ const visibleOptions = listbox.locator(
383
+ 'button[role="option"]:not([hidden])',
384
+ )
385
+ const visibleCount = await visibleOptions.count()
386
+
387
+ expect(visibleCount).toBeLessThan(initialCount)
388
+ expect(visibleCount).toBeGreaterThan(0)
389
+
390
+ // Verify visible options contain "af" in their text
391
+ for (let i = 0; i < Math.min(visibleCount, 5); i++) {
392
+ const optionText = await visibleOptions.nth(i).textContent()
393
+ expect(optionText?.toLowerCase()).toContain('af')
394
+ }
395
+ })
396
+
397
+ test('highlights matching text when filter is applied', async ({
398
+ page,
399
+ }) => {
400
+ const listbox = page.locator('form-listbox').first()
401
+ await expect(listbox.locator('[role="listbox"]')).toBeVisible({
402
+ timeout: 1000,
403
+ })
404
+
405
+ // Set filter
406
+ await page.evaluate(() => {
407
+ const element = document.querySelector('form-listbox') as any
408
+ element.filter = 'new'
409
+ })
410
+
411
+ const visibleOptions = listbox.locator(
412
+ 'button[role="option"]:not([hidden])',
413
+ )
414
+ const visibleCount = await visibleOptions.count()
415
+
416
+ if (visibleCount > 0) {
417
+ const firstVisible = visibleOptions.first()
418
+ const highlightedMark = firstVisible.locator('mark')
419
+
420
+ // Should have highlighted text
421
+ await expect(highlightedMark).toBeVisible()
422
+ const highlightedText = await highlightedMark.textContent()
423
+ expect(highlightedText?.toLowerCase()).toBe('new')
424
+ }
425
+ })
426
+
427
+ test('shows no options when filter matches nothing', async ({ page }) => {
428
+ const listbox = page.locator('form-listbox').first()
429
+ await expect(listbox.locator('[role="listbox"]')).toBeVisible({
430
+ timeout: 1000,
431
+ })
432
+
433
+ // Set filter that won't match anything
434
+ await page.evaluate(() => {
435
+ const element = document.querySelector('form-listbox') as any
436
+ element.filter = 'xyzzyx-nonexistent'
437
+ })
438
+
439
+ const visibleOptions = listbox.locator(
440
+ 'button[role="option"]:not([hidden])',
441
+ )
442
+ const visibleCount = await visibleOptions.count()
443
+
444
+ expect(visibleCount).toBe(0)
445
+ })
446
+
447
+ test('clears filter and shows all options', async ({ page }) => {
448
+ const listbox = page.locator('form-listbox').first()
449
+ await expect(listbox.locator('[role="listbox"]')).toBeVisible({
450
+ timeout: 1000,
451
+ })
452
+
453
+ const allOptions = listbox.locator('button[role="option"]')
454
+ const initialCount = await allOptions.count()
455
+
456
+ // Apply filter
457
+ await page.evaluate(() => {
458
+ const element = document.querySelector('form-listbox') as any
459
+ element.filter = 'africa'
460
+ })
461
+
462
+ const filteredCount = await listbox
463
+ .locator('button[role="option"]:not([hidden])')
464
+ .count()
465
+ expect(filteredCount).toBeLessThan(initialCount)
466
+
467
+ // Clear filter
468
+ await page.evaluate(() => {
469
+ const element = document.querySelector('form-listbox') as any
470
+ element.filter = ''
471
+ })
472
+
473
+ const clearedCount = await listbox
474
+ .locator('button[role="option"]:not([hidden])')
475
+ .count()
476
+ expect(clearedCount).toBe(initialCount)
477
+ })
478
+ })
479
+
480
+ test.describe('Event Handling', () => {
481
+ test('emits change event when value changes', async ({ page }) => {
482
+ const listbox = page.locator('form-listbox').first()
483
+ await expect(listbox.locator('[role="listbox"]')).toBeVisible({
484
+ timeout: 1000,
485
+ })
486
+
487
+ // Set up event listener
488
+ await page.evaluate(() => {
489
+ ;(window as any).changeEvents = []
490
+ const element = document.querySelector('form-listbox')
491
+ element?.addEventListener('change', event => {
492
+ ;(window as any).changeEvents.push(
493
+ (event.target as HTMLInputElement).value,
494
+ )
495
+ })
496
+ })
497
+
498
+ const firstOption = listbox.locator('button[role="option"]').first()
499
+ const optionValue = await firstOption.getAttribute('value')
500
+
501
+ await firstOption.click()
502
+
503
+ // Check that event was fired
504
+ const changeEvents = await page.evaluate(
505
+ () => (window as any).changeEvents,
506
+ )
507
+ expect(changeEvents).toHaveLength(1)
508
+ expect(changeEvents[0]).toBe(optionValue)
509
+ })
510
+
511
+ test('does not emit duplicate events for same value', async ({ page }) => {
512
+ const listbox = page.locator('form-listbox').first()
513
+ await expect(listbox.locator('[role="listbox"]')).toBeVisible({
514
+ timeout: 1000,
515
+ })
516
+
517
+ // Set up event listener
518
+ await page.evaluate(() => {
519
+ ;(window as any).changeEvents = []
520
+ const element = document.querySelector('form-listbox')
521
+ element?.addEventListener('change', event => {
522
+ ;(window as any).changeEvents.push(
523
+ (event.target as HTMLInputElement).value,
524
+ )
525
+ })
526
+ })
527
+
528
+ const firstOption = listbox.locator('button[role="option"]').first()
529
+
530
+ // Click same option twice
531
+ await firstOption.click()
532
+ await firstOption.click()
533
+
534
+ // Should only have one event
535
+ const changeEvents = await page.evaluate(
536
+ () => (window as any).changeEvents,
537
+ )
538
+ expect(changeEvents).toHaveLength(1)
539
+ })
540
+ })
541
+
542
+ test.describe('Dynamic Behavior', () => {
543
+ test('updates content when src property changes', async ({ page }) => {
544
+ // Create a simple JSON endpoint response simulation
545
+ await page.route('**/simple-options.json', route => {
546
+ route.fulfill({
547
+ status: 200,
548
+ contentType: 'application/json',
549
+ body: JSON.stringify([
550
+ { value: 'opt1', label: 'Option 1' },
551
+ { value: 'opt2', label: 'Option 2' },
552
+ { value: 'opt3', label: 'Option 3' },
553
+ ]),
554
+ })
555
+ })
556
+
557
+ const listbox = page.locator('form-listbox').first()
558
+ await expect(listbox.locator('[role="listbox"]')).toBeVisible({
559
+ timeout: 1000,
560
+ })
561
+
562
+ const initialOptions = listbox.locator('button[role="option"]')
563
+ const initialCount = await initialOptions.count()
564
+
565
+ // Change src to simple options
566
+ await page.evaluate(() => {
567
+ const element = document.querySelector('form-listbox') as any
568
+ element.src = '/simple-options.json'
569
+ })
570
+
571
+ // Wait for new content to load
572
+ await page.waitForTimeout(50)
573
+
574
+ const newOptions = listbox.locator('button[role="option"]')
575
+ const newCount = await newOptions.count()
576
+
577
+ expect(newCount).toBe(3)
578
+ expect(newCount).not.toBe(initialCount)
579
+
580
+ await expect(newOptions).toContainText([
581
+ 'Option 1',
582
+ 'Option 2',
583
+ 'Option 3',
584
+ ])
585
+ })
586
+
587
+ test('handles src property validation', async ({ page }) => {
588
+ const listbox = page.locator('form-listbox').first()
589
+ await expect(listbox.locator('[role="listbox"]')).toBeVisible({
590
+ timeout: 1000,
591
+ })
592
+
593
+ // Set invalid src
594
+ await page.evaluate(() => {
595
+ const element = document.querySelector('form-listbox') as any
596
+ element.src = 'invalid-url'
597
+ })
598
+
599
+ const error = listbox.locator('.error')
600
+ const callout = listbox.locator('card-callout')
601
+
602
+ await expect(error).toBeVisible({ timeout: 1000 })
603
+ await expect(callout).toHaveClass(/danger/)
604
+ })
605
+ })
606
+
607
+ test.describe('Accessibility Features', () => {
608
+ test('maintains proper ARIA attributes', async ({ page }) => {
609
+ const listbox = page.locator('form-listbox').first()
610
+ const listboxElement = listbox.locator('[role="listbox"]')
611
+
612
+ await expect(listboxElement).toBeVisible({ timeout: 1000 })
613
+
614
+ // Verify listbox role and aria-label
615
+ await expect(listboxElement).toHaveAttribute('role', 'listbox')
616
+ await expect(listboxElement).toHaveAttribute('aria-label')
617
+
618
+ // Verify option roles and aria-selected attributes
619
+ const options = listbox.locator('button[role="option"]')
620
+ const firstOption = options.first()
621
+
622
+ await expect(firstOption).toHaveAttribute('role', 'option')
623
+ await expect(firstOption).toHaveAttribute('aria-selected', 'false')
624
+
625
+ await firstOption.click()
626
+ await expect(firstOption).toHaveAttribute('aria-selected', 'true')
627
+ })
628
+
629
+ test('manages tabindex correctly for keyboard navigation', async ({
630
+ page,
631
+ }) => {
632
+ const listbox = page.locator('form-listbox').first()
633
+ await expect(listbox.locator('[role="listbox"]')).toBeVisible({
634
+ timeout: 1000,
635
+ })
636
+
637
+ const options = listbox.locator('button[role="option"]')
638
+ const firstOption = options.first()
639
+ const secondOption = options.nth(1)
640
+
641
+ // Initially no option should have tabindex="0"
642
+ await expect(firstOption).toHaveAttribute('tabindex', '-1')
643
+ await expect(secondOption).toHaveAttribute('tabindex', '-1')
644
+
645
+ // Select first option
646
+ await firstOption.click()
647
+
648
+ // Selected option should have tabindex="0"
649
+ await expect(firstOption).toHaveAttribute('tabindex', '0')
650
+ await expect(secondOption).toHaveAttribute('tabindex', '-1')
651
+
652
+ // Select second option
653
+ await secondOption.click()
654
+
655
+ // Tabindex should move to second option
656
+ await expect(firstOption).toHaveAttribute('tabindex', '-1')
657
+ await expect(secondOption).toHaveAttribute('tabindex', '0')
658
+ })
659
+
660
+ test('provides proper live region updates', async ({ page }) => {
661
+ const listbox = page.locator('form-listbox').first()
662
+
663
+ // Check error live region
664
+ const error = listbox.locator('.error')
665
+ await expect(error).toHaveAttribute('role', 'alert')
666
+ await expect(error).toHaveAttribute('aria-live', 'assertive')
667
+
668
+ // Check loading status
669
+ const loading = listbox.locator('.loading')
670
+ await expect(loading).toHaveAttribute('role', 'status')
671
+ })
672
+ })
673
+
674
+ test.describe('Form Integration', () => {
675
+ test('works with FormData and form submission', async ({ page }) => {
676
+ const listbox = page.locator('form-listbox').first()
677
+ await expect(listbox.locator('[role="listbox"]')).toBeVisible({
678
+ timeout: 1000,
679
+ })
680
+
681
+ // Form is already wrapped in HTML, no need to create one
682
+
683
+ const firstOption = listbox.locator('button[role="option"]').first()
684
+ const optionValue = await firstOption.getAttribute('value')
685
+
686
+ await firstOption.click()
687
+
688
+ // Verify hidden input has the value
689
+ const hiddenInputValue = await page.evaluate(() => {
690
+ const hiddenInput = document.querySelector(
691
+ 'form-listbox input[type="hidden"]',
692
+ ) as HTMLInputElement
693
+ return hiddenInput?.value
694
+ })
695
+
696
+ expect(hiddenInputValue).toBe(optionValue)
697
+
698
+ // Test form data
699
+ const formData = await page.evaluate(() => {
700
+ const form = document.querySelector('form')
701
+ if (!form) return { error: 'No form found' }
702
+ const data = new FormData(form)
703
+ const entries = Object.fromEntries(data.entries())
704
+ return entries
705
+ })
706
+
707
+ // Debug form data if test fails
708
+ if (!formData.timezone) {
709
+ console.log('Form data:', formData)
710
+ console.log('Expected timezone value:', optionValue)
711
+ console.log('Hidden input value:', hiddenInputValue)
712
+ }
713
+
714
+ expect(formData).toEqual({ timezone: optionValue })
715
+ })
716
+ })
717
+
718
+ test.describe('Component Properties', () => {
719
+ test('value property reflects selected option', async ({ page }) => {
720
+ const listbox = page.locator('form-listbox').first()
721
+ await expect(listbox.locator('[role="listbox"]')).toBeVisible({
722
+ timeout: 1000,
723
+ })
724
+
725
+ // Initially no value
726
+ let value = await page.evaluate(() => {
727
+ const element = document.querySelector('form-listbox') as any
728
+ return element.value
729
+ })
730
+ expect(value).toBe('')
731
+
732
+ const firstOption = listbox.locator('button[role="option"]').first()
733
+ const optionValue = await firstOption.getAttribute('value')
734
+
735
+ await firstOption.click()
736
+
737
+ // Value should update
738
+ value = await page.evaluate(() => {
739
+ const element = document.querySelector('form-listbox') as any
740
+ return element.value
741
+ })
742
+ expect(value).toBe(optionValue)
743
+ })
744
+
745
+ test('filter property controls option visibility', async ({ page }) => {
746
+ const listbox = page.locator('form-listbox').first()
747
+ await expect(listbox.locator('[role="listbox"]')).toBeVisible({
748
+ timeout: 1000,
749
+ })
750
+
751
+ // Test getting filter property
752
+ let filter = await page.evaluate(() => {
753
+ const element = document.querySelector('form-listbox') as any
754
+ return element.filter
755
+ })
756
+ expect(filter).toBe('')
757
+
758
+ // Set filter property
759
+ await page.evaluate(() => {
760
+ const element = document.querySelector('form-listbox') as any
761
+ element.filter = 'america'
762
+ })
763
+
764
+ filter = await page.evaluate(() => {
765
+ const element = document.querySelector('form-listbox') as any
766
+ return element.filter
767
+ })
768
+ expect(filter).toBe('america')
769
+ })
770
+
771
+ test('src property controls data source', async ({ page }) => {
772
+ const src = await page.evaluate(() => {
773
+ const element = document.querySelector('form-listbox') as any
774
+ return element.src
775
+ })
776
+ expect(src).toBe('/form-listbox/mocks/timezones.json')
777
+
778
+ // Change src property
779
+ await page.evaluate(() => {
780
+ const element = document.querySelector('form-listbox') as any
781
+ element.src = '/different-url.json'
782
+ })
783
+
784
+ const newSrc = await page.evaluate(() => {
785
+ const element = document.querySelector('form-listbox') as any
786
+ return element.src
787
+ })
788
+ expect(newSrc).toBe('/different-url.json')
789
+ })
790
+ })
791
+
792
+ test.describe('Edge Cases and Performance', () => {
793
+ test('handles rapid property changes gracefully', async ({ page }) => {
794
+ const listbox = page.locator('form-listbox').first()
795
+ await expect(listbox.locator('[role="listbox"]')).toBeVisible({
796
+ timeout: 1000,
797
+ })
798
+
799
+ // Test that rapid filter changes don't break the component
800
+ await page.evaluate(() => {
801
+ const element = document.querySelector('form-listbox') as any
802
+ // Apply filters that should all find results
803
+ element.filter = 'a'
804
+ element.filter = 'af'
805
+ element.filter = 'afr'
806
+ element.filter = 'a' // back to broader filter
807
+ element.filter = '' // clear filter
808
+ })
809
+
810
+ // Should show all options when filter is cleared
811
+ const visibleOptions = listbox.locator(
812
+ 'button[role="option"]:not([hidden])',
813
+ )
814
+ const visibleCount = await visibleOptions.count()
815
+ const totalCount = await listbox.locator('button[role="option"]').count()
816
+
817
+ expect(visibleCount).toBe(totalCount)
818
+ expect(visibleCount).toBeGreaterThan(100)
819
+ })
820
+
821
+ test('handles empty groups correctly', async ({ page }) => {
822
+ const listbox = page.locator('form-listbox').first()
823
+ await expect(listbox.locator('[role="listbox"]')).toBeVisible({
824
+ timeout: 1000,
825
+ })
826
+
827
+ // Apply filter that should make some groups empty
828
+ await page.evaluate(() => {
829
+ const element = document.querySelector('form-listbox') as any
830
+ element.filter = 'Zurich' // Only one option
831
+ })
832
+
833
+ // Groups with no visible options should be hidden via CSS
834
+ // Check that some groups are effectively hidden (either via CSS or JS)
835
+ const allGroups = listbox.locator('[role="group"]')
836
+ const totalGroups = await allGroups.count()
837
+
838
+ // Count groups that have visible options
839
+ let groupsWithVisibleOptions = 0
840
+ for (let i = 0; i < totalGroups; i++) {
841
+ const group = allGroups.nth(i)
842
+ const visibleOptionsInGroup = group.locator(
843
+ 'button[role="option"]:not([hidden])',
844
+ )
845
+ const count = await visibleOptionsInGroup.count()
846
+ if (count > 0) {
847
+ groupsWithVisibleOptions++
848
+ }
849
+ }
850
+
851
+ expect(groupsWithVisibleOptions).toBeLessThan(totalGroups)
852
+ expect(groupsWithVisibleOptions).toBeGreaterThan(0)
853
+ })
854
+
855
+ test('maintains performance with large datasets', async ({ page }) => {
856
+ const listbox = page.locator('form-listbox').first()
857
+ await expect(listbox.locator('[role="listbox"]')).toBeVisible({
858
+ timeout: 1000,
859
+ })
860
+
861
+ const startTime = Date.now()
862
+
863
+ // Apply and clear filter rapidly
864
+ await page.evaluate(() => {
865
+ const element = document.querySelector('form-listbox') as any
866
+ for (let i = 0; i < 10; i++) {
867
+ element.filter = 'test'
868
+ element.filter = ''
869
+ }
870
+ })
871
+
872
+ const endTime = Date.now()
873
+ const duration = endTime - startTime
874
+
875
+ // Should complete quickly even with large dataset
876
+ expect(duration).toBeLessThan(2000)
877
+ })
878
+
879
+ test('handles component removal during loading', async ({ page }) => {
880
+ // Add a new listbox that will be removed quickly
881
+ await page.evaluate(() => {
882
+ const listbox = document.createElement('form-listbox')
883
+ listbox.setAttribute('src', '/form-listbox/timezones.json')
884
+ listbox.innerHTML = `
885
+ <input type="hidden" name="timezone" />
886
+ <card-callout>
887
+ <p class="loading" role="status">Loading...</p>
888
+ <p class="error" role="alert" aria-live="assertive" hidden></p>
889
+ </card-callout>
890
+ <module-scrollarea orientation="vertical">
891
+ <div role="listbox" aria-label="Test" hidden></div>
892
+ </module-scrollarea>
893
+ `
894
+ document.body.appendChild(listbox)
895
+
896
+ // Remove it quickly
897
+ setTimeout(() => listbox.remove(), 50)
898
+ })
899
+
900
+ // Wait to ensure no errors occur
901
+ await page.waitForTimeout(50)
902
+
903
+ // Test should complete without throwing errors
904
+ expect(true).toBe(true)
905
+ })
906
+ })
907
+
908
+ test.describe('Declarative Inline HTML', () => {
909
+ test('renders inline options without src attribute', async ({ page }) => {
910
+ const listbox = page.locator('form-listbox#colors')
911
+ const listboxElement = listbox.locator('[role="listbox"]')
912
+ const options = listboxElement.locator('button[role="option"]')
913
+
914
+ // Should show listbox immediately (no loading)
915
+ await expect(listboxElement).toBeVisible()
916
+
917
+ // Should have all inline options
918
+ await expect(options).toHaveCount(5)
919
+
920
+ // Should have correct option values and text
921
+ const firstOption = options.first()
922
+ await expect(firstOption).toHaveAttribute('value', 'red')
923
+ await expect(firstOption).toHaveText('Red')
924
+ })
925
+
926
+ test('handles option selection with inline HTML', async ({ page }) => {
927
+ const listbox = page.locator('form-listbox#colors')
928
+ const listboxElement = listbox.locator('[role="listbox"]')
929
+ const firstOption = listboxElement
930
+ .locator('button[role="option"]')
931
+ .first()
932
+
933
+ // Click first option
934
+ await firstOption.click()
935
+
936
+ // Should update component value
937
+ const componentValue = await listbox.getAttribute('value')
938
+ expect(componentValue).toBe('red')
939
+
940
+ // Should update hidden input
941
+ const hiddenInputValue = await listbox
942
+ .locator('input[type="hidden"]')
943
+ .inputValue()
944
+ expect(hiddenInputValue).toBe('red')
945
+
946
+ // Should mark option as selected
947
+ await expect(firstOption).toHaveAttribute('aria-selected', 'true')
948
+ })
949
+
950
+ test('supports filtering with inline options', async ({ page }) => {
951
+ const listbox = page.locator('form-listbox#colors')
952
+ const options = listbox.locator('button[role="option"]')
953
+
954
+ // All options visible initially
955
+ await expect(options).toHaveCount(5)
956
+ for (const option of await options.all()) {
957
+ await expect(option).toBeVisible()
958
+ }
959
+
960
+ // Set filter property
961
+ await listbox.evaluate((element: any) => {
962
+ element.filter = 'r'
963
+ })
964
+
965
+ // Should filter to options containing 'r'
966
+ const redOption = options.filter({ hasText: 'Red' })
967
+ const greenOption = options.filter({ hasText: 'Green' })
968
+ const yellowOption = options.filter({ hasText: 'Yellow' })
969
+ const blueOption = options.filter({ hasText: 'Blue' })
970
+ const purpleOption = options.filter({ hasText: 'Purple' })
971
+
972
+ await expect(redOption).toBeVisible() // Red contains 'r'
973
+ await expect(greenOption).toBeVisible() // Green contains 'r'
974
+ await expect(purpleOption).toBeVisible() // Purple contains 'r'
975
+ await expect(yellowOption).toBeHidden() // Yellow doesn't contain 'r'
976
+ await expect(blueOption).toBeHidden() // Blue doesn't contain 'r'
977
+
978
+ // Should highlight matching text
979
+ const greenOptionForHighlight = options.filter({ hasText: 'Green' })
980
+ const highlightedMark = greenOptionForHighlight.locator('mark')
981
+ await expect(highlightedMark).toBeVisible()
982
+ await expect(highlightedMark).toHaveText('r')
983
+ })
984
+
985
+ test('renders grouped inline options correctly', async ({ page }) => {
986
+ const listbox = page.locator('form-listbox#fruits')
987
+ const listboxElement = listbox.locator('[role="listbox"]')
988
+ const groups = listboxElement.locator('[role="group"]')
989
+ const options = listboxElement.locator('button[role="option"]')
990
+
991
+ // Should have both groups
992
+ await expect(groups).toHaveCount(2)
993
+
994
+ // Should have all options
995
+ await expect(options).toHaveCount(6)
996
+
997
+ // Should have proper group labels
998
+ const citrusLabel = listboxElement.locator('#fruits-citrus')
999
+ await expect(citrusLabel).toHaveText('Citrus')
1000
+
1001
+ const berriesLabel = listboxElement.locator('#fruits-berries')
1002
+ await expect(berriesLabel).toHaveText('Berries')
1003
+
1004
+ // Should maintain group structure for selection
1005
+ const orangeOption = options.filter({ hasText: 'Orange' })
1006
+ await orangeOption.click()
1007
+
1008
+ const componentValue = await listbox.getAttribute('value')
1009
+ expect(componentValue).toBe('orange')
1010
+ })
1011
+
1012
+ test('works with form integration using inline options', async ({
1013
+ page,
1014
+ }) => {
1015
+ const listbox = page.locator('form-listbox#colors')
1016
+ const firstOption = listbox.locator('button[role="option"]').first()
1017
+
1018
+ // Select an option
1019
+ await firstOption.click()
1020
+
1021
+ // Check FormData
1022
+ const formData = await page.evaluate(() => {
1023
+ const form = document.querySelector(
1024
+ 'form:nth-of-type(2)',
1025
+ ) as HTMLFormElement
1026
+ if (!form) throw new Error('Form not found')
1027
+ const data = new FormData(form)
1028
+ const entries = Array.from(data.entries())
1029
+ return Object.fromEntries(entries)
1030
+ })
1031
+
1032
+ expect(formData.color).toBe('red')
1033
+ })
1034
+
1035
+ test('handles mixed content without src attribute', async ({ page }) => {
1036
+ const listbox = page.locator('form-listbox#colors')
1037
+ const options = listbox.locator('button[role="option"]')
1038
+
1039
+ // Should only count actual option buttons (using colors example)
1040
+ await expect(options).toHaveCount(5)
1041
+
1042
+ // Should still work for selection
1043
+ const firstOption = options.first()
1044
+ await firstOption.click()
1045
+
1046
+ const componentValue = await listbox.getAttribute('value')
1047
+ expect(componentValue).toBe('red')
1048
+ })
1049
+ })
1050
+ })