@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.
Files changed (185) hide show
  1. package/dist/commands/add.js +9 -1
  2. package/dist/commands/add.js.map +1 -1
  3. package/dist/commands/add.test.js +36 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +187 -7
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +280 -7
  8. package/dist/commands/doctor.test.js.map +1 -1
  9. package/dist/commands/init.d.ts +2 -2
  10. package/dist/commands/init.js +38 -18
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/init.test.js +107 -1
  13. package/dist/commands/init.test.js.map +1 -1
  14. package/dist/commands/theme.js +1 -1
  15. package/dist/commands/theme.js.map +1 -1
  16. package/dist/commands/update.d.ts +5 -0
  17. package/dist/commands/update.js +126 -45
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +314 -2
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +7 -0
  22. package/dist/lockfile.js +36 -0
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +25 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +48 -2
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +45 -3
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +5 -0
  33. package/dist/test-utils.js +6 -1
  34. package/dist/test-utils.js.map +1 -1
  35. package/dist/testing/fire-lynx-event.d.ts +14 -0
  36. package/dist/testing/fire-lynx-event.js +19 -0
  37. package/dist/testing/fire-lynx-event.js.map +1 -0
  38. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  39. package/dist/testing/fire-lynx-event.test.js +44 -0
  40. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  41. package/dist/testing/lynx-main-thread.d.ts +22 -0
  42. package/dist/testing/lynx-main-thread.js +11 -0
  43. package/dist/testing/lynx-main-thread.js.map +1 -0
  44. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  45. package/dist/testing/lynx-main-thread.test.js +24 -0
  46. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  47. package/dist/testing/set-input-signal.d.ts +5 -4
  48. package/dist/testing/set-input-signal.js +5 -4
  49. package/dist/testing/set-input-signal.js.map +1 -1
  50. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  51. package/dist/ui/components/accordion/accordion.ts +19 -14
  52. package/dist/ui/components/action-sheet/action-sheet.test.ts +272 -187
  53. package/dist/ui/components/action-sheet/action-sheet.ts +40 -34
  54. package/dist/ui/components/alert/alert.test.ts +67 -19
  55. package/dist/ui/components/alert/alert.ts +14 -16
  56. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +206 -139
  57. package/dist/ui/components/alert-dialog/alert-dialog.ts +32 -26
  58. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  59. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  60. package/dist/ui/components/badge/badge.test.ts +57 -23
  61. package/dist/ui/components/badge/badge.ts +12 -14
  62. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  63. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +373 -297
  64. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +34 -33
  65. package/dist/ui/components/button/button.test.ts +611 -2
  66. package/dist/ui/components/button/button.ts +431 -62
  67. package/dist/ui/components/button/index.ts +2 -0
  68. package/dist/ui/components/button-group/button-group.test.ts +484 -2
  69. package/dist/ui/components/button-group/button-group.ts +380 -68
  70. package/dist/ui/components/button-group/index.ts +3 -0
  71. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  72. package/dist/ui/components/card/card.test.ts +82 -62
  73. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  74. package/dist/ui/components/chart/chart-presets.ts +8 -1
  75. package/dist/ui/components/chart/chart-theme.ts +12 -0
  76. package/dist/ui/components/chart/chart.test.ts +1017 -0
  77. package/dist/ui/components/chart/chart.ts +56 -43
  78. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  79. package/dist/ui/components/checkbox/checkbox.ts +23 -23
  80. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  81. package/dist/ui/components/collapsible/collapsible.ts +19 -14
  82. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  83. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  84. package/dist/ui/components/context-menu/index.ts +1 -0
  85. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  86. package/dist/ui/components/dialog/dialog.ts +29 -23
  87. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  88. package/dist/ui/components/dock/dock-scroll.ts +133 -0
  89. package/dist/ui/components/dock/dock.test.ts +749 -0
  90. package/dist/ui/components/dock/dock.ts +960 -0
  91. package/dist/ui/components/dock/index.ts +2 -0
  92. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  93. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  94. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  95. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  96. package/dist/ui/components/glass/glass-design.ts +119 -0
  97. package/dist/ui/components/glass/glass.test.ts +338 -0
  98. package/dist/ui/components/glass/glass.ts +360 -0
  99. package/dist/ui/components/glass/index.ts +18 -0
  100. package/dist/ui/components/icon/icon-theme.test.ts +36 -0
  101. package/dist/ui/components/icon/icon-theme.ts +36 -13
  102. package/dist/ui/components/icon/icon.test.ts +52 -41
  103. package/dist/ui/components/input/input.test.ts +178 -101
  104. package/dist/ui/components/input/input.ts +15 -12
  105. package/dist/ui/components/label/label.test.ts +11 -24
  106. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  107. package/dist/ui/components/list/list.test.ts +80 -49
  108. package/dist/ui/components/menu/index.ts +25 -0
  109. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  110. package/dist/ui/components/menu/menu-placement.ts +159 -0
  111. package/dist/ui/components/menu/menu-popup.test.ts +632 -0
  112. package/dist/ui/components/menu/menu-popup.ts +544 -0
  113. package/dist/ui/components/menu/menu.test.ts +429 -0
  114. package/dist/ui/components/menu/menu.ts +415 -0
  115. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  116. package/dist/ui/components/nav-drawer/nav-drawer.ts +72 -56
  117. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  118. package/dist/ui/components/progress/progress.test.ts +102 -46
  119. package/dist/ui/components/progress/progress.ts +52 -54
  120. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  121. package/dist/ui/components/radio-group/radio-group.ts +24 -24
  122. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  123. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  124. package/dist/ui/components/select/select.test.ts +138 -78
  125. package/dist/ui/components/select/select.ts +17 -11
  126. package/dist/ui/components/separator/separator.test.ts +18 -17
  127. package/dist/ui/components/sheet/sheet.test.ts +64 -11
  128. package/dist/ui/components/sheet/sheet.ts +9 -1
  129. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  130. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  131. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  132. package/dist/ui/components/spinner/spinner.ts +16 -19
  133. package/dist/ui/components/switch/switch.test.ts +116 -46
  134. package/dist/ui/components/switch/switch.ts +8 -13
  135. package/dist/ui/components/tab-bar/index.ts +1 -0
  136. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  137. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  138. package/dist/ui/components/tabs/tabs.test.ts +215 -157
  139. package/dist/ui/components/tabs/tabs.ts +110 -118
  140. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  141. package/dist/ui/components/textarea/textarea.ts +14 -12
  142. package/dist/ui/components/toast/toast.test.ts +434 -350
  143. package/dist/ui/components/toast/toast.ts +42 -51
  144. package/dist/ui/components/toggle/toggle.test.ts +247 -140
  145. package/dist/ui/theme/chart-colors.test.ts +8 -3
  146. package/dist/ui/theme/dark.css +38 -2
  147. package/dist/ui/theme/default.css +55 -7
  148. package/dist/ui/theme/icon-colors.test.ts +8 -3
  149. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  150. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  151. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  152. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  153. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  154. package/dist/ui/theme/tailwind.css +113 -0
  155. package/dist/ui/utils/animate.test.ts +13 -1
  156. package/dist/utils/analyze.d.ts +14 -0
  157. package/dist/utils/analyze.js +15 -0
  158. package/dist/utils/analyze.js.map +1 -1
  159. package/dist/utils/analyze.test.js +14 -1
  160. package/dist/utils/analyze.test.js.map +1 -1
  161. package/dist/utils/installed.d.ts +12 -0
  162. package/dist/utils/installed.js +16 -0
  163. package/dist/utils/installed.js.map +1 -1
  164. package/dist/utils/installed.test.js +17 -1
  165. package/dist/utils/installed.test.js.map +1 -1
  166. package/dist/utils/resolve-paths.d.ts +4 -4
  167. package/dist/utils/resolve-paths.js +5 -7
  168. package/dist/utils/resolve-paths.js.map +1 -1
  169. package/dist/utils/resolve-paths.test.js +9 -5
  170. package/dist/utils/resolve-paths.test.js.map +1 -1
  171. package/dist/utils/tailwind.d.ts +25 -0
  172. package/dist/utils/tailwind.js +51 -0
  173. package/dist/utils/tailwind.js.map +1 -0
  174. package/dist/utils/tailwind.test.d.ts +1 -0
  175. package/dist/utils/tailwind.test.js +71 -0
  176. package/dist/utils/tailwind.test.js.map +1 -0
  177. package/package.json +9 -1
  178. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  179. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  180. package/dist/ui/components/button/button-render.test.ts +0 -132
  181. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  182. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  183. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  184. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  185. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -0,0 +1,960 @@
