@blotch/dolan 22.2.3 → 22.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/dist/commands/add.js +9 -1
  2. package/dist/commands/add.js.map +1 -1
  3. package/dist/commands/add.test.js +36 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +187 -7
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +280 -7
  8. package/dist/commands/doctor.test.js.map +1 -1
  9. package/dist/commands/init.d.ts +2 -2
  10. package/dist/commands/init.js +38 -18
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/init.test.js +107 -1
  13. package/dist/commands/init.test.js.map +1 -1
  14. package/dist/commands/theme.js +1 -1
  15. package/dist/commands/theme.js.map +1 -1
  16. package/dist/commands/update.d.ts +5 -0
  17. package/dist/commands/update.js +126 -45
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +314 -2
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +7 -0
  22. package/dist/lockfile.js +36 -0
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +25 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +48 -2
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +45 -3
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +5 -0
  33. package/dist/test-utils.js +6 -1
  34. package/dist/test-utils.js.map +1 -1
  35. package/dist/testing/fire-lynx-event.d.ts +14 -0
  36. package/dist/testing/fire-lynx-event.js +19 -0
  37. package/dist/testing/fire-lynx-event.js.map +1 -0
  38. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  39. package/dist/testing/fire-lynx-event.test.js +44 -0
  40. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  41. package/dist/testing/lynx-main-thread.d.ts +22 -0
  42. package/dist/testing/lynx-main-thread.js +11 -0
  43. package/dist/testing/lynx-main-thread.js.map +1 -0
  44. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  45. package/dist/testing/lynx-main-thread.test.js +24 -0
  46. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  47. package/dist/testing/set-input-signal.d.ts +5 -4
  48. package/dist/testing/set-input-signal.js +5 -4
  49. package/dist/testing/set-input-signal.js.map +1 -1
  50. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  51. package/dist/ui/components/accordion/accordion.ts +19 -14
  52. package/dist/ui/components/action-sheet/action-sheet.test.ts +272 -187
  53. package/dist/ui/components/action-sheet/action-sheet.ts +40 -34
  54. package/dist/ui/components/alert/alert.test.ts +67 -19
  55. package/dist/ui/components/alert/alert.ts +14 -16
  56. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +206 -139
  57. package/dist/ui/components/alert-dialog/alert-dialog.ts +32 -26
  58. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  59. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  60. package/dist/ui/components/badge/badge.test.ts +57 -23
  61. package/dist/ui/components/badge/badge.ts +12 -14
  62. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  63. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +373 -297
  64. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +34 -33
  65. package/dist/ui/components/button/button.test.ts +611 -2
  66. package/dist/ui/components/button/button.ts +431 -62
  67. package/dist/ui/components/button/index.ts +2 -0
  68. package/dist/ui/components/button-group/button-group.test.ts +484 -2
  69. package/dist/ui/components/button-group/button-group.ts +380 -68
  70. package/dist/ui/components/button-group/index.ts +3 -0
  71. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  72. package/dist/ui/components/card/card.test.ts +82 -62
  73. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  74. package/dist/ui/components/chart/chart-presets.ts +8 -1
  75. package/dist/ui/components/chart/chart-theme.ts +12 -0
  76. package/dist/ui/components/chart/chart.test.ts +1017 -0
  77. package/dist/ui/components/chart/chart.ts +56 -43
  78. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  79. package/dist/ui/components/checkbox/checkbox.ts +23 -23
  80. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  81. package/dist/ui/components/collapsible/collapsible.ts +19 -14
  82. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  83. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  84. package/dist/ui/components/context-menu/index.ts +1 -0
  85. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  86. package/dist/ui/components/dialog/dialog.ts +29 -23
  87. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  88. package/dist/ui/components/dock/dock-scroll.ts +133 -0
  89. package/dist/ui/components/dock/dock.test.ts +749 -0
  90. package/dist/ui/components/dock/dock.ts +960 -0
  91. package/dist/ui/components/dock/index.ts +2 -0
  92. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  93. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  94. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  95. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  96. package/dist/ui/components/glass/glass-design.ts +119 -0
  97. package/dist/ui/components/glass/glass.test.ts +338 -0
  98. package/dist/ui/components/glass/glass.ts +360 -0
  99. package/dist/ui/components/glass/index.ts +18 -0
  100. package/dist/ui/components/icon/icon-theme.test.ts +36 -0
  101. package/dist/ui/components/icon/icon-theme.ts +36 -13
  102. package/dist/ui/components/icon/icon.test.ts +52 -41
  103. package/dist/ui/components/input/input.test.ts +178 -101
  104. package/dist/ui/components/input/input.ts +15 -12
  105. package/dist/ui/components/label/label.test.ts +11 -24
  106. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  107. package/dist/ui/components/list/list.test.ts +80 -49
  108. package/dist/ui/components/menu/index.ts +25 -0
  109. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  110. package/dist/ui/components/menu/menu-placement.ts +159 -0
  111. package/dist/ui/components/menu/menu-popup.test.ts +632 -0
  112. package/dist/ui/components/menu/menu-popup.ts +544 -0
  113. package/dist/ui/components/menu/menu.test.ts +429 -0
  114. package/dist/ui/components/menu/menu.ts +415 -0
  115. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  116. package/dist/ui/components/nav-drawer/nav-drawer.ts +72 -56
  117. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  118. package/dist/ui/components/progress/progress.test.ts +102 -46
  119. package/dist/ui/components/progress/progress.ts +52 -54
  120. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  121. package/dist/ui/components/radio-group/radio-group.ts +24 -24
  122. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  123. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  124. package/dist/ui/components/select/select.test.ts +138 -78
  125. package/dist/ui/components/select/select.ts +17 -11
  126. package/dist/ui/components/separator/separator.test.ts +18 -17
  127. package/dist/ui/components/sheet/sheet.test.ts +64 -11
  128. package/dist/ui/components/sheet/sheet.ts +9 -1
  129. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  130. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  131. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  132. package/dist/ui/components/spinner/spinner.ts +16 -19
  133. package/dist/ui/components/switch/switch.test.ts +116 -46
  134. package/dist/ui/components/switch/switch.ts +8 -13
  135. package/dist/ui/components/tab-bar/index.ts +1 -0
  136. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  137. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  138. package/dist/ui/components/tabs/tabs.test.ts +215 -157
  139. package/dist/ui/components/tabs/tabs.ts +110 -118
  140. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  141. package/dist/ui/components/textarea/textarea.ts +14 -12
  142. package/dist/ui/components/toast/toast.test.ts +434 -350
  143. package/dist/ui/components/toast/toast.ts +42 -51
  144. package/dist/ui/components/toggle/toggle.test.ts +247 -140
  145. package/dist/ui/theme/chart-colors.test.ts +8 -3
  146. package/dist/ui/theme/dark.css +38 -2
  147. package/dist/ui/theme/default.css +55 -7
  148. package/dist/ui/theme/icon-colors.test.ts +8 -3
  149. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  150. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  151. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  152. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  153. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  154. package/dist/ui/theme/tailwind.css +113 -0
  155. package/dist/ui/utils/animate.test.ts +13 -1
  156. package/dist/utils/analyze.d.ts +14 -0
  157. package/dist/utils/analyze.js +15 -0
  158. package/dist/utils/analyze.js.map +1 -1
  159. package/dist/utils/analyze.test.js +14 -1
  160. package/dist/utils/analyze.test.js.map +1 -1
  161. package/dist/utils/installed.d.ts +12 -0
  162. package/dist/utils/installed.js +16 -0
  163. package/dist/utils/installed.js.map +1 -1
  164. package/dist/utils/installed.test.js +17 -1
  165. package/dist/utils/installed.test.js.map +1 -1
  166. package/dist/utils/resolve-paths.d.ts +4 -4
  167. package/dist/utils/resolve-paths.js +5 -7
  168. package/dist/utils/resolve-paths.js.map +1 -1
  169. package/dist/utils/resolve-paths.test.js +9 -5
  170. package/dist/utils/resolve-paths.test.js.map +1 -1
  171. package/dist/utils/tailwind.d.ts +25 -0
  172. package/dist/utils/tailwind.js +51 -0
  173. package/dist/utils/tailwind.js.map +1 -0
  174. package/dist/utils/tailwind.test.d.ts +1 -0
  175. package/dist/utils/tailwind.test.js +71 -0
  176. package/dist/utils/tailwind.test.js.map +1 -0
  177. package/package.json +9 -1
  178. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  179. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  180. package/dist/ui/components/button/button-render.test.ts +0 -132
  181. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  182. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  183. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  184. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  185. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -44,8 +44,10 @@ export class UiTabs {
44
44
  // When the active tab changes, we compare the old and new index in this
45
45
  // list to determine slide direction (1 = right, -1 = left). This creates
46
46
  // a spatial relationship: tabs feel like they're arranged left-to-right,
47
- // with content sliding in from the direction of the selected tab.
48
- readonly #tabOrder: string[] = [];
47
+ // with content sliding in from the direction of the selected tab. A Set
48
+ // keeps the order tabs first registered in and ignores a re-registration
49
+ // (a trigger re-created by `@if` or `@for`).
50
+ readonly #tabOrder = new Set<string>();
49
51
  #previousValue: string | undefined;
50
52
  readonly direction = signal<1 | -1>(1);
51
53
 
@@ -53,43 +55,38 @@ export class UiTabs {
53
55
  cn('flex-col flex', this.userClass()),
54
56
  );
55
57
 
56
- constructor() {
57
- // Reactively compute direction when the active value changes.
58
- // This handles both internal select() calls and external model changes.
59
- effect(() => {
60
- const currentValue = this.value();
61
- if (this.#previousValue === undefined) {
62
- this.#previousValue = currentValue;
63
- return;
64
- }
65
- // Defensive: signal dedupes same-value writes so the effect never re-runs
66
- // with an unchanged `currentValue`. Kept for safety on-device.
67
- /* v8 ignore next 1 */
68
- if (currentValue === this.#previousValue) return;
69
-
70
- const prevIndex = this.#tabOrder.indexOf(this.#previousValue);
71
- const nextIndex = this.#tabOrder.indexOf(currentValue);
72
- // The `prevIndex !== nextIndex` check is defensive — the earlier
73
- // `if (currentValue === this.#previousValue) return;` guard makes an
74
- // equal-index case unreachable in normal flow.
75
- if (
76
- prevIndex !== -1 &&
77
- nextIndex !== -1 &&
78
- /* v8 ignore next 1 */ prevIndex !== nextIndex
79
- ) {
80
- this.direction.set(nextIndex > prevIndex ? 1 : -1);
81
- }
58
+ /**
59
+ * Computes the slide direction when the active value changes, whether from
60
+ * select() or from the model being set. A field rather than a constructor:
61
+ * Angular's test build keeps a function for a constructor that nothing
62
+ * calls, which an app's coverage report would show as uncovered.
63
+ */
64
+ protected readonly trackingDirection = effect(() => {
65
+ const currentValue = this.value();
66
+ if (this.#previousValue === undefined) {
82
67
  this.#previousValue = currentValue;
83
- });
84
- }
68
+ return;
69
+ }
70
+ // The value can change and change back before the effect runs (two taps
71
+ // in one frame): the tab didn't move, so the direction stays.
72
+ if (currentValue === this.#previousValue) return;
73
+
74
+ const order = [...this.#tabOrder];
75
+ const prevIndex = order.indexOf(this.#previousValue);
76
+ const nextIndex = order.indexOf(currentValue);
77
+ // A value with no trigger has no place in the order to slide from or to.
78
+ // Two different registered values always have different indexes.
79
+ if (prevIndex !== -1 && nextIndex !== -1) {
80
+ this.direction.set(nextIndex > prevIndex ? 1 : -1);
81
+ }
82
+ this.#previousValue = currentValue;
83
+ });
85
84
 
86
85
  /**
87
86
  * Called by UiTabsTrigger to register ordering for direction detection.
88
87
  */
89
88
  registerTab(value: string): void {
90
- if (!this.#tabOrder.includes(value)) {
91
- this.#tabOrder.push(value);
92
- }
89
+ this.#tabOrder.add(value);
93
90
  }
94
91
 
95
92
  select(value: string): void {
@@ -143,7 +140,7 @@ export class UiTabsTrigger {
143
140
  readonly triggerValue = input.required<string>({ alias: 'value' });
144
141
  readonly userClass = input<string>('', { alias: 'class' });
145
142
 
146
- readonly triggerRef = viewChild<ElementRef>('trigger');
143
+ readonly triggerRef = viewChild.required<ElementRef>('trigger');
147
144
  #anim?: AnimationHandle;
148
145
  #previousActive?: boolean;
149
146
 
@@ -151,57 +148,52 @@ export class UiTabsTrigger {
151
148
  () => this.#tabs.value() === this.triggerValue(),
152
149
  );
153
150
 
154
- constructor() {
155
- // Register this trigger with the parent for direction detection
156
- effect(() => {
157
- this.#tabs.registerTab(this.triggerValue());
158
- });
151
+ /** Registers this trigger with the parent for direction detection. */
152
+ protected readonly registering = effect(() => {
153
+ this.#tabs.registerTab(this.triggerValue());
154
+ });
159
155
 
160
- // Animate trigger background on selection change
161
- effect(() => {
162
- const active = this.isActive();
163
- const el = this.triggerRef()?.nativeElement;
164
- if (!el || this.#previousActive === undefined) {
165
- this.#previousActive = active;
166
- return;
167
- }
168
- // Defensive: `isActive` computed dedupes same-value emissions so the
169
- // effect never re-runs with an unchanged `active`. Kept for safety.
170
- /* v8 ignore next 1 */
171
- if (active === this.#previousActive) return;
172
- this.#previousActive = active;
156
+ /** Animates the trigger's background when it's selected or deselected. */
157
+ protected readonly animatingSelection = effect(() => {
158
+ const active = this.isActive();
159
+ const el = this.triggerRef().nativeElement;
160
+ const wasActive = this.#previousActive;
161
+ this.#previousActive = active;
162
+ // The first run only records the starting state, and only a change has
163
+ // anything to animate. (`isActive` is a computed, so the effect only
164
+ // re-runs once it changes; the second check is a safeguard.)
165
+ if (wasActive === undefined || wasActive === active) return;
173
166
 
174
- this.#anim?.cancel();
167
+ this.#anim?.cancel();
175
168
 
176
- if (active) {
177
- // Activation: spring-based scale + opacity for a lively entrance
178
- this.#anim = el.animate(
179
- [
180
- { transform: 'scaleX(0.95)', opacity: 0.7 },
181
- { transform: 'scaleX(1)', opacity: 1 },
182
- ],
183
- {
184
- duration: DURATION.normal,
185
- easing: EASING.springSubtle,
186
- fill: 'forwards',
187
- },
188
- );
189
- } else {
190
- // Deactivation: quick shrink + fade for a graceful exit
191
- this.#anim = el.animate(
192
- [
193
- { transform: 'scaleX(1)', opacity: 1 },
194
- { transform: 'scaleX(0.97)', opacity: 0.7 },
195
- ],
196
- {
197
- duration: DURATION.fast,
198
- easing: EASING.accelerate,
199
- fill: 'forwards',
200
- },
201
- );
202
- }
203
- });
204
- }
169
+ if (active) {
170
+ // Activation: spring-based scale + opacity for a lively entrance
171
+ this.#anim = el.animate(
172
+ [
173
+ { transform: 'scaleX(0.95)', opacity: 0.7 },
174
+ { transform: 'scaleX(1)', opacity: 1 },
175
+ ],
176
+ {
177
+ duration: DURATION.normal,
178
+ easing: EASING.springSubtle,
179
+ fill: 'forwards',
180
+ },
181
+ );
182
+ } else {
183
+ // Deactivation: quick shrink + fade for a graceful exit
184
+ this.#anim = el.animate(
185
+ [
186
+ { transform: 'scaleX(1)', opacity: 1 },
187
+ { transform: 'scaleX(0.97)', opacity: 0.7 },
188
+ ],
189
+ {
190
+ duration: DURATION.fast,
191
+ easing: EASING.accelerate,
192
+ fill: 'forwards',
193
+ },
194
+ );
195
+ }
196
+ });
205
197
 
206
198
  protected readonly triggerClass = computed(() =>
207
199
  cn(
@@ -263,7 +255,7 @@ export class UiTabsContent {
263
255
  readonly contentValue = input.required<string>({ alias: 'value' });
264
256
  readonly userClass = input<string>('', { alias: 'class' });
265
257
 
266
- readonly contentRef = viewChild<ElementRef>('content');
258
+ readonly contentRef = viewChild.required<ElementRef>('content');
267
259
  #anim?: AnimationHandle;
268
260
  #isFirstRender = true;
269
261
 
@@ -271,43 +263,43 @@ export class UiTabsContent {
271
263
  () => this.#tabs.value() === this.contentValue(),
272
264
  );
273
265
 
274
- constructor() {
275
- // Animate content reveal when tab becomes active.
276
- //
277
- // The panel is ALWAYS rendered and shown/hidden via the host's `display`
278
- // (see the `host` block) rather than wrapped in `@if`. On Lynx, destroying a
279
- // subtree that holds projected `<ng-content>` sends that content through the
280
- // renderer's "park on the page root" path (it must keep still-alive
281
- // projected nodes from dying), which drops inactive panels at the bottom of
282
- // the page and made re-showing them unreliable — the symptom was tab content
283
- // that vanished and never came back. Keeping every panel mounted and only
284
- // toggling `display` never destroys or re-parents anything: inactive panels
285
- // collapse to 0×0 (Lynx `display: none`) so they take no space, and the
286
- // active one simply reappears in place.
287
- effect(() => {
288
- if (!this.isActive()) return;
289
- // Defer one tick so the display flip to 'flex' has applied before we animate.
290
- setTimeout(() => {
291
- const el = this.contentRef()?.nativeElement;
292
- if (!el) return;
266
+ /**
267
+ * Animates the content's reveal when its tab becomes active.
268
+ *
269
+ * The panel is ALWAYS rendered and shown/hidden via the host's `display`
270
+ * (see the `host` block) rather than wrapped in `@if`. On Lynx, destroying a
271
+ * subtree that holds projected `<ng-content>` sends that content through the
272
+ * renderer's "park on the page root" path (it must keep still-alive
273
+ * projected nodes from dying), which drops inactive panels at the bottom of
274
+ * the page and made re-showing them unreliable — the symptom was tab content
275
+ * that vanished and never came back. Keeping every panel mounted and only
276
+ * toggling `display` never destroys or re-parents anything: inactive panels
277
+ * collapse to 0×0 (Lynx `display: none`) so they take no space, and the
278
+ * active one simply reappears in place. That also means the content view
279
+ * always exists, so `contentRef` is required.
280
+ */
281
+ protected readonly revealing = effect(() => {
282
+ if (!this.isActive()) return;
283
+ // Defer one tick so the display flip to 'flex' has applied before we animate.
284
+ setTimeout(() => {
285
+ const el = this.contentRef().nativeElement;
293
286
 
294
- this.#anim?.cancel();
287
+ this.#anim?.cancel();
295
288
 
296
- if (this.#isFirstRender) {
297
- // First render: subtle vertical reveal (no directional context yet)
298
- this.#isFirstRender = false;
299
- this.#anim = revealIn(el, { fromY: 6, duration: DURATION.normal });
300
- } else {
301
- // Subsequent renders: direction-aware horizontal slide
302
- const dir = this.#tabs.direction();
303
- this.#anim = directionalSlideIn(el, dir, {
304
- distance: 24,
305
- duration: 300,
306
- });
307
- }
308
- }, 0);
309
- });
310
- }
289
+ if (this.#isFirstRender) {
290
+ // First render: subtle vertical reveal (no directional context yet)
291
+ this.#isFirstRender = false;
292
+ this.#anim = revealIn(el, { fromY: 6, duration: DURATION.normal });
293
+ } else {
294
+ // Subsequent renders: direction-aware horizontal slide
295
+ const dir = this.#tabs.direction();
296
+ this.#anim = directionalSlideIn(el, dir, {
297
+ distance: 24,
298
+ duration: 300,
299
+ });
300
+ }
301
+ }, 0);
302
+ });
311
303
 
312
304
  // Visibility is controlled at the host (see `host` block); the inner view is
313
305
  // always a normal flex column that fills the shown host.
@@ -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
  });