@blotch/dolan 22.2.3 → 22.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) 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 +38 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +258 -21
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +425 -11
  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 +105 -2
  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 +7 -0
  17. package/dist/commands/update.js +194 -50
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +570 -3
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +17 -0
  22. package/dist/lockfile.js +56 -1
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +65 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +119 -11
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +61 -6
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +27 -0
  33. package/dist/test-utils.js +45 -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/boolean-inputs.test.ts +76 -0
  51. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  52. package/dist/ui/components/accordion/accordion.ts +19 -14
  53. package/dist/ui/components/action-sheet/action-sheet.test.ts +637 -183
  54. package/dist/ui/components/action-sheet/action-sheet.ts +339 -55
  55. package/dist/ui/components/action-sheet/index.ts +1 -0
  56. package/dist/ui/components/alert/alert.test.ts +67 -19
  57. package/dist/ui/components/alert/alert.ts +14 -16
  58. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +740 -129
  59. package/dist/ui/components/alert-dialog/alert-dialog.ts +672 -58
  60. package/dist/ui/components/alert-dialog/index.ts +5 -0
  61. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  62. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  63. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  64. package/dist/ui/components/badge/badge.test.ts +57 -23
  65. package/dist/ui/components/badge/badge.ts +14 -15
  66. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  67. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +1314 -273
  68. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1046 -119
  69. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  70. package/dist/ui/components/button/button.test.ts +646 -2
  71. package/dist/ui/components/button/button.ts +443 -64
  72. package/dist/ui/components/button/index.ts +2 -0
  73. package/dist/ui/components/button-group/button-group.test.ts +528 -2
  74. package/dist/ui/components/button-group/button-group.ts +428 -70
  75. package/dist/ui/components/button-group/index.ts +3 -0
  76. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  77. package/dist/ui/components/card/card.test.ts +97 -61
  78. package/dist/ui/components/card/card.ts +2 -1
  79. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  80. package/dist/ui/components/chart/chart-presets.ts +14 -7
  81. package/dist/ui/components/chart/chart-theme.ts +12 -0
  82. package/dist/ui/components/chart/chart.test.ts +1031 -0
  83. package/dist/ui/components/chart/chart.ts +66 -46
  84. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  85. package/dist/ui/components/checkbox/checkbox.ts +25 -24
  86. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  87. package/dist/ui/components/collapsible/collapsible.ts +21 -15
  88. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  89. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  90. package/dist/ui/components/context-menu/index.ts +1 -0
  91. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  92. package/dist/ui/components/dialog/dialog.ts +35 -29
  93. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  94. package/dist/ui/components/dock/dock-scroll.ts +142 -0
  95. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  96. package/dist/ui/components/dock/dock.test.ts +752 -0
  97. package/dist/ui/components/dock/dock.ts +1438 -0
  98. package/dist/ui/components/dock/index.ts +2 -0
  99. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  100. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  101. package/dist/ui/components/dynamic-color/index.ts +5 -0
  102. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  103. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  104. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  105. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  106. package/dist/ui/components/glass/glass-design.ts +119 -0
  107. package/dist/ui/components/glass/glass.test.ts +376 -0
  108. package/dist/ui/components/glass/glass.ts +430 -0
  109. package/dist/ui/components/glass/index.ts +19 -0
  110. package/dist/ui/components/icon/icon-theme.test.ts +64 -0
  111. package/dist/ui/components/icon/icon-theme.ts +70 -16
  112. package/dist/ui/components/icon/icon.test.ts +52 -41
  113. package/dist/ui/components/input/input.test.ts +178 -101
  114. package/dist/ui/components/input/input.ts +17 -13
  115. package/dist/ui/components/label/label.test.ts +11 -24
  116. package/dist/ui/components/label/label.ts +8 -2
  117. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  118. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  119. package/dist/ui/components/list/list.test.ts +80 -49
  120. package/dist/ui/components/list/list.ts +5 -4
  121. package/dist/ui/components/menu/index.ts +25 -0
  122. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  123. package/dist/ui/components/menu/menu-placement.ts +159 -0
  124. package/dist/ui/components/menu/menu-popup.test.ts +636 -0
  125. package/dist/ui/components/menu/menu-popup.ts +565 -0
  126. package/dist/ui/components/menu/menu.test.ts +523 -0
  127. package/dist/ui/components/menu/menu.ts +455 -0
  128. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  129. package/dist/ui/components/nav-drawer/nav-drawer.ts +75 -58
  130. package/dist/ui/components/navigation-bar/index.ts +1 -0
  131. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  132. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  133. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  134. package/dist/ui/components/popover/index.ts +9 -0
  135. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  136. package/dist/ui/components/popover/popover-panel.ts +603 -0
  137. package/dist/ui/components/popover/popover.test.ts +286 -0
  138. package/dist/ui/components/popover/popover.ts +207 -0
  139. package/dist/ui/components/progress/progress.test.ts +102 -46
  140. package/dist/ui/components/progress/progress.ts +54 -55
  141. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  142. package/dist/ui/components/radio-group/radio-group.ts +26 -25
  143. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  144. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  145. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  146. package/dist/ui/components/search-field/index.ts +15 -0
  147. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  148. package/dist/ui/components/search-field/keyboard.ts +123 -0
  149. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  150. package/dist/ui/components/search-field/search-field.ts +628 -0
  151. package/dist/ui/components/segmented-control/index.ts +8 -0
  152. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  153. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  154. package/dist/ui/components/select/select.test.ts +138 -78
  155. package/dist/ui/components/select/select.ts +19 -12
  156. package/dist/ui/components/separator/separator.test.ts +18 -17
  157. package/dist/ui/components/sheet/sheet.test.ts +151 -11
  158. package/dist/ui/components/sheet/sheet.ts +68 -5
  159. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  160. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  161. package/dist/ui/components/slider/index.ts +1 -0
  162. package/dist/ui/components/slider/slider.test.ts +695 -0
  163. package/dist/ui/components/slider/slider.ts +901 -0
  164. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  165. package/dist/ui/components/spinner/spinner.ts +16 -19
  166. package/dist/ui/components/switch/index.ts +1 -1
  167. package/dist/ui/components/switch/switch.test.ts +534 -48
  168. package/dist/ui/components/switch/switch.ts +613 -48
  169. package/dist/ui/components/tab-bar/index.ts +1 -0
  170. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  171. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  172. package/dist/ui/components/tabs/index.ts +7 -1
  173. package/dist/ui/components/tabs/tabs.test.ts +484 -150
  174. package/dist/ui/components/tabs/tabs.ts +344 -149
  175. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  176. package/dist/ui/components/textarea/textarea.ts +16 -13
  177. package/dist/ui/components/toast/toast.test.ts +434 -350
  178. package/dist/ui/components/toast/toast.ts +42 -51
  179. package/dist/ui/components/toggle/index.ts +1 -0
  180. package/dist/ui/components/toggle/toggle.test.ts +559 -146
  181. package/dist/ui/components/toggle/toggle.ts +241 -26
  182. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  183. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  184. package/dist/ui/components/toolbar/index.ts +12 -0
  185. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  186. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  187. package/dist/ui/theme/chart-colors.test.ts +8 -3
  188. package/dist/ui/theme/dark.css +38 -2
  189. package/dist/ui/theme/default.css +55 -7
  190. package/dist/ui/theme/icon-colors.test.ts +8 -3
  191. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  192. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  193. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  194. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  195. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  196. package/dist/ui/theme/tailwind.css +113 -0
  197. package/dist/ui/utils/animate.test.ts +13 -1
  198. package/dist/utils/analyze.d.ts +19 -0
  199. package/dist/utils/analyze.js +16 -0
  200. package/dist/utils/analyze.js.map +1 -1
  201. package/dist/utils/analyze.test.js +14 -1
  202. package/dist/utils/analyze.test.js.map +1 -1
  203. package/dist/utils/installed.d.ts +25 -1
  204. package/dist/utils/installed.js +33 -0
  205. package/dist/utils/installed.js.map +1 -1
  206. package/dist/utils/installed.test.js +55 -1
  207. package/dist/utils/installed.test.js.map +1 -1
  208. package/dist/utils/resolve-paths.d.ts +4 -4
  209. package/dist/utils/resolve-paths.js +5 -7
  210. package/dist/utils/resolve-paths.js.map +1 -1
  211. package/dist/utils/resolve-paths.test.js +9 -5
  212. package/dist/utils/resolve-paths.test.js.map +1 -1
  213. package/dist/utils/tailwind.d.ts +45 -0
  214. package/dist/utils/tailwind.js +90 -0
  215. package/dist/utils/tailwind.js.map +1 -0
  216. package/dist/utils/tailwind.test.d.ts +1 -0
  217. package/dist/utils/tailwind.test.js +134 -0
  218. package/dist/utils/tailwind.test.js.map +1 -0
  219. package/package.json +9 -1
  220. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  221. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  222. package/dist/ui/components/button/button-render.test.ts +0 -132
  223. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  224. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  225. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  226. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  227. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -0,0 +1,1438 @@
