@blotch/dolan 22.2.3 → 22.2.5

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 (227) hide show
  1. package/dist/commands/add.js +9 -1
  2. package/dist/commands/add.js.map +1 -1
  3. package/dist/commands/add.test.js +38 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +258 -21
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +425 -11
  8. package/dist/commands/doctor.test.js.map +1 -1
  9. package/dist/commands/init.d.ts +2 -2
  10. package/dist/commands/init.js +38 -18
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/init.test.js +105 -2
  13. package/dist/commands/init.test.js.map +1 -1
  14. package/dist/commands/theme.js +1 -1
  15. package/dist/commands/theme.js.map +1 -1
  16. package/dist/commands/update.d.ts +7 -0
  17. package/dist/commands/update.js +194 -50
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +570 -3
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +17 -0
  22. package/dist/lockfile.js +56 -1
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +65 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +119 -11
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +61 -6
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +27 -0
  33. package/dist/test-utils.js +45 -1
  34. package/dist/test-utils.js.map +1 -1
  35. package/dist/testing/fire-lynx-event.d.ts +14 -0
  36. package/dist/testing/fire-lynx-event.js +19 -0
  37. package/dist/testing/fire-lynx-event.js.map +1 -0
  38. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  39. package/dist/testing/fire-lynx-event.test.js +44 -0
  40. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  41. package/dist/testing/lynx-main-thread.d.ts +22 -0
  42. package/dist/testing/lynx-main-thread.js +11 -0
  43. package/dist/testing/lynx-main-thread.js.map +1 -0
  44. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  45. package/dist/testing/lynx-main-thread.test.js +24 -0
  46. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  47. package/dist/testing/set-input-signal.d.ts +5 -4
  48. package/dist/testing/set-input-signal.js +5 -4
  49. package/dist/testing/set-input-signal.js.map +1 -1
  50. package/dist/ui/boolean-inputs.test.ts +76 -0
  51. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  52. package/dist/ui/components/accordion/accordion.ts +19 -14
  53. package/dist/ui/components/action-sheet/action-sheet.test.ts +637 -183
  54. package/dist/ui/components/action-sheet/action-sheet.ts +339 -55
  55. package/dist/ui/components/action-sheet/index.ts +1 -0
  56. package/dist/ui/components/alert/alert.test.ts +67 -19
  57. package/dist/ui/components/alert/alert.ts +14 -16
  58. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +740 -129
  59. package/dist/ui/components/alert-dialog/alert-dialog.ts +672 -58
  60. package/dist/ui/components/alert-dialog/index.ts +5 -0
  61. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  62. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  63. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  64. package/dist/ui/components/badge/badge.test.ts +57 -23
  65. package/dist/ui/components/badge/badge.ts +14 -15
  66. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  67. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +1314 -273
  68. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1046 -119
  69. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  70. package/dist/ui/components/button/button.test.ts +646 -2
  71. package/dist/ui/components/button/button.ts +443 -64
  72. package/dist/ui/components/button/index.ts +2 -0
  73. package/dist/ui/components/button-group/button-group.test.ts +528 -2
  74. package/dist/ui/components/button-group/button-group.ts +428 -70
  75. package/dist/ui/components/button-group/index.ts +3 -0
  76. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  77. package/dist/ui/components/card/card.test.ts +97 -61
  78. package/dist/ui/components/card/card.ts +2 -1
  79. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  80. package/dist/ui/components/chart/chart-presets.ts +14 -7
  81. package/dist/ui/components/chart/chart-theme.ts +12 -0
  82. package/dist/ui/components/chart/chart.test.ts +1031 -0
  83. package/dist/ui/components/chart/chart.ts +66 -46
  84. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  85. package/dist/ui/components/checkbox/checkbox.ts +25 -24
  86. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  87. package/dist/ui/components/collapsible/collapsible.ts +21 -15
  88. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  89. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  90. package/dist/ui/components/context-menu/index.ts +1 -0
  91. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  92. package/dist/ui/components/dialog/dialog.ts +35 -29
  93. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  94. package/dist/ui/components/dock/dock-scroll.ts +142 -0
  95. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  96. package/dist/ui/components/dock/dock.test.ts +752 -0
  97. package/dist/ui/components/dock/dock.ts +1438 -0
  98. package/dist/ui/components/dock/index.ts +2 -0
  99. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  100. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  101. package/dist/ui/components/dynamic-color/index.ts +5 -0
  102. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  103. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  104. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  105. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  106. package/dist/ui/components/glass/glass-design.ts +119 -0
  107. package/dist/ui/components/glass/glass.test.ts +376 -0
  108. package/dist/ui/components/glass/glass.ts +430 -0
  109. package/dist/ui/components/glass/index.ts +19 -0
  110. package/dist/ui/components/icon/icon-theme.test.ts +64 -0
  111. package/dist/ui/components/icon/icon-theme.ts +70 -16
  112. package/dist/ui/components/icon/icon.test.ts +52 -41
  113. package/dist/ui/components/input/input.test.ts +178 -101
  114. package/dist/ui/components/input/input.ts +17 -13
  115. package/dist/ui/components/label/label.test.ts +11 -24
  116. package/dist/ui/components/label/label.ts +8 -2
  117. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  118. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  119. package/dist/ui/components/list/list.test.ts +80 -49
  120. package/dist/ui/components/list/list.ts +5 -4
  121. package/dist/ui/components/menu/index.ts +25 -0
  122. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  123. package/dist/ui/components/menu/menu-placement.ts +159 -0
  124. package/dist/ui/components/menu/menu-popup.test.ts +636 -0
  125. package/dist/ui/components/menu/menu-popup.ts +565 -0
  126. package/dist/ui/components/menu/menu.test.ts +523 -0
  127. package/dist/ui/components/menu/menu.ts +455 -0
  128. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  129. package/dist/ui/components/nav-drawer/nav-drawer.ts +75 -58
  130. package/dist/ui/components/navigation-bar/index.ts +1 -0
  131. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  132. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  133. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  134. package/dist/ui/components/popover/index.ts +9 -0
  135. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  136. package/dist/ui/components/popover/popover-panel.ts +603 -0
  137. package/dist/ui/components/popover/popover.test.ts +286 -0
  138. package/dist/ui/components/popover/popover.ts +207 -0
  139. package/dist/ui/components/progress/progress.test.ts +102 -46
  140. package/dist/ui/components/progress/progress.ts +54 -55
  141. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  142. package/dist/ui/components/radio-group/radio-group.ts +26 -25
  143. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  144. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  145. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  146. package/dist/ui/components/search-field/index.ts +15 -0
  147. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  148. package/dist/ui/components/search-field/keyboard.ts +123 -0
  149. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  150. package/dist/ui/components/search-field/search-field.ts +628 -0
  151. package/dist/ui/components/segmented-control/index.ts +8 -0
  152. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  153. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  154. package/dist/ui/components/select/select.test.ts +138 -78
  155. package/dist/ui/components/select/select.ts +19 -12
  156. package/dist/ui/components/separator/separator.test.ts +18 -17
  157. package/dist/ui/components/sheet/sheet.test.ts +151 -11
  158. package/dist/ui/components/sheet/sheet.ts +68 -5
  159. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  160. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  161. package/dist/ui/components/slider/index.ts +1 -0
  162. package/dist/ui/components/slider/slider.test.ts +695 -0
  163. package/dist/ui/components/slider/slider.ts +901 -0
  164. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  165. package/dist/ui/components/spinner/spinner.ts +16 -19
  166. package/dist/ui/components/switch/index.ts +1 -1
  167. package/dist/ui/components/switch/switch.test.ts +534 -48
  168. package/dist/ui/components/switch/switch.ts +613 -48
  169. package/dist/ui/components/tab-bar/index.ts +1 -0
  170. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  171. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  172. package/dist/ui/components/tabs/index.ts +7 -1
  173. package/dist/ui/components/tabs/tabs.test.ts +484 -150
  174. package/dist/ui/components/tabs/tabs.ts +344 -149
  175. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  176. package/dist/ui/components/textarea/textarea.ts +16 -13
  177. package/dist/ui/components/toast/toast.test.ts +434 -350
  178. package/dist/ui/components/toast/toast.ts +42 -51
  179. package/dist/ui/components/toggle/index.ts +1 -0
  180. package/dist/ui/components/toggle/toggle.test.ts +559 -146
  181. package/dist/ui/components/toggle/toggle.ts +241 -26
  182. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  183. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  184. package/dist/ui/components/toolbar/index.ts +12 -0
  185. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  186. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  187. package/dist/ui/theme/chart-colors.test.ts +8 -3
  188. package/dist/ui/theme/dark.css +38 -2
  189. package/dist/ui/theme/default.css +55 -7
  190. package/dist/ui/theme/icon-colors.test.ts +8 -3
  191. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  192. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  193. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  194. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  195. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  196. package/dist/ui/theme/tailwind.css +113 -0
  197. package/dist/ui/utils/animate.test.ts +13 -1
  198. package/dist/utils/analyze.d.ts +19 -0
  199. package/dist/utils/analyze.js +16 -0
  200. package/dist/utils/analyze.js.map +1 -1
  201. package/dist/utils/analyze.test.js +14 -1
  202. package/dist/utils/analyze.test.js.map +1 -1
  203. package/dist/utils/installed.d.ts +25 -1
  204. package/dist/utils/installed.js +33 -0
  205. package/dist/utils/installed.js.map +1 -1
  206. package/dist/utils/installed.test.js +55 -1
  207. package/dist/utils/installed.test.js.map +1 -1
  208. package/dist/utils/resolve-paths.d.ts +4 -4
  209. package/dist/utils/resolve-paths.js +5 -7
  210. package/dist/utils/resolve-paths.js.map +1 -1
  211. package/dist/utils/resolve-paths.test.js +9 -5
  212. package/dist/utils/resolve-paths.test.js.map +1 -1
  213. package/dist/utils/tailwind.d.ts +45 -0
  214. package/dist/utils/tailwind.js +90 -0
  215. package/dist/utils/tailwind.js.map +1 -0
  216. package/dist/utils/tailwind.test.d.ts +1 -0
  217. package/dist/utils/tailwind.test.js +134 -0
  218. package/dist/utils/tailwind.test.js.map +1 -0
  219. package/package.json +9 -1
  220. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  221. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  222. package/dist/ui/components/button/button-render.test.ts +0 -132
  223. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  224. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  225. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  226. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  227. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Sets an Angular `input()` / `model()` signal's value from a test.
