@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,199 +1,533 @@
1
- import { Component, signal } from '@angular/core';
2
- import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
- import { describe, expect, it } from 'vitest';
5
- import { setInputSignal } from '../../../test-utils/set-input-signal';
6
- import { UiTabs, UiTabsContent, UiTabsList, UiTabsTrigger } from './tabs';
1
+ import { Component, signal, viewChild } from '@angular/core';
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ waitForUpdate,
6
+ } from '@blotch/angular-lynx-testing-library';
7
+ import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
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';
21
+ import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
22
+ import {
23
+ type TabsListVariant,
24
+ UiTabs,
25
+ UiTabsContent,
26
+ UiTabsList,
27
+ UiTabsTrigger,
28
+ } from './tabs';
7
29
 
8
30
  @Component({
9
- standalone: true,
10
31
  imports: [UiTabs, UiTabsList, UiTabsTrigger, UiTabsContent, LYNX_ELEMENTS],
11
32
  template: `
12
- <ui-tabs [(value)]="value">
13
- <ui-tabs-list>
14
- <ui-tabs-trigger value="a">A</ui-tabs-trigger>
15
- <ui-tabs-trigger value="b">B</ui-tabs-trigger>
33
+ <ui-tabs [(value)]="value" (changed)="changes.push($event)" class="w-80">
34
+ <ui-tabs-list class="mb-2">
35
+ <ui-tabs-trigger value="account" class="px-4">Account</ui-tabs-trigger>
36
+ <ui-tabs-trigger value="billing">Billing</ui-tabs-trigger>
37
+ <ui-tabs-trigger value="contact">Contact</ui-tabs-trigger>
16
38
  </ui-tabs-list>
17
- <ui-tabs-content value="a"><text>Panel A</text></ui-tabs-content>
18
- <ui-tabs-content value="b"><text>Panel B</text></ui-tabs-content>
39
+ <ui-tabs-content value="account" class="gap-2">
40
+ <text>Account panel</text>
41
+ </ui-tabs-content>
42
+ <ui-tabs-content value="billing">
43
+ <text>Billing panel</text>
44
+ </ui-tabs-content>
45
+ <ui-tabs-content value="contact">
46
+ <text>Contact panel</text>
47
+ </ui-tabs-content>
19
48
  </ui-tabs>
20
49
  `,
21
50
  })
22
51
  class TabsHost {
23
- value = 'a';
52
+ readonly value = signal('account');
53
+ readonly changes: string[] = [];
54
+ readonly tabs = viewChild.required(UiTabs);
24
55
  }
25
56
 
