@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,379 +1,463 @@
1
- import { ElementRef } from '@angular/core';
2
- import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
- import { afterEach, describe, expect, it, vi } from 'vitest';
4
- import { setInputSignal } from '../../../test-utils/set-input-signal';
5
- import { UiToastItem, UiToaster } from './toast';
6
- import { toast, toasts, type ToastData } from './toast-state';
1
+ import {
2
+ fireEvent,
3
+ render,
4
+ waitForUpdate,
5
+ } from '@blotch/angular-lynx-testing-library';
6
+ import {
7
+ type MockInstance,
8
+ afterEach,
9
+ beforeEach,
10
+ describe,
11
+ expect,
12
+ it,
13
+ vi,
14
+ } from 'vitest';
15
+ import {
16
+ type LynxMainThread,
17
+ lynxMainThread,
18
+ } from '../../../test-utils/lynx-main-thread';
19
+ import { DURATION } from '../../utils/animate';
20
+ import { UiToaster } from './toast';
21
+ import { type ToastOptions, dismissToast, toast, toasts } from './toast-state';
22
+
23
+ const sleep = (ms: number) =>
24
+ new Promise<void>((resolve) => setTimeout(resolve, ms));
25
+
26
+ /**
27
+ * Waits out a toast's exit animation, after which the toaster drops it from
28
+ * the queue, and renders that.
29
+ */
30
+ const waitForExit = async () => {
31
+ await sleep(DURATION.fast + 40);
32
+ await waitForUpdate();
33
+ };
34
+
35
+ const renderToaster = async () => {
36
+ const result = await render(UiToaster);
37
+ /**
38
+ * A toast's card: its host element, around the view holding its texts.
39
+ */
40
+ const card = (title: string) =>
41
+ result.getByText(title).parentElement!.parentElement!;
42
+ return {
43
+ ...result,
44
+ card,
45
+ /** One property of a toast card's inline style. */
46
+ styleOf: (title: string, property: string) =>
47
+ (card(title) as HTMLElement).style.getPropertyValue(property),
48
+ /**
49
+ * Raises a toast and lets it mount, then start its entrance, which waits
50
+ * a macrotask so the card is committed to Lynx's tree first.
51
+ */
52
+ raise: async (options: ToastOptions) => {
53
+ const id = toast(options);
54
+ await waitForUpdate();
55
+ await waitForUpdate();
56
+ return id;
57
+ },
58
+ };
59
+ };
60
+
61
+ /** `__ElementAnimate`'s first argument for starting an animation. */
62
+ const START = 0;
63
+
64
+ /** Where the front toast rests. */
65
+ const FRONT = 'translateY(0px) scale(1)';
66
+ /** Where the toast behind the front one rests: lifted, and a little smaller. */
67
+ const SECOND = 'translateY(-14px) scale(0.95)';
7
68
 