3
3
  *
4
- * The Vitest JIT harness does not wire up signal inputs for template binding or
5
- * `ComponentRef.setInput()` (that metadata is only emitted by Angular's AOT
6
- * compiler), so the usual ways of feeding a value into an `input()` are inert
7
- * here. Angular's own reactive-node API is the supported escape hatch: reach the
4
+ * Under `ng test`, which compiles ahead of time, bind the input from a host
5
+ * component's template or call `ComponentRef.setInput()` instead. This is for
6
+ * tests compiled just in time (plain Vitest), where neither reaches a signal
7
+ * input: that metadata is only emitted by Angular's AOT compiler. Angular's own
8
+ * reactive-node API is the supported escape hatch there: reach the
8
9
  * input's SIGNAL node and call `applyValueToInputSignal`, exactly as a parent
9
10
  * template binding would at runtime. This drives a PUBLIC input to a value — it
10
11
  * does not touch a component's private (`#`) state — and mirrors the existing
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Sets an Angular `input()` / `model()` signal's value from a test.
3
3
  *
4
- * The Vitest JIT harness does not wire up signal inputs for template binding or
5
- * `ComponentRef.setInput()` (that metadata is only emitted by Angular's AOT
6
- * compiler), so the usual ways of feeding a value into an `input()` are inert
7
- * here. Angular's own reactive-node API is the supported escape hatch: reach the
4
+ * Under `ng test`, which compiles ahead of time, bind the input from a host
5
+ * component's template or call `ComponentRef.setInput()` instead. This is for
6
+ * tests compiled just in time (plain Vitest), where neither reaches a signal
7
+ * input: that metadata is only emitted by Angular's AOT compiler. Angular's own
8
+ * reactive-node API is the supported escape hatch there: reach the
8
9
  * input's SIGNAL node and call `applyValueToInputSignal`, exactly as a parent
