@blotch/dolan 22.2.3 → 22.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/add.js +9 -1
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/add.test.js +36 -3
- package/dist/commands/add.test.js.map +1 -1
- package/dist/commands/doctor.js +187 -7
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +280 -7
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/init.d.ts +2 -2
- package/dist/commands/init.js +38 -18
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +107 -1
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/theme.js +1 -1
- package/dist/commands/theme.js.map +1 -1
- package/dist/commands/update.d.ts +5 -0
- package/dist/commands/update.js +126 -45
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/update.test.js +314 -2
- package/dist/commands/update.test.js.map +1 -1
- package/dist/lockfile.d.ts +7 -0
- package/dist/lockfile.js +36 -0
- package/dist/lockfile.js.map +1 -1
- package/dist/lockfile.test.d.ts +1 -0
- package/dist/lockfile.test.js +25 -0
- package/dist/lockfile.test.js.map +1 -0
- package/dist/registry.d.ts +6 -0
- package/dist/registry.js +48 -2
- package/dist/registry.js.map +1 -1
- package/dist/registry.test.js +45 -3
- package/dist/registry.test.js.map +1 -1
- package/dist/test-utils.d.ts +5 -0
- package/dist/test-utils.js +6 -1
- package/dist/test-utils.js.map +1 -1
- package/dist/testing/fire-lynx-event.d.ts +14 -0
- package/dist/testing/fire-lynx-event.js +19 -0
- package/dist/testing/fire-lynx-event.js.map +1 -0
- package/dist/testing/fire-lynx-event.test.d.ts +1 -0
- package/dist/testing/fire-lynx-event.test.js +44 -0
- package/dist/testing/fire-lynx-event.test.js.map +1 -0
- package/dist/testing/lynx-main-thread.d.ts +22 -0
- package/dist/testing/lynx-main-thread.js +11 -0
- package/dist/testing/lynx-main-thread.js.map +1 -0
- package/dist/testing/lynx-main-thread.test.d.ts +1 -0
- package/dist/testing/lynx-main-thread.test.js +24 -0
- package/dist/testing/lynx-main-thread.test.js.map +1 -0
- package/dist/testing/set-input-signal.d.ts +5 -4
- package/dist/testing/set-input-signal.js +5 -4
- package/dist/testing/set-input-signal.js.map +1 -1
- package/dist/ui/components/accordion/accordion.test.ts +158 -144
- package/dist/ui/components/accordion/accordion.ts +19 -14
- package/dist/ui/components/action-sheet/action-sheet.test.ts +272 -187
- package/dist/ui/components/action-sheet/action-sheet.ts +40 -34
- package/dist/ui/components/alert/alert.test.ts +67 -19
- package/dist/ui/components/alert/alert.ts +14 -16
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +206 -139
- package/dist/ui/components/alert-dialog/alert-dialog.ts +32 -26
- package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
- package/dist/ui/components/avatar/avatar.test.ts +89 -45
- package/dist/ui/components/badge/badge.test.ts +57 -23
- package/dist/ui/components/badge/badge.ts +12 -14
- package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +373 -297
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +34 -33
- package/dist/ui/components/button/button.test.ts +611 -2
- package/dist/ui/components/button/button.ts +431 -62
- package/dist/ui/components/button/index.ts +2 -0
- package/dist/ui/components/button-group/button-group.test.ts +484 -2
- package/dist/ui/components/button-group/button-group.ts +380 -68
- package/dist/ui/components/button-group/index.ts +3 -0
- package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
- package/dist/ui/components/card/card.test.ts +82 -62
- package/dist/ui/components/chart/chart-presets.test.ts +197 -1
- package/dist/ui/components/chart/chart-presets.ts +8 -1
- package/dist/ui/components/chart/chart-theme.ts +12 -0
- package/dist/ui/components/chart/chart.test.ts +1017 -0
- package/dist/ui/components/chart/chart.ts +56 -43
- package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
- package/dist/ui/components/checkbox/checkbox.ts +23 -23
- package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
- package/dist/ui/components/collapsible/collapsible.ts +19 -14
- package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
- package/dist/ui/components/context-menu/context-menu.ts +110 -0
- package/dist/ui/components/context-menu/index.ts +1 -0
- package/dist/ui/components/dialog/dialog.test.ts +210 -152
- package/dist/ui/components/dialog/dialog.ts +29 -23
- package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
- package/dist/ui/components/dock/dock-scroll.ts +133 -0
- package/dist/ui/components/dock/dock.test.ts +749 -0
- package/dist/ui/components/dock/dock.ts +960 -0
- package/dist/ui/components/dock/index.ts +2 -0
- package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
- package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
- package/dist/ui/components/glass/concentric-radius.ts +17 -0
- package/dist/ui/components/glass/glass-design.test.ts +78 -0
- package/dist/ui/components/glass/glass-design.ts +119 -0
- package/dist/ui/components/glass/glass.test.ts +338 -0
- package/dist/ui/components/glass/glass.ts +360 -0
- package/dist/ui/components/glass/index.ts +18 -0
- package/dist/ui/components/icon/icon-theme.test.ts +36 -0
- package/dist/ui/components/icon/icon-theme.ts +36 -13
- package/dist/ui/components/icon/icon.test.ts +52 -41
- package/dist/ui/components/input/input.test.ts +178 -101
- package/dist/ui/components/input/input.ts +15 -12
- package/dist/ui/components/label/label.test.ts +11 -24
- package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
- package/dist/ui/components/list/list.test.ts +80 -49
- package/dist/ui/components/menu/index.ts +25 -0
- package/dist/ui/components/menu/menu-placement.test.ts +153 -0
- package/dist/ui/components/menu/menu-placement.ts +159 -0
- package/dist/ui/components/menu/menu-popup.test.ts +632 -0
- package/dist/ui/components/menu/menu-popup.ts +544 -0
- package/dist/ui/components/menu/menu.test.ts +429 -0
- package/dist/ui/components/menu/menu.ts +415 -0
- package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
- package/dist/ui/components/nav-drawer/nav-drawer.ts +72 -56
- package/dist/ui/components/pagination/pagination.test.ts +136 -86
- package/dist/ui/components/progress/progress.test.ts +102 -46
- package/dist/ui/components/progress/progress.ts +52 -54
- package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
- package/dist/ui/components/radio-group/radio-group.ts +24 -24
- package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
- package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
- package/dist/ui/components/select/select.test.ts +138 -78
- package/dist/ui/components/select/select.ts +17 -11
- package/dist/ui/components/separator/separator.test.ts +18 -17
- package/dist/ui/components/sheet/sheet.test.ts +64 -11
- package/dist/ui/components/sheet/sheet.ts +9 -1
- package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
- package/dist/ui/components/skeleton/skeleton.ts +10 -16
- package/dist/ui/components/spinner/spinner.test.ts +64 -46
- package/dist/ui/components/spinner/spinner.ts +16 -19
- package/dist/ui/components/switch/switch.test.ts +116 -46
- package/dist/ui/components/switch/switch.ts +8 -13
- package/dist/ui/components/tab-bar/index.ts +1 -0
- package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
- package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
- package/dist/ui/components/tabs/tabs.test.ts +215 -157
- package/dist/ui/components/tabs/tabs.ts +110 -118
- package/dist/ui/components/textarea/textarea.test.ts +206 -100
- package/dist/ui/components/textarea/textarea.ts +14 -12
- package/dist/ui/components/toast/toast.test.ts +434 -350
- package/dist/ui/components/toast/toast.ts +42 -51
- package/dist/ui/components/toggle/toggle.test.ts +247 -140
- package/dist/ui/theme/chart-colors.test.ts +8 -3
- package/dist/ui/theme/dark.css +38 -2
- package/dist/ui/theme/default.css +55 -7
- package/dist/ui/theme/icon-colors.test.ts +8 -3
- package/dist/ui/theme/rgb-channels.test.ts +84 -0
- package/dist/ui/theme/tailwind-build.test.ts +114 -0
- package/dist/ui/theme/tailwind-css.test.ts +83 -0
- package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
- package/dist/ui/theme/tailwind-plugin.ts +93 -29
- package/dist/ui/theme/tailwind.css +113 -0
- package/dist/ui/utils/animate.test.ts +13 -1
- package/dist/utils/analyze.d.ts +14 -0
- package/dist/utils/analyze.js +15 -0
- package/dist/utils/analyze.js.map +1 -1
- package/dist/utils/analyze.test.js +14 -1
- package/dist/utils/analyze.test.js.map +1 -1
- package/dist/utils/installed.d.ts +12 -0
- package/dist/utils/installed.js +16 -0
- package/dist/utils/installed.js.map +1 -1
- package/dist/utils/installed.test.js +17 -1
- package/dist/utils/installed.test.js.map +1 -1
- package/dist/utils/resolve-paths.d.ts +4 -4
- package/dist/utils/resolve-paths.js +5 -7
- package/dist/utils/resolve-paths.js.map +1 -1
- package/dist/utils/resolve-paths.test.js +9 -5
- package/dist/utils/resolve-paths.test.js.map +1 -1
- package/dist/utils/tailwind.d.ts +25 -0
- package/dist/utils/tailwind.js +51 -0
- package/dist/utils/tailwind.js.map +1 -0
- package/dist/utils/tailwind.test.d.ts +1 -0
- package/dist/utils/tailwind.test.js +71 -0
- package/dist/utils/tailwind.test.js.map +1 -0
- package/package.json +9 -1
- package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
- package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
- package/dist/ui/components/button/button-render.test.ts +0 -132
- package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
- package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
- package/dist/ui/components/chart/chart-render.test.ts +0 -730
- package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
- package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
import { Component, signal } from '@angular/core';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
waitForUpdate,
|
|
6
|
+
} from '@blotch/angular-lynx-testing-library';
|
|
3
7
|
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
4
|
-
import {
|
|
5
|
-
|
|
8
|
+
import {
|
|
9
|
+
type MockInstance,
|
|
10
|
+
afterEach,
|
|
11
|
+
beforeEach,
|
|
12
|
+
describe,
|
|
13
|
+
expect,
|
|
14
|
+
it,
|
|
15
|
+
vi,
|
|
16
|
+
} from 'vitest';
|
|
17
|
+
import {
|
|
18
|
+
type LynxMainThread,
|
|
19
|
+
lynxMainThread,
|
|
20
|
+
} from '../../../test-utils/lynx-main-thread';
|
|
6
21
|
import {
|
|
7
22
|
UiCollapsible,
|
|
8
23
|
UiCollapsibleContent,
|
|
@@ -10,103 +25,165 @@ import {
|
|
|
10
25
|
} from './collapsible';
|
|
11
26
|
|
|
12
27
|
@Component({
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
28
|
+
imports: [
|
|
29
|
+
UiCollapsible,
|
|
30
|
+
UiCollapsibleTrigger,
|
|
31
|
+
UiCollapsibleContent,
|
|
32
|
+
LYNX_ELEMENTS,
|
|
33
|
+
],
|
|
34
|
+
template: `
|
|
35
|
+
<ui-collapsible [(open)]="open" [disabled]="disabled()" class="gap-2">
|
|
36
|
+
<ui-collapsible-trigger class="px-2">
|
|
37
|
+
<text>Toggle</text>
|
|
38
|
+
</ui-collapsible-trigger>
|
|
39
|
+
<ui-collapsible-content class="pt-1">
|
|
40
|
+
<text>Details</text>
|
|
41
|
+
</ui-collapsible-content>
|
|
42
|
+
</ui-collapsible>
|
|
43
|
+
`,
|
|
16
44
|
})
|
|
17
|
-
class CollapsibleHost {
|
|
45
|
+
class CollapsibleHost {
|
|
46
|
+
readonly open = signal(false);
|
|
47
|
+
readonly disabled = signal(false);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const renderCollapsible = async () => {
|
|
51
|
+
const result = await render(CollapsibleHost);
|
|
52
|
+
return {
|
|
53
|
+
...result,
|
|
54
|
+
host: result.componentRef.instance as CollapsibleHost,
|
|
55
|
+
// The trigger's own view holds the tap listener; the projected text is
|
|
56
|
+
// inside it.
|
|
57
|
+
trigger: () => result.getByText('Toggle').parentElement!,
|
|
58
|
+
// The content's own view wraps the projected text while it's open.
|
|
59
|
+
content: () => result.queryByText('Details')?.parentElement ?? null,
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** `__ElementAnimate`'s first argument for starting an animation. */
|
|
64
|
+
const START = 0;
|
|
65
|
+
/** `__ElementAnimate`'s first argument for canceling an animation. */
|
|
66
|
+
const CANCEL = 3;
|
|
18
67
|
|
|
19
68
|
describe('UiCollapsible', () => {
|
|
20
|
-
it('
|
|
21
|
-
const {
|
|
22
|
-
|
|
23
|
-
|
|
69
|
+
it("merges each part's class with its own", async () => {
|
|
70
|
+
const { container, host, trigger, content } = await renderCollapsible();
|
|
71
|
+
// The host element carries the class too, so look for the view that has
|
|
72
|
+
// both its own and the given class.
|
|
73
|
+
expect(container.querySelector('.flex-col.gap-2')).not.toBeNull();
|
|
74
|
+
expect(trigger().classList).toContain('flex-row');
|
|
75
|
+
expect(trigger().classList).toContain('px-2');
|
|
24
76
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
expect(
|
|
29
|
-
inst.toggle();
|
|
30
|
-
expect(inst.open()).toBe(true);
|
|
31
|
-
inst.toggle();
|
|
32
|
-
expect(inst.open()).toBe(false);
|
|
77
|
+
host.open.set(true);
|
|
78
|
+
await waitForUpdate();
|
|
79
|
+
expect(content()!.classList).toContain('flex-col');
|
|
80
|
+
expect(content()!.classList).toContain('pt-1');
|
|
33
81
|
});
|
|
34
82
|
|
|
35
|
-
it('
|
|
36
|
-
const {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
inst.toggle();
|
|
40
|
-
expect(inst.open()).toBe(false);
|
|
41
|
-
});
|
|
42
|
-
});
|
|
83
|
+
it('opens and closes when the trigger is tapped', async () => {
|
|
84
|
+
const { host, trigger, content } = await renderCollapsible();
|
|
85
|
+
// Closed, the content isn't mounted at all.
|
|
86
|
+
expect(content()).toBeNull();
|
|
43
87
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
88
|
+
fireEvent.tap(trigger());
|
|
89
|
+
await waitForUpdate();
|
|
90
|
+
expect(content()).not.toBeNull();
|
|
91
|
+
expect(host.open()).toBe(true);
|
|
48
92
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
(trigger as unknown as { onTap: () => void }).onTap();
|
|
54
|
-
expect(parent.open()).toBe(true);
|
|
93
|
+
fireEvent.tap(trigger());
|
|
94
|
+
await waitForUpdate();
|
|
95
|
+
expect(content()).toBeNull();
|
|
96
|
+
expect(host.open()).toBe(false);
|
|
55
97
|
});
|
|
56
98
|
|
|
57
|
-
it('
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
setInputSignal(parent.disabled, true);
|
|
61
|
-
|
|
62
|
-
const triggerResult = await render(UiCollapsibleTrigger, {
|
|
63
|
-
providers: [{ provide: UiCollapsible, useValue: parent }],
|
|
64
|
-
});
|
|
99
|
+
it('opens when its `open` is set', async () => {
|
|
100
|
+
const { host, content } = await renderCollapsible();
|
|
101
|
+
host.open.set(true);
|
|
65
102
|
await waitForUpdate();
|
|
66
|
-
|
|
67
|
-
expect(view?.getAttribute('class')).toContain('opacity-50');
|
|
103
|
+
expect(content()).not.toBeNull();
|
|
68
104
|
});
|
|
69
|
-
});
|
|
70
105
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const parent = parentResult.componentRef.instance as UiCollapsible;
|
|
106
|
+
it('dims its trigger and ignores taps while disabled', async () => {
|
|
107
|
+
const { host, trigger, content } = await renderCollapsible();
|
|
108
|
+
expect(trigger().classList).not.toContain('opacity-50');
|
|
75
109
|
|
|
76
|
-
|
|
77
|
-
providers: [{ provide: UiCollapsible, useValue: parent }],
|
|
78
|
-
});
|
|
79
|
-
const content = contentResult.componentRef.instance as UiCollapsibleContent;
|
|
80
|
-
// JIT + jsdom doesn't populate the `#content` viewChild — patch it with a
|
|
81
|
-
// signal returning the outer view so the setTimeout body's `el` check
|
|
82
|
-
// passes and revealIn actually runs.
|
|
83
|
-
const view = contentResult.container.querySelector('view') as Element;
|
|
84
|
-
(content as unknown as { contentRef: unknown }).contentRef = signal({
|
|
85
|
-
nativeElement: view,
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
parent.open.set(true);
|
|
110
|
+
host.disabled.set(true);
|
|
89
111
|
await waitForUpdate();
|
|
90
|
-
|
|
112
|
+
expect(trigger().classList).toContain('opacity-50');
|
|
91
113
|
|
|
92
|
-
|
|
93
|
-
parent.open.set(false);
|
|
94
|
-
await waitForUpdate();
|
|
95
|
-
parent.open.set(true);
|
|
96
|
-
await waitForUpdate();
|
|
114
|
+
fireEvent.tap(trigger());
|
|
97
115
|
await waitForUpdate();
|
|
98
|
-
expect(
|
|
116
|
+
expect(content()).toBeNull();
|
|
117
|
+
expect(host.open()).toBe(false);
|
|
99
118
|
});
|
|
100
119
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
120
|
+
describe('reveal animation', () => {
|
|
121
|
+
// Watched per test: the testing library resets the main thread after each.
|
|
122
|
+
let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
|
|
125
|
+
});
|
|
126
|
+
afterEach(() => animate.mockRestore());
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The keys of the animations started on an element.
|
|
130
|
+
*/
|
|
131
|
+
const startedOn = (element: Element | null) =>
|
|
132
|
+
animate.mock.calls
|
|
133
|
+
.filter(
|
|
134
|
+
([target, [operation]]) => target === element && operation === START,
|
|
135
|
+
)
|
|
136
|
+
.map(([, [, key]]) => key);
|
|
137
|
+
|
|
138
|
+
it('slides the content down into place each time it opens', async () => {
|
|
139
|
+
const { trigger, content } = await renderCollapsible();
|
|
140
|
+
|
|
141
|
+
fireEvent.tap(trigger());
|
|
142
|
+
// The first update runs the effect, which waits a macrotask so the
|
|
143
|
+
// content is committed to Lynx's tree; the second lets that run.
|
|
144
|
+
await waitForUpdate();
|
|
145
|
+
await waitForUpdate();
|
|
146
|
+
expect(animate).toHaveBeenCalledWith(content(), [
|
|
147
|
+
START,
|
|
148
|
+
expect.any(String),
|
|
149
|
+
[
|
|
150
|
+
{ transform: 'translateY(-8px)', opacity: 0 },
|
|
151
|
+
{ transform: 'translateY(0)', opacity: 1 },
|
|
152
|
+
],
|
|
153
|
+
// No fill: once it ends, the content's inline resting style applies.
|
|
154
|
+
expect.objectContaining({ fill: 'none' }),
|
|
155
|
+
]);
|
|
156
|
+
const [firstKey] = startedOn(content());
|
|
157
|
+
|
|
158
|
+
fireEvent.tap(trigger());
|
|
159
|
+
await waitForUpdate();
|
|
160
|
+
fireEvent.tap(trigger());
|
|
161
|
+
await waitForUpdate();
|
|
162
|
+
await waitForUpdate();
|
|
163
|
+
// Reopening stops the previous reveal before it plays a new one on the
|
|
164
|
+
// content's new view.
|
|
165
|
+
expect(animate).toHaveBeenCalledWith(expect.anything(), [
|
|
166
|
+
CANCEL,
|
|
167
|
+
firstKey,
|
|
168
|
+
]);
|
|
169
|
+
expect(startedOn(content())).toHaveLength(1);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("doesn't animate content that closed before its reveal began", async () => {
|
|
173
|
+
const { componentRef, trigger, content } = await renderCollapsible();
|
|
174
|
+
|
|
175
|
+
fireEvent.tap(trigger());
|
|
176
|
+
// The effect has scheduled the reveal for the next macrotask.
|
|
177
|
+
await waitForUpdate();
|
|
178
|
+
// Close it before then, and render that straight away.
|
|
179
|
+
fireEvent.tap(trigger());
|
|
180
|
+
componentRef.changeDetectorRef.detectChanges();
|
|
181
|
+
expect(content()).toBeNull();
|
|
182
|
+
|
|
183
|
+
await waitForUpdate();
|
|
184
|
+
expect(
|
|
185
|
+
animate.mock.calls.filter(([, [operation]]) => operation === START),
|
|
186
|
+
).toEqual([]);
|
|
106
187
|
});
|
|
107
|
-
parent.open.set(true);
|
|
108
|
-
await waitForUpdate();
|
|
109
|
-
await waitForUpdate();
|
|
110
|
-
expect(parent.open()).toBe(true);
|
|
111
188
|
});
|
|
112
189
|
});
|
|
@@ -101,20 +101,25 @@ export class UiCollapsibleContent {
|
|
|
101
101
|
readonly contentRef = viewChild<ElementRef>('content');
|
|
102
102
|
#anim?: AnimationHandle;
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Animates the reveal whenever the panel opens. A field rather than a
|
|
106
|
+
* constructor: Angular's test build keeps a function for a constructor that
|
|
107
|
+
* nothing calls, which an app's coverage report would show as uncovered.
|
|
108
|
+
*
|
|
109
|
+
* contentRef only resolves while the `@if` content is mounted; defer one
|
|
110
|
+
* macrotask so the freshly (re)created element is committed to the native
|
|
111
|
+
* tree before we animate it. By then the panel may have closed again, which
|
|
112
|
+
* leaves nothing to animate.
|
|
113
|
+
*/
|
|
114
|
+
protected readonly revealing = effect(() => {
|
|
115
|
+
if (!this.collapsible.open()) return;
|
|
116
|
+
setTimeout(() => {
|
|
117
|
+
const el = this.contentRef()?.nativeElement;
|
|
118
|
+
if (!el) return;
|
|
119
|
+
this.#anim?.cancel();
|
|
120
|
+
this.#anim = revealIn(el, { fromY: -8, fill: 'none' });
|
|
121
|
+
}, 0);
|
|
122
|
+
});
|
|
118
123
|
|
|
119
124
|
protected readonly contentClass = computed(() =>
|
|
120
125
|
cn('flex-col flex', this.userClass()),
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import {
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
waitForUpdate,
|
|
6
|
+
within,
|
|
7
|
+
} from '@blotch/angular-lynx-testing-library';
|
|
8
|
+
import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { UiMenuItem, UiMenuSeparator } from '../menu/menu';
|
|
11
|
+
import { UiContextMenu } from './context-menu';
|
|
12
|
+
|
|
13
|
+
@Component({
|
|
14
|
+
imports: [LYNX_ELEMENTS, UiContextMenu, UiMenuItem, UiMenuSeparator],
|
|
15
|
+
template: `
|
|
16
|
+
<ui-context-menu
|
|
17
|
+
[open]="open()"
|
|
18
|
+
(openChange)="open.set($event)"
|
|
19
|
+
[disabled]="disabled()"
|
|
20
|
+
(closed)="closed()"
|
|
21
|
+
>
|
|
22
|
+
<view class="card" (bindtap)="opened()">
|
|
23
|
+
<text>Kyoto</text>
|
|
24
|
+
</view>
|
|
25
|
+
<ui-menu-item label="Share" icon="upload" (pressed)="shared()" />
|
|
26
|
+
<ui-menu-separator />
|
|
27
|
+
<ui-menu-item
|
|
28
|
+
label="Delete"
|
|
29
|
+
icon="trash"
|
|
30
|
+
variant="destructive"
|
|
31
|
+
(pressed)="deleted()"
|
|
32
|
+
/>
|
|
33
|
+
</ui-context-menu>
|
|
34
|
+
`,
|
|
35
|
+
})
|
|
36
|
+
class ContextMenuHost {
|
|
37
|
+
readonly open = signal(false);
|
|
38
|
+
readonly disabled = signal(false);
|
|
39
|
+
readonly closed = vi.fn();
|
|
40
|
+
readonly opened = vi.fn();
|
|
41
|
+
readonly shared = vi.fn();
|
|
42
|
+
readonly deleted = vi.fn();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The Lynx function that runs an element's UI methods, such as measuring it
|
|
47
|
+
* (`boundingClientRect`). The testing library defines it on the global
|
|
48
|
+
* object.
|
|
49
|
+
*/
|
|
50
|
+
type InvokeUIMethod = {
|
|
51
|
+
__InvokeUIMethod: (
|
|
52
|
+
element: Element,
|
|
53
|
+
method: string,
|
|
54
|
+
params: Record<string, unknown>,
|
|
55
|
+
callback: (result: { code: number; data?: unknown }) => void,
|
|
56
|
+
) => void;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Accessibility settings a test can change, as the host app would. */
|
|
60
|
+
const settings = {
|
|
61
|
+
reduceMotion: signal(false),
|
|
62
|
+
reduceTransparency: signal(false),
|
|
63
|
+
increaseContrast: signal(false),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Renders the context menu on a platform (none for Material), measuring
|
|
70
|
+
* elements as `boundingClientRect` would on a phone: the safe area (the
|
|
71
|
+
* element that holds the menu) a phone's screen, the menu's content 120pt
|
|
72
|
+
* tall, and anything else, the pressed element, a card near the top.
|
|
73
|
+
*/
|
|
74
|
+
const renderContextMenu = async (
|
|
75
|
+
platform: { platform: string; osVersion: string } | null = IOS_26,
|
|
76
|
+
) => {
|
|
77
|
+
vi.stubGlobal('SystemInfo', platform ?? undefined);
|
|
78
|
+
vi.spyOn(
|
|
79
|
+
globalThis as unknown as InvokeUIMethod,
|
|
80
|
+
'__InvokeUIMethod',
|
|
81
|
+
).mockImplementation((element, method, _params, callback) => {
|
|
82
|
+
if (method !== 'boundingClientRect') {
|
|
83
|
+
callback({ code: 0 });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const data = element.querySelector(':scope > blur-view[ui-glass]')
|
|
87
|
+
? { left: 0, top: 62, width: 402, height: 778 }
|
|
88
|
+
: element.parentElement?.tagName.toLowerCase() === 'scroll-view'
|
|
89
|
+
? { left: 0, top: 0, width: 250, height: 120 }
|
|
90
|
+
: { left: 16, top: 162, width: 370, height: 96 };
|
|
91
|
+
callback({ code: 0, data });
|
|
92
|
+
});
|
|
93
|
+
const result = await render(ContextMenuHost, {
|
|
94
|
+
providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
|
|
95
|
+
});
|
|
96
|
+
const host = result.componentRef.instance as ContextMenuHost;
|
|
97
|
+
const overlay = result.container.querySelector('overlay')!;
|
|
98
|
+
return {
|
|
99
|
+
...result,
|
|
100
|
+
host,
|
|
101
|
+
overlay,
|
|
102
|
+
menu: within(overlay as HTMLElement),
|
|
103
|
+
card: () => result.getByText('Kyoto').parentElement!,
|
|
104
|
+
/** The element the card is in: its home, or the overlay's slot. */
|
|
105
|
+
holder: () =>
|
|
106
|
+
result.getByText('Kyoto').parentElement!.parentElement!.parentElement!,
|
|
107
|
+
panel: () => overlay.querySelector('blur-view[ui-glass]')!,
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
type Rendered = Awaited<ReturnType<typeof renderContextMenu>>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Waits until the menu has opened.
|
|
115
|
+
*/
|
|
116
|
+
const waitForOpen = (rendered: Rendered) =>
|
|
117
|
+
vi.waitFor(() =>
|
|
118
|
+
expect(rendered.panel().getAttribute('accessibility-exclusive-focus')).toBe(
|
|
119
|
+
'true',
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Waits until the menu has closed and its animation has finished.
|
|
125
|
+
*/
|
|
126
|
+
const waitForClose = async (rendered: Rendered) => {
|
|
127
|
+
await vi.waitFor(() => expect(rendered.host.closed).toHaveBeenCalled());
|
|
128
|
+
await waitForUpdate();
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Long-presses an element. The press bubbles, as Lynx's touch events do, to
|
|
133
|
+
* the context menu around the element.
|
|
134
|
+
*/
|
|
135
|
+
const longPress = (element: Element) =>
|
|
136
|
+
fireEvent.longpress(element, { bubbles: true });
|
|
137
|
+
|
|
138
|
+
const style = (element: Element, name: string) =>
|
|
139
|
+
(element as HTMLElement).style.getPropertyValue(name);
|
|
140
|
+
|
|
141
|
+
// The renderer warns that the components' selectors aren't Lynx elements.
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
afterEach(() => {
|
|
147
|
+
vi.restoreAllMocks();
|
|
148
|
+
vi.unstubAllGlobals();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('UiContextMenu', () => {
|
|
152
|
+
it('opens on a long press, lifting the element above the dimmed screen', async () => {
|
|
153
|
+
const rendered = await renderContextMenu();
|
|
154
|
+
const home = rendered.holder();
|
|
155
|
+
|
|
156
|
+
longPress(rendered.card());
|
|
157
|
+
await waitForOpen(rendered);
|
|
158
|
+
|
|
159
|
+
expect(rendered.host.open()).toBe(true);
|
|
160
|
+
// The element itself is in the menu's overlay now, lifted, and the spot
|
|
161
|
+
// it left keeps its size.
|
|
162
|
+
expect(rendered.overlay.contains(rendered.card())).toBe(true);
|
|
163
|
+
expect(style(rendered.holder(), 'transform')).toBe('scale(1.03)');
|
|
164
|
+
expect(style(home, 'width')).toBe('370px');
|
|
165
|
+
expect(style(home, 'height')).toBe('96px');
|
|
166
|
+
expect(rendered.overlay.querySelector('.bg-glass-dim')).not.toBeNull();
|
|
167
|
+
// The menu opens below it.
|
|
168
|
+
expect(style(rendered.panel(), 'top')).toBe(`${100 + 96 + 8}px`);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('runs the chosen item and closes, putting the element back', async () => {
|
|
172
|
+
const rendered = await renderContextMenu();
|
|
173
|
+
longPress(rendered.card());
|
|
174
|
+
await waitForOpen(rendered);
|
|
175
|
+
|
|
176
|
+
fireEvent.tap(
|
|
177
|
+
rendered.menu.getByRole('button', { name: 'Delete, destructive' }),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
expect(rendered.host.deleted).toHaveBeenCalledTimes(1);
|
|
181
|
+
expect(rendered.host.open()).toBe(false);
|
|
182
|
+
await waitForClose(rendered);
|
|
183
|
+
expect(rendered.overlay.contains(rendered.card())).toBe(false);
|
|
184
|
+
expect(style(rendered.holder(), 'height')).toBe('');
|
|
185
|
+
// A tap on the element still reaches it.
|
|
186
|
+
fireEvent.tap(rendered.card());
|
|
187
|
+
expect(rendered.host.opened).toHaveBeenCalledTimes(1);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('closes on a tap outside the menu', async () => {
|
|
191
|
+
const rendered = await renderContextMenu();
|
|
192
|
+
rendered.host.open.set(true);
|
|
193
|
+
await waitForOpen(rendered);
|
|
194
|
+
|
|
195
|
+
fireEvent.tap(rendered.overlay.firstElementChild!);
|
|
196
|
+
|
|
197
|
+
expect(rendered.host.open()).toBe(false);
|
|
198
|
+
await waitForClose(rendered);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("doesn't open on a long press when disabled", async () => {
|
|
202
|
+
const rendered = await renderContextMenu();
|
|
203
|
+
rendered.host.disabled.set(true);
|
|
204
|
+
await waitForUpdate();
|
|
205
|
+
|
|
206
|
+
longPress(rendered.card());
|
|
207
|
+
await waitForUpdate();
|
|
208
|
+
|
|
209
|
+
expect(rendered.host.open()).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('opens a Material menu beside the element on Android, leaving it in place', async () => {
|
|
213
|
+
const rendered = await renderContextMenu(null);
|
|
214
|
+
const home = rendered.holder();
|
|
215
|
+
|
|
216
|
+
longPress(rendered.card());
|
|
217
|
+
await waitForOpen(rendered);
|
|
218
|
+
|
|
219
|
+
expect(home.contains(rendered.card())).toBe(true);
|
|
220
|
+
expect(style(home, 'height')).toBe('');
|
|
221
|
+
expect(rendered.overlay.querySelector('.bg-glass-dim')).toBeNull();
|
|
222
|
+
expect(rendered.menu.getByRole('button', { name: 'Share' })).toBeTruthy();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Component,
|
|
3
|
+
type ElementRef,
|
|
4
|
+
ViewEncapsulation,
|
|
5
|
+
booleanAttribute,
|
|
6
|
+
inject,
|
|
7
|
+
input,
|
|
8
|
+
model,
|
|
9
|
+
output,
|
|
10
|
+
viewChild,
|
|
11
|
+
} from '@angular/core';
|
|
12
|
+
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
13
|
+
|
|
14
|
+
import { type MenuElement, UiMenuPopup } from '../menu/menu-popup';
|
|
15
|
+
import { UI_MENU, type UiMenuHost } from '../menu/menu';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The `ui-context-menu` an item is in, for `UI_MENU`. A named function rather
|
|
19
|
+
* than an inline factory: the component's metadata keeps its own copy of an
|
|
20
|
+
* inline one, which nothing calls, so an app's coverage report would show it
|
|
21
|
+
* as uncovered.
|
|
22
|
+
*/
|
|
23
|
+
const contextMenu = (): UiMenuHost => inject(UiContextMenu);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A context menu: a long press on an element lifts it and opens a menu of
|
|
27
|
+
* actions beside it, while the rest of the screen dims.
|
|
28
|
+
*
|
|
29
|
+
* ```html
|
|
30
|
+
* <ui-context-menu>
|
|
31
|
+
* <view class="rounded-xl bg-card p-4">
|
|
32
|
+
* <text class="text-foreground">Trip to Kyoto</text>
|
|
33
|
+
* </view>
|
|
34
|
+
* <ui-menu-item label="Share" icon="upload" (pressed)="share()" />
|
|
35
|
+
* <ui-menu-item label="Favorite" icon="heart" (pressed)="favorite()" />
|
|
36
|
+
* <ui-menu-separator />
|
|
37
|
+
* <ui-menu-item label="Delete" icon="trash" variant="destructive" (pressed)="remove()" />
|
|
38
|
+
* </ui-context-menu>
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* The items (`ui-menu-item`, `ui-menu-section`, `ui-menu-separator`) are the
|
|
42
|
+
* menu, and everything else is the element it opens from. Taps on the
|
|
43
|
+
* element still reach it: Lynx tells a tap from a long press. A tap outside
|
|
44
|
+
* the menu, choosing an item, or Android's back button closes it, and
|
|
45
|
+
* `[(open)]` opens and closes it from code.
|
|
46
|
+
*
|
|
47
|
+
* On iOS the element lifts above a dimming layer (a blur before iOS 26) and
|
|
48
|
+
* the menu, Liquid Glass from iOS 26, opens below it, or above it near the
|
|
49
|
+
* bottom of the screen; when it fits on neither side, the element moves up
|
|
50
|
+
* to make room. The element itself moves into the menu's overlay, so it
|
|
51
|
+
* stays live and looks exactly as it did. On Android it's a Material 3 menu
|
|
52
|
+
* beside the element, without the lift or the dimming, unless the app opts
|
|
53
|
+
* into glass with `provideGlass({ android: 'glass' })`.
|
|
54
|
+
*
|
|
55
|
+
* Apple asks for a context menu's first actions to match the swipe actions
|
|
56
|
+
* of the row it's on, when there are any, so the two ways to reach an
|
|
57
|
+
* action agree.
|
|
58
|
+
*/
|
|
59
|
+
@Component({
|
|
60
|
+
selector: 'ui-context-menu',
|
|
61
|
+
imports: [LYNX_ELEMENTS, UiMenuPopup],
|
|
62
|
+
encapsulation: ViewEncapsulation.None,
|
|
63
|
+
providers: [{ provide: UI_MENU, useFactory: contextMenu }],
|
|
64
|
+
template: `
|
|
65
|
+
<!-- Keeps the element's size while it's lifted into the overlay, so the
|
|
66
|
+
page doesn't move under the menu. -->
|
|
67
|
+
<view
|
|
68
|
+
#home
|
|
69
|
+
class="flex-col flex"
|
|
70
|
+
[style.width.px]="popup.placeholder()?.width"
|
|
71
|
+
[style.height.px]="popup.placeholder()?.height"
|
|
72
|
+
>
|
|
73
|
+
<view #element class="flex-col flex" (bindlongpress)="onLongPress()">
|
|
74
|
+
<ng-content />
|
|
75
|
+
</view>
|
|
76
|
+
</view>
|
|
77
|
+
<ui-menu-popup
|
|
78
|
+
#popup
|
|
79
|
+
kind="context"
|
|
80
|
+
[open]="open()"
|
|
81
|
+
[anchor]="elementView().nativeElement"
|
|
82
|
+
[anchorHome]="homeView().nativeElement"
|
|
83
|
+
(dismissed)="close()"
|
|
84
|
+
(closed)="closed.emit()"
|
|
85
|
+
>
|
|
86
|
+
<ng-content select="ui-menu-item, ui-menu-section, ui-menu-separator" />
|
|
87
|
+
</ui-menu-popup>
|
|
88
|
+
`,
|
|
89
|
+
})
|
|
90
|
+
export class UiContextMenu implements UiMenuHost {
|
|
91
|
+
/** Whether the menu is open. */
|
|
92
|
+
readonly open = model(false);
|
|
93
|
+
/** Turns the long press off. */
|
|
94
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
95
|
+
|
|
96
|
+
/** The menu has closed and its close animation has finished. */
|
|
97
|
+
readonly closed = output<void>();
|
|
98
|
+
|
|
99
|
+
// Required: both are always in the template.
|
|
100
|
+
readonly elementView = viewChild.required<ElementRef<MenuElement>>('element');
|
|
101
|
+
readonly homeView = viewChild.required<ElementRef<MenuElement>>('home');
|
|
102
|
+
|
|
103
|
+
protected onLongPress(): void {
|
|
104
|
+
if (!this.disabled()) this.open.set(true);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
close(): void {
|
|
108
|
+
this.open.set(false);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { UiContextMenu } from './context-menu';
|