@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,186 +1,599 @@
1
- import { Component } 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 { UiToggle, UiToggleGroup, UiToggleGroupItem } from './toggle';
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
10
+ import { UiIcon } from '../icon/icon';
11
+ import { ICON_LIGHT_COLORS, UI_ICON_COLOR } from '../icon/icon-theme';
12
+ import {
13
+ type ToggleGroupVariant,
14
+ type ToggleSize,
15
+ type ToggleVariant,
16
+ UiToggle,
17
+ UiToggleGroup,
18
+ UiToggleGroupItem,
19
+ } from './toggle';
7
20
 
8
21
  @Component({
9
- standalone: true,
10
22
  imports: [UiToggle, LYNX_ELEMENTS],
11
- template: `<ui-toggle><text>Bold</text></ui-toggle>`,
23
+ template: `
24
+ <ui-toggle
25
+ [(pressed)]="bold"
26
+ [variant]="variant()"
27
+ [size]="size()"
28
+ [disabled]="disabled()"
29
+ class="w-20"
30
+ ><text>Bold</text></ui-toggle
31
+ >
32
+ `,
12
33
  })
13
- class ToggleHost {}
34
+ class ToggleHost {
35
+ readonly bold = signal(false);
36
+ readonly variant = signal<ToggleVariant>('default');
37
+ readonly size = signal<ToggleSize>('default');
38
+ readonly disabled = signal(false);
39
+ }
14
40
 