1
+ import {
2
+ Component,
3
+ Directive,
4
+ ElementRef,
5
+ Injector,
6
+ Renderer2,
7
+ ViewEncapsulation,
8
+ computed,
9
+ contentChild,
10
+ contentChildren,
11
+ effect,
12
+ forwardRef,
13
+ inject,
14
+ input,
15
+ output,
16
+ signal,
17
+ untracked,
18
+ viewChild,
19
+ } from '@angular/core';
20
+ import { toSignal } from '@angular/core/rxjs-interop';
21
+ import { RouterLink, RouterLinkActive } from '@angular/router';
22
+ import {
23
+ LYNX_ELEMENTS,
24
+ LynxAccessibilitySettings,
25
+ LynxRouterLinkActive,
26
+ } from '@blotch/angular-lynx';
27
+
28
+ import {
29
+ type AnimationHandle,
30
+ DURATION,
31
+ EASING,
32
+ fadeIn,
33
+ pressDown,
34
+ pressRelease,
35
+ } from '../../utils/animate';
36
+ import { cn } from '../../utils/cn';
37
+ import { type GlassShape, UiGlass, UiGlassGroup } from '../glass/glass';
38
+ import { type GlassDesign, injectGlassDesign } from '../glass/glass-design';
39
+ import { injectIconColor, UI_ICON_COLOR } from '../icon/icon-theme';
40
+ import { type IconName, ICONS } from '../icon/icons';
41
+ import {
42
+ KEYBOARD_GAP,
43
+ keyboardLift,
44
+ keyboardScreen,
45
+ measureForKeyboard,
46
+ } from '../search-field/keyboard';
47
+ import {
48
+ type SearchFieldContainer,
49
+ UI_SEARCH_FIELD_CONTAINER,
50
+ UiSearchField,
51
+ } from '../search-field/search-field';
52
+ import { UiDockState } from './dock-scroll';
53
+
54
+ /** The dock's sizes and shapes in one design. Sizes are in px. */
55
+ type DockLook = {
56
+ /** The bar's height. */
57
+ readonly bar: number;
58
+ /** The action button's width and height. */
59
+ readonly action: number;
60
+ /** The accessory's height. */
61
+ readonly accessory: number;
62
+ /** Between the bar and the action, and between the accessory and the bar. */
63
+ readonly gap: number;
64
+ /** The bar's padding around its items. */
65
+ readonly inset: number;
66
+ readonly barShape: GlassShape;
67
+ readonly actionShape: GlassShape;
68
+ readonly accessoryShape: GlassShape;
69
+ /** The corner radius of the shapes that are `'rounded'`. */
70
+ readonly radius: number;
71
+ };
72
+
73
+ /**
74
+ * iOS 26's floating tab bar: a 62pt capsule, a search button as tall as it
75
+ * beside it, and a 48pt accessory capsule above.
76
+ */
77
+ const GLASS_LOOK: DockLook = {
78
+ bar: 62,
79
+ action: 62,
80
+ accessory: 48,
81
+ gap: 10,
82
+ inset: 4,
83
+ barShape: 'capsule',
84
+ actionShape: 'circle',
85
+ accessoryShape: 'capsule',
86
+ radius: 0,
87
+ };
88
+
89
+ /**
90
+ * Material 3 Expressive's floating toolbar (64dp, fully rounded) with a
91
+ * floating action button beside it (56dp, 16dp corners).
92
+ */
93
+ const MATERIAL_LOOK: DockLook = {
94
+ bar: 64,
95
+ action: 56,
96
+ accessory: 56,
97
+ gap: 12,
98
+ inset: 4,
99
+ barShape: 'capsule',
100
+ actionShape: 'rounded',
101
+ accessoryShape: 'rounded',
102
+ radius: 16,
103
+ };
104
+
105
+ /**
106
+ * iOS's tab bar before Liquid Glass: 49pt over the home indicator, edge to
107
+ * edge, with the action as its last button, and the accessory floating above
108
+ * it with rounded corners.
109
+ */
110
+ const FROSTED_LOOK: DockLook = {
111
+ bar: 49,
112
+ action: 64,
113
+ accessory: 48,
114
+ gap: 8,
115
+ inset: 0,
116
+ barShape: 'rounded',
117
+ actionShape: 'rounded',
118
+ accessoryShape: 'rounded',
119
+ radius: 0,
120
+ };
121
+
122
+ const LOOKS: Readonly<Record<GlassDesign, DockLook>> = {
123
+ glass: GLASS_LOOK,
124
+ material: MATERIAL_LOOK,
125
+ frosted: FROSTED_LOOK,
126
+ };
127
+
128
+ /** The frosted accessory's corner radius and inset from the screen edges. */
129
+ const FROSTED_ACCESSORY_RADIUS = 12;
130
+ const FROSTED_ACCESSORY_INSET = 8;
131
+
132
+ /**
133
+ * The glass bar and action minimized: circles a little smaller than the bar,
134
+ * as iOS shrinks its tab bar to the selected tab's icon.
135
+ */
136
+ const MINIMIZED_SIZE = 48;
137
+
138
+ /**
139
+ * How far Material's toolbar slides down when the dock minimizes, beyond its
140
+ * own height: past the dock's margin and the home indicator.
141
+ */
142
+ const MATERIAL_HIDDEN_OFFSET = 64;
143
+
144
+ /**
145
+ * The search field's heights in the dock, in px: iOS 26's, as tall as the
146
+ * minimized bar beside it; Material 3's search bar; and the bar before
147
+ * iOS 26 that holds UISearchBar's 36pt field, 8pt in from its edges.
148
+ */
149
+ const GLASS_SEARCH_SIZE = MINIMIZED_SIZE;
150
+ const MATERIAL_SEARCH_HEIGHT = 56;
151
+ const FROSTED_SEARCH_HEIGHT = 52;
152
+
153
+ /**
154
+ * Glass shapes this close merge, in px: just under the gap between them, so
155
+ * the bar and the action stay apart at rest, and reach for each other as the
156
+ * accessory moves between them.
157
+ */
158
+ const GLASS_SPACING = 8;
159
+
160
+ /**
161
+ * How the shapes move as the dock minimizes and expands, on the curve iOS's
162
+ * sheets use: quick, then settling gently.
163
+ */
164
+ const FRAME_TRANSITION = [
165
+ 'left',
166
+ 'right',
167
+ 'bottom',
168
+ 'width',
169
+ 'height',
170
+ 'transform',
171
+ ]
172
+ .map((property) => `${property} ${DURATION.slow + 50}ms ${EASING.sheet}`)
173
+ .join(', ');
174
+
175
+ /**
176
+ * How the dock follows the keyboard up and back down. The keyboard's height
177
+ * arrives a little after it starts to move, so it's quicker than the frames.
178
+ */
179
+ const KEYBOARD_TRANSITION = `padding-bottom ${DURATION.normal}ms ${EASING.sheet}`;
180
+
181
+ /** The action's icon, fading as the action shrinks into the search field. */
182
+ const ICON_OUT_TRANSITION = `opacity ${DURATION.fast}ms ${EASING.accelerate}`;
183
+
184
+ /** The search field's content, fading in as its glass grows. */
185
+ const SEARCH_IN_TRANSITION = `opacity ${DURATION.normal}ms ${EASING.decelerate} ${DURATION.fast}ms`;
186
+
187
+ /** The selection capsule's slide, with a slight overshoot, and its fade. */
188
+ const CAPSULE_TRANSITION = `transform ${DURATION.slow}ms ${EASING.springSubtle}, opacity ${DURATION.normal}ms ${EASING.standard}`;
189
+
190
+ /**
191
+ * The items fade out quickly as the bar shrinks, and back in once it has
192
+ * mostly grown, so they don't squeeze into the narrow bar.
193
+ */
194
+ const ROW_OUT_TRANSITION = `opacity ${DURATION.fast}ms ${EASING.accelerate}`;
195
+ const ROW_IN_TRANSITION = `opacity ${DURATION.normal}ms ${EASING.decelerate} ${DURATION.fast}ms`;
196
+
197
+ /** How strongly the selection capsule shades the glass. */
198
+ const CAPSULE_OPACITY = 0.1;
199
+
200
+ /**
201
+ * The color of icons in the accessory: the glass's foreground, as its text
202
+ * reads `text-glass-foreground`. The accessory's content is the app's, which
203
+ * the dock's glass doesn't provide for, since content injects from where it's
204
+ * written, inside `<ui-dock>`, not from where it's projected.
205
+ */
206
+ const ACCESSORY_ICON_COLOR = signal('var(--glass-foreground)').asReadonly();
207
+
208
+ /** What Lynx's `layoutchange` event carries, as far as the dock reads it. */
209
+ type LayoutChangeEvent = Event & { detail: { width: number } };
210
+
211
+ /**
212
+ * A CSS length in px.
213
+ */
214
+ const px = (value: number): string => `${value}px`;
215
+
216
+ /**
217
+ * `value` px above the home indicator. The frosted bar runs to the screen's
218
+ * bottom edge and pads itself by the inset (`pb-safe`), so what sits above
219
+ * it counts the inset too.
220
+ */
221
+ const abovePx = (value: number): string =>
222
+ `calc(${value}px + env(safe-area-inset-bottom))`;
223
+
224
+ /**
225
+ * An icon's markup, drawn in `color`.
226
+ */
227
+ const iconSvg = (icon: IconName, color: string): string =>
228
+ // split/join, not replaceAll(), which PrimJS's ES2019 runtime lacks.
229
+ ICONS[icon].split('currentColor').join(color);
230
+
231
+ /**
232
+ * `UiDockItem`, `UiDockAction` and `UiDockAccessory`, for the dock's content
233
+ * queries. Calls rather than the classes themselves: for a class, Angular's
234
+ * test build keeps an arrow function that nothing calls, which an app's
235
+ * coverage report would show as uncovered.
236
+ */
237
+ const itemType = () => UiDockItem;
238
+ const actionType = () => UiDockAction;
239
+ const accessoryType = () => UiDockAccessory;
240
+ const searchFieldType = () => UiSearchField;
241
+ /**
242
+ * UiDock, for its own providers, which run before the class is defined.
243
+ */
244
+ const dockType = () => UiDock;
245
+
246
+ /**
247
+ * Marks the content of the dock's accessory: a bar above the dock for
248
+ * something that stays at hand on every screen, such as what's playing. The
249
+ * dock draws the glass around it.
250
+ *
251
+ * ```html
252
+ * <ui-dock>
253
+ * <view ui-dock-accessory class="flex-row items-center gap-3 px-4">
254
+ * <text class="text-glass-foreground">Now playing</text>
255
+ * </view>
256
+ * …
257
+ * </ui-dock>
258
+ * ```
259
+ *
260
+ * When the glass dock minimizes, the accessory moves down into the row,
261
+ * between the bar and the action, at the minimized bar's height: read the
262
+ * dock's `minimized()` to show less there.
263
+ */
264
+ @Directive({
265
+ selector: '[ui-dock-accessory]',
266
+ // It fills the accessory's glass.
267
+ host: { class: 'flex-1' },
268
+ })
269
+ export class UiDockAccessory {}
270
+
271
+ /**
272
+ * The bar of an app's top-level screens, floating over the content at the
273
+ * bottom of the screen: the Liquid Glass form of the tab bar. Put a
274
+ * `<ui-dock-item>` in it for each screen, and optionally a `<ui-dock-action>`
275
+ * (a separate button, such as search or compose) and a `ui-dock-accessory`
276
+ * (such as what's playing).
277
+ *
278
+ * ```html
279
+ * <view class="h-full">
280
+ * <router-outlet />
281
+ * <ui-dock>
282
+ * <ui-dock-item route="/home" icon="home" label="Home" />
283
+ * <ui-dock-item route="/library" icon="music" label="Library" />
284
+ * <ui-dock-action route="/search" icon="search" label="Search" />
285
+ * </ui-dock>
286
+ * </view>
287
+ * ```
288
+ *
289
+ * It places itself at the bottom of its parent, over the content, which
290
+ * scrolls beneath it. Its form follows the platform (see GlassDesign):
291
+ *
292
+ * - `'glass'` (iOS 26 and later, the web, Android with the glass opt-in): a
293
+ * floating capsule whose selected item is marked by a capsule that slides
294
+ * between the items. The action is a circle beside it, in the same glass
295
+ * group, and the accessory a capsule above. It minimizes as content
296
+ * scrolls down (see UiDockScroll): the bar shrinks to the selected item's
297
+ * icon, and the accessory moves down between it and the action.
298
+ * - `'frosted'` (iOS 15 to 25): the tab bar of that time, edge to edge on
299
+ * the bars' chrome material, with the action as its last button. It
300
+ * doesn't minimize.
301
+ * - `'material'` (Android): a Material 3 Expressive floating toolbar, with an
302
+ * active indicator behind the selected item's icon, and the action as a
303
+ * floating action button. Minimizing slides the toolbar away and leaves
304
+ * the button.
305
+ *
306
+ * With a `<ui-search-field>` in it as well, the action opens a search field
307
+ * at the bottom of the screen, as iOS 26 searches on iPhone:
308
+ *
309
+ * ```html
310
+ * <ui-dock>
311
+ * <ui-dock-item route="/home" icon="home" label="Home" />
312
+ * <ui-dock-action route="/search" icon="search" label="Search" />
313
+ * <ui-search-field [(value)]="query" (submitted)="search($event)" />
314
+ * </ui-dock>
315
+ * ```
316
+ *
317
+ * With a `route`, it's the search tab: the field is open while the route is
318
+ * active. Without one, pressing the action opens it and focuses it, and
319
+ * UiDockState's `searching` says whether it's open. On glass, the action
320
+ * grows into the field across the bottom while the bar shrinks to a button
321
+ * that goes back to the last item. On Material, the action grows into a
322
+ * search bar and the toolbar slides away; the field's back arrow returns.
323
+ * Before iOS 26, the field opens in a bar above the tab bar. The field rises
324
+ * with the dock above the keyboard.
325
+ */
326
+ @Component({
327
+ selector: 'ui-dock',
328
+ imports: [LYNX_ELEMENTS, UiGlass, UiGlassGroup],
329
+ encapsulation: ViewEncapsulation.None,
330
+ providers: [
331
+ { provide: UI_ICON_COLOR, useValue: ACCESSORY_ICON_COLOR },
332
+ // The search field in the dock draws only its content, in the dock's
333
+ // glass, and tells the dock when it's canceled.
334
+ { provide: UI_SEARCH_FIELD_CONTAINER, useExisting: forwardRef(dockType) },
335
+ ],
336
+ host: {
337
+ '[class]': 'hostClass()',
338
+ // Above the keyboard while the search field has it.
339
+ '[style.padding-bottom]': 'keyboardPadding()',
340
+ '[style.transition]': 'keyboardTransition()',
341
+ },
342
+ // The shapes are direct children of the glass group, so they merge on iOS
343
+ // and the web. Each one keeps its element as the dock minimizes and
344
+ // expands: its frame moves and resizes with a CSS transition, which iOS's
345
+ // glass follows frame by frame, where a shape added or removed would pop.
346
+ //
347
+ // `tabbar` is UIKit's tab bar trait, the one a native tab bar's container
348
+ // has: it tells VoiceOver that the buttons in it are tabs. Android reads a
349
+ // single trait and has none for tab bars, so there it's a plain row.
350
+ //
351
+ // The dock measures its width with an empty view rather than on the
352
+ // group: Lynx for Web sends no layoutchange for a blur-view.
353
+
354
+ template: `
355
+ <view class="h-0 w-full" (bindlayoutchange)="measure($event)"></view>
356
+ <blur-view
357
+ ui-glass-group
358
+ class="relative"
359
+ [spacing]="spacing"
360
+ [style]="stageStyle()"
361
+ >
362
+ @if (accessory()) {
363
+ <blur-view
364
+ ui-glass
365
+ interactive
366
+ material="chrome"
367
+ class="flex-row flex absolute overflow-hidden"
368
+ [shape]="look().accessoryShape"
369
+ [radius]="accessoryRadius()"
370
+ [style]="frames().accessory"
371
+ >
372
+ <ng-content select="[ui-dock-accessory]" />
373
+ </blur-view>
374
+ }
375
+ <blur-view
376
+ ui-glass
377
+ interactive
378
+ material="chrome"
379
+ [class]="barClass()"
380
+ [shape]="look().barShape"
381
+ [radius]="look().radius"
382
+ [style]="frames().bar"
383
+ >
384
+ @if (design() === 'frosted') {
385
+ <view
386
+ class="h-[0.5px] bg-border absolute left-0 right-0 top-0"
387
+ ></view>
388
+ }
389
+ <view
390
+ accessibility-traits="tabbar"
391
+ [class]="rowClass()"
392
+ [style]="rowStyle()"
393
+ [attr.accessibility-elements-hidden]="rowHidden() || null"
394
+ >
395
+ <view
396
+ class="rounded-full bg-glass-foreground absolute bottom-0 left-0 top-0"
397
+ [style]="capsuleStyle()"
398
+ ></view>
399
+ <ng-content />
400
+ </view>
401
+ @if (barCollapsed()) {
402
+ <!-- Minimized, the bar is one button that brings the dock back,
403
+ showing the selected item's icon. While the glass dock
404
+ searches, it's the button that goes back to the last item. -->
405
+ <view
406
+ #restore
407
+ class="items-center flex absolute bottom-0 left-0 right-0 top-0 justify-center"
408
+ accessibility-element="true"
409
+ accessibility-traits="button"
410
+ [attr.accessibility-label]="expandLabel()"
411
+ [attr.accessibility-value]="restoreItem()?.label()"
412
+ (bindtap)="onRestore()"
413
+ >
414
+ <svg
415
+ [attr.content]="minimizedIcon()"
416
+ style="width: 24px; height: 24px;"
417
+ />
418
+ </view>
419
+ }
420
+ </blur-view>
421
+ @if (action()) {
422
+ <blur-view
423
+ ui-glass
424
+ interactive
425
+ material="chrome"
426
+ [class]="actionClass()"
427
+ [shape]="look().actionShape"
428
+ [radius]="look().radius"
429
+ [tint]="actionTint()"
430
+ [style]="frames().action"
431
+ [attr.accessibility-elements-hidden]="actionHidden() || null"
432
+ >
433
+ @if (design() === 'frosted') {
434
+ <view
435
+ class="h-[0.5px] bg-border absolute left-0 right-0 top-0"
436
+ ></view>
437
+ }
438
+ <ng-content select="ui-dock-action" />
439
+ </blur-view>
440
+ @if (searchField(); as field) {
441
+ <!-- The search field's own shape, after the action so it draws
442
+ over it: it grows out of the action as the search opens, and
443
+ shrinks back into it as it closes. -->
444
+ <blur-view
445
+ ui-glass
446
+ interactive
447
+ material="chrome"
448
+ class="flex-row flex absolute overflow-hidden"
449
+ [shape]="searchShape()"
450
+ [radius]="0"
451
+ [style]="frames().search"
452
+ >
453
+ @if (design() === 'frosted') {
454
+ <view
455
+ class="h-[0.5px] bg-border absolute left-0 right-0 top-0"
456
+ ></view>
457
+ }
458
+ <view
459
+ [class]="searchContentClass()"
460
+ [style]="searchContentStyle()"
461
+ [attr.accessibility-elements-hidden]="!searching() || null"
462
+ >
463
+ <ng-content select="ui-search-field" />
464
+ </view>
465
+ </blur-view>
466
+ @if (design() === 'glass' && field.cancelable()) {
467
+ <!-- iOS 26's cancel beside the field: a glass circle that grows
468
+ out of the field's end while it's in use. -->
469
+ <blur-view
470
+ ui-glass
471
+ interactive
472
+ shape="circle"
473
+ class="items-center flex absolute justify-center overflow-hidden"
474
+ accessibility-element="true"
475
+ accessibility-traits="button"
476
+ [attr.accessibility-label]="field.cancelLabel()"
477
+ [attr.accessibility-elements-hidden]="!cancelShown() || null"
478
+ [style]="frames().cancel"
479
+ (bindtap)="field.cancel()"
480
+ >
481
+ <svg [attr.content]="cancelIcon()" [style]="cancelIconStyle()" />
482
+ </blur-view>
483
+ }
484
+ }
485
+ }
486
+ </blur-view>
487
+ `,
488
+ })
489
+ export class UiDock implements SearchFieldContainer {
490
+ /**
491
+ * What a screen reader says for the minimized dock, a button that brings
492
+ * it back. The selected item's label follows it.
493
+ */
494
+ readonly expandLabel = input('Show tab bar');
495
+ readonly userClass = input<string>('', { alias: 'class' });
496
+
497
+ readonly #state = inject(UiDockState);
498
+ readonly #settings = inject(LynxAccessibilitySettings);
499
+ readonly #resolveColor = injectIconColor();
500
+ readonly #injector = inject(Injector);
501
+ readonly #renderer = inject(Renderer2);
502
+ readonly #host = inject(ElementRef);
503
+
504
+ /** The design on this platform (see GlassDesign), which the items follow. */
505
+ readonly design = injectGlassDesign();
506
+
507
+ protected readonly look = computed(() => LOOKS[this.design()]);
508
+ protected readonly spacing = GLASS_SPACING;
509
+
510
+ protected readonly items = contentChildren(itemType());
511
+ protected readonly action = contentChild(actionType());
512
+ protected readonly accessory = contentChild(accessoryType());
513
+ protected readonly searchField = contentChild(searchFieldType());
514
+ protected readonly restoreButton = viewChild<ElementRef>('restore');
515
+
516
+ /**
517
+ * The dock's width, from Lynx's layout. The glass bar shrinks from it when
518
+ * the dock minimizes.
519
+ */
520
+ protected readonly width = signal(0);
521
+
522
+ /** The item whose route is active, if any. */
523
+ readonly selectedItem = computed(() =>
524
+ this.items().find((item) => item.active()),
525
+ );
526
+
527
+ /**
528
+ * The item selected last, which stays while the search's route is active
529
+ * and no item is: the glass bar's button goes back to it. Kept by an
530
+ * effect rather than a linkedSignal, which would only see the items
531
+ * selected while something read it.
532
+ */
533
+ readonly #lastItem = signal<UiDockItem | undefined>(undefined);
534
+ protected readonly rememberItem = effect(() => {
535
+ const item = this.selectedItem();
536
+ if (item) this.#lastItem.set(item);
537
+ });
538
+
539
+ /**
540
+ * Whether the search field is open: with a search field and an action in
541
+ * the dock, while the action's route is active, or while UiDockState says
542
+ * so for an action without one.
543
+ */
544
+ readonly searching = computed(() => {
545
+ const action = this.action();
546
+ return (
547
+ !!this.searchField() &&
548
+ !!action &&
549
+ (action.active() || this.#state.searching())
550
+ );
551
+ });
552
+
553
+ /**
554
+ * Whether the dock is minimized now: as UiDockState says, in the designs
555
+ * that minimize, once the dock knows the width it shrinks from, and not
556
+ * while it searches.
557
+ */
558
+ readonly minimized = computed(
559
+ () =>
560
+ this.#state.minimized() &&
561
+ this.design() !== 'frosted' &&
562
+ this.width() > 0 &&
563
+ !this.searching(),
564
+ );
565
+
566
+ /** The glass dock searching: its bar shrinks to a button beside the field. */
567
+ readonly #glassSearch = computed(
568
+ () => this.searching() && this.design() === 'glass' && this.width() > 0,
569
+ );
570
+
571
+ /** Whether the bar is one button: minimized, or beside the glass search. */
572
+ protected readonly barCollapsed = computed(
573
+ () => this.minimized() || this.#glassSearch(),
574
+ );
575
+
576
+ /**
577
+ * Whether the bar's items are out of sight: in a collapsed bar, or in
578
+ * Material's toolbar while it's slid away for the search bar.
579
+ */
580
+ protected readonly rowHidden = computed(
581
+ () =>
582
+ this.barCollapsed() || (this.searching() && this.design() === 'material'),
583
+ );
584
+
585
+ /**
586
+ * The glass and Material action is the field's while it searches: it
587
+ * shrinks into the field's end, and its icon fades out on the way.
588
+ */
589
+ readonly actionHidden = computed(
590
+ () => this.searching() && this.design() !== 'frosted',
591
+ );
592
+
593
+ /** The item the collapsed bar shows, and goes back to while searching. */
594
+ protected readonly restoreItem = computed(() =>
595
+ this.#glassSearch() ? this.#lastItem() : this.selectedItem(),
596
+ );
597
+
598
+ /** Whether the field's Cancel shows, beside it on glass. */
599
+ protected readonly cancelShown = computed(
600
+ () => this.searching() && !!this.searchField()?.cancelShown(),
601
+ );
602
+
603
+ /** No sliding, shrinking or fading under Reduce Motion. */
604
+ readonly #animate = computed(() => !this.#settings.reduceMotion());
605
+
606
+ /**
607
+ * How far up the keyboard reaches into the dock, from its bottom edge, in
608
+ * px: 0 while it's down, or while the search field doesn't have it.
609
+ */
610
+ readonly #keyboardOverlap = signal(0);
611
+ /** Each keyboard change's measuring, so a late answer can't win. */
612
+ #measuring = 0;
613
+
614
+ protected readonly hostClass = computed(() =>
615
+ cn(
616
+ 'flex-col flex absolute bottom-0 left-0 right-0',
617
+ this.design() !== 'frosted' && 'px-4 pb-safe-2',
618
+ this.userClass(),
619
+ ),
620
+ );
621
+
622
+ /**
623
+ * While the search field has the keyboard, the dock floats above it: the
624
+ * glass and Material shapes a little above it, as they float above the home
625
+ * indicator, and the field's bar before iOS 26 on top of it, with the tab
626
+ * bar under the keyboard, as Safari's bar did.
627
+ */
628
+ protected readonly keyboardPadding = computed(() => {
629
+ const overlap = this.searching() ? this.#keyboardOverlap() : 0;
630
+ if (overlap <= 0) return null;
631
+ return this.design() === 'frosted'
632
+ ? `calc(${overlap - this.look().bar}px - env(safe-area-inset-bottom))`
633
+ : px(overlap + KEYBOARD_GAP);
634
+ });
635
+
636
+ protected readonly keyboardTransition = computed(() =>
637
+ this.searchField() && this.#animate() ? KEYBOARD_TRANSITION : null,
638
+ );
639
+
640
+ protected readonly accessoryRadius = computed(() =>
641
+ this.design() === 'frosted' ? FROSTED_ACCESSORY_RADIUS : this.look().radius,
642
+ );
643
+
644
+ /**
645
+ * Each shape's frame in the dock, bottom up, and the dock's height. The
646
+ * shapes are positioned absolutely, so a minimized dock can move each one
647
+ * on its own: the accessory from above the bar into the row beside it.
648
+ *
649
+ * The search field's shape and its Cancel are there only with a search
650
+ * field. Closed, they're points inside the action, which they grow out of
651
+ * as the search opens.
652
+ */
653
+ protected readonly frames = computed(() => {
654
+ const look = this.look();
655
+ const design = this.design();
656
+ const hasAction = !!this.action();
657
+ const minimized = this.minimized();
658
+ const searching = this.searching();
659
+ const width = this.width();
660
+ const transition = this.#animate() ? FRAME_TRANSITION : 'none';
661
+ const actionSpace = hasAction ? look.action + look.gap : 0;
662
+ /**
663
+ * A point, a shape with no size, `right` and `bottom` px in.
664
+ */
665
+ const point = (right: number, bottom: number) => ({
666
+ left: px(Math.max(0, width - right)),
667
+ right: px(right),
668
+ bottom: px(bottom),
669
+ height: '0px',
670
+ transition,
671
+ });
672
+
673
+ if (design === 'frosted') {
674
+ const above = look.bar + look.gap;
675
+ const strip = searching ? FROSTED_SEARCH_HEIGHT : 0;
676
+ // Only with a search field, so the dock moves as it did without one.
677
+ const searchTransition = this.searchField() ? transition : undefined;
678
+ return {
679
+ stage: {
680
+ height: abovePx(
681
+ this.accessory()
682
+ ? above + strip + look.accessory
683
+ : look.bar + strip,
684
+ ),
685
+ transition: searchTransition,
686
+ },
687
+ bar: {
688
+ left: '0px',
689
+ right: px(hasAction ? look.action : 0),
690
+ bottom: '0px',
691
+ height: abovePx(look.bar),
692
+ },
693
+ action: {
694
+ right: '0px',
695
+ bottom: '0px',
696
+ width: px(look.action),
697
+ height: abovePx(look.bar),
698
+ },
699
+ accessory: {
700
+ left: px(FROSTED_ACCESSORY_INSET),
701
+ right: px(FROSTED_ACCESSORY_INSET),
702
+ bottom: abovePx(above + strip),
703
+ height: px(look.accessory),
704
+ transition: searchTransition,
705
+ },
706
+ // A bar on the tab bar's material, above it.
707
+ search: {
708
+ left: '0px',
709
+ right: '0px',
710
+ bottom: abovePx(look.bar),
711
+ height: px(strip),
712
+ transition,
713
+ },
714
+ cancel: point(0, 0),
715
+ };
716
+ }
717
+
718
+ if (design === 'glass' && (minimized || this.#glassSearch())) {
719
+ const size = MINIMIZED_SIZE;
720
+ const besideAction = hasAction ? size + look.gap : 0;
721
+ const cancel = this.cancelShown();
722
+ const cancelSpace = cancel ? GLASS_SEARCH_SIZE + look.gap : 0;
723
+ const accessoryHeight = this.accessory() ? look.gap + look.accessory : 0;
724
+ return {
725
+ stage: {
726
+ height: px(searching ? size + accessoryHeight : size),
727
+ transition,
728
+ },
729
+ bar: {
730
+ left: '0px',
731
+ right: px(width - size),
732
+ bottom: '0px',
733
+ height: px(size),
734
+ transition,
735
+ },
736
+ // Searching, the action shrinks into the end of the field, which
737
+ // grows out of it.
738
+ action: searching
739
+ ? {
740
+ right: px(cancelSpace + size / 2),
741
+ bottom: px(size / 2),
742
+ width: '0px',
743
+ height: '0px',
744
+ transition,
745
+ }
746
+ : {
747
+ right: '0px',
748
+ bottom: '0px',
749
+ width: px(size),
750
+ height: px(size),
751
+ transition,
752
+ },
753
+ // Minimized, the accessory moves down between the bar and the
754
+ // action; searching, it stays above the field.
755
+ accessory: searching
756
+ ? {
757
+ left: '0px',
758
+ right: '0px',
759
+ bottom: px(size + look.gap),
760
+ height: px(look.accessory),
761
+ transition,
762
+ }
763
+ : {
764
+ left: px(size + look.gap),
765
+ right: px(besideAction),
766
+ bottom: '0px',
767
+ height: px(size),
768
+ transition,
769
+ },
770
+ search: searching
771
+ ? {
772
+ left: px(size + look.gap),
773
+ right: px(cancelSpace),
774
+ bottom: '0px',
775
+ height: px(GLASS_SEARCH_SIZE),
776
+ transition,
777
+ }
778
+ : point(size / 2, size / 2),
779
+ cancel: cancel
780
+ ? {
781
+ left: px(width - GLASS_SEARCH_SIZE),
782
+ right: '0px',
783
+ bottom: '0px',
784
+ height: px(GLASS_SEARCH_SIZE),
785
+ transition,
786
+ }
787
+ : point(searching ? 0 : size / 2, size / 2),
788
+ };
789
+ }
790
+
791
+ // Glass or Material, expanded; or Material minimized or searching, which
792
+ // slides the toolbar and the accessory below the screen. Minimized, it
793
+ // keeps the button; searching, the button grows into the search bar.
794
+ const hidden = design === 'material' && (minimized || searching);
795
+ const slide = (by: number) =>
796
+ design === 'material' ? `translateY(${hidden ? by : 0}px)` : null;
797
+ const withAccessory = this.accessory()
798
+ ? look.bar + look.gap + look.accessory
799
+ : look.bar;
800
+ const actionCenter = point(look.action / 2, look.bar / 2);
801
+ // Material's button is shorter than the toolbar, and centered on it.
802
+ const actionBottom = (look.bar - look.action) / 2;
803
+ return {
804
+ stage: { height: px(withAccessory), transition },
805
+ bar: {
806
+ left: '0px',
807
+ right: px(actionSpace),
808
+ bottom: '0px',
809
+ height: px(look.bar),
810
+ transform: slide(look.bar + MATERIAL_HIDDEN_OFFSET),
811
+ transition,
812
+ },
813
+ action:
814
+ design === 'material' && searching
815
+ ? {
816
+ right: px(look.action / 2),
817
+ bottom: px(look.bar / 2),
818
+ width: '0px',
819
+ height: '0px',
820
+ transition,
821
+ }
822
+ : {
823
+ right: '0px',
824
+ bottom: px(actionBottom),
825
+ width: px(look.action),
826
+ height: px(look.action),
827
+ transition,
828
+ },
829
+ accessory: {
830
+ left: '0px',
831
+ right: '0px',
832
+ bottom: px(look.bar + look.gap),
833
+ height: px(look.accessory),
834
+ transform: slide(withAccessory + MATERIAL_HIDDEN_OFFSET),
835
+ transition,
836
+ },
837
+ search:
838
+ design === 'material' && searching
839
+ ? {
840
+ left: '0px',
841
+ right: '0px',
842
+ bottom: px((look.bar - MATERIAL_SEARCH_HEIGHT) / 2),
843
+ height: px(MATERIAL_SEARCH_HEIGHT),
844
+ transition,
845
+ }
846
+ : actionCenter,
847
+ cancel: actionCenter,
848
+ };
849
+ });
850
+
851
+ protected readonly stageStyle = computed(() => this.frames().stage);
852
+
853
+ protected readonly barClass = computed(() =>
854
+ cn(
855
+ 'flex-col flex absolute',
856
+ this.design() === 'frosted' ? 'pb-safe' : 'overflow-hidden',
857
+ ),
858
+ );
859
+
860
+ protected readonly actionClass = computed(() =>
861
+ cn(
862
+ 'flex-col flex absolute overflow-hidden',
863
+ this.design() === 'frosted' && 'pb-safe',
864
+ ),
865
+ );
866
+
867
+ /** Material's floating action button is filled with the primary color. */
868
+ protected readonly actionTint = computed(() =>
869
+ this.design() === 'material' ? 'var(--primary)' : undefined,
870
+ );
871
+
872
+ /**
873
+ * A capsule on glass and Material, as iOS 26's field and Material 3's
874
+ * search bar are; before iOS 26, a bar from edge to edge.
875
+ */
876
+ protected readonly searchShape = computed(() =>
877
+ this.design() === 'frosted' ? 'rounded' : 'capsule',
878
+ );
879
+
880
+ protected readonly searchContentClass = computed(() =>
881
+ cn(
882
+ 'flex-1 flex-row items-center flex',
883
+ this.design() === 'frosted' && 'px-[8px]',
884
+ ),
885
+ );
886
+
887
+ /**
888
+ * The field's content fades in once its shape has mostly grown, and out at
889
+ * once as the search closes.
890
+ */
891
+ protected readonly searchContentStyle = computed(() => {
892
+ const searching = this.searching();
893
+ return {
894
+ opacity: searching ? '1' : '0',
895
+ transition: searching && this.#animate() ? SEARCH_IN_TRANSITION : 'none',
896
+ };
897
+ });
898
+
899
+ protected readonly cancelIcon = computed(() =>
900
+ iconSvg('x', this.#resolveColor('var(--glass-foreground)')),
901
+ );
902
+
903
+ protected readonly cancelIconStyle = computed(() => ({
904
+ width: '20px',
905
+ height: '20px',
906
+ opacity: this.cancelShown() ? '1' : '0',
907
+ }));
908
+
909
+ protected readonly rowClass = computed(() =>
910
+ cn(
911
+ 'flex-row flex relative',
912
+ this.design() === 'frosted' ? 'h-[49px]' : 'flex-1',
913
+ ),
914
+ );
915
+
916
+ /**
917
+ * The row sits inside the bar's padding. Once the dock knows its width,
918
+ * the floating bar's row keeps the expanded width, so the items don't
919
+ * squeeze together as the bar shrinks and grows: the bar clips them
920
+ * instead, and they fade out as it shrinks and back in as it grows.
921
+ */
922
+ protected readonly rowStyle = computed(() => {
923
+ const look = this.look();
924
+ const hidden = this.rowHidden();
925
+ const action = this.action() ? look.action + look.gap : 0;
926
+ const floating = this.design() !== 'frosted' && this.width() > 0;
927
+ return {
928
+ margin: px(look.inset),
929
+ width: floating ? px(this.width() - action - 2 * look.inset) : null,
930
+ opacity: hidden ? '0' : '1',
931
+ transition: this.#animate()
932
+ ? hidden
933
+ ? ROW_OUT_TRANSITION
934
+ : ROW_IN_TRANSITION
935
+ : 'none',
936
+ };
937
+ });
938
+
939
+ /**
940
+ * The capsule behind the selected item, on glass: one element that slides
941
+ * from item to item. It's a shade of the glass's foreground rather than
942
+ * glass of its own, which would be glass on glass, and it's how iOS marks
943
+ * the selected tab while nobody is touching the bar.
944
+ *
945
+ * The items share the row equally, so it's one item wide, and
946
+ * `translateX(n * 100%)` moves it n items along, whatever the row's width.
947
+ * It fades out while no item is selected, and in the other designs, which
948
+ * mark the selection their own way.
949
+ */
950
+ protected readonly capsuleStyle = computed(() => {
951
+ const count = Math.max(1, this.items().length);
952
+ const selected = this.selectedItem();
953
+ const index = selected ? this.items().indexOf(selected) : 0;
954
+ const shown = !!selected && this.design() === 'glass';
955
+ return {
956
+ width: `${100 / count}%`,
957
+ transform: `translateX(${index * 100}%)`,
958
+ opacity: String(shown ? CAPSULE_OPACITY : 0),
959
+ transition: this.#animate() ? CAPSULE_TRANSITION : 'none',
960
+ };
961
+ });
962
+
963
+ /**
964
+ * The icon of the collapsed bar's item (see restoreItem), or the first
965
+ * item's.
966
+ */
967
+ protected readonly minimizedIcon = computed(() => {
968
+ const item = this.restoreItem() ?? this.items()[0];
969
+ return item
970
+ ? iconSvg(item.icon(), this.#resolveColor('var(--primary)'))
971
+ : '';
972
+ });
973
+
974
+ // The collapsed bar's icon fades in as the bar shrinks around it.
975
+ protected readonly restoreEntrance = effect(() => {
976
+ const restore = this.restoreButton();
977
+ if (restore && untracked(this.#animate)) {
978
+ fadeIn(restore.nativeElement, { duration: DURATION.slow });
979
+ }
980
+ });
981
+
982
+ /** Whether the dock searched when it last checked, to see it close. */
983
+ #wasSearching = false;
984
+
985
+ // A search that closes, by its route or by the app, takes the keyboard
986
+ // with it.
987
+ protected readonly closeKeyboard = effect(() => {
988
+ const searching = this.searching();
989
+ if (this.#wasSearching && !searching) {
990
+ untracked(() => this.searchField()?.blur());
991
+ }
992
+ this.#wasSearching = searching;
993
+ });
994
+
995
+ // The search field's keyboard height says the keyboard moved: measure how
996
+ // far it reaches into the dock, which floats above it while it searches.
997
+ protected readonly followKeyboard = effect(() => {
998
+ const height = this.searchField()?.keyboardHeight() ?? 0;
999
+ const measuring = ++this.#measuring;
1000
+ const screen = height > 0 ? keyboardScreen(this.#injector) : null;
1001
+ if (!screen) {
1002
+ this.#keyboardOverlap.set(0);
1003
+ return;
1004
+ }
1005
+ // The dock's host sits at the bottom of the screen whatever its padding,
1006
+ // so measuring it gives the same answer however far the dock has risen.
1007
+ measureForKeyboard(this.#renderer, this.#host.nativeElement).then(
1008
+ (measured) => {
1009
+ if (measuring !== this.#measuring || !measured) return;
1010
+ this.#keyboardOverlap.set(
1011
+ keyboardLift(measured.element, measured.page, height, screen, 0),
1012
+ );
1013
+ },
1014
+ );
1015
+ });
1016
+
1017
+ protected measure(event: Event): void {
1018
+ this.width.set((event as LayoutChangeEvent).detail.width);
1019
+ }
1020
+
1021
+ /**
1022
+ * Brings a minimized dock back.
1023
+ */
1024
+ expand(): void {
1025
+ this.#state.minimized.set(false);
1026
+ }
1027
+
1028
+ /**
1029
+ * Opens the search field and gives it the keyboard, as pressing an action
1030
+ * without a route does.
1031
+ */
1032
+ openSearch(): void {
1033
+ const field = this.searchField();
1034
+ if (!field) return;
1035
+ this.#state.searching.set(true);
1036
+ field.focus();
1037
+ }
1038
+
1039
+ /**
1040
+ * Closes the search field: goes back to the last item while the search's
1041
+ * route is active, or else closes the search UiDockState holds open.
1042
+ */
1043
+ closeSearch(): void {
1044
+ if (this.action()?.active()) {
1045
+ (this.#lastItem() ?? this.items()[0])?.navigate();
1046
+ }
1047
+ this.#state.searching.set(false);
1048
+ }
1049
+
1050
+ /**
1051
+ * Cancel closes the search, except that the search tab of iOS stays open
1052
+ * with its suggestions: there, Cancel clears the field and closes the
1053
+ * keyboard, and the bar's button goes back. Material's back arrow always
1054
+ * goes back.
1055
+ */
1056
+ searchCanceled(): void {
1057
+ if (this.design() === 'material' || !this.action()?.active()) {
1058
+ this.closeSearch();
1059
+ }
1060
+ }
1061
+
1062
+ /**
1063
+ * The action was pressed: one without a route opens the search.
1064
+ */
1065
+ actionPressed(routed: boolean): void {
1066
+ if (!routed) this.openSearch();
1067
+ }
1068
+
1069
+ protected onRestore(): void {
1070
+ if (this.searching()) {
1071
+ this.closeSearch();
1072
+ } else {
1073
+ this.expand();
1074
+ }
1075
+ }
1076
+ }
1077
+
1078
+ /**
1079
+ * One of the dock's screens: an icon over a label that navigates to its
1080
+ * `route` on a tap.
1081
+ *
1082
+ * The host itself is the link. Angular's own `RouterLink` and
1083
+ * `RouterLinkActive` sit on it as host directives, so every `RouterLink`
1084
+ * option (`queryParams`, `replaceUrl`, `state`, …) and
1085
+ * `routerLinkActiveOptions` work as they do on the web, and the item knows
1086
+ * whether its route is active without the app wiring `routerLinkActive` up.
1087
+ * Host directives don't match selectors, so `LynxRouterLinkActive`, which
1088
+ * marks the active item `selected` for screen readers, is listed too.
1089
+ */
1090
+ @Component({
1091
+ selector: 'ui-dock-item',
1092
+ imports: [LYNX_ELEMENTS],
1093
+ encapsulation: ViewEncapsulation.None,
1094
+ hostDirectives: [
1095
+ {
1096
+ directive: RouterLink,
1097
+ // `route`, not `routerLink`: an app that imports RouterLink would
1098
+ // otherwise put a second RouterLink on the item (its selector matches
1099
+ // `routerLink`), and Angular rejects two of one directive on an element.
1100
+ inputs: [
1101
+ 'routerLink: route',
1102
+ 'queryParams',
1103
+ 'queryParamsHandling',
1104
+ 'fragment',
1105
+ 'preserveFragment',
1106
+ 'state',
1107
+ 'info',
1108
+ 'relativeTo',
1109
+ 'replaceUrl',
1110
+ 'skipLocationChange',
1111
+ ],
1112
+ },
1113
+ { directive: RouterLinkActive, inputs: ['routerLinkActiveOptions'] },
1114
+ LynxRouterLinkActive,
1115
+ ],
1116
+ host: {
1117
+ // The host is the tap target and takes an equal share of the bar's row,
1118
+ // which the selection capsule's width counts on.
1119
+ class: 'flex-1 flex-col flex',
1120
+ // One accessibility element per item, named by its label, so a screen
1121
+ // reader reads "Inbox, 3, button" instead of an unlabeled SVG and a word.
1122
+ // LynxRouterLinkActive adds `selected` to `button` while the route is
1123
+ // active. The label is an attribute binding: a property binding on a
1124
+ // component's host is checked against the DOM schema, which has no
1125
+ // `accessibility-label`.
1126
+ 'accessibility-element': 'true',
1127
+ 'accessibility-traits': 'button',
1128
+ '[attr.accessibility-label]': 'accessibilityLabel()',
1129
+ // Press feedback binds touch and mouse events: native fires only touch,
1130
+ // and the web preview, in a browser, fires only mouse. See pressDown().
1131
+ '(bindtouchstart)': 'onPressStart()',
1132
+ '(bindmousedown)': 'onPressStart()',
1133
+ // A canceled touch (a scroll taking over) and a cursor leaving mid-press
1134
+ // release too, or the item would stay shrunk.
1135
+ '(bindtouchend)': 'onPressEnd()',
1136
+ '(bindtouchcancel)': 'onPressEnd()',
1137
+ '(bindmouseup)': 'onPressEnd()',
1138
+ '(bindmouseleave)': 'onPressEnd()',
1139
+ // RouterLink navigates on the same tap through its own listener.
1140
+ '(bindtap)': 'pressed.emit()',
1141
+ },
1142
+ template: `
1143
+ <view [class]="contentClass()">
1144
+ <view [class]="iconFrameClass()">
1145
+ @if (design() === 'material') {
1146
+ <!-- Material's active indicator: a pill behind the icon that
1147
+ widens from its center as the item is selected. -->
1148
+ <view
1149
+ class="h-8 w-14 rounded-full bg-primary absolute left-0 top-0"
1150
+ [style]="indicatorStyle()"
1151
+ ></view>
1152
+ }
1153
+ <view class="relative">
1154
+ <svg [attr.content]="iconSvg()" style="width: 24px; height: 24px;" />
1155
+ @if (badgeText()) {
1156
+ <!-- Hangs off the icon's top-right corner, as on a native tab
1157
+ bar. min-w-4 keeps a one-digit badge round; longer ones
1158
+ grow into a pill. -->
1159
+ <view
1160
+ class="h-4 min-w-4 items-center rounded-full bg-destructive px-1 flex absolute -right-2.5 -top-1 justify-center"
1161
+ >
1162
+ <text
1163
+ class="text-[10px] font-semibold leading-4 text-destructive-foreground"
1164
+ >{{ badgeText() }}</text
1165
+ >
1166
+ </view>
1167
+ }
1168
+ </view>
1169
+ </view>
1170
+ <text [class]="labelClass()">{{ label() }}</text>
1171
+ </view>
1172
+ `,
1173
+ })
1174
+ export class UiDockItem {
1175
+ readonly icon = input.required<IconName>();
1176
+ readonly label = input.required<string>();
1177
+ /**
1178
+ * A count or short text over the icon's corner, such as unread messages.
1179
+ * Nothing shows for `0`, an empty string, `null` or `undefined`, so a count
1180
+ * can be bound as it is.
1181
+ */
1182
+ readonly badge = input<string | number | null | undefined>(undefined);
1183
+ readonly userClass = input<string>('', { alias: 'class' });
1184
+
1185
+ /** Emits on every tap, including a tap on the item that's already active. */
1186
+ readonly pressed = output<void>();
1187
+
1188
+ readonly #dock = inject(UiDock);
1189
+ readonly #settings = inject(LynxAccessibilitySettings);
1190
+ readonly #resolveColor = injectIconColor();
1191
+ // The host, not an inner view, shrinks on a press: it's the whole tap
1192
+ // target, and injecting it needs no view query.
1193
+ readonly #host = inject(ElementRef);
1194
+ readonly #link = inject(RouterLink);
1195
+ #pressAnim?: AnimationHandle;
1196
+
1197
+ protected readonly design = this.#dock.design;
1198
+
1199
+ /**
1200
+ * Whether the item's route is active, from the RouterLinkActive on the
1201
+ * host. RouterLinkActive reports only changes, and starts from not active.
1202
+ */
1203
+ readonly active = toSignal(inject(RouterLinkActive).isActiveChange, {
1204
+ initialValue: false,
1205
+ });
1206
+
1207
+ protected readonly badgeText = computed(() => {
1208
+ const text = String(this.badge() ?? '');
1209
+ return text === '0' ? '' : text;
1210
+ });
1211
+
1212
+ protected readonly accessibilityLabel = computed(() =>
1213
+ this.badgeText() ? `${this.label()}, ${this.badgeText()}` : this.label(),
1214
+ );
1215
+
1216
+ /**
1217
+ * The colors of the selected item and the others, as theme tokens. On
1218
+ * glass, the selected item takes the primary color over the capsule and
1219
+ * the others the glass's foreground. Before iOS 26, the others were gray.
1220
+ * On Material, the selected icon sits on the primary indicator.
1221
+ */
1222
+ readonly #colors = computed(() => {
1223
+ const active = this.active();
1224
+ switch (this.design()) {
1225
+ case 'glass':
1226
+ return active
1227
+ ? { icon: 'var(--primary)', label: 'text-primary' }
1228
+ : { icon: 'var(--glass-foreground)', label: 'text-glass-foreground' };
1229
+ case 'material':
1230
+ return active
1231
+ ? {
1232
+ icon: 'var(--primary-foreground)',
1233
+ label: 'font-semibold text-foreground',
1234
+ }
1235
+ : { icon: 'var(--muted-foreground)', label: 'text-muted-foreground' };
1236
+ default:
1237
+ return active
1238
+ ? { icon: 'var(--primary)', label: 'text-primary' }
1239
+ : { icon: 'var(--muted-foreground)', label: 'text-muted-foreground' };
1240
+ }
1241
+ });
1242
+
1243
+ // The icon is drawn here, as the tab bar draws its own, rather than by a
1244
+ // nested <ui-icon>: an SVG can't read the label's text class (Lynx doesn't
1245
+ // pass `color` down, and the markup is parsed apart from the page's CSS),
1246
+ // so the token is resolved to its value and written into the markup (see
1247
+ // icon-theme.ts).
1248
+ protected readonly iconSvg = computed(() =>
1249
+ iconSvg(this.icon(), this.#resolveColor(this.#colors().icon)),
1250
+ );
1251
+
1252
+ protected readonly labelClass = computed(() =>
1253
+ cn(
1254
+ this.design() === 'material'
1255
+ ? 'text-xs font-medium'
1256
+ : 'text-[10px] font-medium',
1257
+ this.#colors().label,
1258
+ ),
1259
+ );
1260
+
1261
+ protected readonly iconFrameClass = computed(() =>
1262
+ this.design() === 'material'
1263
+ ? 'h-8 w-14 items-center flex relative justify-center'
1264
+ : 'relative',
1265
+ );
1266
+
1267
+ /**
1268
+ * Material's indicator shows at once on the selected item, so its icon is
1269
+ * never light on the toolbar, and widens from the center there. It leaves
1270
+ * the item it moves from at once too.
1271
+ */
1272
+ protected readonly indicatorStyle = computed(() => {
1273
+ const active = this.active();
1274
+ return {
1275
+ opacity: active ? '1' : '0',
1276
+ transform: active ? 'scaleX(1)' : 'scaleX(0.5)',
1277
+ transition:
1278
+ active && !this.#settings.reduceMotion()
1279
+ ? `transform ${DURATION.normal}ms ${EASING.decelerate}`
1280
+ : 'none',
1281
+ };
1282
+ });
1283
+
1284
+ protected readonly contentClass = computed(() =>
1285
+ cn(
1286
+ 'flex-1 flex-col items-center flex justify-center',
1287
+ this.design() === 'material' ? 'gap-1' : 'gap-0.5',
1288
+ this.userClass(),
1289
+ ),
1290
+ );
1291
+
1292
+ /**
1293
+ * Navigates to the item's route, with its RouterLink options, as a tap
1294
+ * does: the glass dock's collapsed bar goes back to it from the search.
1295
+ */
1296
+ navigate(): void {
1297
+ this.#link.onClick(0, false, false, false, false);
1298
+ }
1299
+
1300
+ protected onPressStart(): void {
1301
+ this.#pressAnim?.cancel();
1302
+ this.#pressAnim = pressDown(this.#host.nativeElement);
1303
+ }
1304
+
1305
+ protected onPressEnd(): void {
1306
+ this.#pressAnim?.cancel();
1307
+ this.#pressAnim = pressRelease(this.#host.nativeElement);
1308
+ }
1309
+ }
1310
+
1311
+ /**
1312
+ * The dock's separate button, for the action people reach for from every
1313
+ * screen, such as search or compose. It sits beside the bar: on glass, a
1314
+ * circle in the dock's glass group, which merges with the bar's glass as the
1315
+ * two come close; on Material, a floating action button; before iOS 26, the
1316
+ * bar's last button.
1317
+ *
1318
+ * It navigates to its `route` like an item, and is selected while the route
1319
+ * is active, or emits `pressed` for an action of your own without one:
1320
+ *
1321
+ * ```html
1322
+ * <ui-dock-action route="/search" icon="search" label="Search" />
1323
+ * <ui-dock-action icon="plus" label="New message" (pressed)="compose()" />
1324
+ * ```
1325
+ *
1326
+ * With a `<ui-search-field>` in the dock, it opens the search (see UiDock).
1327
+ */
1328
+ @Component({
1329
+ selector: 'ui-dock-action',
1330
+ imports: [LYNX_ELEMENTS],
1331
+ encapsulation: ViewEncapsulation.None,
1332
+ hostDirectives: [
1333
+ {
1334
+ directive: RouterLink,
1335
+ inputs: [
1336
+ 'routerLink: route',
1337
+ 'queryParams',
1338
+ 'queryParamsHandling',
1339
+ 'fragment',
1340
+ 'preserveFragment',
1341
+ 'state',
1342
+ 'info',
1343
+ 'relativeTo',
1344
+ 'replaceUrl',
1345
+ 'skipLocationChange',
1346
+ ],
1347
+ },
1348
+ { directive: RouterLinkActive, inputs: ['routerLinkActiveOptions'] },
1349
+ LynxRouterLinkActive,
1350
+ ],
1351
+ host: {
1352
+ // It fills the action's glass, which is the button's shape.
1353
+ class: 'flex-1 items-center flex justify-center',
1354
+ 'accessibility-element': 'true',
1355
+ 'accessibility-traits': 'button',
1356
+ '[attr.accessibility-label]': 'label()',
1357
+ '(bindtouchstart)': 'onPressStart()',
1358
+ '(bindmousedown)': 'onPressStart()',
1359
+ '(bindtouchend)': 'onPressEnd()',
1360
+ '(bindtouchcancel)': 'onPressEnd()',
1361
+ '(bindmouseup)': 'onPressEnd()',
1362
+ '(bindmouseleave)': 'onPressEnd()',
1363
+ '(bindtap)': 'onTap()',
1364
+ // Its icon fades while the button shrinks into the search field, so it
1365
+ // doesn't show through the field's glass. The content, not the glass.
1366
+ '[style.opacity]': 'fade().opacity',
1367
+ '[style.transition]': 'fade().transition',
1368
+ },
1369
+ template: `<svg
1370
+ [attr.content]="iconSvg()"
1371
+ style="width: 24px; height: 24px;"
1372
+ />`,
1373
+ })
1374
+ export class UiDockAction {
1375
+ readonly icon = input.required<IconName>();
1376
+ /** What the button does, for screen readers: it shows only its icon. */
1377
+ readonly label = input.required<string>();
1378
+
1379
+ /** Emits on every tap. */
1380
+ readonly pressed = output<void>();
1381
+
1382
+ readonly #dock = inject(UiDock);
1383
+ readonly #settings = inject(LynxAccessibilitySettings);
1384
+ readonly #resolveColor = injectIconColor();
1385
+ readonly #host = inject(ElementRef);
1386
+ readonly #link = inject(RouterLink);
1387
+ #pressAnim?: AnimationHandle;
1388
+
1389
+ /** Whether the button's route is active, as for UiDockItem.active. */
1390
+ readonly active = toSignal(inject(RouterLinkActive).isActiveChange, {
1391
+ initialValue: false,
1392
+ });
1393
+
1394
+ /**
1395
+ * On glass, the icon takes the glass's foreground, and the primary color
1396
+ * while its route is active, as a selected item does. Material's button is
1397
+ * filled with the primary color; before iOS 26, a bar button was tinted.
1398
+ */
1399
+ protected readonly iconSvg = computed(() => {
1400
+ const design = this.#dock.design();
1401
+ const color =
1402
+ design === 'material'
1403
+ ? 'var(--primary-foreground)'
1404
+ : design === 'glass' && !this.active()
1405
+ ? 'var(--glass-foreground)'
1406
+ : 'var(--primary)';
1407
+ return iconSvg(this.icon(), this.#resolveColor(color));
1408
+ });
1409
+
1410
+ protected readonly fade = computed(() => {
1411
+ const hidden = this.#dock.actionHidden();
1412
+ return {
1413
+ opacity: hidden ? '0' : null,
1414
+ transition:
1415
+ hidden && !this.#settings.reduceMotion() ? ICON_OUT_TRANSITION : null,
1416
+ };
1417
+ });
1418
+
1419
+ /**
1420
+ * RouterLink navigates on the same tap through its own listener, when the
1421
+ * button has a route: one without a route opens the dock's search, if it
1422
+ * has one.
1423
+ */
1424
+ protected onTap(): void {
1425
+ this.pressed.emit();
1426
+ this.#dock.actionPressed(this.#link.urlTree !== null);
1427
+ }
1428
+
1429
+ protected onPressStart(): void {
1430
+ this.#pressAnim?.cancel();
1431
+ this.#pressAnim = pressDown(this.#host.nativeElement);
1432
+ }
1433
+
1434
+ protected onPressEnd(): void {
1435
+ this.#pressAnim?.cancel();
1436
+ this.#pressAnim = pressRelease(this.#host.nativeElement);
1437
+ }
1438
+ }