@blotch/dolan 22.2.3 → 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 (185) 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 +36 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +187 -7
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +280 -7
  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 +107 -1
  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 +5 -0
  17. package/dist/commands/update.js +126 -45
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +314 -2
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +7 -0
  22. package/dist/lockfile.js +36 -0
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +25 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +48 -2
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +45 -3
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +5 -0
  33. package/dist/test-utils.js +6 -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/components/accordion/accordion.test.ts +158 -144
  51. package/dist/ui/components/accordion/accordion.ts +19 -14
  52. package/dist/ui/components/action-sheet/action-sheet.test.ts +272 -187
  53. package/dist/ui/components/action-sheet/action-sheet.ts +40 -34
  54. package/dist/ui/components/alert/alert.test.ts +67 -19
  55. package/dist/ui/components/alert/alert.ts +14 -16
  56. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +206 -139
  57. package/dist/ui/components/alert-dialog/alert-dialog.ts +32 -26
  58. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  59. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  60. package/dist/ui/components/badge/badge.test.ts +57 -23
  61. package/dist/ui/components/badge/badge.ts +12 -14
  62. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  63. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +373 -297
  64. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +34 -33
  65. package/dist/ui/components/button/button.test.ts +611 -2
  66. package/dist/ui/components/button/button.ts +431 -62
  67. package/dist/ui/components/button/index.ts +2 -0
  68. package/dist/ui/components/button-group/button-group.test.ts +484 -2
  69. package/dist/ui/components/button-group/button-group.ts +380 -68
  70. package/dist/ui/components/button-group/index.ts +3 -0
  71. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  72. package/dist/ui/components/card/card.test.ts +82 -62
  73. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  74. package/dist/ui/components/chart/chart-presets.ts +8 -1
  75. package/dist/ui/components/chart/chart-theme.ts +12 -0
  76. package/dist/ui/components/chart/chart.test.ts +1017 -0
  77. package/dist/ui/components/chart/chart.ts +56 -43
  78. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  79. package/dist/ui/components/checkbox/checkbox.ts +23 -23
  80. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  81. package/dist/ui/components/collapsible/collapsible.ts +19 -14
  82. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  83. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  84. package/dist/ui/components/context-menu/index.ts +1 -0
  85. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  86. package/dist/ui/components/dialog/dialog.ts +29 -23
  87. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  88. package/dist/ui/components/dock/dock-scroll.ts +133 -0
  89. package/dist/ui/components/dock/dock.test.ts +749 -0
  90. package/dist/ui/components/dock/dock.ts +960 -0
  91. package/dist/ui/components/dock/index.ts +2 -0
  92. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  93. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  94. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  95. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  96. package/dist/ui/components/glass/glass-design.ts +119 -0
  97. package/dist/ui/components/glass/glass.test.ts +338 -0
  98. package/dist/ui/components/glass/glass.ts +360 -0
  99. package/dist/ui/components/glass/index.ts +18 -0
  100. package/dist/ui/components/icon/icon-theme.test.ts +36 -0
  101. package/dist/ui/components/icon/icon-theme.ts +36 -13
  102. package/dist/ui/components/icon/icon.test.ts +52 -41
  103. package/dist/ui/components/input/input.test.ts +178 -101
  104. package/dist/ui/components/input/input.ts +15 -12
  105. package/dist/ui/components/label/label.test.ts +11 -24
  106. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  107. package/dist/ui/components/list/list.test.ts +80 -49
  108. package/dist/ui/components/menu/index.ts +25 -0
  109. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  110. package/dist/ui/components/menu/menu-placement.ts +159 -0
  111. package/dist/ui/components/menu/menu-popup.test.ts +632 -0
  112. package/dist/ui/components/menu/menu-popup.ts +544 -0
  113. package/dist/ui/components/menu/menu.test.ts +429 -0
  114. package/dist/ui/components/menu/menu.ts +415 -0
  115. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  116. package/dist/ui/components/nav-drawer/nav-drawer.ts +72 -56
  117. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  118. package/dist/ui/components/progress/progress.test.ts +102 -46
  119. package/dist/ui/components/progress/progress.ts +52 -54
  120. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  121. package/dist/ui/components/radio-group/radio-group.ts +24 -24
  122. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  123. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  124. package/dist/ui/components/select/select.test.ts +138 -78
  125. package/dist/ui/components/select/select.ts +17 -11
  126. package/dist/ui/components/separator/separator.test.ts +18 -17
  127. package/dist/ui/components/sheet/sheet.test.ts +64 -11
  128. package/dist/ui/components/sheet/sheet.ts +9 -1
  129. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  130. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  131. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  132. package/dist/ui/components/spinner/spinner.ts +16 -19
  133. package/dist/ui/components/switch/switch.test.ts +116 -46
  134. package/dist/ui/components/switch/switch.ts +8 -13
  135. package/dist/ui/components/tab-bar/index.ts +1 -0
  136. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  137. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  138. package/dist/ui/components/tabs/tabs.test.ts +215 -157
  139. package/dist/ui/components/tabs/tabs.ts +110 -118
  140. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  141. package/dist/ui/components/textarea/textarea.ts +14 -12
  142. package/dist/ui/components/toast/toast.test.ts +434 -350
  143. package/dist/ui/components/toast/toast.ts +42 -51
  144. package/dist/ui/components/toggle/toggle.test.ts +247 -140
  145. package/dist/ui/theme/chart-colors.test.ts +8 -3
  146. package/dist/ui/theme/dark.css +38 -2
  147. package/dist/ui/theme/default.css +55 -7
  148. package/dist/ui/theme/icon-colors.test.ts +8 -3
  149. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  150. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  151. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  152. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  153. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  154. package/dist/ui/theme/tailwind.css +113 -0
  155. package/dist/ui/utils/animate.test.ts +13 -1
  156. package/dist/utils/analyze.d.ts +14 -0
  157. package/dist/utils/analyze.js +15 -0
  158. package/dist/utils/analyze.js.map +1 -1
  159. package/dist/utils/analyze.test.js +14 -1
  160. package/dist/utils/analyze.test.js.map +1 -1
  161. package/dist/utils/installed.d.ts +12 -0
  162. package/dist/utils/installed.js +16 -0
  163. package/dist/utils/installed.js.map +1 -1
  164. package/dist/utils/installed.test.js +17 -1
  165. package/dist/utils/installed.test.js.map +1 -1
  166. package/dist/utils/resolve-paths.d.ts +4 -4
  167. package/dist/utils/resolve-paths.js +5 -7
  168. package/dist/utils/resolve-paths.js.map +1 -1
  169. package/dist/utils/resolve-paths.test.js +9 -5
  170. package/dist/utils/resolve-paths.test.js.map +1 -1
  171. package/dist/utils/tailwind.d.ts +25 -0
  172. package/dist/utils/tailwind.js +51 -0
  173. package/dist/utils/tailwind.js.map +1 -0
  174. package/dist/utils/tailwind.test.d.ts +1 -0
  175. package/dist/utils/tailwind.test.js +71 -0
  176. package/dist/utils/tailwind.test.js.map +1 -0
  177. package/package.json +9 -1
  178. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  179. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  180. package/dist/ui/components/button/button-render.test.ts +0 -132
  181. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  182. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  183. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  184. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  185. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -1,345 +1,421 @@
