@blotch/dolan 22.2.2 → 22.2.4

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 (259) hide show
  1. package/dist/commands/add.d.ts +6 -2
  2. package/dist/commands/add.js +41 -11
  3. package/dist/commands/add.js.map +1 -1
  4. package/dist/commands/add.test.js +162 -9
  5. package/dist/commands/add.test.js.map +1 -1
  6. package/dist/commands/diff.d.ts +2 -1
  7. package/dist/commands/diff.js +38 -25
  8. package/dist/commands/diff.js.map +1 -1
  9. package/dist/commands/diff.test.js +72 -11
  10. package/dist/commands/diff.test.js.map +1 -1
  11. package/dist/commands/doctor.js +228 -34
  12. package/dist/commands/doctor.js.map +1 -1
  13. package/dist/commands/doctor.test.js +367 -10
  14. package/dist/commands/doctor.test.js.map +1 -1
  15. package/dist/commands/eject.js +7 -0
  16. package/dist/commands/eject.js.map +1 -1
  17. package/dist/commands/eject.test.js +39 -0
  18. package/dist/commands/eject.test.js.map +1 -1
  19. package/dist/commands/info.js +3 -2
  20. package/dist/commands/info.js.map +1 -1
  21. package/dist/commands/init.d.ts +37 -1
  22. package/dist/commands/init.js +169 -40
  23. package/dist/commands/init.js.map +1 -1
  24. package/dist/commands/init.test.js +392 -27
  25. package/dist/commands/init.test.js.map +1 -1
  26. package/dist/commands/install-packages.d.ts +6 -0
  27. package/dist/commands/install-packages.js +20 -0
  28. package/dist/commands/install-packages.js.map +1 -0
  29. package/dist/commands/install-packages.test.d.ts +1 -0
  30. package/dist/commands/install-packages.test.js +55 -0
  31. package/dist/commands/install-packages.test.js.map +1 -0
  32. package/dist/commands/list.js +21 -14
  33. package/dist/commands/list.js.map +1 -1
  34. package/dist/commands/list.test.js +62 -5
  35. package/dist/commands/list.test.js.map +1 -1
  36. package/dist/commands/outdated.js +11 -10
  37. package/dist/commands/outdated.js.map +1 -1
  38. package/dist/commands/outdated.test.js +20 -5
  39. package/dist/commands/outdated.test.js.map +1 -1
  40. package/dist/commands/remove.d.ts +4 -0
  41. package/dist/commands/remove.js +17 -4
  42. package/dist/commands/remove.js.map +1 -1
  43. package/dist/commands/remove.test.js +53 -2
  44. package/dist/commands/remove.test.js.map +1 -1
  45. package/dist/commands/theme.js +1 -1
  46. package/dist/commands/theme.js.map +1 -1
  47. package/dist/commands/update.d.ts +11 -0
  48. package/dist/commands/update.js +174 -58
  49. package/dist/commands/update.js.map +1 -1
  50. package/dist/commands/update.test.js +587 -33
  51. package/dist/commands/update.test.js.map +1 -1
  52. package/dist/index.js +7 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/lockfile.d.ts +7 -0
  55. package/dist/lockfile.js +36 -0
  56. package/dist/lockfile.js.map +1 -1
  57. package/dist/lockfile.test.d.ts +1 -0
  58. package/dist/lockfile.test.js +25 -0
  59. package/dist/lockfile.test.js.map +1 -0
  60. package/dist/registry.d.ts +14 -0
  61. package/dist/registry.js +78 -24
  62. package/dist/registry.js.map +1 -1
  63. package/dist/registry.test.js +119 -38
  64. package/dist/registry.test.js.map +1 -1
  65. package/dist/test-utils.d.ts +5 -0
  66. package/dist/test-utils.js +6 -1
  67. package/dist/test-utils.js.map +1 -1
  68. package/dist/testing/fire-lynx-event.d.ts +14 -0
  69. package/dist/testing/fire-lynx-event.js +19 -0
  70. package/dist/testing/fire-lynx-event.js.map +1 -0
  71. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  72. package/dist/testing/fire-lynx-event.test.js +44 -0
  73. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  74. package/dist/testing/lynx-main-thread.d.ts +22 -0
  75. package/dist/testing/lynx-main-thread.js +11 -0
  76. package/dist/testing/lynx-main-thread.js.map +1 -0
  77. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  78. package/dist/testing/lynx-main-thread.test.js +24 -0
  79. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  80. package/dist/testing/set-input-signal.d.ts +14 -0
  81. package/dist/testing/set-input-signal.js +21 -0
  82. package/dist/testing/set-input-signal.js.map +1 -0
  83. package/dist/testing/set-input-signal.test.d.ts +1 -0
  84. package/dist/testing/set-input-signal.test.js +20 -0
  85. package/dist/testing/set-input-signal.test.js.map +1 -0
  86. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  87. package/dist/ui/components/accordion/accordion.ts +19 -14
  88. package/dist/ui/components/action-sheet/action-sheet.test.ts +283 -151
  89. package/dist/ui/components/action-sheet/action-sheet.ts +77 -43
  90. package/dist/ui/components/alert/alert.test.ts +67 -19
  91. package/dist/ui/components/alert/alert.ts +14 -16
  92. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +208 -94
  93. package/dist/ui/components/alert-dialog/alert-dialog.ts +75 -38
  94. package/dist/ui/components/area-chart/area-chart.test.ts +134 -119
  95. package/dist/ui/components/area-chart/area-chart.ts +100 -315
  96. package/dist/ui/components/area-chart/index.ts +2 -3
  97. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  98. package/dist/ui/components/badge/badge.test.ts +57 -23
  99. package/dist/ui/components/badge/badge.ts +12 -14
  100. package/dist/ui/components/bar-chart/bar-chart.test.ts +172 -45
  101. package/dist/ui/components/bar-chart/bar-chart.ts +140 -294
  102. package/dist/ui/components/bar-chart/index.ts +3 -3
  103. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +386 -128
  104. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +170 -53
  105. package/dist/ui/components/button/button.test.ts +611 -2
  106. package/dist/ui/components/button/button.ts +434 -49
  107. package/dist/ui/components/button/index.ts +2 -0
  108. package/dist/ui/components/button-group/button-group.test.ts +484 -2
  109. package/dist/ui/components/button-group/button-group.ts +380 -68
  110. package/dist/ui/components/button-group/index.ts +3 -0
  111. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +183 -58
  112. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +150 -323
  113. package/dist/ui/components/candlestick-chart/index.ts +3 -4
  114. package/dist/ui/components/card/card.test.ts +82 -62
  115. package/dist/ui/components/chart/chart-focus.test.ts +132 -0
  116. package/dist/ui/components/chart/chart-focus.ts +115 -0
  117. package/dist/ui/components/chart/chart-presets.test.ts +335 -0
  118. package/dist/ui/components/chart/chart-presets.ts +278 -0
  119. package/dist/ui/components/chart/chart-scene.test.ts +589 -0
  120. package/dist/ui/components/chart/chart-scene.ts +483 -0
  121. package/dist/ui/components/chart/chart-theme.test.ts +85 -0
  122. package/dist/ui/components/chart/chart-theme.ts +210 -0
  123. package/dist/ui/components/chart/chart-zoom.test.ts +159 -0
  124. package/dist/ui/components/chart/chart-zoom.ts +162 -0
  125. package/dist/ui/components/chart/chart.test.ts +1017 -0
  126. package/dist/ui/components/chart/chart.ts +934 -0
  127. package/dist/ui/components/chart/index.ts +24 -0
  128. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  129. package/dist/ui/components/checkbox/checkbox.ts +23 -23
  130. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  131. package/dist/ui/components/collapsible/collapsible.ts +19 -14
  132. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  133. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  134. package/dist/ui/components/context-menu/index.ts +1 -0
  135. package/dist/ui/components/dialog/dialog.test.ts +213 -108
  136. package/dist/ui/components/dialog/dialog.ts +72 -35
  137. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  138. package/dist/ui/components/dock/dock-scroll.ts +133 -0
  139. package/dist/ui/components/dock/dock.test.ts +749 -0
  140. package/dist/ui/components/dock/dock.ts +960 -0
  141. package/dist/ui/components/dock/index.ts +2 -0
  142. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  143. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  144. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  145. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  146. package/dist/ui/components/glass/glass-design.ts +119 -0
  147. package/dist/ui/components/glass/glass.test.ts +338 -0
  148. package/dist/ui/components/glass/glass.ts +360 -0
  149. package/dist/ui/components/glass/index.ts +18 -0
  150. package/dist/ui/components/icon/icon-theme.test.ts +169 -0
  151. package/dist/ui/components/icon/icon-theme.ts +227 -0
  152. package/dist/ui/components/icon/icon.test.ts +85 -25
  153. package/dist/ui/components/icon/icon.ts +38 -21
  154. package/dist/ui/components/input/input.test.ts +178 -101
  155. package/dist/ui/components/input/input.ts +15 -12
  156. package/dist/ui/components/label/label.test.ts +11 -24
  157. package/dist/ui/components/line-chart/index.ts +2 -3
  158. package/dist/ui/components/line-chart/line-chart.test.ts +149 -56
  159. package/dist/ui/components/line-chart/line-chart.ts +83 -241
  160. package/dist/ui/components/list/list.test.ts +119 -21
  161. package/dist/ui/components/list/list.ts +21 -15
  162. package/dist/ui/components/menu/index.ts +25 -0
  163. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  164. package/dist/ui/components/menu/menu-placement.ts +159 -0
  165. package/dist/ui/components/menu/menu-popup.test.ts +632 -0
  166. package/dist/ui/components/menu/menu-popup.ts +544 -0
  167. package/dist/ui/components/menu/menu.test.ts +429 -0
  168. package/dist/ui/components/menu/menu.ts +415 -0
  169. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +435 -191
  170. package/dist/ui/components/nav-drawer/nav-drawer.ts +127 -56
  171. package/dist/ui/components/pagination/pagination.test.ts +177 -65
  172. package/dist/ui/components/pagination/pagination.ts +55 -8
  173. package/dist/ui/components/progress/progress.test.ts +102 -46
  174. package/dist/ui/components/progress/progress.ts +52 -54
  175. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  176. package/dist/ui/components/radio-group/radio-group.ts +24 -24
  177. package/dist/ui/components/scatter-chart/index.ts +2 -3
  178. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +131 -26
  179. package/dist/ui/components/scatter-chart/scatter-chart.ts +104 -250
  180. package/dist/ui/components/scroll-area/scroll-area.test.ts +95 -24
  181. package/dist/ui/components/scroll-area/scroll-area.ts +8 -8
  182. package/dist/ui/components/select/select.test.ts +138 -78
  183. package/dist/ui/components/select/select.ts +17 -11
  184. package/dist/ui/components/separator/separator.test.ts +18 -17
  185. package/dist/ui/components/sheet/sheet.test.ts +64 -11
  186. package/dist/ui/components/sheet/sheet.ts +9 -1
  187. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  188. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  189. package/dist/ui/components/spinner/spinner.test.ts +67 -22
  190. package/dist/ui/components/spinner/spinner.ts +48 -41
  191. package/dist/ui/components/switch/switch.test.ts +116 -46
  192. package/dist/ui/components/switch/switch.ts +8 -13
  193. package/dist/ui/components/tab-bar/index.ts +1 -0
  194. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  195. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  196. package/dist/ui/components/tabs/tabs.test.ts +215 -157
  197. package/dist/ui/components/tabs/tabs.ts +110 -118
  198. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  199. package/dist/ui/components/textarea/textarea.ts +14 -12
  200. package/dist/ui/components/toast/toast.test.ts +436 -315
  201. package/dist/ui/components/toast/toast.ts +62 -55
  202. package/dist/ui/components/toggle/toggle.test.ts +247 -140
  203. package/dist/ui/theme/chart-colors.test.ts +35 -0
  204. package/dist/ui/theme/dark.css +46 -2
  205. package/dist/ui/theme/default.css +63 -7
  206. package/dist/ui/theme/icon-colors.test.ts +35 -0
  207. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  208. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  209. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  210. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  211. package/dist/ui/theme/tailwind-plugin.ts +98 -25
  212. package/dist/ui/theme/tailwind.css +113 -0
  213. package/dist/ui/utils/animate.test.ts +13 -1
  214. package/dist/utils/analyze.d.ts +14 -0
  215. package/dist/utils/analyze.js +15 -0
  216. package/dist/utils/analyze.js.map +1 -1
  217. package/dist/utils/analyze.test.js +14 -1
  218. package/dist/utils/analyze.test.js.map +1 -1
  219. package/dist/utils/installed.d.ts +29 -0
  220. package/dist/utils/installed.js +43 -0
  221. package/dist/utils/installed.js.map +1 -0
  222. package/dist/utils/installed.test.d.ts +1 -0
  223. package/dist/utils/installed.test.js +57 -0
  224. package/dist/utils/installed.test.js.map +1 -0
  225. package/dist/utils/interactive.d.ts +9 -0
  226. package/dist/utils/interactive.js +10 -0
  227. package/dist/utils/interactive.js.map +1 -0
  228. package/dist/utils/interactive.test.d.ts +1 -0
  229. package/dist/utils/interactive.test.js +24 -0
  230. package/dist/utils/interactive.test.js.map +1 -0
  231. package/dist/utils/packages.d.ts +36 -0
  232. package/dist/utils/packages.js +70 -0
  233. package/dist/utils/packages.js.map +1 -0
  234. package/dist/utils/packages.test.d.ts +1 -0
  235. package/dist/utils/packages.test.js +89 -0
  236. package/dist/utils/packages.test.js.map +1 -0
  237. package/dist/utils/resolve-paths.d.ts +17 -1
  238. package/dist/utils/resolve-paths.js +21 -3
  239. package/dist/utils/resolve-paths.js.map +1 -1
  240. package/dist/utils/resolve-paths.test.js +38 -2
  241. package/dist/utils/resolve-paths.test.js.map +1 -1
  242. package/dist/utils/tailwind.d.ts +25 -0
  243. package/dist/utils/tailwind.js +51 -0
  244. package/dist/utils/tailwind.js.map +1 -0
  245. package/dist/utils/tailwind.test.d.ts +1 -0
  246. package/dist/utils/tailwind.test.js +71 -0
  247. package/dist/utils/tailwind.test.js.map +1 -0
  248. package/package.json +13 -1
  249. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -121
  250. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -174
  251. package/dist/ui/components/button/button-render.test.ts +0 -113
  252. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  253. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -174
  254. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +0 -188
  255. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +0 -525
  256. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +0 -1140
  257. package/dist/ui/components/cartesian-chart/index.ts +0 -18
  258. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -121
  259. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -157