57
+ const renderTabs = async () => {
58
+ const result = await render(TabsHost);
59
+ /**
60
+ * A trigger's own view, which holds the tap listener, around its label.
61
+ */
62
+ const trigger = (label: string) => result.getByText(label).parentElement!;
63
+ /**
64
+ * A panel's own view, which wraps its projected text.
65
+ */
66
+ const panel = (text: string) => result.getByText(text).parentElement!;
67
+ return {
68
+ ...result,
69
+ host: result.componentRef.instance as TabsHost,
70
+ trigger,
71
+ panel,
72
+ /** The display of a panel's host element, which shows or hides it. */
73
+ displayOf: (text: string) =>
74
+ (panel(text).parentElement as HTMLElement).style.display,
75
+ tap: async (label: string) => {
76
+ fireEvent.tap(trigger(label));
77
+ await waitForUpdate();
78
+ },
79
+ };
80
+ };
81
+
82
+ /** `__ElementAnimate`'s first argument for starting an animation. */
83
+ const START = 0;
84
+ /** `__ElementAnimate`'s first argument for canceling an animation. */
85
+ const CANCEL = 3;
86
+
26
87
  describe('UiTabs', () => {
27
- it('renders the tab triggers and the active panel', async () => {
28
- const { getByText } = await render(TabsHost);
29
- expect(getByText('A')).toBeTruthy();
30
- expect(getByText('B')).toBeTruthy();
31
- expect(getByText('Panel A')).toBeTruthy();
32
- });
33
-
34
- it('select() sets the model value and emits `changed`', async () => {
35
- const { componentRef } = await render(UiTabs);
36
- const inst = componentRef.instance as UiTabs;
37
- const emitted: string[] = [];
38
- inst.changed.subscribe((v) => emitted.push(v));
39
- inst.select('x');
40
- expect(inst.value()).toBe('x');
41
- expect(emitted).toEqual(['x']);
42
- });
43
-
44
- it('registerTab() records tab order without duplicates', async () => {
45
- const { componentRef } = await render(UiTabs);
46
- const inst = componentRef.instance as UiTabs;
47
- inst.registerTab('a');
48
- inst.registerTab('b');
49
- inst.registerTab('a'); // duplicate, should be ignored
50
- // Seed the effect with a known previousValue so subsequent transitions
51
- // exercise the direction-set branch (not the initial-run early return).
52
- inst.value.set('a');
53
- await waitForUpdate();
54
- // a=0, b=1 → nextIndex > prevIndex → direction=1 (forward branch)
55
- inst.value.set('b');
56
- await waitForUpdate();
57
- await waitForUpdate();
58
- expect(inst.direction()).toBe(1);
59
- // Reverse → direction=-1 (backward branch)
60
- inst.value.set('a');
61
- await waitForUpdate();
62
- await waitForUpdate();
63
- expect(inst.direction()).toBe(-1);
88
+ it("merges each part's class with its own", async () => {
89
+ const { container, trigger, panel } = await renderTabs();
90
+ // Each host element carries the given class too, so look for the view
91
+ // that has both its own and the given class.
92
+ expect(container.querySelector('.flex-col.w-80')).not.toBeNull();
93
+ expect(container.querySelector('.rounded-lg.mb-2')).not.toBeNull();
94
+ expect(
95
+ container.querySelector('[accessibility-traits="tabbar"]'),
96
+ ).not.toBeNull();
97
+ expect(trigger('Account').classList).toContain('rounded-sm');
98
+ expect(trigger('Account').classList).toContain('px-4');
99
+ expect(panel('Account panel').classList).toContain('mt-2');
100
+ expect(panel('Account panel').classList).toContain('gap-2');
64
101
  });
65
102
 
66
- it('direction effect ignores unknown values', async () => {
67
- // Covers the branch where prevIndex or nextIndex is -1.
68
- const { componentRef } = await render(UiTabs);
69
- const inst = componentRef.instance as UiTabs;
70
- inst.value.set('unknown');
71
- inst.value.set('also-unknown');
72
- await waitForUpdate();
73
- await waitForUpdate();
74
- expect(inst.direction()).toBe(1); // unchanged from default
103
+ it("shows only the selected tab's panel, and highlights its trigger", async () => {
104
+ const { getByText, trigger, displayOf } = await renderTabs();
105
+ expect(displayOf('Account panel')).toBe('flex');
106
+ expect(displayOf('Billing panel')).toBe('none');
107
+ expect(displayOf('Contact panel')).toBe('none');
108
+
109
+ expect(trigger('Account').classList).toContain('bg-background');
110
+ expect(getByText('Account').classList).toContain('text-foreground');
111
+ expect(trigger('Billing').classList).toContain('bg-transparent');
112
+ expect(getByText('Billing').classList).toContain('text-muted-foreground');
75
113
  });
76
114
 
77
- it('direction effect leaves direction alone when transitioning from unknown to known', async () => {
78
- const { componentRef } = await render(UiTabs);
79
- const inst = componentRef.instance as UiTabs;
80
- inst.registerTab('a');
81
- inst.registerTab('b');
82
- inst.value.set('unknown');
83
- await waitForUpdate();
84
- inst.value.set('a');
115
+ it('selects a tab when its trigger is tapped', async () => {
116
+ const { host, tap, trigger, displayOf } = await renderTabs();
117
+ await tap('Billing');
118
+ expect(host.value()).toBe('billing');
119
+ expect(host.changes).toEqual(['billing']);
120
+ expect(displayOf('Account panel')).toBe('none');
121
+ expect(displayOf('Billing panel')).toBe('flex');
122
+ expect(trigger('Billing').classList).toContain('bg-background');
123
+ expect(trigger('Account').classList).toContain('bg-transparent');
124
+ });
125
+
126
+ it('reads to a screen reader as a tab bar, with the selected tab selected', async () => {
127
+ const { getByRole, tap } = await renderTabs();
128
+ expect(getByRole('tablist')).toBeTruthy();
129
+ expect(
130
+ getByRole('button', { name: 'Account', selected: true }),
131
+ ).toBeTruthy();
132
+ expect(
133
+ getByRole('button', { name: 'Billing', selected: false }),
134
+ ).toBeTruthy();
135
+ await tap('Billing');
136
+ expect(
137
+ getByRole('button', { name: 'Billing', selected: true }),
138
+ ).toBeTruthy();
139
+ });
140
+
141
+ it('clips a hidden panel, whose text iOS would draw from its corner', async () => {
142
+ const { panel } = await renderTabs();
143
+ const hostOf = (text: string) => panel(text).parentElement as HTMLElement;
144
+ expect(hostOf('Billing panel').style.overflow).toBe('hidden');
145
+ expect(hostOf('Account panel').style.overflow).toBe('');
146
+ });
147
+
148
+ it('shows the tab its value is set to, without reporting a change', async () => {
149
+ const { host, displayOf } = await renderTabs();
150
+ host.value.set('contact');
85
151
  await waitForUpdate();
86
- expect(inst.direction()).toBe(1);
152
+ expect(displayOf('Contact panel')).toBe('flex');
153
+ expect(displayOf('Account panel')).toBe('none');
154
+ // `changed` reports what the user selects, not what the app sets.
155
+ expect(host.changes).toEqual([]);
87
156
  });
88
157
 
89
- it('direction effect leaves direction alone when transitioning from known to unknown', async () => {
90
- const { componentRef } = await render(UiTabs);
91
- const inst = componentRef.instance as UiTabs;
92
- inst.registerTab('a');
93
- inst.registerTab('b');
94
- inst.value.set('a');
158
+ it('keeps its slide direction for a value with no trigger', async () => {
159
+ const { host, tap } = await renderTabs();
160
+ await tap('Contact');
161
+ await tap('Billing');
162
+ expect(host.tabs().direction()).toBe(-1);
163
+
164
+ // Neither moving to nor moving from a value without a trigger has a
165
+ // place in the tab order to slide from.
166
+ host.value.set('archived');
95
167
  await waitForUpdate();
96
- inst.value.set('unknown');
168
+ expect(host.tabs().direction()).toBe(-1);
169
+ host.value.set('contact');
97
170
  await waitForUpdate();
98
- expect(inst.direction()).toBe(1);
171
+ expect(host.tabs().direction()).toBe(-1);
99
172
  });
100
- });
101
173
 
102
- describe('UiTabsList', () => {
103
- it('renders projected content with the list container class', async () => {
104
- const { container } = await render(UiTabsList);
105
- const view = container.querySelector('view');
106
- expect(view?.getAttribute('class')).toContain('rounded-lg');
107
- });
108
- });
174
+ describe('animations', () => {
175
+ // Watched per test: the testing library resets the main thread after each.
176
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
177
+ beforeEach(() => {
178
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
179
+ });
180
+ afterEach(() => animate.mockRestore());
181
+
182
+ /**
183
+ * The keyframes of the animations started on an element.
184
+ */
185
+ const keyframesOn = (element: Element) =>
186
+ animate.mock.calls
187
+ .filter(
188
+ ([target, [operation]]) => target === element && operation === START,
189
+ )
190
+ .map(([, [, , keyframes]]) => keyframes);
109
191
 
110
- describe('UiTabsTrigger', () => {
111
- const renderTrigger = async (activeValue = 'a') => {
112
- const tabsResult = await render(UiTabs);
113
- const tabs = tabsResult.componentRef.instance as UiTabs;
114
- tabs.value.set(activeValue);
192
+ /**
193
+ * The keys of the animations started on an element.
194
+ */
195
+ const keysOn = (element: Element) =>
196
+ animate.mock.calls
197
+ .filter(
198
+ ([target, [operation]]) => target === element && operation === START,
199
+ )
200
+ .map(([, [, key]]) => key);
115
201
 
116
- const triggerResult = await render(UiTabsTrigger, {
117
- providers: [{ provide: UiTabs, useValue: tabs }],
202
+ const reveal = [
203
+ { transform: 'translateY(6px)', opacity: 0 },
204
+ { transform: 'translateY(0)', opacity: 1 },
205
+ ];
206
+ const slideFrom = (x: number) => [
207
+ { transform: `translateX(${x}px)`, opacity: 0 },
208
+ { transform: 'translateX(0px)', opacity: 1 },
209
+ ];
210
+ const activate = [
211
+ { transform: 'scaleX(0.95)', opacity: 0.7 },
212
+ { transform: 'scaleX(1)', opacity: 1 },
213
+ ];
214
+ const deactivate = [
215
+ { transform: 'scaleX(1)', opacity: 1 },
216
+ { transform: 'scaleX(0.97)', opacity: 0.7 },
217
+ ];
218
+
219
+ it('reveals a panel the first time it shows, then slides it in from the side of its tab', async () => {
220
+ const { panel, tap } = await renderTabs();
221
+ // The panel waits a macrotask so its display has applied before it
222
+ // animates; this update lets that run.
223
+ await waitForUpdate();
224
+ expect(keyframesOn(panel('Account panel'))).toEqual([reveal]);
225
+
226
+ await tap('Contact');
227
+ await waitForUpdate();
228
+ expect(keyframesOn(panel('Contact panel'))).toEqual([reveal]);
229
+
230
+ // Account is left of Contact, so its panel slides in from the left.
231
+ await tap('Account');
232
+ await waitForUpdate();
233
+ expect(keyframesOn(panel('Account panel'))).toEqual([
234
+ reveal,
235
+ slideFrom(-24),
236
+ ]);
237
+ // Showing it again stopped its earlier reveal first.
238
+ const [revealKey] = keysOn(panel('Account panel'));
239
+ expect(animate).toHaveBeenCalledWith(panel('Account panel'), [
240
+ CANCEL,
241
+ revealKey,
242
+ ]);
243
+
244
+ // Contact is right of Account, so its panel slides in from the right.
245
+ await tap('Contact');
246
+ await waitForUpdate();
247
+ expect(keyframesOn(panel('Contact panel'))).toEqual([
248
+ reveal,
249
+ slideFrom(24),
250
+ ]);
118
251
  });
119
- const trigger = triggerResult.componentRef.instance as UiTabsTrigger;
120
- setInputSignal(trigger.triggerValue, 'a');
121
- const view = triggerResult.container.querySelector('view') as Element;
122
- (trigger as unknown as { triggerRef: unknown }).triggerRef = signal({
123
- nativeElement: view,
252
+
253
+ it('scales a trigger up when selected and down when deselected', async () => {
254
+ const { trigger, tap } = await renderTabs();
255
+ // Nothing changed selection yet, so no trigger animates.
256
+ expect(keyframesOn(trigger('Account'))).toEqual([]);
257
+
258
+ await tap('Billing');
259
+ expect(keyframesOn(trigger('Billing'))).toEqual([activate]);
260
+ expect(keyframesOn(trigger('Account'))).toEqual([deactivate]);
261
+
262
+ await tap('Account');
263
+ expect(keyframesOn(trigger('Account'))).toEqual([deactivate, activate]);
264
+ // Each animation stops the trigger's previous one.
265
+ const [deactivateKey] = keysOn(trigger('Account'));
266
+ expect(animate).toHaveBeenCalledWith(trigger('Account'), [
267
+ CANCEL,
268
+ deactivateKey,
269
+ ]);
124
270
  });
125
- await waitForUpdate();
126
- await waitForUpdate();
127
- return { tabs, trigger, triggerResult };
128
- };
129
271
 
130
- it('select() delegates to the parent tabs', async () => {
131
- const { tabs, trigger } = await renderTrigger('a');
132
- trigger.select();
133
- expect(tabs.value()).toBe('a');
134
- });
272
+ it('changes at once under Reduce Motion', async () => {
273
+ const reduced = {
274
+ reduceMotion: signal(true),
275
+ reduceTransparency: signal(false),
276
+ increaseContrast: signal(false),
277
+ };
278
+ const result = await render(TabsHost, {
279
+ providers: [{ provide: LynxAccessibilitySettings, useValue: reduced }],
280
+ });
281
+ await waitForUpdate();
282
+ // Only this test's animations: the last test's panels may still have
283
+ // had a reveal waiting for its tick.
284
+ animate.mockClear();
285
+ fireEvent.tap(result.getByText('Billing').parentElement!);
286
+ await waitForUpdate();
287
+ await waitForUpdate();
288
+ expect(animate).not.toHaveBeenCalled();
289
+ expect(
290
+ (
291
+ result.getByText('Billing panel').parentElement!
292
+ .parentElement as HTMLElement
293
+ ).style.display,
294
+ ).toBe('flex');
295
+ });
135
296
 
136
- it('effect runs the activation animation when active flips true', async () => {
137
- // Active path — `#previousActive` starts undefined so the first flip
138
- // exercises the initialization guard, and toggling activeValue drives the
139
- // effect through both animate() branches.
140
- const { tabs } = await renderTrigger('b');
141
- // Switch tabs so trigger becomes active — activation branch.
142
- tabs.value.set('a');
143
- await waitForUpdate();
144
- await waitForUpdate();
145
- // Deactivation branch.
146
- tabs.value.set('b');
147
- await waitForUpdate();
148
- await waitForUpdate();
149
- expect(tabs.value()).toBe('b');
297
+ it("doesn't animate a selection that changed back within one frame", async () => {
298
+ const { host, trigger, tap } = await renderTabs();
299
+ await tap('Billing');
300
+ await waitForUpdate();
301
+ animate.mockClear();
302
+
303
+ // Two taps before the next update: Billing is selected again.
304
+ fireEvent.tap(trigger('Contact'));
305
+ fireEvent.tap(trigger('Billing'));
306
+ await waitForUpdate();
307
+ await waitForUpdate();
308
+ expect(host.changes).toEqual(['billing', 'contact', 'billing']);
309
+ expect(animate).not.toHaveBeenCalled();
310
+ // The direction still points the way Billing was reached, from Account.
311
+ expect(host.tabs().direction()).toBe(1);
312
+ });
150
313
  });
151
314
  });
152
315
 
153
- describe('UiTabsContent', () => {
154
- it('runs the reveal + directional slide animations when active flips', async () => {
155
- const tabsResult = await render(UiTabs);
156
- const tabs = tabsResult.componentRef.instance as UiTabs;
157
- tabs.registerTab('a');
158
- tabs.registerTab('b');
316
+ @Component({
317
+ imports: [UiTabs, UiTabsList, UiTabsTrigger, UiTabsContent, LYNX_ELEMENTS],
318
+ template: `
319
+ <ui-tabs
320
+ [value]="value()"
321
+ (valueChange)="value.set($event)"
322
+ (changed)="changes.push($event)"
323
+ >
324
+ <ui-tabs-list [variant]="variant()">
325
+ @for (tab of tabs; track tab) {
326
+ <ui-tabs-trigger
327
+ [value]="tab"
328
+ [class]="tab === 'People' ? 'flex-[2]' : ''"
329
+ >{{ tab }}</ui-tabs-trigger
330
+ >
331
+ }
332
+ </ui-tabs-list>
333
+ @for (tab of tabs; track tab) {
334
+ <ui-tabs-content [value]="tab"
335
+ ><text>{{ tab }} panel</text></ui-tabs-content
336
+ >
337
+ }
338
+ </ui-tabs>
339
+ `,
340
+ })
341
+ class SegmentedTabsHost {
342
+ readonly tabs = ['Photos', 'Albums', 'People'];
343
+ readonly value = signal('Photos');
344
+ readonly variant = signal<TabsListVariant>('segmented');
345
+ readonly changes: string[] = [];
346
+ }
159
347
 
160
- const contentResult = await render(UiTabsContent, {
161
- providers: [{ provide: UiTabs, useValue: tabs }],
162
- });
163
- const content = contentResult.componentRef.instance as UiTabsContent;
164
- setInputSignal(content.contentValue, 'a');
165
- const view = contentResult.container.querySelector('view') as Element;
166
- (content as unknown as { contentRef: unknown }).contentRef = signal({
167
- nativeElement: view,
348
+ /**
349
+ * A trigger outside a list, which has no segmented control to report to.
350
+ */
351
+ @Component({
352
+ imports: [UiTabs, UiTabsTrigger, LYNX_ELEMENTS],
353
+ template: `
354
+ <ui-tabs value="a">
355
+ <ui-tabs-trigger value="a">Alone</ui-tabs-trigger>
356
+ </ui-tabs>
357
+ `,
358
+ })
359
+ class LoneTriggerHost {}
360
+
361
+ /** Accessibility settings, as the host app would report them. */
362
+ const settings = {
363
+ reduceMotion: signal(false),
364
+ reduceTransparency: signal(false),
365
+ increaseContrast: signal(false),
366
+ };
367
+
368
+ describe('UiTabsList, segmented', () => {
369
+ // The indicator's selector isn't a Lynx element, so the renderer falls
370
+ // back to a <view> for it and warns.
371
+ beforeEach(() => {
372
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
373
+ });
374
+
375
+ afterEach(() => {
376
+ vi.restoreAllMocks();
377
+ vi.unstubAllGlobals();
378
+ });
379
+
380
+ const renderSegmented = async (
381
+ platform = { platform: 'iOS', osVersion: '26.0' },
382
+ ) => {
383
+ vi.stubGlobal('SystemInfo', platform);
384
+ const result = await render(SegmentedTabsHost, {
385
+ providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
168
386
  });
387
+ /**
388
+ * A trigger's own view, around its label.
389
+ */
390
+ const trigger = (label: string) => result.getByText(label).parentElement!;
391
+ /**
392
+ * A trigger's host, the track's flex item.
393
+ */
394
+ const slot = (label: string) => trigger(label).parentElement!;
395
+ const track = () => slot('Photos').parentElement!;
396
+ /**
397
+ * The indicator's frame, which moves between the tabs.
398
+ */
399
+ const frame = () =>
400
+ track().querySelector<HTMLElement>('.h-full .bottom-0.top-0');
401
+ return {
402
+ ...result,
403
+ host: result.componentRef.instance as SegmentedTabsHost,
404
+ trigger,
405
+ slot,
406
+ track,
407
+ frame,
408
+ measure: async () => {
409
+ for (const [label, width] of [
410
+ ['Photos', 90],
411
+ ['Albums', 90],
412
+ ['People', 180],
413
+ ] as const) {
414
+ fireEvent.layoutchange(slot(label), { detail: { width } });
415
+ }
416
+ await waitForUpdate();
417
+ },
418
+ /** The display of a panel's host element, which shows or hides it. */
419
+ displayOf: (text: string) =>
420
+ (result.getByText(text).parentElement!.parentElement as HTMLElement)
421
+ .style.display,
422
+ };
423
+ };
424
+
425
+ it("draws the platform's segmented control, filling its width, its tabs sharing it", async () => {
426
+ const { track, slot, trigger, frame, getByText } = await renderSegmented();
427
+ expect(track().classList).toContain('rounded-full');
428
+ expect(track().classList).not.toContain('self-start');
429
+ expect(slot('Albums').classList).toContain('flex-1');
430
+ expect(slot('People').classList).toContain('flex-[2]');
431
+ expect(trigger('Photos').classList).toContain('justify-center');
432
+ expect(getByText('Photos').classList).toContain('font-medium');
433
+ expect(getByText('Albums').classList).not.toContain('font-medium');
434
+ expect(frame()).not.toBeNull();
435
+ });
169
436
 
170
- // Activate — first render path (revealIn).
171
- tabs.value.set('a');
437
+ it("switches panels on a tap, without the default tabs' scale animation", async () => {
438
+ const animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
439
+ const { host, trigger, displayOf, getByRole } = await renderSegmented();
440
+ fireEvent.tap(trigger('Albums'));
172
441
  await waitForUpdate();
173
- // The effect schedules setTimeout(_, 0); wait one more tick for it.
442
+ expect(host.value()).toBe('Albums');
443
+ expect(host.changes).toEqual(['Albums']);
444
+ expect(displayOf('Albums panel')).toBe('flex');
445
+ expect(
446
+ getByRole('button', { name: 'Albums', selected: true }),
447
+ ).toBeTruthy();
448
+ expect(
449
+ animate.mock.calls.filter(([target]) => target === trigger('Albums')),
450
+ ).toEqual([]);
451
+ });
452
+
453
+ it('switches to the tab the indicator is dragged to', async () => {
454
+ const { host, trigger, track, frame, measure } = await renderSegmented();
455
+ await measure();
456
+ expect(frame()!.style.left).toBe('0px');
457
+
458
+ fireEvent.touchstart(trigger('Photos'), { touches: [{ pageX: 45 }] });
459
+ fireEvent.touchmove(trigger('Photos'), { touches: [{ pageX: 300 }] });
460
+ fireEvent.touchend(trigger('Photos'));
461
+ // The tap Lynx may send as the finger lifts doesn't pick Photos again.
462
+ fireEvent.tap(trigger('Photos'));
174
463
  await waitForUpdate();
464
+ expect(host.value()).toBe('People');
465
+ expect(host.changes).toEqual(['People']);
466
+ expect(frame()!.style.left).toBe('180px');
175
467
 
176
- // Switch off and back on — subsequent path (directionalSlideIn) with cancel.
177
- tabs.value.set('b');
468
+ // Taken away, a drag goes back.
469
+ fireEvent.touchstart(trigger('People'), { touches: [{ pageX: 270 }] });
470
+ fireEvent.touchmove(trigger('People'), { touches: [{ pageX: 20 }] });
471
+ fireEvent.touchcancel(trigger('People'));
178
472
  await waitForUpdate();
179
- tabs.value.set('a');
473
+ expect(frame()!.style.left).toBe('180px');
474
+
475
+ // In the web preview, with the mouse, until it leaves the row.
476
+ fireEvent.mousedown(trigger('People'), { pageX: 270 });
477
+ fireEvent.mousemove(trigger('Albums'), { pageX: 150 });
478
+ fireLynxEvent(trigger('People'), 'mouseleave');
180
479
  await waitForUpdate();
480
+ expect(frame()!.style.left).toBe('60px');
481
+ fireLynxEvent(track(), 'mouseleave');
181
482
  await waitForUpdate();
483
+ expect(frame()!.style.left).toBe('180px');
182
484
 
183
- expect(tabs.value()).toBe('a');
485
+ fireEvent.mousedown(trigger('People'), { pageX: 270 });
486
+ fireEvent.mousemove(trigger('Albums'), { pageX: 150 });
487
+ fireEvent.mouseup(trigger('Albums'));
488
+ await waitForUpdate();
489
+ expect(host.value()).toBe('Albums');
184
490
  });
185
491
 
186
- it('setTimeout body bails out when contentRef is undefined', async () => {
187
- const tabsResult = await render(UiTabs);
188
- const tabs = tabsResult.componentRef.instance as UiTabs;
189
- const contentResult = await render(UiTabsContent, {
190
- providers: [{ provide: UiTabs, useValue: tabs }],
492
+ it("is Material's connected button group on Android", async () => {
493
+ const { trigger, getByText } = await renderSegmented({
494
+ platform: 'Android',
495
+ osVersion: '15',
191
496
  });
192
- const content = contentResult.componentRef.instance as UiTabsContent;
193
- setInputSignal(content.contentValue, 'a');
194
- tabs.value.set('a');
497
+ expect(trigger('Photos').classList).toContain('bg-primary');
498
+ expect(trigger('Photos').style.transition).toContain('background-color');
499
+ expect(getByText('Photos').classList).toContain('text-primary-foreground');
500
+ // The last tab rounds the group's end, and the one between has small
501
+ // corners.
502
+ expect(trigger('Albums').classList).toContain('rounded-[8px]');
503
+ expect(trigger('People').classList).toContain('rounded-r-[20px]');
504
+ // A press shows Material's state layer.
505
+ fireEvent.touchstart(trigger('Albums'), { touches: [{ pageX: 150 }] });
195
506
  await waitForUpdate();
507
+ expect(trigger('Albums').querySelector('.opacity-10')).not.toBeNull();
508
+ });
509
+
510
+ it('goes back to the default tabs when the variant changes', async () => {
511
+ const { host, track, slot, frame } = await renderSegmented();
512
+ host.variant.set('default');
513
+ await waitForUpdate();
514
+ expect(track().classList).toContain('self-start');
515
+ expect(slot('Photos').classList).not.toContain('flex-1');
516
+ expect(frame()).toBeNull();
517
+ });
518
+
519
+ it('lets a trigger outside a list take touches as the default tabs do', async () => {
520
+ const result = await render(LoneTriggerHost);
521
+ const trigger = result.getByText('Alone').parentElement!;
522
+ fireEvent.layoutchange(trigger.parentElement!, { detail: { width: 80 } });
523
+ fireEvent.touchstart(trigger, { touches: [{ pageX: 10 }] });
524
+ fireEvent.touchmove(trigger, { touches: [{ pageX: 60 }] });
525
+ fireEvent.touchend(trigger);
526
+ fireEvent.touchcancel(trigger);
527
+ fireLynxEvent(trigger, 'mouseleave');
528
+ fireEvent.tap(trigger);
196
529
  await waitForUpdate();
197
- expect(tabs.value()).toBe('a');
530
+ expect(trigger.classList).toContain('bg-background');
531
+ expect(trigger.parentElement!.classList).not.toContain('flex-1');
198
532
  });
199
533
  });