@blotch/dolan 22.2.4 → 22.2.6

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 (124) hide show
  1. package/dist/commands/add.test.js +2 -0
  2. package/dist/commands/add.test.js.map +1 -1
  3. package/dist/commands/doctor.js +78 -21
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/doctor.test.js +167 -26
  6. package/dist/commands/doctor.test.js.map +1 -1
  7. package/dist/commands/init.test.js +4 -7
  8. package/dist/commands/init.test.js.map +1 -1
  9. package/dist/commands/update.d.ts +3 -1
  10. package/dist/commands/update.js +78 -15
  11. package/dist/commands/update.js.map +1 -1
  12. package/dist/commands/update.test.js +257 -2
  13. package/dist/commands/update.test.js.map +1 -1
  14. package/dist/lockfile.d.ts +10 -0
  15. package/dist/lockfile.js +20 -1
  16. package/dist/lockfile.js.map +1 -1
  17. package/dist/lockfile.test.js +43 -3
  18. package/dist/lockfile.test.js.map +1 -1
  19. package/dist/registry.js +73 -11
  20. package/dist/registry.js.map +1 -1
  21. package/dist/registry.test.js +16 -3
  22. package/dist/registry.test.js.map +1 -1
  23. package/dist/test-utils.d.ts +24 -2
  24. package/dist/test-utils.js +41 -2
  25. package/dist/test-utils.js.map +1 -1
  26. package/dist/ui/boolean-inputs.test.ts +76 -0
  27. package/dist/ui/components/action-sheet/action-sheet.test.ts +370 -1
  28. package/dist/ui/components/action-sheet/action-sheet.ts +306 -28
  29. package/dist/ui/components/action-sheet/index.ts +1 -0
  30. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +552 -8
  31. package/dist/ui/components/alert-dialog/alert-dialog.ts +644 -36
  32. package/dist/ui/components/alert-dialog/index.ts +5 -0
  33. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  34. package/dist/ui/components/badge/badge.ts +2 -1
  35. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +968 -3
  36. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1021 -95
  37. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  38. package/dist/ui/components/button/button.test.ts +36 -1
  39. package/dist/ui/components/button/button.ts +17 -7
  40. package/dist/ui/components/button-group/button-group.test.ts +53 -9
  41. package/dist/ui/components/button-group/button-group.ts +67 -21
  42. package/dist/ui/components/card/card.test.ts +16 -0
  43. package/dist/ui/components/card/card.ts +2 -1
  44. package/dist/ui/components/chart/chart-presets.ts +6 -6
  45. package/dist/ui/components/chart/chart.test.ts +14 -0
  46. package/dist/ui/components/chart/chart.ts +10 -3
  47. package/dist/ui/components/checkbox/checkbox.ts +2 -1
  48. package/dist/ui/components/collapsible/collapsible.ts +2 -1
  49. package/dist/ui/components/dialog/dialog.ts +6 -6
  50. package/dist/ui/components/dock/dock-scroll.ts +9 -0
  51. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  52. package/dist/ui/components/dock/dock.test.ts +4 -1
  53. package/dist/ui/components/dock/dock.ts +529 -51
  54. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  55. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  56. package/dist/ui/components/dynamic-color/index.ts +5 -0
  57. package/dist/ui/components/glass/glass.test.ts +39 -1
  58. package/dist/ui/components/glass/glass.ts +87 -17
  59. package/dist/ui/components/glass/index.ts +1 -0
  60. package/dist/ui/components/icon/icon-theme.test.ts +28 -0
  61. package/dist/ui/components/icon/icon-theme.ts +38 -7
  62. package/dist/ui/components/input/input.ts +2 -1
  63. package/dist/ui/components/label/label.ts +8 -2
  64. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  65. package/dist/ui/components/list/list.ts +5 -4
  66. package/dist/ui/components/menu/menu-popup.test.ts +18 -14
  67. package/dist/ui/components/menu/menu-popup.ts +24 -3
  68. package/dist/ui/components/menu/menu.test.ts +99 -5
  69. package/dist/ui/components/menu/menu.ts +74 -34
  70. package/dist/ui/components/nav-drawer/nav-drawer.ts +3 -2
  71. package/dist/ui/components/navigation-bar/index.ts +1 -0
  72. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  73. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  74. package/dist/ui/components/popover/index.ts +9 -0
  75. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  76. package/dist/ui/components/popover/popover-panel.ts +603 -0
  77. package/dist/ui/components/popover/popover.test.ts +286 -0
  78. package/dist/ui/components/popover/popover.ts +207 -0
  79. package/dist/ui/components/progress/progress.ts +2 -1
  80. package/dist/ui/components/radio-group/radio-group.ts +2 -1
  81. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  82. package/dist/ui/components/search-field/index.ts +15 -0
  83. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  84. package/dist/ui/components/search-field/keyboard.ts +123 -0
  85. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  86. package/dist/ui/components/search-field/search-field.ts +628 -0
  87. package/dist/ui/components/segmented-control/index.ts +8 -0
  88. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  89. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  90. package/dist/ui/components/select/select.ts +2 -1
  91. package/dist/ui/components/sheet/sheet.test.ts +90 -3
  92. package/dist/ui/components/sheet/sheet.ts +59 -4
  93. package/dist/ui/components/slider/index.ts +1 -0
  94. package/dist/ui/components/slider/slider.test.ts +695 -0
  95. package/dist/ui/components/slider/slider.ts +901 -0
  96. package/dist/ui/components/switch/index.ts +1 -1
  97. package/dist/ui/components/switch/switch.test.ts +464 -48
  98. package/dist/ui/components/switch/switch.ts +613 -43
  99. package/dist/ui/components/tabs/index.ts +7 -1
  100. package/dist/ui/components/tabs/tabs.test.ts +278 -2
  101. package/dist/ui/components/tabs/tabs.ts +233 -30
  102. package/dist/ui/components/textarea/textarea.ts +2 -1
  103. package/dist/ui/components/toggle/index.ts +1 -0
  104. package/dist/ui/components/toggle/toggle.test.ts +310 -4
  105. package/dist/ui/components/toggle/toggle.ts +241 -26
  106. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  107. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  108. package/dist/ui/components/toolbar/index.ts +12 -0
  109. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  110. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  111. package/dist/utils/analyze.d.ts +10 -5
  112. package/dist/utils/analyze.js +3 -2
  113. package/dist/utils/analyze.js.map +1 -1
  114. package/dist/utils/installed.d.ts +13 -1
  115. package/dist/utils/installed.js +17 -0
  116. package/dist/utils/installed.js.map +1 -1
  117. package/dist/utils/installed.test.js +39 -1
  118. package/dist/utils/installed.test.js.map +1 -1
  119. package/dist/utils/tailwind.d.ts +25 -5
  120. package/dist/utils/tailwind.js +53 -14
  121. package/dist/utils/tailwind.js.map +1 -1
  122. package/dist/utils/tailwind.test.js +85 -22
  123. package/dist/utils/tailwind.test.js.map +1 -1
  124. package/package.json +1 -1
