@blotch/dolan 22.2.2 → 22.2.3

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 (154) hide show
  1. package/dist/commands/add.d.ts +6 -2
  2. package/dist/commands/add.js +32 -10
  3. package/dist/commands/add.js.map +1 -1
  4. package/dist/commands/add.test.js +129 -9
  5. package/dist/commands/add.test.js.map +1 -1
  6. package/dist/commands/diff.d.ts +2 -1
  7. package/dist/commands/diff.js +38 -25
  8. package/dist/commands/diff.js.map +1 -1
  9. package/dist/commands/diff.test.js +72 -11
  10. package/dist/commands/diff.test.js.map +1 -1
  11. package/dist/commands/doctor.js +41 -27
  12. package/dist/commands/doctor.js.map +1 -1
  13. package/dist/commands/doctor.test.js +93 -9
  14. package/dist/commands/doctor.test.js.map +1 -1
  15. package/dist/commands/eject.js +7 -0
  16. package/dist/commands/eject.js.map +1 -1
  17. package/dist/commands/eject.test.js +39 -0
  18. package/dist/commands/eject.test.js.map +1 -1
  19. package/dist/commands/info.js +3 -2
  20. package/dist/commands/info.js.map +1 -1
  21. package/dist/commands/init.d.ts +37 -1
  22. package/dist/commands/init.js +149 -40
  23. package/dist/commands/init.js.map +1 -1
  24. package/dist/commands/init.test.js +285 -26
  25. package/dist/commands/init.test.js.map +1 -1
  26. package/dist/commands/install-packages.d.ts +6 -0
  27. package/dist/commands/install-packages.js +20 -0
  28. package/dist/commands/install-packages.js.map +1 -0
  29. package/dist/commands/install-packages.test.d.ts +1 -0
  30. package/dist/commands/install-packages.test.js +55 -0
  31. package/dist/commands/install-packages.test.js.map +1 -0
  32. package/dist/commands/list.js +21 -14
  33. package/dist/commands/list.js.map +1 -1
  34. package/dist/commands/list.test.js +62 -5
  35. package/dist/commands/list.test.js.map +1 -1
  36. package/dist/commands/outdated.js +11 -10
  37. package/dist/commands/outdated.js.map +1 -1
  38. package/dist/commands/outdated.test.js +20 -5
  39. package/dist/commands/outdated.test.js.map +1 -1
  40. package/dist/commands/remove.d.ts +4 -0
  41. package/dist/commands/remove.js +17 -4
  42. package/dist/commands/remove.js.map +1 -1
  43. package/dist/commands/remove.test.js +53 -2
  44. package/dist/commands/remove.test.js.map +1 -1
  45. package/dist/commands/update.d.ts +6 -0
  46. package/dist/commands/update.js +59 -24
  47. package/dist/commands/update.js.map +1 -1
  48. package/dist/commands/update.test.js +273 -31
  49. package/dist/commands/update.test.js.map +1 -1
  50. package/dist/index.js +7 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/registry.d.ts +8 -0
  53. package/dist/registry.js +32 -24
  54. package/dist/registry.js.map +1 -1
  55. package/dist/registry.test.js +76 -37
  56. package/dist/registry.test.js.map +1 -1
  57. package/dist/testing/set-input-signal.d.ts +13 -0
  58. package/dist/testing/set-input-signal.js +20 -0
  59. package/dist/testing/set-input-signal.js.map +1 -0
  60. package/dist/testing/set-input-signal.test.d.ts +1 -0
  61. package/dist/testing/set-input-signal.test.js +20 -0
  62. package/dist/testing/set-input-signal.test.js.map +1 -0
  63. package/dist/ui/components/action-sheet/action-sheet.test.ts +48 -1
  64. package/dist/ui/components/action-sheet/action-sheet.ts +37 -9
  65. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +49 -2
  66. package/dist/ui/components/alert-dialog/alert-dialog.ts +43 -12
  67. package/dist/ui/components/area-chart/area-chart-render.test.ts +52 -110
  68. package/dist/ui/components/area-chart/area-chart.test.ts +58 -123
  69. package/dist/ui/components/area-chart/area-chart.ts +100 -315
  70. package/dist/ui/components/area-chart/index.ts +2 -3
  71. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +47 -163
  72. package/dist/ui/components/bar-chart/bar-chart.test.ts +83 -37
  73. package/dist/ui/components/bar-chart/bar-chart.ts +140 -294
  74. package/dist/ui/components/bar-chart/index.ts +3 -3
  75. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +191 -9
  76. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +136 -20
  77. package/dist/ui/components/button/button-render.test.ts +19 -0
  78. package/dist/ui/components/button/button.ts +18 -2
  79. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +48 -163
  80. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +106 -62
  81. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +150 -323
  82. package/dist/ui/components/candlestick-chart/index.ts +3 -4
  83. package/dist/ui/components/chart/chart-focus.test.ts +132 -0
  84. package/dist/ui/components/chart/chart-focus.ts +115 -0
  85. package/dist/ui/components/chart/chart-presets.test.ts +139 -0
  86. package/dist/ui/components/chart/chart-presets.ts +271 -0
  87. package/dist/ui/components/chart/chart-render.test.ts +730 -0
  88. package/dist/ui/components/chart/chart-scene.test.ts +589 -0
  89. package/dist/ui/components/chart/chart-scene.ts +483 -0
  90. package/dist/ui/components/chart/chart-theme.test.ts +85 -0
  91. package/dist/ui/components/chart/chart-theme.ts +198 -0
  92. package/dist/ui/components/chart/chart-zoom.test.ts +159 -0
  93. package/dist/ui/components/chart/chart-zoom.ts +162 -0
  94. package/dist/ui/components/chart/chart.ts +921 -0
  95. package/dist/ui/components/chart/index.ts +24 -0
  96. package/dist/ui/components/dialog/dialog.test.ts +49 -2
  97. package/dist/ui/components/dialog/dialog.ts +43 -12
  98. package/dist/ui/components/icon/icon-theme.test.ts +133 -0
  99. package/dist/ui/components/icon/icon-theme.ts +204 -0
  100. package/dist/ui/components/icon/icon.test.ts +51 -2
  101. package/dist/ui/components/icon/icon.ts +38 -21
  102. package/dist/ui/components/line-chart/index.ts +2 -3
  103. package/dist/ui/components/line-chart/line-chart-render.test.ts +69 -107
  104. package/dist/ui/components/line-chart/line-chart.test.ts +63 -45
  105. package/dist/ui/components/line-chart/line-chart.ts +83 -241
  106. package/dist/ui/components/list/list.test.ts +69 -2
  107. package/dist/ui/components/list/list.ts +21 -15
  108. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +80 -1
  109. package/dist/ui/components/nav-drawer/nav-drawer.ts +66 -11
  110. package/dist/ui/components/pagination/pagination.test.ts +62 -0
  111. package/dist/ui/components/pagination/pagination.ts +55 -8
  112. package/dist/ui/components/scatter-chart/index.ts +2 -3
  113. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +51 -144
  114. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +57 -25
  115. package/dist/ui/components/scatter-chart/scatter-chart.ts +104 -250
  116. package/dist/ui/components/scroll-area/scroll-area.test.ts +37 -2
  117. package/dist/ui/components/scroll-area/scroll-area.ts +8 -8
  118. package/dist/ui/components/spinner/spinner.test.ts +27 -0
  119. package/dist/ui/components/spinner/spinner.ts +33 -23
  120. package/dist/ui/components/toast/toast.test.ts +37 -0
  121. package/dist/ui/components/toast/toast.ts +20 -4
  122. package/dist/ui/theme/chart-colors.test.ts +30 -0
  123. package/dist/ui/theme/dark.css +8 -0
  124. package/dist/ui/theme/default.css +8 -0
  125. package/dist/ui/theme/icon-colors.test.ts +30 -0
  126. package/dist/ui/theme/tailwind-plugin.ts +10 -1
  127. package/dist/utils/installed.d.ts +17 -0
  128. package/dist/utils/installed.js +27 -0
  129. package/dist/utils/installed.js.map +1 -0
  130. package/dist/utils/installed.test.d.ts +1 -0
  131. package/dist/utils/installed.test.js +41 -0
  132. package/dist/utils/installed.test.js.map +1 -0
  133. package/dist/utils/interactive.d.ts +9 -0
  134. package/dist/utils/interactive.js +10 -0
  135. package/dist/utils/interactive.js.map +1 -0
  136. package/dist/utils/interactive.test.d.ts +1 -0
  137. package/dist/utils/interactive.test.js +24 -0
  138. package/dist/utils/interactive.test.js.map +1 -0
  139. package/dist/utils/packages.d.ts +36 -0
  140. package/dist/utils/packages.js +70 -0
  141. package/dist/utils/packages.js.map +1 -0
  142. package/dist/utils/packages.test.d.ts +1 -0
  143. package/dist/utils/packages.test.js +89 -0
  144. package/dist/utils/packages.test.js.map +1 -0
  145. package/dist/utils/resolve-paths.d.ts +17 -1
  146. package/dist/utils/resolve-paths.js +23 -3
  147. package/dist/utils/resolve-paths.js.map +1 -1
  148. package/dist/utils/resolve-paths.test.js +34 -2
  149. package/dist/utils/resolve-paths.test.js.map +1 -1
  150. package/package.json +5 -1
  151. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +0 -188
  152. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +0 -525
  153. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +0 -1140
  154. package/dist/ui/components/cartesian-chart/index.ts +0 -18
