@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.
- package/dist/commands/add.test.js +2 -0
- package/dist/commands/add.test.js.map +1 -1
- package/dist/commands/doctor.js +78 -21
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +167 -26
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/init.test.js +4 -7
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/update.d.ts +3 -1
- package/dist/commands/update.js +78 -15
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/update.test.js +257 -2
- package/dist/commands/update.test.js.map +1 -1
- package/dist/lockfile.d.ts +10 -0
- package/dist/lockfile.js +20 -1
- package/dist/lockfile.js.map +1 -1
- package/dist/lockfile.test.js +43 -3
- package/dist/lockfile.test.js.map +1 -1
- package/dist/registry.js +73 -11
- package/dist/registry.js.map +1 -1
- package/dist/registry.test.js +16 -3
- package/dist/registry.test.js.map +1 -1
- package/dist/test-utils.d.ts +24 -2
- package/dist/test-utils.js +41 -2
- package/dist/test-utils.js.map +1 -1
- package/dist/ui/boolean-inputs.test.ts +76 -0
- package/dist/ui/components/action-sheet/action-sheet.test.ts +370 -1
- package/dist/ui/components/action-sheet/action-sheet.ts +306 -28
- package/dist/ui/components/action-sheet/index.ts +1 -0
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +552 -8
- package/dist/ui/components/alert-dialog/alert-dialog.ts +644 -36
- package/dist/ui/components/alert-dialog/index.ts +5 -0
- package/dist/ui/components/area-chart/area-chart.ts +3 -2
- package/dist/ui/components/badge/badge.ts +2 -1
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +968 -3
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1021 -95
- package/dist/ui/components/bottom-sheet/index.ts +7 -1
- package/dist/ui/components/button/button.test.ts +36 -1
- package/dist/ui/components/button/button.ts +17 -7
- package/dist/ui/components/button-group/button-group.test.ts +53 -9
- package/dist/ui/components/button-group/button-group.ts +67 -21
- package/dist/ui/components/card/card.test.ts +16 -0
- package/dist/ui/components/card/card.ts +2 -1
- package/dist/ui/components/chart/chart-presets.ts +6 -6
- package/dist/ui/components/chart/chart.test.ts +14 -0
- package/dist/ui/components/chart/chart.ts +10 -3
- package/dist/ui/components/checkbox/checkbox.ts +2 -1
- package/dist/ui/components/collapsible/collapsible.ts +2 -1
- package/dist/ui/components/dialog/dialog.ts +6 -6
- package/dist/ui/components/dock/dock-scroll.ts +9 -0
- package/dist/ui/components/dock/dock-search.test.ts +594 -0
- package/dist/ui/components/dock/dock.test.ts +4 -1
- package/dist/ui/components/dock/dock.ts +529 -51
- package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
- package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
- package/dist/ui/components/dynamic-color/index.ts +5 -0
- package/dist/ui/components/glass/glass.test.ts +39 -1
- package/dist/ui/components/glass/glass.ts +87 -17
- package/dist/ui/components/glass/index.ts +1 -0
- package/dist/ui/components/icon/icon-theme.test.ts +28 -0
- package/dist/ui/components/icon/icon-theme.ts +38 -7
- package/dist/ui/components/input/input.ts +2 -1
- package/dist/ui/components/label/label.ts +8 -2
- package/dist/ui/components/line-chart/line-chart.ts +3 -2
- package/dist/ui/components/list/list.ts +5 -4
- package/dist/ui/components/menu/menu-popup.test.ts +18 -14
- package/dist/ui/components/menu/menu-popup.ts +24 -3
- package/dist/ui/components/menu/menu.test.ts +99 -5
- package/dist/ui/components/menu/menu.ts +74 -34
- package/dist/ui/components/nav-drawer/nav-drawer.ts +3 -2
- package/dist/ui/components/navigation-bar/index.ts +1 -0
- package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
- package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
- package/dist/ui/components/popover/index.ts +9 -0
- package/dist/ui/components/popover/popover-panel.test.ts +735 -0
- package/dist/ui/components/popover/popover-panel.ts +603 -0
- package/dist/ui/components/popover/popover.test.ts +286 -0
- package/dist/ui/components/popover/popover.ts +207 -0
- package/dist/ui/components/progress/progress.ts +2 -1
- package/dist/ui/components/radio-group/radio-group.ts +2 -1
- package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
- package/dist/ui/components/search-field/index.ts +15 -0
- package/dist/ui/components/search-field/keyboard.test.ts +147 -0
- package/dist/ui/components/search-field/keyboard.ts +123 -0
- package/dist/ui/components/search-field/search-field.test.ts +549 -0
- package/dist/ui/components/search-field/search-field.ts +628 -0
- package/dist/ui/components/segmented-control/index.ts +8 -0
- package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
- package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
- package/dist/ui/components/select/select.ts +2 -1
- package/dist/ui/components/sheet/sheet.test.ts +90 -3
- package/dist/ui/components/sheet/sheet.ts +59 -4
- package/dist/ui/components/slider/index.ts +1 -0
- package/dist/ui/components/slider/slider.test.ts +695 -0
- package/dist/ui/components/slider/slider.ts +901 -0
- package/dist/ui/components/switch/index.ts +1 -1
- package/dist/ui/components/switch/switch.test.ts +464 -48
- package/dist/ui/components/switch/switch.ts +613 -43
- package/dist/ui/components/tabs/index.ts +7 -1
- package/dist/ui/components/tabs/tabs.test.ts +278 -2
- package/dist/ui/components/tabs/tabs.ts +233 -30
- package/dist/ui/components/textarea/textarea.ts +2 -1
- package/dist/ui/components/toggle/index.ts +1 -0
- package/dist/ui/components/toggle/toggle.test.ts +310 -4
- package/dist/ui/components/toggle/toggle.ts +241 -26
- package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
- package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
- package/dist/ui/components/toolbar/index.ts +12 -0
- package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
- package/dist/ui/components/toolbar/toolbar.ts +535 -0
- package/dist/utils/analyze.d.ts +10 -5
- package/dist/utils/analyze.js +3 -2
- package/dist/utils/analyze.js.map +1 -1
- package/dist/utils/installed.d.ts +13 -1
- package/dist/utils/installed.js +17 -0
- package/dist/utils/installed.js.map +1 -1
- package/dist/utils/installed.test.js +39 -1
- package/dist/utils/installed.test.js.map +1 -1
- package/dist/utils/tailwind.d.ts +25 -5
- package/dist/utils/tailwind.js +53 -14
- package/dist/utils/tailwind.js.map +1 -1
- package/dist/utils/tailwind.test.js +85 -22
- package/dist/utils/tailwind.test.js.map +1 -1
- 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
|
|
83
|
-
|
|
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="
|
|
86
|
-
[style.opacity]="
|
|
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.
|
|
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
|
-
/**
|
|
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,
|
|
406
|
-
*
|
|
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
|
-
|
|
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
|
+
});
|