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