@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,115 +1,236 @@
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 { UiTextarea } from './textarea';
6
21
 
22
+ @Component({
23
+ imports: [UiTextarea],
24
+ template: `
25
+ <ui-textarea
26
+ [(value)]="notes"
27
+ [label]="label()"
28
+ placeholder="Add a note"
29
+ [helperText]="helperText()"
30
+ [error]="error()"
31
+ [disabled]="disabled()"
32
+ [minLines]="minLines()"
33
+ [maxLines]="maxLines()"
34
+ class="w-full"
35
+ (changed)="changes.push($event)"
36
+ (focused)="events.push('focused')"
37
+ (blurred)="events.push('blurred')"
38
+ />
39
+ `,
40
+ })
41
+ class TextareaHost {
42
+ readonly notes = signal('');
43
+ readonly label = signal('');
44
+ readonly helperText = signal('');
45
+ readonly error = signal('');
46
+ readonly disabled = signal(false);
47
+ readonly minLines = signal(3);
48
+ readonly maxLines = signal<number | undefined>(undefined);
49
+ readonly changes: string[] = [];
50
+ readonly events: string[] = [];
51
+ }
52
+
53
+ const renderTextarea = async () => {
54
+ const result = await render(TextareaHost);
55
+ return {
56
+ ...result,
57
+ host: result.componentRef.instance as TextareaHost,
58
+ field: () => result.container.querySelector('textarea')!,
59
+ // The field's own column; the host element renders as a view too.
60
+ column: () => result.container.querySelector('.flex-col')!,
61
+ wrapper: () => result.container.querySelector('.bg-muted') as HTMLElement,
62
+ // The layer that draws the focus ring around the field.
63
+ ring: () => result.container.querySelector('.absolute') as HTMLElement,
64
+ };
65
+ };
66
+
67
+ /** `__ElementAnimate`'s first argument for starting an animation. */
68
+ const START = 0;
69
+ /** `__ElementAnimate`'s first argument for canceling an animation. */
70
+ const CANCEL = 3;
71
+
7
72
  describe('UiTextarea', () => {
8
- it('renders with default configuration', async () => {
9
- const { container } = await render(UiTextarea);
10
- expect(container).toBeTruthy();
73
+ // Watched per test: the testing library resets the main thread after each.
74
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
75
+ beforeEach(() => {
76
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
11
77
  });
78
+ afterEach(() => animate.mockRestore());
12
79
 
13
- it('onInput() updates the model and emits `changed`', async () => {
14
- const { componentRef } = await render(UiTextarea);
15
- const inst = componentRef.instance as unknown as UiTextarea & {
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']);
80
+ it('renders a native textarea with its placeholder, and no label', async () => {
81
+ const { field, column, queryByText } = await renderTextarea();
82
+ expect(field().getAttribute('placeholder')).toBe('Add a note');
83
+ expect(column().classList).toContain('w-full');
84
+ expect(queryByText('Add a note')).toBeNull();
23
85
  });
24
86
 
25
- it('onInput() is a no-op when disabled', async () => {
26
- const { componentRef } = await render(UiTextarea);
27
- const inst = componentRef.instance as unknown as UiTextarea & {
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();
87
+ it('shows its label', async () => {
88
+ const { host, getByText } = await renderTextarea();
89
+ host.label.set('Notes');
90
+ await waitForUpdate();
91
+ expect(getByText('Notes').classList).toContain('font-medium');
36
92
  });
37
93
 
38
- it('onFocus/onBlur emit and update state', async () => {
39
- const { componentRef } = await render(UiTextarea);
40
- const inst = componentRef.instance as unknown as UiTextarea & {
41
- onFocus: () => void;
42
- onBlur: () => void;
43
- };
44
- const focused = vi.fn();
45
- const blurred = vi.fn();
46
- inst.focused.subscribe(focused);
47
- inst.blurred.subscribe(blurred);
48
- inst.onFocus();
49
- inst.onBlur();
50
- expect(focused).toHaveBeenCalledTimes(1);
51
- expect(blurred).toHaveBeenCalledTimes(1);
94
+ it('updates `value` and emits `changed` as the user types', async () => {
95
+ const { host, field } = await renderTextarea();
96
+ // Lynx puts the typed text in the event's detail, not on its target.
97
+ fireEvent.input(field(), { detail: { value: 'Call' } });
98
+ fireEvent.input(field(), { detail: { value: 'Call Sam' } });
99
+ expect(host.notes()).toBe('Call Sam');
100
+ expect(host.changes).toEqual(['Call', 'Call Sam']);
52
101
  });
53
102
 
54
- it('error input triggers the shake effect', async () => {
55
- // Same pattern as UiInput: JIT + jsdom doesn't populate template-ref
56
- // viewChild queries. Monkey-patch containerRef so the effect body's `el`
57
- // check passes and the shake path executes.
58
- const { componentRef, container } = await render(UiTextarea);
59
- const inst = componentRef.instance as UiTextarea;
60
- const view = container.querySelector('view') as Element;
61
- (inst as unknown as { containerRef: unknown }).containerRef = signal({
62
- nativeElement: view,
63
- });
64
- setInputSignal(inst.error, 'Too short');
103
+ it('hides its placeholder while it has a value', async () => {
104
+ // Lynx's iOS textarea would otherwise draw the placeholder over a value
105
+ // the app sets.
106
+ const { host, field } = await renderTextarea();
107
+ host.notes.set('Call Sam');
65
108
  await waitForUpdate();
109
+ expect(field().hasAttribute('placeholder')).toBe(false);
110
+
111
+ host.notes.set('');
66
112
  await waitForUpdate();
67
- expect(container.textContent).toContain('Too short');
68
- // Clear then re-trigger to cover the cancel path.
69
- setInputSignal(inst.error, '');
113
+ expect(field().getAttribute('placeholder')).toBe('Add a note');
114
+ });
115
+
116
+ it("dims and ignores typing while it's disabled", async () => {
117
+ const { host, field, wrapper } = await renderTextarea();
118
+ host.disabled.set(true);
70
119
  await waitForUpdate();
120
+ expect(wrapper().classList).toContain('opacity-50');
121
+
122
+ fireEvent.input(field(), { detail: { value: 'blocked' } });
123
+ expect(host.notes()).toBe('');
124
+ expect(host.changes).toEqual([]);
125
+ });
126
+
127
+ it('disables the native textarea only while disabled', async () => {
128
+ // Lynx treats any `disabled` value, even false, as disabled, so the
129
+ // attribute has to go rather than be set to false.
130
+ const setAttribute = vi.spyOn(lynxMainThread(), '__SetAttribute');
131
+ const { host, field } = await renderTextarea();
132
+ expect(setAttribute).not.toHaveBeenCalledWith(
133
+ field(),
134
+ 'disabled',
135
+ expect.anything(),
136
+ );
137
+
138
+ host.disabled.set(true);
139
+ await waitForUpdate();
140
+ expect(setAttribute).toHaveBeenLastCalledWith(field(), 'disabled', 'true');
141
+ setAttribute.mockRestore();
142
+ });
143
+
144
+ it('shows a focus ring while focused, and emits `focused` and `blurred`', async () => {
145
+ const { host, field, ring } = await renderTextarea();
146
+ expect(ring().style.getPropertyValue('opacity')).toBe('0');
147
+
148
+ fireEvent.focus(field());
71
149
  await waitForUpdate();
72
- setInputSignal(inst.error, 'Retry');
150
+ expect(ring().style.getPropertyValue('opacity')).toBe('1');
151
+ expect(ring().style.getPropertyValue('box-shadow')).toContain(
152
+ 'var(--ring)',
153
+ );
154
+
155
+ fireEvent.blur(field());
73
156
  await waitForUpdate();
157
+ expect(ring().style.getPropertyValue('opacity')).toBe('0');
158
+ expect(host.events).toEqual(['focused', 'blurred']);
159
+ });
160
+
161
+ it('shows its helper text', async () => {
162
+ const { host, getByText } = await renderTextarea();
163
+ host.helperText.set('Only you can see this.');
74
164
  await waitForUpdate();
75
- expect(container.textContent).toContain('Retry');
165
+ expect(getByText('Only you can see this.').classList).toContain(
166
+ 'text-muted-foreground',
167
+ );
76
168
  });
77
169
 
78
- it('renders helper text when no error is present', async () => {
79
- const { componentRef, container } = await render(UiTextarea);
80
- const inst = componentRef.instance as UiTextarea;
81
- setInputSignal(inst.helperText, 'Notes here');
170
+ it('shows an error in place of the helper text, with a destructive ring', async () => {
171
+ const { host, ring, getByText, queryByText } = await renderTextarea();
172
+ host.helperText.set('Only you can see this.');
173
+ host.error.set('Too short');
82
174
  await waitForUpdate();
83
- expect(container.textContent).toContain('Notes here');
175
+ expect(getByText('Too short').classList).toContain('text-destructive');
176
+ expect(queryByText('Only you can see this.')).toBeNull();
177
+ // An invalid field keeps its ring even when it isn't focused.
178
+ expect(ring().style.getPropertyValue('opacity')).toBe('1');
179
+ expect(ring().style.getPropertyValue('box-shadow')).toContain(
180
+ 'var(--destructive)',
181
+ );
84
182
  });
85
183
 
86
- it('renders the label when provided', async () => {
87
- const { componentRef, container } = await render(UiTextarea);
88
- const inst = componentRef.instance as UiTextarea;
89
- setInputSignal(inst.label, 'Description');
184
+ it('shakes when an error appears, but not when it only changes', async () => {
185
+ const { host, column } = await renderTextarea();
186
+ const shakes = () =>
187
+ animate.mock.calls.filter(([, [action]]) => action === START);
188
+ expect(shakes()).toEqual([]);
189
+
190
+ host.error.set('Required');
191
+ await waitForUpdate();
192
+ expect(animate).toHaveBeenCalledWith(column(), [
193
+ START,
194
+ expect.any(String),
195
+ expect.arrayContaining([{ transform: 'translateX(-4px)' }]),
196
+ expect.anything(),
197
+ ]);
198
+ expect(shakes()).toHaveLength(1);
199
+
200
+ host.error.set('Too short');
90
201
  await waitForUpdate();
91
- expect(container.textContent).toContain('Description');
202
+ expect(shakes()).toHaveLength(1);
203
+
204
+ // Once fixed, the next error shakes again, replacing the last shake.
205
+ host.error.set('');
206
+ await waitForUpdate();
207
+ host.error.set('Required');
208
+ await waitForUpdate();
209
+ expect(shakes()).toHaveLength(2);
210
+ expect(animate).toHaveBeenCalledWith(column(), [
211
+ CANCEL,
212
+ expect.any(String),
213
+ ]);
92
214
  });
93
215
 
94
- it('computes a min-height wrapper style when only minLines is set', async () => {
95
- // Exercises the `max != null ? ... : ''` false branch of wrapperStyle.
96
- const { componentRef, container } = await render(UiTextarea);
97
- const inst = componentRef.instance as UiTextarea;
98
- setInputSignal(inst.minLines, 5);
216
+ it('opens at minLines tall and grows without limit by default', async () => {
217
+ // Each line is 20px, plus 20px of vertical padding.
218
+ const { host, field, wrapper } = await renderTextarea();
219
+ expect(wrapper().style.getPropertyValue('min-height')).toBe('80px');
220
+ expect(wrapper().style.getPropertyValue('max-height')).toBe('');
221
+ expect(field().hasAttribute('enable-scroll-bar')).toBe(false);
222
+
223
+ host.minLines.set(5);
99
224
  await waitForUpdate();
100
- // 5 * 20 + 20 = 120px
101
- const wrapper = container.querySelector('[style*="min-height"]');
102
- expect(wrapper?.getAttribute('style')).toContain('min-height: 120px');
103
- expect(wrapper?.getAttribute('style')).not.toContain('max-height');
225
+ expect(wrapper().style.getPropertyValue('min-height')).toBe('120px');
104
226
  });
105
227
 
106
- it('adds max-height to wrapper style when maxLines is set', async () => {
107
- const { componentRef, container } = await render(UiTextarea);
108
- const inst = componentRef.instance as UiTextarea;
109
- setInputSignal(inst.maxLines, 4);
228
+ it('stops growing at maxLines and shows a scroll bar', async () => {
229
+ const { host, field, wrapper } = await renderTextarea();
230
+ host.maxLines.set(4);
110
231
  await waitForUpdate();
111
- const wrapper = container.querySelector('[style*="max-height"]');
112
- expect(wrapper?.getAttribute('style')).toContain('max-height: 100px');
232
+ expect(wrapper().style.getPropertyValue('max-height')).toBe('100px');
233
+ expect(field().getAttribute('enable-scroll-bar')).toBe('true');
113
234
  });
114
235
 
115
236
  it('makes the wrapper a stacking context so the field scrolls with it', async () => {
@@ -117,22 +238,7 @@ describe('UiTextarea', () => {
117
238
  // when an ancestor inside the scroll content is a stacking context. Without
118
239
  // z-0 on the wrapper, the native field (and its placeholder) stays fixed on
119
240
  // screen while the page scrolls.
120
- const { container } = await render(UiTextarea);
121
- const wrapper = container.querySelector(
122
- '[class*="rounded-xl"][class*="bg-muted"]',
123
- );
124
- expect(wrapper?.getAttribute('class')?.split(/\s+/)).toContain('z-0');
125
- });
126
-
127
- it('applies the disabled dim class when disabled', async () => {
128
- // Covers the true branch of `disabled() && 'opacity-50'` in textareaWrapperClass.
129
- const { componentRef, container } = await render(UiTextarea);
130
- const inst = componentRef.instance as UiTextarea;
131
- setInputSignal(inst.disabled, true);
132
- await waitForUpdate();
133
- const wrapper = container.querySelector(
134
- '[class*="rounded-xl"][class*="bg-muted"]',
135
- );
136
- expect(wrapper?.getAttribute('class')).toContain('opacity-50');
241
+ const { wrapper } = await renderTextarea();
242
+ expect(wrapper().classList).toContain('z-0');
137
243
  });
138
244
  });
@@ -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,
@@ -95,7 +96,7 @@ export class UiTextarea implements FormValueControl<string> {
95
96
  readonly placeholder = input<string>('');
96
97
  readonly helperText = input<string>('');
97
98
  readonly error = input<string>('');
98
- readonly disabled = input(false);
99
+ readonly disabled = input(false, { transform: booleanAttribute });
99
100
  readonly userClass = input<string>('', { alias: 'class' });
100
101
 
101
102
  // SwiftUI-style lineLimit(min...max). The field opens at minLines, grows with
@@ -113,18 +114,20 @@ export class UiTextarea implements FormValueControl<string> {
113
114
  #shakeAnim?: AnimationHandle;
114
115
  #previousError = '';
115
116
 
116
- constructor() {
117
- // Shake when an error first appears
118
- effect(() => {
119
- const err = this.error();
120
- const el = this.containerRef()?.nativeElement;
121
- if (err && !this.#previousError && el) {
122
- this.#shakeAnim?.cancel();
123
- this.#shakeAnim = shake(el);
124
- }
125
- this.#previousError = err;
126
- });
127
- }
117
+ /**
118
+ * Shakes the field when an error first appears. A field rather than a
119
+ * constructor: Angular's test build keeps a function for a constructor that
120
+ * nothing calls, which an app's coverage report would show as uncovered.
121
+ */
122
+ protected readonly shakeOnError = effect(() => {
123
+ const err = this.error();
124
+ const el = this.containerRef()?.nativeElement;
125
+ if (err && !this.#previousError && el) {
126
+ this.#shakeAnim?.cancel();
127
+ this.#shakeAnim = shake(el);
128
+ }
129
+ this.#previousError = err;
130
+ });
128
131
 
129
132
  protected readonly containerClass = computed(() =>
130
133
  cn('flex-col gap-1.5 flex', this.userClass()),