@blotch/dolan 0.0.0-stage → 22.2.1

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 (273) hide show
  1. package/dist/commands/add.d.ts +22 -0
  2. package/dist/commands/add.js +132 -0
  3. package/dist/commands/add.js.map +1 -0
  4. package/dist/commands/add.test.d.ts +1 -0
  5. package/dist/commands/add.test.js +209 -0
  6. package/dist/commands/add.test.js.map +1 -0
  7. package/dist/commands/diff.d.ts +13 -0
  8. package/dist/commands/diff.js +155 -0
  9. package/dist/commands/diff.js.map +1 -0
  10. package/dist/commands/diff.test.d.ts +1 -0
  11. package/dist/commands/diff.test.js +327 -0
  12. package/dist/commands/diff.test.js.map +1 -0
  13. package/dist/commands/doctor.d.ts +15 -0
  14. package/dist/commands/doctor.js +248 -0
  15. package/dist/commands/doctor.js.map +1 -0
  16. package/dist/commands/doctor.test.d.ts +1 -0
  17. package/dist/commands/doctor.test.js +301 -0
  18. package/dist/commands/doctor.test.js.map +1 -0
  19. package/dist/commands/eject.d.ts +11 -0
  20. package/dist/commands/eject.js +55 -0
  21. package/dist/commands/eject.js.map +1 -0
  22. package/dist/commands/eject.test.d.ts +1 -0
  23. package/dist/commands/eject.test.js +151 -0
  24. package/dist/commands/eject.test.js.map +1 -0
  25. package/dist/commands/info.d.ts +1 -0
  26. package/dist/commands/info.js +73 -0
  27. package/dist/commands/info.js.map +1 -0
  28. package/dist/commands/info.test.d.ts +1 -0
  29. package/dist/commands/info.test.js +143 -0
  30. package/dist/commands/info.test.js.map +1 -0
  31. package/dist/commands/init.d.ts +1 -0
  32. package/dist/commands/init.js +98 -0
  33. package/dist/commands/init.js.map +1 -0
  34. package/dist/commands/init.test.d.ts +1 -0
  35. package/dist/commands/init.test.js +154 -0
  36. package/dist/commands/init.test.js.map +1 -0
  37. package/dist/commands/list.d.ts +16 -0
  38. package/dist/commands/list.js +124 -0
  39. package/dist/commands/list.js.map +1 -0
  40. package/dist/commands/list.test.d.ts +1 -0
  41. package/dist/commands/list.test.js +285 -0
  42. package/dist/commands/list.test.js.map +1 -0
  43. package/dist/commands/outdated.d.ts +13 -0
  44. package/dist/commands/outdated.js +116 -0
  45. package/dist/commands/outdated.js.map +1 -0
  46. package/dist/commands/outdated.test.d.ts +1 -0
  47. package/dist/commands/outdated.test.js +208 -0
  48. package/dist/commands/outdated.test.js.map +1 -0
  49. package/dist/commands/remove.d.ts +18 -0
  50. package/dist/commands/remove.js +161 -0
  51. package/dist/commands/remove.js.map +1 -0
  52. package/dist/commands/remove.test.d.ts +1 -0
  53. package/dist/commands/remove.test.js +361 -0
  54. package/dist/commands/remove.test.js.map +1 -0
  55. package/dist/commands/theme.d.ts +1 -0
  56. package/dist/commands/theme.js +66 -0
  57. package/dist/commands/theme.js.map +1 -0
  58. package/dist/commands/theme.test.d.ts +1 -0
  59. package/dist/commands/theme.test.js +133 -0
  60. package/dist/commands/theme.test.js.map +1 -0
  61. package/dist/commands/update.d.ts +31 -0
  62. package/dist/commands/update.js +601 -0
  63. package/dist/commands/update.js.map +1 -0
  64. package/dist/commands/update.test.d.ts +1 -0
  65. package/dist/commands/update.test.js +1233 -0
  66. package/dist/commands/update.test.js.map +1 -0
  67. package/dist/config.d.ts +11 -0
  68. package/dist/config.js +19 -0
  69. package/dist/config.js.map +1 -0
  70. package/dist/config.test.d.ts +1 -0
  71. package/dist/config.test.js +48 -0
  72. package/dist/config.test.js.map +1 -0
  73. package/dist/index.d.ts +2 -0
  74. package/dist/index.js +89 -0
  75. package/dist/index.js.map +1 -0
  76. package/dist/lockfile.d.ts +21 -0
  77. package/dist/lockfile.js +39 -0
  78. package/dist/lockfile.js.map +1 -0
  79. package/dist/registry.d.ts +14 -0
  80. package/dist/registry.js +85 -0
  81. package/dist/registry.js.map +1 -0
  82. package/dist/registry.test.d.ts +1 -0
  83. package/dist/registry.test.js +95 -0
  84. package/dist/registry.test.js.map +1 -0
  85. package/dist/test-utils.d.ts +16 -0
  86. package/dist/test-utils.js +65 -0
  87. package/dist/test-utils.js.map +1 -0
  88. package/dist/ui/components/accordion/accordion.test.ts +194 -0
  89. package/dist/ui/components/accordion/accordion.ts +173 -0
  90. package/dist/ui/components/accordion/index.ts +6 -0
  91. package/dist/ui/components/action-sheet/action-sheet.test.ts +211 -0
  92. package/dist/ui/components/action-sheet/action-sheet.ts +353 -0
  93. package/dist/ui/components/action-sheet/index.ts +6 -0
  94. package/dist/ui/components/alert/alert.test.ts +48 -0
  95. package/dist/ui/components/alert/alert.ts +104 -0
  96. package/dist/ui/components/alert/index.ts +1 -0
  97. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +141 -0
  98. package/dist/ui/components/alert-dialog/alert-dialog.ts +243 -0
  99. package/dist/ui/components/alert-dialog/index.ts +7 -0
  100. package/dist/ui/components/area-chart/area-chart-render.test.ts +121 -0
  101. package/dist/ui/components/area-chart/area-chart.test.ts +158 -0
  102. package/dist/ui/components/area-chart/area-chart.ts +349 -0
  103. package/dist/ui/components/area-chart/index.ts +6 -0
  104. package/dist/ui/components/aspect-ratio/aspect-ratio.test.ts +10 -0
  105. package/dist/ui/components/aspect-ratio/aspect-ratio.ts +28 -0
  106. package/dist/ui/components/aspect-ratio/index.ts +1 -0
  107. package/dist/ui/components/avatar/avatar.test.ts +69 -0
  108. package/dist/ui/components/avatar/avatar.ts +131 -0
  109. package/dist/ui/components/avatar/index.ts +1 -0
  110. package/dist/ui/components/badge/badge.test.ts +49 -0
  111. package/dist/ui/components/badge/badge.ts +97 -0
  112. package/dist/ui/components/badge/index.ts +1 -0
  113. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +174 -0
  114. package/dist/ui/components/bar-chart/bar-chart.test.ts +73 -0
  115. package/dist/ui/components/bar-chart/bar-chart.ts +327 -0
  116. package/dist/ui/components/bar-chart/index.ts +6 -0
  117. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +163 -0
  118. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +339 -0
  119. package/dist/ui/components/bottom-sheet/index.ts +1 -0
  120. package/dist/ui/components/button/button-render.test.ts +113 -0
  121. package/dist/ui/components/button/button.test.ts +70 -0
  122. package/dist/ui/components/button/button.ts +170 -0
  123. package/dist/ui/components/button/index.ts +6 -0
  124. package/dist/ui/components/button-group/button-group-render.test.ts +146 -0
  125. package/dist/ui/components/button-group/button-group.test.ts +54 -0
  126. package/dist/ui/components/button-group/button-group.ts +209 -0
  127. package/dist/ui/components/button-group/index.ts +8 -0
  128. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +174 -0
  129. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +96 -0
  130. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +355 -0
  131. package/dist/ui/components/candlestick-chart/index.ts +7 -0
  132. package/dist/ui/components/card/card.test.ts +110 -0
  133. package/dist/ui/components/card/card.ts +162 -0
  134. package/dist/ui/components/card/index.ts +8 -0
  135. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +188 -0
  136. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +549 -0
  137. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +1140 -0
  138. package/dist/ui/components/cartesian-chart/index.ts +18 -0
  139. package/dist/ui/components/checkbox/checkbox.test.ts +59 -0
  140. package/dist/ui/components/checkbox/checkbox.ts +96 -0
  141. package/dist/ui/components/checkbox/index.ts +1 -0
  142. package/dist/ui/components/collapsible/collapsible.test.ts +112 -0
  143. package/dist/ui/components/collapsible/collapsible.ts +122 -0
  144. package/dist/ui/components/collapsible/index.ts +5 -0
  145. package/dist/ui/components/dialog/dialog.test.ts +157 -0
  146. package/dist/ui/components/dialog/dialog.ts +248 -0
  147. package/dist/ui/components/dialog/index.ts +7 -0
  148. package/dist/ui/components/empty-state/empty-state.test.ts +60 -0
  149. package/dist/ui/components/empty-state/empty-state.ts +46 -0
  150. package/dist/ui/components/empty-state/index.ts +1 -0
  151. package/dist/ui/components/icon/icon.test.ts +52 -0
  152. package/dist/ui/components/icon/icon.ts +54 -0
  153. package/dist/ui/components/icon/icons.ts +569 -0
  154. package/dist/ui/components/icon/index.ts +2 -0
  155. package/dist/ui/components/input/index.ts +1 -0
  156. package/dist/ui/components/input/input.test.ts +134 -0
  157. package/dist/ui/components/input/input.ts +189 -0
  158. package/dist/ui/components/label/index.ts +1 -0
  159. package/dist/ui/components/label/label.test.ts +38 -0
  160. package/dist/ui/components/label/label.ts +24 -0
  161. package/dist/ui/components/line-chart/index.ts +6 -0
  162. package/dist/ui/components/line-chart/line-chart-render.test.ts +121 -0
  163. package/dist/ui/components/line-chart/line-chart.test.ts +81 -0
  164. package/dist/ui/components/line-chart/line-chart.ts +273 -0
  165. package/dist/ui/components/list/index.ts +1 -0
  166. package/dist/ui/components/list/list.test.ts +35 -0
  167. package/dist/ui/components/list/list.ts +71 -0
  168. package/dist/ui/components/nav-drawer/index.ts +8 -0
  169. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +267 -0
  170. package/dist/ui/components/nav-drawer/nav-drawer.ts +504 -0
  171. package/dist/ui/components/pagination/index.ts +1 -0
  172. package/dist/ui/components/pagination/pagination.test.ts +96 -0
  173. package/dist/ui/components/pagination/pagination.ts +147 -0
  174. package/dist/ui/components/progress/index.ts +1 -0
  175. package/dist/ui/components/progress/progress.test.ts +71 -0
  176. package/dist/ui/components/progress/progress.ts +118 -0
  177. package/dist/ui/components/radio-group/index.ts +1 -0
  178. package/dist/ui/components/radio-group/radio-group.test.ts +115 -0
  179. package/dist/ui/components/radio-group/radio-group.ts +129 -0
  180. package/dist/ui/components/scatter-chart/index.ts +6 -0
  181. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +157 -0
  182. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +57 -0
  183. package/dist/ui/components/scatter-chart/scatter-chart.ts +277 -0
  184. package/dist/ui/components/scroll-area/index.ts +1 -0
  185. package/dist/ui/components/scroll-area/scroll-area.test.ts +40 -0
  186. package/dist/ui/components/scroll-area/scroll-area.ts +73 -0
  187. package/dist/ui/components/select/index.ts +1 -0
  188. package/dist/ui/components/select/select.test.ts +107 -0
  189. package/dist/ui/components/select/select.ts +146 -0
  190. package/dist/ui/components/separator/index.ts +1 -0
  191. package/dist/ui/components/separator/separator.test.ts +26 -0
  192. package/dist/ui/components/separator/separator.ts +45 -0
  193. package/dist/ui/components/sheet/index.ts +7 -0
  194. package/dist/ui/components/sheet/sheet.test.ts +44 -0
  195. package/dist/ui/components/sheet/sheet.ts +114 -0
  196. package/dist/ui/components/skeleton/index.ts +1 -0
  197. package/dist/ui/components/skeleton/skeleton.test.ts +13 -0
  198. package/dist/ui/components/skeleton/skeleton.ts +45 -0
  199. package/dist/ui/components/spinner/index.ts +1 -0
  200. package/dist/ui/components/spinner/spinner.test.ts +39 -0
  201. package/dist/ui/components/spinner/spinner.ts +83 -0
  202. package/dist/ui/components/switch/index.ts +1 -0
  203. package/dist/ui/components/switch/switch.test.ts +63 -0
  204. package/dist/ui/components/switch/switch.ts +98 -0
  205. package/dist/ui/components/tabs/index.ts +1 -0
  206. package/dist/ui/components/tabs/tabs.test.ts +199 -0
  207. package/dist/ui/components/tabs/tabs.ts +317 -0
  208. package/dist/ui/components/textarea/index.ts +1 -0
  209. package/dist/ui/components/textarea/textarea.test.ts +138 -0
  210. package/dist/ui/components/textarea/textarea.ts +221 -0
  211. package/dist/ui/components/toast/index.ts +9 -0
  212. package/dist/ui/components/toast/toast-state.test.ts +54 -0
  213. package/dist/ui/components/toast/toast-state.ts +46 -0
  214. package/dist/ui/components/toast/toast.test.ts +342 -0
  215. package/dist/ui/components/toast/toast.ts +527 -0
  216. package/dist/ui/components/toggle/index.ts +7 -0
  217. package/dist/ui/components/toggle/toggle.test.ts +186 -0
  218. package/dist/ui/components/toggle/toggle.ts +218 -0
  219. package/dist/ui/components/typography/index.ts +5 -0
  220. package/dist/ui/components/typography/typography.test.ts +55 -0
  221. package/dist/ui/components/typography/typography.ts +44 -0
  222. package/dist/ui/theme/dark.css +41 -0
  223. package/dist/ui/theme/default.css +56 -0
  224. package/dist/ui/theme/tailwind-plugin.test.ts +124 -0
  225. package/dist/ui/theme/tailwind-plugin.ts +151 -0
  226. package/dist/ui/utils/animate.test.ts +324 -0
  227. package/dist/ui/utils/animate.ts +544 -0
  228. package/dist/ui/utils/cn.test.ts +27 -0
  229. package/dist/ui/utils/cn.ts +6 -0
  230. package/dist/utils/analyze.d.ts +32 -0
  231. package/dist/utils/analyze.js +109 -0
  232. package/dist/utils/analyze.js.map +1 -0
  233. package/dist/utils/analyze.test.d.ts +1 -0
  234. package/dist/utils/analyze.test.js +146 -0
  235. package/dist/utils/analyze.test.js.map +1 -0
  236. package/dist/utils/animate.d.ts +210 -0
  237. package/dist/utils/animate.js +423 -0
  238. package/dist/utils/animate.js.map +1 -0
  239. package/dist/utils/animate.test.d.ts +1 -0
  240. package/dist/utils/animate.test.js +365 -0
  241. package/dist/utils/animate.test.js.map +1 -0
  242. package/dist/utils/banner.d.ts +1 -0
  243. package/dist/utils/banner.js +15 -0
  244. package/dist/utils/banner.js.map +1 -0
  245. package/dist/utils/banner.test.d.ts +1 -0
  246. package/dist/utils/banner.test.js +29 -0
  247. package/dist/utils/banner.test.js.map +1 -0
  248. package/dist/utils/cn.d.ts +2 -0
  249. package/dist/utils/cn.js +6 -0
  250. package/dist/utils/cn.js.map +1 -0
  251. package/dist/utils/cn.test.d.ts +1 -0
  252. package/dist/utils/cn.test.js +21 -0
  253. package/dist/utils/cn.test.js.map +1 -0
  254. package/dist/utils/format.d.ts +31 -0
  255. package/dist/utils/format.js +95 -0
  256. package/dist/utils/format.js.map +1 -0
  257. package/dist/utils/format.test.d.ts +1 -0
  258. package/dist/utils/format.test.js +71 -0
  259. package/dist/utils/format.test.js.map +1 -0
  260. package/dist/utils/logger.d.ts +6 -0
  261. package/dist/utils/logger.js +16 -0
  262. package/dist/utils/logger.js.map +1 -0
  263. package/dist/utils/logger.test.d.ts +1 -0
  264. package/dist/utils/logger.test.js +36 -0
  265. package/dist/utils/logger.test.js.map +1 -0
  266. package/dist/utils/resolve-paths.d.ts +12 -0
  267. package/dist/utils/resolve-paths.js +29 -0
  268. package/dist/utils/resolve-paths.js.map +1 -0
  269. package/dist/utils/resolve-paths.test.d.ts +1 -0
  270. package/dist/utils/resolve-paths.test.js +52 -0
  271. package/dist/utils/resolve-paths.test.js.map +1 -0
  272. package/package.json +56 -4
  273. package/README.md +0 -3