1
1
  import { Component, signal } from '@angular/core';
2
- import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ waitForUpdate,
6
+ } from '@blotch/angular-lynx-testing-library';
3
7
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
- import { describe, expect, it, vi } from 'vitest';
8
+ import {
9
+ type MockInstance,
10
+ afterEach,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ it,
15
+ vi,
16
+ } from 'vitest';
5
17
  import { DURATION } from '../../utils/animate';
6
- import { setInputSignal } from '../../../test-utils/set-input-signal';
18
+ import {
19
+ type LynxMainThread,
20
+ lynxMainThread,
21
+ } from '../../../test-utils/lynx-main-thread';
7
22
  import { UiBottomSheet } from './bottom-sheet';
8
23
 
9
24
  @Component({
10
25
  imports: [UiBottomSheet, LYNX_ELEMENTS],
11
- template: `<ui-bottom-sheet [(open)]="open"
12
- ><text>Panel</text></ui-bottom-sheet
13
- >`,
26
+ template: `
27
+ <ui-bottom-sheet [(open)]="open" class="max-h-96" (closed)="closed()">
28
+ <text>Panel</text>
29
+ </ui-bottom-sheet>
30
+ `,
14
31
  })
15
32
  class BottomSheetHost {
16
- open = true;
33
+ readonly open = signal(false);
34
+ readonly closed = vi.fn();
17
35
  }
18
36
 
