@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.
- package/dist/commands/add.js +9 -1
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/add.test.js +38 -3
- package/dist/commands/add.test.js.map +1 -1
- package/dist/commands/doctor.js +258 -21
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +425 -11
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/init.d.ts +2 -2
- package/dist/commands/init.js +38 -18
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +105 -2
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/theme.js +1 -1
- package/dist/commands/theme.js.map +1 -1
- package/dist/commands/update.d.ts +7 -0
- package/dist/commands/update.js +194 -50
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/update.test.js +570 -3
- package/dist/commands/update.test.js.map +1 -1
- package/dist/lockfile.d.ts +17 -0
- package/dist/lockfile.js +56 -1
- package/dist/lockfile.js.map +1 -1
- package/dist/lockfile.test.d.ts +1 -0
- package/dist/lockfile.test.js +65 -0
- package/dist/lockfile.test.js.map +1 -0
- package/dist/registry.d.ts +6 -0
- package/dist/registry.js +119 -11
- package/dist/registry.js.map +1 -1
- package/dist/registry.test.js +61 -6
- package/dist/registry.test.js.map +1 -1
- package/dist/test-utils.d.ts +27 -0
- package/dist/test-utils.js +45 -1
- package/dist/test-utils.js.map +1 -1
- package/dist/testing/fire-lynx-event.d.ts +14 -0
- package/dist/testing/fire-lynx-event.js +19 -0
- package/dist/testing/fire-lynx-event.js.map +1 -0
- package/dist/testing/fire-lynx-event.test.d.ts +1 -0
- package/dist/testing/fire-lynx-event.test.js +44 -0
- package/dist/testing/fire-lynx-event.test.js.map +1 -0
- package/dist/testing/lynx-main-thread.d.ts +22 -0
- package/dist/testing/lynx-main-thread.js +11 -0
- package/dist/testing/lynx-main-thread.js.map +1 -0
- package/dist/testing/lynx-main-thread.test.d.ts +1 -0
- package/dist/testing/lynx-main-thread.test.js +24 -0
- package/dist/testing/lynx-main-thread.test.js.map +1 -0
- package/dist/testing/set-input-signal.d.ts +5 -4
- package/dist/testing/set-input-signal.js +5 -4
- package/dist/testing/set-input-signal.js.map +1 -1
- package/dist/ui/boolean-inputs.test.ts +76 -0
- package/dist/ui/components/accordion/accordion.test.ts +158 -144
- package/dist/ui/components/accordion/accordion.ts +19 -14
- package/dist/ui/components/action-sheet/action-sheet.test.ts +637 -183
- package/dist/ui/components/action-sheet/action-sheet.ts +339 -55
- package/dist/ui/components/action-sheet/index.ts +1 -0
- package/dist/ui/components/alert/alert.test.ts +67 -19
- package/dist/ui/components/alert/alert.ts +14 -16
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +740 -129
- package/dist/ui/components/alert-dialog/alert-dialog.ts +672 -58
- package/dist/ui/components/alert-dialog/index.ts +5 -0
- package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
- package/dist/ui/components/area-chart/area-chart.ts +3 -2
- package/dist/ui/components/avatar/avatar.test.ts +89 -45
- package/dist/ui/components/badge/badge.test.ts +57 -23
- package/dist/ui/components/badge/badge.ts +14 -15
- package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +1314 -273
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1046 -119
- package/dist/ui/components/bottom-sheet/index.ts +7 -1
- package/dist/ui/components/button/button.test.ts +646 -2
- package/dist/ui/components/button/button.ts +443 -64
- package/dist/ui/components/button/index.ts +2 -0
- package/dist/ui/components/button-group/button-group.test.ts +528 -2
- package/dist/ui/components/button-group/button-group.ts +428 -70
- package/dist/ui/components/button-group/index.ts +3 -0
- package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
- package/dist/ui/components/card/card.test.ts +97 -61
- package/dist/ui/components/card/card.ts +2 -1
- package/dist/ui/components/chart/chart-presets.test.ts +197 -1
- package/dist/ui/components/chart/chart-presets.ts +14 -7
- package/dist/ui/components/chart/chart-theme.ts +12 -0
- package/dist/ui/components/chart/chart.test.ts +1031 -0
- package/dist/ui/components/chart/chart.ts +66 -46
- package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
- package/dist/ui/components/checkbox/checkbox.ts +25 -24
- package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
- package/dist/ui/components/collapsible/collapsible.ts +21 -15
- package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
- package/dist/ui/components/context-menu/context-menu.ts +110 -0
- package/dist/ui/components/context-menu/index.ts +1 -0
- package/dist/ui/components/dialog/dialog.test.ts +210 -152
- package/dist/ui/components/dialog/dialog.ts +35 -29
- package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
- package/dist/ui/components/dock/dock-scroll.ts +142 -0
- package/dist/ui/components/dock/dock-search.test.ts +594 -0
- package/dist/ui/components/dock/dock.test.ts +752 -0
- package/dist/ui/components/dock/dock.ts +1438 -0
- package/dist/ui/components/dock/index.ts +2 -0
- package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
- package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
- package/dist/ui/components/dynamic-color/index.ts +5 -0
- package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
- package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
- package/dist/ui/components/glass/concentric-radius.ts +17 -0
- package/dist/ui/components/glass/glass-design.test.ts +78 -0
- package/dist/ui/components/glass/glass-design.ts +119 -0
- package/dist/ui/components/glass/glass.test.ts +376 -0
- package/dist/ui/components/glass/glass.ts +430 -0
- package/dist/ui/components/glass/index.ts +19 -0
- package/dist/ui/components/icon/icon-theme.test.ts +64 -0
- package/dist/ui/components/icon/icon-theme.ts +70 -16
- package/dist/ui/components/icon/icon.test.ts +52 -41
- package/dist/ui/components/input/input.test.ts +178 -101
- package/dist/ui/components/input/input.ts +17 -13
- package/dist/ui/components/label/label.test.ts +11 -24
- package/dist/ui/components/label/label.ts +8 -2
- package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
- package/dist/ui/components/line-chart/line-chart.ts +3 -2
- package/dist/ui/components/list/list.test.ts +80 -49
- package/dist/ui/components/list/list.ts +5 -4
- package/dist/ui/components/menu/index.ts +25 -0
- package/dist/ui/components/menu/menu-placement.test.ts +153 -0
- package/dist/ui/components/menu/menu-placement.ts +159 -0
- package/dist/ui/components/menu/menu-popup.test.ts +636 -0
- package/dist/ui/components/menu/menu-popup.ts +565 -0
- package/dist/ui/components/menu/menu.test.ts +523 -0
- package/dist/ui/components/menu/menu.ts +455 -0
- package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
- package/dist/ui/components/nav-drawer/nav-drawer.ts +75 -58
- package/dist/ui/components/navigation-bar/index.ts +1 -0
- package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
- package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
- package/dist/ui/components/pagination/pagination.test.ts +136 -86
- package/dist/ui/components/popover/index.ts +9 -0
- package/dist/ui/components/popover/popover-panel.test.ts +735 -0
- package/dist/ui/components/popover/popover-panel.ts +603 -0
- package/dist/ui/components/popover/popover.test.ts +286 -0
- package/dist/ui/components/popover/popover.ts +207 -0
- package/dist/ui/components/progress/progress.test.ts +102 -46
- package/dist/ui/components/progress/progress.ts +54 -55
- package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
- package/dist/ui/components/radio-group/radio-group.ts +26 -25
- package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
- package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
- package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
- package/dist/ui/components/search-field/index.ts +15 -0
- package/dist/ui/components/search-field/keyboard.test.ts +147 -0
- package/dist/ui/components/search-field/keyboard.ts +123 -0
- package/dist/ui/components/search-field/search-field.test.ts +549 -0
- package/dist/ui/components/search-field/search-field.ts +628 -0
- package/dist/ui/components/segmented-control/index.ts +8 -0
- package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
- package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
- package/dist/ui/components/select/select.test.ts +138 -78
- package/dist/ui/components/select/select.ts +19 -12
- package/dist/ui/components/separator/separator.test.ts +18 -17
- package/dist/ui/components/sheet/sheet.test.ts +151 -11
- package/dist/ui/components/sheet/sheet.ts +68 -5
- package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
- package/dist/ui/components/skeleton/skeleton.ts +10 -16
- package/dist/ui/components/slider/index.ts +1 -0
- package/dist/ui/components/slider/slider.test.ts +695 -0
- package/dist/ui/components/slider/slider.ts +901 -0
- package/dist/ui/components/spinner/spinner.test.ts +64 -46
- package/dist/ui/components/spinner/spinner.ts +16 -19
- package/dist/ui/components/switch/index.ts +1 -1
- package/dist/ui/components/switch/switch.test.ts +534 -48
- package/dist/ui/components/switch/switch.ts +613 -48
- package/dist/ui/components/tab-bar/index.ts +1 -0
- package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
- package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
- package/dist/ui/components/tabs/index.ts +7 -1
- package/dist/ui/components/tabs/tabs.test.ts +484 -150
- package/dist/ui/components/tabs/tabs.ts +344 -149
- package/dist/ui/components/textarea/textarea.test.ts +206 -100
- package/dist/ui/components/textarea/textarea.ts +16 -13
- package/dist/ui/components/toast/toast.test.ts +434 -350
- package/dist/ui/components/toast/toast.ts +42 -51
- package/dist/ui/components/toggle/index.ts +1 -0
- package/dist/ui/components/toggle/toggle.test.ts +559 -146
- package/dist/ui/components/toggle/toggle.ts +241 -26
- package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
- package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
- package/dist/ui/components/toolbar/index.ts +12 -0
- package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
- package/dist/ui/components/toolbar/toolbar.ts +535 -0
- package/dist/ui/theme/chart-colors.test.ts +8 -3
- package/dist/ui/theme/dark.css +38 -2
- package/dist/ui/theme/default.css +55 -7
- package/dist/ui/theme/icon-colors.test.ts +8 -3
- package/dist/ui/theme/rgb-channels.test.ts +84 -0
- package/dist/ui/theme/tailwind-build.test.ts +114 -0
- package/dist/ui/theme/tailwind-css.test.ts +83 -0
- package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
- package/dist/ui/theme/tailwind-plugin.ts +93 -29
- package/dist/ui/theme/tailwind.css +113 -0
- package/dist/ui/utils/animate.test.ts +13 -1
- package/dist/utils/analyze.d.ts +19 -0
- package/dist/utils/analyze.js +16 -0
- package/dist/utils/analyze.js.map +1 -1
- package/dist/utils/analyze.test.js +14 -1
- package/dist/utils/analyze.test.js.map +1 -1
- package/dist/utils/installed.d.ts +25 -1
- package/dist/utils/installed.js +33 -0
- package/dist/utils/installed.js.map +1 -1
- package/dist/utils/installed.test.js +55 -1
- package/dist/utils/installed.test.js.map +1 -1
- package/dist/utils/resolve-paths.d.ts +4 -4
- package/dist/utils/resolve-paths.js +5 -7
- package/dist/utils/resolve-paths.js.map +1 -1
- package/dist/utils/resolve-paths.test.js +9 -5
- package/dist/utils/resolve-paths.test.js.map +1 -1
- package/dist/utils/tailwind.d.ts +45 -0
- package/dist/utils/tailwind.js +90 -0
- package/dist/utils/tailwind.js.map +1 -0
- package/dist/utils/tailwind.test.d.ts +1 -0
- package/dist/utils/tailwind.test.js +134 -0
- package/dist/utils/tailwind.test.js.map +1 -0
- package/package.json +9 -1
- package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
- package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
- package/dist/ui/components/button/button-render.test.ts +0 -132
- package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
- package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
- package/dist/ui/components/chart/chart-render.test.ts +0 -730
- package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
- package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
|
@@ -0,0 +1,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
|
+
}
|