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