@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
@@ -15,7 +15,12 @@ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
15
15
 
16
16
  import { type AnimationHandle, DURATION, EASING } from '../../utils/animate';
17
17
  import { cn } from '../../utils/cn';
18
- import { type ToastData, dismissToast, toasts } from './toast-state';
18
+ import {
19
+ type ToastAction,
20
+ type ToastData,
21
+ dismissToast,
22
+ toasts,
23
+ } from './toast-state';
19
24
 
20
25
  // --- Drag-to-dismiss tuning (front toast only) ---
21
26
  // Minimum downward travel (px) before releasing dismisses the toast instead of
@@ -80,7 +85,7 @@ const stackTransform = (depth: number): string =>
80
85
  }
81
86
  </view>
82
87
  @if (data().action; as action) {
83
- <view [class]="actionClass()" (catchtap)="onAction()">
88
+ <view [class]="actionClass()" (catchtap)="onAction(action)">
84
89
  <text [class]="actionTextClass()">{{ action.label }}</text>
85
90
  </view>
86
91
  }
@@ -111,28 +116,25 @@ export class UiToastItem implements OnInit, OnDestroy {
111
116
  // unspecified order, and the timer can race both.
112
117
  #isDismissing = false;
113
118
 
114
- constructor() {
115
- // Restack: when a toast in front is dismissed (or a new one is pushed) this
116
- // toast's depth changes — animate it to its new slot. `effect` runs after
117
- // inputs are set, so the first run just records the starting depth.
118
- let prevDepth: number | null = null;
119
- /* v8 ignore start -- JIT reads input.required<number>() before the value is
120
- set, so the first effect run throws NG0950 before `depth()` is tracked
121
- as a dependency; subsequent depth changes therefore don't re-fire the
122
- effect. On device (AOT) inputs are wired before the constructor runs. */
123
- effect(() => {
124
- const d = this.depth();
125
- if (prevDepth === null) {
126
- prevDepth = d;
127
- return;
128
- }
129
- if (d === prevDepth) return;
130
- const from = prevDepth;
131
- prevDepth = d;
132
- this.#animateRestack(from, d);
133
- });
134
- /* v8 ignore stop */
135
- }
119
+ // The depth the restack effect last saw, so it knows where to animate from.
120
+ #stackedDepth: number | null = null;
121
+
122
+ /**
123
+ * Restack: when a toast in front is dismissed (or a new one is pushed) this
124
+ * toast's depth changes — animate it to its new slot. A field rather than a
125
+ * constructor: Angular's test build keeps a function for a constructor that
126
+ * nothing calls, which an app's coverage report would show as uncovered.
127
+ */
128
+ protected readonly restacking = effect(() => {
129
+ const depth = this.depth();
130
+ const from = this.#stackedDepth;
131
+ this.#stackedDepth = depth;
132
+ // `effect` runs after inputs are set, so the first run just records the
133
+ // starting depth. After that it only re-runs once the depth changes; the
134
+ // second check is a safeguard.
135
+ if (from === null || from === depth) return;
136
+ this.#animateRestack(from, depth);
137
+ });
136
138
 
137
139
  ngOnInit(): void {
138
140
  // Start the auto-dismiss countdown when the toast mounts — i.e. when it is
@@ -230,8 +232,12 @@ export class UiToastItem implements OnInit, OnDestroy {
230
232
  this.dismiss();
231
233
  }
232
234
 
233
- protected onAction(): void {
234
- this.data().action?.onAction();
235
+ /**
236
+ * Runs the toast's action, then dismisses it. The action button only
237
+ * renders for a toast that has an action, which it passes in.
238
+ */
239
+ protected onAction(action: ToastAction): void {
240
+ action.onAction();
235
241
  this.dismiss();
236
242
  }
237
243
 
@@ -317,8 +323,6 @@ export class UiToastItem implements OnInit, OnDestroy {
317
323
 
318
324
  #animateIn(): void {
319
325
  const el = this.#host.nativeElement;
320
- /* v8 ignore next 1 -- host is always mounted when animateIn runs in tests */
321
- if (!el) return;
322
326
  this.#anim?.cancel();
323
327
  // Reveal now that the animation owns opacity (0 → 1 below); afterwards the
324
328
  // host style keeps it at 1. Without this the resting-position frame before
@@ -358,32 +362,24 @@ export class UiToastItem implements OnInit, OnDestroy {
358
362
  }
359
363
 
360
364
  /**
361
- * * Move to a new stack slot when depth changes (a toast in front popped, or a
362
- * * new one pushed this one back). This is what makes the stack slide forward
363
- * * when the front toast is dismissed.
364
- * v8 ignore start -- only invoked from the restack effect, which never
365
- * re-runs in JIT (see the effect body above); exercised on device.
365
+ * Move to a new stack slot when depth changes (a toast in front popped, or a
366
+ * new one pushed this one back). This is what makes the stack slide forward
367
+ * when the front toast is dismissed.
366
368
  */
367
369
  #animateRestack(from: number, to: number): void {
368
370
  // A toast pushed while this one is animating out would shift its depth and
369
371
  // restack it back into view — let the exit win instead.
370
372
  if (this.#isDismissing) return;
371
373
  const el = this.#host.nativeElement;
372
- if (!el) return;
373
374
  this.#anim?.cancel();
374
375
  this.#anim = el.animate(
375
376
  [{ transform: stackTransform(from) }, { transform: stackTransform(to) }],
376
377
  { duration: DURATION.normal, easing: EASING.standard, fill: 'forwards' },
377
378
  );
378
379
  }
379
- /**
380
- * v8 ignore stop
381
- */
382
380
 
383
381
  #animateOut(): void {
384
382
  const el = this.#host.nativeElement;
385
- /* v8 ignore next 1 -- host is always mounted when animateOut runs in tests */
386
- if (!el) return;
387
383
  this.#anim?.cancel();
388
384
 
389
385
  const d = this.depth();
@@ -431,18 +427,13 @@ export class UiToastItem implements OnInit, OnDestroy {
431
427
  #snapBack(fromOffset: number): void {
432
428
  const el = this.#host.nativeElement;
433
429
  this.#anim?.cancel();
434
- /* v8 ignore start -- host is always mounted when snapBack runs in tests;
435
- the else branch is defensive against a host-not-yet-committed race */
436
- if (el) {
437
- this.#anim = el.animate(
438
- [
439
- { transform: `translateY(${fromOffset}px) scale(1)` },
440
- { transform: 'translateY(0px) scale(1)' },
441
- ],
442
- { duration: DURATION.fast, easing: EASING.spring, fill: 'forwards' },
443
- );
444
- }
445
- /* v8 ignore stop */
430
+ this.#anim = el.animate(
431
+ [
432
+ { transform: `translateY(${fromOffset}px) scale(1)` },
433
+ { transform: 'translateY(0px) scale(1)' },
434
+ ],
435
+ { duration: DURATION.fast, easing: EASING.spring, fill: 'forwards' },
436
+ );
446
437
  // Match the inline transform to the animation's resting state so the two
447
438
  // agree once fill:'forwards' hands control back to the style binding.
448
439
  this.dragOffset.set(0);
@@ -1,186 +1,293 @@
1
- import { Component } from '@angular/core';
2
- import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
1
+ import { Component, signal } from '@angular/core';
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
8
  import { describe, expect, it } from 'vitest';
5
- import { setInputSignal } from '../../../test-utils/set-input-signal';
6
- import { UiToggle, UiToggleGroup, UiToggleGroupItem } from './toggle';
9
+ import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
10
+ import {
11
+ type ToggleSize,
12
+ type ToggleVariant,
13
+ UiToggle,
14
+ UiToggleGroup,
15
+ UiToggleGroupItem,
16
+ } from './toggle';
7
17
 
8
18
  @Component({
9
- standalone: true,
10
19
  imports: [UiToggle, LYNX_ELEMENTS],
11
- template: `<ui-toggle><text>Bold</text></ui-toggle>`,
20
+ template: `
21
+ <ui-toggle
22
+ [(pressed)]="bold"
23
+ [variant]="variant()"
24
+ [size]="size()"
25
+ [disabled]="disabled()"
26
+ class="w-20"
27
+ ><text>Bold</text></ui-toggle
28
+ >
29
+ `,
12
30
  })
13
- class ToggleHost {}
31
+ class ToggleHost {
32
+ readonly bold = signal(false);
33
+ readonly variant = signal<ToggleVariant>('default');
34
+ readonly size = signal<ToggleSize>('default');
35
+ readonly disabled = signal(false);
36
+ }
14
37
 
15
- describe('UiToggle', () => {
16
- it('renders projected content', async () => {
17
- const { getByText } = await render(ToggleHost);
18
- expect(getByText('Bold')).toBeTruthy();
19
- });
38
+ @Component({
39
+ imports: [UiToggleGroup, UiToggleGroupItem, LYNX_ELEMENTS],
40
+ template: `
41
+ <ui-toggle-group
42
+ [type]="type()"
43
+ [(value)]="align"
44
+ [variant]="variant()"
45
+ [size]="size()"
46
+ [disabled]="disabled()"
47
+ class="w-full"
48
+ >
49
+ <ui-toggle-group-item value="left" class="flex-1"
50
+ ><text>Left</text></ui-toggle-group-item
51
+ >
52
+ <ui-toggle-group-item value="center"
53
+ ><text>Center</text></ui-toggle-group-item
54
+ >
55
+ <ui-toggle-group-item value="right"
56
+ ><text>Right</text></ui-toggle-group-item
57
+ >
58
+ </ui-toggle-group>
59
+ `,
60
+ })
61
+ class ToggleGroupHost {
62
+ readonly type = signal<'single' | 'multiple'>('single');
63
+ readonly align = signal<string[]>([]);
64
+ readonly variant = signal<ToggleVariant>('default');
65
+ readonly size = signal<ToggleSize>('default');
66
+ readonly disabled = signal(false);
67
+ }
68
+
69
+ /**
70
+ * The view (the one that takes presses) a toggle or toggle group item
71
+ * projects its label into.
72
+ */
73
+ const viewOf = (label: Element) => label.parentElement!;
74
+
75
+ /**
76
+ * The scale a view's inline style holds, if any.
77
+ */
78
+ const scaleOf = (view: Element) =>
79
+ (view as HTMLElement).style.getPropertyValue('transform');
80
+
81
+ /**
82
+ * Presses and releases a view by touch, by mouse, and by a touch a scroll
83
+ * takes over, checking it scales down while pressed and back after each.
84
+ */
85
+ const expectPressFeedback = (view: Element) => {
86
+ fireEvent.touchstart(view);
87
+ expect(scaleOf(view)).toBe('scale(0.97)');
88
+ fireEvent.touchend(view);
89
+ expect(scaleOf(view)).toBe('scale(1)');
90
+
91
+ fireEvent.touchstart(view);
92
+ fireEvent.touchcancel(view);
93
+ expect(scaleOf(view)).toBe('scale(1)');
94
+
95
+ // Lynx on the web fires mouse events for a cursor, never touch ones.
96
+ fireEvent.mousedown(view);
97
+ expect(scaleOf(view)).toBe('scale(0.97)');
98
+ fireEvent.mouseup(view);
99
+ expect(scaleOf(view)).toBe('scale(1)');
100
+
101
+ fireEvent.mousedown(view);
102
+ fireLynxEvent(view, 'mouseleave');
103
+ expect(scaleOf(view)).toBe('scale(1)');
104
+ };
105
+
106
+ /**
107
+ * Presses a disabled view by touch and by mouse: it shouldn't scale.
108
+ */
109
+ const expectNoPressFeedback = (view: Element) => {
110
+ fireEvent.touchstart(view);
111
+ fireEvent.mousedown(view);
112
+ expect(scaleOf(view)).toBe('');
113
+ fireEvent.touchend(view);
114
+ fireEvent.mouseup(view);
115
+ expect(scaleOf(view)).toBe('');
116
+ };
20
117
 
21
- it('onTap flips the pressed model', async () => {
22
- const { componentRef } = await render(UiToggle);
23
- const inst = componentRef.instance as unknown as UiToggle & {
24
- onTap: () => void;
118
+ describe('UiToggle', () => {
119
+ const renderToggle = async () => {
120
+ const result = await render(ToggleHost);
121
+ return {
122
+ ...result,
123
+ host: result.componentRef.instance as ToggleHost,
124
+ toggle: () => viewOf(result.getByText('Bold')),
25
125
  };
26
- inst.onTap();
27
- expect(inst.pressed()).toBe(true);
28
- inst.onTap();
29
- expect(inst.pressed()).toBe(false);
126
+ };
127
+
128
+ it('renders its content unpressed, with the given class', async () => {
129
+ const { toggle } = await renderToggle();
130
+ expect(toggle().classList).toContain('bg-transparent');
131
+ expect(toggle().classList).toContain('h-10');
132
+ expect(toggle().classList).toContain('w-20');
133
+ expect(toggle().classList).not.toContain('opacity-50');
30
134
  });
31
135
 
32
- it('onTap is a no-op when disabled', async () => {
33
- const { componentRef } = await render(UiToggle);
34
- const inst = componentRef.instance as unknown as UiToggle & {
35
- onTap: () => void;
36
- };
37
- setInputSignal(inst.disabled, true);
136
+ it('flips `pressed` and its highlight on each tap', async () => {
137
+ const { host, toggle } = await renderToggle();
138
+ fireEvent.tap(toggle());
139
+ await waitForUpdate();
140
+ expect(host.bold()).toBe(true);
141
+ expect(toggle().classList).toContain('bg-accent');
142
+
143
+ fireEvent.tap(toggle());
38
144
  await waitForUpdate();
39
- inst.onTap();
40
- expect(inst.pressed()).toBe(false);
145
+ expect(host.bold()).toBe(false);
146
+ expect(toggle().classList).toContain('bg-transparent');
41
147
  });
42
148
 
43
- it('press handlers run cleanly (enabled + disabled)', async () => {
44
- const { componentRef } = await render(UiToggle);
45
- const inst = componentRef.instance as unknown as UiToggle & {
46
- onPressStart: () => void;
47
- onPressEnd: () => void;
48
- onPressCancel: () => void;
49
- };
50
- expect(() => inst.onPressStart()).not.toThrow();
51
- expect(() => inst.onPressEnd()).not.toThrow();
52
- expect(() => inst.onPressCancel()).not.toThrow();
53
- setInputSignal((inst as unknown as UiToggle).disabled, true);
149
+ it('highlights when the app presses it', async () => {
150
+ const { host, toggle } = await renderToggle();
151
+ host.bold.set(true);
54
152
  await waitForUpdate();
55
- expect(() => inst.onPressStart()).not.toThrow();
56
- expect(() => inst.onPressEnd()).not.toThrow();
57
- expect(() => inst.onPressCancel()).not.toThrow();
153
+ expect(toggle().classList).toContain('bg-accent');
58
154
  });
59
155
 
60
- it('applies the accent background when pressed', async () => {
61
- const { componentRef, container } = await render(UiToggle);
62
- const inst = componentRef.instance as UiToggle;
63
- inst.pressed.set(true);
156
+ it("dims and ignores taps while it's disabled", async () => {
157
+ const { host, toggle } = await renderToggle();
158
+ host.disabled.set(true);
64
159
  await waitForUpdate();
65
- const view = container.querySelector('view');
66
- expect(view?.getAttribute('class')).toContain('bg-accent');
160
+ expect(toggle().classList).toContain('opacity-50');
161
+ fireEvent.tap(toggle());
162
+ expect(host.bold()).toBe(false);
67
163
  });
68
164
 
69
- it('applies outline variant and small size classes', async () => {
70
- const { componentRef, container } = await render(UiToggle);
71
- const inst = componentRef.instance as UiToggle;
72
- setInputSignal(inst.variant, 'outline');
73
- setInputSignal(inst.size, 'sm');
165
+ it('applies the classes for its variant and size', async () => {
166
+ const { host, toggle } = await renderToggle();
167
+ host.variant.set('outline');
168
+ host.size.set('sm');
74
169
  await waitForUpdate();
75
- const view = container.querySelector('view');
76
- expect(view?.getAttribute('class')).toContain('border-border');
77
- expect(view?.getAttribute('class')).toContain('h-9');
170
+ expect(toggle().classList).toContain('border-border');
171
+ expect(toggle().classList).toContain('h-9');
172
+
173
+ host.size.set('lg');
174
+ await waitForUpdate();
175
+ expect(toggle().classList).toContain('h-11');
176
+ });
177
+
178
+ it('scales down while pressed, and back on release or cancel', async () => {
179
+ const { toggle } = await renderToggle();
180
+ expectPressFeedback(toggle());
78
181
  });
79
182
 
80
- it('applies large size classes', async () => {
81
- const { componentRef, container } = await render(UiToggle);
82
- const inst = componentRef.instance as UiToggle;
83
- setInputSignal(inst.size, 'lg');
183
+ it("doesn't react to presses while it's disabled", async () => {
184
+ const { host, toggle } = await renderToggle();
185
+ host.disabled.set(true);
84
186
  await waitForUpdate();
85
- expect(container.querySelector('view')?.getAttribute('class')).toContain(
86
- 'h-11',
87
- );
187
+ expectNoPressFeedback(toggle());
88
188
  });
89
189
  });
90
190
 
91
191
  describe('UiToggleGroup', () => {
92
- it('single mode replaces selection and allows deselection', async () => {
93
- const { componentRef } = await render(UiToggleGroup);
94
- const inst = componentRef.instance as UiToggleGroup;
95
- inst.toggle('a');
96
- expect(inst.value()).toEqual(['a']);
97
- inst.toggle('b');
98
- expect(inst.value()).toEqual(['b']);
99
- // Tap active item again to deselect (unique to single-mode).
100
- inst.toggle('b');
101
- expect(inst.value()).toEqual([]);
102
- });
192
+ const renderGroup = async () => {
193
+ const result = await render(ToggleGroupHost);
194
+ return {
195
+ ...result,
196
+ host: result.componentRef.instance as ToggleGroupHost,
197
+ group: () => result.container.querySelector('.gap-1')!,
198
+ item: (label: string) => viewOf(result.getByText(label)),
199
+ };
200
+ };
103
201
 
104
- it('multiple mode toggles individual items', async () => {
105
- const { componentRef } = await render(UiToggleGroup);
106
- const inst = componentRef.instance as UiToggleGroup;
107
- setInputSignal(inst.type, 'multiple');
202
+ /**
203
+ * Taps an item, then lets the selection reach its highlight.
204
+ */
205
+ const tap = async (item: Element) => {
206
+ fireEvent.tap(item);
108
207
  await waitForUpdate();
109
- inst.toggle('a');
110
- inst.toggle('b');
111
- expect(inst.value()).toEqual(['a', 'b']);
112
- inst.toggle('a');
113
- expect(inst.value()).toEqual(['b']);
208
+ };
209
+
210
+ it('lays its items out in a row, with the given class', async () => {
211
+ const { group } = await renderGroup();
212
+ expect(group().classList).toContain('flex-row');
213
+ expect(group().classList).toContain('w-full');
114
214
  });
115
215
 
116
- it('toggle() is a no-op when the group is disabled', async () => {
117
- const { componentRef } = await render(UiToggleGroup);
118
- const inst = componentRef.instance as UiToggleGroup;
119
- setInputSignal(inst.disabled, true);
120
- await waitForUpdate();
121
- inst.toggle('a');
122
- expect(inst.value()).toEqual([]);
216
+ it('selects one item at a time, and deselects it on a second tap', async () => {
217
+ const { host, item } = await renderGroup();
218
+ await tap(item('Left'));
219
+ expect(host.align()).toEqual(['left']);
220
+ expect(item('Left').classList).toContain('bg-accent');
221
+
222
+ await tap(item('Center'));
223
+ expect(host.align()).toEqual(['center']);
224
+ expect(item('Left').classList).toContain('bg-transparent');
225
+ expect(item('Center').classList).toContain('bg-accent');
226
+
227
+ // Unlike a radio group, a single toggle group can have nothing selected.
228
+ await tap(item('Center'));
229
+ expect(host.align()).toEqual([]);
230
+ expect(item('Center').classList).toContain('bg-transparent');
123
231
  });
124
232
 
125
- it('isSelected reflects the current value list', async () => {
126
- const { componentRef } = await render(UiToggleGroup);
127
- const inst = componentRef.instance as UiToggleGroup;
128
- inst.toggle('a');
129
- expect(inst.isSelected('a')).toBe(true);
130
- expect(inst.isSelected('b')).toBe(false);
233
+ it('toggles each item on its own when it allows multiple', async () => {
234
+ const { host, item } = await renderGroup();
235
+ host.type.set('multiple');
236
+ await waitForUpdate();
237
+
238
+ await tap(item('Left'));
239
+ await tap(item('Right'));
240
+ expect(host.align()).toEqual(['left', 'right']);
241
+
242
+ await tap(item('Left'));
243
+ expect(host.align()).toEqual(['right']);
244
+ expect(item('Left').classList).toContain('bg-transparent');
245
+ expect(item('Right').classList).toContain('bg-accent');
131
246
  });
132
- });
133
247
 
134
- describe('UiToggleGroupItem', () => {
135
- const renderItem = async (
136
- parentValue: string[] = [],
137
- parentDisabled = false,
138
- ) => {
139
- const parentResult = await render(UiToggleGroup);
140
- const parent = parentResult.componentRef.instance as UiToggleGroup;
141
- if (parentValue.length) parent.value.set(parentValue);
142
- if (parentDisabled) setInputSignal(parent.disabled, true);
248
+ it('highlights the items the app selects', async () => {
249
+ const { host, item } = await renderGroup();
250
+ host.align.set(['right']);
143
251
  await waitForUpdate();
252
+ expect(item('Right').classList).toContain('bg-accent');
253
+ expect(item('Left').classList).toContain('bg-transparent');
254
+ });
144
255
 
145
- const itemResult = await render(UiToggleGroupItem, {
146
- providers: [{ provide: UiToggleGroup, useValue: parent }],
147
- });
148
- const item = itemResult.componentRef.instance as UiToggleGroupItem;
149
- setInputSignal(item.itemValue, 'a');
256
+ it('dims every item and ignores taps while disabled', async () => {
257
+ const { host, item } = await renderGroup();
258
+ host.disabled.set(true);
150
259
  await waitForUpdate();
151
- return { parent, item, itemResult };
152
- };
260
+ for (const label of ['Left', 'Center', 'Right']) {
261
+ expect(item(label).classList).toContain('opacity-50');
262
+ await tap(item(label));
263
+ }
264
+ expect(host.align()).toEqual([]);
265
+ });
153
266
 
154
- it('onTap delegates to the parent group', async () => {
155
- const { parent, item } = await renderItem();
156
- (item as unknown as { onTap: () => void }).onTap();
157
- expect(parent.value()).toEqual(['a']);
267
+ it("styles every item with the group's variant and size, and its own class", async () => {
268
+ const { host, item } = await renderGroup();
269
+ expect(item('Left').classList).toContain('h-10');
270
+ expect(item('Left').classList).toContain('flex-1');
271
+ expect(item('Left').classList).not.toContain('opacity-50');
272
+
273
+ host.variant.set('outline');
274
+ host.size.set('sm');
275
+ await waitForUpdate();
276
+ for (const label of ['Left', 'Center', 'Right']) {
277
+ expect(item(label).classList).toContain('border-border');
278
+ expect(item(label).classList).toContain('h-9');
279
+ }
158
280
  });
159
281
 
160
- it('highlights when selected', async () => {
161
- const { itemResult } = await renderItem(['a']);
162
- const view = itemResult.container.querySelector('view');
163
- expect(view?.getAttribute('class')).toContain('bg-accent');
282
+ it('scales an item down while pressed, and back on release or cancel', async () => {
283
+ const { item } = await renderGroup();
284
+ expectPressFeedback(item('Center'));
164
285
  });
165
286
 
166
- it('press handlers run cleanly (enabled + disabled)', async () => {
167
- const { item } = await renderItem();
168
- const cast = item as unknown as {
169
- onPressStart: () => void;
170
- onPressEnd: () => void;
171
- onPressCancel: () => void;
172
- };
173
- expect(() => cast.onPressStart()).not.toThrow();
174
- expect(() => cast.onPressEnd()).not.toThrow();
175
-
176
- const disabled = await renderItem([], true);
177
- const cast2 = disabled.item as unknown as {
178
- onPressStart: () => void;
179
- onPressEnd: () => void;
180
- onPressCancel: () => void;
181
- };
182
- expect(() => cast2.onPressStart()).not.toThrow();
183
- expect(() => cast2.onPressEnd()).not.toThrow();
184
- expect(() => cast2.onPressCancel()).not.toThrow();
287
+ it("doesn't react to presses while the group is disabled", async () => {
288
+ const { host, item } = await renderGroup();
289
+ host.disabled.set(true);
290
+ await waitForUpdate();
291
+ expectNoPressFeedback(item('Center'));
185
292
  });
186
293
  });
@@ -1,3 +1,6 @@
1
+ /// <reference types="node" />
2
+ // Not copied into apps (it checks the theme against a component), so it can
3
+ // read the theme files with Node.
1
4
  import { readFileSync } from 'node:fs';
2
5
  import { describe, expect, it } from 'vitest';
3
6
  import {
@@ -11,7 +14,9 @@ import {
11
14
  * copy in step with default.css and dark.css.
12
15
  */
13
16
  const tokens = (file: string): Record<string, string> => {
14
- const css = readFileSync(new URL(file, import.meta.url), 'utf8');
17
+ // Relative to the package, where `ng test` runs: the test runs from a build
18
+ // of it elsewhere, so `import.meta.url` doesn't point next to the CSS.
19
+ const css = readFileSync(`src/lib/theme/${file}`, 'utf8');
15
20
  const values: Record<string, string> = {};
16
21
  for (const match of css.matchAll(/--([\w-]+):\s*(rgba\([^)]*\))/g)) {
17
22
  values[match[1]!] = match[2]!;
@@ -21,10 +26,10 @@ const tokens = (file: string): Record<string, string> => {
21
26
 
22
27
  describe('chart color tables', () => {
23
28
  it('match default.css', () => {
24
- expect(CHART_LIGHT_COLORS).toEqual(tokens('./default.css'));
29
+ expect(CHART_LIGHT_COLORS).toEqual(tokens('default.css'));
25
30
  });
26
31
 
27
32
  it('match dark.css', () => {
28
- expect(CHART_DARK_COLORS).toEqual(tokens('./dark.css'));
33
+ expect(CHART_DARK_COLORS).toEqual(tokens('dark.css'));
29
34
  });
30
35
  });