@blotch/dolan 22.2.4 → 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 (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
@@ -7,7 +7,6 @@ import {
7
7
  ViewEncapsulation,
8
8
  booleanAttribute,
9
9
  computed,
10
- effect,
11
10
  inject,
12
11
  input,
13
12
  model,
@@ -18,19 +17,12 @@ import {
18
17
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
19
18
 
20
19
  import { cn } from '../../utils/cn';
20
+ import { UiGlass } from '../glass/glass';
21
21
  import { injectGlassDesign } from '../glass/glass-design';
22
22
  import { UiIcon } from '../icon/icon';
23
23
  import { type IconName } from '../icon/icons';
24
24
  import { type MenuElement, UiMenuPopup } from './menu-popup';
25
25
 
26
- /**
27
- * AngularLynx's element, which can add an accessibility trait to the app's
28
- * own (see LynxElement.setAccessibilityTrait).
29
- */
30
- type AccessibleElement = {
31
- setAccessibilityTrait(trait: string, on: boolean): void;
32
- };
33
-
34
26
  /**
35
27
  * What a menu's items need from the menu they're in: `ui-menu` or
36
28
  * `ui-context-menu`.
@@ -46,6 +38,14 @@ export type UiMenuHost = {
46
38
  */
47
39
  export const UI_MENU = new InjectionToken<UiMenuHost>('UI_MENU');
48
40
 
41
+ /**
42
+ * `booleanAttribute` for an input that can also be unset: a bare attribute
43
+ * is true, as it is for the other boolean inputs, and `undefined` stays
44
+ * `undefined` rather than becoming false.
45
+ */
46
+ const optionalBooleanAttribute = (value: unknown): boolean | undefined =>
47
+ value === undefined ? undefined : booleanAttribute(value);
48
+
49
49
  /**
50
50
  * A menu item: a label, with an optional icon, that does something and
51
51
  * closes the menu.
@@ -65,12 +65,15 @@ export const UI_MENU = new InjectionToken<UiMenuHost>('UI_MENU');
65
65
  imports: [LYNX_ELEMENTS, UiIcon],
66
66
  encapsulation: ViewEncapsulation.None,
67
67
  template: `
68
+ <!-- A checked item is selected to a screen reader, which iOS reads.
69
+ lynxAccessibilityTraits adds the trait to button on iOS, and leaves
70
+ Android, which reads one trait, with button. -->
68
71
  <view
69
- #row
70
72
  [class]="rowClass()"
71
73
  [accessibility-element]="true"
72
74
  accessibility-traits="button"
73
75
  [accessibility-label]="spokenLabel()"
76
+ [lynxAccessibilityTraits]="{ selected: checked() === true }"
74
77
  (bindtouchstart)="hold(true)"
75
78
  (bindtouchend)="hold(false)"
76
79
  (bindtouchcancel)="hold(false)"
@@ -79,11 +82,11 @@ export const UI_MENU = new InjectionToken<UiMenuHost>('UI_MENU');
79
82
  (bindmouseleave)="hold(false)"
80
83
  (bindtap)="choose()"
81
84
  >
82
- <!-- The pressed highlight: the glass's own foreground at a tenth, so it
83
- suits light and dark glass, and is Material's state layer. -->
85
+ <!-- The pressed highlight, in the glass's own foreground, so it suits
86
+ light and dark glass (see highlightClass). -->
84
87
  <view
85
- class="h-full w-full bg-glass-foreground absolute left-0 top-0"
86
- [style.opacity]="held() ? 0.1 : 0"
88
+ [class]="highlightClass()"
89
+ [style.opacity]="highlightOpacity()"
87
90
  ></view>
88
91
  @if (checked() !== undefined) {
89
92
  <!-- A checkable item keeps the column for the checkmark when it's
@@ -120,7 +123,9 @@ export class UiMenuItem {
120
123
  * set it, true or false, on each item of a group of options, so their
121
124
  * labels line up.
122
125
  */
123
- readonly checked = input<boolean | undefined>(undefined);
126
+ readonly checked = input<boolean | undefined, unknown>(undefined, {
127
+ transform: optionalBooleanAttribute,
128
+ });
124
129
  /** Shows the item without letting it be chosen. */
125
130
  readonly disabled = input(false, { transform: booleanAttribute });
126
131
  /**
@@ -135,9 +140,6 @@ export class UiMenuItem {
135
140
  /** Whether a finger (or the mouse) is down on the item. */
136
141
  protected readonly held = signal(false);
137
142
 
138
- // Required: always in the template.
139
- readonly row = viewChild.required<ElementRef<AccessibleElement>>('row');
140
-
141
143
  readonly #material = computed(() => this.#design() === 'material');
142
144
  readonly #destructive = computed(() => this.variant() === 'destructive');
143
145
 
@@ -153,6 +155,25 @@ export class UiMenuItem {
153
155
  this.#iconLeads() ? undefined : this.icon(),
154
156
  );
155
157
 
158
+ /**
159
+ * iOS 26's menus highlight a pressed row with a capsule inset 10pt from
160
+ * the menu's sides, 8% darker than the glass, measured on iOS 27's Files
161
+ * menu. Before, Apple's menus filled the row edge to edge, and so does
162
+ * Material's state layer, at a tenth.
163
+ */
164
+ protected readonly highlightClass = computed(() =>
165
+ cn(
166
+ 'bg-glass-foreground absolute bottom-0 top-0',
167
+ this.#design() === 'glass'
168
+ ? 'rounded-full left-[10px] right-[10px]'
169
+ : 'left-0 right-0',
170
+ ),
171
+ );
172
+ protected readonly highlightOpacity = computed(() => {
173
+ if (!this.held()) return 0;
174
+ return this.#design() === 'glass' ? 0.08 : 0.1;
175
+ });
176
+
156
177
  protected readonly rowClass = computed(() =>
157
178
  cn(
158
179
  'flex-row items-center gap-3 flex relative',
@@ -164,17 +185,6 @@ export class UiMenuItem {
164
185
  ),
165
186
  );
166
187
 
167
- /**
168
- * A checked item is `selected` to a screen reader, which iOS reads.
169
- * Through the runtime, which adds the trait to `button` on iOS and leaves
170
- * Android, which reads one trait, with `button`.
171
- */
172
- protected readonly selectedTrait = effect(() => {
173
- this.row().nativeElement.setAccessibilityTrait(
174
- 'selected',
175
- this.checked() === true,
176
- );
177
- });
178
188
  protected readonly labelClass = computed(() =>
179
189
  cn(
180
190
  'flex-1',
@@ -271,6 +281,9 @@ export class UiMenuSeparator {
271
281
  });
272
282
  }
273
283
 
284
+ /** How many pull-down menus there have been, for their glass ids. */
285
+ let menuCount = 0;
286
+
274
287
  /**
275
288
  * The `ui-menu` a trigger is in, for `UI_MENU`. A named function rather than
276
289
  * an inline factory: the component's metadata keeps its own copy of an
@@ -314,6 +327,7 @@ const pullDownMenu = (): UiMenuHost => inject(UiMenu);
314
327
  #popup
315
328
  [open]="open()"
316
329
  [anchor]="anchor()"
330
+ [glassId]="glassId"
317
331
  (dismissed)="close()"
318
332
  (closed)="closed.emit()"
319
333
  >
@@ -330,6 +344,13 @@ export class UiMenu implements UiMenuHost {
330
344
  readonly #host = inject<ElementRef<MenuElement>>(ElementRef);
331
345
  readonly #trigger = signal<MenuElement | null>(null);
332
346
 
347
+ /**
348
+ * The id the menu's glass and a glass trigger share (see UiGlass's
349
+ * `glassId`), unique to the menu, so the menu takes over the trigger's
350
+ * glass as it grows out of it.
351
+ */
352
+ readonly glassId = `ui-menu-${++menuCount}`;
353
+
333
354
  // Required: always in the template.
334
355
  readonly popup = viewChild.required<UiMenuPopup>('popup');
335
356
 
@@ -345,6 +366,13 @@ export class UiMenu implements UiMenuHost {
345
366
  * Whether the menu covers its trigger now: a glass or frosted menu grows
346
367
  * out of it and shrinks back into it, so the trigger hides meanwhile, as
347
368
  * on iOS. A Material menu opens beside it.
369
+ *
370
+ * It hides with `visibility`, in the same update that shows the menu's
371
+ * glass at the trigger's place, and shows again in the one that hides the
372
+ * glass. On iOS 26 and later, the shell pairs a glass trigger with the menu
373
+ * across that change (see UiGlass's `glassId`): the menu takes over the
374
+ * trigger's corners, and the trigger's content fades out in it, and back in
375
+ * as the menu closes.
348
376
  */
349
377
  readonly coversTrigger = computed(() => {
350
378
  const popup = this.popup();
@@ -387,29 +415,41 @@ export class UiMenu implements UiMenuHost {
387
415
  selector: '[uiMenuTrigger]',
388
416
  host: {
389
417
  '(click)': 'menu.toggle()',
390
- '[style.opacity]': 'menu.coversTrigger() ? 0 : null',
418
+ '[style.visibility]': 'menu.coversTrigger() ? "hidden" : null',
391
419
  },
392
420
  })
393
421
  export class UiMenuTrigger {
394
422
  protected readonly menu = inject(UiMenu);
395
423
 
396
- /** Points the menu at this element until the directive is destroyed. */
424
+ /**
425
+ * Points the menu at this element until the directive is destroyed. A
426
+ * trigger that's glass itself (`<blur-view ui-glass uiMenuTrigger>`)
427
+ * shares the menu's glass id meanwhile, so the menu takes its glass over
428
+ * (see UiMenu's coversTrigger).
429
+ */
397
430
  protected readonly unregister = registerTrigger(
398
431
  this.menu,
399
432
  inject<ElementRef<MenuElement>>(ElementRef).nativeElement,
433
+ inject(UiGlass, { self: true, optional: true }),
400
434
  inject(DestroyRef),
401
435
  );
402
436
  }
403
437
 
404
438
  /**
405
- * Makes `trigger` the element `menu` grows out of, until `destroyRef` is
406
- * destroyed. Returns what takes the registration back early.
439
+ * Makes `trigger` the element `menu` grows out of, and pairs its `glass`, if
440
+ * it's glass, with the menu's, until `destroyRef` is destroyed. Returns what
441
+ * takes the registration back early.
407
442
  */
408
443
  const registerTrigger = (
409
444
  menu: UiMenu,
410
445
  trigger: MenuElement,
446
+ glass: UiGlass | null,
411
447
  destroyRef: DestroyRef,
412
448
  ): (() => void) => {
413
449
  menu.setTrigger(trigger);
414
- return destroyRef.onDestroy(() => menu.setTrigger(null));
450
+ glass?.pair(menu.glassId);
451
+ return destroyRef.onDestroy(() => {
452
+ menu.setTrigger(null);
453
+ glass?.pair(null);
454
+ });
415
455
  };
@@ -3,6 +3,7 @@ import {
3
3
  type OnDestroy,
4
4
  Component,
5
5
  ViewEncapsulation,
6
+ booleanAttribute,
6
7
  computed,
7
8
  contentChildren,
8
9
  effect,
@@ -407,8 +408,8 @@ export class UiNavDrawerItem {
407
408
  readonly #drawer = inject(UiNavDrawer);
408
409
 
409
410
  readonly variant = input<'default' | 'destructive'>('default');
410
- readonly active = input(false);
411
- readonly autoClose = input(true);
411
+ readonly active = input(false, { transform: booleanAttribute });
412
+ readonly autoClose = input(true, { transform: booleanAttribute });
412
413
  readonly userClass = input<string>('', { alias: 'class' });
413
414
 
414
415
  readonly pressed = output<void>();
@@ -0,0 +1 @@
1
+ export { UiNavigationBar } from './navigation-bar';
@@ -0,0 +1,425 @@
1
+ import { Location, LocationStrategy } from '@angular/common';
2
+ import { MockLocationStrategy } from '@angular/common/testing';
3
+ import { Component, type Provider, signal, viewChild } from '@angular/core';
4
+ import { Router, RouterOutlet, type Routes } from '@angular/router';
5
+ import {
6
+ fireEvent,
7
+ render,
8
+ waitForUpdate,
9
+ } from '@blotch/angular-lynx-testing-library';
10
+ import {
11
+ LYNX_ELEMENTS,
12
+ LynxAccessibilitySettings,
13
+ LynxSystemInfo,
14
+ provideRouter,
15
+ } from '@blotch/angular-lynx';
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
17
+ import { provideGlass } from '../glass/glass-design';
18
+ import { UiBarScroll, UiBarState } from '../toolbar/bar-scroll';
19
+ import { UiToolbarItem } from '../toolbar/toolbar';
20
+ import { UiNavigationBar } from './navigation-bar';
21
+
22
+ @Component({
23
+ selector: 'notes-page',
24
+ imports: [LYNX_ELEMENTS],
25
+ template: `<text>Notes page</text>`,
26
+ })
27
+ class NotesPage {}
28
+
29
+ @Component({
30
+ selector: 'note-page',
31
+ imports: [LYNX_ELEMENTS],
32
+ template: `<text>Note page</text>`,
33
+ })
34
+ class NotePage {}
35
+
36
+ const routes: Routes = [
37
+ { path: '', component: NotesPage },
38
+ { path: 'note', component: NotePage },
39
+ ];
40
+
41
+ @Component({
42
+ imports: [
43
+ LYNX_ELEMENTS,
44
+ RouterOutlet,
45
+ UiBarScroll,
46
+ UiNavigationBar,
47
+ UiToolbarItem,
48
+ ],
49
+ template: `
50
+ @if (scroll()) {
51
+ <scroll-view ui-bar-scroll id="scroll" scroll-orientation="vertical">
52
+ <view style="height: 2000px"></view>
53
+ </scroll-view>
54
+ }
55
+ <ui-navigation-bar
56
+ id="bar"
57
+ [title]="title()"
58
+ [largeTitle]="largeTitle()"
59
+ [titleCentered]="titleCentered()"
60
+ [backLabel]="backLabel()"
61
+ [backRoute]="backRoute()"
62
+ [backButtonHidden]="backButtonHidden()"
63
+ [class]="barClass()"
64
+ >
65
+ <ui-toolbar-item leading label="Edit" />
66
+ <ui-toolbar-item icon="plus" label="New note" />
67
+ </ui-navigation-bar>
68
+ <router-outlet />
69
+ `,
70
+ })
71
+ class BarHost {
72
+ readonly title = signal('Notes');
73
+ readonly largeTitle = signal(false);
74
+ readonly titleCentered = signal(false);
75
+ readonly backLabel = signal('Back');
76
+ readonly backRoute = signal<string | undefined>(undefined);
77
+ readonly backButtonHidden = signal(false);
78
+ readonly barClass = signal('');
79
+ readonly scroll = signal(false);
80
+ readonly bar = viewChild.required(UiNavigationBar);
81
+ }
82
+
83
+ /** Accessibility settings a test can change, as the host app would. */
84
+ const settings = {
85
+ reduceMotion: signal(false),
86
+ reduceTransparency: signal(false),
87
+ increaseContrast: signal(false),
88
+ };
89
+
90
+ const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
91
+ const IOS_18 = { platform: 'iOS', osVersion: '18.5' };
92
+ const ANDROID = { platform: 'Android', osVersion: '15' };
93
+
94
+ /**
95
+ * Runs change detection until `done` holds: a navigation takes a few ticks.
96
+ */
97
+ const until = async (done: () => boolean): Promise<void> => {
98
+ for (let i = 0; i < 20 && !done(); i++) {
99
+ await waitForUpdate();
100
+ }
101
+ expect(done()).toBe(true);
102
+ };
103
+
104
+ /**
105
+ * Renders the bar on a platform, with the router unless `router` is false.
106
+ * The platform is provided rather than stubbed as a global: the router's
107
+ * setup switches the testing library's threads, which puts back its own
108
+ * SystemInfo before the bar reads it.
109
+ */
110
+ const renderBar = async (
111
+ platform: { platform: string; osVersion: string },
112
+ providers: Provider[] = [],
113
+ ) => {
114
+ const result = await render(BarHost, {
115
+ providers: [
116
+ provideRouter(routes),
117
+ { provide: LynxSystemInfo, useValue: platform },
118
+ { provide: LynxAccessibilitySettings, useValue: settings },
119
+ ...providers,
120
+ ],
121
+ });
122
+ const host = result.componentRef.instance as BarHost;
123
+ const injector = result.componentRef.injector;
124
+ const state = injector.get(UiBarState);
125
+ const { container } = result;
126
+ const bar = () => container.querySelector<HTMLElement>('#bar')!;
127
+ const row = () =>
128
+ Array.from(bar().children).find((child) =>
129
+ child.className.includes('justify-between'),
130
+ ) as HTMLElement;
131
+ /**
132
+ * The bar's two ends: the views holding Edit and New note.
133
+ */
134
+ const leading = () =>
135
+ result.getByRole('button', { name: 'Edit' }).parentElement!;
136
+ const trailing = () =>
137
+ result.getByRole('button', { name: 'New note' }).parentElement!;
138
+ const title = () => result.queryByRole('heading', { name: host.title() });
139
+ /**
140
+ * The view holding the bar's title, not the copy that measures it.
141
+ */
142
+ const titleBox = () =>
143
+ Array.from(row().children).find(
144
+ (child) =>
145
+ child.className.includes('flex-1') ||
146
+ child.className.includes('bottom-0'),
147
+ ) as HTMLElement;
148
+ const back = () => result.queryByRole('button', { name: host.backLabel() });
149
+ const page = () =>
150
+ Array.from(container.querySelectorAll('text'))
151
+ .map((text) => text.textContent ?? '')
152
+ .find((text) => text.endsWith(' page'));
153
+ const update = async (change: () => void) => {
154
+ change();
155
+ await waitForUpdate();
156
+ };
157
+ await until(() => page() === 'Notes page');
158
+ return {
159
+ ...result,
160
+ host,
161
+ injector,
162
+ state,
163
+ bar,
164
+ row,
165
+ leading,
166
+ trailing,
167
+ title,
168
+ titleBox,
169
+ back,
170
+ page,
171
+ update,
172
+ };
173
+ };
174
+
175
+ const style = (element: HTMLElement, name: string) =>
176
+ element.style.getPropertyValue(name);
177
+
178
+ // The components' selectors aren't Lynx elements, so the renderer falls back
179
+ // to a <view> for each and warns.
180
+ beforeEach(() => {
181
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
182
+ });
183
+
184
+ afterEach(() => {
185
+ vi.restoreAllMocks();
186
+ settings.reduceMotion.set(false);
187
+ });
188
+
189
+ describe('UiNavigationBar on iOS 26 and later', () => {
190
+ it('floats over the top of the content below the status bar, with no background', async () => {
191
+ const { bar, state, row } = await renderBar(IOS_26);
192
+ expect(bar().classList).toContain('absolute');
193
+ expect(bar().classList).toContain('top-0');
194
+ expect(bar().classList).toContain('pt-safe');
195
+ expect(bar().classList).not.toContain('bg-background');
196
+ expect(bar().querySelector('[shape="rounded"]')).toBeNull();
197
+ expect(row().className).toContain('h-[44px]');
198
+ expect(style(row(), 'padding-left')).toBe('16px');
199
+ // It tells the scroll view under it how much room it takes.
200
+ expect(state.topBar()?.height()).toBe(44);
201
+ expect(state.topBar()?.largeTitle()).toBeNull();
202
+ });
203
+
204
+ it('shows the title as a heading, with the leading items after the Back button and the rest at the end', async () => {
205
+ const { title, leading, trailing, row, host, update } =
206
+ await renderBar(IOS_26);
207
+ expect(title()?.className).toContain('font-semibold');
208
+ expect(title()?.className).toContain('text-center');
209
+ const ends = Array.from(row().children);
210
+ expect(ends.indexOf(leading())).toBeLessThan(ends.indexOf(trailing()));
211
+
212
+ await update(() => host.title.set('Inbox'));
213
+ expect(title()?.textContent).toBe('Inbox');
214
+ });
215
+
216
+ it('centers the title on the screen when it fits, or between the ends when it doesn’t', async () => {
217
+ const { row, leading, trailing, titleBox, container } =
218
+ await renderBar(IOS_26);
219
+ const copy = () =>
220
+ container.querySelector<HTMLElement>(
221
+ '[accessibility-elements-hidden="true"] text',
222
+ )!;
223
+ const lay = async (element: Element, width: number) => {
224
+ fireEvent.layoutchange(element, { detail: { width, height: 44 } });
225
+ await waitForUpdate();
226
+ };
227
+ await lay(row(), 402);
228
+ await lay(leading(), 44);
229
+ await lay(trailing(), 100);
230
+ await lay(copy(), 80);
231
+ // Clear of the wider end on both sides: 16 + 100 + 8.
232
+ expect(style(titleBox(), 'left')).toBe('124px');
233
+ expect(style(titleBox(), 'right')).toBe('124px');
234
+
235
+ // Too wide for that: between the ends.
236
+ await lay(copy(), 200);
237
+ expect(style(titleBox(), 'left')).toBe('68px');
238
+ expect(style(titleBox(), 'right')).toBe('124px');
239
+ });
240
+
241
+ it('shows a Back button while there’s a screen to go back to, and goes back through the router', async () => {
242
+ const { back, injector, page, host, update } = await renderBar(IOS_26);
243
+ expect(back()).toBeNull();
244
+
245
+ await injector.get(Router).navigateByUrl('/note');
246
+ await until(() => page() === 'Note page');
247
+ await update(() => host.backLabel.set('Notes'));
248
+ expect(back()).not.toBeNull();
249
+ expect(host.bar().canGoBack()).toBe(true);
250
+
251
+ fireEvent.tap(back()!);
252
+ await until(() => page() === 'Notes page');
253
+ expect(back()).toBeNull();
254
+ });
255
+
256
+ it('hides the Back button on a screen that asks', async () => {
257
+ const { back, injector, page, host, update } = await renderBar(IOS_26);
258
+ await injector.get(Router).navigateByUrl('/note');
259
+ await until(() => page() === 'Note page');
260
+ await update(() => host.backButtonHidden.set(true));
261
+ expect(back()).toBeNull();
262
+ });
263
+
264
+ it('goes to backRoute in place of the screen when there’s no history', async () => {
265
+ const { back, injector, page, host, update } = await renderBar(IOS_26);
266
+ await injector.get(Router).navigateByUrl('/note', { replaceUrl: true });
267
+ await until(() => page() === 'Note page');
268
+ expect(back()).toBeNull();
269
+
270
+ await update(() => host.backRoute.set('/'));
271
+ fireEvent.tap(back()!);
272
+ await until(() => page() === 'Notes page');
273
+ // It replaced the note: there's still nothing to go back to.
274
+ expect(host.bar().canGoBack()).toBe(false);
275
+ expect(injector.get(Location).path()).toBe('');
276
+ });
277
+
278
+ it('does nothing on goBack() with nowhere to go', async () => {
279
+ const { host, page } = await renderBar(IOS_26);
280
+ host.bar().goBack();
281
+ await waitForUpdate();
282
+ expect(page()).toBe('Notes page');
283
+ });
284
+
285
+ it('starts the title large at the top of the content, and shows it once it has scrolled under', async () => {
286
+ const { host, state, update, title, titleBox, getAllByRole } =
287
+ await renderBar(IOS_26);
288
+ await update(() => host.largeTitle.set(true));
289
+ // Without a scroll view there's no large title, so the bar keeps it.
290
+ expect(title()).not.toBeNull();
291
+ expect(state.topBar()?.largeTitle()).toBe('Notes');
292
+
293
+ await update(() => host.scroll.set(true));
294
+ // The scroll view draws the large title; the bar's hides.
295
+ expect(getAllByRole('heading', { name: 'Notes' })).toHaveLength(1);
296
+ expect(style(titleBox(), 'opacity')).toBe('0');
297
+ expect(style(titleBox(), 'transition')).toContain('opacity');
298
+
299
+ await update(() => state.largeTitleUnder.set(true));
300
+ expect(getAllByRole('heading', { name: 'Notes' })).toHaveLength(2);
301
+ expect(style(titleBox(), 'opacity')).toBe('1');
302
+ });
303
+
304
+ it('shows the title at once under Reduce Motion', async () => {
305
+ const { host, update, titleBox } = await renderBar(IOS_26);
306
+ await update(() => {
307
+ host.largeTitle.set(true);
308
+ host.scroll.set(true);
309
+ settings.reduceMotion.set(true);
310
+ });
311
+ expect(style(titleBox(), 'transition')).toBe('none');
312
+ });
313
+
314
+ it('takes extra classes', async () => {
315
+ const { host, update, bar } = await renderBar(IOS_26);
316
+ await update(() => host.barClass.set('z-10'));
317
+ expect(bar().classList).toContain('z-10');
318
+ });
319
+ });
320
+
321
+ describe('UiNavigationBar before iOS 26', () => {
322
+ it("is clear at the top of the content, and on the bars' chrome material with a hairline once content scrolls under it", async () => {
323
+ const { bar, state, update, row } = await renderBar(IOS_18);
324
+ const material = () => bar().querySelector('[shape="rounded"]');
325
+ expect(material()).toBeNull();
326
+ expect(style(row(), 'padding-left')).toBe('8px');
327
+
328
+ await update(() => state.scrolledUnderTop.set(true));
329
+ expect(material()?.getAttribute('blur-material')).toBe('chrome');
330
+ expect(material()?.getAttribute('blur-effect')).toBe('extra-light');
331
+ expect(bar().querySelector('.h-\\[0\\.5px\\]')).not.toBeNull();
332
+ });
333
+
334
+ it('shows the previous screen’s title beside the Back button’s chevron', async () => {
335
+ const { back, injector, page, host, update } = await renderBar(IOS_18);
336
+ await injector.get(Router).navigateByUrl('/note');
337
+ await until(() => page() === 'Note page');
338
+ await update(() => host.backLabel.set('Notes'));
339
+ expect(back()?.querySelector('text')?.textContent).toBe('Notes');
340
+ });
341
+ });
342
+
343
+ describe('UiNavigationBar on Android', () => {
344
+ it('is Material 3’s small top app bar, the title at the start', async () => {
345
+ const { bar, row, state, title, titleBox, leading, trailing, container } =
346
+ await renderBar(ANDROID);
347
+ expect(row().className).toContain('h-[64px]');
348
+ expect(state.topBar()?.height()).toBe(64);
349
+ expect(bar().classList).toContain('bg-background');
350
+ expect(title()?.className).toContain('text-[22px]');
351
+ expect(title()?.className).not.toContain('text-center');
352
+ expect(titleBox().className).toContain('flex-1');
353
+ expect(leading().className).toContain('min-w-[12px]');
354
+ expect(trailing().className).toContain('pr-[4px]');
355
+ // Laid out in the row, so it measures no copy.
356
+ expect(
357
+ container.querySelector('[accessibility-elements-hidden="true"]'),
358
+ ).toBeNull();
359
+ });
360
+
361
+ it('centers the title in the center-aligned app bar', async () => {
362
+ const { host, update, title, titleBox, leading } = await renderBar(ANDROID);
363
+ await update(() => host.titleCentered.set(true));
364
+ expect(title()?.className).toContain('text-center');
365
+ expect(titleBox().className).toContain('top-0');
366
+ expect(leading().className).not.toContain('min-w-[12px]');
367
+ });
368
+
369
+ it('turns to the surface container color as content scrolls under it', async () => {
370
+ const { bar, state, update } = await renderBar(ANDROID);
371
+ expect(style(bar(), 'transition')).toContain('background-color');
372
+ await update(() => state.scrolledUnderTop.set(true));
373
+ expect(bar().classList).toContain('bg-secondary');
374
+
375
+ await update(() => settings.reduceMotion.set(true));
376
+ expect(style(bar(), 'transition')).toBe('');
377
+ });
378
+ });
379
+
380
+ /**
381
+ * A bar in an app without the router.
382
+ */
383
+ @Component({
384
+ imports: [LYNX_ELEMENTS, UiNavigationBar],
385
+ template: `<ui-navigation-bar title="Settings" backRoute="/" />`,
386
+ })
387
+ class PlainBarHost {
388
+ readonly bar = viewChild.required(UiNavigationBar);
389
+ }
390
+
391
+ describe('UiNavigationBar with glass on Android', () => {
392
+ it('fades the content beneath it itself, as Android’s scroll view takes no mask', async () => {
393
+ const { bar } = await renderBar(ANDROID, [
394
+ provideGlass({ android: 'glass' }),
395
+ ]);
396
+ const fade = style(bar(), 'background-image');
397
+ expect(fade).toContain('linear-gradient(to bottom');
398
+ expect(fade).toContain('var(--background-rgb)');
399
+ // It isn't Material's bar.
400
+ expect(bar().classList).not.toContain('bg-background');
401
+ });
402
+ });
403
+
404
+ describe('UiNavigationBar without the Lynx router', () => {
405
+ it('has no history to go back through without the router', async () => {
406
+ const result = await render(PlainBarHost, {
407
+ providers: [
408
+ { provide: LynxSystemInfo, useValue: IOS_26 },
409
+ { provide: LynxAccessibilitySettings, useValue: settings },
410
+ ],
411
+ });
412
+ const host = result.componentRef.instance as PlainBarHost;
413
+ expect(host.bar().canGoBack()).toBe(false);
414
+ // backRoute still shows the button, for an app with routes of its own.
415
+ expect(result.getByRole('button', { name: 'Back' })).toBeTruthy();
416
+ });
417
+
418
+ it('has no Back button with a LocationStrategy of the app’s own', async () => {
419
+ const { back, host } = await renderBar(IOS_26, [
420
+ { provide: LocationStrategy, useClass: MockLocationStrategy },
421
+ ]);
422
+ expect(back()).toBeNull();
423
+ expect(host.bar().canGoBack()).toBe(false);
424
+ });
425
+ });