@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
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
render,
|
|
5
5
|
waitForUpdate,
|
|
6
6
|
} from '@blotch/angular-lynx-testing-library';
|
|
7
|
-
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
7
|
+
import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
|
|
8
8
|
import {
|
|
9
9
|
type MockInstance,
|
|
10
10
|
afterEach,
|
|
@@ -15,11 +15,18 @@ import {
|
|
|
15
15
|
vi,
|
|
16
16
|
} from 'vitest';
|
|
17
17
|
import { DURATION } from '../../utils/animate';
|
|
18
|
+
import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
|
|
18
19
|
import {
|
|
19
20
|
type LynxMainThread,
|
|
20
21
|
lynxMainThread,
|
|
21
22
|
} from '../../../test-utils/lynx-main-thread';
|
|
22
|
-
import {
|
|
23
|
+
import { UiGlass } from '../glass/glass';
|
|
24
|
+
import {
|
|
25
|
+
type BottomSheetVariant,
|
|
26
|
+
type SheetDetent,
|
|
27
|
+
UiBottomSheet,
|
|
28
|
+
UiSheetSource,
|
|
29
|
+
} from './bottom-sheet';
|
|
23
30
|
|
|
24
31
|
@Component({
|
|
25
32
|
imports: [UiBottomSheet, LYNX_ELEMENTS],
|
|
@@ -192,6 +199,16 @@ describe('UiBottomSheet', () => {
|
|
|
192
199
|
expect(host.closed).toHaveBeenCalledOnce();
|
|
193
200
|
});
|
|
194
201
|
|
|
202
|
+
it("closes on Android's back button", async () => {
|
|
203
|
+
const { host, overlay } = await renderSheet();
|
|
204
|
+
await openSheet(host);
|
|
205
|
+
// While an overlay shows, it takes the press and fires requestclose.
|
|
206
|
+
fireLynxEvent(overlay, 'requestclose');
|
|
207
|
+
expect(host.open()).toBe(false);
|
|
208
|
+
await waitForClose(host);
|
|
209
|
+
expect(host.closed).toHaveBeenCalledOnce();
|
|
210
|
+
});
|
|
211
|
+
|
|
195
212
|
it('stays open when the panel is tapped', async () => {
|
|
196
213
|
const { host, panel } = await renderSheet();
|
|
197
214
|
await openSheet(host);
|
|
@@ -254,7 +271,9 @@ describe('UiBottomSheet', () => {
|
|
|
254
271
|
expect.objectContaining({ duration: DURATION.normal }),
|
|
255
272
|
]);
|
|
256
273
|
expect(host.closed).toHaveBeenCalledOnce();
|
|
257
|
-
// Hidden, it
|
|
274
|
+
// Hidden, it waits below the screen, and opens again back in place.
|
|
275
|
+
expect(transformOf(panel)).toBe('translateY(110%)');
|
|
276
|
+
await openSheet(host);
|
|
258
277
|
expect(transformOf(panel)).toBe('translateY(0px)');
|
|
259
278
|
});
|
|
260
279
|
|
|
@@ -419,3 +438,949 @@ describe('UiBottomSheet', () => {
|
|
|
419
438
|
});
|
|
420
439
|
});
|
|
421
440
|
});
|
|
441
|
+
|
|
442
|
+
@Component({
|
|
443
|
+
imports: [UiBottomSheet, LYNX_ELEMENTS],
|
|
444
|
+
template: `
|
|
445
|
+
<ui-bottom-sheet
|
|
446
|
+
[(open)]="open"
|
|
447
|
+
[variant]="variant()"
|
|
448
|
+
[detents]="detents()"
|
|
449
|
+
[(detent)]="detent"
|
|
450
|
+
(closed)="closed()"
|
|
451
|
+
>
|
|
452
|
+
<text>Panel</text>
|
|
453
|
+
</ui-bottom-sheet>
|
|
454
|
+
`,
|
|
455
|
+
})
|
|
456
|
+
class DetentHost {
|
|
457
|
+
readonly open = signal(false);
|
|
458
|
+
readonly variant = signal<BottomSheetVariant>('glass');
|
|
459
|
+
readonly detents = signal<readonly SheetDetent[]>(['medium', 'large']);
|
|
460
|
+
readonly detent = signal<SheetDetent | undefined>(undefined);
|
|
461
|
+
readonly closed = vi.fn();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Accessibility settings a test can change, as the host app would. */
|
|
465
|
+
const settings = {
|
|
466
|
+
reduceMotion: signal(false),
|
|
467
|
+
reduceTransparency: signal(false),
|
|
468
|
+
increaseContrast: signal(false),
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
|
|
472
|
+
const IOS_18 = { platform: 'iOS', osVersion: '18.5' };
|
|
473
|
+
const ANDROID = { platform: 'Android', osVersion: '15' };
|
|
474
|
+
|
|
475
|
+
/** The phone the tests lay the sheet out on: an iPhone 18 Pro. */
|
|
476
|
+
const SCREEN = 874;
|
|
477
|
+
/** The screen below its status bar, which the large detent fills. */
|
|
478
|
+
const LARGE = 812;
|
|
479
|
+
/** Half the screen, the medium detent. */
|
|
480
|
+
const MEDIUM = SCREEN / 2;
|
|
481
|
+
/** The panel's height when it fits its content. */
|
|
482
|
+
const FIT = 300;
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Renders the host on a platform, as `SystemInfo` reports it, with the
|
|
486
|
+
* sheet's elements found the same way in every look.
|
|
487
|
+
*/
|
|
488
|
+
const renderDetents = async (
|
|
489
|
+
platform: { platform: string; osVersion: string } = IOS_26,
|
|
490
|
+
setUp: (host: DetentHost) => void = () => {},
|
|
491
|
+
) => {
|
|
492
|
+
vi.stubGlobal('SystemInfo', platform);
|
|
493
|
+
const result = await render(DetentHost, {
|
|
494
|
+
providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
|
|
495
|
+
});
|
|
496
|
+
const host = result.componentRef.instance as DetentHost;
|
|
497
|
+
setUp(host);
|
|
498
|
+
await waitForUpdate();
|
|
499
|
+
const backdrop = result.container.querySelector('overlay > view')!;
|
|
500
|
+
const panel = backdrop.children[1] as HTMLElement;
|
|
501
|
+
// The grabber is the panel's one capsule: the glass is a rounded shape.
|
|
502
|
+
const grabber = panel.querySelector('.rounded-full')!;
|
|
503
|
+
return {
|
|
504
|
+
...result,
|
|
505
|
+
host,
|
|
506
|
+
overlay: result.container.querySelector('overlay')!,
|
|
507
|
+
backdrop,
|
|
508
|
+
large: backdrop.children[0],
|
|
509
|
+
panel,
|
|
510
|
+
grabber,
|
|
511
|
+
handle: grabber.parentElement!,
|
|
512
|
+
glass: () => panel.querySelector('blur-view'),
|
|
513
|
+
opaque: () => panel.querySelector('.bg-background.absolute') as HTMLElement,
|
|
514
|
+
};
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
type Rendered = Awaited<ReturnType<typeof renderDetents>>;
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Lays the screen out as Lynx reports it after the first frame: the screen,
|
|
521
|
+
* the part below the status bar, and the panel at its content's height.
|
|
522
|
+
*/
|
|
523
|
+
const layOut = async (sheet: Rendered, panel = FIT) => {
|
|
524
|
+
fireEvent.layoutchange(sheet.backdrop, { detail: { height: SCREEN } });
|
|
525
|
+
fireEvent.layoutchange(sheet.large, { detail: { height: LARGE } });
|
|
526
|
+
fireEvent.layoutchange(sheet.panel, { detail: { height: panel } });
|
|
527
|
+
await waitForUpdate();
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const style = (element: Element, property: string) =>
|
|
531
|
+
(element as HTMLElement).style.getPropertyValue(property);
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Waits for a settle at a detent to finish.
|
|
535
|
+
*/
|
|
536
|
+
const settled = async () => {
|
|
537
|
+
await new Promise((resolve) => setTimeout(resolve, DURATION.slow + 40));
|
|
538
|
+
await waitForUpdate();
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Drags the handle from `from` to `to`, a screen y each, and lets go.
|
|
543
|
+
*/
|
|
544
|
+
const drag = async (sheet: Rendered, from: number, to: number) => {
|
|
545
|
+
fireEvent.touchstart(sheet.handle, touchAt(from));
|
|
546
|
+
fireEvent.touchmove(sheet.handle, touchAt(to));
|
|
547
|
+
await waitForUpdate();
|
|
548
|
+
fireEvent.touchend(sheet.handle, { eventType: 'catchEvent' });
|
|
549
|
+
await waitForUpdate();
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
const tapGrabber = async (sheet: Rendered) => {
|
|
553
|
+
fireEvent.tap(sheet.handle, { eventType: 'catchEvent' });
|
|
554
|
+
await waitForUpdate();
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
describe('UiBottomSheet detents and glass variant', () => {
|
|
558
|
+
let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
|
|
559
|
+
beforeEach(() => {
|
|
560
|
+
animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
|
|
561
|
+
});
|
|
562
|
+
afterEach(() => {
|
|
563
|
+
animate.mockRestore();
|
|
564
|
+
vi.unstubAllGlobals();
|
|
565
|
+
settings.reduceMotion.set(false);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
describe('on iOS 26 (Liquid Glass)', () => {
|
|
569
|
+
it('waits below the screen, clear, until it opens', async () => {
|
|
570
|
+
const sheet = await renderDetents();
|
|
571
|
+
expect(style(sheet.panel, 'transform')).toBe('translateY(110%)');
|
|
572
|
+
expect(sheet.backdrop.getAttribute('style')).toContain(
|
|
573
|
+
'rgba(0, 0, 0, 0)',
|
|
574
|
+
);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('opens at its first detent as inset glass, with concentric corners', async () => {
|
|
578
|
+
const sheet = await renderDetents();
|
|
579
|
+
await layOut(sheet);
|
|
580
|
+
await openSheet(sheet.host);
|
|
581
|
+
|
|
582
|
+
expect(sheet.host.detent()).toBe('medium');
|
|
583
|
+
// Inset 8px from the sides and bottom; its top halfway up the screen.
|
|
584
|
+
expect(style(sheet.panel, 'bottom')).toBe('8px');
|
|
585
|
+
expect(style(sheet.panel, 'left')).toBe('8px');
|
|
586
|
+
expect(style(sheet.panel, 'right')).toBe('8px');
|
|
587
|
+
expect(style(sheet.panel, 'height')).toBe(`${MEDIUM - 8}px`);
|
|
588
|
+
// The display's 62px corners, less the inset.
|
|
589
|
+
expect(sheet.glass()?.getAttribute('blur-effect')).toBe('glass');
|
|
590
|
+
expect(style(sheet.glass()!, 'border-radius')).toBe('54px');
|
|
591
|
+
expect(style(sheet.opaque(), 'opacity')).toBe('0');
|
|
592
|
+
expect(sheet.backdrop.getAttribute('style')).toContain(
|
|
593
|
+
'rgba(0, 0, 0, 0.2)',
|
|
594
|
+
);
|
|
595
|
+
expect(sheet.grabber.classList).toContain('w-[60px]');
|
|
596
|
+
// Glass slides up without fading.
|
|
597
|
+
expect(animate).toHaveBeenCalledWith(sheet.panel, [
|
|
598
|
+
START,
|
|
599
|
+
expect.any(String),
|
|
600
|
+
[{ transform: 'translateY(110%)' }, { transform: 'translateY(0%)' }],
|
|
601
|
+
expect.objectContaining({ duration: DURATION.slow }),
|
|
602
|
+
]);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('turns opaque and meets the edges at full height', async () => {
|
|
606
|
+
const sheet = await renderDetents();
|
|
607
|
+
await layOut(sheet);
|
|
608
|
+
await openSheet(sheet.host);
|
|
609
|
+
|
|
610
|
+
sheet.host.detent.set('large');
|
|
611
|
+
await waitForUpdate();
|
|
612
|
+
// The frame transitions there, and the opaque layer fades in.
|
|
613
|
+
expect(style(sheet.panel, 'transition')).toContain('height');
|
|
614
|
+
expect(style(sheet.panel, 'height')).toBe(`${LARGE}px`);
|
|
615
|
+
expect(style(sheet.panel, 'left')).toBe('0px');
|
|
616
|
+
expect(style(sheet.panel, 'bottom')).toBe('0px');
|
|
617
|
+
expect(style(sheet.opaque(), 'opacity')).toBe('1');
|
|
618
|
+
expect(style(sheet.opaque(), 'transition')).toContain('opacity');
|
|
619
|
+
expect(style(sheet.opaque(), 'border-bottom-left-radius')).toBe('55px');
|
|
620
|
+
|
|
621
|
+
await settled();
|
|
622
|
+
// At rest, its bottom corners meet the screen's edge square.
|
|
623
|
+
expect(style(sheet.panel, 'transition')).toBe('');
|
|
624
|
+
expect(style(sheet.opaque(), 'border-top-left-radius')).toBe('55px');
|
|
625
|
+
expect(style(sheet.opaque(), 'border-bottom-left-radius')).toBe('0px');
|
|
626
|
+
expect(style(sheet.opaque(), 'transition')).toBe('none');
|
|
627
|
+
expect(sheet.host.detent()).toBe('large');
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('follows a drag between detents, less inset and more opaque as it grows', async () => {
|
|
631
|
+
const sheet = await renderDetents();
|
|
632
|
+
await layOut(sheet);
|
|
633
|
+
await openSheet(sheet.host);
|
|
634
|
+
|
|
635
|
+
// Halfway from medium to large.
|
|
636
|
+
const halfway = (LARGE - MEDIUM) / 2;
|
|
637
|
+
fireEvent.touchstart(sheet.handle, touchAt(MEDIUM));
|
|
638
|
+
fireEvent.touchmove(sheet.handle, touchAt(MEDIUM - halfway));
|
|
639
|
+
await waitForUpdate();
|
|
640
|
+
expect(style(sheet.panel, 'left')).toBe('4px');
|
|
641
|
+
expect(style(sheet.panel, 'height')).toBe(`${MEDIUM + halfway - 4}px`);
|
|
642
|
+
expect(style(sheet.opaque(), 'opacity')).toBe('0.5');
|
|
643
|
+
expect(style(sheet.panel, 'transition')).toBe('');
|
|
644
|
+
// It goes no higher than the large detent.
|
|
645
|
+
fireEvent.touchmove(sheet.handle, touchAt(0));
|
|
646
|
+
await waitForUpdate();
|
|
647
|
+
expect(style(sheet.panel, 'height')).toBe(`${LARGE}px`);
|
|
648
|
+
|
|
649
|
+
// Let go past the threshold: on to the large detent.
|
|
650
|
+
fireEvent.touchend(sheet.handle, { eventType: 'catchEvent' });
|
|
651
|
+
await waitForUpdate();
|
|
652
|
+
expect(sheet.host.detent()).toBe('large');
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('settles back at its detent after a short drag', async () => {
|
|
656
|
+
const sheet = await renderDetents();
|
|
657
|
+
await layOut(sheet);
|
|
658
|
+
await openSheet(sheet.host);
|
|
659
|
+
|
|
660
|
+
await drag(sheet, MEDIUM, MEDIUM - 30);
|
|
661
|
+
expect(sheet.host.detent()).toBe('medium');
|
|
662
|
+
expect(style(sheet.panel, 'transition')).toContain('height');
|
|
663
|
+
expect(style(sheet.panel, 'height')).toBe(`${MEDIUM - 8}px`);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('opens at the detent the app sets, and drags down to the next', async () => {
|
|
667
|
+
const sheet = await renderDetents(IOS_26, (host) =>
|
|
668
|
+
host.detent.set('large'),
|
|
669
|
+
);
|
|
670
|
+
await layOut(sheet);
|
|
671
|
+
await openSheet(sheet.host);
|
|
672
|
+
expect(style(sheet.panel, 'height')).toBe(`${LARGE}px`);
|
|
673
|
+
|
|
674
|
+
await drag(sheet, 100, 400);
|
|
675
|
+
expect(sheet.host.detent()).toBe('medium');
|
|
676
|
+
await settled();
|
|
677
|
+
expect(style(sheet.panel, 'height')).toBe(`${MEDIUM - 8}px`);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('closes when dragged down past its shortest detent, sliding on from the finger', async () => {
|
|
681
|
+
const sheet = await renderDetents();
|
|
682
|
+
await layOut(sheet);
|
|
683
|
+
await openSheet(sheet.host);
|
|
684
|
+
|
|
685
|
+
await drag(sheet, MEDIUM, MEDIUM + 150);
|
|
686
|
+
expect(sheet.host.open()).toBe(false);
|
|
687
|
+
await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
|
|
688
|
+
// Past the screen's height, and without fading the glass.
|
|
689
|
+
expect(animate).toHaveBeenCalledWith(sheet.panel, [
|
|
690
|
+
START,
|
|
691
|
+
expect.any(String),
|
|
692
|
+
[
|
|
693
|
+
{ transform: 'translateY(150px)' },
|
|
694
|
+
{ transform: `translateY(${SCREEN}px)` },
|
|
695
|
+
],
|
|
696
|
+
expect.objectContaining({ duration: DURATION.normal }),
|
|
697
|
+
]);
|
|
698
|
+
// Closed, it has no detent, and opens again at its first.
|
|
699
|
+
await waitForUpdate();
|
|
700
|
+
expect(sheet.host.detent()).toBeUndefined();
|
|
701
|
+
await openSheet(sheet.host);
|
|
702
|
+
expect(sheet.host.detent()).toBe('medium');
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('springs back up to its shortest detent short of closing', async () => {
|
|
706
|
+
const sheet = await renderDetents();
|
|
707
|
+
await layOut(sheet);
|
|
708
|
+
await openSheet(sheet.host);
|
|
709
|
+
animate.mockClear();
|
|
710
|
+
|
|
711
|
+
await drag(sheet, MEDIUM, MEDIUM + 40);
|
|
712
|
+
expect(sheet.host.open()).toBe(true);
|
|
713
|
+
expect(animate).toHaveBeenCalledWith(sheet.panel, [
|
|
714
|
+
START,
|
|
715
|
+
expect.any(String),
|
|
716
|
+
[{ transform: 'translateY(40px)' }, { transform: 'translateY(0px)' }],
|
|
717
|
+
expect.objectContaining({ duration: DURATION.fast }),
|
|
718
|
+
]);
|
|
719
|
+
expect(sheet.host.detent()).toBe('medium');
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it('slides down without fading when closed at rest', async () => {
|
|
723
|
+
const sheet = await renderDetents();
|
|
724
|
+
await layOut(sheet);
|
|
725
|
+
await openSheet(sheet.host);
|
|
726
|
+
|
|
727
|
+
fireEvent.tap(sheet.backdrop);
|
|
728
|
+
await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
|
|
729
|
+
expect(animate).toHaveBeenCalledWith(sheet.panel, [
|
|
730
|
+
START,
|
|
731
|
+
expect.any(String),
|
|
732
|
+
[{ transform: 'translateY(0%)' }, { transform: 'translateY(110%)' }],
|
|
733
|
+
expect.objectContaining({ duration: DURATION.normal }),
|
|
734
|
+
]);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it('moves to the next detent when the grabber is tapped, and back from the last', async () => {
|
|
738
|
+
const sheet = await renderDetents();
|
|
739
|
+
await layOut(sheet);
|
|
740
|
+
await openSheet(sheet.host);
|
|
741
|
+
// A button that says what it is, for VoiceOver and TalkBack.
|
|
742
|
+
expect(sheet.handle.getAttribute('accessibility-traits')).toBe('button');
|
|
743
|
+
expect(sheet.handle.getAttribute('accessibility-label')).toBe(
|
|
744
|
+
'Sheet grabber',
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
await tapGrabber(sheet);
|
|
748
|
+
expect(sheet.host.detent()).toBe('large');
|
|
749
|
+
await tapGrabber(sheet);
|
|
750
|
+
expect(sheet.host.detent()).toBe('medium');
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it("doesn't take the click that follows a mouse drag released on the grabber", async () => {
|
|
754
|
+
const sheet = await renderDetents();
|
|
755
|
+
await layOut(sheet);
|
|
756
|
+
await openSheet(sheet.host);
|
|
757
|
+
|
|
758
|
+
fireEvent.mousedown(sheet.handle, { eventType: 'catchEvent', y: MEDIUM });
|
|
759
|
+
fireEvent.mousemove(sheet.backdrop, { y: MEDIUM - 20, buttons: 1 });
|
|
760
|
+
fireEvent.mouseup(sheet.backdrop);
|
|
761
|
+
await tapGrabber(sheet);
|
|
762
|
+
expect(sheet.host.detent()).toBe('medium');
|
|
763
|
+
// Only that one: the next tap moves it.
|
|
764
|
+
await tapGrabber(sheet);
|
|
765
|
+
expect(sheet.host.detent()).toBe('large');
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it('ignores a detent it was not given, and one set during a drag', async () => {
|
|
769
|
+
const sheet = await renderDetents();
|
|
770
|
+
await layOut(sheet);
|
|
771
|
+
await openSheet(sheet.host);
|
|
772
|
+
|
|
773
|
+
sheet.host.detent.set('fit');
|
|
774
|
+
await waitForUpdate();
|
|
775
|
+
expect(style(sheet.panel, 'height')).toBe(`${MEDIUM - 8}px`);
|
|
776
|
+
|
|
777
|
+
fireEvent.touchstart(sheet.handle, touchAt(MEDIUM));
|
|
778
|
+
sheet.host.detent.set('large');
|
|
779
|
+
await waitForUpdate();
|
|
780
|
+
expect(style(sheet.panel, 'transition')).toBe('');
|
|
781
|
+
expect(style(sheet.panel, 'height')).toBe(`${MEDIUM - 8}px`);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("keeps a drag that interrupts a settle going when the settle's time is up", async () => {
|
|
785
|
+
const sheet = await renderDetents();
|
|
786
|
+
await layOut(sheet);
|
|
787
|
+
await openSheet(sheet.host);
|
|
788
|
+
|
|
789
|
+
await tapGrabber(sheet);
|
|
790
|
+
fireEvent.touchstart(sheet.handle, touchAt(100));
|
|
791
|
+
await settled();
|
|
792
|
+
// Still following the finger, halfway back down to medium: no
|
|
793
|
+
// transition came back.
|
|
794
|
+
const halfway = (LARGE - MEDIUM) / 2;
|
|
795
|
+
fireEvent.touchmove(sheet.handle, touchAt(100 + halfway));
|
|
796
|
+
await waitForUpdate();
|
|
797
|
+
expect(style(sheet.panel, 'transition')).toBe('');
|
|
798
|
+
expect(style(sheet.panel, 'height')).toBe(`${LARGE - halfway - 4}px`);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it('fits its content at a fit detent, and transitions from it', async () => {
|
|
802
|
+
const sheet = await renderDetents(IOS_26, (host) =>
|
|
803
|
+
host.detents.set(['fit', 'large']),
|
|
804
|
+
);
|
|
805
|
+
await layOut(sheet);
|
|
806
|
+
await openSheet(sheet.host);
|
|
807
|
+
expect(sheet.host.detent()).toBe('fit');
|
|
808
|
+
expect(style(sheet.panel, 'height')).toBe('');
|
|
809
|
+
|
|
810
|
+
// A transition can't start from the content's height, so the sheet
|
|
811
|
+
// pins it for a frame first.
|
|
812
|
+
await tapGrabber(sheet);
|
|
813
|
+
expect(style(sheet.panel, 'height')).toBe(`${FIT}px`);
|
|
814
|
+
await vi.waitFor(() =>
|
|
815
|
+
expect(style(sheet.panel, 'height')).toBe(`${LARGE}px`),
|
|
816
|
+
);
|
|
817
|
+
await settled();
|
|
818
|
+
// A layout at the large detent's height isn't its content's.
|
|
819
|
+
fireEvent.layoutchange(sheet.panel, { detail: { height: LARGE } });
|
|
820
|
+
|
|
821
|
+
await tapGrabber(sheet);
|
|
822
|
+
expect(style(sheet.panel, 'height')).toBe(`${FIT}px`);
|
|
823
|
+
await settled();
|
|
824
|
+
expect(style(sheet.panel, 'height')).toBe('');
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it('settles at the nearest detent a long drag passes, past the ones between', async () => {
|
|
828
|
+
const sheet = await renderDetents(IOS_26, (host) =>
|
|
829
|
+
host.detents.set(['fit', 'medium', 'large']),
|
|
830
|
+
);
|
|
831
|
+
await layOut(sheet);
|
|
832
|
+
await openSheet(sheet.host);
|
|
833
|
+
|
|
834
|
+
// From its content's height to just short of the large detent.
|
|
835
|
+
await drag(sheet, SCREEN - FIT, 100);
|
|
836
|
+
expect(sheet.host.detent()).toBe('large');
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it('lets a drag take over from the pinned content height', async () => {
|
|
840
|
+
const sheet = await renderDetents(IOS_26, (host) =>
|
|
841
|
+
host.detents.set(['fit', 'large']),
|
|
842
|
+
);
|
|
843
|
+
await layOut(sheet);
|
|
844
|
+
await openSheet(sheet.host);
|
|
845
|
+
|
|
846
|
+
await tapGrabber(sheet);
|
|
847
|
+
fireEvent.touchstart(sheet.handle, touchAt(500));
|
|
848
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
849
|
+
await waitForUpdate();
|
|
850
|
+
expect(style(sheet.panel, 'transition')).toBe('');
|
|
851
|
+
expect(style(sheet.panel, 'height')).toBe(`${FIT}px`);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it('waits for the screen to be laid out before opening at a detent', async () => {
|
|
855
|
+
const sheet = await renderDetents();
|
|
856
|
+
sheet.host.open.set(true);
|
|
857
|
+
for (let i = 0; i < 3; i++) await waitForUpdate();
|
|
858
|
+
expect(style(sheet.panel, 'transform')).toBe('translateY(110%)');
|
|
859
|
+
|
|
860
|
+
await layOut(sheet);
|
|
861
|
+
await vi.waitFor(() =>
|
|
862
|
+
expect(style(sheet.panel, 'height')).toBe(`${MEDIUM - 8}px`),
|
|
863
|
+
);
|
|
864
|
+
expect(style(sheet.panel, 'transform')).toBe('translateY(0px)');
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it('opens at its content height if the screen is never laid out', async () => {
|
|
868
|
+
const sheet = await renderDetents();
|
|
869
|
+
sheet.host.open.set(true);
|
|
870
|
+
await vi.waitFor(() =>
|
|
871
|
+
expect(style(sheet.panel, 'transform')).toBe('translateY(0px)'),
|
|
872
|
+
);
|
|
873
|
+
expect(style(sheet.panel, 'height')).toBe('');
|
|
874
|
+
// Partial until it knows the screen.
|
|
875
|
+
expect(style(sheet.panel, 'left')).toBe('8px');
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it('treats no detents as fitting its content', async () => {
|
|
879
|
+
const sheet = await renderDetents(IOS_26, (host) => host.detents.set([]));
|
|
880
|
+
await openSheet(sheet.host);
|
|
881
|
+
expect(sheet.host.detent()).toBe('fit');
|
|
882
|
+
expect(sheet.handle.hasAttribute('accessibility-label')).toBe(false);
|
|
883
|
+
// Not resizable: a tap on the grabber does nothing.
|
|
884
|
+
await tapGrabber(sheet);
|
|
885
|
+
expect(sheet.host.detent()).toBe('fit');
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
it('fills the screen at a fit detent whose content is taller than it', async () => {
|
|
889
|
+
const sheet = await renderDetents(IOS_26, (host) =>
|
|
890
|
+
host.detents.set(['fit']),
|
|
891
|
+
);
|
|
892
|
+
await layOut(sheet, 900);
|
|
893
|
+
await openSheet(sheet.host);
|
|
894
|
+
expect(style(sheet.opaque(), 'opacity')).toBe('1');
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
it('under Reduce Motion, appears and moves between detents at once', async () => {
|
|
898
|
+
settings.reduceMotion.set(true);
|
|
899
|
+
const sheet = await renderDetents();
|
|
900
|
+
await layOut(sheet);
|
|
901
|
+
await openSheet(sheet.host);
|
|
902
|
+
const animated = () =>
|
|
903
|
+
animate.mock.calls.filter(
|
|
904
|
+
([element, [kind]]) => element === sheet.panel && kind === START,
|
|
905
|
+
);
|
|
906
|
+
// It fades in with the backdrop, and doesn't slide.
|
|
907
|
+
expect(animated()).toEqual([]);
|
|
908
|
+
|
|
909
|
+
await tapGrabber(sheet);
|
|
910
|
+
expect(style(sheet.panel, 'transition')).toBe('');
|
|
911
|
+
expect(style(sheet.panel, 'height')).toBe(`${LARGE}px`);
|
|
912
|
+
|
|
913
|
+
// Straight back after a short drag down, and away without sliding.
|
|
914
|
+
await tapGrabber(sheet);
|
|
915
|
+
await drag(sheet, MEDIUM, MEDIUM + 40);
|
|
916
|
+
expect(style(sheet.panel, 'transform')).toBe('translateY(0px)');
|
|
917
|
+
fireEvent.tap(sheet.backdrop);
|
|
918
|
+
await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
|
|
919
|
+
expect(animated()).toEqual([]);
|
|
920
|
+
});
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
describe('on iOS 15 to 25', () => {
|
|
924
|
+
it("draws the opaque sheet of its time, which doesn't inset", async () => {
|
|
925
|
+
const sheet = await renderDetents(IOS_18);
|
|
926
|
+
await layOut(sheet);
|
|
927
|
+
await openSheet(sheet.host);
|
|
928
|
+
|
|
929
|
+
expect(sheet.glass()).toBeNull();
|
|
930
|
+
expect(sheet.panel.classList).toContain('rounded-t-[10px]');
|
|
931
|
+
expect(sheet.panel.classList).toContain('bg-background');
|
|
932
|
+
expect(style(sheet.panel, 'left')).toBe('0px');
|
|
933
|
+
expect(style(sheet.panel, 'height')).toBe(`${MEDIUM}px`);
|
|
934
|
+
expect(sheet.grabber.classList).toContain('w-[36px]');
|
|
935
|
+
expect(sheet.backdrop.getAttribute('style')).toContain(
|
|
936
|
+
'rgba(0, 0, 0, 0.2)',
|
|
937
|
+
);
|
|
938
|
+
// An opaque sheet slides up as the default does.
|
|
939
|
+
expect(animate).toHaveBeenCalledWith(sheet.panel, [
|
|
940
|
+
START,
|
|
941
|
+
expect.any(String),
|
|
942
|
+
[
|
|
943
|
+
{ transform: 'translateY(100%)', opacity: 0.8 },
|
|
944
|
+
{ transform: 'translateY(0%)', opacity: 1 },
|
|
945
|
+
],
|
|
946
|
+
expect.objectContaining({ duration: DURATION.slow }),
|
|
947
|
+
]);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it('fades in and out with the backdrop under Reduce Motion', async () => {
|
|
951
|
+
settings.reduceMotion.set(true);
|
|
952
|
+
const sheet = await renderDetents(IOS_18);
|
|
953
|
+
await layOut(sheet);
|
|
954
|
+
await openSheet(sheet.host);
|
|
955
|
+
fireEvent.tap(sheet.backdrop);
|
|
956
|
+
await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
|
|
957
|
+
|
|
958
|
+
const panelStarts = animate.mock.calls.filter(
|
|
959
|
+
([element, [kind]]) => element === sheet.panel && kind === START,
|
|
960
|
+
);
|
|
961
|
+
expect(panelStarts).toEqual([]);
|
|
962
|
+
expect(animate).toHaveBeenCalledWith(sheet.backdrop, [
|
|
963
|
+
START,
|
|
964
|
+
expect.any(String),
|
|
965
|
+
[{ opacity: 1 }, { opacity: 0 }],
|
|
966
|
+
expect.objectContaining({ duration: DURATION.normal }),
|
|
967
|
+
]);
|
|
968
|
+
});
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
describe('on Android (Material 3)', () => {
|
|
972
|
+
it('draws a Material 3 bottom sheet', async () => {
|
|
973
|
+
const sheet = await renderDetents(ANDROID);
|
|
974
|
+
await layOut(sheet);
|
|
975
|
+
await openSheet(sheet.host);
|
|
976
|
+
|
|
977
|
+
expect(sheet.glass()).toBeNull();
|
|
978
|
+
expect(sheet.panel.classList).toContain('rounded-t-[28px]');
|
|
979
|
+
expect(sheet.panel.classList).toContain('bg-secondary');
|
|
980
|
+
expect(style(sheet.panel, 'box-shadow')).not.toBe('');
|
|
981
|
+
expect(sheet.handle.classList).toContain('py-[22px]');
|
|
982
|
+
expect(sheet.grabber.classList).toContain('w-[32px]');
|
|
983
|
+
expect(sheet.backdrop.getAttribute('style')).toContain(
|
|
984
|
+
'rgba(0, 0, 0, 0.32)',
|
|
985
|
+
);
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
it('slides on from the finger, fading, when dragged closed', async () => {
|
|
989
|
+
const sheet = await renderDetents(ANDROID);
|
|
990
|
+
await layOut(sheet);
|
|
991
|
+
await openSheet(sheet.host);
|
|
992
|
+
|
|
993
|
+
await drag(sheet, MEDIUM, MEDIUM + 200);
|
|
994
|
+
await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
|
|
995
|
+
expect(animate).toHaveBeenCalledWith(sheet.panel, [
|
|
996
|
+
START,
|
|
997
|
+
expect.any(String),
|
|
998
|
+
[
|
|
999
|
+
{ transform: 'translateY(200px)', opacity: 1 },
|
|
1000
|
+
{ transform: `translateY(${SCREEN}px)`, opacity: 0.8 },
|
|
1001
|
+
],
|
|
1002
|
+
expect.objectContaining({ duration: DURATION.normal }),
|
|
1003
|
+
]);
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
it('keeps the default look with detents', async () => {
|
|
1008
|
+
const sheet = await renderDetents(IOS_26, (host) =>
|
|
1009
|
+
host.variant.set('default'),
|
|
1010
|
+
);
|
|
1011
|
+
await layOut(sheet);
|
|
1012
|
+
await openSheet(sheet.host);
|
|
1013
|
+
expect(sheet.glass()).toBeNull();
|
|
1014
|
+
expect(sheet.panel.classList).toContain('rounded-t-2xl');
|
|
1015
|
+
expect(style(sheet.panel, 'height')).toBe(`${MEDIUM}px`);
|
|
1016
|
+
expect(sheet.backdrop.getAttribute('style')).toContain(
|
|
1017
|
+
'rgba(0, 0, 0, 0.5)',
|
|
1018
|
+
);
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* A glass sheet presented by a control marked `uiSheetSource`: a glass
|
|
1024
|
+
* button, or one that isn't glass.
|
|
1025
|
+
*/
|
|
1026
|
+
@Component({
|
|
1027
|
+
imports: [UiBottomSheet, UiSheetSource, UiGlass, LYNX_ELEMENTS],
|
|
1028
|
+
template: `
|
|
1029
|
+
@if (glassSource()) {
|
|
1030
|
+
<blur-view ui-glass class="source" [uiSheetSource]="sheet">
|
|
1031
|
+
<text>Add</text>
|
|
1032
|
+
</blur-view>
|
|
1033
|
+
} @else if (withSource()) {
|
|
1034
|
+
<view class="source" [uiSheetSource]="sheet"><text>Add</text></view>
|
|
1035
|
+
}
|
|
1036
|
+
<ui-bottom-sheet
|
|
1037
|
+
#sheet
|
|
1038
|
+
[(open)]="open"
|
|
1039
|
+
[variant]="variant()"
|
|
1040
|
+
[detents]="detents()"
|
|
1041
|
+
(closed)="closed()"
|
|
1042
|
+
>
|
|
1043
|
+
<text>Panel</text>
|
|
1044
|
+
</ui-bottom-sheet>
|
|
1045
|
+
`,
|
|
1046
|
+
})
|
|
1047
|
+
class SourceHost {
|
|
1048
|
+
readonly open = signal(false);
|
|
1049
|
+
readonly variant = signal<BottomSheetVariant>('glass');
|
|
1050
|
+
readonly detents = signal<readonly SheetDetent[]>(['medium']);
|
|
1051
|
+
readonly glassSource = signal(true);
|
|
1052
|
+
readonly withSource = signal(true);
|
|
1053
|
+
readonly closed = vi.fn();
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** The phone's width, beside SCREEN. */
|
|
1057
|
+
const WIDTH = 402;
|
|
1058
|
+
/** Where the control sits: a 44px circle near the top right. */
|
|
1059
|
+
const SOURCE = { left: 340, top: 60, width: 44, height: 44 };
|
|
1060
|
+
/** Where the sheet starts from and ends at: the control, as its style puts it. */
|
|
1061
|
+
const SOURCE_FRAME = {
|
|
1062
|
+
left: '340px',
|
|
1063
|
+
right: `${WIDTH - 340 - 44}px`,
|
|
1064
|
+
bottom: `${SCREEN - 60 - 44}px`,
|
|
1065
|
+
height: '44px',
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Renders the host on a platform, measuring the screen as
|
|
1070
|
+
* `boundingClientRect` would, and the control once the test lets the answer
|
|
1071
|
+
* through (see measureSource), or failing to measure it.
|
|
1072
|
+
*/
|
|
1073
|
+
const renderSource = async (
|
|
1074
|
+
platform: { platform: string; osVersion: string } = IOS_26,
|
|
1075
|
+
{ measurable = true } = {},
|
|
1076
|
+
) => {
|
|
1077
|
+
vi.stubGlobal('SystemInfo', platform);
|
|
1078
|
+
const answers: (() => void)[] = [];
|
|
1079
|
+
vi.spyOn(
|
|
1080
|
+
globalThis as unknown as InvokeUIMethod,
|
|
1081
|
+
'__InvokeUIMethod',
|
|
1082
|
+
).mockImplementation((element, method, _params, callback) => {
|
|
1083
|
+
if (method !== 'boundingClientRect') {
|
|
1084
|
+
callback({ code: 0 });
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
const answer = callback as (result: {
|
|
1088
|
+
code: number;
|
|
1089
|
+
data?: unknown;
|
|
1090
|
+
}) => void;
|
|
1091
|
+
if (!element.classList.contains('source')) {
|
|
1092
|
+
answer({
|
|
1093
|
+
code: 0,
|
|
1094
|
+
data: { left: 0, top: 0, width: WIDTH, height: SCREEN },
|
|
1095
|
+
});
|
|
1096
|
+
} else if (measurable) {
|
|
1097
|
+
answers.push(() => answer({ code: 0, data: SOURCE }));
|
|
1098
|
+
} else {
|
|
1099
|
+
answers.push(() => answer({ code: 1 }));
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
const result = await render(SourceHost, {
|
|
1103
|
+
providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
|
|
1104
|
+
});
|
|
1105
|
+
const host = result.componentRef.instance as SourceHost;
|
|
1106
|
+
const backdrop = result.container.querySelector('overlay > view')!;
|
|
1107
|
+
const panel = backdrop.children[1] as HTMLElement;
|
|
1108
|
+
fireEvent.layoutchange(backdrop, {
|
|
1109
|
+
detail: { height: SCREEN, width: WIDTH },
|
|
1110
|
+
});
|
|
1111
|
+
fireEvent.layoutchange(backdrop.children[0], { detail: { height: LARGE } });
|
|
1112
|
+
fireEvent.layoutchange(panel, { detail: { height: FIT } });
|
|
1113
|
+
await waitForUpdate();
|
|
1114
|
+
return {
|
|
1115
|
+
...result,
|
|
1116
|
+
host,
|
|
1117
|
+
overlay: result.container.querySelector('overlay')!,
|
|
1118
|
+
backdrop,
|
|
1119
|
+
panel,
|
|
1120
|
+
source: () => result.container.querySelector('.source') as HTMLElement,
|
|
1121
|
+
glass: () => panel.querySelector('blur-view')!,
|
|
1122
|
+
// The handle and the content, which fade as the sheet grows.
|
|
1123
|
+
content: () => panel.querySelector('.grow') as HTMLElement,
|
|
1124
|
+
/**
|
|
1125
|
+
* Waits until the sheet measures its control, and answers. The sheet
|
|
1126
|
+
* then shows at the control's frame for a frame, which the test sees
|
|
1127
|
+
* right after the update.
|
|
1128
|
+
*/
|
|
1129
|
+
measureSource: async () => {
|
|
1130
|
+
await vi.waitFor(() => expect(answers.length).toBeGreaterThan(0));
|
|
1131
|
+
for (const answer of answers.splice(0)) answer();
|
|
1132
|
+
await waitForUpdate();
|
|
1133
|
+
},
|
|
1134
|
+
};
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
type SourceRendered = Awaited<ReturnType<typeof renderSource>>;
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* The panel's frame, from its inline style.
|
|
1141
|
+
*/
|
|
1142
|
+
const frameOf = (panel: Element) => ({
|
|
1143
|
+
left: style(panel, 'left'),
|
|
1144
|
+
right: style(panel, 'right'),
|
|
1145
|
+
bottom: style(panel, 'bottom'),
|
|
1146
|
+
height: style(panel, 'height'),
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Answers the sheet's measuring of its control, and checks it then shows at
|
|
1151
|
+
* the control's frame, to grow out of it or as it shrinks into it.
|
|
1152
|
+
*/
|
|
1153
|
+
const atSource = async (sheet: SourceRendered) => {
|
|
1154
|
+
await sheet.measureSource();
|
|
1155
|
+
expect(frameOf(sheet.panel)).toEqual(SOURCE_FRAME);
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1158
|
+
/** The frame of the sheet at rest at its medium detent. */
|
|
1159
|
+
const MEDIUM_FRAME = {
|
|
1160
|
+
left: '8px',
|
|
1161
|
+
right: '8px',
|
|
1162
|
+
bottom: '8px',
|
|
1163
|
+
height: `${MEDIUM - 8}px`,
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Waits for a sheet growing out of its control to come to rest: the grow
|
|
1168
|
+
* takes longer than a settle between detents.
|
|
1169
|
+
*/
|
|
1170
|
+
const grown = async () => {
|
|
1171
|
+
await new Promise((resolve) => setTimeout(resolve, DURATION.slow + 140));
|
|
1172
|
+
await waitForUpdate();
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
describe('UiBottomSheet grown out of its control', () => {
|
|
1176
|
+
let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
|
|
1177
|
+
beforeEach(() => {
|
|
1178
|
+
animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
|
|
1179
|
+
// The renderer warns that the components aren't Lynx elements.
|
|
1180
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
1181
|
+
});
|
|
1182
|
+
afterEach(() => {
|
|
1183
|
+
vi.restoreAllMocks();
|
|
1184
|
+
vi.unstubAllGlobals();
|
|
1185
|
+
settings.reduceMotion.set(false);
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
it('grows out of the control that presents it, which hides meanwhile', async () => {
|
|
1189
|
+
const sheet = await renderSource();
|
|
1190
|
+
// The control and the sheet's glass share an id, for the shell to pair
|
|
1191
|
+
// them; the sheet's glass is hidden until the control hides.
|
|
1192
|
+
const id = sheet.source().getAttribute('glass-effect-id');
|
|
1193
|
+
expect(id).toMatch(/^ui-sheet-\d+$/);
|
|
1194
|
+
expect(sheet.glass().getAttribute('glass-effect-id')).toBe(id);
|
|
1195
|
+
expect(style(sheet.glass(), 'visibility')).toBe('hidden');
|
|
1196
|
+
expect(style(sheet.source(), 'visibility')).toBe('');
|
|
1197
|
+
|
|
1198
|
+
sheet.host.open.set(true);
|
|
1199
|
+
await atSource(sheet);
|
|
1200
|
+
expect(style(sheet.source(), 'visibility')).toBe('hidden');
|
|
1201
|
+
expect(style(sheet.glass(), 'visibility')).toBe('');
|
|
1202
|
+
expect(style(sheet.content(), 'opacity')).toBe('0');
|
|
1203
|
+
expect(style(sheet.panel, 'overflow')).toBe('hidden');
|
|
1204
|
+
// The backdrop dims through its color rather than fading in, which
|
|
1205
|
+
// would fade the glass in it too.
|
|
1206
|
+
expect(sheet.backdrop.getAttribute('style')).toContain(
|
|
1207
|
+
'rgba(0, 0, 0, 0.2)',
|
|
1208
|
+
);
|
|
1209
|
+
expect(style(sheet.backdrop, 'transition')).toContain('background-color');
|
|
1210
|
+
|
|
1211
|
+
// On to its detent, on the sheet's curve, its content fading in.
|
|
1212
|
+
await vi.waitFor(() => expect(style(sheet.panel, 'left')).toBe('8px'));
|
|
1213
|
+
expect(frameOf(sheet.panel)).toEqual(MEDIUM_FRAME);
|
|
1214
|
+
expect(style(sheet.panel, 'transition')).toContain(
|
|
1215
|
+
`height ${DURATION.slow + 100}ms cubic-bezier(0.32, 0.72, 0, 1)`,
|
|
1216
|
+
);
|
|
1217
|
+
expect(style(sheet.content(), 'opacity')).toBe('1');
|
|
1218
|
+
// It grows rather than slides, and nothing fades.
|
|
1219
|
+
expect(animate).not.toHaveBeenCalledWith(sheet.panel, expect.anything());
|
|
1220
|
+
expect(animate).not.toHaveBeenCalledWith(sheet.backdrop, expect.anything());
|
|
1221
|
+
|
|
1222
|
+
await grown();
|
|
1223
|
+
expect(style(sheet.panel, 'transition')).toBe('');
|
|
1224
|
+
expect(style(sheet.panel, 'overflow')).toBe('');
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
it('shrinks back into its control as it closes, which shows again', async () => {
|
|
1228
|
+
const sheet = await renderSource();
|
|
1229
|
+
sheet.host.open.set(true);
|
|
1230
|
+
await atSource(sheet);
|
|
1231
|
+
await grown();
|
|
1232
|
+
|
|
1233
|
+
sheet.host.open.set(false);
|
|
1234
|
+
await sheet.measureSource();
|
|
1235
|
+
// Its height pinned for a frame, then into the control.
|
|
1236
|
+
await vi.waitFor(() => expect(frameOf(sheet.panel)).toEqual(SOURCE_FRAME));
|
|
1237
|
+
expect(style(sheet.panel, 'transition')).toContain(
|
|
1238
|
+
`height ${DURATION.normal}ms`,
|
|
1239
|
+
);
|
|
1240
|
+
expect(style(sheet.content(), 'opacity')).toBe('0');
|
|
1241
|
+
expect(style(sheet.source(), 'visibility')).toBe('hidden');
|
|
1242
|
+
expect(sheet.backdrop.getAttribute('style')).toContain('rgba(0, 0, 0, 0)');
|
|
1243
|
+
|
|
1244
|
+
// The control shows in the update that hides the sheet's glass.
|
|
1245
|
+
await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
|
|
1246
|
+
await waitForUpdate();
|
|
1247
|
+
expect(sheet.overlay.getAttribute('visible')).toBe('false');
|
|
1248
|
+
expect(style(sheet.source(), 'visibility')).toBe('');
|
|
1249
|
+
expect(style(sheet.glass(), 'visibility')).toBe('hidden');
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
it("slides up and down when its control can't be measured", async () => {
|
|
1253
|
+
const sheet = await renderSource(IOS_26, { measurable: false });
|
|
1254
|
+
sheet.host.open.set(true);
|
|
1255
|
+
await sheet.measureSource();
|
|
1256
|
+
await vi.waitFor(() =>
|
|
1257
|
+
expect(animate).toHaveBeenCalledWith(sheet.panel, [
|
|
1258
|
+
START,
|
|
1259
|
+
expect.any(String),
|
|
1260
|
+
[{ transform: 'translateY(110%)' }, { transform: 'translateY(0%)' }],
|
|
1261
|
+
expect.anything(),
|
|
1262
|
+
]),
|
|
1263
|
+
);
|
|
1264
|
+
expect(style(sheet.panel, 'left')).toBe('8px');
|
|
1265
|
+
|
|
1266
|
+
sheet.host.open.set(false);
|
|
1267
|
+
await sheet.measureSource();
|
|
1268
|
+
await vi.waitFor(() =>
|
|
1269
|
+
expect(animate).toHaveBeenCalledWith(sheet.panel, [
|
|
1270
|
+
START,
|
|
1271
|
+
expect.any(String),
|
|
1272
|
+
[{ transform: 'translateY(0%)' }, { transform: 'translateY(110%)' }],
|
|
1273
|
+
expect.anything(),
|
|
1274
|
+
]),
|
|
1275
|
+
);
|
|
1276
|
+
await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
it('goes on down from the finger when dragged closed', async () => {
|
|
1280
|
+
const sheet = await renderSource();
|
|
1281
|
+
sheet.host.open.set(true);
|
|
1282
|
+
await atSource(sheet);
|
|
1283
|
+
await grown();
|
|
1284
|
+
|
|
1285
|
+
const handle = sheet.panel.querySelector('.rounded-full')!.parentElement!;
|
|
1286
|
+
fireEvent.touchstart(handle, touchAt(MEDIUM));
|
|
1287
|
+
fireEvent.touchmove(handle, touchAt(MEDIUM + 200));
|
|
1288
|
+
await waitForUpdate();
|
|
1289
|
+
fireEvent.touchend(handle, { eventType: 'catchEvent' });
|
|
1290
|
+
await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
|
|
1291
|
+
expect(animate).toHaveBeenCalledWith(sheet.panel, [
|
|
1292
|
+
START,
|
|
1293
|
+
expect.any(String),
|
|
1294
|
+
[
|
|
1295
|
+
{ transform: 'translateY(200px)' },
|
|
1296
|
+
{ transform: `translateY(${SCREEN}px)` },
|
|
1297
|
+
],
|
|
1298
|
+
expect.anything(),
|
|
1299
|
+
]);
|
|
1300
|
+
await waitForUpdate();
|
|
1301
|
+
expect(style(sheet.source(), 'visibility')).toBe('');
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
it('lets a drag that begins as it grows have it', async () => {
|
|
1305
|
+
const sheet = await renderSource();
|
|
1306
|
+
sheet.host.detents.set(['medium', 'large']);
|
|
1307
|
+
sheet.host.open.set(true);
|
|
1308
|
+
await atSource(sheet);
|
|
1309
|
+
await vi.waitFor(() => expect(style(sheet.panel, 'left')).toBe('8px'));
|
|
1310
|
+
|
|
1311
|
+
const handle = sheet.panel.querySelector('.rounded-full')!.parentElement!;
|
|
1312
|
+
fireEvent.touchstart(handle, touchAt(MEDIUM));
|
|
1313
|
+
fireEvent.touchmove(handle, touchAt(MEDIUM - 100));
|
|
1314
|
+
await waitForUpdate();
|
|
1315
|
+
const dragged = style(sheet.panel, 'height');
|
|
1316
|
+
expect(dragged).not.toBe(MEDIUM_FRAME.height);
|
|
1317
|
+
await grown();
|
|
1318
|
+
// Still where the finger has it, not back at the detent.
|
|
1319
|
+
expect(style(sheet.panel, 'height')).toBe(dragged);
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
it('stops growing when it closes first, and shrinking when it opens again', async () => {
|
|
1323
|
+
const sheet = await renderSource();
|
|
1324
|
+
// Closed while it measures its control: it never shows.
|
|
1325
|
+
sheet.host.open.set(true);
|
|
1326
|
+
await vi.waitFor(() =>
|
|
1327
|
+
expect(sheet.overlay.getAttribute('visible')).toBe('true'),
|
|
1328
|
+
);
|
|
1329
|
+
sheet.host.open.set(false);
|
|
1330
|
+
await waitForUpdate();
|
|
1331
|
+
await sheet.measureSource();
|
|
1332
|
+
await vi.waitFor(() => expect(sheet.host.closed).toHaveBeenCalled());
|
|
1333
|
+
await waitForUpdate();
|
|
1334
|
+
expect(style(sheet.source(), 'visibility')).toBe('');
|
|
1335
|
+
|
|
1336
|
+
// Opened again while it measures its control to shrink into it: it
|
|
1337
|
+
// grows again instead.
|
|
1338
|
+
sheet.host.open.set(true);
|
|
1339
|
+
await atSource(sheet);
|
|
1340
|
+
await grown();
|
|
1341
|
+
sheet.host.open.set(false);
|
|
1342
|
+
await waitForUpdate();
|
|
1343
|
+
sheet.host.open.set(true);
|
|
1344
|
+
await waitForUpdate();
|
|
1345
|
+
await sheet.measureSource();
|
|
1346
|
+
await sheet.measureSource();
|
|
1347
|
+
await grown();
|
|
1348
|
+
expect(sheet.overlay.getAttribute('visible')).toBe('true');
|
|
1349
|
+
expect(frameOf(sheet.panel)).toEqual(MEDIUM_FRAME);
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
it('appears as without one under Reduce Motion', async () => {
|
|
1353
|
+
settings.reduceMotion.set(true);
|
|
1354
|
+
const sheet = await renderSource();
|
|
1355
|
+
await openSheet(sheet.host);
|
|
1356
|
+
expect(style(sheet.source(), 'visibility')).toBe('');
|
|
1357
|
+
expect(sheet.content().getAttribute('style') ?? '').toBe('');
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
it('slides as without one before iOS 26', async () => {
|
|
1361
|
+
const sheet = await renderSource(IOS_18);
|
|
1362
|
+
await openSheet(sheet.host);
|
|
1363
|
+
expect(style(sheet.source(), 'visibility')).toBe('');
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
it("leaves a control that isn't glass unpaired, and lets go of one that goes", async () => {
|
|
1367
|
+
const sheet = await renderSource();
|
|
1368
|
+
sheet.host.glassSource.set(false);
|
|
1369
|
+
await waitForUpdate();
|
|
1370
|
+
expect(sheet.source().tagName.toLowerCase()).toBe('view');
|
|
1371
|
+
expect(sheet.source().hasAttribute('glass-effect-id')).toBe(false);
|
|
1372
|
+
|
|
1373
|
+
// Without a control, the glass sheet slides up again.
|
|
1374
|
+
sheet.host.withSource.set(false);
|
|
1375
|
+
await waitForUpdate();
|
|
1376
|
+
await openSheet(sheet.host);
|
|
1377
|
+
await vi.waitFor(() =>
|
|
1378
|
+
expect(animate).toHaveBeenCalledWith(sheet.panel, [
|
|
1379
|
+
START,
|
|
1380
|
+
expect.any(String),
|
|
1381
|
+
[{ transform: 'translateY(110%)' }, { transform: 'translateY(0%)' }],
|
|
1382
|
+
expect.anything(),
|
|
1383
|
+
]),
|
|
1384
|
+
);
|
|
1385
|
+
});
|
|
1386
|
+
});
|