@blotch/dolan 22.2.4 → 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.test.js +2 -0
- package/dist/commands/add.test.js.map +1 -1
- package/dist/commands/doctor.js +78 -21
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +167 -26
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/init.test.js +4 -7
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/update.d.ts +3 -1
- package/dist/commands/update.js +78 -15
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/update.test.js +257 -2
- package/dist/commands/update.test.js.map +1 -1
- package/dist/lockfile.d.ts +10 -0
- package/dist/lockfile.js +20 -1
- package/dist/lockfile.js.map +1 -1
- package/dist/lockfile.test.js +43 -3
- package/dist/lockfile.test.js.map +1 -1
- package/dist/registry.js +73 -11
- package/dist/registry.js.map +1 -1
- package/dist/registry.test.js +16 -3
- package/dist/registry.test.js.map +1 -1
- package/dist/test-utils.d.ts +24 -2
- package/dist/test-utils.js +41 -2
- package/dist/test-utils.js.map +1 -1
- package/dist/ui/boolean-inputs.test.ts +76 -0
- package/dist/ui/components/action-sheet/action-sheet.test.ts +370 -1
- package/dist/ui/components/action-sheet/action-sheet.ts +306 -28
- package/dist/ui/components/action-sheet/index.ts +1 -0
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +552 -8
- package/dist/ui/components/alert-dialog/alert-dialog.ts +644 -36
- package/dist/ui/components/alert-dialog/index.ts +5 -0
- package/dist/ui/components/area-chart/area-chart.ts +3 -2
- package/dist/ui/components/badge/badge.ts +2 -1
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +968 -3
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1021 -95
- package/dist/ui/components/bottom-sheet/index.ts +7 -1
- package/dist/ui/components/button/button.test.ts +36 -1
- package/dist/ui/components/button/button.ts +17 -7
- package/dist/ui/components/button-group/button-group.test.ts +53 -9
- package/dist/ui/components/button-group/button-group.ts +67 -21
- package/dist/ui/components/card/card.test.ts +16 -0
- package/dist/ui/components/card/card.ts +2 -1
- package/dist/ui/components/chart/chart-presets.ts +6 -6
- package/dist/ui/components/chart/chart.test.ts +14 -0
- package/dist/ui/components/chart/chart.ts +10 -3
- package/dist/ui/components/checkbox/checkbox.ts +2 -1
- package/dist/ui/components/collapsible/collapsible.ts +2 -1
- package/dist/ui/components/dialog/dialog.ts +6 -6
- package/dist/ui/components/dock/dock-scroll.ts +9 -0
- package/dist/ui/components/dock/dock-search.test.ts +594 -0
- package/dist/ui/components/dock/dock.test.ts +4 -1
- package/dist/ui/components/dock/dock.ts +529 -51
- 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/glass/glass.test.ts +39 -1
- package/dist/ui/components/glass/glass.ts +87 -17
- package/dist/ui/components/glass/index.ts +1 -0
- package/dist/ui/components/icon/icon-theme.test.ts +28 -0
- package/dist/ui/components/icon/icon-theme.ts +38 -7
- package/dist/ui/components/input/input.ts +2 -1
- package/dist/ui/components/label/label.ts +8 -2
- package/dist/ui/components/line-chart/line-chart.ts +3 -2
- package/dist/ui/components/list/list.ts +5 -4
- package/dist/ui/components/menu/menu-popup.test.ts +18 -14
- package/dist/ui/components/menu/menu-popup.ts +24 -3
- package/dist/ui/components/menu/menu.test.ts +99 -5
- package/dist/ui/components/menu/menu.ts +74 -34
- package/dist/ui/components/nav-drawer/nav-drawer.ts +3 -2
- 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/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.ts +2 -1
- package/dist/ui/components/radio-group/radio-group.ts +2 -1
- 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.ts +2 -1
- package/dist/ui/components/sheet/sheet.test.ts +90 -3
- package/dist/ui/components/sheet/sheet.ts +59 -4
- 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/switch/index.ts +1 -1
- package/dist/ui/components/switch/switch.test.ts +464 -48
- package/dist/ui/components/switch/switch.ts +613 -43
- package/dist/ui/components/tabs/index.ts +7 -1
- package/dist/ui/components/tabs/tabs.test.ts +278 -2
- package/dist/ui/components/tabs/tabs.ts +233 -30
- package/dist/ui/components/textarea/textarea.ts +2 -1
- package/dist/ui/components/toggle/index.ts +1 -0
- package/dist/ui/components/toggle/toggle.test.ts +310 -4
- 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/utils/analyze.d.ts +10 -5
- package/dist/utils/analyze.js +3 -2
- package/dist/utils/analyze.js.map +1 -1
- package/dist/utils/installed.d.ts +13 -1
- package/dist/utils/installed.js +17 -0
- package/dist/utils/installed.js.map +1 -1
- package/dist/utils/installed.test.js +39 -1
- package/dist/utils/installed.test.js.map +1 -1
- package/dist/utils/tailwind.d.ts +25 -5
- package/dist/utils/tailwind.js +53 -14
- package/dist/utils/tailwind.js.map +1 -1
- package/dist/utils/tailwind.test.js +85 -22
- package/dist/utils/tailwind.test.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,91 +2,661 @@ import {
|
|
|
2
2
|
type ElementRef,
|
|
3
3
|
Component,
|
|
4
4
|
ViewEncapsulation,
|
|
5
|
+
booleanAttribute,
|
|
5
6
|
computed,
|
|
7
|
+
inject,
|
|
6
8
|
input,
|
|
7
9
|
model,
|
|
8
10
|
output,
|
|
11
|
+
signal,
|
|
9
12
|
viewChild,
|
|
10
13
|
} from '@angular/core';
|
|
11
|
-
import {
|
|
14
|
+
import { type FormCheckboxControl } from '@angular/forms/signals';
|
|
15
|
+
import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
|
|
12
16
|
|
|
13
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type AnimationHandle,
|
|
19
|
+
DURATION,
|
|
20
|
+
EASING,
|
|
21
|
+
springTranslateX,
|
|
22
|
+
} from '../../utils/animate';
|
|
14
23
|
import { cn } from '../../utils/cn';
|
|
24
|
+
import { UiGlass } from '../glass/glass';
|
|
25
|
+
import {
|
|
26
|
+
type GlassDesign,
|
|
27
|
+
injectGlassDesign,
|
|
28
|
+
injectSystemDrawsGlass,
|
|
29
|
+
} from '../glass/glass-design';
|
|
30
|
+
import { injectColorScheme } from '../icon/icon-theme';
|
|
15
31
|
|
|
16
32
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
|
|
33
|
+
* `'default'` is the flat switch in the theme's colors on every platform.
|
|
34
|
+
* `'glass'` takes each platform's own: iOS 26's, whose thumb turns into a
|
|
35
|
+
* Liquid Glass lens while it's touched; iOS 15 to 25's; and Material 3's on
|
|
36
|
+
* Android (see GlassDesign).
|
|
37
|
+
*/
|
|
38
|
+
export type SwitchVariant = 'default' | 'glass';
|
|
39
|
+
|
|
40
|
+
/** What the switch draws: the default look, or a platform's design. */
|
|
41
|
+
type SwitchLook = 'default' | GlassDesign;
|
|
42
|
+
|
|
43
|
+
/** A switch's sizes in one look, in px. */
|
|
44
|
+
type SwitchGeometry = {
|
|
45
|
+
readonly width: number;
|
|
46
|
+
readonly height: number;
|
|
47
|
+
/** The thumb's size at rest. Material's grows when the switch is on. */
|
|
48
|
+
readonly thumbWidth: number;
|
|
49
|
+
readonly thumbHeight: number;
|
|
50
|
+
/** From the track's edge to the thumb at either end. */
|
|
51
|
+
readonly inset: number;
|
|
52
|
+
/** The thumb's size while it's touched: iOS 26's lens, or a stretch. */
|
|
53
|
+
readonly pressedWidth: number;
|
|
54
|
+
readonly pressedHeight: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The sizes of each look:
|
|
22
59
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
60
|
+
* - `default`: a 44 by 24px track and a 20px thumb, 2px in from the ends.
|
|
61
|
+
* - `glass`: iOS 26's UISwitch, measured on the iOS 27 Simulator: a 63 by
|
|
62
|
+
* 28pt track and a 37 by 24pt capsule thumb, which grows 1.6 times into a
|
|
63
|
+
* 59 by 38pt glass lens, taller than the track, while it's touched.
|
|
64
|
+
* - `frosted`: UISwitch from iOS 7 to 25: a 51 by 31pt track and a 27pt
|
|
65
|
+
* round thumb that stretches 6pt wider while it's touched.
|
|
66
|
+
* - `material`: Material 3's switch: a 52 by 32dp track whose handle is 16dp
|
|
67
|
+
* off, 24dp on and 28dp while it's pressed, centered 16dp from the ends.
|
|
30
68
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
69
|
+
* Every size is in px, not rem: Lynx's rem is 14px (`DEFAULT_FONT_SIZE_DP`),
|
|
70
|
+
* not the web's 16px, so a rem-sized track would be smaller than the px
|
|
71
|
+
* offsets the thumb moves by, and the thumb would overshoot it.
|
|
72
|
+
*/
|
|
73
|
+
const SWITCH_GEOMETRY: Readonly<Record<SwitchLook, SwitchGeometry>> = {
|
|
74
|
+
default: {
|
|
75
|
+
width: 44,
|
|
76
|
+
height: 24,
|
|
77
|
+
thumbWidth: 20,
|
|
78
|
+
thumbHeight: 20,
|
|
79
|
+
inset: 2,
|
|
80
|
+
pressedWidth: 20,
|
|
81
|
+
pressedHeight: 20,
|
|
82
|
+
},
|
|
83
|
+
glass: {
|
|
84
|
+
width: 63,
|
|
85
|
+
height: 28,
|
|
86
|
+
thumbWidth: 37,
|
|
87
|
+
thumbHeight: 24,
|
|
88
|
+
inset: 2,
|
|
89
|
+
pressedWidth: 59,
|
|
90
|
+
pressedHeight: 38,
|
|
91
|
+
},
|
|
92
|
+
frosted: {
|
|
93
|
+
width: 51,
|
|
94
|
+
height: 31,
|
|
95
|
+
thumbWidth: 27,
|
|
96
|
+
thumbHeight: 27,
|
|
97
|
+
inset: 2,
|
|
98
|
+
pressedWidth: 33,
|
|
99
|
+
pressedHeight: 27,
|
|
100
|
+
},
|
|
101
|
+
material: {
|
|
102
|
+
width: 52,
|
|
103
|
+
height: 32,
|
|
104
|
+
thumbWidth: 24,
|
|
105
|
+
thumbHeight: 24,
|
|
106
|
+
inset: 4,
|
|
107
|
+
pressedWidth: 28,
|
|
108
|
+
pressedHeight: 28,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Material's handle while the switch is off, smaller than when it's on. */
|
|
113
|
+
const MATERIAL_OFF_THUMB = 16;
|
|
114
|
+
|
|
115
|
+
/** The pressed state layer around Material's handle. */
|
|
116
|
+
const MATERIAL_HALO = 40;
|
|
117
|
+
|
|
118
|
+
/** How much smaller than the thumb the hidden lens is, on each side. */
|
|
119
|
+
const LENS_INSET = 2;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* iOS's track colors, in the app's color scheme as UIKit draws them in the
|
|
123
|
+
* phone's: the system green UISwitch takes when it's on (whatever the app's
|
|
124
|
+
* tint), and a translucent gray when it's off, measured on the iOS 27
|
|
125
|
+
* Simulator for iOS 26 and Apple's fill colors before it.
|
|
126
|
+
*/
|
|
127
|
+
const IOS_TRACK = {
|
|
128
|
+
glass: {
|
|
129
|
+
light: { on: '#34c759', off: 'rgba(120, 120, 128, 0.43)' },
|
|
130
|
+
dark: { on: '#30d158', off: 'rgba(120, 120, 128, 0.58)' },
|
|
131
|
+
},
|
|
132
|
+
frosted: {
|
|
133
|
+
light: { on: '#34c759', off: 'rgba(120, 120, 128, 0.16)' },
|
|
134
|
+
dark: { on: '#30d158', off: 'rgba(120, 120, 128, 0.32)' },
|
|
135
|
+
},
|
|
136
|
+
} as const;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The shadow under the white thumb before iOS 26. iOS 26's draws none. An
|
|
140
|
+
* inline style, because Tailwind's shadow classes draw nothing on Lynx.
|
|
141
|
+
*/
|
|
142
|
+
const FROSTED_THUMB_SHADOW =
|
|
143
|
+
'0 3px 8px rgba(0, 0, 0, 0.15), 0 3px 1px rgba(0, 0, 0, 0.06)';
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* How far a finger moves before a press becomes a drag, in px. Below it, a
|
|
147
|
+
* press is a tap, which toggles the switch, or the start of a scroll.
|
|
148
|
+
*/
|
|
149
|
+
const DRAG_SLOP = 6;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The swipe angles, in degrees from the right, that the switch keeps from
|
|
153
|
+
* the scroll views around it: within 45 degrees of sideways. On iOS a
|
|
154
|
+
* scroll view's pan runs alongside Lynx's touch events, so a slightly
|
|
155
|
+
* diagonal drag would also scroll the page (`consume-slide-event` fails
|
|
156
|
+
* those pans). A swipe up or down still scrolls.
|
|
157
|
+
*/
|
|
158
|
+
const SIDEWAYS: [number, number][] = [
|
|
159
|
+
[-45, 45],
|
|
160
|
+
[135, 180],
|
|
161
|
+
[-180, -135],
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A CSS transition of each property, for an inline `transition`.
|
|
166
|
+
*/
|
|
167
|
+
const transitionOf = (
|
|
168
|
+
properties: readonly string[],
|
|
169
|
+
duration: number,
|
|
170
|
+
easing: string,
|
|
171
|
+
): string =>
|
|
172
|
+
properties
|
|
173
|
+
.map((property) => `${property} ${duration}ms ${easing}`)
|
|
174
|
+
.join(', ');
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The platform designs' thumb slides across, with a slight overshoot, and
|
|
178
|
+
* follows a dragging finger at once. The default look springs with
|
|
179
|
+
* `springTranslateX` instead, as it always has.
|
|
180
|
+
*/
|
|
181
|
+
const THUMB_SLIDE = transitionOf(
|
|
182
|
+
['transform'],
|
|
183
|
+
DURATION.normal,
|
|
184
|
+
EASING.springSubtle,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The thumb's change of size and opacity as it's touched and let go. Lynx
|
|
189
|
+
* can't animate a corner radius, but a capsule's is half its height at any
|
|
190
|
+
* size (`rounded-full`), so it stays a capsule as it grows.
|
|
191
|
+
*/
|
|
192
|
+
const MORPH = transitionOf(
|
|
193
|
+
['left', 'top', 'width', 'height', 'opacity'],
|
|
194
|
+
DURATION.fast,
|
|
195
|
+
EASING.standard,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
/** The track's color change as the switch turns on or off. */
|
|
199
|
+
const TRACK_FADE = transitionOf(
|
|
200
|
+
['background-color'],
|
|
201
|
+
DURATION.normal,
|
|
202
|
+
EASING.standard,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
/** Where a touch or the mouse is, in what Lynx sends with its events. */
|
|
206
|
+
type PointerEvent = {
|
|
207
|
+
readonly touches?: readonly { readonly x?: number; readonly y?: number }[];
|
|
208
|
+
readonly x?: number;
|
|
209
|
+
readonly y?: number;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The position of a touch, or of the mouse on the web. A desktop browser
|
|
214
|
+
* sends no touch events for a mouse, so the switch listens for both. Only
|
|
215
|
+
* how far it moves matters, so the switch doesn't need to know where it is
|
|
216
|
+
* on screen: Lynx gives `x` from the element touched on iOS and Android and
|
|
217
|
+
* from the `<lynx-view>` on the web, both fixed for the press.
|
|
218
|
+
*/
|
|
219
|
+
const pointOf = (event: PointerEvent): { x: number; y: number } | null => {
|
|
220
|
+
const source = event.touches?.[0] ?? event;
|
|
221
|
+
return typeof source.x === 'number' && typeof source.y === 'number'
|
|
222
|
+
? { x: source.x, y: source.y }
|
|
223
|
+
: null;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* A length in px, for a style object.
|
|
33
228
|
*/
|
|
34
|
-
const
|
|
35
|
-
const THUMB_OFFSET_ON = 22;
|
|
229
|
+
const px = (value: number): string => `${value}px`;
|
|
36
230
|
|
|
231
|
+
/** A press in progress: where it started and where the thumb was. */
|
|
232
|
+
type Press = {
|
|
233
|
+
readonly startX: number;
|
|
234
|
+
readonly startY: number;
|
|
235
|
+
/** The thumb's center when the press started. */
|
|
236
|
+
readonly from: number;
|
|
237
|
+
/** It moved sideways past DRAG_SLOP, so the thumb follows it. */
|
|
238
|
+
dragging: boolean;
|
|
239
|
+
/** It moved up or down first: a scroll, which the switch leaves alone. */
|
|
240
|
+
scrolling: boolean;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* An on/off switch for a setting:
|
|
245
|
+
*
|
|
246
|
+
* ```html
|
|
247
|
+
* <ui-switch [(checked)]="wifi" accessibility-label="Wi-Fi" />
|
|
248
|
+
* <ui-switch variant="glass" [(checked)]="wifi" accessibility-label="Wi-Fi" />
|
|
249
|
+
* ```
|
|
250
|
+
*
|
|
251
|
+
* A tap toggles it, and so does dragging the thumb across: it lands on the
|
|
252
|
+
* side it's let go nearer, as on iOS. A drag only starts once the finger
|
|
253
|
+
* moves sideways, so a scroll that starts on the switch leaves it alone.
|
|
254
|
+
*
|
|
255
|
+
* `checked` is a `model()`, so it works with `[(checked)]`, with Signal
|
|
256
|
+
* Forms' `[formField]` (it implements FormCheckboxControl) and, since
|
|
257
|
+
* Angular 22, with reactive forms' `[formControl]` and `formControlName`
|
|
258
|
+
* and with `[(ngModel)]`.
|
|
259
|
+
*
|
|
260
|
+
* Screen readers find it as a button with its label and an "On" or "Off"
|
|
261
|
+
* value, and Android's as a switch.
|
|
262
|
+
*/
|
|
37
263
|
@Component({
|
|
38
264
|
selector: 'ui-switch',
|
|
39
|
-
|
|
40
|
-
imports: [LYNX_ELEMENTS],
|
|
265
|
+
imports: [LYNX_ELEMENTS, UiGlass],
|
|
41
266
|
encapsulation: ViewEncapsulation.None,
|
|
267
|
+
// Only the root takes touches: what's inside ignores them, so every touch
|
|
268
|
+
// and its `x` belong to the switch.
|
|
269
|
+
//
|
|
270
|
+
// The track is drawn by a view of its own under the thumb, not by the root.
|
|
271
|
+
// Lynx for Web's glass refracts the nearest ancestor that paints a
|
|
272
|
+
// background, cut to that ancestor's box, and the lens is bigger than the
|
|
273
|
+
// track: as the lens's parent, the track would leave the lens blank where
|
|
274
|
+
// it reaches past it. iOS's glass shows what's on screen either way.
|
|
275
|
+
//
|
|
276
|
+
// Mouse events are bound beside the touch events because a desktop
|
|
277
|
+
// browser never sends touches for a mouse (see the bottom sheet). There's
|
|
278
|
+
// no mouseleave: Chrome doesn't deliver it to Lynx for Web, so a move with
|
|
279
|
+
// no button held ends the drag instead.
|
|
280
|
+
//
|
|
281
|
+
// iOS 26's lens sits under the solid thumb, a little smaller, so it's
|
|
282
|
+
// hidden until the switch is touched: then the thumb fades as the lens
|
|
283
|
+
// grows. It's always there rather than added on touch, since iOS's glass
|
|
284
|
+
// pops in at once when it's added.
|
|
42
285
|
template: `
|
|
43
|
-
<view
|
|
44
|
-
|
|
286
|
+
<view
|
|
287
|
+
[class]="rootClass()"
|
|
288
|
+
[style]="rootStyle()"
|
|
289
|
+
hit-slop="8px"
|
|
290
|
+
[consume-slide-event]="sideways"
|
|
291
|
+
[accessibility-element]="true"
|
|
292
|
+
[accessibility-traits]="disabled() ? 'button,disabled' : 'button'"
|
|
293
|
+
accessibility-role-description="switch"
|
|
294
|
+
[accessibility-label]="accessibilityLabel()"
|
|
295
|
+
[accessibility-value]="shownChecked() ? 'On' : 'Off'"
|
|
296
|
+
(bindtouchstart)="onPressStart($event)"
|
|
297
|
+
(bindtouchmove)="onPressMove($event)"
|
|
298
|
+
(bindtouchend)="onPressEnd()"
|
|
299
|
+
(bindtouchcancel)="onPressCancel()"
|
|
300
|
+
(bindmousedown)="onPressStart($event)"
|
|
301
|
+
(bindmousemove)="onMouseMove($event)"
|
|
302
|
+
(bindmouseup)="onPressEnd()"
|
|
303
|
+
(bindtap)="onTap()"
|
|
304
|
+
>
|
|
305
|
+
<view
|
|
306
|
+
[user-interaction-enabled]="false"
|
|
307
|
+
[class]="trackClass()"
|
|
308
|
+
[style]="trackStyle()"
|
|
309
|
+
></view>
|
|
310
|
+
@if (look() === 'material') {
|
|
311
|
+
@if (!shownChecked()) {
|
|
312
|
+
<view
|
|
313
|
+
[user-interaction-enabled]="false"
|
|
314
|
+
class="rounded-full border-2 border-muted-foreground absolute bottom-0 left-0 right-0 top-0"
|
|
315
|
+
></view>
|
|
316
|
+
}
|
|
317
|
+
<view
|
|
318
|
+
[user-interaction-enabled]="false"
|
|
319
|
+
[class]="haloClass()"
|
|
320
|
+
[style]="frames().halo"
|
|
321
|
+
></view>
|
|
322
|
+
}
|
|
323
|
+
@if (look() === 'glass') {
|
|
324
|
+
<blur-view
|
|
325
|
+
ui-glass
|
|
326
|
+
variant="clear"
|
|
327
|
+
class="absolute"
|
|
328
|
+
[user-interaction-enabled]="false"
|
|
329
|
+
[style]="frames().lens"
|
|
330
|
+
></blur-view>
|
|
331
|
+
}
|
|
332
|
+
<view
|
|
333
|
+
#thumb
|
|
334
|
+
[user-interaction-enabled]="false"
|
|
335
|
+
[class]="thumbClass()"
|
|
336
|
+
[style]="frames().thumb"
|
|
337
|
+
></view>
|
|
45
338
|
</view>
|
|
46
339
|
`,
|
|
47
340
|
})
|
|
48
|
-
export class UiSwitch {
|
|
341
|
+
export class UiSwitch implements FormCheckboxControl {
|
|
49
342
|
readonly checked = model(false);
|
|
50
|
-
readonly disabled = input(false);
|
|
343
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
344
|
+
/** `'default'` or `'glass'`, each platform's own design; see SwitchVariant. */
|
|
345
|
+
readonly variant = input<SwitchVariant>('default');
|
|
346
|
+
/** What a screen reader calls the switch, such as "Wi-Fi". */
|
|
347
|
+
readonly accessibilityLabel = input<string | undefined>(undefined, {
|
|
348
|
+
alias: 'accessibility-label',
|
|
349
|
+
});
|
|
51
350
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
52
351
|
|
|
352
|
+
/** The new state, after each tap or drag that changes it. */
|
|
53
353
|
readonly changed = output<boolean>();
|
|
54
354
|
|
|
55
|
-
|
|
355
|
+
protected readonly sideways = SIDEWAYS;
|
|
356
|
+
|
|
357
|
+
/** The thumb is always in the template, so the switch can always slide it. */
|
|
56
358
|
readonly thumbRef = viewChild.required<ElementRef>('thumb');
|
|
359
|
+
|
|
360
|
+
readonly #settings = inject(LynxAccessibilitySettings);
|
|
361
|
+
readonly #design = injectGlassDesign();
|
|
362
|
+
readonly #scheme = injectColorScheme();
|
|
363
|
+
readonly #systemDrawsGlass = injectSystemDrawsGlass();
|
|
57
364
|
#activeAnimation?: AnimationHandle;
|
|
365
|
+
#press: Press | null = null;
|
|
366
|
+
/** The press was a drag, so the tap that may follow it isn't one. */
|
|
367
|
+
#suppressTap = false;
|
|
368
|
+
|
|
369
|
+
/** The switch is touched: iOS 26's thumb is a lens, Material's grows. */
|
|
370
|
+
readonly #holding = signal(false);
|
|
371
|
+
/** Where a dragging finger has the thumb's center, or null. */
|
|
372
|
+
readonly #dragCenter = signal<number | null>(null);
|
|
58
373
|
|
|
59
|
-
|
|
374
|
+
readonly look = computed<SwitchLook>(() =>
|
|
375
|
+
this.variant() === 'glass' ? this.#design() : 'default',
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
readonly #geometry = computed(() => SWITCH_GEOMETRY[this.look()]);
|
|
379
|
+
|
|
380
|
+
/** The thumb's center when the switch is off, and when it's on. */
|
|
381
|
+
readonly #ends = computed(() => {
|
|
382
|
+
const { width, thumbWidth, inset } = this.#geometry();
|
|
383
|
+
const off = inset + thumbWidth / 2;
|
|
384
|
+
return { off, on: width - off };
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* On or off as the switch shows it: the side a dragging thumb is on, so
|
|
389
|
+
* the track changes color as the thumb crosses the middle, as on iOS.
|
|
390
|
+
*/
|
|
391
|
+
readonly shownChecked = computed(() => {
|
|
392
|
+
const center = this.#dragCenter();
|
|
393
|
+
if (center === null) return this.checked();
|
|
394
|
+
const { off, on } = this.#ends();
|
|
395
|
+
return center > (off + on) / 2;
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
readonly #animate = computed(() => !this.#settings.reduceMotion());
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Where the runtime draws the glass (Android and the web), Reduce
|
|
402
|
+
* Transparency turns it into an opaque surface, which as a lens would hide
|
|
403
|
+
* the track. The solid thumb grows instead. iOS adapts its own glass.
|
|
404
|
+
*/
|
|
405
|
+
readonly #lensIsGlass = computed(
|
|
406
|
+
() => this.#systemDrawsGlass || !this.#settings.reduceTransparency(),
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
protected readonly rootClass = computed(() =>
|
|
60
410
|
cn(
|
|
61
|
-
'
|
|
62
|
-
this.
|
|
63
|
-
|
|
411
|
+
'relative',
|
|
412
|
+
this.disabled() &&
|
|
413
|
+
(this.look() === 'material' ? 'opacity-[0.38]' : 'opacity-50'),
|
|
64
414
|
this.userClass(),
|
|
65
415
|
),
|
|
66
416
|
);
|
|
67
417
|
|
|
68
|
-
protected readonly
|
|
69
|
-
|
|
70
|
-
|
|
418
|
+
protected readonly rootStyle = computed(() => {
|
|
419
|
+
const { width, height } = this.#geometry();
|
|
420
|
+
return { width: px(width), height: px(height) };
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
protected readonly trackClass = computed(() => {
|
|
424
|
+
const look = this.look();
|
|
425
|
+
const on = this.shownChecked();
|
|
426
|
+
return cn(
|
|
427
|
+
'rounded-full absolute bottom-0 left-0 right-0 top-0',
|
|
428
|
+
look === 'default' && (on ? 'bg-primary' : 'bg-input'),
|
|
429
|
+
look === 'material' && (on ? 'bg-primary' : 'bg-secondary'),
|
|
430
|
+
);
|
|
431
|
+
});
|
|
71
432
|
|
|
72
|
-
protected readonly
|
|
73
|
-
()
|
|
74
|
-
|
|
433
|
+
protected readonly trackStyle = computed(() => {
|
|
434
|
+
const look = this.look();
|
|
435
|
+
const colors =
|
|
436
|
+
look === 'glass' || look === 'frosted'
|
|
437
|
+
? IOS_TRACK[look][this.#scheme()]
|
|
438
|
+
: null;
|
|
439
|
+
let background: string | null = null;
|
|
440
|
+
if (colors) background = this.shownChecked() ? colors.on : colors.off;
|
|
441
|
+
return {
|
|
442
|
+
'background-color': background,
|
|
443
|
+
transition: this.#animate() ? TRACK_FADE : 'none',
|
|
444
|
+
};
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
protected readonly thumbClass = computed(() => {
|
|
448
|
+
switch (this.look()) {
|
|
449
|
+
case 'default':
|
|
450
|
+
return 'rounded-full bg-background absolute';
|
|
451
|
+
case 'material':
|
|
452
|
+
return cn(
|
|
453
|
+
'rounded-full absolute',
|
|
454
|
+
this.shownChecked() ? 'bg-primary-foreground' : 'bg-muted-foreground',
|
|
455
|
+
);
|
|
456
|
+
default:
|
|
457
|
+
return 'rounded-full bg-white absolute';
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
protected readonly haloClass = computed(() =>
|
|
462
|
+
cn(
|
|
463
|
+
'rounded-full absolute',
|
|
464
|
+
this.shownChecked() ? 'bg-primary' : 'bg-foreground',
|
|
465
|
+
),
|
|
75
466
|
);
|
|
76
467
|
|
|
468
|
+
/**
|
|
469
|
+
* The frames of the thumb and what goes with it (iOS 26's lens, Material's
|
|
470
|
+
* pressed halo). Each sits at the track's left edge, centered on it, and a
|
|
471
|
+
* transform moves it along, so a change of size keeps it centered while a
|
|
472
|
+
* drag moves it.
|
|
473
|
+
*/
|
|
474
|
+
protected readonly frames = computed(() => {
|
|
475
|
+
const look = this.look();
|
|
476
|
+
const geometry = this.#geometry();
|
|
477
|
+
const on = this.shownChecked();
|
|
478
|
+
const holding = this.#holding();
|
|
479
|
+
const dragging = this.#dragCenter() !== null;
|
|
480
|
+
const center =
|
|
481
|
+
this.#dragCenter() ?? this.#ends()[this.checked() ? 'on' : 'off'];
|
|
482
|
+
const animate = this.#animate();
|
|
483
|
+
// The default look springs its thumb with an animation, which plays
|
|
484
|
+
// over the transform (see #settle).
|
|
485
|
+
const slide = animate && !dragging && look !== 'default';
|
|
486
|
+
const transition =
|
|
487
|
+
[animate && MORPH, slide && THUMB_SLIDE].filter(Boolean).join(', ') ||
|
|
488
|
+
'none';
|
|
489
|
+
|
|
490
|
+
const shape = (width: number, height: number) => {
|
|
491
|
+
// A stretched thumb keeps inside the track, growing toward the middle
|
|
492
|
+
// from the end it's at, as iOS's did before iOS 26.
|
|
493
|
+
const inner = geometry.width - geometry.inset * 2;
|
|
494
|
+
const left =
|
|
495
|
+
look === 'frosted'
|
|
496
|
+
? Math.min(
|
|
497
|
+
Math.max(center - width / 2, geometry.inset),
|
|
498
|
+
geometry.inset + inner - width,
|
|
499
|
+
) - center
|
|
500
|
+
: -width / 2;
|
|
501
|
+
return {
|
|
502
|
+
left: px(left),
|
|
503
|
+
top: px((geometry.height - height) / 2),
|
|
504
|
+
width: px(width),
|
|
505
|
+
height: px(height),
|
|
506
|
+
transform: `translateX(${center}px)`,
|
|
507
|
+
transition,
|
|
508
|
+
};
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
let rest = shape(geometry.thumbWidth, geometry.thumbHeight);
|
|
512
|
+
if (look === 'material' && !on) {
|
|
513
|
+
rest = shape(MATERIAL_OFF_THUMB, MATERIAL_OFF_THUMB);
|
|
514
|
+
}
|
|
515
|
+
const held = shape(geometry.pressedWidth, geometry.pressedHeight);
|
|
516
|
+
const lensShows = look === 'glass' && holding && this.#lensIsGlass();
|
|
517
|
+
const thumb = holding && look !== 'default' ? held : rest;
|
|
518
|
+
return {
|
|
519
|
+
thumb: {
|
|
520
|
+
...thumb,
|
|
521
|
+
opacity: lensShows ? '0' : '1',
|
|
522
|
+
'box-shadow': look === 'frosted' ? FROSTED_THUMB_SHADOW : null,
|
|
523
|
+
},
|
|
524
|
+
// Hidden under the thumb at rest: a little smaller, so its rim
|
|
525
|
+
// doesn't show around it.
|
|
526
|
+
lens: lensShows
|
|
527
|
+
? held
|
|
528
|
+
: shape(
|
|
529
|
+
geometry.thumbWidth - LENS_INSET * 2,
|
|
530
|
+
geometry.thumbHeight - LENS_INSET * 2,
|
|
531
|
+
),
|
|
532
|
+
// Material's state layer: the thumb's color at a tenth, while it's
|
|
533
|
+
// pressed.
|
|
534
|
+
halo: {
|
|
535
|
+
...shape(MATERIAL_HALO, MATERIAL_HALO),
|
|
536
|
+
opacity: holding ? '0.1' : '0',
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Turns the switch on or off, sliding the thumb across. Does nothing while
|
|
543
|
+
* it's disabled.
|
|
544
|
+
*/
|
|
77
545
|
toggle(): void {
|
|
78
546
|
if (this.disabled()) return;
|
|
547
|
+
const from = this.#ends()[this.checked() ? 'on' : 'off'];
|
|
548
|
+
this.#settle(!this.checked(), from);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
protected onPressStart(event: PointerEvent): void {
|
|
552
|
+
// A new press starts a new tap, so the last drag's tap can't eat it.
|
|
553
|
+
this.#suppressTap = false;
|
|
554
|
+
const point = pointOf(event);
|
|
555
|
+
if (this.disabled() || !point) return;
|
|
556
|
+
this.#press = {
|
|
557
|
+
startX: point.x,
|
|
558
|
+
startY: point.y,
|
|
559
|
+
from: this.#ends()[this.checked() ? 'on' : 'off'],
|
|
560
|
+
dragging: false,
|
|
561
|
+
scrolling: false,
|
|
562
|
+
};
|
|
563
|
+
this.#holding.set(true);
|
|
564
|
+
}
|
|
79
565
|
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
const
|
|
566
|
+
protected onPressMove(event: PointerEvent): void {
|
|
567
|
+
const press = this.#press;
|
|
568
|
+
const point = pointOf(event);
|
|
569
|
+
if (!press || !point || press.scrolling) return;
|
|
570
|
+
const dx = point.x - press.startX;
|
|
571
|
+
if (!press.dragging) {
|
|
572
|
+
const dy = Math.abs(point.y - press.startY);
|
|
573
|
+
if (Math.abs(dx) >= DRAG_SLOP && Math.abs(dx) > dy) {
|
|
574
|
+
press.dragging = true;
|
|
575
|
+
// The default look's spring would hold the thumb where it ended.
|
|
576
|
+
this.#activeAnimation?.cancel();
|
|
577
|
+
} else if (dy >= DRAG_SLOP) {
|
|
578
|
+
// A scroll, which the scroll view may or may not take over: Android
|
|
579
|
+
// doesn't always cancel the touch. Either way, the switch sits it
|
|
580
|
+
// out.
|
|
581
|
+
press.scrolling = true;
|
|
582
|
+
this.#holding.set(false);
|
|
583
|
+
return;
|
|
584
|
+
} else {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const { off, on } = this.#ends();
|
|
589
|
+
this.#dragCenter.set(Math.min(on, Math.max(off, press.from + dx)));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* The mouse's twin of onPressMove, on the web. A move with no button held
|
|
594
|
+
* means the button came up outside the switch, where it couldn't see it.
|
|
595
|
+
*/
|
|
596
|
+
protected onMouseMove(event: PointerEvent & { buttons?: number }): void {
|
|
597
|
+
if (event.buttons === 0) {
|
|
598
|
+
this.onPressEnd();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
this.onPressMove(event);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The finger lifted or the mouse button came up. A drag lands on the
|
|
606
|
+
* side it's let go nearer; the tap Lynx may still send for a short drag,
|
|
607
|
+
* and the click the browser sends after a mouse drag, aren't taps.
|
|
608
|
+
*/
|
|
609
|
+
protected onPressEnd(): void {
|
|
610
|
+
if (this.#release()) this.#suppressTap = true;
|
|
611
|
+
}
|
|
83
612
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
this.#
|
|
613
|
+
/**
|
|
614
|
+
* A scroll view took the touch over: a drag still lands on a side.
|
|
615
|
+
*/
|
|
616
|
+
protected onPressCancel(): void {
|
|
617
|
+
this.#release();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
protected onTap(): void {
|
|
621
|
+
if (this.#suppressTap) {
|
|
622
|
+
this.#suppressTap = false;
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
this.toggle();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Ends the press, landing a drag, and says whether it was one.
|
|
630
|
+
*/
|
|
631
|
+
#release(): boolean {
|
|
632
|
+
const center = this.#dragCenter();
|
|
633
|
+
this.#press = null;
|
|
634
|
+
this.#holding.set(false);
|
|
635
|
+
if (center === null) return false;
|
|
636
|
+
const on = this.shownChecked();
|
|
637
|
+
this.#dragCenter.set(null);
|
|
638
|
+
this.#settle(on, center);
|
|
639
|
+
return true;
|
|
640
|
+
}
|
|
89
641
|
|
|
642
|
+
/**
|
|
643
|
+
* Turns the switch on or off from where the thumb is now. The platform
|
|
644
|
+
* designs' thumb slides with a CSS transition. The default look's
|
|
645
|
+
* springs across, squishing halfway, with `element.animate()`, which
|
|
646
|
+
* plays over its transform without holding it after (`fill: 'none'`), so
|
|
647
|
+
* a later change of `checked` from a binding still moves it.
|
|
648
|
+
*/
|
|
649
|
+
#settle(next: boolean, from: number): void {
|
|
650
|
+
if (this.look() === 'default' && this.#animate()) {
|
|
651
|
+
this.#activeAnimation?.cancel();
|
|
652
|
+
this.#activeAnimation = springTranslateX(
|
|
653
|
+
this.thumbRef().nativeElement,
|
|
654
|
+
from,
|
|
655
|
+
this.#ends()[next ? 'on' : 'off'],
|
|
656
|
+
{ fill: 'none' },
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
if (next === this.checked()) return;
|
|
90
660
|
this.checked.set(next);
|
|
91
661
|
this.changed.emit(next);
|
|
92
662
|
}
|