19
- describe('UiBottomSheet', () => {
20
- it('stops its close timers when it is destroyed while closing', async () => {
21
- const warn = vi.spyOn(console, 'warn');
22
- const { componentRef, unmount } = await render(UiBottomSheet);
23
- const inst = componentRef.instance as UiBottomSheet;
24
- inst.open.set(true);
25
- for (let i = 0; i < 5; i++) await waitForUpdate();
26
- inst.open.set(false);
27
- await waitForUpdate();
28
- // Destroyed during the close animation, as an `@if` around the sheet
29
- // would do. Its close timer (DURATION.normal + 20) used to fire anyway
30
- // and emit `closed` from the destroyed output.
31
- unmount();
32
- await new Promise((r) => setTimeout(r, DURATION.normal + 50));
33
- const warnings = warn.mock.calls.map(([message]) => String(message));
34
- warn.mockRestore();
35
- expect(warnings.filter((message) => message.includes('NG0953'))).toEqual(
36
- [],
37
- );
38
- });
39
-
40
- it('moves a screen reader into the panel and keeps it there while open', async () => {
41
- const { componentRef, container } = await render(UiBottomSheet);
42
- const inst = componentRef.instance as UiBottomSheet;
43
- const panel = container.querySelector(
44
- '[accessibility-exclusive-focus]',
45
- ) as Element & { invoke?: unknown };
46
- expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('false');
47
- const invoke = vi.fn();
48
- panel.invoke = invoke;
49
- (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
50
- nativeElement: panel,
51
- });
52
- (inst as unknown as { panelRef: unknown }).panelRef = signal({
53
- nativeElement: panel,
54
- });
55
- inst.open.set(true);
56
- for (let i = 0; i < 5; i++) await waitForUpdate();
57
- expect(invoke).toHaveBeenCalledWith('requestAccessibilityFocus');
58
- expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('true');
59
- });
60
-
61
- const patchRefs = (inst: UiBottomSheet, view: Element) => {
62
- (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
63
- nativeElement: view,
64
- });
65
- (inst as unknown as { panelRef: unknown }).panelRef = signal({
66
- nativeElement: view,
67
- });
37
+ /**
38
+ * The Lynx function an element's `invoke()` calls to run one of its UI
39
+ * methods, such as moving a screen reader's focus. The testing library
40
+ * defines it on the global object.
41
+ */
42
+ type InvokeUIMethod = {
43
+ __InvokeUIMethod: (
44
+ element: Element,
45
+ method: string,
46
+ params: Record<string, unknown>,
47
+ callback: (result: { code: number }) => void,
48
+ ) => void;
49
+ };
50
+
51
+ /** `__ElementAnimate`'s first argument for starting an animation. */
52
+ const START = 0;
53
+ /** `__ElementAnimate`'s first argument for canceling an animation. */
54
+ const CANCEL = 3;
55
+
56
+ const renderSheet = async () => {
57
+ const result = await render(BottomSheetHost);
58
+ const { container } = result;
59
+ return {
60
+ ...result,
61
+ host: result.componentRef.instance as BottomSheetHost,
62
+ overlay: container.querySelector('overlay')!,
63
+ backdrop: container.querySelector('overlay > view')!,
64
+ panel: container.querySelector('.rounded-t-2xl')!,
65
+ // The strip around the grabber pill, which takes the drag.
66
+ handle: container.querySelector('.bg-muted')!.parentElement!,
68
67
  };
68
+ };
69
+
70
+ /**
71
+ * Opens the sheet and waits out its open sequence: the overlay shows on one
72
+ * tick, and the panel slides in on the next.
73
+ */
74
+ const openSheet = async (host: BottomSheetHost) => {
75
+ host.open.set(true);
76
+ for (let i = 0; i < 3; i++) await waitForUpdate();
77
+ };
78
+
79
+ /**
80
+ * Waits for the close sequence to finish: the panel slides out, then the
81
+ * overlay hides and `closed` fires. Polled rather than timed, since the
82
+ * sequence only starts on the update after `open` changes.
83
+ */
84
+ const waitForClose = async (host: BottomSheetHost) => {
85
+ await vi.waitFor(() => expect(host.closed).toHaveBeenCalled());
86
+ await waitForUpdate();
87
+ };
88
+
89
+ /**
90
+ * The panel's live drag offset, from its inline transform.
91
+ */
92
+ const transformOf = (panel: Element) =>
93
+ (panel as HTMLElement).style.getPropertyValue('transform');
94
+
95
+ /**
96
+ * The touch fields a handle's touch event carries.
97
+ */
98
+ const touchAt = (clientY: number) => ({
99
+ eventType: 'catchEvent',
100
+ touches: [{ clientY }],
101
+ });
69
102
 
70
- it('renders the panel content when open', async () => {
71
- const { getByText } = await render(BottomSheetHost);
72
- expect(getByText('Panel')).toBeTruthy();
103
+ describe('UiBottomSheet', () => {
104
+ // Watched per test: the testing library resets the main thread after each.
105
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
106
+ beforeEach(() => {
107
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
73
108
  });
109
+ afterEach(() => animate.mockRestore());
74
110
 
75
- it('backdrop tap closes the sheet', async () => {
76
- const { componentRef } = await render(UiBottomSheet);
77
- const inst = componentRef.instance as UiBottomSheet;
78
- inst.open.set(true);
79
- await waitForUpdate();
80
- (inst as unknown as { onBackdropTap: () => void }).onBackdropTap();
81
- expect(inst.open()).toBe(false);
82
- });
111
+ it('stays hidden until opened, then shows the panel and its content', async () => {
112
+ const { host, overlay, getByText } = await renderSheet();
113
+ expect(overlay.getAttribute('style')).toContain('display: none');
114
+ expect(overlay.getAttribute('visible')).toBe('false');
83
115
 
84
- it('panel tap is a no-op', async () => {
85
- const { componentRef } = await render(UiBottomSheet);
86
- const inst = componentRef.instance as UiBottomSheet;
87
- expect(() =>
88
- (inst as unknown as { onPanelTap: () => void }).onPanelTap(),
89
- ).not.toThrow();
116
+ await openSheet(host);
117
+ expect(overlay.getAttribute('style')).not.toContain('display: none');
118
+ expect(overlay.getAttribute('visible')).toBe('true');
119
+ expect(getByText('Panel')).toBeTruthy();
90
120
  });
91
121
 
92
- it('open effect runs the animate-in and animate-out lifecycles', async () => {
93
- const { componentRef, container } = await render(UiBottomSheet);
94
- const inst = componentRef.instance as UiBottomSheet;
95
- const view = container.querySelector('view') as Element;
96
- patchRefs(inst, view);
97
-
98
- inst.open.set(true);
99
- await waitForUpdate();
100
- await waitForUpdate();
101
- await waitForUpdate();
102
-
103
- inst.open.set(false);
104
- await new Promise((r) => setTimeout(r, 320));
105
- await waitForUpdate();
106
- expect(inst.open()).toBe(false);
122
+ it("adds the class to the panel's own", async () => {
123
+ const { panel } = await renderSheet();
124
+ expect(panel.classList).toContain('max-h-96');
125
+ expect(panel.classList).toContain('bg-background');
107
126
  });
108
127
 
109
- it('open effect bails when backdrop/panel refs are undefined', async () => {
110
- const { componentRef } = await render(UiBottomSheet);
111
- const inst = componentRef.instance as UiBottomSheet;
112
- const closed = vi.fn();
113
- inst.closed.subscribe(closed);
114
- inst.open.set(true);
115
- await waitForUpdate();
116
- await waitForUpdate();
117
- inst.open.set(false);
118
- await waitForUpdate();
119
- // Wait out the close sequence, so #animateOut runs (and bails) while the
120
- // component is still alive. Destroying it clears the sequence's timers.
121
- await new Promise((r) => setTimeout(r, DURATION.normal + 50));
122
- expect(inst.open()).toBe(false);
123
- expect(closed).toHaveBeenCalledOnce();
128
+ it('fades the backdrop in and slides the panel up when opened', async () => {
129
+ const { host, backdrop, panel } = await renderSheet();
130
+ await openSheet(host);
131
+ expect(animate).toHaveBeenCalledWith(backdrop, [
132
+ START,
133
+ expect.any(String),
134
+ [{ opacity: 0 }, { opacity: 1 }],
135
+ expect.objectContaining({ duration: DURATION.normal }),
136
+ ]);
137
+ expect(animate).toHaveBeenCalledWith(panel, [
138
+ START,
139
+ expect.any(String),
140
+ [
141
+ { transform: 'translateY(100%)', opacity: 0.8 },
142
+ { transform: 'translateY(0%)', opacity: 1 },
143
+ ],
144
+ expect.objectContaining({ duration: DURATION.slow }),
145
+ ]);
124
146
  });
125
147
 
126
- it('drag beyond the dismiss threshold closes the sheet', async () => {
127
- const { componentRef, container } = await render(UiBottomSheet);
128
- const inst = componentRef.instance as UiBottomSheet;
129
- const view = container.querySelector('view') as Element;
130
- patchRefs(inst, view);
131
- const animate = vi.spyOn(
132
- view as Element & { animate: () => unknown },
133
- 'animate',
148
+ it('moves a screen reader into the panel and keeps it there while open', async () => {
149
+ const invoke = vi.spyOn(
150
+ globalThis as unknown as InvokeUIMethod,
151
+ '__InvokeUIMethod',
134
152
  );
135
- inst.open.set(true);
136
- await waitForUpdate();
153
+ const { host, panel } = await renderSheet();
154
+ expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('false');
137
155
 
138
- const cast = inst as unknown as {
139
- onHandleTouchStart: (e: { touches: { clientY: number }[] }) => void;
140
- onHandleTouchMove: (e: { touches: { clientY: number }[] }) => void;
141
- onDragEnd: () => void;
142
- };
143
- cast.onHandleTouchStart({ touches: [{ clientY: 100 }] });
144
- cast.onHandleTouchMove({ touches: [{ clientY: 300 }] });
145
- cast.onDragEnd();
146
- await waitForUpdate();
147
- expect(inst.open()).toBe(false);
148
- // The exit slide starts the next tick, from where the finger let go
149
- // rather than from the docked position.
150
- await new Promise((r) => setTimeout(r, 0));
151
- expect(
152
- animate.mock.calls.some(
153
- ([keyframes]) =>
154
- (keyframes as Keyframe[])[0]?.transform === 'translateY(200px)',
155
- ),
156
- ).toBe(true);
156
+ await openSheet(host);
157
+ expect(invoke).toHaveBeenCalledWith(
158
+ panel,
159
+ 'requestAccessibilityFocus',
160
+ expect.anything(),
161
+ expect.any(Function),
162
+ );
163
+ expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('true');
164
+ invoke.mockRestore();
157
165
  });
158
166
 
159
- it('drag below the dismiss threshold snaps back to open', async () => {
160
- const { componentRef, container } = await render(UiBottomSheet);
161
- const inst = componentRef.instance as UiBottomSheet;
162
- const view = container.querySelector('view') as Element;
163
- patchRefs(inst, view);
164
- inst.open.set(true);
165
- await waitForUpdate();
166
-
167
- const cast = inst as unknown as {
168
- onHandleTouchStart: (e: { touches: { clientY: number }[] }) => void;
169
- onHandleTouchMove: (e: { touches: { clientY: number }[] }) => void;
170
- onDragEnd: () => void;
171
- };
172
- cast.onHandleTouchStart({ touches: [{ clientY: 100 }] });
173
- cast.onHandleTouchMove({ touches: [{ clientY: 130 }] });
174
- cast.onDragEnd();
175
- await waitForUpdate();
176
- expect(inst.open()).toBe(true);
167
+ it('closes when the backdrop is tapped, sliding the panel down', async () => {
168
+ const { host, overlay, backdrop, panel } = await renderSheet();
169
+ await openSheet(host);
170
+
171
+ fireEvent.tap(backdrop);
172
+ expect(host.open()).toBe(false);
173
+ await waitForClose(host);
174
+ expect(animate).toHaveBeenCalledWith(backdrop, [
175
+ START,
176
+ expect.any(String),
177
+ [{ opacity: 1 }, { opacity: 0 }],
178
+ expect.objectContaining({ duration: DURATION.normal }),
179
+ ]);
180
+ expect(animate).toHaveBeenCalledWith(panel, [
181
+ START,
182
+ expect.any(String),
183
+ [
184
+ { transform: 'translateY(0%)', opacity: 1 },
185
+ { transform: 'translateY(100%)', opacity: 0.8 },
186
+ ],
187
+ expect.objectContaining({ duration: DURATION.normal }),
188
+ ]);
189
+ // Once the slide is over, the overlay hides and `closed` fires, only for
190
+ // this close: a sheet that starts closed hasn't closed before.
191
+ expect(overlay.getAttribute('style')).toContain('display: none');
192
+ expect(host.closed).toHaveBeenCalledOnce();
177
193
  });
178
194
 
179
- it('touch handlers guard against missing touches and non-active drags', async () => {
180
- const { componentRef } = await render(UiBottomSheet);
181
- const inst = componentRef.instance as UiBottomSheet;
182
- const cast = inst as unknown as {
183
- onHandleTouchStart: (e: { touches?: { clientY: number }[] }) => void;
184
- onHandleTouchMove: (e: { touches?: { clientY: number }[] }) => void;
185
- onDragEnd: () => void;
186
- };
187
- // touchmove without prior touchstart is a no-op (guards `#isDragging`).
188
- cast.onHandleTouchMove({ touches: [{ clientY: 200 }] });
189
- // touchstart / touchmove without touches entries is a no-op.
190
- cast.onHandleTouchStart({ touches: [] });
191
- cast.onHandleTouchStart({ touches: [{ clientY: 100 }] });
192
- cast.onHandleTouchMove({ touches: [] });
193
- // touchend without an active drag is also a no-op.
194
- cast.onDragEnd();
195
- cast.onDragEnd();
195
+ it('stays open when the panel is tapped', async () => {
196
+ const { host, panel } = await renderSheet();
197
+ await openSheet(host);
198
+ // The panel catches its taps, so they never reach the backdrop.
199
+ fireEvent.tap(panel, { eventType: 'catchEvent' });
200
+ expect(host.open()).toBe(true);
196
201
  });
197
202
 
198
- type MouseDrag = {
199
- onHandleMouseDown: (e: { y: number }) => void;
200
- onMouseDragMove: (e: { y: number; buttons: number }) => void;
201
- onDragEnd: () => void;
202
- onBackdropMouseDown: () => void;
203
- onBackdropTap: () => void;
204
- };
205
-
206
- it('mouse drag beyond the dismiss threshold closes the sheet', async () => {
207
- const { componentRef, container } = await render(UiBottomSheet);
208
- const inst = componentRef.instance as UiBottomSheet;
209
- patchRefs(inst, container.querySelector('view') as Element);
210
- inst.open.set(true);
211
- await waitForUpdate();
212
-
213
- const cast = inst as unknown as MouseDrag;
214
- cast.onHandleMouseDown({ y: 100 });
215
- cast.onMouseDragMove({ y: 300, buttons: 1 });
216
- cast.onDragEnd();
203
+ it('stops its close timers when it is destroyed while closing', async () => {
204
+ const warn = vi.spyOn(console, 'warn');
205
+ const { host, unmount } = await renderSheet();
206
+ await openSheet(host);
207
+ host.open.set(false);
217
208
  await waitForUpdate();
218
- expect(inst.open()).toBe(false);
209
+ // Destroyed during the close animation, as an `@if` around the sheet
210
+ // would do. Its close timer (DURATION.normal + 20) used to fire anyway
211
+ // and emit `closed` from the destroyed output.
212
+ unmount();
213
+ await new Promise((resolve) => setTimeout(resolve, DURATION.normal + 50));
214
+ const warnings = warn.mock.calls.map(([message]) => String(message));
215
+ warn.mockRestore();
216
+ expect(warnings.filter((message) => message.includes('NG0953'))).toEqual(
217
+ [],
218
+ );
219
+ expect(host.closed).not.toHaveBeenCalled();
219
220
  });
220
221
 
221
- it('mouse drag below the dismiss threshold snaps back to open', async () => {
222
- const { componentRef, container } = await render(UiBottomSheet);
223
- const inst = componentRef.instance as UiBottomSheet;
224
- patchRefs(inst, container.querySelector('view') as Element);
225
- inst.open.set(true);
226
- await waitForUpdate();
222
+ describe('dragged by its handle', () => {
223
+ it('follows the finger down, lightening the backdrop as it goes', async () => {
224
+ const { host, backdrop, panel, handle } = await renderSheet();
225
+ await openSheet(host);
226
+ animate.mockClear();
227
+
228
+ fireEvent.touchstart(handle, touchAt(100));
229
+ // The open slide holds the panel in place until it's canceled.
230
+ expect(animate).toHaveBeenCalledWith(panel, [CANCEL, expect.any(String)]);
231
+ fireEvent.touchmove(handle, touchAt(200));
232
+ await waitForUpdate();
233
+ expect(transformOf(panel)).toBe('translateY(100px)');
234
+ // 100px of the 500px over which the backdrop clears: 4/5 of its dim.
235
+ expect(backdrop.getAttribute('style')).toContain('rgba(0, 0, 0, 0.4)');
236
+ });
227
237
 
228
- const cast = inst as unknown as MouseDrag;
229
- cast.onHandleMouseDown({ y: 100 });
230
- cast.onMouseDragMove({ y: 130, buttons: 1 });
231
- cast.onDragEnd();
232
- await waitForUpdate();
233
- expect(inst.open()).toBe(true);
234
- });
238
+ it('closes when released past the threshold, sliding on from the finger', async () => {
239
+ const { host, panel, handle } = await renderSheet();
240
+ await openSheet(host);
241
+
242
+ fireEvent.touchstart(handle, touchAt(100));
243
+ fireEvent.touchmove(handle, touchAt(300));
244
+ fireEvent.touchend(handle, { eventType: 'catchEvent' });
245
+ expect(host.open()).toBe(false);
246
+ await waitForClose(host);
247
+ expect(animate).toHaveBeenCalledWith(panel, [
248
+ START,
249
+ expect.any(String),
250
+ [
251
+ { transform: 'translateY(200px)', opacity: 1 },
252
+ { transform: 'translateY(600px)', opacity: 0.8 },
253
+ ],
254
+ expect.objectContaining({ duration: DURATION.normal }),
255
+ ]);
256
+ expect(host.closed).toHaveBeenCalledOnce();
257
+ // Hidden, it's back in place for the next open.
258
+ expect(transformOf(panel)).toBe('translateY(0px)');
259
+ });
235
260
 
236
- it('ignores the backdrop click that follows a moved mouse drag', async () => {
237
- const { componentRef } = await render(UiBottomSheet);
238
- const inst = componentRef.instance as UiBottomSheet;
239
- inst.open.set(true);
240
- await waitForUpdate();
261
+ it('springs back when released or canceled short of the threshold', async () => {
262
+ const { host, panel, handle } = await renderSheet();
263
+ await openSheet(host);
264
+
265
+ fireEvent.touchstart(handle, touchAt(100));
266
+ fireEvent.touchmove(handle, touchAt(130));
267
+ fireEvent.touchend(handle, { eventType: 'catchEvent' });
268
+ await waitForUpdate();
269
+ expect(host.open()).toBe(true);
270
+ expect(animate).toHaveBeenCalledWith(panel, [
271
+ START,
272
+ expect.any(String),
273
+ [{ transform: 'translateY(30px)' }, { transform: 'translateY(0px)' }],
274
+ expect.objectContaining({ duration: DURATION.fast }),
275
+ ]);
276
+ expect(transformOf(panel)).toBe('translateY(0px)');
277
+
278
+ // A scroll that takes the touch over cancels it the same way.
279
+ fireEvent.touchstart(handle, touchAt(100));
280
+ fireEvent.touchmove(handle, touchAt(150));
281
+ fireEvent.touchcancel(handle, { eventType: 'catchEvent' });
282
+ await waitForUpdate();
283
+ expect(host.open()).toBe(true);
284
+ expect(transformOf(panel)).toBe('translateY(0px)');
285
+ });
286
+
287
+ it("doesn't move up past where it's docked", async () => {
288
+ const { host, panel, handle } = await renderSheet();
289
+ await openSheet(host);
290
+ fireEvent.touchstart(handle, touchAt(100));
291
+ fireEvent.touchmove(handle, touchAt(40));
292
+ await waitForUpdate();
293
+ expect(transformOf(panel)).toBe('translateY(0px)');
294
+ });
241
295
 
242
- const cast = inst as unknown as MouseDrag;
243
- // A drag released above the panel: the browser's click lands on the
244
- // backdrop, and must not close the sheet that just snapped back.
245
- cast.onHandleMouseDown({ y: 100 });
246
- cast.onMouseDragMove({ y: 40, buttons: 1 });
247
- cast.onDragEnd();
248
- cast.onBackdropTap();
249
- expect(inst.open()).toBe(true);
250
-
251
- // Only that one click is ignored: the next backdrop tap closes it.
252
- cast.onBackdropTap();
253
- expect(inst.open()).toBe(false);
296
+ it('ignores touches without a position, and moves outside a drag', async () => {
297
+ const { host, panel, handle } = await renderSheet();
298
+ await openSheet(host);
299
+
300
+ // A move or release with no drag under way does nothing.
301
+ fireEvent.touchmove(handle, touchAt(300));
302
+ fireEvent.touchend(handle, { eventType: 'catchEvent' });
303
+ // Nor does a touch whose position is missing.
304
+ fireEvent.touchstart(handle, { eventType: 'catchEvent' });
305
+ fireEvent.touchmove(handle, touchAt(300));
306
+ fireEvent.touchstart(handle, { eventType: 'catchEvent', touches: [] });
307
+ fireEvent.touchmove(handle, touchAt(300));
308
+ await waitForUpdate();
309
+ expect(transformOf(panel)).toBe('translateY(0px)');
310
+
311
+ fireEvent.touchstart(handle, touchAt(100));
312
+ fireEvent.touchmove(handle, { eventType: 'catchEvent', touches: [] });
313
+ await waitForUpdate();
314
+ expect(transformOf(panel)).toBe('translateY(0px)');
315
+ expect(host.open()).toBe(true);
316
+ });
254
317
  });
255
318
 
256
- it('a new press clears an ignore no click consumed', async () => {
257
- const { componentRef } = await render(UiBottomSheet);
258
- const inst = componentRef.instance as UiBottomSheet;
259
- inst.open.set(true);
260
- await waitForUpdate();
319
+ describe('dragged by mouse', () => {
320
+ /**
321
+ * Presses the handle, which catches its own mousedown.
322
+ */
323
+ const press = (handle: Element, y: number) =>
324
+ fireEvent.mousedown(handle, { eventType: 'catchEvent', y });
325
+ /**
326
+ * Moves the mouse, which the backdrop sees anywhere in the sheet.
327
+ */
328
+ const move = (backdrop: Element, y: number, buttons = 1) =>
329
+ fireEvent.mousemove(backdrop, { y, buttons });
330
+
331
+ it('closes when released past the threshold', async () => {
332
+ const { host, backdrop, panel, handle } = await renderSheet();
333
+ await openSheet(host);
334
+
335
+ press(handle, 100);
336
+ move(backdrop, 300);
337
+ await waitForUpdate();
338
+ expect(transformOf(panel)).toBe('translateY(200px)');
339
+ fireEvent.mouseup(backdrop);
340
+ expect(host.open()).toBe(false);
341
+ });
261
342
 
262
- const cast = inst as unknown as MouseDrag;
263
- // Released over the panel, so its catchtap ate the click.
264
- cast.onHandleMouseDown({ y: 100 });
265
- cast.onMouseDragMove({ y: 120, buttons: 1 });
266
- cast.onDragEnd();
267
- // A real backdrop click starts with a mousedown on the backdrop.
268
- cast.onBackdropMouseDown();
269
- cast.onBackdropTap();
270
- expect(inst.open()).toBe(false);
271
- });
343
+ it('springs back when released short of the threshold', async () => {
344
+ const { host, backdrop, panel, handle } = await renderSheet();
345
+ await openSheet(host);
272
346
 
273
- it('a mouse press without a move, or a touch drag, leaves the backdrop tap alone', async () => {
274
- const { componentRef } = await render(UiBottomSheet);
275
- const inst = componentRef.instance as UiBottomSheet;
276
- inst.open.set(true);
277
- await waitForUpdate();
347
+ press(handle, 100);
348
+ move(backdrop, 130);
349
+ fireEvent.mouseup(backdrop);
350
+ await waitForUpdate();
351
+ expect(host.open()).toBe(true);
352
+ expect(transformOf(panel)).toBe('translateY(0px)');
353
+ });
278
354
 
279
- const cast = inst as unknown as MouseDrag & {
280
- onHandleTouchStart: (e: { touches: { clientY: number }[] }) => void;
281
- onHandleTouchMove: (e: { touches: { clientY: number }[] }) => void;
282
- };
283
- cast.onHandleMouseDown({ y: 100 });
284
- cast.onDragEnd();
285
- // A moved touch is never followed by a tap, so nothing is ignored.
286
- cast.onHandleTouchStart({ touches: [{ clientY: 100 }] });
287
- cast.onHandleTouchMove({ touches: [{ clientY: 120 }] });
288
- cast.onDragEnd();
289
- cast.onBackdropTap();
290
- expect(inst.open()).toBe(false);
291
- });
355
+ it('ignores the backdrop click that follows a moved drag', async () => {
356
+ const { host, backdrop, handle } = await renderSheet();
357
+ await openSheet(host);
358
+
359
+ // A drag released above the panel: the browser's click lands on the
360
+ // backdrop, and must not close the sheet that just snapped back.
361
+ press(handle, 100);
362
+ move(backdrop, 40);
363
+ fireEvent.mouseup(backdrop);
364
+ fireEvent.tap(backdrop);
365
+ expect(host.open()).toBe(true);
366
+
367
+ // Only that one click is ignored: the next backdrop tap closes it.
368
+ fireEvent.tap(backdrop);
369
+ expect(host.open()).toBe(false);
370
+ });
292
371
 
293
- it('ends a mouse drag whose release it never saw', async () => {
294
- const { componentRef, container } = await render(UiBottomSheet);
295
- const inst = componentRef.instance as UiBottomSheet;
296
- patchRefs(inst, container.querySelector('view') as Element);
297
- inst.open.set(true);
298
- await waitForUpdate();
372
+ it('a new press clears an ignore no click consumed', async () => {
373
+ const { host, backdrop, handle } = await renderSheet();
374
+ await openSheet(host);
375
+
376
+ // Released over the panel, so its catchtap ate the click.
377
+ press(handle, 100);
378
+ move(backdrop, 120);
379
+ fireEvent.mouseup(backdrop);
380
+ // A real backdrop click starts with a mousedown on the backdrop.
381
+ fireEvent.mousedown(backdrop);
382
+ fireEvent.tap(backdrop);
383
+ expect(host.open()).toBe(false);
384
+ });
299
385
 
300
- const cast = inst as unknown as MouseDrag;
301
- cast.onHandleMouseDown({ y: 100 });
302
- cast.onMouseDragMove({ y: 300, buttons: 1 });
303
- // The button came up outside the window; the cursor is back without it.
304
- cast.onMouseDragMove({ y: 120, buttons: 0 });
305
- await waitForUpdate();
306
- // Released 200px down, past the threshold, so it closes.
307
- expect(inst.open()).toBe(false);
308
- });
386
+ it('a press without a move, or a touch drag, leaves the backdrop tap alone', async () => {
387
+ const { host, backdrop, handle } = await renderSheet();
388
+ await openSheet(host);
389
+
390
+ press(handle, 100);
391
+ fireEvent.mouseup(backdrop);
392
+ // A moved touch is never followed by a tap, so nothing is ignored.
393
+ fireEvent.touchstart(handle, touchAt(100));
394
+ fireEvent.touchmove(handle, touchAt(120));
395
+ fireEvent.touchend(handle, { eventType: 'catchEvent' });
396
+ fireEvent.tap(backdrop);
397
+ expect(host.open()).toBe(false);
398
+ });
309
399
 
310
- it('a mouse move outside a drag leaves the sheet in place', async () => {
311
- const { componentRef } = await render(UiBottomSheet);
312
- const inst = componentRef.instance as UiBottomSheet;
313
- // The cursor moving over an open sheet with no button pressed.
314
- (inst as unknown as MouseDrag).onMouseDragMove({ y: 300, buttons: 1 });
315
- expect((inst as unknown as { dragOffset: () => number }).dragOffset()).toBe(
316
- 0,
317
- );
318
- });
400
+ it('ends a drag whose release it never saw', async () => {
401
+ const { host, backdrop, handle } = await renderSheet();
402
+ await openSheet(host);
319
403
 
320
- it('applies a user class on the panel', async () => {
321
- const { componentRef, container } = await render(UiBottomSheet);
322
- const inst = componentRef.instance as UiBottomSheet;
323
- setInputSignal(inst.userClass, 'custom-panel');
324
- await waitForUpdate();
325
- expect(
326
- Array.from(container.querySelectorAll('view')).some((el) =>
327
- el.getAttribute('class')?.includes('custom-panel'),
328
- ),
329
- ).toBe(true);
330
- });
404
+ press(handle, 100);
405
+ move(backdrop, 300);
406
+ // The button came up outside the window; the cursor is back without it.
407
+ move(backdrop, 120, 0);
408
+ // Released 200px down, past the threshold, so it closes.
409
+ expect(host.open()).toBe(false);
410
+ });
331
411
 
332
- it('overlayStyle switches between visible and hidden variants', async () => {
333
- const { componentRef, container } = await render(UiBottomSheet);
334
- const inst = componentRef.instance as UiBottomSheet;
335
- let overlay = container.querySelector('overlay');
336
- expect(overlay?.getAttribute('style')).toContain('display: none');
337
-
338
- const view = container.querySelector('view') as Element;
339
- patchRefs(inst, view);
340
- inst.open.set(true);
341
- for (let i = 0; i < 10; i++) await waitForUpdate();
342
- overlay = container.querySelector('overlay');
343
- expect(overlay?.getAttribute('style')).not.toContain('display: none');
412
+ it('a move outside a drag leaves the sheet in place', async () => {
413
+ const { host, backdrop, panel } = await renderSheet();
414
+ await openSheet(host);
415
+ // The cursor passing over an open sheet with a button held elsewhere.
416
+ move(backdrop, 300);
417
+ await waitForUpdate();
418
+ expect(transformOf(panel)).toBe('translateY(0px)');
419
+ });
344
420
  });
345
421
  });