1
+ import {
2
+ Component,
3
+ Directive,
4
+ ElementRef,
5
+ ViewEncapsulation,
6
+ computed,
7
+ contentChild,
8
+ contentChildren,
9
+ effect,
10
+ inject,
11
+ input,
12
+ output,
13
+ signal,
14
+ untracked,
15
+ viewChild,
16
+ } from '@angular/core';
17
+ import { toSignal } from '@angular/core/rxjs-interop';
18
+ import { RouterLink, RouterLinkActive } from '@angular/router';
19
+ import {
20
+ LYNX_ELEMENTS,
21
+ LynxAccessibilitySettings,
22
+ LynxRouterLinkActive,
23
+ } from '@blotch/angular-lynx';
24
+
25
+ import {
26
+ type AnimationHandle,
27
+ DURATION,
28
+ EASING,
29
+ fadeIn,
30
+ pressDown,
31
+ pressRelease,
32
+ } from '../../utils/animate';
33
+ import { cn } from '../../utils/cn';
34
+ import { type GlassShape, UiGlass, UiGlassGroup } from '../glass/glass';
35
+ import { type GlassDesign, injectGlassDesign } from '../glass/glass-design';
36
+ import { injectIconColor, UI_ICON_COLOR } from '../icon/icon-theme';
37
+ import { type IconName, ICONS } from '../icon/icons';
38
+ import { UiDockState } from './dock-scroll';
39
+
40
+ /** The dock's sizes and shapes in one design. Sizes are in px. */
41
+ type DockLook = {
42
+ /** The bar's height. */
43
+ readonly bar: number;
44
+ /** The action button's width and height. */
45
+ readonly action: number;
46
+ /** The accessory's height. */
47
+ readonly accessory: number;
48
+ /** Between the bar and the action, and between the accessory and the bar. */
49
+ readonly gap: number;
50
+ /** The bar's padding around its items. */
51
+ readonly inset: number;
52
+ readonly barShape: GlassShape;
53
+ readonly actionShape: GlassShape;
54
+ readonly accessoryShape: GlassShape;
55
+ /** The corner radius of the shapes that are `'rounded'`. */
56
+ readonly radius: number;
57
+ };
58
+
59
+ /**
60
+ * iOS 26's floating tab bar: a 62pt capsule, a search button as tall as it
61
+ * beside it, and a 48pt accessory capsule above.
62
+ */
63
+ const GLASS_LOOK: DockLook = {
64
+ bar: 62,
65
+ action: 62,
66
+ accessory: 48,
67
+ gap: 10,
68
+ inset: 4,
69
+ barShape: 'capsule',
70
+ actionShape: 'circle',
71
+ accessoryShape: 'capsule',
72
+ radius: 0,
73
+ };
74
+
75
+ /**
76
+ * Material 3 Expressive's floating toolbar (64dp, fully rounded) with a
77
+ * floating action button beside it (56dp, 16dp corners).
78
+ */
79
+ const MATERIAL_LOOK: DockLook = {
80
+ bar: 64,
81
+ action: 56,
82
+ accessory: 56,
83
+ gap: 12,
84
+ inset: 4,
85
+ barShape: 'capsule',
86
+ actionShape: 'rounded',
87
+ accessoryShape: 'rounded',
88
+ radius: 16,
89
+ };
90
+
91
+ /**
92
+ * iOS's tab bar before Liquid Glass: 49pt over the home indicator, edge to
93
+ * edge, with the action as its last button, and the accessory floating above
94
+ * it with rounded corners.
95
+ */
96
+ const FROSTED_LOOK: DockLook = {
97
+ bar: 49,
98
+ action: 64,
99
+ accessory: 48,
100
+ gap: 8,
101
+ inset: 0,
102
+ barShape: 'rounded',
103
+ actionShape: 'rounded',
104
+ accessoryShape: 'rounded',
105
+ radius: 0,
106
+ };
107
+
108
+ const LOOKS: Readonly<Record<GlassDesign, DockLook>> = {
109
+ glass: GLASS_LOOK,
110
+ material: MATERIAL_LOOK,
111
+ frosted: FROSTED_LOOK,
112
+ };
113
+
114
+ /** The frosted accessory's corner radius and inset from the screen edges. */
115
+ const FROSTED_ACCESSORY_RADIUS = 12;
116
+ const FROSTED_ACCESSORY_INSET = 8;
117
+
118
+ /**
119
+ * The glass bar and action minimized: circles a little smaller than the bar,
120
+ * as iOS shrinks its tab bar to the selected tab's icon.
121
+ */
122
+ const MINIMIZED_SIZE = 48;
123
+
124
+ /**
125
+ * How far Material's toolbar slides down when the dock minimizes, beyond its
126
+ * own height: past the dock's margin and the home indicator.
127
+ */
128
+ const MATERIAL_HIDDEN_OFFSET = 64;
129
+
130
+ /**
131
+ * Glass shapes this close merge, in px: just under the gap between them, so
132
+ * the bar and the action stay apart at rest, and reach for each other as the
133
+ * accessory moves between them.
134
+ */
135
+ const GLASS_SPACING = 8;
136
+
137
+ /**
138
+ * How the shapes move as the dock minimizes and expands, on the curve iOS's
139
+ * sheets use: quick, then settling gently.
140
+ */
141
+ const FRAME_TRANSITION = [
142
+ 'left',
143
+ 'right',
144
+ 'bottom',
145
+ 'width',
146
+ 'height',
147
+ 'transform',
148
+ ]
149
+ .map((property) => `${property} ${DURATION.slow + 50}ms ${EASING.sheet}`)
150
+ .join(', ');
151
+
152
+ /** The selection capsule's slide, with a slight overshoot, and its fade. */
153
+ const CAPSULE_TRANSITION = `transform ${DURATION.slow}ms ${EASING.springSubtle}, opacity ${DURATION.normal}ms ${EASING.standard}`;
154
+
155
+ /**
156
+ * The items fade out quickly as the bar shrinks, and back in once it has
157
+ * mostly grown, so they don't squeeze into the narrow bar.
158
+ */
159
+ const ROW_OUT_TRANSITION = `opacity ${DURATION.fast}ms ${EASING.accelerate}`;
160
+ const ROW_IN_TRANSITION = `opacity ${DURATION.normal}ms ${EASING.decelerate} ${DURATION.fast}ms`;
161
+
162
+ /** How strongly the selection capsule shades the glass. */
163
+ const CAPSULE_OPACITY = 0.1;
164
+
165
+ /**
166
+ * The color of icons in the accessory: the glass's foreground, as its text
167
+ * reads `text-glass-foreground`. The accessory's content is the app's, which
168
+ * the dock's glass doesn't provide for, since content injects from where it's
169
+ * written, inside `<ui-dock>`, not from where it's projected.
170
+ */
171
+ const ACCESSORY_ICON_COLOR = signal('var(--glass-foreground)').asReadonly();
172
+
173
+ /** What Lynx's `layoutchange` event carries, as far as the dock reads it. */
174
+ type LayoutChangeEvent = Event & { detail: { width: number } };
175
+
176
+ /**
177
+ * A CSS length in px.
178
+ */
179
+ const px = (value: number): string => `${value}px`;
180
+
181
+ /**
182
+ * `value` px above the home indicator. The frosted bar runs to the screen's
183
+ * bottom edge and pads itself by the inset (`pb-safe`), so what sits above
184
+ * it counts the inset too.
185
+ */
186
+ const abovePx = (value: number): string =>
187
+ `calc(${value}px + env(safe-area-inset-bottom))`;
188
+
189
+ /**
190
+ * An icon's markup, drawn in `color`.
191
+ */
192
+ const iconSvg = (icon: IconName, color: string): string =>
193
+ // split/join, not replaceAll(), which PrimJS's ES2019 runtime lacks.
194
+ ICONS[icon].split('currentColor').join(color);
195
+
196
+ /**
197
+ * `UiDockItem`, `UiDockAction` and `UiDockAccessory`, for the dock's content
198
+ * queries. Calls rather than the classes themselves: for a class, Angular's
199
+ * test build keeps an arrow function that nothing calls, which an app's
200
+ * coverage report would show as uncovered.
201
+ */
202
+ const itemType = () => UiDockItem;
203
+ const actionType = () => UiDockAction;
204
+ const accessoryType = () => UiDockAccessory;
205
+
206
+ /**
207
+ * Marks the content of the dock's accessory: a bar above the dock for
208
+ * something that stays at hand on every screen, such as what's playing. The
209
+ * dock draws the glass around it.
210
+ *
211
+ * ```html
212
+ * <ui-dock>
213
+ * <view ui-dock-accessory class="flex-row items-center gap-3 px-4">
214
+ * <text class="text-glass-foreground">Now playing</text>
215
+ * </view>
216
+ * …
217
+ * </ui-dock>
218
+ * ```
219
+ *
220
+ * When the glass dock minimizes, the accessory moves down into the row,
221
+ * between the bar and the action, at the minimized bar's height: read the
222
+ * dock's `minimized()` to show less there.
223
+ */
224
+ @Directive({
225
+ selector: '[ui-dock-accessory]',
226
+ // It fills the accessory's glass.
227
+ host: { class: 'flex-1' },
228
+ })
229
+ export class UiDockAccessory {}
230
+
231
+ /**
232
+ * The bar of an app's top-level screens, floating over the content at the
233
+ * bottom of the screen: the Liquid Glass form of the tab bar. Put a
234
+ * `<ui-dock-item>` in it for each screen, and optionally a `<ui-dock-action>`
235
+ * (a separate button, such as search or compose) and a `ui-dock-accessory`
236
+ * (such as what's playing).
237
+ *
238
+ * ```html
239
+ * <view class="h-full">
240
+ * <router-outlet />
241
+ * <ui-dock>
242
+ * <ui-dock-item route="/home" icon="home" label="Home" />
243
+ * <ui-dock-item route="/library" icon="music" label="Library" />
244
+ * <ui-dock-action route="/search" icon="search" label="Search" />
245
+ * </ui-dock>
246
+ * </view>
247
+ * ```
248
+ *
249
+ * It places itself at the bottom of its parent, over the content, which
250
+ * scrolls beneath it. Its form follows the platform (see GlassDesign):
251
+ *
252
+ * - `'glass'` (iOS 26 and later, the web, Android with the glass opt-in): a
253
+ * floating capsule whose selected item is marked by a capsule that slides
254
+ * between the items. The action is a circle beside it, in the same glass
255
+ * group, and the accessory a capsule above. It minimizes as content
256
+ * scrolls down (see UiDockScroll): the bar shrinks to the selected item's
257
+ * icon, and the accessory moves down between it and the action.
258
+ * - `'frosted'` (iOS 15 to 25): the tab bar of that time, edge to edge on a
259
+ * blur material, with the action as its last button. It doesn't minimize.
260
+ * - `'material'` (Android): a Material 3 Expressive floating toolbar, with an
261
+ * active indicator behind the selected item's icon, and the action as a
262
+ * floating action button. Minimizing slides the toolbar away and leaves
263
+ * the button.
264
+ */
265
+ @Component({
266
+ selector: 'ui-dock',
267
+ imports: [LYNX_ELEMENTS, UiGlass, UiGlassGroup],
268
+ encapsulation: ViewEncapsulation.None,
269
+ providers: [{ provide: UI_ICON_COLOR, useValue: ACCESSORY_ICON_COLOR }],
270
+ host: { '[class]': 'hostClass()' },
271
+ // The shapes are direct children of the glass group, so they merge on iOS
272
+ // and the web. Each one keeps its element as the dock minimizes and
273
+ // expands: its frame moves and resizes with a CSS transition, which iOS's
274
+ // glass follows frame by frame, where a shape added or removed would pop.
275
+ //
276
+ // `tabbar` is UIKit's tab bar trait, the one a native tab bar's container
277
+ // has: it tells VoiceOver that the buttons in it are tabs. Android reads a
278
+ // single trait and has none for tab bars, so there it's a plain row.
279
+ //
280
+ // The dock measures its width with an empty view rather than on the
281
+ // group: Lynx for Web sends no layoutchange for a blur-view.
282
+ template: `
283
+ <view class="h-0 w-full" (bindlayoutchange)="measure($event)"></view>
284
+ <blur-view
285
+ ui-glass-group
286
+ class="relative"
287
+ [spacing]="spacing"
288
+ [style]="stageStyle()"
289
+ >
290
+ @if (accessory()) {
291
+ <blur-view
292
+ ui-glass
293
+ interactive
294
+ class="flex-row flex absolute overflow-hidden"
295
+ [shape]="look().accessoryShape"
296
+ [radius]="accessoryRadius()"
297
+ [style]="frames().accessory"
298
+ >
299
+ <ng-content select="[ui-dock-accessory]" />
300
+ </blur-view>
301
+ }
302
+ <blur-view
303
+ ui-glass
304
+ interactive
305
+ [class]="barClass()"
306
+ [shape]="look().barShape"
307
+ [radius]="look().radius"
308
+ [style]="frames().bar"
309
+ >
310
+ @if (design() === 'frosted') {
311
+ <view
312
+ class="h-[0.5px] bg-border absolute left-0 right-0 top-0"
313
+ ></view>
314
+ }
315
+ <view
316
+ accessibility-traits="tabbar"
317
+ [class]="rowClass()"
318
+ [style]="rowStyle()"
319
+ [attr.accessibility-elements-hidden]="minimized() || null"
320
+ >
321
+ <view
322
+ class="rounded-full bg-glass-foreground absolute bottom-0 left-0 top-0"
323
+ [style]="capsuleStyle()"
324
+ ></view>
325
+ <ng-content />
326
+ </view>
327
+ @if (minimized()) {
328
+ <!-- Minimized, the bar is one button that brings the dock back,
329
+ showing the selected item's icon. -->
330
+ <view
331
+ #restore
332
+ class="items-center flex absolute bottom-0 left-0 right-0 top-0 justify-center"
333
+ accessibility-element="true"
334
+ accessibility-traits="button"
335
+ [attr.accessibility-label]="expandLabel()"
336
+ [attr.accessibility-value]="selectedItem()?.label()"
337
+ (bindtap)="expand()"
338
+ >
339
+ <svg
340
+ [attr.content]="minimizedIcon()"
341
+ style="width: 24px; height: 24px;"
342
+ />
343
+ </view>
344
+ }
345
+ </blur-view>
346
+ @if (action()) {
347
+ <blur-view
348
+ ui-glass
349
+ interactive
350
+ [class]="actionClass()"
351
+ [shape]="look().actionShape"
352
+ [radius]="look().radius"
353
+ [tint]="actionTint()"
354
+ [style]="frames().action"
355
+ >
356
+ @if (design() === 'frosted') {
357
+ <view
358
+ class="h-[0.5px] bg-border absolute left-0 right-0 top-0"
359
+ ></view>
360
+ }
361
+ <ng-content select="ui-dock-action" />
362
+ </blur-view>
363
+ }
364
+ </blur-view>
365
+ `,
366
+ })
367
+ export class UiDock {
368
+ /**
369
+ * What a screen reader says for the minimized dock, a button that brings
370
+ * it back. The selected item's label follows it.
371
+ */
372
+ readonly expandLabel = input('Show tab bar');
373
+ readonly userClass = input<string>('', { alias: 'class' });
374
+
375
+ readonly #state = inject(UiDockState);
376
+ readonly #settings = inject(LynxAccessibilitySettings);
377
+ readonly #resolveColor = injectIconColor();
378
+
379
+ /** The design on this platform (see GlassDesign), which the items follow. */
380
+ readonly design = injectGlassDesign();
381
+
382
+ protected readonly look = computed(() => LOOKS[this.design()]);
383
+ protected readonly spacing = GLASS_SPACING;
384
+
385
+ protected readonly items = contentChildren(itemType());
386
+ protected readonly action = contentChild(actionType());
387
+ protected readonly accessory = contentChild(accessoryType());
388
+ protected readonly restore = viewChild<ElementRef>('restore');
389
+
390
+ /**
391
+ * The dock's width, from Lynx's layout. The glass bar shrinks from it when
392
+ * the dock minimizes.
393
+ */
394
+ protected readonly width = signal(0);
395
+
396
+ /** The item whose route is active, if any. */
397
+ readonly selectedItem = computed(() =>
398
+ this.items().find((item) => item.active()),
399
+ );
400
+
401
+ /**
402
+ * Whether the dock is minimized now: as UiDockState says, in the designs
403
+ * that minimize, once the dock knows the width it shrinks from.
404
+ */
405
+ readonly minimized = computed(
406
+ () =>
407
+ this.#state.minimized() &&
408
+ this.design() !== 'frosted' &&
409
+ this.width() > 0,
410
+ );
411
+
412
+ /** No sliding, shrinking or fading under Reduce Motion. */
413
+ readonly #animate = computed(() => !this.#settings.reduceMotion());
414
+
415
+ protected readonly hostClass = computed(() =>
416
+ cn(
417
+ 'flex-col flex absolute bottom-0 left-0 right-0',
418
+ this.design() !== 'frosted' && 'px-4 pb-safe-2',
419
+ this.userClass(),
420
+ ),
421
+ );
422
+
423
+ protected readonly accessoryRadius = computed(() =>
424
+ this.design() === 'frosted' ? FROSTED_ACCESSORY_RADIUS : this.look().radius,
425
+ );
426
+
427
+ /**
428
+ * Each shape's frame in the dock, bottom up, and the dock's height. The
429
+ * shapes are positioned absolutely, so a minimized dock can move each one
430
+ * on its own: the accessory from above the bar into the row beside it.
431
+ */
432
+ protected readonly frames = computed(() => {
433
+ const look = this.look();
434
+ const design = this.design();
435
+ const hasAction = !!this.action();
436
+ const minimized = this.minimized();
437
+ const transition = this.#animate() ? FRAME_TRANSITION : 'none';
438
+ const actionSpace = hasAction ? look.action + look.gap : 0;
439
+
440
+ if (design === 'frosted') {
441
+ const above = look.bar + look.gap;
442
+ return {
443
+ stage: {
444
+ height: abovePx(this.accessory() ? above + look.accessory : look.bar),
445
+ },
446
+ bar: {
447
+ left: '0px',
448
+ right: px(hasAction ? look.action : 0),
449
+ bottom: '0px',
450
+ height: abovePx(look.bar),
451
+ },
452
+ action: {
453
+ right: '0px',
454
+ bottom: '0px',
455
+ width: px(look.action),
456
+ height: abovePx(look.bar),
457
+ },
458
+ accessory: {
459
+ left: px(FROSTED_ACCESSORY_INSET),
460
+ right: px(FROSTED_ACCESSORY_INSET),
461
+ bottom: abovePx(above),
462
+ height: px(look.accessory),
463
+ },
464
+ };
465
+ }
466
+
467
+ if (design === 'glass' && minimized) {
468
+ const size = MINIMIZED_SIZE;
469
+ const besideAction = hasAction ? size + look.gap : 0;
470
+ return {
471
+ stage: { height: px(size), transition },
472
+ bar: {
473
+ left: '0px',
474
+ right: px(this.width() - size),
475
+ bottom: '0px',
476
+ height: px(size),
477
+ transition,
478
+ },
479
+ action: {
480
+ right: '0px',
481
+ bottom: '0px',
482
+ width: px(size),
483
+ height: px(size),
484
+ transition,
485
+ },
486
+ accessory: {
487
+ left: px(size + look.gap),
488
+ right: px(besideAction),
489
+ bottom: '0px',
490
+ height: px(size),
491
+ transition,
492
+ },
493
+ };
494
+ }
495
+
496
+ // Glass or Material, expanded; or Material minimized, which slides the
497
+ // toolbar and the accessory below the screen and keeps the button.
498
+ const hidden = design === 'material' && minimized;
499
+ const slide = (by: number) =>
500
+ design === 'material' ? `translateY(${hidden ? by : 0}px)` : null;
501
+ const withAccessory = this.accessory()
502
+ ? look.bar + look.gap + look.accessory
503
+ : look.bar;
504
+ return {
505
+ stage: { height: px(withAccessory), transition },
506
+ bar: {
507
+ left: '0px',
508
+ right: px(actionSpace),
509
+ bottom: '0px',
510
+ height: px(look.bar),
511
+ transform: slide(look.bar + MATERIAL_HIDDEN_OFFSET),
512
+ transition,
513
+ },
514
+ action: {
515
+ right: '0px',
516
+ // Material's button is shorter than the toolbar, and centered on it.
517
+ bottom: px((look.bar - look.action) / 2),
518
+ width: px(look.action),
519
+ height: px(look.action),
520
+ transition,
521
+ },
522
+ accessory: {
523
+ left: '0px',
524
+ right: '0px',
525
+ bottom: px(look.bar + look.gap),
526
+ height: px(look.accessory),
527
+ transform: slide(withAccessory + MATERIAL_HIDDEN_OFFSET),
528
+ transition,
529
+ },
530
+ };
531
+ });
532
+
533
+ protected readonly stageStyle = computed(() => this.frames().stage);
534
+
535
+ protected readonly barClass = computed(() =>
536
+ cn(
537
+ 'flex-col flex absolute',
538
+ this.design() === 'frosted' ? 'pb-safe' : 'overflow-hidden',
539
+ ),
540
+ );
541
+
542
+ protected readonly actionClass = computed(() =>
543
+ cn(
544
+ 'flex-col flex absolute overflow-hidden',
545
+ this.design() === 'frosted' && 'pb-safe',
546
+ ),
547
+ );
548
+
549
+ /** Material's floating action button is filled with the primary color. */
550
+ protected readonly actionTint = computed(() =>
551
+ this.design() === 'material' ? 'var(--primary)' : undefined,
552
+ );
553
+
554
+ protected readonly rowClass = computed(() =>
555
+ cn(
556
+ 'flex-row flex relative',
557
+ this.design() === 'frosted' ? 'h-[49px]' : 'flex-1',
558
+ ),
559
+ );
560
+
561
+ /**
562
+ * The row sits inside the bar's padding. Once the dock knows its width,
563
+ * the floating bar's row keeps the expanded width, so the items don't
564
+ * squeeze together as the bar shrinks and grows: the bar clips them
565
+ * instead, and they fade out as it shrinks and back in as it grows.
566
+ */
567
+ protected readonly rowStyle = computed(() => {
568
+ const look = this.look();
569
+ const minimized = this.minimized();
570
+ const action = this.action() ? look.action + look.gap : 0;
571
+ const floating = this.design() !== 'frosted' && this.width() > 0;
572
+ return {
573
+ margin: px(look.inset),
574
+ width: floating ? px(this.width() - action - 2 * look.inset) : null,
575
+ opacity: minimized ? '0' : '1',
576
+ transition: this.#animate()
577
+ ? minimized
578
+ ? ROW_OUT_TRANSITION
579
+ : ROW_IN_TRANSITION
580
+ : 'none',
581
+ };
582
+ });
583
+
584
+ /**
585
+ * The capsule behind the selected item, on glass: one element that slides
586
+ * from item to item. It's a shade of the glass's foreground rather than
587
+ * glass of its own, which would be glass on glass, and it's how iOS marks
588
+ * the selected tab while nobody is touching the bar.
589
+ *
590
+ * The items share the row equally, so it's one item wide, and
591
+ * `translateX(n * 100%)` moves it n items along, whatever the row's width.
592
+ * It fades out while no item is selected, and in the other designs, which
593
+ * mark the selection their own way.
594
+ */
595
+ protected readonly capsuleStyle = computed(() => {
596
+ const count = Math.max(1, this.items().length);
597
+ const selected = this.selectedItem();
598
+ const index = selected ? this.items().indexOf(selected) : 0;
599
+ const shown = !!selected && this.design() === 'glass';
600
+ return {
601
+ width: `${100 / count}%`,
602
+ transform: `translateX(${index * 100}%)`,
603
+ opacity: String(shown ? CAPSULE_OPACITY : 0),
604
+ transition: this.#animate() ? CAPSULE_TRANSITION : 'none',
605
+ };
606
+ });
607
+
608
+ /** The selected item's icon, or the first item's, for the minimized bar. */
609
+ protected readonly minimizedIcon = computed(() => {
610
+ const item = this.selectedItem() ?? this.items()[0];
611
+ return item
612
+ ? iconSvg(item.icon(), this.#resolveColor('var(--primary)'))
613
+ : '';
614
+ });
615
+
616
+ // The minimized bar's icon fades in as the bar shrinks around it.
617
+ protected readonly restoreEntrance = effect(() => {
618
+ const restore = this.restore();
619
+ if (restore && untracked(this.#animate)) {
620
+ fadeIn(restore.nativeElement, { duration: DURATION.slow });
621
+ }
622
+ });
623
+
624
+ protected measure(event: Event): void {
625
+ this.width.set((event as LayoutChangeEvent).detail.width);
626
+ }
627
+
628
+ /**
629
+ * Brings a minimized dock back.
630
+ */
631
+ expand(): void {
632
+ this.#state.minimized.set(false);
633
+ }
634
+ }
635
+
636
+ /**
637
+ * One of the dock's screens: an icon over a label that navigates to its
638
+ * `route` on a tap.
639
+ *
640
+ * The host itself is the link. Angular's own `RouterLink` and
641
+ * `RouterLinkActive` sit on it as host directives, so every `RouterLink`
642
+ * option (`queryParams`, `replaceUrl`, `state`, …) and
643
+ * `routerLinkActiveOptions` work as they do on the web, and the item knows
644
+ * whether its route is active without the app wiring `routerLinkActive` up.
645
+ * Host directives don't match selectors, so `LynxRouterLinkActive`, which
646
+ * marks the active item `selected` for screen readers, is listed too.
647
+ */
648
+ @Component({
649
+ selector: 'ui-dock-item',
650
+ imports: [LYNX_ELEMENTS],
651
+ encapsulation: ViewEncapsulation.None,
652
+ hostDirectives: [
653
+ {
654
+ directive: RouterLink,
655
+ // `route`, not `routerLink`: an app that imports RouterLink would
656
+ // otherwise put a second RouterLink on the item (its selector matches
657
+ // `routerLink`), and Angular rejects two of one directive on an element.
658
+ inputs: [
659
+ 'routerLink: route',
660
+ 'queryParams',
661
+ 'queryParamsHandling',
662
+ 'fragment',
663
+ 'preserveFragment',
664
+ 'state',
665
+ 'info',
666
+ 'relativeTo',
667
+ 'replaceUrl',
668
+ 'skipLocationChange',
669
+ ],
670
+ },
671
+ { directive: RouterLinkActive, inputs: ['routerLinkActiveOptions'] },
672
+ LynxRouterLinkActive,
673
+ ],
674
+ host: {
675
+ // The host is the tap target and takes an equal share of the bar's row,
676
+ // which the selection capsule's width counts on.
677
+ class: 'flex-1 flex-col flex',
678
+ // One accessibility element per item, named by its label, so a screen
679
+ // reader reads "Inbox, 3, button" instead of an unlabeled SVG and a word.
680
+ // LynxRouterLinkActive adds `selected` to `button` while the route is
681
+ // active. The label is an attribute binding: a property binding on a
682
+ // component's host is checked against the DOM schema, which has no
683
+ // `accessibility-label`.
684
+ 'accessibility-element': 'true',
685
+ 'accessibility-traits': 'button',
686
+ '[attr.accessibility-label]': 'accessibilityLabel()',
687
+ // Press feedback binds touch and mouse events: native fires only touch,
688
+ // and the web preview, in a browser, fires only mouse. See pressDown().
689
+ '(bindtouchstart)': 'onPressStart()',
690
+ '(bindmousedown)': 'onPressStart()',
691
+ // A canceled touch (a scroll taking over) and a cursor leaving mid-press
692
+ // release too, or the item would stay shrunk.
693
+ '(bindtouchend)': 'onPressEnd()',
694
+ '(bindtouchcancel)': 'onPressEnd()',
695
+ '(bindmouseup)': 'onPressEnd()',
696
+ '(bindmouseleave)': 'onPressEnd()',
697
+ // RouterLink navigates on the same tap through its own listener.
698
+ '(bindtap)': 'pressed.emit()',
699
+ },
700
+ template: `
701
+ <view [class]="contentClass()">
702
+ <view [class]="iconFrameClass()">
703
+ @if (design() === 'material') {
704
+ <!-- Material's active indicator: a pill behind the icon that
705
+ widens from its center as the item is selected. -->
706
+ <view
707
+ class="h-8 w-14 rounded-full bg-primary absolute left-0 top-0"
708
+ [style]="indicatorStyle()"
709
+ ></view>
710
+ }
711
+ <view class="relative">
712
+ <svg [attr.content]="iconSvg()" style="width: 24px; height: 24px;" />
713
+ @if (badgeText()) {
714
+ <!-- Hangs off the icon's top-right corner, as on a native tab
715
+ bar. min-w-4 keeps a one-digit badge round; longer ones
716
+ grow into a pill. -->
717
+ <view
718
+ class="h-4 min-w-4 items-center rounded-full bg-destructive px-1 flex absolute -right-2.5 -top-1 justify-center"
719
+ >
720
+ <text
721
+ class="text-[10px] font-semibold leading-4 text-destructive-foreground"
722
+ >{{ badgeText() }}</text
723
+ >
724
+ </view>
725
+ }
726
+ </view>
727
+ </view>
728
+ <text [class]="labelClass()">{{ label() }}</text>
729
+ </view>
730
+ `,
731
+ })
732
+ export class UiDockItem {
733
+ readonly icon = input.required<IconName>();
734
+ readonly label = input.required<string>();
735
+ /**
736
+ * A count or short text over the icon's corner, such as unread messages.
737
+ * Nothing shows for `0`, an empty string, `null` or `undefined`, so a count
738
+ * can be bound as it is.
739
+ */
740
+ readonly badge = input<string | number | null | undefined>(undefined);
741
+ readonly userClass = input<string>('', { alias: 'class' });
742
+
743
+ /** Emits on every tap, including a tap on the item that's already active. */
744
+ readonly pressed = output<void>();
745
+
746
+ readonly #dock = inject(UiDock);
747
+ readonly #settings = inject(LynxAccessibilitySettings);
748
+ readonly #resolveColor = injectIconColor();
749
+ // The host, not an inner view, shrinks on a press: it's the whole tap
750
+ // target, and injecting it needs no view query.
751
+ readonly #host = inject(ElementRef);
752
+ #pressAnim?: AnimationHandle;
753
+
754
+ protected readonly design = this.#dock.design;
755
+
756
+ /**
757
+ * Whether the item's route is active, from the RouterLinkActive on the
758
+ * host. RouterLinkActive reports only changes, and starts from not active.
759
+ */
760
+ readonly active = toSignal(inject(RouterLinkActive).isActiveChange, {
761
+ initialValue: false,
762
+ });
763
+
764
+ protected readonly badgeText = computed(() => {
765
+ const text = String(this.badge() ?? '');
766
+ return text === '0' ? '' : text;
767
+ });
768
+
769
+ protected readonly accessibilityLabel = computed(() =>
770
+ this.badgeText() ? `${this.label()}, ${this.badgeText()}` : this.label(),
771
+ );
772
+
773
+ /**
774
+ * The colors of the selected item and the others, as theme tokens. On
775
+ * glass, the selected item takes the primary color over the capsule and
776
+ * the others the glass's foreground. Before iOS 26, the others were gray.
777
+ * On Material, the selected icon sits on the primary indicator.
778
+ */
779
+ readonly #colors = computed(() => {
780
+ const active = this.active();
781
+ switch (this.design()) {
782
+ case 'glass':
783
+ return active
784
+ ? { icon: 'var(--primary)', label: 'text-primary' }
785
+ : { icon: 'var(--glass-foreground)', label: 'text-glass-foreground' };
786
+ case 'material':
787
+ return active
788
+ ? {
789
+ icon: 'var(--primary-foreground)',
790
+ label: 'font-semibold text-foreground',
791
+ }
792
+ : { icon: 'var(--muted-foreground)', label: 'text-muted-foreground' };
793
+ default:
794
+ return active
795
+ ? { icon: 'var(--primary)', label: 'text-primary' }
796
+ : { icon: 'var(--muted-foreground)', label: 'text-muted-foreground' };
797
+ }
798
+ });
799
+
800
+ // The icon is drawn here, as the tab bar draws its own, rather than by a
801
+ // nested <ui-icon>: an SVG can't read the label's text class (Lynx doesn't
802
+ // pass `color` down, and the markup is parsed apart from the page's CSS),
803
+ // so the token is resolved to its value and written into the markup (see
804
+ // icon-theme.ts).
805
+ protected readonly iconSvg = computed(() =>
806
+ iconSvg(this.icon(), this.#resolveColor(this.#colors().icon)),
807
+ );
808
+
809
+ protected readonly labelClass = computed(() =>
810
+ cn(
811
+ this.design() === 'material'
812
+ ? 'text-xs font-medium'
813
+ : 'text-[10px] font-medium',
814
+ this.#colors().label,
815
+ ),
816
+ );
817
+
818
+ protected readonly iconFrameClass = computed(() =>
819
+ this.design() === 'material'
820
+ ? 'h-8 w-14 items-center flex relative justify-center'
821
+ : 'relative',
822
+ );
823
+
824
+ /**
825
+ * Material's indicator shows at once on the selected item, so its icon is
826
+ * never light on the toolbar, and widens from the center there. It leaves
827
+ * the item it moves from at once too.
828
+ */
829
+ protected readonly indicatorStyle = computed(() => {
830
+ const active = this.active();
831
+ return {
832
+ opacity: active ? '1' : '0',
833
+ transform: active ? 'scaleX(1)' : 'scaleX(0.5)',
834
+ transition:
835
+ active && !this.#settings.reduceMotion()
836
+ ? `transform ${DURATION.normal}ms ${EASING.decelerate}`
837
+ : 'none',
838
+ };
839
+ });
840
+
841
+ protected readonly contentClass = computed(() =>
842
+ cn(
843
+ 'flex-1 flex-col items-center flex justify-center',
844
+ this.design() === 'material' ? 'gap-1' : 'gap-0.5',
845
+ this.userClass(),
846
+ ),
847
+ );
848
+
849
+ protected onPressStart(): void {
850
+ this.#pressAnim?.cancel();
851
+ this.#pressAnim = pressDown(this.#host.nativeElement);
852
+ }
853
+
854
+ protected onPressEnd(): void {
855
+ this.#pressAnim?.cancel();
856
+ this.#pressAnim = pressRelease(this.#host.nativeElement);
857
+ }
858
+ }
859
+
860
+ /**
861
+ * The dock's separate button, for the action people reach for from every
862
+ * screen, such as search or compose. It sits beside the bar: on glass, a
863
+ * circle in the dock's glass group, which merges with the bar's glass as the
864
+ * two come close; on Material, a floating action button; before iOS 26, the
865
+ * bar's last button.
866
+ *
867
+ * It navigates to its `route` like an item, and is selected while the route
868
+ * is active, or emits `pressed` for an action of your own without one:
869
+ *
870
+ * ```html
871
+ * <ui-dock-action route="/search" icon="search" label="Search" />
872
+ * <ui-dock-action icon="plus" label="New message" (pressed)="compose()" />
873
+ * ```
874
+ */
875
+ @Component({
876
+ selector: 'ui-dock-action',
877
+ imports: [LYNX_ELEMENTS],
878
+ encapsulation: ViewEncapsulation.None,
879
+ hostDirectives: [
880
+ {
881
+ directive: RouterLink,
882
+ inputs: [
883
+ 'routerLink: route',
884
+ 'queryParams',
885
+ 'queryParamsHandling',
886
+ 'fragment',
887
+ 'preserveFragment',
888
+ 'state',
889
+ 'info',
890
+ 'relativeTo',
891
+ 'replaceUrl',
892
+ 'skipLocationChange',
893
+ ],
894
+ },
895
+ { directive: RouterLinkActive, inputs: ['routerLinkActiveOptions'] },
896
+ LynxRouterLinkActive,
897
+ ],
898
+ host: {
899
+ // It fills the action's glass, which is the button's shape.
900
+ class: 'flex-1 items-center flex justify-center',
901
+ 'accessibility-element': 'true',
902
+ 'accessibility-traits': 'button',
903
+ '[attr.accessibility-label]': 'label()',
904
+ '(bindtouchstart)': 'onPressStart()',
905
+ '(bindmousedown)': 'onPressStart()',
906
+ '(bindtouchend)': 'onPressEnd()',
907
+ '(bindtouchcancel)': 'onPressEnd()',
908
+ '(bindmouseup)': 'onPressEnd()',
909
+ '(bindmouseleave)': 'onPressEnd()',
910
+ '(bindtap)': 'pressed.emit()',
911
+ },
912
+ template: `<svg
913
+ [attr.content]="iconSvg()"
914
+ style="width: 24px; height: 24px;"
915
+ />`,
916
+ })
917
+ export class UiDockAction {
918
+ readonly icon = input.required<IconName>();
919
+ /** What the button does, for screen readers: it shows only its icon. */
920
+ readonly label = input.required<string>();
921
+
922
+ /** Emits on every tap. */
923
+ readonly pressed = output<void>();
924
+
925
+ readonly #dock = inject(UiDock);
926
+ readonly #resolveColor = injectIconColor();
927
+ readonly #host = inject(ElementRef);
928
+ #pressAnim?: AnimationHandle;
929
+
930
+ /** Whether the button's route is active, as for UiDockItem.active. */
931
+ readonly active = toSignal(inject(RouterLinkActive).isActiveChange, {
932
+ initialValue: false,
933
+ });
934
+
935
+ /**
936
+ * On glass, the icon takes the glass's foreground, and the primary color
937
+ * while its route is active, as a selected item does. Material's button is
938
+ * filled with the primary color; before iOS 26, a bar button was tinted.
939
+ */
940
+ protected readonly iconSvg = computed(() => {
941
+ const design = this.#dock.design();
942
+ const color =
943
+ design === 'material'
944
+ ? 'var(--primary-foreground)'
945
+ : design === 'glass' && !this.active()
946
+ ? 'var(--glass-foreground)'
947
+ : 'var(--primary)';
948
+ return iconSvg(this.icon(), this.#resolveColor(color));
949
+ });
950
+
951
+ protected onPressStart(): void {
952
+ this.#pressAnim?.cancel();
953
+ this.#pressAnim = pressDown(this.#host.nativeElement);
954
+ }
955
+
956
+ protected onPressEnd(): void {
957
+ this.#pressAnim?.cancel();
958
+ this.#pressAnim = pressRelease(this.#host.nativeElement);
959
+ }
960
+ }