@@ -2,7 +2,9 @@ import {
2
2
  type ElementRef,
3
3
  Component,
4
4
  ViewEncapsulation,
5
+ booleanAttribute,
5
6
  computed,
7
+ contentChildren,
6
8
  inject,
7
9
  input,
8
10
  model,
@@ -17,6 +19,14 @@ import {
17
19
  pressRelease,
18
20
  } from '../../utils/animate';
19
21
  import { cn } from '../../utils/cn';
22
+ import { UI_ICON_COLOR } from '../icon/icon-theme';
23
+ import {
24
+ type SegmentedHost,
25
+ segmentPlace,
26
+ UI_SEGMENTED_HOST,
27
+ UiSegmentedControl,
28
+ UiSegmentedIndicator,
29
+ } from '../segmented-control/segmented-control';
20
30
 
21
31
  const toggleVariants = cva('items-center rounded-md flex justify-center', {
22
32
  variants: {
@@ -43,9 +53,39 @@ export type ToggleSize = NonNullable<
43
53
  VariantProps<typeof toggleVariants>['size']
44
54
  >;
45
55
 
56
+ /**
57
+ * A group's look: a toggle variant for every item, or `'segmented'` for the
58
+ * platform's segmented control (see UiToggleGroup).
59
+ */
60
+ export type ToggleGroupVariant = ToggleVariant | 'segmented';
61
+
62
+ /**
63
+ * `UiToggleGroupItem`, for the group's content query. A call rather than
64
+ * the class itself: for a class, Angular's test build keeps an arrow
65
+ * function that nothing calls, which an app's coverage report would show as
66
+ * uncovered.
67
+ */
68
+ const itemType = () => UiToggleGroupItem;
69
+
70
+ /**
71
+ * The group, as the segmented control it provides reads it. Named, not
72
+ * inline: the component's metadata keeps its own copy of an inline factory,
73
+ * which nothing calls.
74
+ */
75
+ const toggleGroupHost = (): SegmentedHost => inject(UiToggleGroup);
76
+
77
+ /**
78
+ * The color icons projected into an item draw with (see UI_ICON_COLOR): a
79
+ * Material segment's content color, or else whatever surrounds the group.
80
+ */
81
+ const itemIconColor = () => {
82
+ const item = inject(UiToggleGroupItem);
83
+ const surrounding = inject(UI_ICON_COLOR, { optional: true, skipSelf: true });
84
+ return computed(() => item.iconColor() ?? surrounding?.());
85
+ };
86
+
46
87
  @Component({
47
88
  selector: 'ui-toggle',
48
- standalone: true,
49
89
  imports: [LYNX_ELEMENTS],
50
90
  encapsulation: ViewEncapsulation.None,
51
91
  template: `
@@ -68,7 +108,7 @@ export class UiToggle {
68
108
  readonly pressed = model(false);
69
109
  readonly variant = input<ToggleVariant>('default');
70
110
  readonly size = input<ToggleSize>('default');
71
- readonly disabled = input(false);
111
+ readonly disabled = input(false, { transform: booleanAttribute });
72
112
  readonly userClass = input<string>('', { alias: 'class' });
73
113
 
74
114
  readonly containerRef = viewChild<ElementRef>('container');
@@ -106,27 +146,77 @@ export class UiToggle {
106
146
  }
107
147
  }
108
148
 
149
+ /**
150
+ * A row of toggles where one (`type="single"`) or several
151
+ * (`type="multiple"`) are pressed. Its `variant` and `size` style every item.
152
+ *
153
+ * `variant="segmented"` draws the platform's segmented control instead
154
+ * (see UiSegmentedControl): iOS 26's capsule with a thumb that lifts into
155
+ * Liquid Glass as it slides between the items, iOS 13 to 18's raised thumb
156
+ * before iOS 26, and Material 3 Expressive's connected button group on
157
+ * Android. The items share the row's width. A single selection has one
158
+ * indicator that slides, which can also be dragged across the items, and
159
+ * keeps one item selected: tapping it again doesn't clear it, as a
160
+ * segmented control's segment doesn't. In a multiple selection each pressed
161
+ * item draws its own thumb, and nothing slides: one indicator can't mark
162
+ * several items. `size` and the other variants don't apply to it.
163
+ */
109
164
  @Component({
110
165
  selector: 'ui-toggle-group',
111
- standalone: true,
112
- imports: [LYNX_ELEMENTS],
166
+ imports: [LYNX_ELEMENTS, UiSegmentedIndicator],
113
167
  encapsulation: ViewEncapsulation.None,
168
+ providers: [
169
+ UiSegmentedControl,
170
+ { provide: UI_SEGMENTED_HOST, useFactory: toggleGroupHost },
171
+ ],
172
+ // The mouse leaving the row lets go of an indicator dragged in the web
173
+ // preview, where a drag gets no touchcancel.
114
174
  template: `
115
- <view [class]="containerClass()">
175
+ <view
176
+ [class]="containerClass()"
177
+ (bindmouseleave)="segmentedControl.cancel()"
178
+ >
179
+ @if (segmentedControl.underlay()) {
180
+ <ui-segmented-indicator />
181
+ }
116
182
  <ng-content />
117
183
  </view>
118
184
  `,
119
185
  })
120
- export class UiToggleGroup {
186
+ export class UiToggleGroup implements SegmentedHost {
121
187
  readonly type = input<'single' | 'multiple'>('single');
122
188
  readonly value = model<string[]>([]);
123
- readonly variant = input<ToggleVariant>('default');
189
+ /**
190
+ * `'default'` or `'outline'` for every item, or `'segmented'` for the
191
+ * platform's segmented control.
192
+ */
193
+ readonly variant = input<ToggleGroupVariant>('default');
124
194
  readonly size = input<ToggleSize>('default');
125
- readonly disabled = input(false);
195
+ readonly disabled = input(false, { transform: booleanAttribute });
126
196
  readonly userClass = input<string>('', { alias: 'class' });
127
197
 
198
+ protected readonly segmentedControl = inject(UiSegmentedControl);
199
+ /** The items in the group, in order. */
200
+ readonly items = contentChildren(itemType(), { descendants: true });
201
+
202
+ /** Whether the group is drawn as a segmented control (SegmentedHost). */
203
+ readonly segmented = computed(() => this.variant() === 'segmented');
204
+ /** The items' values, in order (SegmentedHost). */
205
+ readonly segmentValues = computed(() =>
206
+ this.items().map((item) => item.itemValue()),
207
+ );
208
+ /** The pressed items' values (SegmentedHost). */
209
+ readonly selectedValues = this.value.asReadonly();
210
+ /** Whether several items can be pressed (SegmentedHost). */
211
+ readonly multiple = computed(() => this.type() === 'multiple');
212
+
128
213
  protected readonly containerClass = computed(() =>
129
- cn('flex-row items-center gap-1 flex', this.userClass()),
214
+ cn(
215
+ this.segmented()
216
+ ? this.segmentedControl.trackClass()
217
+ : 'flex-row items-center gap-1 flex',
218
+ this.userClass(),
219
+ ),
130
220
  );
131
221
 
132
222
  toggle(val: string): void {
@@ -135,8 +225,9 @@ export class UiToggleGroup {
135
225
  const current = this.value();
136
226
  // Single mode allows deselection: tapping the active item again clears
137
227
  // the selection to []. This differs from a radio group, which requires
138
- // one item to always be selected.
139
- this.value.set(current.includes(val) ? [] : [val]);
228
+ // one item to always be selected. A segmented control's segment stays
229
+ // selected, as on iOS and Android.
230
+ this.value.set(current.includes(val) && !this.segmented() ? [] : [val]);
140
231
  } else {
141
232
  this.value.update((list) =>
142
233
  list.includes(val) ? list.filter((v) => v !== val) : [...list, val],
@@ -144,6 +235,14 @@ export class UiToggleGroup {
144
235
  }
145
236
  }
146
237
 
238
+ /**
239
+ * Selects one item, as dragging the segmented control's indicator onto it
240
+ * does (SegmentedHost).
241
+ */
242
+ select(val: string): void {
243
+ this.value.set([val]);
244
+ }
245
+
147
246
  isSelected(val: string): boolean {
148
247
  return this.value().includes(val);
149
248
  }
@@ -151,68 +250,184 @@ export class UiToggleGroup {
151
250
 
152
251
  @Component({
153
252
  selector: 'ui-toggle-group-item',
154
- standalone: true,
155
253
  imports: [LYNX_ELEMENTS],
156
254
  encapsulation: ViewEncapsulation.None,
255
+ // Icons projected into the item inject this: they're declared inside it.
256
+ providers: [{ provide: UI_ICON_COLOR, useFactory: itemIconColor }],
257
+ host: {
258
+ '[class]': 'hostClass()',
259
+ // The segmented control places its indicator from each item's width.
260
+ '(bindlayoutchange)': 'measure($event)',
261
+ },
157
262
  template: `
263
+ <!-- A labeled item is selected to a screen reader while pressed, which
264
+ iOS reads. lynxAccessibilityTraits adds the trait to button on iOS,
265
+ and leaves Android, which reads one trait, with button. -->
158
266
  <view
159
267
  #container
160
268
  [class]="containerClass()"
161
- (bindtouchstart)="onPressStart()"
269
+ [style]="containerStyle()"
270
+ [attr.accessibility-element]="accessibilityLabel() ? 'true' : null"
271
+ [attr.accessibility-traits]="accessibilityLabel() ? 'button' : null"
272
+ [attr.accessibility-label]="accessibilityLabel() || null"
273
+ [lynxAccessibilityTraits]="{ selected: selectedTrait() }"
274
+ (bindtouchstart)="onPressStart($event)"
275
+ (bindtouchmove)="onPressMove($event)"
162
276
  (bindtouchend)="onPressEnd()"
163
277
  (bindtouchcancel)="onPressCancel()"
164
- (bindmousedown)="onPressStart()"
278
+ (bindmousedown)="onPressStart($event)"
279
+ (bindmousemove)="onPressMove($event)"
165
280
  (bindmouseup)="onPressEnd()"
166
- (bindmouseleave)="onPressCancel()"
281
+ (bindmouseleave)="onPointerLeave()"
167
282
  (bindtap)="onTap()"
168
283
  >
284
+ @if (stateLayerClass(); as stateLayer) {
285
+ <view [class]="stateLayer"></view>
286
+ }
169
287
  <ng-content />
170
288
  </view>
171
289
  `,
172
290
  })
173
291
  export class UiToggleGroupItem {
174
292
  readonly #group = inject(UiToggleGroup);
293
+ readonly #segmented = inject(UiSegmentedControl);
175
294
 
176
295
  readonly itemValue = input.required<string>({ alias: 'value' });
296
+ /**
297
+ * What a screen reader says for the item. With it, the item is one
298
+ * accessibility element, a button that's `selected` while pressed. An
299
+ * item of only an icon needs one.
300
+ */
301
+ readonly accessibilityLabel = input<string | undefined>(undefined, {
302
+ alias: 'accessibility-label',
303
+ });
177
304
  readonly userClass = input<string>('', { alias: 'class' });
178
305
 
179
- readonly containerRef = viewChild<ElementRef>('container');
306
+ // Required: always in the template.
307
+ readonly containerRef = viewChild.required<ElementRef>('container');
180
308
  #pressAnim?: AnimationHandle;
181
309
 
182
310
  protected readonly isSelected = computed(() =>
183
311
  this.#group.isSelected(this.itemValue()),
184
312
  );
313
+ /** Only a labeled item is an accessibility element to be `selected`. */
314
+ protected readonly selectedTrait = computed(
315
+ () => !!this.accessibilityLabel() && this.isSelected(),
316
+ );
185
317
 
186
- protected readonly containerClass = computed(() =>
187
- cn(
318
+ /** Whether the item is a segment of a segmented control. */
319
+ readonly #segment = computed(() => this.#segmented.enabled());
320
+
321
+ /**
322
+ * The color of icons projected into the item, as a token, or undefined
323
+ * for the surrounding one. A Material segment's content takes the color
324
+ * of its fill's content.
325
+ */
326
+ readonly iconColor = computed(() =>
327
+ this.#segment() ? this.#segmented.iconColor(this.itemValue()) : undefined,
328
+ );
329
+
330
+ /**
331
+ * A segment's host is the track's flex item, an equal share of it. Angular
332
+ * puts the app's static `class` on the host as well as on the item, so the
333
+ * two are merged here: `class="flex-[2]"` makes a wider segment.
334
+ */
335
+ protected readonly hostClass = computed(() =>
336
+ this.#segment()
337
+ ? cn(this.#segmented.segmentHostClass, this.userClass())
338
+ : '',
339
+ );
340
+
341
+ protected readonly containerClass = computed(() => {
342
+ if (this.#segment()) {
343
+ return cn(
344
+ this.#segmented.segmentClass(
345
+ this.itemValue(),
346
+ segmentPlace(this.#group.items(), this),
347
+ ),
348
+ this.userClass(),
349
+ );
350
+ }
351
+ return cn(
188
352
  toggleVariants({
189
- variant: this.#group.variant(),
353
+ // The group's variant is a toggle variant unless it's segmented.
354
+ variant: this.#group.variant() as ToggleVariant,
190
355
  size: this.#group.size(),
191
356
  }),
192
357
  this.isSelected() ? 'bg-accent' : 'bg-transparent',
193
358
  this.#group.disabled() && 'opacity-50',
194
359
  this.userClass(),
195
- ),
360
+ );
361
+ });
362
+
363
+ protected readonly containerStyle = computed(() =>
364
+ this.#segment() ? this.#segmented.segmentStyle(this.itemValue()) : null,
196
365
  );
197
366
 
198
- protected onPressStart(): void {
367
+ protected readonly stateLayerClass = computed(() =>
368
+ this.#segment() ? this.#segmented.stateLayerClass(this.itemValue()) : null,
369
+ );
370
+
371
+ protected measure(event: unknown): void {
372
+ this.#segmented.measure(this.itemValue(), event);
373
+ }
374
+
375
+ /**
376
+ * A press. A segment's touches go to the segmented control, which draws
377
+ * their feedback and moves its indicator; other items shrink under the
378
+ * finger.
379
+ */
380
+ protected onPressStart(event: unknown): void {
199
381
  if (this.#group.disabled()) return;
382
+ if (this.#segment()) {
383
+ this.#segmented.press(this.itemValue(), event);
384
+ return;
385
+ }
200
386
  this.#pressAnim?.cancel();
201
- this.#pressAnim = pressDown(this.containerRef()?.nativeElement);
387
+ this.#pressAnim = pressDown(this.containerRef().nativeElement);
388
+ }
389
+
390
+ protected onPressMove(event: unknown): void {
391
+ this.#segmented.move(event);
202
392
  }
203
393
 
204
394
  protected onPressEnd(): void {
205
395
  if (this.#group.disabled()) return;
396
+ if (this.#segment()) {
397
+ this.#segmented.release();
398
+ return;
399
+ }
206
400
  this.#pressAnim?.cancel();
207
- this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
401
+ this.#pressAnim = pressRelease(this.containerRef().nativeElement);
208
402
  }
209
403
 
210
404
  protected onPressCancel(): void {
211
- this.#pressAnim?.cancel();
212
- this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
405
+ if (this.#segment()) {
406
+ this.#segmented.cancel();
407
+ return;
408
+ }
409
+ this.#release();
410
+ }
411
+
412
+ /**
413
+ * The mouse leaving one segment doesn't end a drag across the others.
414
+ */
415
+ protected onPointerLeave(): void {
416
+ if (this.#segment()) {
417
+ this.#segmented.leave(this.itemValue());
418
+ return;
419
+ }
420
+ this.#release();
213
421
  }
214
422
 
215
423
  protected onTap(): void {
216
- this.#group.toggle(this.itemValue());
424
+ if (this.#segmented.tap(this.itemValue())) {
425
+ this.#group.toggle(this.itemValue());
426
+ }
427
+ }
428
+
429
+ #release(): void {
430
+ this.#pressAnim?.cancel();
431
+ this.#pressAnim = pressRelease(this.containerRef().nativeElement);
217
432
  }
218
433
  }
@@ -0,0 +1,310 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ waitForUpdate,
6
+ } from '@blotch/angular-lynx-testing-library';
7
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
8
+ import { afterEach, describe, expect, it, vi } from 'vitest';
9
+ import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
10
+ import { provideGlass } from '../glass/glass-design';
11
+ import { UiBarScroll, UiBarState } from './bar-scroll';
12
+
13
+ @Component({
14
+ imports: [LYNX_ELEMENTS, UiBarScroll],
15
+ template: `
16
+ @if (first()) {
17
+ <scroll-view ui-bar-scroll id="first" scroll-orientation="vertical">
18
+ <view style="height: 2000px"></view>
19
+ </scroll-view>
20
+ }
21
+ @if (second()) {
22
+ <scroll-view ui-bar-scroll id="second" scroll-orientation="vertical">
23
+ <view style="height: 2000px"></view>
24
+ </scroll-view>
25
+ }
26
+ `,
27
+ })
28
+ class ScrollHost {
29
+ readonly first = signal(true);
30
+ readonly second = signal(false);
31
+ }
32
+
33
+ const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
34
+ const IOS_18 = { platform: 'iOS', osVersion: '18.5' };
35
+ const ANDROID = { platform: 'Android', osVersion: '15' };
36
+ const WEB = { platform: 'web', osVersion: '' };
37
+
38
+ /**
39
+ * The bars a screen would have, as a navigation bar and a toolbar say.
40
+ */
41
+ const topBar = (largeTitle: string | null = null) => ({
42
+ height: signal(44),
43
+ largeTitle: signal(largeTitle),
44
+ });
45
+ const bottomBar = () => ({ height: signal(52) });
46
+
47
+ const renderScroll = async (
48
+ platform: { platform: string; osVersion: string } = IOS_26,
49
+ ) => {
50
+ vi.stubGlobal('SystemInfo', platform);
51
+ const result = await render(ScrollHost);
52
+ const host = result.componentRef.instance as ScrollHost;
53
+ const state = result.componentRef.injector.get(UiBarState);
54
+ const scrollView = (id = 'first') =>
55
+ result.container.querySelector<HTMLElement>(`#${id}`)!;
56
+ /**
57
+ * The spacers the scroll view puts at its ends for the bars.
58
+ */
59
+ const topSpacer = () => scrollView().firstElementChild as HTMLElement;
60
+ const bottomSpacer = () => scrollView().lastElementChild as HTMLElement;
61
+ const update = async (change: () => void) => {
62
+ change();
63
+ await waitForUpdate();
64
+ };
65
+ /**
66
+ * Scrolls the content to `scrollTop`, as Lynx reports it.
67
+ */
68
+ const scrollTo = (scrollTop: number, id = 'first') =>
69
+ fireEvent.scroll(scrollView(id), {
70
+ detail: { scrollTop, scrollHeight: 2000 },
71
+ });
72
+ /**
73
+ * Lays the scroll view out `height` px tall, and its spacers.
74
+ */
75
+ const layOut = async (height: number, top = 0, bottom = 0) => {
76
+ fireEvent.layoutchange(scrollView(), { detail: { width: 402, height } });
77
+ fireEvent.layoutchange(topSpacer(), {
78
+ detail: { width: 402, height: top },
79
+ });
80
+ fireEvent.layoutchange(bottomSpacer(), {
81
+ detail: { width: 402, height: bottom },
82
+ });
83
+ await waitForUpdate();
84
+ };
85
+ return {
86
+ ...result,
87
+ host,
88
+ state,
89
+ scrollView,
90
+ topSpacer,
91
+ bottomSpacer,
92
+ update,
93
+ scrollTo,
94
+ layOut,
95
+ };
96
+ };
97
+
98
+ /**
99
+ * The scroll view's mask as [how much shows, where] stops, however the DOM
100
+ * writes it back: jsdom versions differ, writing opaque black as
101
+ * `rgb(0, 0, 0)` and leaving out the default `to bottom`.
102
+ */
103
+ const maskStops = (element: HTMLElement): [number, string][] =>
104
+ Array.from(
105
+ element.style
106
+ .getPropertyValue('mask-image')
107
+ .matchAll(
108
+ /rgba?\(0, 0, 0(?:, ([\d.]+))?\) (calc\([^)]*\)|[\d.]+(?:%|px))/g,
109
+ ),
110
+ ([, alpha, at]): [number, string] => [
111
+ alpha === undefined ? 1 : Number(alpha),
112
+ at,
113
+ ],
114
+ );
115
+
116
+ afterEach(() => {
117
+ vi.unstubAllGlobals();
118
+ });
119
+
120
+ describe('UiBarScroll', () => {
121
+ it('makes no room and draws no edge without bars over it', async () => {
122
+ const { topSpacer, bottomSpacer, scrollView, layOut, queryByRole } =
123
+ await renderScroll();
124
+ await layOut(800);
125
+ expect(topSpacer().style.height).toBe('');
126
+ expect(bottomSpacer().style.height).toBe('');
127
+ expect(scrollView().style.getPropertyValue('mask-image')).toBe('');
128
+ expect(queryByRole('heading')).toBeNull();
129
+ });
130
+
131
+ it('starts the content below the top bar and ends it above the bottom bar', async () => {
132
+ const { state, topSpacer, bottomSpacer, update } = await renderScroll();
133
+ await update(() => {
134
+ state.topBar.set(topBar());
135
+ state.bottomBar.set(bottomBar());
136
+ });
137
+ // The status bar and the home indicator count too. jsdom versions write
138
+ // the sum in different orders.
139
+ expect(topSpacer().style.height).toContain('44px');
140
+ expect(topSpacer().style.height).toContain('env(safe-area-inset-top)');
141
+ expect(bottomSpacer().style.height).toContain('52px');
142
+ expect(bottomSpacer().style.height).toContain(
143
+ 'env(safe-area-inset-bottom)',
144
+ );
145
+ });
146
+
147
+ it('puts the large title at the top of the content, and tells the bar when it has scrolled under', async () => {
148
+ const { state, update, getByRole, scrollTo } = await renderScroll();
149
+ await update(() => state.topBar.set(topBar('Inbox')));
150
+ const title = getByRole('heading', { name: 'Inbox' });
151
+ expect(title.className).toContain('text-[34px]');
152
+ expect(title.parentElement?.className).toContain('h-[52px]');
153
+
154
+ scrollTo(20);
155
+ expect(state.scrolledUnderTop()).toBe(true);
156
+ expect(state.largeTitleUnder()).toBe(false);
157
+ scrollTo(44);
158
+ expect(state.largeTitleUnder()).toBe(true);
159
+ scrollTo(0);
160
+ expect(state.scrolledUnderTop()).toBe(false);
161
+ expect(state.largeTitleUnder()).toBe(false);
162
+ });
163
+
164
+ it('draws Material’s large title, the medium flexible app bar’s', async () => {
165
+ const { state, update, getByRole, scrollTo } = await renderScroll(ANDROID);
166
+ await update(() => state.topBar.set(topBar('Inbox')));
167
+ const title = getByRole('heading', { name: 'Inbox' });
168
+ expect(title.className).toContain('text-[28px]');
169
+ expect(title.parentElement?.className).toContain('h-[48px]');
170
+ scrollTo(40);
171
+ expect(state.largeTitleUnder()).toBe(true);
172
+ });
173
+
174
+ it('says whether content continues under the bottom bar', async () => {
175
+ const { state, scrollView, scrollTo, layOut } = await renderScroll();
176
+ // Before the scroll view is laid out, it can't tell.
177
+ scrollTo(0);
178
+ expect(state.scrolledUnderBottom()).toBe(false);
179
+
180
+ await layOut(800);
181
+ expect(state.scrolledUnderBottom()).toBe(true);
182
+ scrollTo(1200);
183
+ expect(state.scrolledUnderBottom()).toBe(false);
184
+
185
+ // iOS and Android say when the content's height changes.
186
+ fireLynxEvent(scrollView(), 'contentsizechanged', {
187
+ detail: { scrollTop: 1200, scrollHeight: 2400 },
188
+ });
189
+ expect(state.scrolledUnderBottom()).toBe(true);
190
+ });
191
+
192
+ it('fades content out under glass bars, from their measured room', async () => {
193
+ const { state, scrollView, update, layOut } = await renderScroll();
194
+ await update(() => {
195
+ state.topBar.set(topBar());
196
+ state.bottomBar.set(bottomBar());
197
+ });
198
+ // Not laid out yet: no edge.
199
+ expect(scrollView().style.getPropertyValue('mask-image')).toBe('');
200
+
201
+ // 1000px tall, with 100px for the top bar and 50px for the bottom one.
202
+ await layOut(1000, 100, 50);
203
+ expect(scrollView().style.getPropertyValue('mask-image')).toContain(
204
+ 'linear-gradient(',
205
+ );
206
+ expect(maskStops(scrollView())).toEqual([
207
+ [0, '0.00%'],
208
+ [0, '7.00%'],
209
+ [0.12, '9.00%'],
210
+ [1, '10.00%'],
211
+ [1, '95.00%'],
212
+ [0.12, '95.50%'],
213
+ [0, '96.50%'],
214
+ [0, '100.00%'],
215
+ ]);
216
+
217
+ // A top bar alone fades the top only, and a bottom bar alone the bottom.
218
+ await layOut(1000, 100, 0);
219
+ expect(maskStops(scrollView()).slice(-2)).toEqual([
220
+ [1, '10.00%'],
221
+ [1, '100.00%'],
222
+ ]);
223
+ await layOut(1000, 0, 50);
224
+ expect(maskStops(scrollView()).slice(0, 2)).toEqual([
225
+ [1, '0.00%'],
226
+ [1, '95.00%'],
227
+ ]);
228
+ });
229
+
230
+ it('fades with lengths in the web preview, whose scroll view reports no layout', async () => {
231
+ const { state, scrollView, update, topSpacer, bottomSpacer } =
232
+ await renderScroll(WEB);
233
+ await update(() => {
234
+ state.topBar.set(topBar());
235
+ state.bottomBar.set(bottomBar());
236
+ });
237
+ fireEvent.layoutchange(topSpacer(), {
238
+ detail: { width: 402, height: 100 },
239
+ });
240
+ await waitForUpdate();
241
+ expect(maskStops(scrollView())).toEqual([
242
+ [0, '0px'],
243
+ [0, '70px'],
244
+ [0.12, '90px'],
245
+ [1, '100px'],
246
+ [1, 'calc(100% - 0px)'],
247
+ ]);
248
+
249
+ fireEvent.layoutchange(bottomSpacer(), {
250
+ detail: { width: 402, height: 50 },
251
+ });
252
+ await waitForUpdate();
253
+ expect(maskStops(scrollView()).slice(-4)).toEqual([
254
+ [1, 'calc(100% - 50px)'],
255
+ [0.12, 'calc(100% - 45px)'],
256
+ [0, 'calc(100% - 35px)'],
257
+ [0, 'calc(100% - 0px)'],
258
+ ]);
259
+ });
260
+
261
+ it('leaves the edge to Android’s glass bars, as its scroll view takes no mask', async () => {
262
+ vi.stubGlobal('SystemInfo', ANDROID);
263
+ const result = await render(ScrollHost, {
264
+ providers: [provideGlass({ android: 'glass' })],
265
+ });
266
+ const state = result.componentRef.injector.get(UiBarState);
267
+ state.topBar.set(topBar());
268
+ await waitForUpdate();
269
+ const scrollView = result.container.querySelector<HTMLElement>('#first')!;
270
+ fireEvent.layoutchange(scrollView, {
271
+ detail: { width: 402, height: 1000 },
272
+ });
273
+ fireEvent.layoutchange(scrollView.firstElementChild!, {
274
+ detail: { width: 402, height: 100 },
275
+ });
276
+ await waitForUpdate();
277
+ expect(scrollView.style.getPropertyValue('mask-image')).toBe('');
278
+ });
279
+
280
+ it('leaves the edge to the bars before iOS 26, which mark it with their background', async () => {
281
+ const { state, scrollView, update, layOut } = await renderScroll(IOS_18);
282
+ await update(() => state.topBar.set(topBar()));
283
+ await layOut(1000, 100);
284
+ expect(scrollView().style.getPropertyValue('mask-image')).toBe('');
285
+ });
286
+
287
+ it('lets the last scroll view tell the bars, and resets them when it goes', async () => {
288
+ const { host, state, update, scrollTo } = await renderScroll();
289
+ scrollTo(200);
290
+ expect(state.scrolledUnderTop()).toBe(true);
291
+
292
+ // A second scroll view, a sheet's say, takes over.
293
+ await update(() => host.second.set(true));
294
+ scrollTo(0, 'second');
295
+ expect(state.scrolledUnderTop()).toBe(false);
296
+ scrollTo(200);
297
+ expect(state.scrolledUnderTop()).toBe(false);
298
+
299
+ // The first going leaves the second's state alone.
300
+ scrollTo(300, 'second');
301
+ await update(() => host.first.set(false));
302
+ expect(state.scrolledUnderTop()).toBe(true);
303
+ expect(state.content()).not.toBeNull();
304
+
305
+ // The last going resets it, for the next screen.
306
+ await update(() => host.second.set(false));
307
+ expect(state.content()).toBeNull();
308
+ expect(state.scrolledUnderTop()).toBe(false);
309
+ });
310
+ });