@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
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { Component, signal, viewChildren } from '@angular/core';
|
|
2
|
+
import { Router, RouterOutlet, type Routes } from '@angular/router';
|
|
3
|
+
import {
|
|
4
|
+
fireEvent,
|
|
5
|
+
render,
|
|
6
|
+
waitForUpdate,
|
|
7
|
+
} from '@blotch/angular-lynx-testing-library';
|
|
8
|
+
import { LYNX_ELEMENTS, provideRouter } from '@blotch/angular-lynx';
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
|
|
11
|
+
import { UiTabBar, UiTabBarItem } from './tab-bar';
|
|
12
|
+
|
|
13
|
+
@Component({
|
|
14
|
+
selector: 'home-page',
|
|
15
|
+
imports: [LYNX_ELEMENTS],
|
|
16
|
+
template: `<text>Home page</text>`,
|
|
17
|
+
})
|
|
18
|
+
class HomePage {}
|
|
19
|
+
|
|
20
|
+
@Component({
|
|
21
|
+
selector: 'inbox-page',
|
|
22
|
+
imports: [LYNX_ELEMENTS],
|
|
23
|
+
template: `<text>Inbox page</text>`,
|
|
24
|
+
})
|
|
25
|
+
class InboxPage {}
|
|
26
|
+
|
|
27
|
+
@Component({
|
|
28
|
+
selector: 'profile-page',
|
|
29
|
+
imports: [LYNX_ELEMENTS],
|
|
30
|
+
template: `<text>Profile page</text>`,
|
|
31
|
+
})
|
|
32
|
+
class ProfilePage {}
|
|
33
|
+
|
|
34
|
+
const routes: Routes = [
|
|
35
|
+
{ path: '', component: HomePage },
|
|
36
|
+
{ path: 'inbox', component: InboxPage },
|
|
37
|
+
{ path: 'inbox/:id', component: InboxPage },
|
|
38
|
+
{ path: 'profile', component: ProfilePage },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
@Component({
|
|
42
|
+
imports: [LYNX_ELEMENTS, RouterOutlet, UiTabBar, UiTabBarItem],
|
|
43
|
+
template: `
|
|
44
|
+
<router-outlet />
|
|
45
|
+
<ui-tab-bar>
|
|
46
|
+
<ui-tab-bar-item
|
|
47
|
+
route="/"
|
|
48
|
+
icon="home"
|
|
49
|
+
label="Home"
|
|
50
|
+
[routerLinkActiveOptions]="{ exact: true }"
|
|
51
|
+
/>
|
|
52
|
+
<ui-tab-bar-item
|
|
53
|
+
route="/inbox"
|
|
54
|
+
icon="inbox"
|
|
55
|
+
label="Inbox"
|
|
56
|
+
[badge]="inboxBadge()"
|
|
57
|
+
[class]="inboxClass()"
|
|
58
|
+
/>
|
|
59
|
+
<ui-tab-bar-item
|
|
60
|
+
route="/profile"
|
|
61
|
+
icon="user"
|
|
62
|
+
label="Profile"
|
|
63
|
+
[queryParams]="{ tab: 'posts' }"
|
|
64
|
+
/>
|
|
65
|
+
</ui-tab-bar>
|
|
66
|
+
`,
|
|
67
|
+
})
|
|
68
|
+
class TabBarHost {
|
|
69
|
+
readonly inboxBadge = signal<string | number | null | undefined>(undefined);
|
|
70
|
+
readonly inboxClass = signal('');
|
|
71
|
+
readonly items = viewChildren(UiTabBarItem);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The theme's light colors, as icon-theme.ts resolves them for an SVG. */
|
|
75
|
+
const PRIMARY = 'rgba(24, 24, 27, 1)';
|
|
76
|
+
const MUTED_FOREGROUND = 'rgba(113, 113, 122, 1)';
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Runs change detection until `done` holds: a navigation takes a few ticks,
|
|
80
|
+
* and RouterLinkActive reports the result in a microtask after it.
|
|
81
|
+
*/
|
|
82
|
+
const until = async (done: () => boolean): Promise<void> => {
|
|
83
|
+
for (let i = 0; i < 20 && !done(); i++) {
|
|
84
|
+
await waitForUpdate();
|
|
85
|
+
}
|
|
86
|
+
expect(done()).toBe(true);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const renderTabBar = async () => {
|
|
90
|
+
const result = await render(TabBarHost, {
|
|
91
|
+
providers: [provideRouter(routes)],
|
|
92
|
+
});
|
|
93
|
+
const { container } = result;
|
|
94
|
+
const host = result.componentRef.instance as TabBarHost;
|
|
95
|
+
const router = result.componentRef.injector.get(Router);
|
|
96
|
+
/**
|
|
97
|
+
* An item's host is the accessibility element, named by its label.
|
|
98
|
+
*/
|
|
99
|
+
const tab = (label: string) =>
|
|
100
|
+
container.querySelector(`[accessibility-label^="${label}"]`)!;
|
|
101
|
+
const isActive = (label: string) =>
|
|
102
|
+
tab(label).getAttribute('accessibility-traits') === 'button,selected';
|
|
103
|
+
await until(() => isActive('Home'));
|
|
104
|
+
return {
|
|
105
|
+
...result,
|
|
106
|
+
host,
|
|
107
|
+
router,
|
|
108
|
+
tab,
|
|
109
|
+
isActive,
|
|
110
|
+
item: (label: string) =>
|
|
111
|
+
host.items().find((item) => item.label() === label)!,
|
|
112
|
+
labelOf: (label: string) =>
|
|
113
|
+
Array.from(tab(label).querySelectorAll('text')).find(
|
|
114
|
+
(text) => text.textContent === label,
|
|
115
|
+
)!,
|
|
116
|
+
iconOf: (label: string) =>
|
|
117
|
+
tab(label).querySelector('svg')!.getAttribute('content') ?? '',
|
|
118
|
+
page: () =>
|
|
119
|
+
Array.from(container.querySelectorAll('text'))
|
|
120
|
+
.map((text) => text.textContent ?? '')
|
|
121
|
+
.find((text) => text.endsWith(' page')),
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
describe('UiTabBar', () => {
|
|
126
|
+
// Item and page selectors aren't Lynx elements, so the renderer falls back
|
|
127
|
+
// to a <view> for each and warns.
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
130
|
+
});
|
|
131
|
+
afterEach(() => vi.restoreAllMocks());
|
|
132
|
+
|
|
133
|
+
it('lays the bar out as a row above the bottom safe area', async () => {
|
|
134
|
+
const { container, componentRef } = await render(UiTabBar);
|
|
135
|
+
const bar = container.querySelector('[accessibility-traits="tabbar"]')!;
|
|
136
|
+
expect(bar.classList).toContain('flex-row');
|
|
137
|
+
expect(bar.classList).toContain('pb-safe');
|
|
138
|
+
expect(bar.classList).toContain('border-t');
|
|
139
|
+
|
|
140
|
+
componentRef.setInput('class', 'border-t-0');
|
|
141
|
+
await waitForUpdate();
|
|
142
|
+
expect(bar.classList).toContain('border-t-0');
|
|
143
|
+
expect(bar.classList).not.toContain('border-t');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('draws an icon and a label in each tab', async () => {
|
|
147
|
+
const { tab, labelOf, iconOf } = await renderTabBar();
|
|
148
|
+
for (const label of ['Home', 'Inbox', 'Profile']) {
|
|
149
|
+
expect(labelOf(label)).toBeTruthy();
|
|
150
|
+
expect(iconOf(label)).toContain('<svg');
|
|
151
|
+
// Each tab is one button for a screen reader, and takes an equal share
|
|
152
|
+
// of the row.
|
|
153
|
+
expect(tab(label).getAttribute('accessibility-element')).toBe('true');
|
|
154
|
+
expect(tab(label).classList).toContain('flex-1');
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('marks the tab of the current route as selected, in the primary color', async () => {
|
|
159
|
+
const { isActive, labelOf, iconOf } = await renderTabBar();
|
|
160
|
+
expect(isActive('Home')).toBe(true);
|
|
161
|
+
expect(labelOf('Home').classList).toContain('text-primary');
|
|
162
|
+
expect(iconOf('Home')).toContain(PRIMARY);
|
|
163
|
+
|
|
164
|
+
expect(isActive('Inbox')).toBe(false);
|
|
165
|
+
expect(labelOf('Inbox').classList).toContain('text-muted-foreground');
|
|
166
|
+
expect(iconOf('Inbox')).toContain(MUTED_FOREGROUND);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('reads to a screen reader as a tab bar, the current tab selected', async () => {
|
|
170
|
+
const { getByRole, queryByRole } = await renderTabBar();
|
|
171
|
+
expect(getByRole('tablist')).toBeTruthy();
|
|
172
|
+
expect(getByRole('button', { name: 'Home', selected: true })).toBeTruthy();
|
|
173
|
+
expect(queryByRole('button', { name: 'Inbox', selected: true })).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('navigates on a tap, and moves the selection to the tapped tab', async () => {
|
|
177
|
+
const { tab, isActive, page, labelOf, item } = await renderTabBar();
|
|
178
|
+
const pressed = vi.fn();
|
|
179
|
+
item('Inbox').pressed.subscribe(pressed);
|
|
180
|
+
expect(page()).toBe('Home page');
|
|
181
|
+
|
|
182
|
+
fireEvent.tap(tab('Inbox'));
|
|
183
|
+
|
|
184
|
+
await until(() => page() === 'Inbox page');
|
|
185
|
+
expect(isActive('Inbox')).toBe(true);
|
|
186
|
+
expect(labelOf('Inbox').classList).toContain('text-primary');
|
|
187
|
+
// `exact` keeps "/" from matching every route.
|
|
188
|
+
expect(isActive('Home')).toBe(false);
|
|
189
|
+
expect(labelOf('Home').classList).toContain('text-muted-foreground');
|
|
190
|
+
expect(pressed).toHaveBeenCalledTimes(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('keeps a tab selected on the routes under it', async () => {
|
|
194
|
+
const { router, isActive, page } = await renderTabBar();
|
|
195
|
+
await router.navigateByUrl('/inbox/7');
|
|
196
|
+
await until(() => page() === 'Inbox page');
|
|
197
|
+
expect(isActive('Inbox')).toBe(true);
|
|
198
|
+
expect(isActive('Home')).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("navigates with the item's RouterLink options", async () => {
|
|
202
|
+
const { router, tab, isActive } = await renderTabBar();
|
|
203
|
+
fireEvent.tap(tab('Profile'));
|
|
204
|
+
await until(() => isActive('Profile'));
|
|
205
|
+
expect(router.url).toBe('/profile?tab=posts');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('emits pressed for a tap on the selected tab too', async () => {
|
|
209
|
+
const { router, tab, item } = await renderTabBar();
|
|
210
|
+
const pressed = vi.fn();
|
|
211
|
+
item('Home').pressed.subscribe(pressed);
|
|
212
|
+
fireEvent.tap(tab('Home'));
|
|
213
|
+
await waitForUpdate();
|
|
214
|
+
expect(pressed).toHaveBeenCalledTimes(1);
|
|
215
|
+
expect(router.url).toBe('/');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("shows a badge over the icon, and says it after the tab's name", async () => {
|
|
219
|
+
const { host, tab } = await renderTabBar();
|
|
220
|
+
const badge = () =>
|
|
221
|
+
Array.from(tab('Inbox').querySelectorAll('text')).find(
|
|
222
|
+
(text) => text.textContent !== 'Inbox',
|
|
223
|
+
);
|
|
224
|
+
expect(badge()).toBeUndefined();
|
|
225
|
+
|
|
226
|
+
host.inboxBadge.set(3);
|
|
227
|
+
await waitForUpdate();
|
|
228
|
+
expect(badge()?.textContent).toBe('3');
|
|
229
|
+
expect(badge()?.parentElement?.classList).toContain('bg-destructive');
|
|
230
|
+
expect(tab('Inbox').getAttribute('accessibility-label')).toBe('Inbox, 3');
|
|
231
|
+
|
|
232
|
+
host.inboxBadge.set('New');
|
|
233
|
+
await waitForUpdate();
|
|
234
|
+
expect(badge()?.textContent).toBe('New');
|
|
235
|
+
|
|
236
|
+
// A count of 0 and an empty value show nothing, so a count binds as is.
|
|
237
|
+
for (const empty of [0, '', null, undefined]) {
|
|
238
|
+
host.inboxBadge.set(empty);
|
|
239
|
+
await waitForUpdate();
|
|
240
|
+
expect(badge()).toBeUndefined();
|
|
241
|
+
expect(tab('Inbox').getAttribute('accessibility-label')).toBe('Inbox');
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('shrinks a tab while it is pressed', async () => {
|
|
246
|
+
const { tab } = await renderTabBar();
|
|
247
|
+
const inbox = tab('Inbox') as HTMLElement;
|
|
248
|
+
const scale = () => inbox.style.getPropertyValue('transform');
|
|
249
|
+
|
|
250
|
+
fireEvent.touchstart(inbox);
|
|
251
|
+
expect(scale()).toBe('scale(0.97)');
|
|
252
|
+
fireEvent.touchend(inbox);
|
|
253
|
+
expect(scale()).toBe('scale(1)');
|
|
254
|
+
|
|
255
|
+
fireEvent.touchstart(inbox);
|
|
256
|
+
// A scroll taking over cancels the touch.
|
|
257
|
+
fireEvent.touchcancel(inbox);
|
|
258
|
+
expect(scale()).toBe('scale(1)');
|
|
259
|
+
|
|
260
|
+
// The web preview, in a browser, sends mouse events instead.
|
|
261
|
+
fireEvent.mousedown(inbox);
|
|
262
|
+
expect(scale()).toBe('scale(0.97)');
|
|
263
|
+
fireEvent.mouseup(inbox);
|
|
264
|
+
expect(scale()).toBe('scale(1)');
|
|
265
|
+
fireEvent.mousedown(inbox);
|
|
266
|
+
fireLynxEvent(inbox, 'mouseleave');
|
|
267
|
+
expect(scale()).toBe('scale(1)');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("merges an item's class into its content, the given class winning", async () => {
|
|
271
|
+
const { host, tab } = await renderTabBar();
|
|
272
|
+
const content = () => tab('Inbox').querySelector('view')!;
|
|
273
|
+
expect(content().classList).toContain('pt-2');
|
|
274
|
+
|
|
275
|
+
host.inboxClass.set('pt-3');
|
|
276
|
+
await waitForUpdate();
|
|
277
|
+
expect(content().classList).toContain('pt-3');
|
|
278
|
+
expect(content().classList).not.toContain('pt-2');
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Component,
|
|
3
|
+
ElementRef,
|
|
4
|
+
ViewEncapsulation,
|
|
5
|
+
computed,
|
|
6
|
+
inject,
|
|
7
|
+
input,
|
|
8
|
+
output,
|
|
9
|
+
} from '@angular/core';
|
|
10
|
+
import { toSignal } from '@angular/core/rxjs-interop';
|
|
11
|
+
import { RouterLink, RouterLinkActive } from '@angular/router';
|
|
12
|
+
import { LYNX_ELEMENTS, LynxRouterLinkActive } from '@blotch/angular-lynx';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type AnimationHandle,
|
|
16
|
+
pressDown,
|
|
17
|
+
pressRelease,
|
|
18
|
+
} from '../../utils/animate';
|
|
19
|
+
import { cn } from '../../utils/cn';
|
|
20
|
+
import { injectIconColor } from '../icon/icon-theme';
|
|
21
|
+
import { type IconName, ICONS } from '../icon/icons';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The bar along the bottom of the screen that switches between an app's
|
|
25
|
+
* top-level screens. Put a `<ui-tab-bar-item>` in it for each screen, and
|
|
26
|
+
* place it under the `<router-outlet>`.
|
|
27
|
+
*/
|
|
28
|
+
@Component({
|
|
29
|
+
selector: 'ui-tab-bar',
|
|
30
|
+
imports: [LYNX_ELEMENTS],
|
|
31
|
+
encapsulation: ViewEncapsulation.None,
|
|
32
|
+
// `pb-safe` pads the bar by the bottom safe-area inset, so the items sit
|
|
33
|
+
// above the iPhone's home indicator while the bar's background still runs
|
|
34
|
+
// to the bottom edge, as a native tab bar's does. Lynx reports no insets on
|
|
35
|
+
// Android, nor does the web preview have any, so it's 0 there.
|
|
36
|
+
//
|
|
37
|
+
// `tabbar` is UIKit's tab bar trait (UIAccessibilityTraitTabBar), the one a
|
|
38
|
+
// native tab bar's container has: it tells VoiceOver that the buttons in it
|
|
39
|
+
// are tabs. Android reads a single trait and has no tab bar trait, so there
|
|
40
|
+
// the row stays a plain container.
|
|
41
|
+
template: `
|
|
42
|
+
<view accessibility-traits="tabbar" [class]="barClass()">
|
|
43
|
+
<ng-content />
|
|
44
|
+
</view>
|
|
45
|
+
`,
|
|
46
|
+
})
|
|
47
|
+
export class UiTabBar {
|
|
48
|
+
readonly userClass = input<string>('', { alias: 'class' });
|
|
49
|
+
|
|
50
|
+
protected readonly barClass = computed(() =>
|
|
51
|
+
cn(
|
|
52
|
+
'flex-row border-t border-border bg-background flex pb-safe',
|
|
53
|
+
this.userClass(),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One tab: an icon over a label that navigates to its `route` on a tap.
|
|
60
|
+
*
|
|
61
|
+
* The host itself is the link. Angular's own `RouterLink` and
|
|
62
|
+
* `RouterLinkActive` sit on it as host directives, so every `RouterLink`
|
|
63
|
+
* option (`queryParams`, `replaceUrl`, `state`, …) and
|
|
64
|
+
* `routerLinkActiveOptions` work as they do on the web, and the item knows
|
|
65
|
+
* whether its route is active without the app wiring `routerLinkActive` up.
|
|
66
|
+
* Host directives don't match selectors, so `LynxRouterLinkActive`, which
|
|
67
|
+
* marks the active item `selected` for screen readers, is listed too.
|
|
68
|
+
*/
|
|
69
|
+
@Component({
|
|
70
|
+
selector: 'ui-tab-bar-item',
|
|
71
|
+
imports: [LYNX_ELEMENTS],
|
|
72
|
+
encapsulation: ViewEncapsulation.None,
|
|
73
|
+
hostDirectives: [
|
|
74
|
+
{
|
|
75
|
+
directive: RouterLink,
|
|
76
|
+
// `route`, not `routerLink`: an app that imports RouterLink would
|
|
77
|
+
// otherwise put a second RouterLink on the item (its selector matches
|
|
78
|
+
// `routerLink`), and Angular rejects two of one directive on an element.
|
|
79
|
+
inputs: [
|
|
80
|
+
'routerLink: route',
|
|
81
|
+
'queryParams',
|
|
82
|
+
'queryParamsHandling',
|
|
83
|
+
'fragment',
|
|
84
|
+
'preserveFragment',
|
|
85
|
+
'state',
|
|
86
|
+
'info',
|
|
87
|
+
'relativeTo',
|
|
88
|
+
'replaceUrl',
|
|
89
|
+
'skipLocationChange',
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
{ directive: RouterLinkActive, inputs: ['routerLinkActiveOptions'] },
|
|
93
|
+
LynxRouterLinkActive,
|
|
94
|
+
],
|
|
95
|
+
host: {
|
|
96
|
+
// The host is the tap target and fills an equal share of the bar's row.
|
|
97
|
+
// `flex-1` has to be on the host, the row's real child: on an inner view
|
|
98
|
+
// it would share out the host's width instead (same lesson as
|
|
99
|
+
// ui-nav-drawer-content).
|
|
100
|
+
class: 'flex-1 flex-col flex',
|
|
101
|
+
// One accessibility element per tab, named by its label, so a screen
|
|
102
|
+
// reader reads "Inbox, 3, button" instead of an unlabeled SVG and a word.
|
|
103
|
+
// LynxRouterLinkActive adds `selected` to `button` while the route is
|
|
104
|
+
// active. The label is an attribute binding: a property binding on a
|
|
105
|
+
// component's host is checked against the DOM schema, which has no
|
|
106
|
+
// `accessibility-label`.
|
|
107
|
+
'accessibility-element': 'true',
|
|
108
|
+
'accessibility-traits': 'button',
|
|
109
|
+
'[attr.accessibility-label]': 'accessibilityLabel()',
|
|
110
|
+
// Press feedback binds touch and mouse events: native fires only touch,
|
|
111
|
+
// and the web preview, in a browser, fires only mouse. See pressDown().
|
|
112
|
+
'(bindtouchstart)': 'onPressStart()',
|
|
113
|
+
'(bindmousedown)': 'onPressStart()',
|
|
114
|
+
// A canceled touch (a scroll taking over) and a cursor leaving mid-press
|
|
115
|
+
// release too, or the tab would stay shrunk.
|
|
116
|
+
'(bindtouchend)': 'onPressEnd()',
|
|
117
|
+
'(bindtouchcancel)': 'onPressEnd()',
|
|
118
|
+
'(bindmouseup)': 'onPressEnd()',
|
|
119
|
+
'(bindmouseleave)': 'onPressEnd()',
|
|
120
|
+
// RouterLink navigates on the same tap through its own listener: Lynx
|
|
121
|
+
// keeps one handler per event, and the runtime runs both from it.
|
|
122
|
+
'(bindtap)': 'pressed.emit()',
|
|
123
|
+
},
|
|
124
|
+
template: `
|
|
125
|
+
<view [class]="contentClass()">
|
|
126
|
+
<view class="relative">
|
|
127
|
+
<svg [attr.content]="iconSvg()" style="width: 24px; height: 24px;" />
|
|
128
|
+
@if (badgeText()) {
|
|
129
|
+
<!-- Hangs off the icon's top-right corner, as on a native tab bar.
|
|
130
|
+
min-w-4 keeps a one-digit badge round; longer ones grow into
|
|
131
|
+
a pill. -->
|
|
132
|
+
<view
|
|
133
|
+
class="h-4 min-w-4 items-center rounded-full bg-destructive px-1 flex absolute -right-2.5 -top-1 justify-center"
|
|
134
|
+
>
|
|
135
|
+
<text
|
|
136
|
+
class="text-[10px] font-semibold leading-4 text-destructive-foreground"
|
|
137
|
+
>{{ badgeText() }}</text
|
|
138
|
+
>
|
|
139
|
+
</view>
|
|
140
|
+
}
|
|
141
|
+
</view>
|
|
142
|
+
<text [class]="labelClass()">{{ label() }}</text>
|
|
143
|
+
</view>
|
|
144
|
+
`,
|
|
145
|
+
})
|
|
146
|
+
export class UiTabBarItem {
|
|
147
|
+
readonly icon = input.required<IconName>();
|
|
148
|
+
readonly label = input.required<string>();
|
|
149
|
+
/**
|
|
150
|
+
* A count or short text over the icon's corner, such as unread messages.
|
|
151
|
+
* Nothing shows for `0`, an empty string, `null` or `undefined`, so a count
|
|
152
|
+
* can be bound as it is.
|
|
153
|
+
*/
|
|
154
|
+
readonly badge = input<string | number | null | undefined>(undefined);
|
|
155
|
+
readonly userClass = input<string>('', { alias: 'class' });
|
|
156
|
+
|
|
157
|
+
/** Emits on every tap, including a tap on the tab that's already active. */
|
|
158
|
+
readonly pressed = output<void>();
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether the item's route is active, from the RouterLinkActive on the host.
|
|
162
|
+
* RouterLinkActive reports only changes, and starts from not active.
|
|
163
|
+
*/
|
|
164
|
+
protected readonly active = toSignal(
|
|
165
|
+
inject(RouterLinkActive).isActiveChange,
|
|
166
|
+
{ initialValue: false },
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// The host, not an inner view, shrinks on a press: it's the whole tap
|
|
170
|
+
// target, and injecting it needs no view query.
|
|
171
|
+
readonly #host = inject(ElementRef);
|
|
172
|
+
#pressAnim?: AnimationHandle;
|
|
173
|
+
|
|
174
|
+
protected readonly badgeText = computed(() => {
|
|
175
|
+
const text = String(this.badge() ?? '');
|
|
176
|
+
return text === '0' ? '' : text;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
protected readonly accessibilityLabel = computed(() =>
|
|
180
|
+
this.badgeText() ? `${this.label()}, ${this.badgeText()}` : this.label(),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
readonly #resolveColor = injectIconColor();
|
|
184
|
+
|
|
185
|
+
// The active tab takes the theme's primary color and the others its muted
|
|
186
|
+
// foreground, like the tint of a native tab bar. They're solid tokens, not
|
|
187
|
+
// an opacity modifier, which a theme copied before the `--x-rgb` channels
|
|
188
|
+
// can't draw.
|
|
189
|
+
//
|
|
190
|
+
// The icon is drawn here, as the spinner and pagination draw theirs, rather
|
|
191
|
+
// than by a nested <ui-icon>: each tab stays a single component, with no
|
|
192
|
+
// child whose inputs it has to keep in step. An SVG can't read the label's
|
|
193
|
+
// text class (Lynx doesn't pass `color` down, and the markup is parsed
|
|
194
|
+
// apart from the page's CSS), so the token is resolved to its value and
|
|
195
|
+
// written into the markup (see icon-theme.ts). split/join, not
|
|
196
|
+
// replaceAll(), which PrimJS's ES2019 runtime lacks (see UiIcon).
|
|
197
|
+
protected readonly iconSvg = computed(() =>
|
|
198
|
+
ICONS[this.icon()]
|
|
199
|
+
.split('currentColor')
|
|
200
|
+
.join(
|
|
201
|
+
this.#resolveColor(
|
|
202
|
+
this.active() ? 'var(--primary)' : 'var(--muted-foreground)',
|
|
203
|
+
),
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
protected readonly labelClass = computed(() =>
|
|
208
|
+
cn(
|
|
209
|
+
'text-xs font-medium',
|
|
210
|
+
this.active() ? 'text-primary' : 'text-muted-foreground',
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
protected readonly contentClass = computed(() =>
|
|
215
|
+
cn(
|
|
216
|
+
'flex-1 flex-col items-center gap-1 px-1 pb-1.5 pt-2 flex justify-center',
|
|
217
|
+
this.userClass(),
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
protected onPressStart(): void {
|
|
222
|
+
this.#pressAnim?.cancel();
|
|
223
|
+
this.#pressAnim = pressDown(this.#host.nativeElement);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
protected onPressEnd(): void {
|
|
227
|
+
this.#pressAnim?.cancel();
|
|
228
|
+
this.#pressAnim = pressRelease(this.#host.nativeElement);
|
|
229
|
+
}
|
|
230
|
+
}
|