@@ -1,5 +1,608 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { buttonVariants } from './button';
1
+ import { Component, type Provider, signal } from '@angular/core';
2
+ import { LynxAccessibilitySettings } from '@blotch/angular-lynx';
3
+ import {
4
+ fireEvent,
5
+ render,
6
+ waitForUpdate,
7
+ } from '@blotch/angular-lynx-testing-library';
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
10
+ import { provideGlass } from '../glass/glass-design';
11
+ import { UiIcon } from '../icon/icon';
12
+ import { ICON_LIGHT_COLORS } from '../icon/icon-theme';
13
+ import {
14
+ type ButtonSize,
15
+ type ButtonVariant,
16
+ UiButton,
17
+ buttonVariants,
18
+ } from './button';
19
+
20
+ @Component({
21
+ imports: [UiButton, UiIcon],
22
+ template: `
23
+ <ui-button
24
+ [variant]="variant()"
25
+ [size]="size()"
26
+ [disabled]="disabled()"
27
+ [loading]="loading()"
28
+ [accessibility-label]="label()"
29
+ class="w-full"
30
+ (pressed)="presses.set(presses() + 1)"
31
+ >
32
+ @if (withIcon()) {
33
+ <ui-icon name="search" />
34
+ }
35
+ Save
36
+ </ui-button>
37
+ `,
38
+ })
39
+ class ButtonHost {
40
+ readonly variant = signal<ButtonVariant>('default');
41
+ readonly size = signal<ButtonSize>('default');
42
+ readonly disabled = signal(false);
43
+ readonly loading = signal(false);
44
+ readonly label = signal<string | undefined>(undefined);
45
+ readonly withIcon = signal(false);
46
+ readonly presses = signal(0);
47
+ }
48
+
49
+ /** Accessibility settings a test can change, as the host app would. */
50
+ const settings = {
51
+ reduceMotion: signal(false),
52
+ reduceTransparency: signal(false),
53
+ increaseContrast: signal(false),
54
+ };
55
+
56
+ /**
57
+ * Renders the host on a platform, as `SystemInfo` reports it, or with no
58
+ * SystemInfo at all, which gives the glass variants Material's design.
59
+ */
60
+ const renderButton = async (
61
+ platform?: { platform: string; osVersion: string },
62
+ providers: Provider[] = [],
63
+ ) => {
64
+ vi.stubGlobal('SystemInfo', platform);
65
+ const result = await render(ButtonHost, {
66
+ providers: [
67
+ { provide: LynxAccessibilitySettings, useValue: settings },
68
+ ...providers,
69
+ ],
70
+ });
71
+ const host = result.componentRef.instance as ButtonHost;
72
+ return {
73
+ ...result,
74
+ host,
75
+ /**
76
+ * The button's own box, the first child of its host: it takes the
77
+ * presses, and holds the glass on a glass variant.
78
+ */
79
+ button: () => result.container.firstElementChild!.firstElementChild!,
80
+ glass: () => result.container.querySelector('blur-view')!,
81
+ spinner: () => result.container.querySelector('svg'),
82
+ icon: () => result.container.querySelector('svg')!,
83
+ update: async (change: () => void) => {
84
+ change();
85
+ await waitForUpdate();
86
+ },
87
+ };
88
+ };
89
+
90
+ const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
91
+ const IOS_18 = { platform: 'iOS', osVersion: '18.5' };
92
+ const ANDROID = { platform: 'Android', osVersion: '15' };
93
+ const WEB = { platform: 'web', osVersion: '' };
94
+
95
+ /**
96
+ * The scale the button's inline style holds, if any.
97
+ */
98
+ const scaleOf = (view: Element) =>
99
+ (view as HTMLElement).style.getPropertyValue('transform');
100
+
101
+ // The icon's selector isn't a Lynx element, so the renderer falls back to a
102
+ // <view> for it and warns.
103
+ beforeEach(() => {
104
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
105
+ });
106
+
107
+ afterEach(() => {
108
+ vi.restoreAllMocks();
109
+ vi.unstubAllGlobals();
110
+ settings.reduceMotion.set(false);
111
+ settings.reduceTransparency.set(false);
112
+ settings.increaseContrast.set(false);
113
+ });
114
+
115
+ describe('UiButton', () => {
116
+ it('renders its label with the classes for its variant and size', async () => {
117
+ const { button, getByText } = await renderButton();
118
+ expect(button().classList).toContain('bg-primary');
119
+ expect(button().classList).toContain('h-10');
120
+ expect(button().classList).toContain('w-full');
121
+ expect(button().classList).not.toContain('opacity-50');
122
+ expect(getByText('Save').classList).toContain('text-primary-foreground');
123
+ expect(getByText('Save').classList).toContain('text-sm');
124
+ });
125
+
126
+ it('restyles the button and its label when the variant or size changes', async () => {
127
+ const { host, button, getByText, update } = await renderButton();
128
+ await update(() => {
129
+ host.variant.set('destructive');
130
+ host.size.set('lg');
131
+ });
132
+ expect(button().classList).toContain('bg-destructive');
133
+ expect(button().classList).toContain('h-11');
134
+ expect(getByText('Save').classList).toContain(
135
+ 'text-destructive-foreground',
136
+ );
137
+ expect(getByText('Save').classList).toContain('text-base');
138
+ });
139
+
140
+ it('emits `pressed` when tapped', async () => {
141
+ const { host, button } = await renderButton();
142
+ fireEvent.tap(button());
143
+ fireEvent.tap(button());
144
+ expect(host.presses()).toBe(2);
145
+ });
146
+
147
+ it("dims and ignores taps while it's disabled", async () => {
148
+ const { host, button, update } = await renderButton();
149
+ await update(() => host.disabled.set(true));
150
+ expect(button().classList).toContain('opacity-50');
151
+ fireEvent.tap(button());
152
+ expect(host.presses()).toBe(0);
153
+ });
154
+
155
+ it("shows a spinner in place of the label, dims and ignores taps while it's loading", async () => {
156
+ const { host, button, spinner, queryByText, update } = await renderButton();
157
+ expect(spinner()).toBeNull();
158
+
159
+ await update(() => host.loading.set(true));
160
+ expect(spinner()).not.toBeNull();
161
+ expect(queryByText('Save')).toBeNull();
162
+ expect(button().classList).toContain('opacity-50');
163
+ fireEvent.tap(button());
164
+ expect(host.presses()).toBe(0);
165
+ });
166
+
167
+ it('shrinks the spinner for a small button', async () => {
168
+ const { host, spinner, update } = await renderButton();
169
+ await update(() => host.loading.set(true));
170
+ /**
171
+ * The spinner's size: its own view carries it, and the svg fills it.
172
+ */
173
+ const spinnerSize = () =>
174
+ (spinner()!.parentElement as HTMLElement).style.getPropertyValue('width');
175
+ expect(spinnerSize()).toBe('20px');
176
+
177
+ await update(() => host.size.set('sm'));
178
+ expect(spinnerSize()).toBe('16px');
179
+ });
180
+
181
+ it("colors the spinner like each variant's label", async () => {
182
+ const { host, spinner, update } = await renderButton();
183
+ host.loading.set(true);
184
+ // The spinner draws with SVG, which can't read the theme's CSS variables,
185
+ // so its markup carries the light theme's value of the label's color.
186
+ for (const [variant, color] of [
187
+ ['default', 'rgba(250, 250, 250, 1)'], // --primary-foreground
188
+ ['destructive', 'rgba(250, 250, 250, 1)'], // --destructive-foreground
189
+ ['outline', 'rgba(9, 9, 11, 1)'], // --foreground
190
+ ['secondary', 'rgba(24, 24, 27, 1)'], // --secondary-foreground
191
+ ['ghost', 'rgba(9, 9, 11, 1)'], // --foreground
192
+ ] as const) {
193
+ await update(() => host.variant.set(variant));
194
+ expect(spinner()!.getAttribute('content')).toContain(`stroke="${color}"`);
195
+ }
196
+ });
197
+
198
+ it('scales down while pressed by touch, and back on release', async () => {
199
+ const { button } = await renderButton();
200
+ fireEvent.touchstart(button());
201
+ expect(scaleOf(button())).toBe('scale(0.97)');
202
+ fireEvent.touchend(button());
203
+ expect(scaleOf(button())).toBe('scale(1)');
204
+
205
+ // A scroll that takes the touch over cancels it: the button scales back
206
+ // rather than staying pressed.
207
+ fireEvent.touchstart(button());
208
+ expect(scaleOf(button())).toBe('scale(0.97)');
209
+ fireEvent.touchcancel(button());
210
+ expect(scaleOf(button())).toBe('scale(1)');
211
+ });
212
+
213
+ it('scales down while pressed by mouse, and back on release or leave', async () => {
214
+ // Lynx on the web fires mouse events for a cursor, never touch ones.
215
+ const { button } = await renderButton();
216
+ fireEvent.mousedown(button());
217
+ expect(scaleOf(button())).toBe('scale(0.97)');
218
+ fireEvent.mouseup(button());
219
+ expect(scaleOf(button())).toBe('scale(1)');
220
+
221
+ fireEvent.mousedown(button());
222
+ fireLynxEvent(button(), 'mouseleave');
223
+ expect(scaleOf(button())).toBe('scale(1)');
224
+ });
225
+
226
+ it("doesn't react to presses while it's disabled or loading", async () => {
227
+ const { host, button, update } = await renderButton();
228
+ for (const state of [host.disabled, host.loading]) {
229
+ await update(() => state.set(true));
230
+ fireEvent.touchstart(button());
231
+ fireEvent.mousedown(button());
232
+ expect(scaleOf(button())).toBe('');
233
+ fireEvent.touchend(button());
234
+ fireEvent.mouseup(button());
235
+ expect(scaleOf(button())).toBe('');
236
+ state.set(false);
237
+ }
238
+ });
239
+
240
+ it('dims at once instead of shrinking under Reduce Motion', async () => {
241
+ const { button, update } = await renderButton();
242
+ await update(() => settings.reduceMotion.set(true));
243
+
244
+ fireEvent.touchstart(button());
245
+ await waitForUpdate();
246
+ expect(button().classList).toContain('opacity-70');
247
+ expect(scaleOf(button())).toBe('');
248
+
249
+ fireEvent.touchend(button());
250
+ await waitForUpdate();
251
+ expect(button().classList).not.toContain('opacity-70');
252
+ expect(scaleOf(button())).toBe('');
253
+ });
254
+
255
+ it('puts a projected icon before the label, in the label color', async () => {
256
+ const { host, button, getByText, icon, update } = await renderButton();
257
+ await update(() => host.withIcon.set(true));
258
+
259
+ expect(button().classList).toContain('gap-2');
260
+ expect(icon().compareDocumentPosition(getByText('Save'))).toBe(
261
+ Node.DOCUMENT_POSITION_FOLLOWING,
262
+ );
263
+ expect(icon().getAttribute('content')).toContain(
264
+ ICON_LIGHT_COLORS['primary-foreground'],
265
+ );
266
+
267
+ await update(() => host.variant.set('outline'));
268
+ expect(icon().getAttribute('content')).toContain(
269
+ ICON_LIGHT_COLORS['foreground'],
270
+ );
271
+ });
272
+
273
+ it('shows only the icon in an icon size, named by its accessibility label', async () => {
274
+ const { host, button, queryByText, getByRole, update } =
275
+ await renderButton();
276
+ await update(() => {
277
+ host.withIcon.set(true);
278
+ host.size.set('icon-circle');
279
+ host.label.set('Search');
280
+ });
281
+
282
+ expect(queryByText('Save')).toBeNull();
283
+ expect(button().classList).toContain('rounded-full');
284
+ expect(button().classList).not.toContain('gap-2');
285
+ expect(getByRole('button', { name: 'Search' })).toBe(button());
286
+ });
287
+
288
+ it("keeps an icon size's text when nothing else is projected", async () => {
289
+ // `size="icon"` has long held a single character, such as "+".
290
+ const { host, getByText, update } = await renderButton();
291
+ await update(() => host.size.set('icon'));
292
+
293
+ expect(getByText('Save').classList).toContain('text-sm');
294
+ });
295
+
296
+ it('is read as a button with its label, or with its accessibility label', async () => {
297
+ const { host, button, getByRole, getByText, update } = await renderButton();
298
+ expect(getByRole('button', { name: 'Save' })).toBe(getByText('Save'));
299
+ expect(button().hasAttribute('accessibility-element')).toBe(false);
300
+
301
+ await update(() => host.label.set('Save the draft'));
302
+ expect(getByRole('button', { name: 'Save the draft' })).toBe(button());
303
+ expect(button().getAttribute('accessibility-element')).toBe('true');
304
+ expect(getByText('Save').hasAttribute('accessibility-traits')).toBe(false);
305
+ });
306
+ });
307
+
308
+ describe('UiButton glass variants', () => {
309
+ it('draws interactive Liquid Glass on iOS 26, filling the button', async () => {
310
+ const { host, button, glass, getByText, update } =
311
+ await renderButton(IOS_26);
312
+ await update(() => host.variant.set('glass'));
313
+
314
+ expect(glass().getAttribute('blur-effect')).toBe('glass');
315
+ expect(glass().getAttribute('glass-style')).toBe('regular');
316
+ expect(glass().getAttribute('glass-interactive')).toBe('true');
317
+ expect(glass().hasAttribute('glass-tint-color')).toBe(false);
318
+ expect(glass().parentElement).toBe(button());
319
+ expect(glass().classList).toContain('flex-1');
320
+ expect(glass().classList).toContain('px-4');
321
+ // The button is a capsule, sized by its size and the app's classes.
322
+ expect(button().classList).toContain('rounded-full');
323
+ expect(button().classList).toContain('h-10');
324
+ expect(button().classList).toContain('w-full');
325
+ expect(button().classList).not.toContain('bg-glass-dim');
326
+ // The label is inside the glass, in the glass's foreground.
327
+ expect(glass().contains(getByText('Save'))).toBe(true);
328
+ expect(getByText('Save').classList).toContain('text-glass-foreground');
329
+ });
330
+
331
+ it('leaves the press to the glass on iOS 26, which reacts by itself', async () => {
332
+ const { host, button, glass, getByText, update } =
333
+ await renderButton(IOS_26);
334
+ await update(() => host.variant.set('glass'));
335
+
336
+ fireEvent.touchstart(button());
337
+ await waitForUpdate();
338
+ expect(scaleOf(button())).toBe('');
339
+ fireEvent.touchend(button());
340
+ fireEvent.tap(button());
341
+ expect(scaleOf(button())).toBe('');
342
+ expect(host.presses()).toBe(1);
343
+
344
+ // iOS tones its own glass down under Reduce Motion, so it stays
345
+ // interactive, and the button adds nothing.
346
+ await update(() => settings.reduceMotion.set(true));
347
+ expect(glass().getAttribute('glass-interactive')).toBe('true');
348
+ fireEvent.touchstart(button());
349
+ await waitForUpdate();
350
+ expect(getByText('Save').parentElement!.classList).not.toContain(
351
+ 'opacity-70',
352
+ );
353
+ });
354
+
355
+ it('tints the prominent variant for the main action', async () => {
356
+ const { host, glass, getByText, icon, update } = await renderButton(IOS_26);
357
+ await update(() => {
358
+ host.variant.set('glass-prominent');
359
+ host.withIcon.set(true);
360
+ });
361
+
362
+ expect(glass().getAttribute('glass-tint-color')).toBe(
363
+ ICON_LIGHT_COLORS['glass-tint'],
364
+ );
365
+ expect(getByText('Save').classList).toContain('text-glass-tint-foreground');
366
+ expect(getByText('Save').parentElement!.classList).toContain('gap-2');
367
+ expect(icon().getAttribute('content')).toContain(
368
+ ICON_LIGHT_COLORS['glass-tint-foreground'],
369
+ );
370
+ });
371
+
372
+ it('puts clear glass on the dimming layer, with a white label and icon', async () => {
373
+ const { host, button, glass, icon, update } = await renderButton(IOS_26);
374
+ await update(() => {
375
+ host.variant.set('glass-clear');
376
+ host.withIcon.set(true);
377
+ });
378
+
379
+ expect(glass().getAttribute('glass-style')).toBe('clear');
380
+ expect(button().classList).toContain('bg-glass-dim');
381
+ // The glass points the label's color at the clear foreground.
382
+ expect(glass().classList).toContain(
383
+ '[--glass-foreground:var(--glass-clear-foreground)]',
384
+ );
385
+ expect(icon().getAttribute('content')).toContain(
386
+ ICON_LIGHT_COLORS['glass-clear-foreground'],
387
+ );
388
+ });
389
+
390
+ it("dims its content, not the glass, and stops reacting while it's disabled", async () => {
391
+ const { host, button, glass, getByText, update } =
392
+ await renderButton(IOS_26);
393
+ await update(() => {
394
+ host.variant.set('glass');
395
+ host.disabled.set(true);
396
+ });
397
+
398
+ // Faded glass looks washed out, so the label dims and the glass stays.
399
+ expect(getByText('Save').parentElement!.classList).toContain('opacity-50');
400
+ expect(button().classList).not.toContain('opacity-50');
401
+ expect(glass().hasAttribute('glass-interactive')).toBe(false);
402
+ fireEvent.touchstart(button());
403
+ fireEvent.tap(button());
404
+ expect(host.presses()).toBe(0);
405
+ });
406
+
407
+ it('colors the spinner like the label on clear glass', async () => {
408
+ const { host, spinner, update } = await renderButton(IOS_26);
409
+ await update(() => {
410
+ host.variant.set('glass-clear');
411
+ host.loading.set(true);
412
+ });
413
+
414
+ expect(spinner()!.getAttribute('content')).toContain(
415
+ `stroke="${ICON_LIGHT_COLORS['glass-clear-foreground']}"`,
416
+ );
417
+ });
418
+
419
+ it('is a circle of glass around an icon, named by its accessibility label', async () => {
420
+ const { host, button, glass, queryByText, getByRole, update } =
421
+ await renderButton(IOS_26);
422
+ await update(() => {
423
+ host.variant.set('glass');
424
+ host.size.set('icon-circle');
425
+ host.withIcon.set(true);
426
+ host.label.set('Search');
427
+ });
428
+
429
+ expect(button().classList).toContain('rounded-full');
430
+ expect(glass().classList).not.toContain('px-4');
431
+ expect(queryByText('Save')).toBeNull();
432
+ expect(getByRole('button', { name: 'Search' })).toBe(button());
433
+ });
434
+
435
+ it('draws the frosted material before iOS 26, and shrinks when pressed', async () => {
436
+ const { host, button, glass, update } = await renderButton(IOS_18);
437
+ await update(() => host.variant.set('glass'));
438
+
439
+ expect(glass().getAttribute('blur-effect')).toBe('extra-light');
440
+ fireEvent.touchstart(button());
441
+ expect(scaleOf(button())).toBe('scale(0.97)');
442
+ fireEvent.touchend(button());
443
+ expect(scaleOf(button())).toBe('scale(1)');
444
+ });
445
+
446
+ it("dims at once under Reduce Motion where the glass doesn't react", async () => {
447
+ const { host, button, getByText, update } = await renderButton(IOS_18);
448
+ await update(() => {
449
+ host.variant.set('glass');
450
+ settings.reduceMotion.set(true);
451
+ });
452
+ const content = () => getByText('Save').parentElement!;
453
+
454
+ fireEvent.touchstart(button());
455
+ await waitForUpdate();
456
+ expect(content().classList).toContain('opacity-70');
457
+ expect(button().classList).not.toContain('opacity-70');
458
+ expect(scaleOf(button())).toBe('');
459
+
460
+ fireEvent.touchcancel(button());
461
+ await waitForUpdate();
462
+ expect(content().classList).not.toContain('opacity-70');
463
+ });
464
+
465
+ it('leaves the press to the glass in the web preview, unless Reduce Motion is on', async () => {
466
+ const { host, button, glass, getByText, update } = await renderButton(WEB);
467
+ await update(() => host.variant.set('glass'));
468
+
469
+ expect(glass().getAttribute('glass-interactive')).toBe('true');
470
+ fireEvent.mousedown(button());
471
+ expect(scaleOf(button())).toBe('');
472
+ fireEvent.mouseup(button());
473
+
474
+ // The runtime's glass swells without regard to the setting, so the
475
+ // button stops it swelling and dims instead.
476
+ await update(() => settings.reduceMotion.set(true));
477
+ expect(glass().hasAttribute('glass-interactive')).toBe(false);
478
+ fireEvent.mousedown(button());
479
+ await waitForUpdate();
480
+ expect(getByText('Save').parentElement!.classList).toContain('opacity-70');
481
+ });
482
+
483
+ it('turns clear glass solid under Reduce Transparency in the web preview, with no dimming layer', async () => {
484
+ const { host, button, glass, icon, update } = await renderButton(WEB);
485
+ await update(() => {
486
+ host.variant.set('glass-clear');
487
+ host.withIcon.set(true);
488
+ settings.reduceTransparency.set(true);
489
+ });
490
+
491
+ expect(glass().classList).toContain('bg-secondary');
492
+ expect(button().classList).not.toContain('bg-glass-dim');
493
+ expect(icon().getAttribute('content')).toContain(
494
+ ICON_LIGHT_COLORS['glass-foreground'],
495
+ );
496
+ // The opaque surface doesn't react to touch, so the button shrinks.
497
+ fireEvent.touchstart(button());
498
+ expect(scaleOf(button())).toBe('scale(0.97)');
499
+ });
500
+
501
+ it("shrinks when pressed on Android's glass, which doesn't react to touch", async () => {
502
+ const { host, button, glass, update } = await renderButton(ANDROID, [
503
+ provideGlass({ android: 'glass' }),
504
+ ]);
505
+ await update(() => host.variant.set('glass'));
506
+
507
+ expect(glass().getAttribute('blur-effect')).toBe('glass');
508
+ fireEvent.touchstart(button());
509
+ expect(scaleOf(button())).toBe('scale(0.97)');
510
+ fireEvent.touchend(button());
511
+ expect(scaleOf(button())).toBe('scale(1)');
512
+ });
513
+ });
514
+
515
+ describe('UiButton glass variants in Material (Android)', () => {
516
+ it('draws a filled tonal button for glass and clear glass', async () => {
517
+ const { host, button, glass, getByText, icon, update } =
518
+ await renderButton(ANDROID);
519
+ await update(() => {
520
+ host.variant.set('glass');
521
+ host.withIcon.set(true);
522
+ });
523
+
524
+ expect(glass()).toBeNull();
525
+ for (const name of ['bg-secondary', 'rounded-full', 'h-10', 'gap-2']) {
526
+ expect(button().classList).toContain(name);
527
+ }
528
+ expect(getByText('Save').classList).toContain('text-secondary-foreground');
529
+ expect(icon().getAttribute('content')).toContain(
530
+ ICON_LIGHT_COLORS['secondary-foreground'],
531
+ );
532
+
533
+ await update(() => host.variant.set('glass-clear'));
534
+ expect(button().classList).toContain('bg-secondary');
535
+ expect(button().classList).not.toContain('bg-glass-dim');
536
+ });
537
+
538
+ it('draws a filled button in the glass tint for the prominent variant', async () => {
539
+ const { host, button, getByText, update } = await renderButton(ANDROID);
540
+ await update(() => host.variant.set('glass-prominent'));
541
+
542
+ expect(button().classList).toContain('bg-glass-tint');
543
+ expect(getByText('Save').classList).toContain('text-glass-tint-foreground');
544
+ });
545
+
546
+ it('shows the pressed state layer while pressed, with no motion', async () => {
547
+ const { host, button, getByText, update } = await renderButton(ANDROID);
548
+ await update(() => host.variant.set('glass'));
549
+ const layer = () => button().querySelector('.opacity-10');
550
+
551
+ expect(layer()).toBeNull();
552
+ fireEvent.touchstart(button());
553
+ await waitForUpdate();
554
+ expect(layer()!.classList).toContain('bg-secondary-foreground');
555
+ expect(layer()!.classList).toContain('absolute');
556
+ // Before the label, which draws over it.
557
+ expect(layer()!.compareDocumentPosition(getByText('Save'))).toBe(
558
+ Node.DOCUMENT_POSITION_FOLLOWING,
559
+ );
560
+ expect(scaleOf(button())).toBe('');
561
+
562
+ fireEvent.touchend(button());
563
+ await waitForUpdate();
564
+ expect(layer()).toBeNull();
565
+ expect(scaleOf(button())).toBe('');
566
+
567
+ await update(() => host.variant.set('glass-prominent'));
568
+ fireEvent.mousedown(button());
569
+ await waitForUpdate();
570
+ expect(layer()!.classList).toContain('bg-glass-tint-foreground');
571
+ });
572
+
573
+ it('outlines a tonal button under Increase Contrast', async () => {
574
+ const { host, button, update } = await renderButton(ANDROID);
575
+ await update(() => {
576
+ host.variant.set('glass');
577
+ settings.increaseContrast.set(true);
578
+ });
579
+ expect(button().classList).toContain('border-foreground');
580
+
581
+ // A filled button's edge already stands out.
582
+ await update(() => host.variant.set('glass-prominent'));
583
+ expect(button().classList).not.toContain('border-foreground');
584
+ });
585
+
586
+ it("dims and ignores taps while it's disabled", async () => {
587
+ const { host, button, update } = await renderButton(ANDROID);
588
+ await update(() => {
589
+ host.variant.set('glass');
590
+ host.disabled.set(true);
591
+ });
592
+
593
+ expect(button().classList).toContain('opacity-50');
594
+ fireEvent.tap(button());
595
+ expect(host.presses()).toBe(0);
596
+ });
597
+
598
+ it("is Material where there's no SystemInfo to read", async () => {
599
+ const { host, button, glass, update } = await renderButton();
600
+ await update(() => host.variant.set('glass'));
601
+
602
+ expect(glass()).toBeNull();
603
+ expect(button().classList).toContain('bg-secondary');
604
+ });
605
+ });
3
606
 
4
607
  describe('buttonVariants', () => {
5
608
  it('returns default variant and size classes', () => {
@@ -48,6 +651,12 @@ describe('buttonVariants', () => {
48
651
  expect(result).toContain('w-10');
49
652
  });
50
653
 
654
+ it('returns round icon size classes', () => {
655
+ const result = buttonVariants({ size: 'icon-circle' });
656
+ expect(result).toContain('w-10');
657
+ expect(result).toContain('rounded-full');
658
+ });
659
+
51
660
  it('always includes base classes', () => {
52
661
  const result = buttonVariants({ variant: 'ghost', size: 'sm' });
53
662
  expect(result).toContain('flex');