@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.
Files changed (124) hide show
  1. package/dist/commands/add.test.js +2 -0
  2. package/dist/commands/add.test.js.map +1 -1
  3. package/dist/commands/doctor.js +78 -21
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/doctor.test.js +167 -26
  6. package/dist/commands/doctor.test.js.map +1 -1
  7. package/dist/commands/init.test.js +4 -7
  8. package/dist/commands/init.test.js.map +1 -1
  9. package/dist/commands/update.d.ts +3 -1
  10. package/dist/commands/update.js +78 -15
  11. package/dist/commands/update.js.map +1 -1
  12. package/dist/commands/update.test.js +257 -2
  13. package/dist/commands/update.test.js.map +1 -1
  14. package/dist/lockfile.d.ts +10 -0
  15. package/dist/lockfile.js +20 -1
  16. package/dist/lockfile.js.map +1 -1
  17. package/dist/lockfile.test.js +43 -3
  18. package/dist/lockfile.test.js.map +1 -1
  19. package/dist/registry.js +73 -11
  20. package/dist/registry.js.map +1 -1
  21. package/dist/registry.test.js +16 -3
  22. package/dist/registry.test.js.map +1 -1
  23. package/dist/test-utils.d.ts +24 -2
  24. package/dist/test-utils.js +41 -2
  25. package/dist/test-utils.js.map +1 -1
  26. package/dist/ui/boolean-inputs.test.ts +76 -0
  27. package/dist/ui/components/action-sheet/action-sheet.test.ts +370 -1
  28. package/dist/ui/components/action-sheet/action-sheet.ts +306 -28
  29. package/dist/ui/components/action-sheet/index.ts +1 -0
  30. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +552 -8
  31. package/dist/ui/components/alert-dialog/alert-dialog.ts +644 -36
  32. package/dist/ui/components/alert-dialog/index.ts +5 -0
  33. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  34. package/dist/ui/components/badge/badge.ts +2 -1
  35. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +968 -3
  36. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1021 -95
  37. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  38. package/dist/ui/components/button/button.test.ts +36 -1
  39. package/dist/ui/components/button/button.ts +17 -7
  40. package/dist/ui/components/button-group/button-group.test.ts +53 -9
  41. package/dist/ui/components/button-group/button-group.ts +67 -21
  42. package/dist/ui/components/card/card.test.ts +16 -0
  43. package/dist/ui/components/card/card.ts +2 -1
  44. package/dist/ui/components/chart/chart-presets.ts +6 -6
  45. package/dist/ui/components/chart/chart.test.ts +14 -0
  46. package/dist/ui/components/chart/chart.ts +10 -3
  47. package/dist/ui/components/checkbox/checkbox.ts +2 -1
  48. package/dist/ui/components/collapsible/collapsible.ts +2 -1
  49. package/dist/ui/components/dialog/dialog.ts +6 -6
  50. package/dist/ui/components/dock/dock-scroll.ts +9 -0
  51. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  52. package/dist/ui/components/dock/dock.test.ts +4 -1
  53. package/dist/ui/components/dock/dock.ts +529 -51
  54. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  55. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  56. package/dist/ui/components/dynamic-color/index.ts +5 -0
  57. package/dist/ui/components/glass/glass.test.ts +39 -1
  58. package/dist/ui/components/glass/glass.ts +87 -17
  59. package/dist/ui/components/glass/index.ts +1 -0
  60. package/dist/ui/components/icon/icon-theme.test.ts +28 -0
  61. package/dist/ui/components/icon/icon-theme.ts +38 -7
  62. package/dist/ui/components/input/input.ts +2 -1
  63. package/dist/ui/components/label/label.ts +8 -2
  64. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  65. package/dist/ui/components/list/list.ts +5 -4
  66. package/dist/ui/components/menu/menu-popup.test.ts +18 -14
  67. package/dist/ui/components/menu/menu-popup.ts +24 -3
  68. package/dist/ui/components/menu/menu.test.ts +99 -5
  69. package/dist/ui/components/menu/menu.ts +74 -34
  70. package/dist/ui/components/nav-drawer/nav-drawer.ts +3 -2
  71. package/dist/ui/components/navigation-bar/index.ts +1 -0
  72. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  73. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  74. package/dist/ui/components/popover/index.ts +9 -0
  75. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  76. package/dist/ui/components/popover/popover-panel.ts +603 -0
  77. package/dist/ui/components/popover/popover.test.ts +286 -0
  78. package/dist/ui/components/popover/popover.ts +207 -0
  79. package/dist/ui/components/progress/progress.ts +2 -1
  80. package/dist/ui/components/radio-group/radio-group.ts +2 -1
  81. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  82. package/dist/ui/components/search-field/index.ts +15 -0
  83. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  84. package/dist/ui/components/search-field/keyboard.ts +123 -0
  85. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  86. package/dist/ui/components/search-field/search-field.ts +628 -0
  87. package/dist/ui/components/segmented-control/index.ts +8 -0
  88. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  89. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  90. package/dist/ui/components/select/select.ts +2 -1
  91. package/dist/ui/components/sheet/sheet.test.ts +90 -3
  92. package/dist/ui/components/sheet/sheet.ts +59 -4
  93. package/dist/ui/components/slider/index.ts +1 -0
  94. package/dist/ui/components/slider/slider.test.ts +695 -0
  95. package/dist/ui/components/slider/slider.ts +901 -0
  96. package/dist/ui/components/switch/index.ts +1 -1
  97. package/dist/ui/components/switch/switch.test.ts +464 -48
  98. package/dist/ui/components/switch/switch.ts +613 -43
  99. package/dist/ui/components/tabs/index.ts +7 -1
  100. package/dist/ui/components/tabs/tabs.test.ts +278 -2
  101. package/dist/ui/components/tabs/tabs.ts +233 -30
  102. package/dist/ui/components/textarea/textarea.ts +2 -1
  103. package/dist/ui/components/toggle/index.ts +1 -0
  104. package/dist/ui/components/toggle/toggle.test.ts +310 -4
  105. package/dist/ui/components/toggle/toggle.ts +241 -26
  106. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  107. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  108. package/dist/ui/components/toolbar/index.ts +12 -0
  109. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  110. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  111. package/dist/utils/analyze.d.ts +10 -5
  112. package/dist/utils/analyze.js +3 -2
  113. package/dist/utils/analyze.js.map +1 -1
  114. package/dist/utils/installed.d.ts +13 -1
  115. package/dist/utils/installed.js +17 -0
  116. package/dist/utils/installed.js.map +1 -1
  117. package/dist/utils/installed.test.js +39 -1
  118. package/dist/utils/installed.test.js.map +1 -1
  119. package/dist/utils/tailwind.d.ts +25 -5
  120. package/dist/utils/tailwind.js +53 -14
  121. package/dist/utils/tailwind.js.map +1 -1
  122. package/dist/utils/tailwind.test.js +85 -22
  123. package/dist/utils/tailwind.test.js.map +1 -1
  124. 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
+ }