@blotch/dolan 22.2.3 → 22.2.5

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 (227) 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 +38 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +258 -21
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +425 -11
  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 +105 -2
  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 +7 -0
  17. package/dist/commands/update.js +194 -50
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +570 -3
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +17 -0
  22. package/dist/lockfile.js +56 -1
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +65 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +119 -11
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +61 -6
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +27 -0
  33. package/dist/test-utils.js +45 -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/boolean-inputs.test.ts +76 -0
  51. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  52. package/dist/ui/components/accordion/accordion.ts +19 -14
  53. package/dist/ui/components/action-sheet/action-sheet.test.ts +637 -183
  54. package/dist/ui/components/action-sheet/action-sheet.ts +339 -55
  55. package/dist/ui/components/action-sheet/index.ts +1 -0
  56. package/dist/ui/components/alert/alert.test.ts +67 -19
  57. package/dist/ui/components/alert/alert.ts +14 -16
  58. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +740 -129
  59. package/dist/ui/components/alert-dialog/alert-dialog.ts +672 -58
  60. package/dist/ui/components/alert-dialog/index.ts +5 -0
  61. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  62. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  63. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  64. package/dist/ui/components/badge/badge.test.ts +57 -23
  65. package/dist/ui/components/badge/badge.ts +14 -15
  66. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  67. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +1314 -273
  68. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1046 -119
  69. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  70. package/dist/ui/components/button/button.test.ts +646 -2
  71. package/dist/ui/components/button/button.ts +443 -64
  72. package/dist/ui/components/button/index.ts +2 -0
  73. package/dist/ui/components/button-group/button-group.test.ts +528 -2
  74. package/dist/ui/components/button-group/button-group.ts +428 -70
  75. package/dist/ui/components/button-group/index.ts +3 -0
  76. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  77. package/dist/ui/components/card/card.test.ts +97 -61
  78. package/dist/ui/components/card/card.ts +2 -1
  79. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  80. package/dist/ui/components/chart/chart-presets.ts +14 -7
  81. package/dist/ui/components/chart/chart-theme.ts +12 -0
  82. package/dist/ui/components/chart/chart.test.ts +1031 -0
  83. package/dist/ui/components/chart/chart.ts +66 -46
  84. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  85. package/dist/ui/components/checkbox/checkbox.ts +25 -24
  86. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  87. package/dist/ui/components/collapsible/collapsible.ts +21 -15
  88. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  89. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  90. package/dist/ui/components/context-menu/index.ts +1 -0
  91. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  92. package/dist/ui/components/dialog/dialog.ts +35 -29
  93. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  94. package/dist/ui/components/dock/dock-scroll.ts +142 -0
  95. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  96. package/dist/ui/components/dock/dock.test.ts +752 -0
  97. package/dist/ui/components/dock/dock.ts +1438 -0
  98. package/dist/ui/components/dock/index.ts +2 -0
  99. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  100. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  101. package/dist/ui/components/dynamic-color/index.ts +5 -0
  102. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  103. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  104. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  105. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  106. package/dist/ui/components/glass/glass-design.ts +119 -0
  107. package/dist/ui/components/glass/glass.test.ts +376 -0
  108. package/dist/ui/components/glass/glass.ts +430 -0
  109. package/dist/ui/components/glass/index.ts +19 -0
  110. package/dist/ui/components/icon/icon-theme.test.ts +64 -0
  111. package/dist/ui/components/icon/icon-theme.ts +70 -16
  112. package/dist/ui/components/icon/icon.test.ts +52 -41
  113. package/dist/ui/components/input/input.test.ts +178 -101
  114. package/dist/ui/components/input/input.ts +17 -13
  115. package/dist/ui/components/label/label.test.ts +11 -24
  116. package/dist/ui/components/label/label.ts +8 -2
  117. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  118. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  119. package/dist/ui/components/list/list.test.ts +80 -49
  120. package/dist/ui/components/list/list.ts +5 -4
  121. package/dist/ui/components/menu/index.ts +25 -0
  122. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  123. package/dist/ui/components/menu/menu-placement.ts +159 -0
  124. package/dist/ui/components/menu/menu-popup.test.ts +636 -0
  125. package/dist/ui/components/menu/menu-popup.ts +565 -0
  126. package/dist/ui/components/menu/menu.test.ts +523 -0
  127. package/dist/ui/components/menu/menu.ts +455 -0
  128. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  129. package/dist/ui/components/nav-drawer/nav-drawer.ts +75 -58
  130. package/dist/ui/components/navigation-bar/index.ts +1 -0
  131. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  132. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  133. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  134. package/dist/ui/components/popover/index.ts +9 -0
  135. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  136. package/dist/ui/components/popover/popover-panel.ts +603 -0
  137. package/dist/ui/components/popover/popover.test.ts +286 -0
  138. package/dist/ui/components/popover/popover.ts +207 -0
  139. package/dist/ui/components/progress/progress.test.ts +102 -46
  140. package/dist/ui/components/progress/progress.ts +54 -55
  141. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  142. package/dist/ui/components/radio-group/radio-group.ts +26 -25
  143. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  144. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  145. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  146. package/dist/ui/components/search-field/index.ts +15 -0
  147. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  148. package/dist/ui/components/search-field/keyboard.ts +123 -0
  149. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  150. package/dist/ui/components/search-field/search-field.ts +628 -0
  151. package/dist/ui/components/segmented-control/index.ts +8 -0
  152. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  153. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  154. package/dist/ui/components/select/select.test.ts +138 -78
  155. package/dist/ui/components/select/select.ts +19 -12
  156. package/dist/ui/components/separator/separator.test.ts +18 -17
  157. package/dist/ui/components/sheet/sheet.test.ts +151 -11
  158. package/dist/ui/components/sheet/sheet.ts +68 -5
  159. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  160. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  161. package/dist/ui/components/slider/index.ts +1 -0
  162. package/dist/ui/components/slider/slider.test.ts +695 -0
  163. package/dist/ui/components/slider/slider.ts +901 -0
  164. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  165. package/dist/ui/components/spinner/spinner.ts +16 -19
  166. package/dist/ui/components/switch/index.ts +1 -1
  167. package/dist/ui/components/switch/switch.test.ts +534 -48
  168. package/dist/ui/components/switch/switch.ts +613 -48
  169. package/dist/ui/components/tab-bar/index.ts +1 -0
  170. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  171. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  172. package/dist/ui/components/tabs/index.ts +7 -1
  173. package/dist/ui/components/tabs/tabs.test.ts +484 -150
  174. package/dist/ui/components/tabs/tabs.ts +344 -149
  175. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  176. package/dist/ui/components/textarea/textarea.ts +16 -13
  177. package/dist/ui/components/toast/toast.test.ts +434 -350
  178. package/dist/ui/components/toast/toast.ts +42 -51
  179. package/dist/ui/components/toggle/index.ts +1 -0
  180. package/dist/ui/components/toggle/toggle.test.ts +559 -146
  181. package/dist/ui/components/toggle/toggle.ts +241 -26
  182. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  183. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  184. package/dist/ui/components/toolbar/index.ts +12 -0
  185. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  186. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  187. package/dist/ui/theme/chart-colors.test.ts +8 -3
  188. package/dist/ui/theme/dark.css +38 -2
  189. package/dist/ui/theme/default.css +55 -7
  190. package/dist/ui/theme/icon-colors.test.ts +8 -3
  191. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  192. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  193. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  194. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  195. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  196. package/dist/ui/theme/tailwind.css +113 -0
  197. package/dist/ui/utils/animate.test.ts +13 -1
  198. package/dist/utils/analyze.d.ts +19 -0
  199. package/dist/utils/analyze.js +16 -0
  200. package/dist/utils/analyze.js.map +1 -1
  201. package/dist/utils/analyze.test.js +14 -1
  202. package/dist/utils/analyze.test.js.map +1 -1
  203. package/dist/utils/installed.d.ts +25 -1
  204. package/dist/utils/installed.js +33 -0
  205. package/dist/utils/installed.js.map +1 -1
  206. package/dist/utils/installed.test.js +55 -1
  207. package/dist/utils/installed.test.js.map +1 -1
  208. package/dist/utils/resolve-paths.d.ts +4 -4
  209. package/dist/utils/resolve-paths.js +5 -7
  210. package/dist/utils/resolve-paths.js.map +1 -1
  211. package/dist/utils/resolve-paths.test.js +9 -5
  212. package/dist/utils/resolve-paths.test.js.map +1 -1
  213. package/dist/utils/tailwind.d.ts +45 -0
  214. package/dist/utils/tailwind.js +90 -0
  215. package/dist/utils/tailwind.js.map +1 -0
  216. package/dist/utils/tailwind.test.d.ts +1 -0
  217. package/dist/utils/tailwind.test.js +134 -0
  218. package/dist/utils/tailwind.test.js.map +1 -0
  219. package/package.json +9 -1
  220. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  221. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  222. package/dist/ui/components/button/button-render.test.ts +0 -132
  223. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  224. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  225. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  226. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  227. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -1,8 +1,6 @@