@@ -1,12 +1,12 @@
1
1
  import { Component, signal } from '@angular/core';
2
2
  import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
3
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
- import { describe, expect, it } from 'vitest';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { DURATION } from '../../utils/animate';
5
6
  import { setInputSignal } from '../../../test-utils/set-input-signal';
6
7
  import { UiBottomSheet } from './bottom-sheet';
7
8
 
8
9
  @Component({
9
- standalone: true,
10
10
  imports: [UiBottomSheet, LYNX_ELEMENTS],
11
11
  template: `<ui-bottom-sheet [(open)]="open"
12
12
  ><text>Panel</text></ui-bottom-sheet
@@ -17,6 +17,47 @@ class BottomSheetHost {
17
17
  }
18
18
 
19
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
+
20
61
  const patchRefs = (inst: UiBottomSheet, view: Element) => {
21
62
  (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
22
63
  nativeElement: view,
@@ -68,12 +109,18 @@ describe('UiBottomSheet', () => {
68
109
  it('open effect bails when backdrop/panel refs are undefined', async () => {
69
110
  const { componentRef } = await render(UiBottomSheet);
70
111
  const inst = componentRef.instance as UiBottomSheet;
112
+ const closed = vi.fn();
113
+ inst.closed.subscribe(closed);
71
114
  inst.open.set(true);
72
115
  await waitForUpdate();
73
116
  await waitForUpdate();
74
117
  inst.open.set(false);
75
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));
76
122
  expect(inst.open()).toBe(false);
123
+ expect(closed).toHaveBeenCalledOnce();
77
124
  });
78
125
 
79
126
  it('drag beyond the dismiss threshold closes the sheet', async () => {
@@ -81,19 +128,32 @@ describe('UiBottomSheet', () => {
81
128
  const inst = componentRef.instance as UiBottomSheet;
82
129
  const view = container.querySelector('view') as Element;
83
130
  patchRefs(inst, view);
131
+ const animate = vi.spyOn(
132
+ view as Element & { animate: () => unknown },
133
+ 'animate',
134
+ );
84
135
  inst.open.set(true);
85
136
  await waitForUpdate();
86
137
 
87
138
  const cast = inst as unknown as {
88
139
  onHandleTouchStart: (e: { touches: { clientY: number }[] }) => void;
89
140
  onHandleTouchMove: (e: { touches: { clientY: number }[] }) => void;
90
- onHandleTouchEnd: () => void;
141
+ onDragEnd: () => void;
91
142
  };
92
143
  cast.onHandleTouchStart({ touches: [{ clientY: 100 }] });
93
144
  cast.onHandleTouchMove({ touches: [{ clientY: 300 }] });
94
- cast.onHandleTouchEnd();
145
+ cast.onDragEnd();
95
146
  await waitForUpdate();
96
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);
97
157
  });
98
158
 
99
159
  it('drag below the dismiss threshold snaps back to open', async () => {
@@ -107,11 +167,11 @@ describe('UiBottomSheet', () => {
107
167
  const cast = inst as unknown as {
108
168
  onHandleTouchStart: (e: { touches: { clientY: number }[] }) => void;
109
169
  onHandleTouchMove: (e: { touches: { clientY: number }[] }) => void;
110
- onHandleTouchEnd: () => void;
170
+ onDragEnd: () => void;
111
171
  };
112
172
  cast.onHandleTouchStart({ touches: [{ clientY: 100 }] });
113
173
  cast.onHandleTouchMove({ touches: [{ clientY: 130 }] });
114
- cast.onHandleTouchEnd();
174
+ cast.onDragEnd();
115
175
  await waitForUpdate();
116
176
  expect(inst.open()).toBe(true);
117
177
  });
@@ -122,7 +182,7 @@ describe('UiBottomSheet', () => {
122
182
  const cast = inst as unknown as {
123
183
  onHandleTouchStart: (e: { touches?: { clientY: number }[] }) => void;
124
184
  onHandleTouchMove: (e: { touches?: { clientY: number }[] }) => void;
125
- onHandleTouchEnd: () => void;
185
+ onDragEnd: () => void;
126
186
  };
127
187
  // touchmove without prior touchstart is a no-op (guards `#isDragging`).
128
188
  cast.onHandleTouchMove({ touches: [{ clientY: 200 }] });
@@ -131,8 +191,130 @@ describe('UiBottomSheet', () => {
131
191
  cast.onHandleTouchStart({ touches: [{ clientY: 100 }] });
132
192
  cast.onHandleTouchMove({ touches: [] });
133
193
  // touchend without an active drag is also a no-op.
134
- cast.onHandleTouchEnd();
135
- cast.onHandleTouchEnd();
194
+ cast.onDragEnd();
195
+ cast.onDragEnd();
196
+ });
197
+
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();
217
+ await waitForUpdate();
218
+ expect(inst.open()).toBe(false);
219
+ });
220
+
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();
227
+
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
+ });
235
+
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();
241
+
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);
254
+ });
255
+
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();
261
+
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
+ });
272
+
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();
278
+
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
+ });
292
+
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();
299
+
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
+ });
309
+
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
+ );
136
318
  });
