@blotch/dolan 22.2.4 → 22.2.6
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
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Component,
|
|
3
|
+
computed,
|
|
4
|
+
DestroyRef,
|
|
5
|
+
inject,
|
|
6
|
+
Injectable,
|
|
7
|
+
InjectionToken,
|
|
8
|
+
Injector,
|
|
9
|
+
type Signal,
|
|
10
|
+
signal,
|
|
11
|
+
ViewEncapsulation,
|
|
12
|
+
} from '@angular/core';
|
|
13
|
+
import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
|
|
14
|
+
|
|
15
|
+
import { DURATION, EASING } from '../../utils/animate';
|
|
16
|
+
import { cn } from '../../utils/cn';
|
|
17
|
+
import { UiGlass } from '../glass/glass';
|
|
18
|
+
import { injectGlassDesign } from '../glass/glass-design';
|
|
19
|
+
import { injectColorScheme } from '../icon/icon-theme';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What a segmented control's host tells the control it draws: `ui-tabs-list`
|
|
23
|
+
* and `ui-toggle-group` with `variant="segmented"`, or a control of your own.
|
|
24
|
+
* Provide it under {@link UI_SEGMENTED_HOST} next to {@link UiSegmentedControl}.
|
|
25
|
+
*/
|
|
26
|
+
export type SegmentedHost = {
|
|
27
|
+
/** Whether the host is drawn as a segmented control right now. */
|
|
28
|
+
readonly segmented: Signal<boolean>;
|
|
29
|
+
/** Every segment's value, in the order the segments are laid out. */
|
|
30
|
+
readonly segmentValues: Signal<readonly string[]>;
|
|
31
|
+
/** The selected segments' values. */
|
|
32
|
+
readonly selectedValues: Signal<readonly string[]>;
|
|
33
|
+
/**
|
|
34
|
+
* Whether several segments can be selected at once. One indicator can't
|
|
35
|
+
* mark several segments, so then each selected segment draws its own
|
|
36
|
+
* thumb, and nothing slides.
|
|
37
|
+
*/
|
|
38
|
+
readonly multiple: Signal<boolean>;
|
|
39
|
+
readonly disabled: Signal<boolean>;
|
|
40
|
+
/** Selects a segment the user dragged the indicator to. */
|
|
41
|
+
select(value: string): void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const UI_SEGMENTED_HOST = new InjectionToken<SegmentedHost>(
|
|
45
|
+
'UI_SEGMENTED_HOST',
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Where a segment sits in the row, which a segment tells
|
|
50
|
+
* UiSegmentedControl.segmentClass(): Material rounds the group's ends on the
|
|
51
|
+
* first and the last. A segment drawn by `@for` passes
|
|
52
|
+
* `{ first: $first, last: $last }`, and a host's content child finds it
|
|
53
|
+
* with {@link segmentPlace}.
|
|
54
|
+
*/
|
|
55
|
+
export type SegmentPlace = { readonly first: boolean; readonly last: boolean };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A segment's place among its host's segments, in order. It's found from
|
|
59
|
+
* the segments themselves rather than their values: a segment that `@for`
|
|
60
|
+
* adds has no value yet while the segments before it are redrawn, and
|
|
61
|
+
* Angular throws NG0950 for a required input read that early.
|
|
62
|
+
*/
|
|
63
|
+
export const segmentPlace = <T>(
|
|
64
|
+
segments: readonly T[],
|
|
65
|
+
segment: T,
|
|
66
|
+
): SegmentPlace => ({
|
|
67
|
+
first: segments[0] === segment,
|
|
68
|
+
last: segments[segments.length - 1] === segment,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** A segment's place in the row, in px from the track's left edge. */
|
|
72
|
+
type SegmentFrame = { readonly left: number; readonly width: number };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A drag of the indicator: the segment it started on, where the pointer
|
|
76
|
+
* went down, and how far it has moved since.
|
|
77
|
+
*/
|
|
78
|
+
type Drag = {
|
|
79
|
+
readonly start: number;
|
|
80
|
+
readonly startX: number;
|
|
81
|
+
readonly dx: number;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The glass lens the indicator lifts into on iOS 26, as UIKit's own
|
|
86
|
+
* segmented control does while its selection moves:
|
|
87
|
+
*
|
|
88
|
+
* - `'off'`: no lens; the indicator is the opaque thumb.
|
|
89
|
+
* - `'rest'`: the lens is drawn under the thumb at the thumb's size, about
|
|
90
|
+
* to grow or just shrunk back. The opaque thumb hides it, so the glass
|
|
91
|
+
* never visibly pops in or out (Lynx can't animate glass appearing).
|
|
92
|
+
* - `'open'`: the lens is larger than the thumb, and the thumb has faded out
|
|
93
|
+
* to show it.
|
|
94
|
+
*/
|
|
95
|
+
type LensPhase = 'off' | 'rest' | 'open';
|
|
96
|
+
|
|
97
|
+
/** What Lynx's touch and mouse events carry, as far as a drag reads them. */
|
|
98
|
+
type PointerEventLike = {
|
|
99
|
+
readonly touches?: readonly { readonly pageX?: number }[];
|
|
100
|
+
readonly pageX?: number;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What a layoutchange event carries: its size is in `detail` on iOS and the
|
|
105
|
+
* web, and in `params` on older Android.
|
|
106
|
+
*/
|
|
107
|
+
type LayoutEventLike = {
|
|
108
|
+
readonly detail?: { readonly width?: number };
|
|
109
|
+
readonly params?: { readonly width?: number };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* How far the thumb sits inside its segment, in px: 2pt in UIKit's
|
|
114
|
+
* segmented control, before iOS 26 and since.
|
|
115
|
+
*/
|
|
116
|
+
const THUMB_INSET = 2;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* How far the lens reaches past the thumb on each side, in px. Measured from
|
|
120
|
+
* UIKit's segmented control on the iOS 27 Simulator: its 89×28pt thumb lifts
|
|
121
|
+
* into a lens about 113×38pt, taller than the 32pt track.
|
|
122
|
+
*/
|
|
123
|
+
const LENS_GROWTH = { x: 12, y: 5 } as const;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* How far, in px, the pointer moves before a press on the selected segment
|
|
127
|
+
* counts as a drag, which chooses the segment it ends over. A press that
|
|
128
|
+
* moves less is left to the tap.
|
|
129
|
+
*/
|
|
130
|
+
const DRAG_SLOP = 6;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The thumb's shadow before iOS 26, from Apple's iOS 13 design resources: a
|
|
134
|
+
* soft drop under a tight one.
|
|
135
|
+
*/
|
|
136
|
+
const FROSTED_THUMB_SHADOW =
|
|
137
|
+
'0 3px 8px rgba(0, 0, 0, 0.12), 0 3px 1px rgba(0, 0, 0, 0.04)';
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The thumb's shadow on iOS 26. UIKit draws none, but its track is a little
|
|
141
|
+
* darker than the theme's `muted`, so a hairline of shadow keeps the white
|
|
142
|
+
* thumb apart from the track.
|
|
143
|
+
*/
|
|
144
|
+
const GLASS_THUMB_SHADOW = '0 1px 2px rgba(0, 0, 0, 0.1)';
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The thumb's color in a dark theme: the gray UIKit draws it in, sampled from
|
|
148
|
+
* the iOS 27 Simulator (#5A5A5F), lighter than the track. No theme token is
|
|
149
|
+
* that color (`muted` is the track itself), so it's this one-off color.
|
|
150
|
+
*/
|
|
151
|
+
const DARK_THUMB_COLOR = 'rgba(90, 90, 95, 1)';
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* How the indicator moves to a new segment: on iOS 26 with a slight
|
|
155
|
+
* overshoot, as the liquid lens settles, and before it with the gentle
|
|
156
|
+
* spring of iOS's sheets.
|
|
157
|
+
*/
|
|
158
|
+
const GLASS_SLIDE = `left ${DURATION.slow}ms ${EASING.springSubtle}, width ${DURATION.slow}ms ${EASING.springSubtle}, opacity ${DURATION.fast}ms ${EASING.standard}`;
|
|
159
|
+
const FROSTED_SLIDE = `left ${DURATION.normal}ms ${EASING.sheet}, width ${DURATION.normal}ms ${EASING.sheet}, opacity ${DURATION.fast}ms ${EASING.standard}`;
|
|
160
|
+
|
|
161
|
+
/** The lens grows and shrinks quickly, overshooting a little. */
|
|
162
|
+
const LENS_TRANSITION = ['left', 'right', 'top', 'bottom']
|
|
163
|
+
.map((side) => `${side} ${DURATION.fast}ms ${EASING.springSubtle}`)
|
|
164
|
+
.join(', ');
|
|
165
|
+
|
|
166
|
+
/** The thumb fades as the lens takes over, and shrinks while held. */
|
|
167
|
+
const THUMB_TRANSITION = `opacity ${DURATION.fast}ms ${EASING.standard}, transform ${DURATION.fast}ms ${EASING.standard}`;
|
|
168
|
+
|
|
169
|
+
/** The separators fade as the thumb comes and goes beside them. */
|
|
170
|
+
const SEPARATOR_TRANSITION = `opacity ${DURATION.fast}ms ${EASING.standard}`;
|
|
171
|
+
|
|
172
|
+
/** Material's segments change color as they're selected. */
|
|
173
|
+
const MATERIAL_FILL_TRANSITION = `background-color ${DURATION.normal}ms ${EASING.standard}`;
|
|
174
|
+
|
|
175
|
+
/** How strongly the separators before iOS 26 show (`muted-foreground`). */
|
|
176
|
+
const SEPARATOR_OPACITY = '0.3';
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Material's state layer, shown while a segment is pressed: its content's
|
|
180
|
+
* color (the segment points `--foreground` at it) at 10%.
|
|
181
|
+
*/
|
|
182
|
+
const STATE_LAYER_CLASS =
|
|
183
|
+
'h-full w-full bg-foreground opacity-10 absolute left-0 top-0';
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* An unselected Material segment's corners: small inside the group, and
|
|
187
|
+
* round at its ends, as Material's connected buttons are. The end segments
|
|
188
|
+
* draw the ends themselves, rather than the track clipping them, so that
|
|
189
|
+
* the outline Increase Contrast gives them follows the curve. Half the
|
|
190
|
+
* track's 40px height, not `rounded-l-full`: corners too large for a box
|
|
191
|
+
* are all scaled down together until they fit, so the small inner ones
|
|
192
|
+
* would come out square.
|
|
193
|
+
*/
|
|
194
|
+
const materialCorners = ({ first, last }: SegmentPlace): string => {
|
|
195
|
+
if (first && last) return 'rounded-full';
|
|
196
|
+
if (first) return 'rounded-l-[20px] rounded-r-[8px]';
|
|
197
|
+
if (last) return 'rounded-l-[8px] rounded-r-[20px]';
|
|
198
|
+
return 'rounded-[8px]';
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Stops a timer that may not have been started: the native main thread's
|
|
203
|
+
* `clearTimeout()` throws for `undefined`, which the web ignores.
|
|
204
|
+
*/
|
|
205
|
+
const stop = (timer: ReturnType<typeof setTimeout> | undefined): void => {
|
|
206
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A CSS length in px.
|
|
211
|
+
*/
|
|
212
|
+
const px = (value: number): string => `${value}px`;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The pointer's horizontal position on the page: the first touch's for a
|
|
216
|
+
* touch event, the mouse's for a mouse event (the web preview), or
|
|
217
|
+
* undefined for an event that has none.
|
|
218
|
+
*/
|
|
219
|
+
const pointerX = (event: unknown): number | undefined => {
|
|
220
|
+
const pointer = event as PointerEventLike;
|
|
221
|
+
const x = (pointer.touches?.[0] ?? pointer).pageX;
|
|
222
|
+
return typeof x === 'number' ? x : undefined;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The width a layoutchange event reports, if it reports one.
|
|
227
|
+
*/
|
|
228
|
+
const layoutWidth = (event: unknown): number | undefined => {
|
|
229
|
+
const layout = event as LayoutEventLike;
|
|
230
|
+
const width = (layout.detail ?? layout.params)?.width;
|
|
231
|
+
return typeof width === 'number' ? width : undefined;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The shared logic of a segmented control: the selection indicator that
|
|
236
|
+
* slides between the segments, its drag, and each platform's look of the
|
|
237
|
+
* track and the segments. `ui-tabs-list` and `ui-toggle-group` provide one
|
|
238
|
+
* for `variant="segmented"`, and their segments draw from it.
|
|
239
|
+
*
|
|
240
|
+
* Provide it on the control's host component, with the host itself under
|
|
241
|
+
* {@link UI_SEGMENTED_HOST}:
|
|
242
|
+
*
|
|
243
|
+
* ```ts
|
|
244
|
+
* providers: [
|
|
245
|
+
* UiSegmentedControl,
|
|
246
|
+
* { provide: UI_SEGMENTED_HOST, useFactory: () => inject(MyControl) },
|
|
247
|
+
* ],
|
|
248
|
+
* ```
|
|
249
|
+
*
|
|
250
|
+
* Put a `<ui-segmented-indicator>` first in the track (it draws under the
|
|
251
|
+
* segments), and have each segment report its width from `layoutchange`
|
|
252
|
+
* (see `measure()`) and its touches (`press()`, `move()`, `release()`,
|
|
253
|
+
* `cancel()`, `tap()`), and take its classes from `segmentClass()` with
|
|
254
|
+
* its place in the row.
|
|
255
|
+
*
|
|
256
|
+
* Its look follows the platform (see GlassDesign):
|
|
257
|
+
*
|
|
258
|
+
* - `'glass'` (iOS 26 and later, the web, Android with the glass opt-in):
|
|
259
|
+
* iOS 26's capsule track with an opaque thumb. When the selection moves,
|
|
260
|
+
* and while the thumb is held and dragged, the thumb lifts into a Liquid
|
|
261
|
+
* Glass lens that slides to the new segment and settles back, as UIKit's
|
|
262
|
+
* segmented control does. The lens is the only glass: the segments are
|
|
263
|
+
* plain.
|
|
264
|
+
* - `'frosted'` (iOS 15 to 25): the segmented control of iOS 13 to 18, a
|
|
265
|
+
* gray track with rounded corners, hairline separators and a raised white
|
|
266
|
+
* thumb that slides, and shrinks a little while held.
|
|
267
|
+
* - `'material'` (Android): Material 3 Expressive's connected button group,
|
|
268
|
+
* which replaces Material's deprecated segmented buttons: segments 2dp
|
|
269
|
+
* apart with small inner corners, the selected one filled with the primary
|
|
270
|
+
* color and fully round. Nothing slides, as nothing does in Material's
|
|
271
|
+
* group.
|
|
272
|
+
*
|
|
273
|
+
* The segments share the track's width equally, unless an app sizes them
|
|
274
|
+
* itself. The indicator follows each segment's measured width, so segments
|
|
275
|
+
* of different widths, and a control that's resized or rotated, work too.
|
|
276
|
+
* Under Reduce Motion the indicator jumps rather than slides, and doesn't
|
|
277
|
+
* lift into glass.
|
|
278
|
+
*/
|
|
279
|
+
@Injectable()
|
|
280
|
+
export class UiSegmentedControl {
|
|
281
|
+
readonly #injector = inject(Injector);
|
|
282
|
+
#hostRef?: SegmentedHost;
|
|
283
|
+
readonly #settings = inject(LynxAccessibilitySettings);
|
|
284
|
+
readonly #scheme = injectColorScheme();
|
|
285
|
+
|
|
286
|
+
/** The design on this platform, which the segments follow. */
|
|
287
|
+
readonly design = injectGlassDesign();
|
|
288
|
+
|
|
289
|
+
/** Each segment's measured width, by value. */
|
|
290
|
+
readonly #widths = signal<Readonly<Record<string, number>>>({});
|
|
291
|
+
/**
|
|
292
|
+
* Whether the indicator has been placed on the measured segments. It's
|
|
293
|
+
* placed without a transition, so it doesn't slide in from where it sat
|
|
294
|
+
* before the widths were known.
|
|
295
|
+
*/
|
|
296
|
+
readonly #placed = signal(false);
|
|
297
|
+
readonly #drag = signal<Drag | null>(null);
|
|
298
|
+
/** The segment a finger (or the mouse) is down on. */
|
|
299
|
+
readonly #pressed = signal<string | null>(null);
|
|
300
|
+
readonly #lens = signal<LensPhase>('off');
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Set when a drag chose a segment, so the tap Lynx may send as the finger
|
|
304
|
+
* lifts doesn't choose the segment the drag started on again.
|
|
305
|
+
*/
|
|
306
|
+
#dragChose = false;
|
|
307
|
+
#openTimer?: ReturnType<typeof setTimeout>;
|
|
308
|
+
#settleTimer?: ReturnType<typeof setTimeout>;
|
|
309
|
+
#placeTimer?: ReturnType<typeof setTimeout>;
|
|
310
|
+
|
|
311
|
+
protected readonly cleanup = inject(DestroyRef).onDestroy(() => {
|
|
312
|
+
stop(this.#openTimer);
|
|
313
|
+
stop(this.#settleTimer);
|
|
314
|
+
stop(this.#placeTimer);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The host, looked up when first read rather than injected: the host
|
|
319
|
+
* component injects this control in its own fields, before it exists to
|
|
320
|
+
* be injected itself.
|
|
321
|
+
*/
|
|
322
|
+
#host(): SegmentedHost {
|
|
323
|
+
return (this.#hostRef ??= this.#injector.get(UI_SEGMENTED_HOST));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Whether the host is drawn as a segmented control. */
|
|
327
|
+
readonly enabled = computed(() => this.#host().segmented());
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Whether one indicator slides between the segments: for a single
|
|
331
|
+
* selection, in Apple's designs. Material's segments fill themselves.
|
|
332
|
+
*/
|
|
333
|
+
readonly slides = computed(
|
|
334
|
+
() =>
|
|
335
|
+
this.enabled() &&
|
|
336
|
+
!this.#host().multiple() &&
|
|
337
|
+
this.design() !== 'material',
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
readonly #dark = computed(() => this.#scheme() === 'dark');
|
|
341
|
+
readonly #animates = computed(() => !this.#settings.reduceMotion());
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Whether the indicator lifts into glass as it moves: on glass, unless
|
|
345
|
+
* Reduce Motion is on (lifting is motion) or Reduce Transparency is
|
|
346
|
+
* (there'd be no glass to see through).
|
|
347
|
+
*/
|
|
348
|
+
readonly #lifts = computed(
|
|
349
|
+
() =>
|
|
350
|
+
this.slides() &&
|
|
351
|
+
this.design() === 'glass' &&
|
|
352
|
+
this.#animates() &&
|
|
353
|
+
!this.#settings.reduceTransparency(),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Whether the track draws under its segments (see UiSegmentedIndicator):
|
|
358
|
+
* the indicator and the separators of Apple's designs.
|
|
359
|
+
*/
|
|
360
|
+
readonly underlay = computed(
|
|
361
|
+
() => this.enabled() && this.design() !== 'material',
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The selected value, for a single selection: only a single selection has
|
|
366
|
+
* the indicator and its drag, which read this.
|
|
367
|
+
*/
|
|
368
|
+
readonly #selected = computed(() => this.#host().selectedValues()[0]);
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Every segment's frame, in order, from their measured widths, and the
|
|
372
|
+
* width of them all; null until every segment has been measured. The
|
|
373
|
+
* segments sit side by side with no gap, so each starts where the last
|
|
374
|
+
* ended. Widths, not positions, are measured: Lynx reports a layout's
|
|
375
|
+
* position relative to the whole page, which goes stale for a segment
|
|
376
|
+
* whose own layout didn't change when the control moved.
|
|
377
|
+
*/
|
|
378
|
+
readonly #layout = computed(() => {
|
|
379
|
+
const widths = this.#widths();
|
|
380
|
+
const frames: SegmentFrame[] = [];
|
|
381
|
+
let left = 0;
|
|
382
|
+
for (const value of this.#host().segmentValues()) {
|
|
383
|
+
const width = widths[value];
|
|
384
|
+
if (width === undefined) return null;
|
|
385
|
+
frames.push({ left, width });
|
|
386
|
+
left += width;
|
|
387
|
+
}
|
|
388
|
+
return { frames, total: left };
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Where the indicator is while it's dragged: it keeps its width and
|
|
393
|
+
* follows the pointer, kept within the track. `under` is the segment its
|
|
394
|
+
* middle is over, which it moves to when released.
|
|
395
|
+
*/
|
|
396
|
+
readonly #dragFrame = computed(() => {
|
|
397
|
+
// The drag first: a segment reads this as it's drawn, and the layout
|
|
398
|
+
// reads every segment's value, which segments drawn by `@for` don't all
|
|
399
|
+
// have yet (Angular throws NG0950 for a required input read too early).
|
|
400
|
+
const drag = this.#drag();
|
|
401
|
+
if (!drag) return null;
|
|
402
|
+
const layout = this.#layout();
|
|
403
|
+
if (!layout) return null;
|
|
404
|
+
// The segment it started on is gone if segments were removed mid-drag.
|
|
405
|
+
const from = layout.frames[drag.start];
|
|
406
|
+
if (!from) return null;
|
|
407
|
+
const { width, left: start } = from;
|
|
408
|
+
const left = Math.min(Math.max(start + drag.dx, 0), layout.total - width);
|
|
409
|
+
const middle = left + width / 2;
|
|
410
|
+
const under = layout.frames.findIndex(
|
|
411
|
+
(frame) => middle < frame.left + frame.width,
|
|
412
|
+
);
|
|
413
|
+
return { left, width, under };
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The segment that shows as selected: the one the indicator is over while
|
|
418
|
+
* it's dragged, which takes the selected label's weight as it passes.
|
|
419
|
+
*/
|
|
420
|
+
readonly #highlighted = computed(() => {
|
|
421
|
+
const dragged = this.#dragFrame();
|
|
422
|
+
return dragged
|
|
423
|
+
? this.#host().segmentValues()[dragged.under]
|
|
424
|
+
: this.#selected();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// The track and the indicator
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
/** The track's classes: the row the segments sit in. */
|
|
432
|
+
readonly trackClass = computed(() => {
|
|
433
|
+
const design = this.design();
|
|
434
|
+
return cn(
|
|
435
|
+
'flex-row flex relative',
|
|
436
|
+
design === 'material'
|
|
437
|
+
? // No clip: the end segments round the group's ends themselves
|
|
438
|
+
// (see materialCorners), and a clip would cut their outlines.
|
|
439
|
+
'h-[40px] gap-[2px]'
|
|
440
|
+
: cn(
|
|
441
|
+
'h-[32px] bg-muted',
|
|
442
|
+
design === 'glass' ? 'rounded-full' : 'rounded-[9px]',
|
|
443
|
+
),
|
|
444
|
+
this.#host().disabled() && 'opacity-50',
|
|
445
|
+
);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The indicator's frame in the track: over the selected segment, or under
|
|
450
|
+
* the pointer while dragged. Until every segment is measured, it takes an
|
|
451
|
+
* equal share of the track, which is right for segments of equal width,
|
|
452
|
+
* the default. It slides when the selection moves, but jumps under Reduce
|
|
453
|
+
* Motion, when it's first placed and while it follows a drag.
|
|
454
|
+
*/
|
|
455
|
+
readonly frameStyle = computed(() => {
|
|
456
|
+
const values = this.#host().segmentValues();
|
|
457
|
+
const selected = this.#selected();
|
|
458
|
+
const index = selected === undefined ? -1 : values.indexOf(selected);
|
|
459
|
+
const layout = this.#layout();
|
|
460
|
+
const dragged = this.#dragFrame();
|
|
461
|
+
let left: string;
|
|
462
|
+
let width: string;
|
|
463
|
+
if (dragged) {
|
|
464
|
+
left = px(dragged.left);
|
|
465
|
+
width = px(dragged.width);
|
|
466
|
+
} else if (layout && index !== -1) {
|
|
467
|
+
left = px(layout.frames[index]!.left);
|
|
468
|
+
width = px(layout.frames[index]!.width);
|
|
469
|
+
} else {
|
|
470
|
+
const share = 100 / Math.max(values.length, 1);
|
|
471
|
+
left = `${Math.max(index, 0) * share}%`;
|
|
472
|
+
width = `${share}%`;
|
|
473
|
+
}
|
|
474
|
+
const slides = this.#animates() && this.#placed() && !dragged;
|
|
475
|
+
// The transition goes first, so it's in place for the frame it animates.
|
|
476
|
+
return {
|
|
477
|
+
transition: slides
|
|
478
|
+
? this.design() === 'glass'
|
|
479
|
+
? GLASS_SLIDE
|
|
480
|
+
: FROSTED_SLIDE
|
|
481
|
+
: 'none',
|
|
482
|
+
left,
|
|
483
|
+
width,
|
|
484
|
+
opacity: index === -1 ? '0' : '1',
|
|
485
|
+
};
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* The separators between the segments before iOS 26, as each one's
|
|
490
|
+
* inline style: a hairline on each boundary, hidden beside a selected
|
|
491
|
+
* segment (or the thumb, while it's dragged). None until the segments are
|
|
492
|
+
* measured.
|
|
493
|
+
*/
|
|
494
|
+
readonly separators = computed(() => {
|
|
495
|
+
const layout = this.#layout();
|
|
496
|
+
if (this.design() !== 'frosted' || !layout) return [];
|
|
497
|
+
const values = this.#host().segmentValues();
|
|
498
|
+
const transition = this.#animates() ? SEPARATOR_TRANSITION : 'none';
|
|
499
|
+
return layout.frames.slice(1).map((frame, index) => ({
|
|
500
|
+
transition,
|
|
501
|
+
// Centered on the boundary.
|
|
502
|
+
left: px(frame.left - 0.5),
|
|
503
|
+
opacity:
|
|
504
|
+
this.isOn(values[index]!) || this.isOn(values[index + 1]!)
|
|
505
|
+
? '0'
|
|
506
|
+
: SEPARATOR_OPACITY,
|
|
507
|
+
}));
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
/** Whether the glass lens is drawn (see LensPhase). */
|
|
511
|
+
readonly lensShown = computed(() => this.#lens() !== 'off');
|
|
512
|
+
|
|
513
|
+
/** The lens's frame in the indicator: the thumb's, or larger when open. */
|
|
514
|
+
readonly lensStyle = computed(() => {
|
|
515
|
+
const open = this.#lens() === 'open';
|
|
516
|
+
const x = open ? THUMB_INSET - LENS_GROWTH.x : THUMB_INSET;
|
|
517
|
+
const y = open ? THUMB_INSET - LENS_GROWTH.y : THUMB_INSET;
|
|
518
|
+
return {
|
|
519
|
+
transition: LENS_TRANSITION,
|
|
520
|
+
left: px(x),
|
|
521
|
+
right: px(x),
|
|
522
|
+
top: px(y),
|
|
523
|
+
bottom: px(y),
|
|
524
|
+
};
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* The thumb's look, for the indicator and for a segment that draws its
|
|
529
|
+
* own (see #ownThumb): a capsule on iOS 26, round corners before; white in
|
|
530
|
+
* a light theme, or in a dark one the gray UIKit draws (see
|
|
531
|
+
* DARK_THUMB_COLOR), opaque so the lens under it stays hidden at rest.
|
|
532
|
+
*/
|
|
533
|
+
readonly #thumbLook = computed(() => ({
|
|
534
|
+
class: cn(
|
|
535
|
+
this.design() === 'glass' ? 'rounded-full' : 'rounded-[7px]',
|
|
536
|
+
!this.#dark() && 'bg-background',
|
|
537
|
+
this.#settings.increaseContrast() && 'border border-foreground',
|
|
538
|
+
),
|
|
539
|
+
// Inline: Lynx draws no shadow classes.
|
|
540
|
+
style: {
|
|
541
|
+
'background-color': this.#dark() ? DARK_THUMB_COLOR : null,
|
|
542
|
+
'box-shadow':
|
|
543
|
+
this.design() === 'glass' ? GLASS_THUMB_SHADOW : FROSTED_THUMB_SHADOW,
|
|
544
|
+
},
|
|
545
|
+
}));
|
|
546
|
+
|
|
547
|
+
readonly thumbClass = computed(() => cn('absolute', this.#thumbLook().class));
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The indicator's thumb: inset in the segment, faded out while the lens
|
|
551
|
+
* is open, and before iOS 26 a little smaller while it's held.
|
|
552
|
+
*/
|
|
553
|
+
readonly thumbStyle = computed(() => {
|
|
554
|
+
const held = this.design() === 'frosted' && !!this.#drag();
|
|
555
|
+
return {
|
|
556
|
+
transition: this.#animates() ? THUMB_TRANSITION : 'none',
|
|
557
|
+
left: px(THUMB_INSET),
|
|
558
|
+
right: px(THUMB_INSET),
|
|
559
|
+
top: px(THUMB_INSET),
|
|
560
|
+
bottom: px(THUMB_INSET),
|
|
561
|
+
opacity: this.#lens() === 'open' ? '0' : '1',
|
|
562
|
+
transform: held ? 'scale(0.95)' : 'scale(1)',
|
|
563
|
+
...this.#thumbLook().style,
|
|
564
|
+
};
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
// Segments
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* The classes of a segment's host, the row's flex item: an equal share of
|
|
573
|
+
* the track. Positioned, so that on the web it paints over the indicator,
|
|
574
|
+
* which comes before it (see investigations/lynx-vs-web-differences.md).
|
|
575
|
+
*/
|
|
576
|
+
readonly segmentHostClass = 'flex-1 flex-row flex relative';
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Whether a segment shows as selected: it's selected, or for a single
|
|
580
|
+
* selection, the indicator is being dragged over it.
|
|
581
|
+
*/
|
|
582
|
+
isOn(value: string): boolean {
|
|
583
|
+
return this.#host().multiple()
|
|
584
|
+
? this.#host().selectedValues().includes(value)
|
|
585
|
+
: this.#highlighted() === value;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* The classes of a segment's own view, around its content. A Material
|
|
590
|
+
* segment points `--foreground` at its content's color: a component can't
|
|
591
|
+
* recolor text projected into it (Lynx doesn't inherit `color`), but text
|
|
592
|
+
* written `text-foreground` reads the variable, which does inherit.
|
|
593
|
+
*
|
|
594
|
+
* `place` is where the segment sits in the row (see SegmentPlace), which
|
|
595
|
+
* Material's ends follow.
|
|
596
|
+
*/
|
|
597
|
+
segmentClass(value: string, place: SegmentPlace): string {
|
|
598
|
+
const on = this.isOn(value);
|
|
599
|
+
if (this.design() === 'material') {
|
|
600
|
+
return cn(
|
|
601
|
+
'flex-1 flex-row items-center gap-2 px-4 flex relative justify-center overflow-hidden',
|
|
602
|
+
on
|
|
603
|
+
? 'rounded-full bg-primary [--foreground:var(--primary-foreground)] [--muted-foreground:var(--primary-foreground)]'
|
|
604
|
+
: cn(
|
|
605
|
+
materialCorners(place),
|
|
606
|
+
'bg-secondary [--foreground:var(--secondary-foreground)] [--muted-foreground:var(--secondary-foreground)]',
|
|
607
|
+
),
|
|
608
|
+
// Increase Contrast outlines the light unselected segments. The
|
|
609
|
+
// selected one's dark fill stands out already, and its outline in
|
|
610
|
+
// the fill's own color keeps every segment the same size. Named
|
|
611
|
+
// colors, not `border-foreground`: the segment redefines that
|
|
612
|
+
// variable for its content.
|
|
613
|
+
this.#settings.increaseContrast() &&
|
|
614
|
+
(on ? 'border border-primary' : 'border border-secondary-foreground'),
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
return cn(
|
|
618
|
+
'flex-1 flex-row items-center gap-1.5 px-3 flex relative justify-center',
|
|
619
|
+
// In a multiple selection each segment is its own thumb, inset in its
|
|
620
|
+
// slot, and drawn while selected (see #ownThumb).
|
|
621
|
+
this.#host().multiple() && 'm-[2px]',
|
|
622
|
+
this.#ownThumb(value) && this.#thumbLook().class,
|
|
623
|
+
// A pressed segment's content dims, as UIKit highlights it, unless
|
|
624
|
+
// the press took hold of the indicator and is dragging it away.
|
|
625
|
+
!on && this.#pressed() === value && !this.#drag() && 'opacity-60',
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* A segment's inline style: Material's fill changes color smoothly, and a
|
|
631
|
+
* segment that's its own thumb takes the thumb's color and shadow.
|
|
632
|
+
*/
|
|
633
|
+
segmentStyle(value: string): Record<string, string | null> | null {
|
|
634
|
+
if (this.design() === 'material') {
|
|
635
|
+
return {
|
|
636
|
+
transition: this.#animates() ? MATERIAL_FILL_TRANSITION : 'none',
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
return this.#ownThumb(value) ? this.#thumbLook().style : null;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Whether a segment draws the thumb itself: in a multiple selection,
|
|
644
|
+
* which has no indicator, each selected segment does, in Apple's designs.
|
|
645
|
+
* Its own view is the thumb, rather than a view under its content, which
|
|
646
|
+
* the web would paint over the content (an absolute view paints above
|
|
647
|
+
* static ones there).
|
|
648
|
+
*/
|
|
649
|
+
#ownThumb(value: string): boolean {
|
|
650
|
+
return (
|
|
651
|
+
this.#host().multiple() &&
|
|
652
|
+
this.design() !== 'material' &&
|
|
653
|
+
this.isOn(value)
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* The classes of a segment's label, for a control that draws its own:
|
|
659
|
+
* 13pt as in UIKit, medium when selected on iOS 26 and semibold before;
|
|
660
|
+
* Material's label in its content color.
|
|
661
|
+
*/
|
|
662
|
+
labelClass(value: string): string {
|
|
663
|
+
const on = this.isOn(value);
|
|
664
|
+
switch (this.design()) {
|
|
665
|
+
case 'material':
|
|
666
|
+
return cn(
|
|
667
|
+
'text-[14px] font-medium',
|
|
668
|
+
on ? 'text-primary-foreground' : 'text-secondary-foreground',
|
|
669
|
+
);
|
|
670
|
+
case 'glass':
|
|
671
|
+
return cn('text-[13px] text-foreground', on && 'font-medium');
|
|
672
|
+
default:
|
|
673
|
+
return cn('text-[13px] text-foreground', on && 'font-semibold');
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* The color of icons in a segment, as a token: Material's content color,
|
|
679
|
+
* or undefined for the icons' own default.
|
|
680
|
+
*/
|
|
681
|
+
iconColor(value: string): string | undefined {
|
|
682
|
+
if (this.design() !== 'material') return undefined;
|
|
683
|
+
return this.isOn(value)
|
|
684
|
+
? 'var(--primary-foreground)'
|
|
685
|
+
: 'var(--secondary-foreground)';
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* The classes of Material's state layer on a segment, or null: it shows
|
|
690
|
+
* while the segment is pressed.
|
|
691
|
+
*/
|
|
692
|
+
stateLayerClass(value: string): string | null {
|
|
693
|
+
return this.design() === 'material' && this.#pressed() === value
|
|
694
|
+
? STATE_LAYER_CLASS
|
|
695
|
+
: null;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ---------------------------------------------------------------------------
|
|
699
|
+
// Events from the segments
|
|
700
|
+
// ---------------------------------------------------------------------------
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* A segment's `layoutchange`: its width places the indicator.
|
|
704
|
+
*/
|
|
705
|
+
measure(value: string, event: unknown): void {
|
|
706
|
+
const width = layoutWidth(event);
|
|
707
|
+
if (width === undefined || this.#widths()[value] === width) return;
|
|
708
|
+
this.#widths.update((widths) => ({ ...widths, [value]: width }));
|
|
709
|
+
// Once every segment is measured, the indicator moves onto them at once,
|
|
710
|
+
// and slides from the next change on.
|
|
711
|
+
if (!this.#placed() && this.#layout()) {
|
|
712
|
+
stop(this.#placeTimer);
|
|
713
|
+
this.#placeTimer = setTimeout(() => this.#placed.set(true), 0);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* A finger or the mouse went down on a segment. On the selected segment
|
|
719
|
+
* of a sliding control, it takes hold of the indicator, which lifts into
|
|
720
|
+
* glass and follows the pointer.
|
|
721
|
+
*/
|
|
722
|
+
press(value: string, event: unknown): void {
|
|
723
|
+
if (this.#host().disabled()) return;
|
|
724
|
+
this.#dragChose = false;
|
|
725
|
+
this.#pressed.set(value);
|
|
726
|
+
const x = pointerX(event);
|
|
727
|
+
if (
|
|
728
|
+
this.slides() &&
|
|
729
|
+
value === this.#selected() &&
|
|
730
|
+
this.#layout() &&
|
|
731
|
+
x !== undefined
|
|
732
|
+
) {
|
|
733
|
+
const start = this.#host().segmentValues().indexOf(value);
|
|
734
|
+
this.#drag.set({ start, startX: x, dx: 0 });
|
|
735
|
+
this.#lift();
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* The pointer moved: a held indicator follows it.
|
|
741
|
+
*/
|
|
742
|
+
move(event: unknown): void {
|
|
743
|
+
const drag = this.#drag();
|
|
744
|
+
const x = pointerX(event);
|
|
745
|
+
if (drag && x !== undefined) {
|
|
746
|
+
this.#drag.set({ ...drag, dx: x - drag.startX });
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* The pointer went up. A held indicator that was dragged selects the
|
|
752
|
+
* segment it's over, and settles there.
|
|
753
|
+
*/
|
|
754
|
+
release(): void {
|
|
755
|
+
this.#pressed.set(null);
|
|
756
|
+
const drag = this.#drag();
|
|
757
|
+
if (!drag) return;
|
|
758
|
+
const dragged = this.#dragFrame();
|
|
759
|
+
this.#drag.set(null);
|
|
760
|
+
if (dragged && Math.abs(drag.dx) >= DRAG_SLOP) {
|
|
761
|
+
this.#dragChose = true;
|
|
762
|
+
const value = this.#host().segmentValues()[dragged.under]!;
|
|
763
|
+
if (value !== this.#selected()) this.#host().select(value);
|
|
764
|
+
}
|
|
765
|
+
this.#settle(DURATION.normal);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* The touch was taken away (by a scroll, say), or the mouse left the
|
|
770
|
+
* track: a held indicator goes back to the selected segment.
|
|
771
|
+
*/
|
|
772
|
+
cancel(): void {
|
|
773
|
+
this.#pressed.set(null);
|
|
774
|
+
if (!this.#drag()) return;
|
|
775
|
+
this.#drag.set(null);
|
|
776
|
+
this.#settle(DURATION.normal);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* The mouse left a segment: it's no longer pressed. A drag goes on.
|
|
781
|
+
*/
|
|
782
|
+
leave(value: string): void {
|
|
783
|
+
if (this.#pressed() === value) this.#pressed.set(null);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* A segment was tapped. Returns whether to select it: not in a disabled
|
|
788
|
+
* control, and not when the tap ends a drag, which chose already. A tap that moves the selection lifts
|
|
789
|
+
* the indicator into glass for the slide.
|
|
790
|
+
*/
|
|
791
|
+
tap(value: string): boolean {
|
|
792
|
+
if (this.#host().disabled()) return false;
|
|
793
|
+
if (this.#dragChose) {
|
|
794
|
+
this.#dragChose = false;
|
|
795
|
+
return false;
|
|
796
|
+
}
|
|
797
|
+
if (this.slides() && value !== this.#selected()) {
|
|
798
|
+
this.#lift();
|
|
799
|
+
this.#settle(DURATION.slow);
|
|
800
|
+
}
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Lifts the indicator into the glass lens (see LensPhase): the lens is
|
|
806
|
+
* drawn under the thumb first, then grows a tick later, so it grows from
|
|
807
|
+
* the thumb's size rather than appearing at its own.
|
|
808
|
+
*/
|
|
809
|
+
#lift(): void {
|
|
810
|
+
if (!this.#lifts()) return;
|
|
811
|
+
stop(this.#settleTimer);
|
|
812
|
+
if (this.#lens() === 'off') {
|
|
813
|
+
this.#lens.set('rest');
|
|
814
|
+
stop(this.#openTimer);
|
|
815
|
+
this.#openTimer = setTimeout(() => this.#lens.set('open'), 0);
|
|
816
|
+
} else {
|
|
817
|
+
this.#lens.set('open');
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Settles the lens back into the thumb once the indicator has arrived,
|
|
823
|
+
* after `delay` ms: it shrinks under the thumb as the thumb fades back in,
|
|
824
|
+
* and goes once hidden.
|
|
825
|
+
*/
|
|
826
|
+
#settle(delay: number): void {
|
|
827
|
+
if (this.#lens() === 'off') return;
|
|
828
|
+
stop(this.#settleTimer);
|
|
829
|
+
this.#settleTimer = setTimeout(() => {
|
|
830
|
+
this.#lens.set('rest');
|
|
831
|
+
this.#settleTimer = setTimeout(
|
|
832
|
+
() => this.#lens.set('off'),
|
|
833
|
+
DURATION.fast,
|
|
834
|
+
);
|
|
835
|
+
}, delay);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* What a segmented control draws under its segments (see
|
|
841
|
+
* UiSegmentedControl): the selection indicator, the thumb that slides
|
|
842
|
+
* between them and the glass lens it lifts into on iOS 26, and before iOS 26
|
|
843
|
+
* the separators between them. Put it first in the track, when the control's
|
|
844
|
+
* `underlay()` says so, so the segments draw over it.
|
|
845
|
+
*/
|
|
846
|
+
@Component({
|
|
847
|
+
selector: 'ui-segmented-indicator',
|
|
848
|
+
imports: [LYNX_ELEMENTS, UiGlass],
|
|
849
|
+
encapsulation: ViewEncapsulation.None,
|
|
850
|
+
host: {
|
|
851
|
+
// Absolute, so it takes no room in the track's row.
|
|
852
|
+
class: 'h-full w-full absolute left-0 top-0',
|
|
853
|
+
'accessibility-elements-hidden': 'true',
|
|
854
|
+
},
|
|
855
|
+
// The lens comes before the thumb, so the opaque thumb covers it at rest.
|
|
856
|
+
// Clear glass, as UIKit's lens is: it shows the labels it passes over.
|
|
857
|
+
template: `
|
|
858
|
+
@for (separator of control.separators(); track $index) {
|
|
859
|
+
<view
|
|
860
|
+
class="w-[1px] bg-muted-foreground absolute bottom-[8px] top-[8px]"
|
|
861
|
+
[style]="separator"
|
|
862
|
+
></view>
|
|
863
|
+
}
|
|
864
|
+
@if (control.slides()) {
|
|
865
|
+
<view class="absolute bottom-0 top-0" [style]="control.frameStyle()">
|
|
866
|
+
@if (control.lensShown()) {
|
|
867
|
+
<blur-view
|
|
868
|
+
ui-glass
|
|
869
|
+
variant="clear"
|
|
870
|
+
class="absolute"
|
|
871
|
+
[style]="control.lensStyle()"
|
|
872
|
+
></blur-view>
|
|
873
|
+
}
|
|
874
|
+
<view
|
|
875
|
+
[class]="control.thumbClass()"
|
|
876
|
+
[style]="control.thumbStyle()"
|
|
877
|
+
></view>
|
|
878
|
+
</view>
|
|
879
|
+
}
|
|
880
|
+
`,
|
|
881
|
+
})
|
|
882
|
+
export class UiSegmentedIndicator {
|
|
883
|
+
protected readonly control = inject(UiSegmentedControl);
|
|
884
|
+
}
|