1
1
  import { Component, signal } from '@angular/core';
2
2
  import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
3
  import { describe, expect, it } from 'vitest';
5
- import { setInputSignal } from '../../../test-utils/set-input-signal';
6
4
  import { UiIcon } from './icon';
7
5
  import {
8
6
  ICON_DARK_COLORS,
@@ -13,84 +11,97 @@ import {
13
11
  import { ICONS, type IconName } from './icons';
14
12
 
15
13
  @Component({
16
- imports: [UiIcon, LYNX_ELEMENTS],
17
- template: `<ui-icon [name]="name" [size]="size" [color]="color" />`,
14
+ imports: [UiIcon],
15
+ template: `
16
+ <ui-icon
17
+ [name]="name()"
18
+ [size]="size()"
19
+ [color]="color()"
20
+ [class]="className()"
21
+ />
22
+ `,
18
23
  })
19
24
  class IconHost {
20
- name: IconName = 'check';
21
- size: 'xs' | 'sm' | 'md' | 'lg' = 'md';
22
- color?: string;
25
+ readonly name = signal<IconName>('check');
26
+ readonly size = signal<'xs' | 'sm' | 'md' | 'lg'>('md');
27
+ readonly color = signal<string | undefined>(undefined);
28
+ readonly className = signal('');
23
29
  }
24
30
 
31
+ const renderIcon = async (providers: Parameters<typeof render>[1] = {}) => {
32
+ const result = await render(IconHost, providers);
33
+ return {
34
+ ...result,
35
+ host: result.componentRef.instance as IconHost,
36
+ svg: () => result.container.querySelector('svg')!,
37
+ };
38
+ };
39
+
25
40
  describe('UiIcon', () => {
26
- it('renders the requested icon at default size', async () => {
27
- const { container } = await render(IconHost);
28
- expect(container).toBeTruthy();
41
+ it('draws the named icon at the default size', async () => {
42
+ const { svg } = await renderIcon();
43
+ expect(svg().getAttribute('style')).toContain('24px');
44
+ expect(svg().getAttribute('content')).toContain('<svg');
45
+ });
46
+
47
+ it('draws another icon when the name changes', async () => {
48
+ const { host, svg } = await renderIcon();
49
+ const check = svg().getAttribute('content');
50
+ host.name.set('x');
51
+ await waitForUpdate();
52
+ expect(svg().getAttribute('content')).not.toBe(check);
29
53
  });
30
54
 
31
55
  it('applies the size input to the width/height style', async () => {
32
- // JIT template bindings are inert against `input()` — drive the signals
33
- // directly so sizeStyle and svgContent both re-run.
34
- const { componentRef, container } = await render(UiIcon);
35
- setInputSignal((componentRef.instance as UiIcon).name, 'check' as IconName);
36
- setInputSignal((componentRef.instance as UiIcon).size, 'xs');
56
+ const { host, svg } = await renderIcon();
57
+ host.size.set('xs');
37
58
  await waitForUpdate();
38
- const svg = container.querySelector('svg');
39
- expect(svg?.getAttribute('style')).toContain('16px');
59
+ expect(svg().getAttribute('style')).toContain('16px');
40
60
  });
41
61
 
42
62
  it('substitutes currentColor with the color input value', async () => {
43
- const { componentRef, container } = await render(UiIcon);
44
- setInputSignal((componentRef.instance as UiIcon).name, 'check' as IconName);
45
- setInputSignal((componentRef.instance as UiIcon).color, 'rgb(255, 0, 0)');
63
+ // Lynx's SVG parser reads neither `currentColor` nor CSS variables.
64
+ const { host, svg } = await renderIcon();
65
+ host.color.set('rgb(255, 0, 0)');
46
66
  await waitForUpdate();
47
- const svg = container.querySelector('svg');
48
- expect(svg?.getAttribute('content')).toContain('rgb(255, 0, 0)');
49
- expect(svg?.getAttribute('content')).not.toContain('currentColor');
67
+ expect(svg().getAttribute('content')).toContain('rgb(255, 0, 0)');
68
+ expect(svg().getAttribute('content')).not.toContain('currentColor');
50
69
  });
51
70
 
52
71
  it("draws in the light theme's foreground when no color is set", async () => {
53
- const { componentRef, container } = await render(UiIcon);
54
- setInputSignal((componentRef.instance as UiIcon).name, 'check' as IconName);
55
- await waitForUpdate();
56
- const content = container.querySelector('svg')?.getAttribute('content');
72
+ const { svg } = await renderIcon();
73
+ const content = svg().getAttribute('content');
57
74
  expect(content).toContain(ICON_LIGHT_COLORS['foreground']);
58
75
  expect(content).not.toContain('currentColor');
59
76
  });
60
77
 
61
78
  it('resolves a theme token against the active color scheme', async () => {
62
- const { componentRef, container } = await render(UiIcon, {
79
+ const { host, svg } = await renderIcon({
63
80
  providers: [provideIconTheme({ colorScheme: 'dark' })],
64
81
  });
65
- const icon = componentRef.instance as UiIcon;
66
- setInputSignal(icon.name, 'check' as IconName);
67
- setInputSignal(icon.color, 'var(--primary)');
82
+ host.color.set('var(--primary)');
68
83
  await waitForUpdate();
69
- expect(container.querySelector('svg')?.getAttribute('content')).toContain(
84
+ expect(svg().getAttribute('content')).toContain(
70
85
  ICON_DARK_COLORS['primary'],
71
86
  );
72
87
  });
73
88
 
74
89
  it("takes its container's color when it sets none", async () => {
75
- const { componentRef, container } = await render(UiIcon, {
90
+ const { svg } = await renderIcon({
76
91
  providers: [
77
92
  { provide: UI_ICON_COLOR, useValue: signal('var(--destructive)') },
78
93
  ],
79
94
  });
80
- setInputSignal((componentRef.instance as UiIcon).name, 'x' as IconName);
81
- await waitForUpdate();
82
- expect(container.querySelector('svg')?.getAttribute('content')).toContain(
95
+ expect(svg().getAttribute('content')).toContain(
83
96
  ICON_LIGHT_COLORS['destructive'],
84
97
  );
85
98
  });
86
99
 
87
100
  it('applies the class input to the svg', async () => {
88
- const { componentRef, container } = await render(UiIcon);
89
- const icon = componentRef.instance as UiIcon;
90
- setInputSignal(icon.name, 'check' as IconName);
91
- setInputSignal(icon.userClass, 'mr-2');
101
+ const { host, svg } = await renderIcon();
102
+ host.className.set('mr-2');
92
103
  await waitForUpdate();
93
- expect(container.querySelector('svg')?.getAttribute('class')).toBe('mr-2');
104
+ expect(svg().getAttribute('class')).toBe('mr-2');
94
105
  });
95
106
  });
96
107
 
@@ -1,111 +1,203 @@
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';
1
+ import { Component, signal } from '@angular/core';
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ waitForUpdate,
6
+ } from '@blotch/angular-lynx-testing-library';
7
+ import {
8
+ type MockInstance,
9
+ afterEach,
10
+ beforeEach,
11
+ describe,
12
+ expect,
13
+ it,
14
+ vi,
15
+ } from 'vitest';
16
+ import {
17
+ type LynxMainThread,
18
+ lynxMainThread,
19
+ } from '../../../test-utils/lynx-main-thread';
5
20
  import { UiInput } from './input';
6
21
 
7
- describe('UiInput', () => {
8
- it('renders with default configuration', async () => {
9
- const { container } = await render(UiInput);
10
- expect(container).toBeTruthy();
11
- });
22
+ @Component({
23
+ imports: [UiInput],
24
+ template: `
25
+ <ui-input
26
+ [(value)]="email"
27
+ [label]="label()"
28
+ placeholder="you@example.com"
29
+ type="email"
30
+ [helperText]="helperText()"
31
+ [error]="error()"
32
+ [disabled]="disabled()"
33
+ class="w-full"
34
+ (changed)="changes.push($event)"
35
+ (focused)="events.push('focused')"
36
+ (blurred)="events.push('blurred')"
37
+ />
38
+ `,
39
+ })
40
+ class InputHost {
41
+ readonly email = signal('');
42
+ readonly label = signal('');
43
+ readonly helperText = signal('');
44
+ readonly error = signal('');
45
+ readonly disabled = signal(false);
46
+ readonly changes: string[] = [];
47
+ readonly events: string[] = [];
48
+ }
49
+
50
+ const renderInput = async () => {
51
+ const result = await render(InputHost);
52
+ return {
53
+ ...result,
54
+ host: result.componentRef.instance as InputHost,
55
+ field: () => result.container.querySelector('input')!,
56
+ // The field's own column; the host element renders as a view too.
57
+ column: () => result.container.querySelector('.flex-col')!,
58
+ wrapper: () => result.container.querySelector('.bg-muted')!,
59
+ // The layer that draws the focus ring around the field.
60
+ ring: () => result.container.querySelector('.absolute') as HTMLElement,
61
+ };
62
+ };
63
+
64
+ /** `__ElementAnimate`'s first argument for starting an animation. */
65
+ const START = 0;
66
+ /** `__ElementAnimate`'s first argument for canceling an animation. */
67
+ const CANCEL = 3;
12
68
 
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']);
69
+ describe('UiInput', () => {
70
+ // Watched per test: the testing library resets the main thread after each.
71
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
72
+ beforeEach(() => {
73
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
23
74
  });
75
+ afterEach(() => animate.mockRestore());
24
76
 
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();
77
+ it('renders a native input with its placeholder and type, and no label', async () => {
78
+ const { field, column, queryByText } = await renderInput();
79
+ expect(field().getAttribute('placeholder')).toBe('you@example.com');
80
+ expect(field().getAttribute('type')).toBe('email');
81
+ expect(column().classList).toContain('w-full');
82
+ expect(queryByText('you@example.com')).toBeNull();
36
83
  });
37
84
 
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();
85
+ it('shows its label', async () => {
86
+ const { host, getByText } = await renderInput();
87
+ host.label.set('Email');
46
88
  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');
89
+ expect(getByText('Email').classList).toContain('font-medium');
51
90
  });
52
91
 
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);
92
+ it('updates `value` and emits `changed` as the user types', async () => {
93
+ const { host, field } = await renderInput();
94
+ // Lynx puts the typed text in the event's detail, not on its target.
95
+ fireEvent.input(field(), { detail: { value: 'jane@' } });
96
+ fireEvent.input(field(), { detail: { value: 'jane@example.com' } });
97
+ expect(host.email()).toBe('jane@example.com');
98
+ expect(host.changes).toEqual(['jane@', 'jane@example.com']);
64
99
  });
65
100
 
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();
101
+ it("dims and ignores typing while it's disabled", async () => {
102
+ const { host, field, wrapper } = await renderInput();
103
+ host.disabled.set(true);
80
104
  await waitForUpdate();
81
- // The error text should render and the destructive ring shadow should apply.
82
- expect(container.textContent).toContain('Required');
105
+ expect(wrapper().classList).toContain('opacity-50');
106
+
107
+ fireEvent.input(field(), { detail: { value: 'blocked' } });
108
+ expect(host.email()).toBe('');
109
+ expect(host.changes).toEqual([]);
110
+ });
83
111
 
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, '');
112
+ it('disables the native input only while disabled', async () => {
113
+ // Lynx treats any `disabled` value, even false, as disabled, so the
114
+ // attribute has to go rather than be set to false.
115
+ const setAttribute = vi.spyOn(lynxMainThread(), '__SetAttribute');
116
+ const { host, field } = await renderInput();
117
+ expect(setAttribute).not.toHaveBeenCalledWith(
118
+ field(),
119
+ 'disabled',
120
+ expect.anything(),
121
+ );
122
+
123
+ host.disabled.set(true);
87
124
  await waitForUpdate();
125
+ expect(setAttribute).toHaveBeenLastCalledWith(field(), 'disabled', 'true');
126
+ setAttribute.mockRestore();
127
+ });
128
+
129
+ it('shows a focus ring while focused, and emits `focused` and `blurred`', async () => {
130
+ const { host, field, ring } = await renderInput();
131
+ expect(ring().style.getPropertyValue('opacity')).toBe('0');
132
+
133
+ fireEvent.focus(field());
88
134
  await waitForUpdate();
89
- setInputSignal(inst.error, 'Required again');
135
+ expect(ring().style.getPropertyValue('opacity')).toBe('1');
136
+ expect(ring().style.getPropertyValue('box-shadow')).toContain(
137
+ 'var(--ring)',
138
+ );
139
+
140
+ fireEvent.blur(field());
90
141
  await waitForUpdate();
142
+ expect(ring().style.getPropertyValue('opacity')).toBe('0');
143
+ expect(host.events).toEqual(['focused', 'blurred']);
144
+ });
145
+
146
+ it('shows its helper text', async () => {
147
+ const { host, getByText } = await renderInput();
148
+ host.helperText.set("We'll never share it.");
91
149
  await waitForUpdate();
92
- expect(container.textContent).toContain('Required again');
150
+ expect(getByText("We'll never share it.").classList).toContain(
151
+ 'text-muted-foreground',
152
+ );
93
153
  });
94
154
 
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');
155
+ it('shows an error in place of the helper text, with a destructive ring', async () => {
156
+ const { host, ring, getByText, queryByText } = await renderInput();
157
+ host.helperText.set("We'll never share it.");
158
+ host.error.set('Enter a valid email');
99
159
  await waitForUpdate();
100
- expect(container.textContent).toContain('Enter your name');
160
+ expect(getByText('Enter a valid email').classList).toContain(
161
+ 'text-destructive',
162
+ );
163
+ expect(queryByText("We'll never share it.")).toBeNull();
164
+ // An invalid field keeps its ring even when it isn't focused.
165
+ expect(ring().style.getPropertyValue('opacity')).toBe('1');
166
+ expect(ring().style.getPropertyValue('box-shadow')).toContain(
167
+ 'var(--destructive)',
168
+ );
101
169
  });
102
170
 
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');
171
+ it('shakes when an error appears, but not when it only changes', async () => {
172
+ const { host, column } = await renderInput();
173
+ const shakes = () =>
174
+ animate.mock.calls.filter(([, [action]]) => action === START);
175
+ expect(shakes()).toEqual([]);
176
+
177
+ host.error.set('Required');
178
+ await waitForUpdate();
179
+ expect(animate).toHaveBeenCalledWith(column(), [
180
+ START,
181
+ expect.any(String),
182
+ expect.arrayContaining([{ transform: 'translateX(-4px)' }]),
183
+ expect.anything(),
184
+ ]);
185
+ expect(shakes()).toHaveLength(1);
186
+
187
+ host.error.set('Enter a valid email');
107
188
  await waitForUpdate();
108
- expect(container.textContent).toContain('Name');
189
+ expect(shakes()).toHaveLength(1);
190
+
191
+ // Once fixed, the next error shakes again, replacing the last shake.
192
+ host.error.set('');
193
+ await waitForUpdate();
194
+ host.error.set('Required');
195
+ await waitForUpdate();
196
+ expect(shakes()).toHaveLength(2);
197
+ expect(animate).toHaveBeenCalledWith(column(), [
198
+ CANCEL,
199
+ expect.any(String),
200
+ ]);
109
201
  });
110
202
 
111
203
  it('makes the wrapper a stacking context so the field scrolls with it', async () => {
@@ -113,22 +205,7 @@ describe('UiInput', () => {
113
205
  // when an ancestor inside the scroll content is a stacking context. Without
114
206
  // z-0 on the wrapper, the native field (and its placeholder) stays fixed on
115
207
  // 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');
208
+ const { wrapper } = await renderInput();
209
+ expect(wrapper().classList).toContain('z-0');
133
210
  });
134
211
  });
@@ -2,6 +2,7 @@ import {
2
2
  type ElementRef,
3
3
  Component,
4
4
  ViewEncapsulation,
5
+ booleanAttribute,
5
6
  computed,
6
7
  effect,
7
8
  input,
@@ -90,7 +91,7 @@ export class UiInput implements FormValueControl<string> {
90
91
  readonly type = input<string>('text');
91
92
  readonly helperText = input<string>('');
92
93
  readonly error = input<string>('');
93
- readonly disabled = input(false);
94
+ readonly disabled = input(false, { transform: booleanAttribute });
94
95
  readonly userClass = input<string>('', { alias: 'class' });
95
96
 
96
97
  readonly changed = output<string>();
@@ -102,18 +103,21 @@ export class UiInput implements FormValueControl<string> {
102
103
  #shakeAnim?: AnimationHandle;
103
104
  #previousError = '';
104
105
 
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
- }
106
+ /**
107
+ * Shakes the field when an error appears (a change from no error to one). A
108
+ * field rather than a constructor: Angular's test build keeps a function for
109
+ * a constructor that nothing calls, which an app's coverage report would show
110
+ * as uncovered.
111
+ */
112
+ protected readonly shakeOnError = effect(() => {
113
+ const err = this.error();
114
+ const el = this.containerRef()?.nativeElement;
115
+ if (err && !this.#previousError && el) {
116
+ this.#shakeAnim?.cancel();
117
+ this.#shakeAnim = shake(el);
118
+ }
119
+ this.#previousError = err;
120
+ });
117
121
 
118
122
  protected readonly containerClass = computed(() =>
119
123
  cn('flex-col gap-1.5 flex', this.userClass()),
@@ -1,38 +1,25 @@
1
- import { Component } from '@angular/core';
1
+ import { Component, signal } from '@angular/core';
2
2
  import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
3
  import { describe, expect, it } from 'vitest';
5
- import { setInputSignal } from '../../../test-utils/set-input-signal';
6
4
  import { UiLabel } from './label';
7
5
 
8
6
  @Component({
9
- standalone: true,
10
- imports: [UiLabel, LYNX_ELEMENTS],
11
- template: `<ui-label>{{ label }}</ui-label>`,
7
+ imports: [UiLabel],
8
+ template: `<ui-label [disabled]="disabled()">Name</ui-label>`,
12
9
  })
13
10
  class LabelHost {
14
- label = 'Name';
11
+ readonly disabled = signal(false);
15
12
  }
16
13
 
17
14
  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
- });
15
+ it('renders its text, dimmed while disabled', async () => {
16
+ const { componentRef, getByText } = await render(LabelHost);
17
+ const label = getByText('Name');
18
+ expect(label.classList).toContain('font-medium');
19
+ expect(label.classList).not.toContain('opacity-50');
28
20
 
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);
21
+ (componentRef.instance as LabelHost).disabled.set(true);
34
22
  await waitForUpdate();
35
- const textEl = container.querySelector('text');
36
- expect(textEl?.getAttribute('class')).toContain('opacity-50');
23
+ expect(label.classList).toContain('opacity-50');
37
24
  });
38
25
  });
@@ -1,4 +1,10 @@
1
- import { Component, ViewEncapsulation, computed, input } from '@angular/core';
1
+ import {
2
+ Component,
3
+ ViewEncapsulation,
4
+ booleanAttribute,
5
+ computed,
6
+ input,
7
+ } from '@angular/core';
2
8
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
3
9
 
4
10
  import { cn } from '../../utils/cn';
@@ -11,7 +17,7 @@ import { cn } from '../../utils/cn';
11
17
  template: `<text [class]="labelClass()"><ng-content /></text>`,
12
18
  })
13
19
  export class UiLabel {
14
- readonly disabled = input(false);
20
+ readonly disabled = input(false, { transform: booleanAttribute });
15
21
  readonly userClass = input<string>('', { alias: 'class' });
16
22
 
17
23
  protected readonly labelClass = computed(() =>