137
319
 
138
320
  it('applies a user class on the panel', async () => {
@@ -1,6 +1,7 @@
1
1
  // cspell:words WAAPI tweens
2
2
  import {
3
3
  type ElementRef,
4
+ type OnDestroy,
4
5
  Component,
5
6
  ViewEncapsulation,
6
7
  computed,
@@ -59,29 +60,44 @@ const BACKDROP_CLEAR_DISTANCE = 500;
59
60
  */
60
61
  @Component({
61
62
  selector: 'ui-bottom-sheet',
62
- standalone: true,
63
63
  imports: [LYNX_ELEMENTS],
64
64
  encapsulation: ViewEncapsulation.None,
65
65
  template: `
66
66
  <overlay [attr.visible]="overlayVisible()" [style]="overlayStyle()">
67
+ <!--
68
+ The mouse drag's move/up listeners sit on the full-screen backdrop,
69
+ not the handle: a touch keeps targeting the element it started on, but
70
+ a mouse event targets whatever is under the cursor, so a cursor that
71
+ outruns the thin handle strip (or drags up off the panel) would stop
72
+ driving the drag. Mouse events bubble here from anywhere in the sheet,
73
+ including the handle and the panel. There's no mouseleave: like a
74
+ touch, the drag carries on until release, and Chrome doesn't deliver
75
+ mouseleave to Lynx web anyway (see onMouseDragMove for a release
76
+ outside the view).
77
+ -->
67
78
  <view
68
79
  #backdrop
69
80
  class="h-full w-full"
70
81
  [style]="backdropStyle()"
71
82
  (bindtap)="onBackdropTap()"
83
+ (bindmousedown)="onBackdropMouseDown()"
84
+ (bindmousemove)="onMouseDragMove($event)"
85
+ (bindmouseup)="onDragEnd()"
72
86
  >
73
87
  <view
74
88
  #panel
75
89
  [class]="panelClass()"
76
90
  [style]="panelPositionStyle()"
91
+ [accessibility-exclusive-focus]="overlayVisible()"
77
92
  (catchtap)="onPanelTap()"
78
93
  >
79
94
  <view
80
95
  class="items-center pb-3 pt-2 flex justify-center"
81
96
  (catchtouchstart)="onHandleTouchStart($event)"
82
97
  (catchtouchmove)="onHandleTouchMove($event)"
83
- (catchtouchend)="onHandleTouchEnd()"
84
- (catchtouchcancel)="onHandleTouchEnd()"
98
+ (catchtouchend)="onDragEnd()"
99
+ (catchtouchcancel)="onDragEnd()"
100
+ (catchmousedown)="onHandleMouseDown($event)"
85
101
  >
86
102
  <view class="h-1 w-10 rounded-full bg-muted" />
87
103
  </view>
@@ -91,7 +107,7 @@ const BACKDROP_CLEAR_DISTANCE = 500;
91
107
  </overlay>
92
108
  `,
93
109
  })
94
- export class UiBottomSheet {
110
+ export class UiBottomSheet implements OnDestroy {
95
111
  readonly open = model(false);
96
112
  readonly userClass = input<string>('', { alias: 'class' });
97
113
 
@@ -115,6 +131,9 @@ export class UiBottomSheet {
115
131
  #backdropAnim?: AnimationHandle;
116
132
  #panelAnim?: AnimationHandle;
117
133
  #hasBeenOpen = false;
134
+ // The open and close sequences' pending timers, so ngOnDestroy can stop
135
+ // them.
136
+ readonly #timers = new Set<ReturnType<typeof setTimeout>>();
118
137
 
119
138
  // --- Drag-to-dismiss state ---
120
139
  // Live downward drag distance (px), applied to the panel as a translateY via
@@ -125,6 +144,15 @@ export class UiBottomSheet {
125
144
  // True only between touchstart and touchend on the handle, so unrelated move
126
145
  // events can't shift the sheet.
127
146
  #isDragging = false;
147
+ // Set when a mouse drag that actually moved is released. The browser follows
148
+ // that mouseup with a click on the nearest element under both the mousedown
149
+ // and the mouseup, which is the backdrop whenever the cursor ended up off the
150
+ // panel, and Lynx web maps that click to the backdrop's tap. Without this, a
151
+ // drag released above the panel (where the sheet snaps back) would close it.
152
+ #ignoreNextBackdropTap = false;
153
+ // Whether the current drag is a mouse drag that has moved: only those are
154
+ // followed by a click. A moved touch produces no tap.
155
+ #mouseDragMoved = false;
128
156
 
129
157
  constructor() {
130
158
  // `#hasBeenOpen` prevents the close animation from running on the initial
@@ -184,9 +212,24 @@ export class UiBottomSheet {
184
212
  });
185
213
 
186
214
  protected onBackdropTap(): void {
215
+ if (this.#ignoreNextBackdropTap) {
216
+ this.#ignoreNextBackdropTap = false;
217
+ return;
218
+ }
187
219
  this.open.set(false);
188
220
  }
189
221
 
222
+ /**
223
+ * A fresh press starts a fresh click, so drop any ignore left over from a
224
+ * drag whose release didn't produce a backdrop click (one released over the
225
+ * panel, whose `catchtap` swallows it). Otherwise the next real backdrop tap
226
+ * would be ignored. The handle catches its own mousedown, which is fine: it
227
+ * starts a new drag that resets the flag on release.
228
+ */
229
+ protected onBackdropMouseDown(): void {
230
+ this.#ignoreNextBackdropTap = false;
231
+ }
232
+
190
233
  /**
191
234
  * No-op tap handler for the panel. `catchtap` (vs `bindtap`) already stops
192
235
  * the tap from bubbling to the backdrop — Lynx controls propagation via the
@@ -196,19 +239,14 @@ export class UiBottomSheet {
196
239
  protected onPanelTap(): void {}
197
240
 
198
241
  /**
199
- * Begin a drag on the handle strip. The open slideIn holds the panel at
200
- * translateY(0%) via fill:'forwards', which would override our inline
201
- * transform — cancel it so `dragOffset` (applied through panelPositionStyle)
202
- * actually moves the panel from here on.
242
+ * Begin a drag on the handle strip.
203
243
  */
204
244
  protected onHandleTouchStart(e: {
205
245
  touches?: ReadonlyArray<{ clientY: number }>;
206
246
  }): void {
207
247
  const y = e.touches?.[0]?.clientY;
208
248
  if (y == null) return;
209
- this.#panelAnim?.cancel();
210
- this.#isDragging = true;
211
- this.#dragStartY = y;
249
+ this.#startDrag(y);
212
250
  }
213
251
 
214
252
  protected onHandleTouchMove(e: {
@@ -217,20 +255,53 @@ export class UiBottomSheet {
217
255
  if (!this.#isDragging) return;
218
256
  const y = e.touches?.[0]?.clientY;
219
257
  if (y == null) return;
220
- // Clamp to >= 0: the sheet is already docked at the bottom, so only a
221
- // downward (dismiss-direction) drag should move it.
222
- this.dragOffset.set(Math.max(0, y - this.#dragStartY));
258
+ this.#moveDrag(y);
259
+ }
260
+
261
+ /**
262
+ * The mouse twin of onHandleTouchStart, so the handle drags on web. A
263
+ * desktop browser fires no touch events for a mouse, and Lynx web passes
264
+ * DOM events straight through rather than turning mouse input into touches.
265
+ * On a touch-only device no mouse events fire, so touch keeps driving it.
266
+ *
267
+ * Reads `y`, not `clientY`: Lynx PC reports a mouse event's clientY relative
268
+ * to the element under the cursor, which changes as the cursor moves across
269
+ * the sheet, while `y` is relative to the Lynx view on PC and web alike.
270
+ */
271
+ protected onHandleMouseDown(e: { y: number }): void {
272
+ this.#startDrag(e.y);
273
+ }
274
+
275
+ /**
276
+ * Bound on the backdrop (see the template), so it also sees every move the
277
+ * cursor makes over an open sheet. Only those during a drag count.
278
+ */
279
+ protected onMouseDragMove(e: { y: number; buttons: number }): void {
280
+ if (!this.#isDragging) return;
281
+ // No button held means the mouseup happened where the sheet couldn't see
282
+ // it: a release outside the Lynx view (past the edge of a docs preview, or
283
+ // outside the browser window) goes to the page, and web-core only listens
284
+ // inside the view. Treat the first move back as the release, or the panel
285
+ // would stay mid-drag.
286
+ if (e.buttons === 0) {
287
+ this.onDragEnd();
288
+ return;
289
+ }
290
+ this.#mouseDragMoved = true;
291
+ this.#moveDrag(e.y);
223
292
  }
224
293
 
225
294
  /**
226
- * Release the drag. Past the threshold we set `open` to false and let the
295
+ * Release the drag, from a touchend, touchcancel or mouseup, or a mouse
296
+ * move with no button held. Past the threshold we set `open` to false and let the
227
297
  * effect drive teardown (#doClose → drag-aware #animateOut), which keeps the
228
298
  * two-way `open` binding in sync and reuses the `closed` emit. Otherwise the
229
299
  * sheet springs back to fully open.
230
300
  */
231
- protected onHandleTouchEnd(): void {
301
+ protected onDragEnd(): void {
232
302
  if (!this.#isDragging) return;
233
303
  this.#isDragging = false;
304
+ this.#ignoreNextBackdropTap = this.#mouseDragMoved;
234
305
  if (this.dragOffset() > DISMISS_THRESHOLD) {
235
306
  this.open.set(false);
236
307
  } else {
@@ -238,15 +309,55 @@ export class UiBottomSheet {
238
309
  }
239
310
  }
240
311
 
312
+ /**
313
+ * The open slideIn holds the panel at translateY(0%) via fill:'forwards',
314
+ * which would override our inline transform — cancel it so `dragOffset`
315
+ * (applied through panelPositionStyle) actually moves the panel from here on.
316
+ */
317
+ #startDrag(y: number): void {
318
+ this.#panelAnim?.cancel();
319
+ this.#isDragging = true;
320
+ this.#mouseDragMoved = false;
321
+ this.#dragStartY = y;
322
+ }
323
+
324
+ #moveDrag(y: number): void {
325
+ // Clamp to >= 0: the sheet is already docked at the bottom, so only a
326
+ // downward (dismiss-direction) drag should move it.
327
+ this.dragOffset.set(Math.max(0, y - this.#dragStartY));
328
+ }
329
+
330
+ /**
331
+ * The sheet can be destroyed while its open or close sequence is still
332
+ * running, for example by an `@if` around it or a route change right after
333
+ * closing it. The rest of the sequence then ran on the destroyed component:
334
+ * it animated detached elements and emitted `closed` from a destroyed
335
+ * output, which Angular warns about in dev builds (NG0953).
336
+ */
337
+ ngOnDestroy(): void {
338
+ for (const timer of this.#timers) clearTimeout(timer);
339
+ }
340
+
341
+ /**
342
+ * `setTimeout`, tracked so that ngOnDestroy can cancel it.
343
+ */
344
+ #later(callback: () => void, ms: number): void {
345
+ const timer = setTimeout(() => {
346
+ this.#timers.delete(timer);
347
+ callback();
348
+ }, ms);
349
+ this.#timers.add(timer);
350
+ }
351
+
241
352
  /**
242
353
  * Two-phase open: make the overlay visible first so native elements exist
243
354
  * in the tree, then animate on the next frame. Without this, animate()
244
355
  * targets elements that haven't been flushed to native yet and silently fails.
245
356
  */
246
357
  #doOpen(): void {
247
- setTimeout(() => {
358
+ this.#later(() => {
248
359
  this.overlayVisible.set(true);
249
- setTimeout(() => this.#animateIn(), 0);
360
+ this.#later(() => this.#animateIn(), 0);
250
361
  }, 0);
251
362
  }
252
363
 
@@ -257,9 +368,9 @@ export class UiBottomSheet {
257
368
  * before the fade finishes.
258
369
  */
259
370
  #doClose(): void {
260
- setTimeout(() => {
371
+ this.#later(() => {
261
372
  this.#animateOut();
262
- setTimeout(() => {
373
+ this.#later(() => {
263
374
  this.overlayVisible.set(false);
264
375
  this.closed.emit();
265
376
  // Reset for the next open: the sheet returns to its docked position
@@ -278,6 +389,11 @@ export class UiBottomSheet {
278
389
  this.#panelAnim?.cancel();
279
390
 
280
391
  this.#backdropAnim = fadeIn(backdrop, { duration: DURATION.normal });
392
+ // Move a screen reader into the panel. It would otherwise stay on whatever
393
+ // opened it, which is now behind the overlay. With the panel's
394
+ // accessibility-exclusive-focus (see the template), it then can't leave
395
+ // the panel until it closes.
396
+ panel.invoke?.('requestAccessibilityFocus');
281
397
  this.#panelAnim = slideIn(panel, 'up', {
282
398
  duration: DURATION.slow,
283
399
  easing: EASING.sheet,
@@ -91,6 +91,25 @@ describe('UiButton (render)', () => {
91
91
  expect(inst.spinnerSize()).toBe('sm');
92
92
  });
93
93
 
94
+ it("colors the spinner like each variant's label", async () => {
95
+ // The template binding doesn't reach the spinner's input under JIT (see
96
+ // setInputSignal), so check the value the button passes it.
97
+ const { componentRef } = await render(UiButton);
98
+ const inst = componentRef.instance as unknown as UiButton & {
99
+ spinnerColor: () => string;
100
+ };
101
+ for (const [variant, color] of [
102
+ ['default', 'var(--primary-foreground)'],
103
+ ['destructive', 'var(--destructive-foreground)'],
104
+ ['outline', 'var(--foreground)'],
105
+ ['secondary', 'var(--secondary-foreground)'],
106
+ ['ghost', 'var(--foreground)'],
107
+ ] as const) {
108
+ setInputSignal(inst.variant, variant);
109
+ expect(inst.spinnerColor()).toBe(color);
110
+ }
111
+ });
112
+
94
113
  it('press handlers run cleanly (guarded by disabled/loading state)', async () => {
95
114
  // onPressStart/End are no-ops when the button is disabled/loading; the
96
115
  // active-press path relies on the ref being wired up on-device — assert
@@ -65,6 +65,19 @@ const buttonTextVariants = cva('font-medium', {
65
65
  },
66
66
  });
67
67
 
68
+ /**
69
+ * The spinner's color for each variant: the same token as the label's text
70
+ * class above. The spinner draws with SVG, which can't read the text classes,
71
+ * so it gets the token and resolves it itself (see icon-theme.ts).
72
+ */
73
+ const SPINNER_COLORS = {
74
+ default: 'var(--primary-foreground)',
75
+ destructive: 'var(--destructive-foreground)',
76
+ outline: 'var(--foreground)',
77
+ secondary: 'var(--secondary-foreground)',
78
+ ghost: 'var(--foreground)',
79
+ } as const;
80
+
68
81
  export type ButtonVariant = NonNullable<
69
82
  VariantProps<typeof buttonVariants>['variant']
70
83
  >;
@@ -74,7 +87,6 @@ export type ButtonSize = NonNullable<
74
87
 
75
88
  @Component({
76
89
  selector: 'ui-button',
77
- standalone: true,
78
90
  imports: [LYNX_ELEMENTS, UiSpinner],
79
91
  encapsulation: ViewEncapsulation.None,
80
92
  template: `
@@ -100,7 +112,7 @@ export type ButtonSize = NonNullable<
100
112
  (bindtap)="onTap()"
101
113
  >
102
114
  @if (loading()) {
103
- <ui-spinner [size]="spinnerSize()" />
115
+ <ui-spinner [size]="spinnerSize()" [color]="spinnerColor()" />
104
116
  } @else {
105
117
  <text [class]="labelClass()"><ng-content /></text>
106
118
  }
@@ -136,6 +148,10 @@ export class UiButton {
136
148
  return s === 'sm' ? ('xs' as const) : ('sm' as const);
137
149
  });
138
150
 
151
+ protected readonly spinnerColor = computed(
152
+ () => SPINNER_COLORS[this.variant()],
153
+ );
154
+
139
155
  protected onPressStart(): void {
140
156
  if (this.disabled() || this.loading()) return;
141
157
  // pressDown/pressRelease drive el.setStyle() based CSS transitions on the