15
- describe('UiToggle', () => {
16
- it('renders projected content', async () => {
17
- const { getByText } = await render(ToggleHost);
18
- expect(getByText('Bold')).toBeTruthy();
19
- });
41
+ @Component({
42
+ imports: [UiToggleGroup, UiToggleGroupItem, LYNX_ELEMENTS],
43
+ template: `
44
+ <ui-toggle-group
45
+ [type]="type()"
46
+ [(value)]="align"
47
+ [variant]="variant()"
48
+ [size]="size()"
49
+ [disabled]="disabled()"
50
+ class="w-full"
51
+ >
52
+ <ui-toggle-group-item value="left" class="flex-1"
53
+ ><text>Left</text></ui-toggle-group-item
54
+ >
55
+ <ui-toggle-group-item value="center"
56
+ ><text>Center</text></ui-toggle-group-item
57
+ >
58
+ <ui-toggle-group-item value="right"
59
+ ><text>Right</text></ui-toggle-group-item
60
+ >
61
+ </ui-toggle-group>
62
+ `,
63
+ })
64
+ class ToggleGroupHost {
65
+ readonly type = signal<'single' | 'multiple'>('single');
66
+ readonly align = signal<string[]>([]);
67
+ readonly variant = signal<ToggleGroupVariant>('default');
68
+ readonly size = signal<ToggleSize>('default');
69
+ readonly disabled = signal(false);
70
+ readonly group = viewChild.required(UiToggleGroup);
71
+ }
72
+
73
+ /**
74
+ * The view (the one that takes presses) a toggle or toggle group item
75
+ * projects its label into.
76
+ */
77
+ const viewOf = (label: Element) => label.parentElement!;
78
+
79
+ /**
80
+ * The scale a view's inline style holds, if any.
81
+ */
82
+ const scaleOf = (view: Element) =>
83
+ (view as HTMLElement).style.getPropertyValue('transform');
84
+
85
+ /**
86
+ * Presses and releases a view by touch, by mouse, and by a touch a scroll
87
+ * takes over, checking it scales down while pressed and back after each.
88
+ */
89
+ const expectPressFeedback = (view: Element) => {
90
+ fireEvent.touchstart(view);
91
+ expect(scaleOf(view)).toBe('scale(0.97)');
92
+ fireEvent.touchend(view);
93
+ expect(scaleOf(view)).toBe('scale(1)');
94
+
95
+ fireEvent.touchstart(view);
96
+ fireEvent.touchcancel(view);
97
+ expect(scaleOf(view)).toBe('scale(1)');
20
98
 
21
- it('onTap flips the pressed model', async () => {
22
- const { componentRef } = await render(UiToggle);
23
- const inst = componentRef.instance as unknown as UiToggle & {
24
- onTap: () => void;
99
+ // Lynx on the web fires mouse events for a cursor, never touch ones.
100
+ fireEvent.mousedown(view);
101
+ expect(scaleOf(view)).toBe('scale(0.97)');
102
+ fireEvent.mouseup(view);
103
+ expect(scaleOf(view)).toBe('scale(1)');
104
+
105
+ fireEvent.mousedown(view);
106
+ fireLynxEvent(view, 'mouseleave');
107
+ expect(scaleOf(view)).toBe('scale(1)');
108
+ };
109
+
110
+ /**
111
+ * Presses a disabled view by touch and by mouse: it shouldn't scale.
112
+ */
113
+ const expectNoPressFeedback = (view: Element) => {
114
+ fireEvent.touchstart(view);
115
+ fireEvent.mousedown(view);
116
+ expect(scaleOf(view)).toBe('');
117
+ fireEvent.touchend(view);
118
+ fireEvent.mouseup(view);
119
+ expect(scaleOf(view)).toBe('');
120
+ };
121
+
122
+ describe('UiToggle', () => {
123
+ const renderToggle = async () => {
124
+ const result = await render(ToggleHost);
125
+ return {
126
+ ...result,
127
+ host: result.componentRef.instance as ToggleHost,
128
+ toggle: () => viewOf(result.getByText('Bold')),
25
129
  };
26
- inst.onTap();
27
- expect(inst.pressed()).toBe(true);
28
- inst.onTap();
29
- expect(inst.pressed()).toBe(false);
130
+ };
131
+
132
+ it('renders its content unpressed, with the given class', async () => {
133
+ const { toggle } = await renderToggle();
134
+ expect(toggle().classList).toContain('bg-transparent');
135
+ expect(toggle().classList).toContain('h-10');
136
+ expect(toggle().classList).toContain('w-20');
137
+ expect(toggle().classList).not.toContain('opacity-50');
30
138
  });
31
139
 
32
- it('onTap is a no-op when disabled', async () => {
33
- const { componentRef } = await render(UiToggle);
34
- const inst = componentRef.instance as unknown as UiToggle & {
35
- onTap: () => void;
36
- };
37
- setInputSignal(inst.disabled, true);
140
+ it('flips `pressed` and its highlight on each tap', async () => {
141
+ const { host, toggle } = await renderToggle();
142
+ fireEvent.tap(toggle());
38
143
  await waitForUpdate();
39
- inst.onTap();
40
- expect(inst.pressed()).toBe(false);
144
+ expect(host.bold()).toBe(true);
145
+ expect(toggle().classList).toContain('bg-accent');
146
+
147
+ fireEvent.tap(toggle());
148
+ await waitForUpdate();
149
+ expect(host.bold()).toBe(false);
150
+ expect(toggle().classList).toContain('bg-transparent');
41
151
  });
42
152
 
43
- it('press handlers run cleanly (enabled + disabled)', async () => {
44
- const { componentRef } = await render(UiToggle);
45
- const inst = componentRef.instance as unknown as UiToggle & {
46
- onPressStart: () => void;
47
- onPressEnd: () => void;
48
- onPressCancel: () => void;
49
- };
50
- expect(() => inst.onPressStart()).not.toThrow();
51
- expect(() => inst.onPressEnd()).not.toThrow();
52
- expect(() => inst.onPressCancel()).not.toThrow();
53
- setInputSignal((inst as unknown as UiToggle).disabled, true);
153
+ it('highlights when the app presses it', async () => {
154
+ const { host, toggle } = await renderToggle();
155
+ host.bold.set(true);
54
156
  await waitForUpdate();
55
- expect(() => inst.onPressStart()).not.toThrow();
56
- expect(() => inst.onPressEnd()).not.toThrow();
57
- expect(() => inst.onPressCancel()).not.toThrow();
157
+ expect(toggle().classList).toContain('bg-accent');
58
158
  });
59
159
 
60
- it('applies the accent background when pressed', async () => {
61
- const { componentRef, container } = await render(UiToggle);
62
- const inst = componentRef.instance as UiToggle;
63
- inst.pressed.set(true);
160
+ it("dims and ignores taps while it's disabled", async () => {
161
+ const { host, toggle } = await renderToggle();
162
+ host.disabled.set(true);
64
163
  await waitForUpdate();
65
- const view = container.querySelector('view');
66
- expect(view?.getAttribute('class')).toContain('bg-accent');
164
+ expect(toggle().classList).toContain('opacity-50');
165
+ fireEvent.tap(toggle());
166
+ expect(host.bold()).toBe(false);
67
167
  });
68
168
 
69
- it('applies outline variant and small size classes', async () => {
70
- const { componentRef, container } = await render(UiToggle);
71
- const inst = componentRef.instance as UiToggle;
72
- setInputSignal(inst.variant, 'outline');
73
- setInputSignal(inst.size, 'sm');
169
+ it('applies the classes for its variant and size', async () => {
170
+ const { host, toggle } = await renderToggle();
171
+ host.variant.set('outline');
172
+ host.size.set('sm');
74
173
  await waitForUpdate();
75
- const view = container.querySelector('view');
76
- expect(view?.getAttribute('class')).toContain('border-border');
77
- expect(view?.getAttribute('class')).toContain('h-9');
174
+ expect(toggle().classList).toContain('border-border');
175
+ expect(toggle().classList).toContain('h-9');
176
+
177
+ host.size.set('lg');
178
+ await waitForUpdate();
179
+ expect(toggle().classList).toContain('h-11');
180
+ });
181
+
182
+ it('scales down while pressed, and back on release or cancel', async () => {
183
+ const { toggle } = await renderToggle();
184
+ expectPressFeedback(toggle());
78
185
  });
79
186
 
80
- it('applies large size classes', async () => {
81
- const { componentRef, container } = await render(UiToggle);
82
- const inst = componentRef.instance as UiToggle;
83
- setInputSignal(inst.size, 'lg');
187
+ it("doesn't react to presses while it's disabled", async () => {
188
+ const { host, toggle } = await renderToggle();
189
+ host.disabled.set(true);
84
190
  await waitForUpdate();
85
- expect(container.querySelector('view')?.getAttribute('class')).toContain(
86
- 'h-11',
87
- );
191
+ expectNoPressFeedback(toggle());
88
192
  });
89
193
  });
90
194
 
91
195
  describe('UiToggleGroup', () => {
92
- it('single mode replaces selection and allows deselection', async () => {
93
- const { componentRef } = await render(UiToggleGroup);
94
- const inst = componentRef.instance as UiToggleGroup;
95
- inst.toggle('a');
96
- expect(inst.value()).toEqual(['a']);
97
- inst.toggle('b');
98
- expect(inst.value()).toEqual(['b']);
99
- // Tap active item again to deselect (unique to single-mode).
100
- inst.toggle('b');
101
- expect(inst.value()).toEqual([]);
102
- });
103
-
104
- it('multiple mode toggles individual items', async () => {
105
- const { componentRef } = await render(UiToggleGroup);
106
- const inst = componentRef.instance as UiToggleGroup;
107
- setInputSignal(inst.type, 'multiple');
108
- await waitForUpdate();
109
- inst.toggle('a');
110
- inst.toggle('b');
111
- expect(inst.value()).toEqual(['a', 'b']);
112
- inst.toggle('a');
113
- expect(inst.value()).toEqual(['b']);
114
- });
115
-
116
- it('toggle() is a no-op when the group is disabled', async () => {
117
- const { componentRef } = await render(UiToggleGroup);
118
- const inst = componentRef.instance as UiToggleGroup;
119
- setInputSignal(inst.disabled, true);
120
- await waitForUpdate();
121
- inst.toggle('a');
122
- expect(inst.value()).toEqual([]);
123
- });
124
-
125
- it('isSelected reflects the current value list', async () => {
126
- const { componentRef } = await render(UiToggleGroup);
127
- const inst = componentRef.instance as UiToggleGroup;
128
- inst.toggle('a');
129
- expect(inst.isSelected('a')).toBe(true);
130
- expect(inst.isSelected('b')).toBe(false);
196
+ const renderGroup = async () => {
197
+ const result = await render(ToggleGroupHost);
198
+ return {
199
+ ...result,
200
+ host: result.componentRef.instance as ToggleGroupHost,
201
+ group: () => result.container.querySelector('.gap-1')!,
202
+ item: (label: string) => viewOf(result.getByText(label)),
203
+ };
204
+ };
205
+
206
+ /**
207
+ * Taps an item, then lets the selection reach its highlight.
208
+ */
209
+ const tap = async (item: Element) => {
210
+ fireEvent.tap(item);
211
+ await waitForUpdate();
212
+ };
213
+
214
+ it('lays its items out in a row, with the given class', async () => {
215
+ const { group } = await renderGroup();
216
+ expect(group().classList).toContain('flex-row');
217
+ expect(group().classList).toContain('w-full');
131
218
  });
132
- });
133
219
 
134
- describe('UiToggleGroupItem', () => {
135
- const renderItem = async (
136
- parentValue: string[] = [],
137
- parentDisabled = false,
138
- ) => {
139
- const parentResult = await render(UiToggleGroup);
140
- const parent = parentResult.componentRef.instance as UiToggleGroup;
141
- if (parentValue.length) parent.value.set(parentValue);
142
- if (parentDisabled) setInputSignal(parent.disabled, true);
220
+ it('selects one item at a time, and deselects it on a second tap', async () => {
221
+ const { host, item } = await renderGroup();
222
+ await tap(item('Left'));
223
+ expect(host.align()).toEqual(['left']);
224
+ expect(item('Left').classList).toContain('bg-accent');
225
+
226
+ await tap(item('Center'));
227
+ expect(host.align()).toEqual(['center']);
228
+ expect(item('Left').classList).toContain('bg-transparent');
229
+ expect(item('Center').classList).toContain('bg-accent');
230
+
231
+ // Unlike a radio group, a single toggle group can have nothing selected.
232
+ await tap(item('Center'));
233
+ expect(host.align()).toEqual([]);
234
+ expect(item('Center').classList).toContain('bg-transparent');
235
+ });
236
+
237
+ it('toggles each item on its own when it allows multiple', async () => {
238
+ const { host, item } = await renderGroup();
239
+ host.type.set('multiple');
143
240
  await waitForUpdate();
144
241
 
145
- const itemResult = await render(UiToggleGroupItem, {
146
- providers: [{ provide: UiToggleGroup, useValue: parent }],
147
- });
148
- const item = itemResult.componentRef.instance as UiToggleGroupItem;
149
- setInputSignal(item.itemValue, 'a');
242
+ await tap(item('Left'));
243
+ await tap(item('Right'));
244
+ expect(host.align()).toEqual(['left', 'right']);
245
+
246
+ await tap(item('Left'));
247
+ expect(host.align()).toEqual(['right']);
248
+ expect(item('Left').classList).toContain('bg-transparent');
249
+ expect(item('Right').classList).toContain('bg-accent');
250
+ });
251
+
252
+ it('highlights the items the app selects', async () => {
253
+ const { host, item } = await renderGroup();
254
+ host.align.set(['right']);
150
255
  await waitForUpdate();
151
- return { parent, item, itemResult };
152
- };
256
+ expect(item('Right').classList).toContain('bg-accent');
257
+ expect(item('Left').classList).toContain('bg-transparent');
258
+ });
259
+
260
+ it('dims every item and ignores taps while disabled', async () => {
261
+ const { host, item } = await renderGroup();
262
+ host.disabled.set(true);
263
+ await waitForUpdate();
264
+ for (const label of ['Left', 'Center', 'Right']) {
265
+ expect(item(label).classList).toContain('opacity-50');
266
+ await tap(item(label));
267
+ }
268
+ expect(host.align()).toEqual([]);
269
+ });
153
270
 
154
- it('onTap delegates to the parent group', async () => {
155
- const { parent, item } = await renderItem();
156
- (item as unknown as { onTap: () => void }).onTap();
157
- expect(parent.value()).toEqual(['a']);
271
+ it("ignores toggle() from code while it's disabled", async () => {
272
+ const { host } = await renderGroup();
273
+ host.disabled.set(true);
274
+ await waitForUpdate();
275
+ host.group().toggle('left');
276
+ expect(host.align()).toEqual([]);
277
+ });
278
+
279
+ it("styles every item with the group's variant and size, and its own class", async () => {
280
+ const { host, item } = await renderGroup();
281
+ expect(item('Left').classList).toContain('h-10');
282
+ expect(item('Left').classList).toContain('flex-1');
283
+ expect(item('Left').classList).not.toContain('opacity-50');
284
+
285
+ host.variant.set('outline');
286
+ host.size.set('sm');
287
+ await waitForUpdate();
288
+ for (const label of ['Left', 'Center', 'Right']) {
289
+ expect(item(label).classList).toContain('border-border');
290
+ expect(item(label).classList).toContain('h-9');
291
+ }
292
+ });
293
+
294
+ it('scales an item down while pressed, and back on release or cancel', async () => {
295
+ const { item } = await renderGroup();
296
+ expectPressFeedback(item('Center'));
158
297
  });
159
298
 
160
- it('highlights when selected', async () => {
161
- const { itemResult } = await renderItem(['a']);
162
- const view = itemResult.container.querySelector('view');
163
- expect(view?.getAttribute('class')).toContain('bg-accent');
299
+ it("doesn't react to presses while the group is disabled", async () => {
300
+ const { host, item } = await renderGroup();
301
+ host.disabled.set(true);
302
+ await waitForUpdate();
303
+ expectNoPressFeedback(item('Center'));
164
304
  });
305
+ });
165
306
 
166
- it('press handlers run cleanly (enabled + disabled)', async () => {
167
- const { item } = await renderItem();
168
- const cast = item as unknown as {
169
- onPressStart: () => void;
170
- onPressEnd: () => void;
171
- onPressCancel: () => void;
307
+ @Component({
308
+ imports: [UiToggleGroup, UiToggleGroupItem, UiIcon, LYNX_ELEMENTS],
309
+ template: `
310
+ <ui-toggle-group
311
+ [variant]="variant()"
312
+ [type]="type()"
313
+ [value]="range()"
314
+ (valueChange)="range.set($event)"
315
+ [disabled]="disabled()"
316
+ >
317
+ @for (option of options(); track option) {
318
+ <ui-toggle-group-item
319
+ [value]="option"
320
+ [accessibility-label]="option"
321
+ [class]="option === 'Month' ? 'flex-[2]' : ''"
322
+ ><text class="text-foreground">{{
323
+ option
324
+ }}</text></ui-toggle-group-item
325
+ >
326
+ }
327
+ <ui-toggle-group-item value="Star"
328
+ ><ui-icon name="star"
329
+ /></ui-toggle-group-item>
330
+ </ui-toggle-group>
331
+ `,
332
+ })
333
+ class SegmentedGroupHost {
334
+ readonly variant = signal<ToggleGroupVariant>('segmented');
335
+ readonly type = signal<'single' | 'multiple'>('single');
336
+ readonly range = signal<string[]>(['Week']);
337
+ readonly options = signal(['Day', 'Week', 'Month']);
338
+ readonly disabled = signal(false);
339
+ }
340
+
341
+ /**
342
+ * A group inside something that colors its icons, such as glass.
343
+ */
344
+ @Component({
345
+ imports: [UiToggleGroup, UiToggleGroupItem, UiIcon, LYNX_ELEMENTS],
346
+ providers: [
347
+ { provide: UI_ICON_COLOR, useValue: signal('var(--primary)').asReadonly() },
348
+ ],
349
+ template: `
350
+ <ui-toggle-group [variant]="variant()">
351
+ <ui-toggle-group-item value="star"
352
+ ><ui-icon name="star"
353
+ /></ui-toggle-group-item>
354
+ </ui-toggle-group>
355
+ `,
356
+ })
357
+ class TintedGroupHost {
358
+ readonly variant = signal<ToggleGroupVariant>('default');
359
+ }
360
+
361
+ /** Accessibility settings a test can change, as the host app would. */
362
+ const settings = {
363
+ reduceMotion: signal(false),
364
+ reduceTransparency: signal(false),
365
+ increaseContrast: signal(false),
366
+ };
367
+
368
+ const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
369
+ const ANDROID = { platform: 'Android', osVersion: '15' };
370
+
371
+ describe('UiToggleGroup, segmented', () => {
372
+ // The indicator's and the icon's selectors aren't Lynx elements, so the
373
+ // renderer falls back to a <view> for them and warns.
374
+ beforeEach(() => {
375
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
376
+ });
377
+
378
+ afterEach(() => {
379
+ vi.restoreAllMocks();
380
+ vi.unstubAllGlobals();
381
+ settings.reduceMotion.set(false);
382
+ });
383
+
384
+ const renderSegmented = async (
385
+ platform: { platform: string; osVersion: string } = IOS_26,
386
+ ) => {
387
+ vi.stubGlobal('SystemInfo', platform);
388
+ const result = await render(SegmentedGroupHost, {
389
+ providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
390
+ });
391
+ /**
392
+ * An item's own view: around its label, or around the star icon.
393
+ */
394
+ const item = (label: string) =>
395
+ label === 'Star'
396
+ ? result.container.querySelector('svg')!.parentElement!.parentElement!
397
+ : viewOf(result.getByText(label));
398
+ /**
399
+ * An item's host, the track's flex item.
400
+ */
401
+ const slot = (label: string) => item(label).parentElement!;
402
+ const track = () => slot('Day').parentElement!;
403
+ /**
404
+ * The indicator's frame, which moves between the items.
405
+ */
406
+ const frame = () =>
407
+ track().querySelector<HTMLElement>('.h-full .bottom-0.top-0');
408
+ const measure = async () => {
409
+ for (const label of ['Day', 'Week', 'Month', 'Star']) {
410
+ fireEvent.layoutchange(slot(label), {
411
+ detail: { width: label === 'Month' ? 160 : 80 },
412
+ });
413
+ }
414
+ await waitForUpdate();
172
415
  };
173
- expect(() => cast.onPressStart()).not.toThrow();
174
- expect(() => cast.onPressEnd()).not.toThrow();
175
-
176
- const disabled = await renderItem([], true);
177
- const cast2 = disabled.item as unknown as {
178
- onPressStart: () => void;
179
- onPressEnd: () => void;
180
- onPressCancel: () => void;
416
+ return {
417
+ ...result,
418
+ host: result.componentRef.instance as SegmentedGroupHost,
419
+ item,
420
+ slot,
421
+ track,
422
+ frame,
423
+ measure,
424
+ tap: async (label: string) => {
425
+ fireEvent.tap(item(label));
426
+ await waitForUpdate();
427
+ },
181
428
  };
182
- expect(() => cast2.onPressStart()).not.toThrow();
183
- expect(() => cast2.onPressEnd()).not.toThrow();
184
- expect(() => cast2.onPressCancel()).not.toThrow();
429
+ };
430
+
431
+ it("draws the platform's segmented control, its items sharing the track", async () => {
432
+ const { track, slot, item, frame } = await renderSegmented();
433
+ expect(track().classList).toContain('rounded-full');
434
+ expect(track().classList).toContain('bg-muted');
435
+ expect(slot('Day').classList).toContain('flex-1');
436
+ // An item's own class sizes its slot too.
437
+ expect(slot('Month').classList).toContain('flex-[2]');
438
+ expect(slot('Month').classList).not.toContain('flex-1');
439
+ expect(item('Day').classList).toContain('justify-center');
440
+ expect(frame()).not.toBeNull();
441
+ });
442
+
443
+ it('keeps one item selected: tapping it again leaves it selected', async () => {
444
+ const { host, tap } = await renderSegmented();
445
+ await tap('Day');
446
+ expect(host.range()).toEqual(['Day']);
447
+ await tap('Day');
448
+ expect(host.range()).toEqual(['Day']);
449
+ });
450
+
451
+ it('marks the selected item selected for screen readers', async () => {
452
+ const { getByRole, tap } = await renderSegmented();
453
+ expect(getByRole('button', { name: 'Week', selected: true })).toBeTruthy();
454
+ expect(getByRole('button', { name: 'Day', selected: false })).toBeTruthy();
455
+ await tap('Day');
456
+ expect(getByRole('button', { name: 'Day', selected: true })).toBeTruthy();
457
+ });
458
+
459
+ it('moves the indicator to the item it was dragged to', async () => {
460
+ const { host, item, frame, measure } = await renderSegmented();
461
+ await measure();
462
+ expect(frame()!.style.left).toBe('80px');
463
+
464
+ fireEvent.touchstart(item('Week'), { touches: [{ pageX: 120 }] });
465
+ fireEvent.touchmove(item('Week'), { touches: [{ pageX: 220 }] });
466
+ fireEvent.touchend(item('Week'));
467
+ // Lynx may send a tap as the finger lifts: the drag chose already.
468
+ fireEvent.tap(item('Week'));
469
+ await waitForUpdate();
470
+ expect(host.range()).toEqual(['Month']);
471
+ expect(frame()!.style.left).toBe('160px');
472
+ expect(frame()!.style.width).toBe('160px');
473
+ });
474
+
475
+ it('lets go of a drag the touch is taken from, or the mouse leaves', async () => {
476
+ const { host, item, track, frame, measure } = await renderSegmented();
477
+ await measure();
478
+
479
+ fireEvent.touchstart(item('Week'), { touches: [{ pageX: 120 }] });
480
+ fireEvent.touchmove(item('Week'), { touches: [{ pageX: 220 }] });
481
+ fireEvent.touchcancel(item('Week'));
482
+ await waitForUpdate();
483
+ expect(frame()!.style.left).toBe('80px');
484
+
485
+ fireEvent.mousedown(item('Week'), { pageX: 120 });
486
+ fireEvent.mousemove(item('Week'), { pageX: 220 });
487
+ fireLynxEvent(item('Week'), 'mouseleave');
488
+ await waitForUpdate();
489
+ expect(frame()!.style.left).toBe('180px');
490
+ fireLynxEvent(track(), 'mouseleave');
491
+ await waitForUpdate();
492
+ expect(frame()!.style.left).toBe('80px');
493
+
494
+ fireEvent.mousedown(item('Week'), { pageX: 120 });
495
+ fireEvent.mousemove(item('Day'), { pageX: 20 });
496
+ fireEvent.mouseup(item('Day'));
497
+ await waitForUpdate();
498
+ expect(host.range()).toEqual(['Day']);
499
+ });
500
+
501
+ it("ignores touches while it's disabled", async () => {
502
+ const { host, item, frame, measure } = await renderSegmented();
503
+ await measure();
504
+ host.disabled.set(true);
505
+ await waitForUpdate();
506
+ fireEvent.touchstart(item('Week'), { touches: [{ pageX: 120 }] });
507
+ fireEvent.touchmove(item('Week'), { touches: [{ pageX: 220 }] });
508
+ fireEvent.touchend(item('Week'));
509
+ fireEvent.tap(item('Day'));
510
+ await waitForUpdate();
511
+ expect(host.range()).toEqual(['Week']);
512
+ expect(frame()!.style.left).toBe('80px');
513
+ });
514
+
515
+ it('gives each pressed item its own thumb in a multiple selection', async () => {
516
+ const { host, item, frame, tap } = await renderSegmented();
517
+ host.type.set('multiple');
518
+ await waitForUpdate();
519
+ expect(frame()).toBeNull();
520
+ expect(item('Week').classList).toContain('bg-background');
521
+
522
+ await tap('Day');
523
+ expect(host.range()).toEqual(['Week', 'Day']);
524
+ expect(item('Day').classList).toContain('bg-background');
525
+ await tap('Day');
526
+ expect(host.range()).toEqual(['Week']);
527
+ });
528
+
529
+ it("is Material's connected button group on Android, its icons in the fill's content color", async () => {
530
+ const { host, item, getByText, container, tap } =
531
+ await renderSegmented(ANDROID);
532
+ expect(item('Week').classList).toContain('bg-primary');
533
+ expect(item('Week').style.transition).toContain('background-color');
534
+ expect(item('Day').classList).toContain('bg-secondary');
535
+ const star = () =>
536
+ container.querySelector('svg')!.getAttribute('content') ?? '';
537
+ expect(star()).toContain(ICON_LIGHT_COLORS['secondary-foreground']);
538
+
539
+ await tap('Star');
540
+ expect(host.range()).toEqual(['Star']);
541
+ expect(star()).toContain(ICON_LIGHT_COLORS['primary-foreground']);
542
+
543
+ // A press shows Material's state layer.
544
+ fireEvent.touchstart(item('Day'), { touches: [{ pageX: 20 }] });
545
+ await waitForUpdate();
546
+ expect(item('Day').querySelector('.opacity-10')).not.toBeNull();
547
+ fireEvent.touchend(item('Day'));
548
+ await waitForUpdate();
549
+ expect(item('Day').querySelector('.opacity-10')).toBeNull();
550
+ expect(getByText('Day')).toBeTruthy();
551
+ });
552
+
553
+ it("rounds the group's ends on Android, as items come and go", async () => {
554
+ const { host, item } = await renderSegmented(ANDROID);
555
+ expect(item('Day').classList).toContain('rounded-l-[20px]');
556
+ expect(item('Month').classList).toContain('rounded-[8px]');
557
+ expect(item('Star').classList).toContain('rounded-r-[20px]');
558
+
559
+ // The items before a new one are redrawn before it has a value, so each
560
+ // finds its place from the items rather than their values.
561
+ host.options.set(['Day', 'Week', 'Month', 'Year']);
562
+ await waitForUpdate();
563
+ expect(item('Year').classList).toContain('rounded-[8px]');
564
+ expect(item('Star').classList).toContain('rounded-r-[20px]');
565
+
566
+ host.options.set(['Month']);
567
+ await waitForUpdate();
568
+ expect(item('Month').classList).toContain('rounded-l-[20px]');
569
+ });
570
+
571
+ it('leaves an icon the color around the group in the other variants', async () => {
572
+ vi.stubGlobal('SystemInfo', IOS_26);
573
+ const tinted = await render(TintedGroupHost);
574
+ const star = () =>
575
+ tinted.container.querySelector('svg')!.getAttribute('content') ?? '';
576
+ expect(star()).toContain(ICON_LIGHT_COLORS['primary']);
577
+
578
+ // A segmented control on iOS leaves it alone too.
579
+ (tinted.componentRef.instance as TintedGroupHost).variant.set('segmented');
580
+ await waitForUpdate();
581
+ expect(star()).toContain(ICON_LIGHT_COLORS['primary']);
582
+ tinted.unmount();
583
+
584
+ // With nothing around it, an icon takes its own default.
585
+ const plain = await renderSegmented();
586
+ expect(
587
+ plain.container.querySelector('svg')!.getAttribute('content'),
588
+ ).toContain(ICON_LIGHT_COLORS['foreground']);
589
+ });
590
+
591
+ it('goes back to the toggle look when the variant changes', async () => {
592
+ const { host, item, frame } = await renderSegmented();
593
+ host.variant.set('outline');
594
+ await waitForUpdate();
595
+ expect(frame()).toBeNull();
596
+ expect(item('Week').classList).toContain('border-border');
597
+ expect(item('Week').classList).toContain('bg-accent');
185
598
  });
186
599
  });