@blotch/dolan 0.0.0-stage → 22.2.0

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,134 @@
1
+ import { signal } from '@angular/core';
2
+ import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { setInputSignal } from '../../../test-utils/set-input-signal';
5
+ import { UiInput } from './input';
6
+
7
+ describe('UiInput', () => {
8
+ it('renders with default configuration', async () => {
9
+ const { container } = await render(UiInput);
10
+ expect(container).toBeTruthy();
11
+ });
12
+
13
+ it('onInput() updates the model and emits `changed`', async () => {
14
+ const { componentRef } = await render(UiInput);
15
+ const inst = componentRef.instance as unknown as UiInput & {
16
+ onInput: (e: { detail: { value: string } }) => void;
17
+ };
18
+ const emitted: string[] = [];
19
+ inst.changed.subscribe((v) => emitted.push(v));
20
+ inst.onInput({ detail: { value: 'hello' } });
21
+ expect(inst.value()).toBe('hello');
22
+ expect(emitted).toEqual(['hello']);
23
+ });
24
+
25
+ it('onInput() is a no-op when disabled', async () => {
26
+ const { componentRef } = await render(UiInput);
27
+ const inst = componentRef.instance as unknown as UiInput & {
28
+ onInput: (e: { detail: { value: string } }) => void;
29
+ };
30
+ setInputSignal(inst.disabled, true);
31
+ const emitted = vi.fn();
32
+ inst.changed.subscribe(emitted);
33
+ inst.onInput({ detail: { value: 'blocked' } });
34
+ expect(inst.value()).toBe('');
35
+ expect(emitted).not.toHaveBeenCalled();
36
+ });
37
+
38
+ it('onFocus() flips the focused state and emits `focused`', async () => {
39
+ const { componentRef, container } = await render(UiInput);
40
+ const inst = componentRef.instance as unknown as UiInput & {
41
+ onFocus: () => void;
42
+ };
43
+ const emitted = vi.fn();
44
+ inst.focused.subscribe(emitted);
45
+ inst.onFocus();
46
+ await waitForUpdate();
47
+ expect(emitted).toHaveBeenCalledTimes(1);
48
+ // Focus turns the overlay opacity to 1 via ringVisible().
49
+ const overlay = container.querySelector('[style*="box-shadow"]');
50
+ expect(overlay?.getAttribute('style')).toContain('opacity');
51
+ });
52
+
53
+ it('onBlur() clears focused state and emits `blurred`', async () => {
54
+ const { componentRef } = await render(UiInput);
55
+ const inst = componentRef.instance as unknown as UiInput & {
56
+ onFocus: () => void;
57
+ onBlur: () => void;
58
+ };
59
+ const emitted = vi.fn();
60
+ inst.blurred.subscribe(emitted);
61
+ inst.onFocus();
62
+ inst.onBlur();
63
+ expect(emitted).toHaveBeenCalledTimes(1);
64
+ });
65
+
66
+ it('error input triggers the shake animation via effect', async () => {
67
+ // The effect reads error() + containerRef.nativeElement and calls shake().
68
+ // Setting error to a non-empty value while it was empty exercises the branch.
69
+ // JIT + jsdom doesn't populate template-ref viewChild queries, so we
70
+ // monkey-patch containerRef with a signal returning a fake ElementRef so the
71
+ // effect's `el` check passes and the shake path runs.
72
+ const { componentRef, container } = await render(UiInput);
73
+ const inst = componentRef.instance as UiInput;
74
+ const view = container.querySelector('view') as Element;
75
+ (inst as unknown as { containerRef: unknown }).containerRef = signal({
76
+ nativeElement: view,
77
+ });
78
+ setInputSignal(inst.error, 'Required');
79
+ await waitForUpdate();
80
+ await waitForUpdate();
81
+ // The error text should render and the destructive ring shadow should apply.
82
+ expect(container.textContent).toContain('Required');
83
+
84
+ // Re-trigger the shake (previous error cleared → then set) so `#shakeAnim`
85
+ // is set once and canceled the next time — covers both branches of `?.`.
86
+ setInputSignal(inst.error, '');
87
+ await waitForUpdate();
88
+ await waitForUpdate();
89
+ setInputSignal(inst.error, 'Required again');
90
+ await waitForUpdate();
91
+ await waitForUpdate();
92
+ expect(container.textContent).toContain('Required again');
93
+ });
94
+
95
+ it('renders helper text when no error is present', async () => {
96
+ const { componentRef, container } = await render(UiInput);
97
+ const inst = componentRef.instance as UiInput;
98
+ setInputSignal(inst.helperText, 'Enter your name');
99
+ await waitForUpdate();
100
+ expect(container.textContent).toContain('Enter your name');
101
+ });
102
+
103
+ it('renders the label when provided', async () => {
104
+ const { componentRef, container } = await render(UiInput);
105
+ const inst = componentRef.instance as UiInput;
106
+ setInputSignal(inst.label, 'Name');
107
+ await waitForUpdate();
108
+ expect(container.textContent).toContain('Name');
109
+ });
110
+
111
+ it('makes the wrapper a stacking context so the field scrolls with it', async () => {
112
+ // On Lynx a z-indexed element inside a scroll-view only follows the scroll
113
+ // when an ancestor inside the scroll content is a stacking context. Without
114
+ // z-0 on the wrapper, the native field (and its placeholder) stays fixed on
115
+ // screen while the page scrolls.
116
+ const { container } = await render(UiInput);
117
+ const wrapper = container.querySelector(
118
+ '[class*="rounded-xl"][class*="bg-muted"]',
119
+ );
120
+ expect(wrapper?.getAttribute('class')?.split(/\s+/)).toContain('z-0');
121
+ });
122
+
123
+ it('applies the disabled dim class when disabled', async () => {
124
+ // Covers the true branch of `disabled() && 'opacity-50'` in inputWrapperClass.
125
+ const { componentRef, container } = await render(UiInput);
126
+ const inst = componentRef.instance as UiInput;
127
+ setInputSignal(inst.disabled, true);
128
+ await waitForUpdate();
129
+ const wrapper = container.querySelector(
130
+ '[class*="rounded-xl"][class*="bg-muted"]',
131
+ );
132
+ expect(wrapper?.getAttribute('class')).toContain('opacity-50');
133
+ });
134
+ });
@@ -0,0 +1,189 @@
1
+ import {
2
+ type ElementRef,
3
+ Component,
4
+ ViewEncapsulation,
5
+ computed,
6
+ effect,
7
+ input,
8
+ model,
9
+ output,
10
+ signal,
11
+ viewChild,
12
+ } from '@angular/core';
13
+ import { type FormValueControl } from '@angular/forms/signals';
14
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
15
+
16
+ import { type AnimationHandle, shake } from '../../utils/animate';
17
+ import { cn } from '../../utils/cn';
18
+
19
+ @Component({
20
+ selector: 'ui-input',
21
+ standalone: true,
22
+ imports: [LYNX_ELEMENTS],
23
+ encapsulation: ViewEncapsulation.None,
24
+ template: `
25
+ <view #container [class]="containerClass()">
26
+ @if (label()) {
27
+ <text [class]="labelClass()">{{ label() }}</text>
28
+ }
29
+ <!-- Focus ring is an inline box-shadow on a dedicated overlay layer, not a
30
+ Tailwind ring-* class (the Lynx preset doesn't wire up --tw-ring-*, so
31
+ ring-* renders nothing on device). It rides its OWN layer so it can
32
+ fade: box-shadow is animatable:no on Lynx (the core rejects a
33
+ transition: box-shadow), but opacity is animatable, so we transition
34
+ the layer's opacity instead. The overlay is the FIRST child so Lynx,
35
+ which paints in strict source order, keeps the <input> on top and
36
+ tappable. Source order alone is NOT enough on web: CSS paints a
37
+ positioned (absolute) sibling above a static one regardless of source
38
+ order, so this transparent overlay would swallow every tap — no focus,
39
+ no typing. The <input> below therefore carries position:relative +
40
+ z-index:1 so it sits above the overlay on BOTH platforms (required on
41
+ web). On Lynx, though, a z-indexed element is stacked against its
42
+ nearest stacking context, and a scroll-view isn't one unless it sets
43
+ z-index itself — so the native input would stay fixed on screen while
44
+ the page scrolled (only its placeholder visibly floats). The wrapper's
45
+ z-0 makes it that stacking context, keeping the input's z-index local
46
+ so it scrolls with the field. pointer-events is avoided — it errors
47
+ the Lynx build. The shadow is OUTSET only: Lynx doesn't render inset box-shadows,
48
+ so the crisp 1px "border" is an outset ring hugging the edge, not a
49
+ real inset border. -->
50
+ <view [class]="inputWrapperClass()">
51
+ <view
52
+ class="rounded-xl opacity-0 absolute bottom-0 left-0 right-0 top-0"
53
+ [style.transition]="'opacity 150ms ease'"
54
+ [style.box-shadow]="ringShadow()"
55
+ [style.opacity]="ringVisible() ? 1 : 0"
56
+ ></view>
57
+ <!-- [attr.disabled]="disabled() || undefined": passing 'undefined'
58
+ removes the attribute entirely; passing 'false' would set
59
+ disabled="false" which Lynx still treats as disabled. -->
60
+ <!-- [value] not [attr.value]: [attr.value] calls renderer.setAttribute()
61
+ which routes to __SetAttribute — a no-op for live text on Lynx native
62
+ inputs. [value] is an Angular input binding that triggers
63
+ LynxInput.ngOnChanges, where the setValue UIMethod override lives.
64
+ Without this, form model resets would update the signal but the
65
+ native input would keep showing the user's old typed text. -->
66
+ <input
67
+ [attr.placeholder]="placeholder()"
68
+ [attr.type]="type()"
69
+ [value]="value()"
70
+ [attr.disabled]="disabled() || undefined"
71
+ class="text-sm text-foreground"
72
+ style="border: none; background: transparent; height: 100%; width: 100%; position: relative; z-index: 1;"
73
+ (bindinput)="onInput($any($event))"
74
+ (bindfocus)="onFocus()"
75
+ (bindblur)="onBlur()"
76
+ />
77
+ </view>
78
+ @if (error()) {
79
+ <text [class]="errorClass()">{{ error() }}</text>
80
+ } @else if (helperText()) {
81
+ <text [class]="helperClass()">{{ helperText() }}</text>
82
+ }
83
+ </view>
84
+ `,
85
+ })
86
+ export class UiInput implements FormValueControl<string> {
87
+ readonly value = model<string>('');
88
+ readonly label = input<string>('');
89
+ readonly placeholder = input<string>('');
90
+ readonly type = input<string>('text');
91
+ readonly helperText = input<string>('');
92
+ readonly error = input<string>('');
93
+ readonly disabled = input(false);
94
+ readonly userClass = input<string>('', { alias: 'class' });
95
+
96
+ readonly changed = output<string>();
97
+ readonly focused = output<void>();
98
+ readonly blurred = output<void>();
99
+
100
+ readonly containerRef = viewChild<ElementRef>('container');
101
+ readonly #isFocused = signal(false);
102
+ #shakeAnim?: AnimationHandle;
103
+ #previousError = '';
104
+
105
+ constructor() {
106
+ // Shake the input when an error appears (transition from no-error to error)
107
+ effect(() => {
108
+ const err = this.error();
109
+ const el = this.containerRef()?.nativeElement;
110
+ if (err && !this.#previousError && el) {
111
+ this.#shakeAnim?.cancel();
112
+ this.#shakeAnim = shake(el);
113
+ }
114
+ this.#previousError = err;
115
+ });
116
+ }
117
+
118
+ protected readonly containerClass = computed(() =>
119
+ cn('flex-col gap-1.5 flex', this.userClass()),
120
+ );
121
+
122
+ protected readonly labelClass = computed(() =>
123
+ cn('text-sm font-medium text-foreground'),
124
+ );
125
+
126
+ protected readonly inputWrapperClass = computed(() =>
127
+ // z-0: stacking context for the input's z-index (see the template comment).
128
+ cn(
129
+ 'z-0 h-10 rounded-xl bg-muted px-3.5 py-2',
130
+ this.disabled() && 'opacity-50',
131
+ ),
132
+ );
133
+
134
+ /**
135
+ * Focus ring, drawn as an OUTSET box-shadow on a dedicated overlay layer (see
136
+ * the template comment for why it can't be a Tailwind ring-* class and why it
137
+ * needs its own layer). Two stacked outset shadows: a solid 1px ring hugging
138
+ * the edge (reads as a crisp border) and a softer 3px ring around it — both
139
+ * follow the wrapper's rounded-xl corners. Focus pairs var(--ring) with the
140
+ * translucent var(--ring-subtle); error uses solid var(--destructive) for both
141
+ * since it flags a problem. The value is always set (never null) so the shadow
142
+ * stays painted while the layer fades OUT — visibility is driven by
143
+ * ringVisible()/opacity, not by adding/removing the shadow. Colors resolve at
144
+ * runtime and track dark mode.
145
+ */
146
+ protected readonly ringShadow = computed(() =>
147
+ this.error()
148
+ ? '0 0 0 1px var(--destructive), 0 0 0 3px var(--destructive)'
149
+ : '0 0 0 1px var(--ring), 0 0 0 3px var(--ring-subtle)',
150
+ );
151
+
152
+ /**
153
+ * Whether the focus ring is shown. The overlay's opacity transitions between 0
154
+ * and 1 on this, fading the ring in/out (opacity is animatable on Lynx;
155
+ * box-shadow is not). Shown while focused, or while an error is present so an
156
+ * invalid field always carries the destructive ring.
157
+ */
158
+ protected readonly ringVisible = computed(
159
+ () => this.#isFocused() || this.error() !== '',
160
+ );
161
+
162
+ protected readonly helperClass = computed(() =>
163
+ cn('text-xs text-muted-foreground'),
164
+ );
165
+
166
+ protected readonly errorClass = computed(() =>
167
+ cn('text-xs text-destructive'),
168
+ );
169
+
170
+ protected onFocus(): void {
171
+ this.#isFocused.set(true);
172
+ this.focused.emit();
173
+ }
174
+
175
+ protected onBlur(): void {
176
+ this.#isFocused.set(false);
177
+ this.blurred.emit();
178
+ }
179
+
180
+ /**
181
+ * Lynx input events carry the updated value in `event.detail.value`,
182
+ * not in `event.target.value` as on the web — hence the custom type.
183
+ */
184
+ protected onInput(event: { detail: { value: string } }): void {
185
+ if (this.disabled()) return;
186
+ this.value.set(event.detail.value);
187
+ this.changed.emit(event.detail.value);
188
+ }
189
+ }
@@ -0,0 +1 @@
1
+ export { UiLabel } from './label';
@@ -0,0 +1,38 @@
1
+ import { Component } from '@angular/core';
2
+ import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { setInputSignal } from '../../../test-utils/set-input-signal';
6
+ import { UiLabel } from './label';
7
+
8
+ @Component({
9
+ standalone: true,
10
+ imports: [UiLabel, LYNX_ELEMENTS],
11
+ template: `<ui-label>{{ label }}</ui-label>`,
12
+ })
13
+ class LabelHost {
14
+ label = 'Name';
15
+ }
16
+
17
+ describe('UiLabel', () => {
18
+ it('renders projected text', async () => {
19
+ const { getByText } = await render(LabelHost);
20
+ expect(getByText('Name')).toBeTruthy();
21
+ });
22
+
23
+ it('renders standalone with no host wrapper (default disabled=false)', async () => {
24
+ const { container } = await render(UiLabel);
25
+ const textEl = container.querySelector('text');
26
+ expect(textEl?.getAttribute('class')).toContain('font-medium');
27
+ });
28
+
29
+ it('applies opacity when disabled flips to true', async () => {
30
+ // JIT template bindings don't wire signal inputs — drive the input
31
+ // directly to exercise the `disabled() && 'opacity-50'` truthy branch.
32
+ const { componentRef, container } = await render(UiLabel);
33
+ setInputSignal((componentRef.instance as UiLabel).disabled, true);
34
+ await waitForUpdate();
35
+ const textEl = container.querySelector('text');
36
+ expect(textEl?.getAttribute('class')).toContain('opacity-50');
37
+ });
38
+ });
@@ -0,0 +1,24 @@
1
+ import { Component, ViewEncapsulation, computed, input } from '@angular/core';
2
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
3
+
4
+ import { cn } from '../../utils/cn';
5
+
6
+ @Component({
7
+ selector: 'ui-label',
8
+ standalone: true,
9
+ imports: [LYNX_ELEMENTS],
10
+ encapsulation: ViewEncapsulation.None,
11
+ template: `<text [class]="labelClass()"><ng-content /></text>`,
12
+ })
13
+ export class UiLabel {
14
+ readonly disabled = input(false);
15
+ readonly userClass = input<string>('', { alias: 'class' });
16
+
17
+ protected readonly labelClass = computed(() =>
18
+ cn(
19
+ 'text-sm font-medium text-foreground',
20
+ this.disabled() && 'opacity-50',
21
+ this.userClass(),
22
+ ),
23
+ );
24
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ UiLineChart,
3
+ UiLineSeries,
4
+ computeLineSegments,
5
+ type LineSegment,
6
+ } from './line-chart';
@@ -0,0 +1,121 @@
1
+ import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { setInputSignal } from '../../../test-utils/set-input-signal';
4
+ import { UiCartesianChart } from '../cartesian-chart/cartesian-chart';
5
+ import { UiLineChart, UiLineSeries } from './line-chart';
6
+
7
+ describe('UiLineChart', () => {
8
+ it('renders with default configuration', async () => {
9
+ const { container } = await render(UiLineChart);
10
+ expect(container).toBeTruthy();
11
+ });
12
+
13
+ it('computes axis domains from data', async () => {
14
+ const { componentRef } = await render(UiLineChart);
15
+ const inst = componentRef.instance as UiLineChart;
16
+ setInputSignal(inst.data, [
17
+ { x: 0, y: 0 },
18
+ { x: 5, y: 10 },
19
+ { x: 10, y: 5 },
20
+ ]);
21
+ await waitForUpdate();
22
+ const cast = inst as unknown as {
23
+ xAxis: () => { domain: readonly [number, number]; ticks: number[] };
24
+ yAxis: () => { domain: readonly [number, number]; ticks: number[] };
25
+ };
26
+ expect(cast.xAxis().domain[0]).toBeLessThanOrEqual(0);
27
+ expect(cast.xAxis().domain[1]).toBeGreaterThanOrEqual(10);
28
+ expect(cast.yAxis().domain[0]).toBeLessThanOrEqual(0);
29
+ expect(cast.yAxis().domain[1]).toBeGreaterThanOrEqual(10);
30
+ });
31
+
32
+ it('honors explicit xMin / xMax / yMin / yMax overrides', async () => {
33
+ const { componentRef } = await render(UiLineChart);
34
+ const inst = componentRef.instance as UiLineChart;
35
+ setInputSignal(inst.data, [
36
+ { x: 0, y: 0 },
37
+ { x: 10, y: 10 },
38
+ ]);
39
+ setInputSignal(inst.xMin, -5);
40
+ setInputSignal(inst.xMax, 20);
41
+ setInputSignal(inst.yMin, -1);
42
+ setInputSignal(inst.yMax, 15);
43
+ await waitForUpdate();
44
+ const cast = inst as unknown as {
45
+ xAxis: () => { domain: readonly [number, number] };
46
+ yAxis: () => { domain: readonly [number, number] };
47
+ };
48
+ expect(cast.xAxis().domain[0]).toBeLessThanOrEqual(-5);
49
+ expect(cast.xAxis().domain[1]).toBeGreaterThanOrEqual(20);
50
+ expect(cast.yAxis().domain[0]).toBeLessThanOrEqual(-1);
51
+ expect(cast.yAxis().domain[1]).toBeGreaterThanOrEqual(15);
52
+ });
53
+
54
+ it('exposes the default tick formatter used by the child cartesian chart', async () => {
55
+ const { componentRef } = await render(UiLineChart);
56
+ const inst = componentRef.instance as UiLineChart;
57
+ // JIT doesn't wire the child cartesian chart's [xTickFormat] input, so the
58
+ // default formatter is never invoked through the template. Invoke it
59
+ // directly to cover its two-decimal rounding branch.
60
+ expect(inst.xTickFormat()(3.14159)).toBe('3.14');
61
+ expect(inst.yTickFormat()(1.005)).toBe('1');
62
+ });
63
+ });
64
+
65
+ describe('UiLineSeries', () => {
66
+ const renderSeries = async () => {
67
+ const parentResult = await render(UiCartesianChart);
68
+ const parent = parentResult.componentRef.instance as UiCartesianChart;
69
+ setInputSignal(parent.xDomain, [0, 10]);
70
+ setInputSignal(parent.yDomain, [0, 10]);
71
+ await waitForUpdate();
72
+ const seriesResult = await render(UiLineSeries, {
73
+ providers: [{ provide: UiCartesianChart, useValue: parent }],
74
+ });
75
+ const series = seriesResult.componentRef.instance as UiLineSeries;
76
+ setInputSignal(series.data, [
77
+ { x: 0, y: 0 },
78
+ { x: 5, y: 5 },
79
+ { x: 10, y: 10 },
80
+ ]);
81
+ await waitForUpdate();
82
+ return { parent, series, seriesResult };
83
+ };
84
+
85
+ it('renders segments and dots for the projected data', async () => {
86
+ const { series } = await renderSeries();
87
+ const cast = series as unknown as {
88
+ segments: () => { style: string }[];
89
+ dots: () => { style: string }[];
90
+ };
91
+ expect(cast.segments().length).toBeGreaterThan(0);
92
+ expect(cast.dots().length).toBe(3);
93
+ });
94
+
95
+ it('hides dots when showDots is false', async () => {
96
+ const { series } = await renderSeries();
97
+ setInputSignal(series.showDots, false);
98
+ await waitForUpdate();
99
+ const cast = series as unknown as { dots: () => { style: string }[] };
100
+ expect(cast.dots()).toEqual([]);
101
+ });
102
+
103
+ it('emits pointTap when onPointTap is invoked', async () => {
104
+ const { series } = await renderSeries();
105
+ const cb = vi.fn();
106
+ series.pointTap.subscribe(cb);
107
+ (series as unknown as { onPointTap: (p: unknown) => void }).onPointTap({
108
+ x: 1,
109
+ y: 2,
110
+ });
111
+ expect(cb).toHaveBeenCalledWith({ x: 1, y: 2 });
112
+ });
113
+
114
+ it('smooth mode resamples through sampleSmoothLine', async () => {
115
+ const { series } = await renderSeries();
116
+ setInputSignal(series.smooth, true);
117
+ await waitForUpdate();
118
+ const cast = series as unknown as { segments: () => { style: string }[] };
119
+ expect(cast.segments().length).toBeGreaterThan(2);
120
+ });
121
+ });
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ // Mock Angular (and the sibling cartesian-chart's Angular usage, which loads
4
+ // transitively) so importing the module needs no framework runtime. Only the
5
+ // exported pure `computeLineSegments` is exercised; components are never built.
6
+ vi.mock('@angular/core', () => ({
7
+ Component: () => () => {},
8
+ ViewEncapsulation: { None: 0 },
9
+ computed: () => () => {},
10
+ inject: () => ({}),
11
+ input: () => () => {},
12
+ output: () => () => {},
13
+ }));
14
+
15
+ vi.mock('@blotch/angular-lynx', () => ({
16
+ LYNX_ELEMENTS: [],
17
+ // cartesian-chart (imported transitively) registers these in its decorator.
18
+ LynxGestureDetector: class {},
19
+ PanGesture: class {},
20
+ PinchGesture: class {},
21
+ Gesture: { Simultaneous: () => ({}) },
22
+ }));
23
+
24
+ const { computeLineSegments } = await import('./line-chart');
25
+
26
+ describe('computeLineSegments', () => {
27
+ it('produces a horizontal segment (angle 0)', () => {
28
+ const segments = computeLineSegments([
29
+ { x: 0, y: 10 },
30
+ { x: 100, y: 10 },
31
+ ]);
32
+ expect(segments).toHaveLength(1);
33
+ expect(segments[0].x).toBe(0);
34
+ expect(segments[0].y).toBe(10);
35
+ expect(segments[0].length).toBeCloseTo(100);
36
+ expect(segments[0].angle).toBeCloseTo(0);
37
+ });
38
+
39
+ it('measures a vertical segment as 90 degrees (screen y-down)', () => {
40
+ const segments = computeLineSegments([
41
+ { x: 0, y: 0 },
42
+ { x: 0, y: 50 },
43
+ ]);
44
+ expect(segments[0].length).toBeCloseTo(50);
45
+ expect(segments[0].angle).toBeCloseTo(90);
46
+ });
47
+
48
+ it('measures a 45-degree diagonal', () => {
49
+ const segments = computeLineSegments([
50
+ { x: 0, y: 0 },
51
+ { x: 10, y: 10 },
52
+ ]);
53
+ expect(segments[0].length).toBeCloseTo(Math.sqrt(200));
54
+ expect(segments[0].angle).toBeCloseTo(45);
55
+ });
56
+
57
+ it('emits one segment per consecutive pair', () => {
58
+ const segments = computeLineSegments([
59
+ { x: 0, y: 0 },
60
+ { x: 10, y: 5 },
61
+ { x: 20, y: 0 },
62
+ ]);
63
+ expect(segments).toHaveLength(2);
64
+ });
65
+
66
+ it('skips zero-length (duplicate) points', () => {
67
+ const segments = computeLineSegments([
68
+ { x: 5, y: 5 },
69
+ { x: 5, y: 5 },
70
+ { x: 10, y: 5 },
71
+ ]);
72
+ expect(segments).toHaveLength(1);
73
+ expect(segments[0].x).toBe(5);
74
+ expect(segments[0].length).toBeCloseTo(5);
75
+ });
76
+
77
+ it('returns no segments for fewer than two points', () => {
78
+ expect(computeLineSegments([])).toEqual([]);
79
+ expect(computeLineSegments([{ x: 1, y: 1 }])).toEqual([]);
80
+ });
81
+ });