9
10
  * template binding would at runtime. This drives a PUBLIC input to a value — it
10
11
  * does not touch a component's private (`#`) state — and mirrors the existing
@@ -1 +1 @@
1
- {"version":3,"file":"set-input-signal.js","sourceRoot":"","sources":["../../src/testing/set-input-signal.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,QAAiB,EAAE,KAAc,EAAQ,EAAE;IACxE,MAAM,OAAO,GAAG,MAAM,CAAC,qBAAqB,CAAC,QAAkB,CAAC,CAAC;IACjE,MAAM,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE,KAAK,gBAAgB,CAAE,CAAC;IAC7E,MAAM,IAAI,GAAI,QAAoC,CAAC,YAAY,CAAC,CAAC;IACjE,MAAM,KAAK,GAAG,MAAM,CAAC,cAAc,CAAC,IAAI,CAEvC,CAAC;IACF,KAAK,CAAC,uBAAuB,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AAC7C,CAAC,CAAC"}
1
+ {"version":3,"file":"set-input-signal.js","sourceRoot":"","sources":["../../src/testing/set-input-signal.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,QAAiB,EAAE,KAAc,EAAQ,EAAE;IACxE,MAAM,OAAO,GAAG,MAAM,CAAC,qBAAqB,CAAC,QAAkB,CAAC,CAAC;IACjE,MAAM,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE,KAAK,gBAAgB,CAAE,CAAC;IAC7E,MAAM,IAAI,GAAI,QAAoC,CAAC,YAAY,CAAC,CAAC;IACjE,MAAM,KAAK,GAAG,MAAM,CAAC,cAAc,CAAC,IAAI,CAEvC,CAAC;IACF,KAAK,CAAC,uBAAuB,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AAC7C,CAAC,CAAC"}
@@ -0,0 +1,76 @@
1
+ /// <reference types="node" />
2
+ // Not copied into apps (it reads the components' source with Node), like the
3
+ // theme checks. It sits outside `components/`, whose folders are each a
4
+ // component to the coverage script.
5
+ import { readdirSync, readFileSync } from 'node:fs';
6
+ import { describe, expect, it } from 'vitest';
7
+
8
+ /**
9
+ * Relative to the package, where `ng test` runs: the test runs from a build
10
+ * of it elsewhere, so `import.meta.url` doesn't point next to the source.
11
+ */
12
+ const COMPONENTS = 'src/lib/components';
13
+
14
+ /**
15
+ * Where a bracket that opens at `start` closes: `<…>` or `(…)`. In a type
16
+ * argument, the `>` of a function type's `=>` doesn't close anything.
17
+ */
18
+ const closingBracket = (source: string, start: number): number => {
19
+ const [open, close] = source[start] === '<' ? '<>' : '()';
20
+ let depth = 0;
21
+ for (let at = start; ; at++) {
22
+ if (source[at] === open) depth++;
23
+ else if (source[at] === close && source[at - 1] !== '=') depth--;
24
+ if (depth === 0) return at;
25
+ }
26
+ };
27
+
28
+ /**
29
+ * Each `input()` or `input.required()` field in a file, with its type
30
+ * argument and its arguments, as written.
31
+ */
32
+ const inputs = (source: string) =>
33
+ [...source.matchAll(/(\w+) = input(?:\.required)?(?=[<(])/g)].map((match) => {
34
+ let at = match.index + match[0].length;
35
+ let type = '';
36
+ if (source[at] === '<') {
37
+ const end = closingBracket(source, at);
38
+ type = source.slice(at + 1, end);
39
+ at = end + 1;
40
+ }
41
+ const args = source.slice(at + 1, closingBracket(source, at));
42
+ return { name: match[1]!, type, args };
43
+ });
44
+
45
+ describe('boolean inputs', () => {
46
+ // An app shouldn't have to read a component's source to know whether it
47
+ // takes `<ui-card pressable>` or needs `[pressable]="true"`, so every one
48
+ // takes the bare attribute (#156). `model()`s can't: Angular's `model()`
49
+ // takes no transform.
50
+ it('take a bare attribute, through a transform such as booleanAttribute', () => {
51
+ const files = readdirSync(COMPONENTS, { recursive: true, encoding: 'utf8' })
52
+ .filter((file) => file.endsWith('.ts') && !file.endsWith('.test.ts'))
53
+ .sort();
54
+ const booleans = files.flatMap((file) =>
55
+ inputs(readFileSync(`${COMPONENTS}/${file}`, 'utf8'))
56
+ .filter(
57
+ ({ type, args }) =>
58
+ /^boolean\b/.test(type) || /^(true|false)\b/.test(args.trim()),
59
+ )
60
+ .map((input) => ({ ...input, field: `${file}: ${input.name}` })),
61
+ );
62
+
63
+ // The parse finds them all, rather than none (which would pass).
64
+ expect(booleans.map(({ field }) => field)).toContain(
65
+ 'card/card.ts: pressable',
66
+ );
67
+ expect(booleans.map(({ field }) => field)).toContain(
68
+ 'menu/menu.ts: checked',
69
+ );
70
+ expect(
71
+ booleans
72
+ .filter(({ args }) => !/\btransform:/.test(args))
73
+ .map(({ field }) => field),
74
+ ).toEqual([]);
75
+ });
76
+ });
@@ -1,8 +1,23 @@
1
- import { Component, signal } from '@angular/core';
2
- import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
1
+ import { Component, signal, viewChild } from '@angular/core';
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ waitForUpdate,
6
+ } from '@blotch/angular-lynx-testing-library';
3
7
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
- import { describe, expect, it } from 'vitest';
5
- import { setInputSignal } from '../../../test-utils/set-input-signal';
8
+ import {
9
+ type MockInstance,
10
+ afterEach,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ it,
15
+ vi,
16
+ } from 'vitest';
17
+ import {
18
+ type LynxMainThread,
19
+ lynxMainThread,
20
+ } from '../../../test-utils/lynx-main-thread';
6
21
  import {
7
22
  UiAccordion,
8
23
  UiAccordionContent,
@@ -11,7 +26,6 @@ import {
11
26
  } from './accordion';
12
27
 
13
28
  @Component({
14
- standalone: true,
15
29
  imports: [
16
30
  UiAccordion,
17
31
  UiAccordionItem,
@@ -20,175 +34,175 @@ import {
20
34
  LYNX_ELEMENTS,
21
35
  ],
22
36
  template: `
23
- <ui-accordion [type]="type">
24
- <ui-accordion-item value="a">
25
- <ui-accordion-trigger>A</ui-accordion-trigger>
26
- <ui-accordion-content><text>Alpha</text></ui-accordion-content>
37
+ <ui-accordion [type]="type()" class="w-80">
38
+ <ui-accordion-item value="a" class="px-1">
39
+ <ui-accordion-trigger class="gap-2">Alpha</ui-accordion-trigger>
40
+ <ui-accordion-content class="pt-1">
41
+ <text>Alpha body</text>
42
+ </ui-accordion-content>
27
43
  </ui-accordion-item>
28
44
  <ui-accordion-item value="b">
29
- <ui-accordion-trigger>B</ui-accordion-trigger>
30
- <ui-accordion-content><text>Beta</text></ui-accordion-content>
45
+ <ui-accordion-trigger>Beta</ui-accordion-trigger>
46
+ <ui-accordion-content>
47
+ <text>Beta body</text>
48
+ </ui-accordion-content>
31
49
  </ui-accordion-item>
32
50
  </ui-accordion>
33
51
  `,
34
52
  })
35
53
  class AccordionHost {
36
- type: 'single' | 'multiple' = 'single';
54
+ readonly type = signal<'single' | 'multiple'>('single');
55
+ readonly accordion = viewChild.required(UiAccordion);
37
56
  }
38
57
 
58
+ const renderAccordion = async () => {
59
+ const result = await render(AccordionHost);
60
+ /**
61
+ * An item's trigger: its own view, which holds the tap listener, around
62
+ * its label.
63
+ */
64
+ const trigger = (label: string) => result.getByText(label).parentElement!;
65
+ return {
66
+ ...result,
67
+ host: result.componentRef.instance as AccordionHost,
68
+ trigger,
69
+ tap: async (label: string) => {
70
+ fireEvent.tap(trigger(label));
71
+ await waitForUpdate();
72
+ },
73
+ /** The chevron beside an item's label. */
74
+ chevron: (label: string) => result.getByText(label).nextElementSibling!,
75
+ /** An item's content view, which is only mounted while it's expanded. */
76
+ content: (text: string) => result.queryByText(text)?.parentElement ?? null,
77
+ };
78
+ };
79
+
80
+ /** `__ElementAnimate`'s first argument for starting an animation. */
81
+ const START = 0;
82
+ /** `__ElementAnimate`'s first argument for canceling an animation. */
83
+ const CANCEL = 3;
84
+
39
85
  describe('UiAccordion', () => {
40
- it('renders each item trigger', async () => {
41
- const { getByText } = await render(AccordionHost);
42
- expect(getByText('A')).toBeTruthy();
43
- expect(getByText('B')).toBeTruthy();
86
+ it("merges each part's class with its own", async () => {
87
+ const { container, trigger, tap, content } = await renderAccordion();
88
+ // Each host element carries the given class too, so look for the view
89
+ // that has both its own and the given class.
90
+ expect(container.querySelector('.flex-col.w-80')).not.toBeNull();
91
+ expect(container.querySelector('.border-b.px-1')).not.toBeNull();
92
+ expect(trigger('Alpha').classList).toContain('justify-between');
93
+ expect(trigger('Alpha').classList).toContain('gap-2');
94
+
95
+ await tap('Alpha');
96
+ expect(content('Alpha body')!.classList).toContain('pb-4');
97
+ expect(content('Alpha body')!.classList).toContain('pt-1');
44
98
  });
45
99
 
46
- it('toggle() in single mode replaces the expanded value', async () => {
47
- const { componentRef } = await render(UiAccordion);
48
- const acc = componentRef.instance as UiAccordion;
49
- acc.toggle('a');
50
- expect(acc.isExpanded('a')).toBe(true);
51
- acc.toggle('b');
52
- expect(acc.isExpanded('a')).toBe(false);
53
- expect(acc.isExpanded('b')).toBe(true);
54
- acc.toggle('b');
55
- expect(acc.isExpanded('b')).toBe(false);
56
- });
100
+ it('expands an item when its trigger is tapped, and collapses it on a second tap', async () => {
101
+ const { tap, chevron, content } = await renderAccordion();
102
+ // Collapsed, the content isn't mounted at all.
103
+ expect(content('Alpha body')).toBeNull();
104
+ expect(chevron('Alpha').textContent).toBe('▶');
57
105
 
58
- it('renders type="multiple" host without throwing', async () => {
59
- @Component({
60
- standalone: true,
61
- imports: [UiAccordion, LYNX_ELEMENTS],
62
- template: `<ui-accordion type="multiple" />`,
63
- })
64
- class MultiHost {}
65
- const { container } = await render(MultiHost);
66
- expect(container).toBeTruthy();
106
+ await tap('Alpha');
107
+ expect(content('Alpha body')).not.toBeNull();
108
+ expect(chevron('Alpha').textContent).toBe('▼');
109
+
110
+ await tap('Alpha');
111
+ expect(content('Alpha body')).toBeNull();
112
+ expect(chevron('Alpha').textContent).toBe('▶');
67
113
  });
68
114
 
69
- it('type="multiple" retains previously expanded items', async () => {
70
- // Directly exercises the multiple-mode branch of `toggle()`. Without this,
71
- // the `.clear()`-skip branch stays uncovered.
72
- const { componentRef } = await render(UiAccordion);
73
- const acc = componentRef.instance as UiAccordion;
74
- setInputSignal(acc.type, 'multiple');
75
- await waitForUpdate();
76
- acc.toggle('a');
77
- acc.toggle('b');
78
- expect(acc.isExpanded('a')).toBe(true);
79
- expect(acc.isExpanded('b')).toBe(true);
115
+ it('keeps one item expanded at a time by default', async () => {
116
+ const { tap, content } = await renderAccordion();
117
+ await tap('Alpha');
118
+ await tap('Beta');
119
+ expect(content('Alpha body')).toBeNull();
120
+ expect(content('Beta body')).not.toBeNull();
80
121
  });
81
- });
82
122
 
83
- describe('UiAccordionItem / Trigger / Content', () => {
84
- /**
85
- * Render UiAccordionItem directly with a UiAccordion instance provided via
86
- * DI. Using `render(UiAccordionItem, { providers: [...] })` alone would fail
87
- * because the required `value` input has no template binding and the
88
- * component's initial computed reads it — so we set the input before any
89
- * effect fires. The parent instance comes from rendering UiAccordion (which
90
- * sets up the internal injection context inputs) and stashing its instance.
91
- */
92
- const renderItemWithParent = async () => {
93
- // Render the accordion first so we have an instance with initialized signals.
94
- const parentResult = await render(UiAccordion);
95
- const parent = parentResult.componentRef.instance as UiAccordion;
96
-
97
- // Now render the child with the parent as a DI value. This unmounts the
98
- // previous render but the parent instance object (a plain JS class) still
99
- // holds its signals.
100
- const childResult = await render(UiAccordionItem, {
101
- providers: [{ provide: UiAccordion, useValue: parent }],
102
- });
103
- const item = childResult.componentRef.instance as UiAccordionItem;
104
- setInputSignal(item.itemValue, 'a');
123
+ it('keeps every expanded item open when its type is multiple', async () => {
124
+ const { host, tap, content } = await renderAccordion();
125
+ host.type.set('multiple');
105
126
  await waitForUpdate();
106
- return { parent, item, result: childResult };
107
- };
108
127
 
109
- it('item.toggle() delegates to the parent accordion', async () => {
110
- const { parent, item } = await renderItemWithParent();
111
- item.toggle();
112
- expect(parent.isExpanded('a')).toBe(true);
128
+ await tap('Alpha');
129
+ await tap('Beta');
130
+ expect(content('Alpha body')).not.toBeNull();
131
+ expect(content('Beta body')).not.toBeNull();
113
132
  });
114
133
 
115
- it('trigger onTap toggles the parent item', async () => {
116
- // The trigger injects UiAccordionItem (not UiAccordion) — same pattern.
117
- const parentResult = await render(UiAccordion);
118
- const parent = parentResult.componentRef.instance as UiAccordion;
119
-
120
- const itemResult = await render(UiAccordionItem, {
121
- providers: [{ provide: UiAccordion, useValue: parent }],
122
- });
123
- const item = itemResult.componentRef.instance as UiAccordionItem;
124
- setInputSignal(item.itemValue, 'a');
134
+ it('expands an item from code, and says which items are expanded', async () => {
135
+ const { host, content } = await renderAccordion();
136
+ host.accordion().toggle('b');
125
137
  await waitForUpdate();
126
-
127
- const triggerResult = await render(UiAccordionTrigger, {
128
- providers: [{ provide: UiAccordionItem, useValue: item }],
129
- });
130
- const trigger = triggerResult.componentRef.instance as UiAccordionTrigger;
131
- (trigger as unknown as { onTap: () => void }).onTap();
132
- expect(parent.isExpanded('a')).toBe(true);
138
+ expect(content('Beta body')).not.toBeNull();
139
+ expect(host.accordion().isExpanded('b')).toBe(true);
140
+ expect(host.accordion().isExpanded('a')).toBe(false);
133
141
  });
134
142
 
135
- describe('content effect', () => {
136
- it('runs the reveal animation after expansion via setTimeout', async () => {
137
- const parentResult = await render(UiAccordion);
138
- const parent = parentResult.componentRef.instance as UiAccordion;
139
-
140
- const itemResult = await render(UiAccordionItem, {
141
- providers: [{ provide: UiAccordion, useValue: parent }],
142
- });
143
- const item = itemResult.componentRef.instance as UiAccordionItem;
144
- setInputSignal(item.itemValue, 'a');
145
- await waitForUpdate();
146
-
147
- const contentResult = await render(UiAccordionContent, {
148
- providers: [{ provide: UiAccordionItem, useValue: item }],
149
- });
150
- const content = contentResult.componentRef.instance as UiAccordionContent;
151
- // JIT + jsdom doesn't populate the `#content` viewChild — patch it with a
152
- // signal returning the outer view so the setTimeout body's `el` check
153
- // passes and revealIn/cancel actually run.
154
- const view = contentResult.container.querySelector('view') as Element;
155
- (content as unknown as { contentRef: unknown }).contentRef = signal({
156
- nativeElement: view,
157
- });
158
-
159
- parent.toggle('a');
160
- await waitForUpdate();
161
- await waitForUpdate(); // wait for the effect's setTimeout(_, 0) to fire
162
-
163
- // Cancellation path: toggle off then on to re-schedule with `#anim` set.
164
- parent.toggle('a');
165
- await waitForUpdate();
166
- parent.toggle('a');
143
+ describe('reveal animation', () => {
144
+ // Watched per test: the testing library resets the main thread after each.
145
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
146
+ beforeEach(() => {
147
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
148
+ });
149
+ afterEach(() => animate.mockRestore());
150
+
151
+ /**
152
+ * The keys of the animations started on an element.
153
+ */
154
+ const startedOn = (element: Element | null) =>
155
+ animate.mock.calls
156
+ .filter(
157
+ ([target, [operation]]) => target === element && operation === START,
158
+ )
159
+ .map(([, [, key]]) => key);
160
+
161
+ it('slides the content down into place each time it expands', async () => {
162
+ const { tap, content } = await renderAccordion();
163
+
164
+ await tap('Alpha');
165
+ // The effect waits a macrotask so the content is committed to Lynx's
166
+ // tree before animating it; this update lets that run.
167
167
  await waitForUpdate();
168
+ expect(animate).toHaveBeenCalledWith(content('Alpha body'), [
169
+ START,
170
+ expect.any(String),
171
+ [
172
+ { transform: 'translateY(-8px)', opacity: 0 },
173
+ { transform: 'translateY(0)', opacity: 1 },
174
+ ],
175
+ // No fill: once it ends, the content's inline resting style applies.
176
+ expect.objectContaining({ fill: 'none' }),
177
+ ]);
178
+ const [firstKey] = startedOn(content('Alpha body'));
179
+
180
+ await tap('Alpha');
181
+ await tap('Alpha');
168
182
  await waitForUpdate();
169
- expect(parent.isExpanded('a')).toBe(true);
183
+ // Expanding again stops the previous reveal before it plays a new one
184
+ // on the content's new view.
185
+ expect(animate).toHaveBeenCalledWith(expect.anything(), [
186
+ CANCEL,
187
+ firstKey,
188
+ ]);
189
+ expect(startedOn(content('Alpha body'))).toHaveLength(1);
170
190
  });
171
191
 
172
- it('returns early inside setTimeout when contentRef is undefined', async () => {
173
- // Covers the `if (!el) return;` true branch — no monkey-patch here, so
174
- // the viewChild is unresolved and the setTimeout body bails out.
175
- const parentResult = await render(UiAccordion);
176
- const parent = parentResult.componentRef.instance as UiAccordion;
177
-
178
- const itemResult = await render(UiAccordionItem, {
179
- providers: [{ provide: UiAccordion, useValue: parent }],
180
- });
181
- const item = itemResult.componentRef.instance as UiAccordionItem;
182
- setInputSignal(item.itemValue, 'a');
183
- await waitForUpdate();
192
+ it("doesn't animate content that collapsed before its reveal began", async () => {
193
+ const { componentRef, trigger, tap, content } = await renderAccordion();
194
+
195
+ // The effect has scheduled the reveal for the next macrotask.
196
+ await tap('Alpha');
197
+ // Collapse it before then, and render that straight away.
198
+ fireEvent.tap(trigger('Alpha'));
199
+ componentRef.changeDetectorRef.detectChanges();
200
+ expect(content('Alpha body')).toBeNull();
184
201
 
185
- await render(UiAccordionContent, {
186
- providers: [{ provide: UiAccordionItem, useValue: item }],
187
- });
188
- parent.toggle('a');
189
- await waitForUpdate();
190
202
  await waitForUpdate();
191
- expect(parent.isExpanded('a')).toBe(true);
203
+ expect(
204
+ animate.mock.calls.filter(([, [operation]]) => operation === START),
205
+ ).toEqual([]);
192
206
  });
193
207
  });
194
208
  });
@@ -152,20 +152,25 @@ export class UiAccordionContent {
152
152
  readonly contentRef = viewChild<ElementRef>('content');
153
153
  #anim?: AnimationHandle;
154
154
 
155
- constructor() {
156
- // Animate the reveal whenever the panel expands. contentRef only resolves
157
- // while the `@if` content is mounted; defer one macrotask so the freshly
158
- // (re)created element is committed to the native tree before we animate it.
159
- effect(() => {
160
- if (!this.item.isExpanded()) return;
161
- setTimeout(() => {
162
- const el = this.contentRef()?.nativeElement;
163
- if (!el) return;
164
- this.#anim?.cancel();
165
- this.#anim = revealIn(el, { fromY: -8, fill: 'none' });
166
- }, 0);
167
- });
168
- }
155
+ /**
156
+ * Animates the reveal whenever the panel expands. A field rather than a
157
+ * constructor: Angular's test build keeps a function for a constructor that
158
+ * nothing calls, which an app's coverage report would show as uncovered.
159
+ *
160
+ * contentRef only resolves while the `@if` content is mounted; defer one
161
+ * macrotask so the freshly (re)created element is committed to the native
162
+ * tree before we animate it. By then the panel may have collapsed again,
163
+ * which leaves nothing to animate.
164
+ */
165
+ protected readonly revealing = effect(() => {
166
+ if (!this.item.isExpanded()) return;
167
+ setTimeout(() => {
168
+ const el = this.contentRef()?.nativeElement;
169
+ if (!el) return;
170
+ this.#anim?.cancel();
171
+ this.#anim = revealIn(el, { fromY: -8, fill: 'none' });
172
+ }, 0);
173
+ });
169
174
 
170
175
  protected readonly contentClass = computed(() =>
171
176
  cn('flex-col pb-4 flex', this.userClass()),