@@ -0,0 +1,527 @@
1
+ import {
2
+ type OnInit,
3
+ Component,
4
+ ElementRef,
5
+ ViewEncapsulation,
6
+ computed,
7
+ effect,
8
+ inject,
9
+ input,
10
+ output,
11
+ signal,
12
+ } from '@angular/core';
13
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
14
+
15
+ import { type AnimationHandle, DURATION, EASING } from '../../utils/animate';
16
+ import { cn } from '../../utils/cn';
17
+ import { type ToastData, dismissToast, toasts } from './toast-state';
18
+
19
+ // --- Drag-to-dismiss tuning (front toast only) ---
20
+ // Minimum downward travel (px) before releasing dismisses the toast instead of
21
+ // snapping it back — small enough to feel responsive, large enough that a stray
22
+ // tap doesn't close it.
23
+ const DISMISS_THRESHOLD = 80;
24
+ // Off-screen translateY target (px) for the drag-dismiss slide. Larger than any
25
+ // realistic toast height so the toast fully clears the bottom of the screen.
26
+ const DISMISS_TRANSLATE = 300;
27
+
28
+ // --- Stack tuning ---
29
+ // How many toasts are visually stacked at once. Extras stay in the `toasts`
30
+ // array (the queue) but aren't rendered, so they hold no timer and simply wait
31
+ // their turn — nothing is dropped.
32
+ const MAX_VISIBLE = 3;
33
+ // Each toast deeper in the stack is lifted up by this many px, so older toasts
34
+ // peek out above the front one.
35
+ const STACK_GAP = 14;
36
+ // ...and shrunk by this much per depth level, faking perspective.
37
+ const STACK_SCALE_STEP = 0.05;
38
+
39
+ /**
40
+ * `translateY`/`scale` transform for a toast at a given stack depth, ignoring
41
+ * drag. depth 0 = front (full size, no lift); deeper = higher up + smaller.
42
+ * Used as the FROM/TO of the entrance/restack/exit animations, and mirrored by
43
+ * `UiToastItem.hostStyle()` for the resting state the animations hand back to.
44
+ */
45
+ const stackTransform = (depth: number): string =>
46
+ `translateY(${-depth * STACK_GAP}px) scale(${1 - depth * STACK_SCALE_STEP})`;
47
+
48
+ /**
49
+ * A single toast in the stack. Owns its host `<view>` (styled/animated
50
+ * directly), its auto-dismiss timer, and — when it's the front toast — its
51
+ * drag-to-dismiss gesture. Emits `dismissed` once it has animated out so the
52
+ * host can remove it from the queue.
53
+ */
54
+ @Component({
55
+ selector: 'ui-toast-item',
56
+ standalone: true,
57
+ imports: [LYNX_ELEMENTS],
58
+ encapsulation: ViewEncapsulation.None,
59
+ // Styling + interaction live on the HOST element (a plain `view`, since the
60
+ // custom tag falls back to one — see the separator component). Making the host
61
+ // the card avoids an extra wrapper and lets absolute positioning + the stack
62
+ // transform apply to the actual element. Only the front toast reacts to
63
+ // taps/touches; behind toasts gate their handlers on isFront().
64
+ host: {
65
+ '[class]': 'cardClass()',
66
+ '[style]': 'hostStyle()',
67
+ '(bindtap)': 'onTap()',
68
+ '(catchtouchstart)': 'onTouchStart($event)',
69
+ '(catchtouchmove)': 'onTouchMove($event)',
70
+ '(catchtouchend)': 'onTouchEnd()',
71
+ '(catchtouchcancel)': 'onTouchEnd()',
72
+ },
73
+ template: `
74
+ <view class="flex-1 flex-col gap-1 flex">
75
+ @if (data().title) {
76
+ <text [class]="titleClass()">{{ data().title }}</text>
77
+ }
78
+ @if (data().description) {
79
+ <text [class]="descriptionClass()">{{ data().description }}</text>
80
+ }
81
+ </view>
82
+ @if (data().action; as action) {
83
+ <view [class]="actionClass()" (catchtap)="onAction()">
84
+ <text [class]="actionTextClass()">{{ action.label }}</text>
85
+ </view>
86
+ }
87
+ `,
88
+ })
89
+ export class UiToastItem implements OnInit {
90
+ readonly data = input.required<ToastData>();
91
+ // 0 = front (newest, interactive); higher = further back in the stack.
92
+ readonly depth = input.required<number>();
93
+ readonly dismissed = output<void>();
94
+
95
+ // The host element itself — the card we position and animate.
96
+ readonly #host = inject(ElementRef);
97
+ #anim?: AnimationHandle;
98
+ #dismissTimer: ReturnType<typeof setTimeout> | null = null;
99
+
100
+ // Hidden until the entrance animation runs, so the toast doesn't flash at its
101
+ // resting position for a frame before sliding/fading in.
102
+ readonly #hasEntered = signal(false);
103
+
104
+ // --- Drag-to-dismiss state (front toast only) ---
105
+ protected readonly dragOffset = signal(0);
106
+ #dragStartY = 0;
107
+ #isDragging = false;
108
+ // Guards dismiss() against re-entry: a tap and its touchend fire in an
109
+ // unspecified order, and the timer can race both.
110
+ #isDismissing = false;
111
+
112
+ constructor() {
113
+ // Restack: when a toast in front is dismissed (or a new one is pushed) this
114
+ // toast's depth changes — animate it to its new slot. `effect` runs after
115
+ // inputs are set, so the first run just records the starting depth.
116
+ let prevDepth: number | null = null;
117
+ /* v8 ignore start -- JIT reads input.required<number>() before the value is
118
+ set, so the first effect run throws NG0950 before `depth()` is tracked
119
+ as a dependency; subsequent depth changes therefore don't re-fire the
120
+ effect. On device (AOT) inputs are wired before the constructor runs. */
121
+ effect(() => {
122
+ const d = this.depth();
123
+ if (prevDepth === null) {
124
+ prevDepth = d;
125
+ return;
126
+ }
127
+ if (d === prevDepth) return;
128
+ const from = prevDepth;
129
+ prevDepth = d;
130
+ this.#animateRestack(from, d);
131
+ });
132
+ /* v8 ignore stop */
133
+ }
134
+
135
+ ngOnInit(): void {
136
+ // Start the auto-dismiss countdown when the toast mounts — i.e. when it is
137
+ // promoted into the visible stack — so a queued toast doesn't expire while
138
+ // hidden.
139
+ this.#dismissTimer = setTimeout(() => this.dismiss(), this.data().duration);
140
+ // Two-phase like the original toaster: wait a tick so the host view is
141
+ // committed to Lynx's native tree before animating (animate() silently
142
+ // no-ops on elements not yet flushed).
143
+ setTimeout(() => this.#animateIn(), 0);
144
+ }
145
+
146
+ #isFront(): boolean {
147
+ return this.depth() === 0;
148
+ }
149
+
150
+ // Resting state: docked to the bottom of UiToaster's stack container (which
151
+ // owns the screen-edge inset and safe-area lift — see its style comment),
152
+ // lifted/scaled into the stack slot, plus the live drag offset (front only).
153
+ // Height is left to auto so the card grows with its content — this is why
154
+ // the container gives absolutely-positioned children a *definite* (if
155
+ // small) height to resolve `bottom` against: Lynx resolves an absolute
156
+ // element's auto height against its containing block, so a collapsed block
157
+ // squished the card (and its text).
158
+ //
159
+ // The entrance/restack/exit animations run via element.animate() and override
160
+ // `transform`/`opacity` while they play (fill:'forwards'); canceling one (on
161
+ // touchstart) hands control back to this style so the drag can move the toast.
162
+ // `box-shadow` isn't animatable on Lynx, so it lives here and stays put.
163
+ protected readonly hostStyle = computed(() => {
164
+ const d = this.depth();
165
+ const ty = this.dragOffset() - d * STACK_GAP;
166
+ const scale = 1 - d * STACK_SCALE_STEP;
167
+ // Subtle elevation, softening slightly with depth.
168
+ const shadow = `0 ${6 - d}px ${16 - d * 3}px rgba(0, 0, 0, ${(0.15 - d * 0.03).toFixed(2)})`;
169
+ return (
170
+ // left/right (not width) set the width to fill the container; bottom
171
+ // docks to its bottom edge (the container itself carries the safe-area
172
+ // and screen-edge insets, so this is 0, not calc(...) + 1rem).
173
+ `position: absolute; left: 0; right: 0; bottom: 0; z-index: ${100 - d};` +
174
+ // Scale from the bottom edge so stacked toasts keep their base aligned and
175
+ // recede upward — a cleaner fan than center-origin scaling.
176
+ ` transform-origin: 50% 100%; transform: translateY(${ty}px) scale(${scale});` +
177
+ ` box-shadow: ${shadow}; opacity: ${this.#hasEntered() ? 1 : 0};`
178
+ );
179
+ });
180
+
181
+ protected readonly cardClass = computed(() => {
182
+ const isDestructive = this.data().variant === 'destructive';
183
+ // No `w-full`: the host is absolutely positioned and its width comes from
184
+ // the left/right insets in hostStyle(); width:100% would fight `right` and
185
+ // overflow the screen.
186
+ return cn(
187
+ 'flex-row items-start gap-3 rounded-lg border p-4 flex',
188
+ isDestructive
189
+ ? 'border-destructive bg-destructive'
190
+ : 'border-border bg-background',
191
+ );
192
+ });
193
+
194
+ protected readonly titleClass = computed(() => {
195
+ const isDestructive = this.data().variant === 'destructive';
196
+ return cn(
197
+ 'text-sm font-semibold',
198
+ isDestructive ? 'text-destructive-foreground' : 'text-foreground',
199
+ );
200
+ });
201
+
202
+ protected readonly descriptionClass = computed(() => {
203
+ const isDestructive = this.data().variant === 'destructive';
204
+ return cn(
205
+ 'text-sm',
206
+ isDestructive ? 'text-destructive-foreground' : 'text-muted-foreground',
207
+ );
208
+ });
209
+
210
+ protected readonly actionClass = computed(() => {
211
+ const isDestructive = this.data().variant === 'destructive';
212
+ return cn(
213
+ 'items-center rounded-md border px-3 py-1.5 flex justify-center',
214
+ isDestructive ? 'border-destructive-foreground' : 'border-border',
215
+ );
216
+ });
217
+
218
+ protected readonly actionTextClass = computed(() => {
219
+ const isDestructive = this.data().variant === 'destructive';
220
+ return cn(
221
+ 'text-sm font-medium',
222
+ isDestructive ? 'text-destructive-foreground' : 'text-foreground',
223
+ );
224
+ });
225
+
226
+ protected onTap(): void {
227
+ if (!this.#isFront()) return;
228
+ this.dismiss();
229
+ }
230
+
231
+ protected onAction(): void {
232
+ this.data().action?.onAction();
233
+ this.dismiss();
234
+ }
235
+
236
+ /**
237
+ * Begin a drag (front toast only). Cancel the current animation — its
238
+ * fill:'forwards' pins the transform and would override the inline drag
239
+ * offset — and pause the auto-dismiss timer so the toast can't vanish
240
+ * mid-drag.
241
+ */
242
+ protected onTouchStart(e: {
243
+ touches?: ReadonlyArray<{ clientY: number }>;
244
+ }): void {
245
+ if (!this.#isFront()) return;
246
+ const y = e.touches?.[0]?.clientY;
247
+ if (y == null) return;
248
+ this.#anim?.cancel();
249
+ if (this.#dismissTimer) {
250
+ clearTimeout(this.#dismissTimer);
251
+ this.#dismissTimer = null;
252
+ }
253
+ this.#isDragging = true;
254
+ this.#dragStartY = y;
255
+ }
256
+
257
+ protected onTouchMove(e: {
258
+ touches?: ReadonlyArray<{ clientY: number }>;
259
+ }): void {
260
+ if (!this.#isDragging) return;
261
+ const y = e.touches?.[0]?.clientY;
262
+ if (y == null) return;
263
+ // Clamp to >= 0: the toast is docked at the bottom, so only a downward
264
+ // (dismiss-direction) drag should move it.
265
+ this.dragOffset.set(Math.max(0, y - this.#dragStartY));
266
+ }
267
+
268
+ /**
269
+ * Release the drag. Past the threshold, dismiss (drag-aware exit). Otherwise
270
+ * spring back and resume the auto-dismiss timer. The #isDismissing guard makes
271
+ * this a no-op when a concurrent tap already started dismissing.
272
+ */
273
+ protected onTouchEnd(): void {
274
+ if (!this.#isDragging) return;
275
+ this.#isDragging = false;
276
+ if (this.#isDismissing) return;
277
+ if (this.dragOffset() > DISMISS_THRESHOLD) {
278
+ this.dismiss();
279
+ } else {
280
+ this.#snapBack(this.dragOffset());
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Dismiss this toast: animate out, then (after the animation) emit `dismissed`
286
+ * so the host removes it from the queue. Removal is deferred until the
287
+ * animation completes because there's no Angular `:leave` hook here — pulling
288
+ * the node from `@for` immediately would cut the exit short.
289
+ */
290
+ dismiss(): void {
291
+ if (this.#isDismissing) return;
292
+ this.#isDismissing = true;
293
+ if (this.#dismissTimer) {
294
+ clearTimeout(this.#dismissTimer);
295
+ this.#dismissTimer = null;
296
+ }
297
+ this.#animateOut();
298
+ setTimeout(() => this.dismissed.emit(), DURATION.fast + 20);
299
+ }
300
+
301
+ #animateIn(): void {
302
+ const el = this.#host.nativeElement;
303
+ /* v8 ignore next 1 -- host is always mounted when animateIn runs in tests */
304
+ if (!el) return;
305
+ this.#anim?.cancel();
306
+ // Reveal now that the animation owns opacity (0 → 1 below); afterwards the
307
+ // host style keeps it at 1. Without this the resting-position frame before
308
+ // this runs would flash.
309
+ this.#hasEntered.set(true);
310
+
311
+ const d = this.depth();
312
+ this.#anim =
313
+ d === 0
314
+ ? // Front: spring up from below the screen.
315
+ el.animate(
316
+ [
317
+ { transform: 'translateY(100%) scale(0.95)', opacity: 0 },
318
+ { transform: stackTransform(0), opacity: 1 },
319
+ ],
320
+ {
321
+ duration: DURATION.slow,
322
+ easing: EASING.spring,
323
+ fill: 'forwards',
324
+ },
325
+ )
326
+ : // Promoted from the queue straight into a back slot: fade/rise in place.
327
+ el.animate(
328
+ [
329
+ {
330
+ transform: `translateY(${-d * STACK_GAP + 8}px) scale(${(1 - d * STACK_SCALE_STEP) * 0.96})`,
331
+ opacity: 0,
332
+ },
333
+ { transform: stackTransform(d), opacity: 1 },
334
+ ],
335
+ {
336
+ duration: DURATION.normal,
337
+ easing: EASING.decelerate,
338
+ fill: 'forwards',
339
+ },
340
+ );
341
+ }
342
+
343
+ /**
344
+ * * Move to a new stack slot when depth changes (a toast in front popped, or a
345
+ * * new one pushed this one back). This is what makes the stack slide forward
346
+ * * when the front toast is dismissed.
347
+ * v8 ignore start -- only invoked from the restack effect, which never
348
+ * re-runs in JIT (see the effect body above); exercised on device.
349
+ */
350
+ #animateRestack(from: number, to: number): void {
351
+ // A toast pushed while this one is animating out would shift its depth and
352
+ // restack it back into view — let the exit win instead.
353
+ if (this.#isDismissing) return;
354
+ const el = this.#host.nativeElement;
355
+ if (!el) return;
356
+ this.#anim?.cancel();
357
+ this.#anim = el.animate(
358
+ [{ transform: stackTransform(from) }, { transform: stackTransform(to) }],
359
+ { duration: DURATION.normal, easing: EASING.standard, fill: 'forwards' },
360
+ );
361
+ }
362
+ /**
363
+ * v8 ignore stop
364
+ */
365
+
366
+ #animateOut(): void {
367
+ const el = this.#host.nativeElement;
368
+ /* v8 ignore next 1 -- host is always mounted when animateOut runs in tests */
369
+ if (!el) return;
370
+ this.#anim?.cancel();
371
+
372
+ const d = this.depth();
373
+ if (d === 0) {
374
+ // Front: continue from the finger's offset (or 0) straight off the bottom.
375
+ const from = this.dragOffset();
376
+ this.#anim = el.animate(
377
+ [
378
+ { transform: `translateY(${from}px) scale(1)`, opacity: 1 },
379
+ {
380
+ transform: `translateY(${DISMISS_TRANSLATE}px) scale(0.95)`,
381
+ opacity: 0,
382
+ },
383
+ ],
384
+ {
385
+ duration: DURATION.fast,
386
+ easing: EASING.accelerate,
387
+ fill: 'forwards',
388
+ },
389
+ );
390
+ } else {
391
+ // Behind: fade and shrink away in place (e.g. the oldest expiring at the
392
+ // back), without sliding across the front toast.
393
+ this.#anim = el.animate(
394
+ [
395
+ { transform: stackTransform(d), opacity: 1 },
396
+ {
397
+ transform: `translateY(${-d * STACK_GAP}px) scale(${(1 - d * STACK_SCALE_STEP) * 0.9})`,
398
+ opacity: 0,
399
+ },
400
+ ],
401
+ {
402
+ duration: DURATION.fast,
403
+ easing: EASING.accelerate,
404
+ fill: 'forwards',
405
+ },
406
+ );
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Release below the dismiss threshold: spring back to the front resting
412
+ * position (px → 0px) and restart the auto-dismiss timer.
413
+ */
414
+ #snapBack(fromOffset: number): void {
415
+ const el = this.#host.nativeElement;
416
+ this.#anim?.cancel();
417
+ /* v8 ignore start -- host is always mounted when snapBack runs in tests;
418
+ the else branch is defensive against a host-not-yet-committed race */
419
+ if (el) {
420
+ this.#anim = el.animate(
421
+ [
422
+ { transform: `translateY(${fromOffset}px) scale(1)` },
423
+ { transform: 'translateY(0px) scale(1)' },
424
+ ],
425
+ { duration: DURATION.fast, easing: EASING.spring, fill: 'forwards' },
426
+ );
427
+ }
428
+ /* v8 ignore stop */
429
+ // Match the inline transform to the animation's resting state so the two
430
+ // agree once fill:'forwards' hands control back to the style binding.
431
+ this.dragOffset.set(0);
432
+ // Resume auto-dismiss from the full duration — the user interacted, so give
433
+ // them the standard window to read it again.
434
+ this.#dismissTimer = setTimeout(() => this.dismiss(), this.data().duration);
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Stack host. Renders the newest `MAX_VISIBLE` toasts as an overlapping,
440
+ * pseudo-3D stack (newest in front, older ones scaled down and shifted up).
441
+ *
442
+ * All per-toast behavior — entrance/exit/restack animation, the auto-dismiss
443
+ * timer, and drag-to-dismiss — lives in `UiToastItem`, mirroring how the
444
+ * accordion delegates per-item animation to a child component. This host only
445
+ * decides which toasts are on screen and at what depth, and removes a toast from
446
+ * the shared queue once its child has finished animating out.
447
+ */
448
+ @Component({
449
+ selector: 'ui-toaster',
450
+ standalone: true,
451
+ imports: [LYNX_ELEMENTS, UiToastItem],
452
+ encapsulation: ViewEncapsulation.None,
453
+ template: `
454
+ <!--
455
+ Deliberately NOT an <overlay>. The previous implementation wrapped the
456
+ stack in <overlay> (needed a full-screen first child to avoid Lynx's
457
+ "collapses to top-left" layout quirk — see the note below). Symptom:
458
+ while ANY toast was visible, every tap and long-press anywhere else in
459
+ the app — even a header button nowhere near the toast — silently did
460
+ nothing, for the toast's entire lifetime (confirmed with an on-screen
461
+ tap counter, since Lynx has no attachable console on-device).
462
+ Root cause: <overlay> is Lynx's modal-flavored native construct — a real
463
+ separate paint surface (a genuinely separate UIWindow on iOS unless
464
+ mode="page" is set) with its own bespoke hit-testing
465
+ (UIOverlay::OnNodeEvent / LynxOverlayContainer.hitTest on Harmony/iOS).
466
+ That's correct for action-sheet/dialog/bottom-sheet, which SHOULD block
467
+ the rest of the app. The toast is explicitly non-modal — the rest of the
468
+ app must stay interactive while it's showing — so it had no business
469
+ being built on a modal primitive at all.
470
+ event-through (an inheritable per-view attribute honored by <overlay>'s
471
+ hit-testing) looked like the fix and checked out against the native
472
+ source on paper, but did not stop full-screen touch capture in testing;
473
+ neither did also forcing mode="page" (which stops <overlay> from
474
+ defaulting to a separate native window) — see
475
+ investigations/lynx-vs-web-differences.md for the abandoned attempts.
476
+ A plain view sidesteps the whole question: ordinary Lynx views are hit
477
+ exactly where they're actually drawn, via the SAME hit-testing every
478
+ other element in the app already relies on — no special attribute
479
+ needed, nothing overlay-specific to get wrong.
480
+ @if (not [attr.visible]) is safe here (unlike for <overlay>) because
481
+ there's no live native window to orphan — that re-homing hazard is
482
+ specific to <overlay> (see the "orphaned <overlay>" note in
483
+ investigations/lynx-vs-web-differences.md).
484
+ -->
485
+ @if (hasToasts()) {
486
+ <view [style]="stackContainerStyle">
487
+ @for (t of visible(); track t.id; let i = $index) {
488
+ <ui-toast-item
489
+ [data]="t"
490
+ [depth]="visible().length - 1 - i"
491
+ (dismissed)="onDismissed(t.id)"
492
+ />
493
+ }
494
+ </view>
495
+ }
496
+ `,
497
+ })
498
+ export class UiToaster {
499
+ // Newest `MAX_VISIBLE`, in chronological order (last = newest = front).
500
+ protected readonly visible = computed(() => toasts().slice(-MAX_VISIBLE));
501
+
502
+ // Kept mounted as long as any toast exists. A dismissing toast stays in the
503
+ // array until its child emits `dismissed` (after its exit animation), so
504
+ // the container never unmounts mid-animation.
505
+ protected readonly hasToasts = computed(() => toasts().length > 0);
506
+
507
+ // position:fixed reparents this to the page root and positions it against
508
+ // the full screen (Lynx-documented behavior), landing each toast card's
509
+ // absolute left/right/bottom in the same place <overlay>'s backdrop did.
510
+ // height is a small, explicit, DEFINITE value — not full-screen — so this
511
+ // container only occupies real screen space near the toast stack, and
512
+ // ordinary hit-testing naturally leaves the rest of the screen alone. It
513
+ // exists solely so the absolutely-positioned cards inside have a definite
514
+ // height to resolve `bottom: 0` against (an auto/collapsed height here
515
+ // squishes them — see UiToastItem.hostStyle); it does not need to match
516
+ // real toast content height, because `overflow: visible` lets both painting
517
+ // and hit-testing extend past it for any card taller than this floor.
518
+ protected readonly stackContainerStyle =
519
+ 'position: fixed; left: 1rem; right: 1rem; bottom: calc(env(safe-area-inset-bottom) + 1rem); overflow: visible; height: 8rem;';
520
+
521
+ protected onDismissed(id: string): void {
522
+ // Removing here (after the child's exit animation) drops the toast from the
523
+ // stack; any queued toast then slides into the freed slot, and remaining
524
+ // toasts restack forward via their depth-watching effect.
525
+ dismissToast(id);
526
+ }
527
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ UiToggle,
3
+ UiToggleGroup,
4
+ UiToggleGroupItem,
5
+ type ToggleVariant,
6
+ type ToggleSize,
7
+ } from './toggle';