8
69
  describe('UiToaster', () => {
9
- afterEach(() => {
10
- toasts.set([]);
11
- });
70
+ // The queue is module state, so each test starts it empty.
71
+ beforeEach(() => toasts.set([]));
12
72
 
13
- it('renders without any active toasts', async () => {
14
- const { container } = await render(UiToaster);
15
- expect(container).toBeTruthy();
73
+ // Watched per test: the testing library resets the main thread after each.
74
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
75
+ beforeEach(() => {
76
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
16
77
  });
78
+ afterEach(() => animate.mockRestore());
17
79
 
18
- it('renders the stack container when a toast is added', async () => {
19
- toast({ title: 'Saved!' });
20
- const { container } = await render(UiToaster);
21
- await waitForUpdate();
22
- // Toast content isn't asserted here — JIT does not wire the required
23
- // `data` input on the projected <ui-toast-item> so the child template
24
- // silently fails. UiToastItem's lifecycle is exercised directly below.
25
- expect(container.querySelectorAll('view').length).toBeGreaterThan(0);
26
- });
80
+ /**
81
+ * The keyframes of the animations started on an element.
82
+ */
83
+ const keyframesOn = (element: Element) =>
84
+ animate.mock.calls
85
+ .filter(
86
+ ([target, [operation]]) => target === element && operation === START,
87
+ )
88
+ .map(([, [, , keyframes]]) => keyframes);
27
89
 
28
- it('onDismissed removes the toast from the queue', async () => {
29
- const id = toast({ title: 'ByeBye' });
30
- const { componentRef } = await render(UiToaster);
31
- const inst = componentRef.instance as unknown as {
32
- onDismissed: (id: string) => void;
33
- };
34
- inst.onDismissed(id);
35
- expect(toasts()).toHaveLength(0);
36
- });
37
- });
90
+ const entrance = [
91
+ { transform: 'translateY(100%) scale(0.95)', opacity: 0 },
92
+ { transform: FRONT, opacity: 1 },
93
+ ];
94
+ const exit = (fromY: number) => [
95
+ { transform: `translateY(${fromY}px) scale(1)`, opacity: 1 },
96
+ { transform: 'translateY(300px) scale(0.95)', opacity: 0 },
97
+ ];
98
+ const snapBack = (fromY: number) => [
99
+ { transform: `translateY(${fromY}px) scale(1)` },
100
+ { transform: FRONT },
101
+ ];
38
102
 
39
- describe('UiToastItem', () => {
40
- const baseData = (over: Partial<ToastData> = {}): ToastData => ({
41
- id: 'toast-x',
42
- title: 'Hello',
43
- description: 'World',
44
- variant: 'default',
45
- duration: 60_000,
46
- ...over,
47
- });
103
+ describe('showing toasts', () => {
104
+ it('shows a toast once one is raised', async () => {
105
+ const { queryByText, getByText, raise } = await renderToaster();
106
+ expect(queryByText('Saved')).toBeNull();
48
107
 
49
- it('renders default and destructive variants', async () => {
50
- const { componentRef, container } = await render(UiToastItem);
51
- const inst = componentRef.instance as UiToastItem;
52
- setInputSignal(inst.data, baseData({ variant: 'destructive' }));
53
- setInputSignal(inst.depth, 0);
54
- await waitForUpdate();
55
- // JIT + input.required races the initial CD: even with the signal set, the
56
- // template DOM may not be materialized in this synthetic bootstrap. The
57
- // variant computed is exercised elsewhere; asserting on the container ref
58
- // proves the render didn't throw.
59
- expect(container).toBeTruthy();
60
- });
108
+ await raise({ title: 'Saved', description: 'Your changes are live.' });
109
+ expect(getByText('Saved')).toBeTruthy();
110
+ expect(getByText('Your changes are live.')).toBeTruthy();
111
+ });
61
112
 
62
- it('exercises all computed presentation getters (default + destructive)', async () => {
63
- // The template computed getters (`cardClass`, `titleClass`, etc.) are only
64
- // read by the template when it materializes; JIT + required inputs races
65
- // that, so read them directly so their branches run under coverage.
66
- const { componentRef } = await render(UiToastItem);
67
- const inst = componentRef.instance as UiToastItem;
68
- const cast = inst as unknown as {
69
- hostStyle: () => string;
70
- cardClass: () => string;
71
- titleClass: () => string;
72
- descriptionClass: () => string;
73
- actionClass: () => string;
74
- actionTextClass: () => string;
75
- };
76
-
77
- setInputSignal(inst.data, baseData());
78
- setInputSignal(inst.depth, 0);
79
- await waitForUpdate();
80
- expect(cast.hostStyle()).toContain('translateY');
81
- expect(cast.cardClass()).toContain('bg-background');
82
- expect(cast.titleClass()).toContain('text-foreground');
83
- expect(cast.descriptionClass()).toContain('text-muted-foreground');
84
- expect(cast.actionClass()).toContain('border-border');
85
- expect(cast.actionTextClass()).toContain('text-foreground');
86
-
87
- setInputSignal(inst.data, baseData({ variant: 'destructive' }));
88
- setInputSignal(inst.depth, 2);
89
- await waitForUpdate();
90
- expect(cast.cardClass()).toContain('bg-destructive');
91
- expect(cast.titleClass()).toContain('text-destructive-foreground');
92
- expect(cast.descriptionClass()).toContain('text-destructive-foreground');
93
- expect(cast.actionClass()).toContain('border-destructive-foreground');
94
- expect(cast.actionTextClass()).toContain('text-destructive-foreground');
95
- expect(cast.hostStyle()).toContain('translateY');
96
- });
113
+ it("leaves out a title or description it doesn't have", async () => {
114
+ const { getByText, raise } = await renderToaster();
115
+ await raise({ title: 'Only a title' });
116
+ await raise({ title: '', description: 'Only a description' });
117
+ const textsBeside = (text: string) =>
118
+ getByText(text).parentElement!.querySelectorAll('text');
119
+ expect(textsBeside('Only a title')).toHaveLength(1);
120
+ expect(textsBeside('Only a description')).toHaveLength(1);
121
+ });
97
122
 
98
- it('renders an action button and invokes it via onAction', async () => {
99
- const cb = vi.fn();
100
- const { componentRef } = await render(UiToastItem);
101
- const inst = componentRef.instance as UiToastItem;
102
- setInputSignal(
103
- inst.data,
104
- baseData({ action: { label: 'Do it', onAction: cb } }),
105
- );
106
- setInputSignal(inst.depth, 0);
107
- await waitForUpdate();
108
- await new Promise((r) => setTimeout(r, 20));
109
- (inst as unknown as { onAction: () => void }).onAction();
110
- expect(cb).toHaveBeenCalledTimes(1);
111
- });
123
+ it('styles a toast by its variant', async () => {
124
+ const { card, getByText, raise } = await renderToaster();
125
+ const action = { label: 'Undo', onAction: () => {} };
126
+ await raise({ title: 'Saved', description: 'Done.', action });
127
+ expect(card('Saved').classList).toContain('bg-background');
128
+ expect(getByText('Saved').classList).toContain('text-foreground');
129
+ expect(getByText('Done.').classList).toContain('text-muted-foreground');
130
+ expect(getByText('Undo').classList).toContain('text-foreground');
131
+ expect(getByText('Undo').parentElement!.classList).toContain(
132
+ 'border-border',
133
+ );
112
134
 
113
- it('onAction handles missing action gracefully', async () => {
114
- const { componentRef } = await render(UiToastItem);
115
- const inst = componentRef.instance as UiToastItem;
116
- setInputSignal(inst.data, baseData());
117
- setInputSignal(inst.depth, 0);
118
- await waitForUpdate();
119
- expect(() =>
120
- (inst as unknown as { onAction: () => void }).onAction(),
121
- ).not.toThrow();
122
- });
135
+ await raise({
136
+ title: 'Failed',
137
+ description: 'Try again.',
138
+ variant: 'destructive',
139
+ action: { label: 'Retry', onAction: () => {} },
140
+ });
141
+ expect(card('Failed').classList).toContain('bg-destructive');
142
+ for (const text of ['Failed', 'Try again.', 'Retry']) {
143
+ expect(getByText(text).classList).toContain(
144
+ 'text-destructive-foreground',
145
+ );
146
+ }
147
+ expect(getByText('Retry').parentElement!.classList).toContain(
148
+ 'border-destructive-foreground',
149
+ );
150
+ });
123
151
 
124
- it('onTap dismisses only when the toast is front (depth 0)', async () => {
125
- const { componentRef } = await render(UiToastItem);
126
- const inst = componentRef.instance as UiToastItem;
127
- setInputSignal(inst.data, baseData());
128
- setInputSignal(inst.depth, 1); // behind — no-op
129
- await waitForUpdate();
130
- expect(() =>
131
- (inst as unknown as { onTap: () => void }).onTap(),
132
- ).not.toThrow();
133
-
134
- setInputSignal(inst.depth, 0);
135
- await waitForUpdate();
136
- (inst as unknown as { onTap: () => void }).onTap();
137
- });
152
+ it('stays hidden until it slides up into view', async () => {
153
+ const { card, styleOf } = await renderToaster();
154
+ toast({ title: 'Saved' });
155
+ // Mounted, but its entrance waits a macrotask: until then it's hidden,
156
+ // or it would flash at its resting place first.
157
+ await waitForUpdate();
158
+ expect(styleOf('Saved', 'opacity')).toBe('0');
159
+ expect(keyframesOn(card('Saved'))).toEqual([]);
138
160
 
139
- it('auto-dismisses after the duration timer fires', async () => {
140
- const dismissed = vi.fn();
141
- const { componentRef } = await render(UiToastItem);
142
- const inst = componentRef.instance as UiToastItem;
143
- inst.dismissed.subscribe(dismissed);
144
- setInputSignal(inst.data, baseData({ duration: 30 }));
145
- setInputSignal(inst.depth, 0);
146
- await waitForUpdate();
147
- // Manually call ngOnInit after data is set so the auto-dismiss timer
148
- // registers (JIT's initial CD ran ngOnInit before the required-input's
149
- // value was available, so the timer was skipped).
150
- inst.ngOnInit();
151
- // Wait long enough for both the auto-dismiss (30ms) and the dismissed
152
- // emit setTimeout(DURATION.fast + 20 = 170ms) to fire.
153
- await new Promise((r) => setTimeout(r, 400));
154
- expect(dismissed).toHaveBeenCalled();
155
- });
161
+ await waitForUpdate();
162
+ expect(styleOf('Saved', 'opacity')).toBe('1');
163
+ expect(keyframesOn(card('Saved'))).toEqual([entrance]);
164
+ });
156
165
 
157
- it('stops its auto-dismiss timer when it is destroyed first', async () => {
158
- // dismissToast(id) removes the toast at once. Its timer used to fire on
159
- // the destroyed component anyway, and emit from a destroyed output.
160
- const dismissed = vi.fn();
161
- const warn = vi.spyOn(console, 'warn');
162
- const { componentRef } = await render(UiToastItem);
163
- const inst = componentRef.instance as UiToastItem;
164
- inst.dismissed.subscribe(dismissed);
165
- setInputSignal(inst.data, baseData({ duration: 30 }));
166
- setInputSignal(inst.depth, 0);
167
- await waitForUpdate();
168
- inst.ngOnInit();
169
- componentRef.destroy();
170
- await new Promise((r) => setTimeout(r, 400));
171
- expect(dismissed).not.toHaveBeenCalled();
172
- expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('NG0953'));
173
- warn.mockRestore();
174
- });
166
+ it('stacks the newest three, newest in front, and queues the rest', async () => {
167
+ const { queryByText, styleOf, raise } = await renderToaster();
168
+ for (const title of ['First', 'Second', 'Third', 'Fourth']) {
169
+ await raise({ title });
170
+ }
171
+ // The oldest waits its turn, off screen.
172
+ expect(queryByText('First')).toBeNull();
173
+ expect(toasts()).toHaveLength(4);
175
174
 
176
- it('stops its exit timer when it is destroyed mid-exit', async () => {
177
- // A destroyed output drops the emit, so the warning is what shows it.
178
- const dismissed = vi.fn();
179
- const warn = vi.spyOn(console, 'warn');
180
- const { componentRef } = await render(UiToastItem);
181
- const inst = componentRef.instance as UiToastItem;
182
- inst.dismissed.subscribe(dismissed);
183
- setInputSignal(inst.data, baseData());
184
- setInputSignal(inst.depth, 0);
185
- await waitForUpdate();
186
- inst.dismiss();
187
- componentRef.destroy();
188
- await new Promise((r) => setTimeout(r, 400));
189
- expect(dismissed).not.toHaveBeenCalled();
190
- expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('NG0953'));
191
- warn.mockRestore();
192
- });
175
+ expect(styleOf('Fourth', 'z-index')).toBe('100');
176
+ expect(styleOf('Fourth', 'transform')).toBe(FRONT);
177
+ expect(styleOf('Third', 'z-index')).toBe('99');
178
+ expect(styleOf('Third', 'transform')).toBe(SECOND);
179
+ expect(styleOf('Second', 'z-index')).toBe('98');
180
+ expect(styleOf('Second', 'transform')).toBe(
181
+ 'translateY(-28px) scale(0.9)',
182
+ );
183
+ });
193
184
 
194
- it('touch drag beyond threshold dismisses; below threshold snaps back', async () => {
195
- const { componentRef } = await render(UiToastItem);
196
- const inst = componentRef.instance as UiToastItem;
197
- setInputSignal(inst.data, baseData({ duration: 60_000 }));
198
- setInputSignal(inst.depth, 0);
199
- await waitForUpdate();
200
- // Call ngOnInit manually so the auto-dismiss timer is set — that's the
201
- // branch inside dismiss() that touchend beyond threshold exercises.
202
- inst.ngOnInit();
203
- await new Promise((r) => setTimeout(r, 10));
204
-
205
- const cast = inst as unknown as {
206
- onTouchStart: (e: { touches?: { clientY: number }[] }) => void;
207
- onTouchMove: (e: { touches?: { clientY: number }[] }) => void;
208
- onTouchEnd: () => void;
209
- };
210
- // Below threshold → snap back.
211
- cast.onTouchStart({ touches: [{ clientY: 100 }] });
212
- cast.onTouchMove({ touches: [{ clientY: 130 }] });
213
- cast.onTouchEnd();
214
- await waitForUpdate();
215
-
216
- // Beyond threshold → dismiss.
217
- cast.onTouchStart({ touches: [{ clientY: 100 }] });
218
- cast.onTouchMove({ touches: [{ clientY: 400 }] });
219
- cast.onTouchEnd();
220
- await new Promise((r) => setTimeout(r, 200));
221
- });
185
+ it('moves the stack back for a new toast, and forward when the front one goes', async () => {
186
+ const { card, raise } = await renderToaster();
187
+ await raise({ title: 'Older' });
188
+ await raise({ title: 'Newer' });
189
+ expect(keyframesOn(card('Older'))).toEqual([
190
+ entrance,
191
+ [{ transform: FRONT }, { transform: SECOND }],
192
+ ]);
222
193
 
223
- it('touch handlers guard non-front toasts and missing touches', async () => {
224
- const { componentRef } = await render(UiToastItem);
225
- const inst = componentRef.instance as UiToastItem;
226
- setInputSignal(inst.data, baseData());
227
- setInputSignal(inst.depth, 1); // behind — early-return in onTouchStart
228
- await waitForUpdate();
229
-
230
- const cast = inst as unknown as {
231
- onTouchStart: (e: { touches?: { clientY: number }[] }) => void;
232
- onTouchMove: (e: { touches?: { clientY: number }[] }) => void;
233
- onTouchEnd: () => void;
234
- };
235
- cast.onTouchStart({ touches: [{ clientY: 100 }] });
236
- cast.onTouchMove({ touches: [{ clientY: 200 }] }); // not dragging → no-op
237
- cast.onTouchEnd(); // not dragging → no-op
238
-
239
- setInputSignal(inst.depth, 0);
240
- await waitForUpdate();
241
- // Empty touches array → no-op
242
- cast.onTouchStart({ touches: [] });
243
- cast.onTouchMove({ touches: [] });
244
- // No prior touchstart with real coords, so onTouchEnd is a no-op.
245
- cast.onTouchEnd();
246
- });
194
+ fireEvent.tap(card('Newer'));
195
+ await waitForExit();
196
+ expect(keyframesOn(card('Older')).at(-1)).toEqual([
197
+ { transform: SECOND },
198
+ { transform: FRONT },
199
+ ]);
200
+ });
247
201
 
248
- it('onTouchMove ignores subsequent moves with missing touches once dragging', async () => {
249
- const { componentRef } = await render(UiToastItem);
250
- const inst = componentRef.instance as UiToastItem;
251
- setInputSignal(inst.data, baseData());
252
- setInputSignal(inst.depth, 0);
253
- await waitForUpdate();
254
- inst.ngOnInit();
255
- const cast = inst as unknown as {
256
- onTouchStart: (e: { touches?: { clientY: number }[] }) => void;
257
- onTouchMove: (e: { touches?: { clientY: number }[] }) => void;
258
- };
259
- // Start a real drag (sets #isDragging = true).
260
- cast.onTouchStart({ touches: [{ clientY: 50 }] });
261
- // A subsequent move without touches must fall through the `y == null` guard
262
- // in onTouchMove (line 257) — this is the "finger left the surface without
263
- // a touchend" native quirk we defend against.
264
- cast.onTouchMove({ touches: [] });
265
- cast.onTouchMove({}); // touches undefined
202
+ it('brings a queued toast into the back of the stack once there is room', async () => {
203
+ const { card, raise } = await renderToaster();
204
+ for (const title of ['First', 'Second', 'Third', 'Fourth']) {
205
+ await raise({ title });
206
+ }
207
+ fireEvent.tap(card('Fourth'));
208
+ await waitForExit();
209
+ // It mounts there, so it rises into the back slot rather than sliding
210
+ // up past the others.
211
+ await waitForUpdate();
212
+ expect(keyframesOn(card('First'))).toEqual([
213
+ [
214
+ { transform: 'translateY(-20px) scale(0.864)', opacity: 0 },
215
+ { transform: 'translateY(-28px) scale(0.9)', opacity: 1 },
216
+ ],
217
+ ]);
218
+ });
266
219
  });
267
220
 
268
- it('onTouchEnd is a no-op when dismissal is already in progress', async () => {
269
- const { componentRef } = await render(UiToastItem);
270
- const inst = componentRef.instance as UiToastItem;
271
- setInputSignal(inst.data, baseData());
272
- setInputSignal(inst.depth, 0);
273
- await waitForUpdate();
274
- inst.ngOnInit();
275
- const cast = inst as unknown as {
276
- onTouchStart: (e: { touches?: { clientY: number }[] }) => void;
277
- onTouchMove: (e: { touches?: { clientY: number }[] }) => void;
278
- onTouchEnd: () => void;
279
- };
280
- // Start a drag.
281
- cast.onTouchStart({ touches: [{ clientY: 100 }] });
282
- cast.onTouchMove({ touches: [{ clientY: 200 }] });
283
- // Race the dismiss(): tapping or the timer already started tearing down.
284
- inst.dismiss();
285
- // touchend must observe #isDismissing and bail out (line 271).
286
- cast.onTouchEnd();
287
- });
221
+ describe('dismissing', () => {
222
+ it('slides the front toast away when tapped', async () => {
223
+ const { card, queryByText, raise } = await renderToaster();
224
+ await raise({ title: 'Saved' });
225
+ fireEvent.tap(card('Saved'));
226
+ expect(keyframesOn(card('Saved')).at(-1)).toEqual(exit(0));
288
227
 
289
- it('snap-back reschedules the auto-dismiss timer that eventually fires', async () => {
290
- const dismissed = vi.fn();
291
- const { componentRef } = await render(UiToastItem);
292
- const inst = componentRef.instance as UiToastItem;
293
- inst.dismissed.subscribe(dismissed);
294
- // Short duration so the snap-back's rescheduled setTimeout callback
295
- // (line 419) actually fires before the test times out.
296
- setInputSignal(inst.data, baseData({ duration: 30 }));
297
- setInputSignal(inst.depth, 0);
298
- await waitForUpdate();
299
- inst.ngOnInit();
300
- const cast = inst as unknown as {
301
- onTouchStart: (e: { touches?: { clientY: number }[] }) => void;
302
- onTouchMove: (e: { touches?: { clientY: number }[] }) => void;
303
- onTouchEnd: () => void;
304
- };
305
- // Drag below threshold → snap back reschedules the auto-dismiss.
306
- cast.onTouchStart({ touches: [{ clientY: 100 }] });
307
- cast.onTouchMove({ touches: [{ clientY: 120 }] });
308
- cast.onTouchEnd();
309
- // Wait long enough for the rescheduled dismiss (30ms) + dismissed emit
310
- // (DURATION.fast + 20 ≈ 170ms) to fire.
311
- await new Promise((r) => setTimeout(r, 400));
312
- expect(dismissed).toHaveBeenCalled();
313
- });
228
+ await waitForExit();
229
+ expect(queryByText('Saved')).toBeNull();
230
+ expect(toasts()).toEqual([]);
231
+ });
314
232
 
315
- it('restack effect runs when depth changes on an already-mounted item', async () => {
316
- const { componentRef } = await render(UiToastItem);
317
- const inst = componentRef.instance as UiToastItem;
318
- setInputSignal(inst.data, baseData());
319
- setInputSignal(inst.depth, 0);
320
- await waitForUpdate();
321
- setInputSignal(inst.depth, 1); // triggers restack animation
322
- await waitForUpdate();
323
- setInputSignal(inst.depth, 2);
324
- await waitForUpdate();
325
- });
233
+ it('slides away only once, however often it is tapped', async () => {
234
+ const { card, queryByText, raise } = await renderToaster();
235
+ await raise({ title: 'Saved' });
236
+ fireEvent.tap(card('Saved'));
237
+ fireEvent.tap(card('Saved'));
238
+ expect(keyframesOn(card('Saved'))).toEqual([entrance, exit(0)]);
326
239
 
327
- it('hostStyle flips opacity to 1 after the entrance animation runs', async () => {
328
- const { componentRef } = await render(UiToastItem);
329
- const inst = componentRef.instance as UiToastItem;
330
- setInputSignal(inst.data, baseData());
331
- setInputSignal(inst.depth, 0);
332
- await waitForUpdate();
333
- const cast = inst as unknown as { hostStyle: () => string };
334
- // Before ngOnInit → #hasEntered = false → opacity: 0.
335
- expect(cast.hostStyle()).toContain('opacity: 0');
336
- inst.ngOnInit();
337
- // Wait past the setTimeout(0) that runs #animateIn, which sets #hasEntered.
338
- await new Promise((r) => setTimeout(r, 30));
339
- // After animateIn → #hasEntered = true → opacity: 1 (the ternary's truthy arm).
340
- expect(cast.hostStyle()).toContain('opacity: 1');
341
- });
240
+ await waitForExit();
241
+ expect(queryByText('Saved')).toBeNull();
242
+ });
342
243
 
343
- it('dismiss() is idempotent (re-entry guard)', async () => {
344
- const dismissed = vi.fn();
345
- const { componentRef } = await render(UiToastItem);
346
- const inst = componentRef.instance as UiToastItem;
347
- inst.dismissed.subscribe(dismissed);
348
- setInputSignal(inst.data, baseData({ duration: 60_000 }));
349
- setInputSignal(inst.depth, 0);
350
- await waitForUpdate();
351
- inst.dismiss();
352
- inst.dismiss(); // guarded
353
- await new Promise((r) => setTimeout(r, 200));
354
- expect(dismissed).toHaveBeenCalledTimes(1);
355
- });
244
+ it('ignores taps on a toast behind the front one', async () => {
245
+ const { card, getByText, raise } = await renderToaster();
246
+ await raise({ title: 'Older' });
247
+ await raise({ title: 'Newer' });
248
+ animate.mockClear();
249
+
250
+ fireEvent.tap(card('Older'));
251
+ await waitForExit();
252
+ expect(animate).not.toHaveBeenCalled();
253
+ expect(getByText('Older')).toBeTruthy();
254
+ });
255
+
256
+ it('runs its action, then goes', async () => {
257
+ const onAction = vi.fn();
258
+ const { getByText, queryByText, raise } = await renderToaster();
259
+ await raise({ title: 'Deleted', action: { label: 'Undo', onAction } });
260
+
261
+ // The action catches its tap, so the card doesn't see it as well.
262
+ fireEvent.tap(getByText('Undo').parentElement!, {
263
+ eventType: 'catchEvent',
264
+ });
265
+ expect(onAction).toHaveBeenCalledTimes(1);
356
266
 
357
- it('behind toast (depth > 0) uses the promoted-from-queue entrance animation', async () => {
358
- const { componentRef } = await render(UiToastItem);
359
- const inst = componentRef.instance as UiToastItem;
360
- setInputSignal(inst.data, baseData());
361
- setInputSignal(inst.depth, 2);
362
- await waitForUpdate();
363
- inst.ngOnInit();
364
- await new Promise((r) => setTimeout(r, 30));
267
+ await waitForExit();
268
+ expect(queryByText('Deleted')).toBeNull();
269
+ });
270
+
271
+ it('goes by itself once its duration is up', async () => {
272
+ const { queryByText, raise } = await renderToaster();
273
+ await raise({ title: 'Saved', duration: 30 });
274
+ expect(queryByText('Saved')).not.toBeNull();
275
+
276
+ await sleep(30);
277
+ await waitForExit();
278
+ expect(queryByText('Saved')).toBeNull();
279
+ });
280
+
281
+ it('fades a toast behind the front one away in place when it expires', async () => {
282
+ const { card, queryByText, raise } = await renderToaster();
283
+ // Raised together, so the older one is behind from the start.
284
+ toast({ title: 'Older', duration: 30 });
285
+ await raise({ title: 'Newer' });
286
+
287
+ await sleep(30);
288
+ await waitForUpdate();
289
+ expect(keyframesOn(card('Older')).at(-1)).toEqual([
290
+ { transform: SECOND, opacity: 1 },
291
+ { transform: 'translateY(-14px) scale(0.855)', opacity: 0 },
292
+ ]);
293
+
294
+ await waitForExit();
295
+ expect(queryByText('Older')).toBeNull();
296
+ expect(queryByText('Newer')).not.toBeNull();
297
+ });
298
+
299
+ it("lets a toast that's going finish, rather than moving back for a new one", async () => {
300
+ const { card, raise } = await renderToaster();
301
+ await raise({ title: 'Older' });
302
+ fireEvent.tap(card('Older'));
303
+ await raise({ title: 'Newer' });
304
+ expect(keyframesOn(card('Older'))).toEqual([entrance, exit(0)]);
305
+ });
306
+
307
+ describe('removed before its timers fire', () => {
308
+ // A destroyed output warns (NG0953) if something still emits on it.
309
+ let warn: MockInstance<typeof console.warn>;
310
+ beforeEach(() => {
311
+ warn = vi.spyOn(console, 'warn');
312
+ });
313
+ afterEach(() => warn.mockRestore());
314
+
315
+ it("doesn't expire once it's gone", async () => {
316
+ const { card, queryByText, raise } = await renderToaster();
317
+ const id = await raise({ title: 'Saved', duration: 100 });
318
+ const removed = card('Saved');
319
+ dismissToast(id);
320
+ await waitForUpdate();
321
+ expect(queryByText('Saved')).toBeNull();
322
+
323
+ await sleep(100);
324
+ await waitForExit();
325
+ expect(keyframesOn(removed)).toEqual([entrance]);
326
+ expect(warn).not.toHaveBeenCalledWith(
327
+ expect.stringContaining('NG0953'),
328
+ );
329
+ });
330
+
331
+ it("doesn't report finishing its exit once it's gone", async () => {
332
+ const { card, queryByText, raise } = await renderToaster();
333
+ const id = await raise({ title: 'Saved' });
334
+ fireEvent.tap(card('Saved'));
335
+ dismissToast(id);
336
+ await waitForUpdate();
337
+ expect(queryByText('Saved')).toBeNull();
338
+
339
+ await waitForExit();
340
+ expect(warn).not.toHaveBeenCalledWith(
341
+ expect.stringContaining('NG0953'),
342
+ );
343
+ });
344
+ });
365
345
  });
366
346
 
367
- it('behind toast (depth > 0) also dismisses via a per-depth exit animation', async () => {
368
- const { componentRef } = await render(UiToastItem);
369
- const inst = componentRef.instance as UiToastItem;
370
- setInputSignal(inst.data, baseData());
371
- setInputSignal(inst.depth, 2);
372
- await waitForUpdate();
373
- inst.ngOnInit();
374
- await new Promise((r) => setTimeout(r, 20));
375
- inst.dismiss();
376
- await new Promise((r) => setTimeout(r, 200));
347
+ describe('dragging', () => {
348
+ /**
349
+ * Fires a touch event the card catches, at a height on screen.
350
+ */
351
+ const touch = (
352
+ name: 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel',
353
+ element: Element,
354
+ clientY?: number,
355
+ ) =>
356
+ fireEvent[name](element, {
357
+ eventType: 'catchEvent',
358
+ ...(clientY === undefined ? {} : { touches: [{ clientY }] }),
359
+ });
360
+
361
+ it('follows the finger down, and springs back if let go early', async () => {
362
+ const { card, styleOf, raise } = await renderToaster();
363
+ await raise({ title: 'Saved' });
364
+
365
+ touch('touchstart', card('Saved'), 100);
366
+ touch('touchmove', card('Saved'), 130);
367
+ await waitForUpdate();
368
+ expect(styleOf('Saved', 'transform')).toBe('translateY(30px) scale(1)');
369
+
370
+ // Docked at the bottom, it only moves down.
371
+ touch('touchmove', card('Saved'), 60);
372
+ await waitForUpdate();
373
+ expect(styleOf('Saved', 'transform')).toBe(FRONT);
374
+
375
+ touch('touchmove', card('Saved'), 150);
376
+ touch('touchend', card('Saved'));
377
+ await waitForUpdate();
378
+ expect(keyframesOn(card('Saved')).at(-1)).toEqual(snapBack(50));
379
+ expect(styleOf('Saved', 'transform')).toBe(FRONT);
380
+ });
381
+
382
+ it('springs back when the touch is canceled', async () => {
383
+ const { card, raise } = await renderToaster();
384
+ await raise({ title: 'Saved' });
385
+ touch('touchstart', card('Saved'), 100);
386
+ touch('touchmove', card('Saved'), 140);
387
+ touch('touchcancel', card('Saved'));
388
+ expect(keyframesOn(card('Saved')).at(-1)).toEqual(snapBack(40));
389
+ });
390
+
391
+ it('slides away from the finger when dragged far enough', async () => {
392
+ const { card, queryByText, raise } = await renderToaster();
393
+ await raise({ title: 'Saved' });
394
+ touch('touchstart', card('Saved'), 100);
395
+ touch('touchmove', card('Saved'), 220);
396
+ touch('touchend', card('Saved'));
397
+ expect(keyframesOn(card('Saved')).at(-1)).toEqual(exit(120));
398
+
399
+ await waitForExit();
400
+ expect(queryByText('Saved')).toBeNull();
401
+ });
402
+
403
+ it("doesn't expire while held, and gets its full time again once let go", async () => {
404
+ const { card, queryByText, raise } = await renderToaster();
405
+ await raise({ title: 'Saved', duration: 100 });
406
+ touch('touchstart', card('Saved'), 100);
407
+
408
+ await sleep(100);
409
+ await waitForExit();
410
+ expect(queryByText('Saved')).not.toBeNull();
411
+
412
+ touch('touchend', card('Saved'));
413
+ await sleep(100);
414
+ await waitForExit();
415
+ expect(queryByText('Saved')).toBeNull();
416
+ });
417
+
418
+ it('ignores touches on a toast behind the front one', async () => {
419
+ const { card, styleOf, raise } = await renderToaster();
420
+ await raise({ title: 'Older' });
421
+ await raise({ title: 'Newer' });
422
+ animate.mockClear();
423
+
424
+ touch('touchstart', card('Older'), 100);
425
+ touch('touchmove', card('Older'), 200);
426
+ touch('touchend', card('Older'));
427
+ await waitForUpdate();
428
+ expect(styleOf('Older', 'transform')).toBe(SECOND);
429
+ expect(animate).not.toHaveBeenCalled();
430
+ });
431
+
432
+ it('ignores touches without a position', async () => {
433
+ const { card, styleOf, raise } = await renderToaster();
434
+ await raise({ title: 'Saved' });
435
+
436
+ // Without a starting point, there's no drag to follow.
437
+ touch('touchstart', card('Saved'));
438
+ touch('touchmove', card('Saved'), 200);
439
+ await waitForUpdate();
440
+ expect(styleOf('Saved', 'transform')).toBe(FRONT);
441
+
442
+ // A move without a position leaves the toast where it was.
443
+ touch('touchstart', card('Saved'), 100);
444
+ touch('touchmove', card('Saved'), 140);
445
+ touch('touchmove', card('Saved'));
446
+ await waitForUpdate();
447
+ expect(styleOf('Saved', 'transform')).toBe('translateY(40px) scale(1)');
448
+ });
449
+
450
+ it("doesn't spring back when a tap dismissed it mid-drag", async () => {
451
+ const { card, queryByText, raise } = await renderToaster();
452
+ await raise({ title: 'Saved' });
453
+ touch('touchstart', card('Saved'), 100);
454
+ touch('touchmove', card('Saved'), 120);
455
+ fireEvent.tap(card('Saved'));
456
+ touch('touchend', card('Saved'));
457
+ expect(keyframesOn(card('Saved'))).toEqual([entrance, exit(20)]);
458
+
459
+ await waitForExit();
460
+ expect(queryByText('Saved')).toBeNull();
461
+ });
377
462
  });
378
463
  });
379
- void ElementRef;