@blotch/dolan 22.2.2 → 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.
Files changed (259) hide show
  1. package/dist/commands/add.d.ts +6 -2
  2. package/dist/commands/add.js +41 -11
  3. package/dist/commands/add.js.map +1 -1
  4. package/dist/commands/add.test.js +162 -9
  5. package/dist/commands/add.test.js.map +1 -1
  6. package/dist/commands/diff.d.ts +2 -1
  7. package/dist/commands/diff.js +38 -25
  8. package/dist/commands/diff.js.map +1 -1
  9. package/dist/commands/diff.test.js +72 -11
  10. package/dist/commands/diff.test.js.map +1 -1
  11. package/dist/commands/doctor.js +228 -34
  12. package/dist/commands/doctor.js.map +1 -1
  13. package/dist/commands/doctor.test.js +367 -10
  14. package/dist/commands/doctor.test.js.map +1 -1
  15. package/dist/commands/eject.js +7 -0
  16. package/dist/commands/eject.js.map +1 -1
  17. package/dist/commands/eject.test.js +39 -0
  18. package/dist/commands/eject.test.js.map +1 -1
  19. package/dist/commands/info.js +3 -2
  20. package/dist/commands/info.js.map +1 -1
  21. package/dist/commands/init.d.ts +37 -1
  22. package/dist/commands/init.js +169 -40
  23. package/dist/commands/init.js.map +1 -1
  24. package/dist/commands/init.test.js +392 -27
  25. package/dist/commands/init.test.js.map +1 -1
  26. package/dist/commands/install-packages.d.ts +6 -0
  27. package/dist/commands/install-packages.js +20 -0
  28. package/dist/commands/install-packages.js.map +1 -0
  29. package/dist/commands/install-packages.test.d.ts +1 -0
  30. package/dist/commands/install-packages.test.js +55 -0
  31. package/dist/commands/install-packages.test.js.map +1 -0
  32. package/dist/commands/list.js +21 -14
  33. package/dist/commands/list.js.map +1 -1
  34. package/dist/commands/list.test.js +62 -5
  35. package/dist/commands/list.test.js.map +1 -1
  36. package/dist/commands/outdated.js +11 -10
  37. package/dist/commands/outdated.js.map +1 -1
  38. package/dist/commands/outdated.test.js +20 -5
  39. package/dist/commands/outdated.test.js.map +1 -1
  40. package/dist/commands/remove.d.ts +4 -0
  41. package/dist/commands/remove.js +17 -4
  42. package/dist/commands/remove.js.map +1 -1
  43. package/dist/commands/remove.test.js +53 -2
  44. package/dist/commands/remove.test.js.map +1 -1
  45. package/dist/commands/theme.js +1 -1
  46. package/dist/commands/theme.js.map +1 -1
  47. package/dist/commands/update.d.ts +11 -0
  48. package/dist/commands/update.js +174 -58
  49. package/dist/commands/update.js.map +1 -1
  50. package/dist/commands/update.test.js +587 -33
  51. package/dist/commands/update.test.js.map +1 -1
  52. package/dist/index.js +7 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/lockfile.d.ts +7 -0
  55. package/dist/lockfile.js +36 -0
  56. package/dist/lockfile.js.map +1 -1
  57. package/dist/lockfile.test.d.ts +1 -0
  58. package/dist/lockfile.test.js +25 -0
  59. package/dist/lockfile.test.js.map +1 -0
  60. package/dist/registry.d.ts +14 -0
  61. package/dist/registry.js +78 -24
  62. package/dist/registry.js.map +1 -1
  63. package/dist/registry.test.js +119 -38
  64. package/dist/registry.test.js.map +1 -1
  65. package/dist/test-utils.d.ts +5 -0
  66. package/dist/test-utils.js +6 -1
  67. package/dist/test-utils.js.map +1 -1
  68. package/dist/testing/fire-lynx-event.d.ts +14 -0
  69. package/dist/testing/fire-lynx-event.js +19 -0
  70. package/dist/testing/fire-lynx-event.js.map +1 -0
  71. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  72. package/dist/testing/fire-lynx-event.test.js +44 -0
  73. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  74. package/dist/testing/lynx-main-thread.d.ts +22 -0
  75. package/dist/testing/lynx-main-thread.js +11 -0
  76. package/dist/testing/lynx-main-thread.js.map +1 -0
  77. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  78. package/dist/testing/lynx-main-thread.test.js +24 -0
  79. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  80. package/dist/testing/set-input-signal.d.ts +14 -0
  81. package/dist/testing/set-input-signal.js +21 -0
  82. package/dist/testing/set-input-signal.js.map +1 -0
  83. package/dist/testing/set-input-signal.test.d.ts +1 -0
  84. package/dist/testing/set-input-signal.test.js +20 -0
  85. package/dist/testing/set-input-signal.test.js.map +1 -0
  86. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  87. package/dist/ui/components/accordion/accordion.ts +19 -14
  88. package/dist/ui/components/action-sheet/action-sheet.test.ts +283 -151
  89. package/dist/ui/components/action-sheet/action-sheet.ts +77 -43
  90. package/dist/ui/components/alert/alert.test.ts +67 -19
  91. package/dist/ui/components/alert/alert.ts +14 -16
  92. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +208 -94
  93. package/dist/ui/components/alert-dialog/alert-dialog.ts +75 -38
  94. package/dist/ui/components/area-chart/area-chart.test.ts +134 -119
  95. package/dist/ui/components/area-chart/area-chart.ts +100 -315
  96. package/dist/ui/components/area-chart/index.ts +2 -3
  97. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  98. package/dist/ui/components/badge/badge.test.ts +57 -23
  99. package/dist/ui/components/badge/badge.ts +12 -14
  100. package/dist/ui/components/bar-chart/bar-chart.test.ts +172 -45
  101. package/dist/ui/components/bar-chart/bar-chart.ts +140 -294
  102. package/dist/ui/components/bar-chart/index.ts +3 -3
  103. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +386 -128
  104. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +170 -53
  105. package/dist/ui/components/button/button.test.ts +611 -2
  106. package/dist/ui/components/button/button.ts +434 -49
  107. package/dist/ui/components/button/index.ts +2 -0
  108. package/dist/ui/components/button-group/button-group.test.ts +484 -2
  109. package/dist/ui/components/button-group/button-group.ts +380 -68
  110. package/dist/ui/components/button-group/index.ts +3 -0
  111. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +183 -58
  112. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +150 -323
  113. package/dist/ui/components/candlestick-chart/index.ts +3 -4
  114. package/dist/ui/components/card/card.test.ts +82 -62
  115. package/dist/ui/components/chart/chart-focus.test.ts +132 -0
  116. package/dist/ui/components/chart/chart-focus.ts +115 -0
  117. package/dist/ui/components/chart/chart-presets.test.ts +335 -0
  118. package/dist/ui/components/chart/chart-presets.ts +278 -0
  119. package/dist/ui/components/chart/chart-scene.test.ts +589 -0
  120. package/dist/ui/components/chart/chart-scene.ts +483 -0
  121. package/dist/ui/components/chart/chart-theme.test.ts +85 -0
  122. package/dist/ui/components/chart/chart-theme.ts +210 -0
  123. package/dist/ui/components/chart/chart-zoom.test.ts +159 -0
  124. package/dist/ui/components/chart/chart-zoom.ts +162 -0
  125. package/dist/ui/components/chart/chart.test.ts +1017 -0
  126. package/dist/ui/components/chart/chart.ts +934 -0
  127. package/dist/ui/components/chart/index.ts +24 -0
  128. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  129. package/dist/ui/components/checkbox/checkbox.ts +23 -23
  130. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  131. package/dist/ui/components/collapsible/collapsible.ts +19 -14
  132. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  133. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  134. package/dist/ui/components/context-menu/index.ts +1 -0
  135. package/dist/ui/components/dialog/dialog.test.ts +213 -108
  136. package/dist/ui/components/dialog/dialog.ts +72 -35
  137. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  138. package/dist/ui/components/dock/dock-scroll.ts +133 -0
  139. package/dist/ui/components/dock/dock.test.ts +749 -0
  140. package/dist/ui/components/dock/dock.ts +960 -0
  141. package/dist/ui/components/dock/index.ts +2 -0
  142. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  143. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  144. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  145. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  146. package/dist/ui/components/glass/glass-design.ts +119 -0
  147. package/dist/ui/components/glass/glass.test.ts +338 -0
  148. package/dist/ui/components/glass/glass.ts +360 -0
  149. package/dist/ui/components/glass/index.ts +18 -0
  150. package/dist/ui/components/icon/icon-theme.test.ts +169 -0
  151. package/dist/ui/components/icon/icon-theme.ts +227 -0
  152. package/dist/ui/components/icon/icon.test.ts +85 -25
  153. package/dist/ui/components/icon/icon.ts +38 -21
  154. package/dist/ui/components/input/input.test.ts +178 -101
  155. package/dist/ui/components/input/input.ts +15 -12
  156. package/dist/ui/components/label/label.test.ts +11 -24
  157. package/dist/ui/components/line-chart/index.ts +2 -3
  158. package/dist/ui/components/line-chart/line-chart.test.ts +149 -56
  159. package/dist/ui/components/line-chart/line-chart.ts +83 -241
  160. package/dist/ui/components/list/list.test.ts +119 -21
  161. package/dist/ui/components/list/list.ts +21 -15
  162. package/dist/ui/components/menu/index.ts +25 -0
  163. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  164. package/dist/ui/components/menu/menu-placement.ts +159 -0
  165. package/dist/ui/components/menu/menu-popup.test.ts +632 -0
  166. package/dist/ui/components/menu/menu-popup.ts +544 -0
  167. package/dist/ui/components/menu/menu.test.ts +429 -0
  168. package/dist/ui/components/menu/menu.ts +415 -0
  169. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +435 -191
  170. package/dist/ui/components/nav-drawer/nav-drawer.ts +127 -56
  171. package/dist/ui/components/pagination/pagination.test.ts +177 -65
  172. package/dist/ui/components/pagination/pagination.ts +55 -8
  173. package/dist/ui/components/progress/progress.test.ts +102 -46
  174. package/dist/ui/components/progress/progress.ts +52 -54
  175. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  176. package/dist/ui/components/radio-group/radio-group.ts +24 -24
  177. package/dist/ui/components/scatter-chart/index.ts +2 -3
  178. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +131 -26
  179. package/dist/ui/components/scatter-chart/scatter-chart.ts +104 -250
  180. package/dist/ui/components/scroll-area/scroll-area.test.ts +95 -24
  181. package/dist/ui/components/scroll-area/scroll-area.ts +8 -8
  182. package/dist/ui/components/select/select.test.ts +138 -78
  183. package/dist/ui/components/select/select.ts +17 -11
  184. package/dist/ui/components/separator/separator.test.ts +18 -17
  185. package/dist/ui/components/sheet/sheet.test.ts +64 -11
  186. package/dist/ui/components/sheet/sheet.ts +9 -1
  187. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  188. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  189. package/dist/ui/components/spinner/spinner.test.ts +67 -22
  190. package/dist/ui/components/spinner/spinner.ts +48 -41
  191. package/dist/ui/components/switch/switch.test.ts +116 -46
  192. package/dist/ui/components/switch/switch.ts +8 -13
  193. package/dist/ui/components/tab-bar/index.ts +1 -0
  194. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  195. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  196. package/dist/ui/components/tabs/tabs.test.ts +215 -157
  197. package/dist/ui/components/tabs/tabs.ts +110 -118
  198. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  199. package/dist/ui/components/textarea/textarea.ts +14 -12
  200. package/dist/ui/components/toast/toast.test.ts +436 -315
  201. package/dist/ui/components/toast/toast.ts +62 -55
  202. package/dist/ui/components/toggle/toggle.test.ts +247 -140
  203. package/dist/ui/theme/chart-colors.test.ts +35 -0
  204. package/dist/ui/theme/dark.css +46 -2
  205. package/dist/ui/theme/default.css +63 -7
  206. package/dist/ui/theme/icon-colors.test.ts +35 -0
  207. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  208. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  209. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  210. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  211. package/dist/ui/theme/tailwind-plugin.ts +98 -25
  212. package/dist/ui/theme/tailwind.css +113 -0
  213. package/dist/ui/utils/animate.test.ts +13 -1
  214. package/dist/utils/analyze.d.ts +14 -0
  215. package/dist/utils/analyze.js +15 -0
  216. package/dist/utils/analyze.js.map +1 -1
  217. package/dist/utils/analyze.test.js +14 -1
  218. package/dist/utils/analyze.test.js.map +1 -1
  219. package/dist/utils/installed.d.ts +29 -0
  220. package/dist/utils/installed.js +43 -0
  221. package/dist/utils/installed.js.map +1 -0
  222. package/dist/utils/installed.test.d.ts +1 -0
  223. package/dist/utils/installed.test.js +57 -0
  224. package/dist/utils/installed.test.js.map +1 -0
  225. package/dist/utils/interactive.d.ts +9 -0
  226. package/dist/utils/interactive.js +10 -0
  227. package/dist/utils/interactive.js.map +1 -0
  228. package/dist/utils/interactive.test.d.ts +1 -0
  229. package/dist/utils/interactive.test.js +24 -0
  230. package/dist/utils/interactive.test.js.map +1 -0
  231. package/dist/utils/packages.d.ts +36 -0
  232. package/dist/utils/packages.js +70 -0
  233. package/dist/utils/packages.js.map +1 -0
  234. package/dist/utils/packages.test.d.ts +1 -0
  235. package/dist/utils/packages.test.js +89 -0
  236. package/dist/utils/packages.test.js.map +1 -0
  237. package/dist/utils/resolve-paths.d.ts +17 -1
  238. package/dist/utils/resolve-paths.js +21 -3
  239. package/dist/utils/resolve-paths.js.map +1 -1
  240. package/dist/utils/resolve-paths.test.js +38 -2
  241. package/dist/utils/resolve-paths.test.js.map +1 -1
  242. package/dist/utils/tailwind.d.ts +25 -0
  243. package/dist/utils/tailwind.js +51 -0
  244. package/dist/utils/tailwind.js.map +1 -0
  245. package/dist/utils/tailwind.test.d.ts +1 -0
  246. package/dist/utils/tailwind.test.js +71 -0
  247. package/dist/utils/tailwind.test.js.map +1 -0
  248. package/package.json +13 -1
  249. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -121
  250. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -174
  251. package/dist/ui/components/button/button-render.test.ts +0 -113
  252. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  253. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -174
  254. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +0 -188
  255. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +0 -525
  256. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +0 -1140
  257. package/dist/ui/components/cartesian-chart/index.ts +0 -18
  258. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -121
  259. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -157
@@ -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
+ }