@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
@@ -1,17 +1,21 @@
1
+ import { NgTemplateOutlet } from '@angular/common';
1
2
  import {
2
- type ElementRef,
3
3
  type OnDestroy,
4
4
  Component,
5
+ ElementRef,
5
6
  ViewEncapsulation,
7
+ booleanAttribute,
6
8
  computed,
9
+ contentChildren,
7
10
  effect,
11
+ inject,
8
12
  input,
9
13
  model,
10
14
  output,
11
15
  signal,
12
16
  viewChild,
13
17
  } from '@angular/core';
14
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
18
+ import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
15
19
 
16
20
  import {
17
21
  type AnimationHandle,
@@ -24,39 +28,245 @@ import {
24
28
  scaleOut,
25
29
  } from '../../utils/animate';
26
30
  import { cn } from '../../utils/cn';
31
+ import { UiGlass } from '../glass/glass';
32
+ import { type GlassDesign, injectGlassDesign } from '../glass/glass-design';
27
33
 
34
+ /**
35
+ * `'default'` is the alert dialog's own panel on every platform. `'glass'`
36
+ * draws the platform's alert: Liquid Glass on iOS 26 and later, the frosted
37
+ * alert of iOS 15 to 25, and Material 3's dialog on Android (see
38
+ * GlassDesign).
39
+ */
40
+ export type AlertDialogVariant = 'default' | 'glass';
41
+
42
+ /**
43
+ * What the alert dialog draws: its own panel, or the glass variant's form on
44
+ * this platform.
45
+ */
46
+ export type AlertDialogLook = 'default' | GlassDesign;
47
+
48
+ /**
49
+ * `'cancel'` backs out of the alert, `'destructive'` deletes or can't be
50
+ * undone, and `'default'` is any other action.
51
+ */
52
+ export type AlertDialogActionVariant = 'default' | 'cancel' | 'destructive';
53
+
54
+ /**
55
+ * How a glass alert's footer lays out its actions: `'auto'` side by side for
56
+ * up to two and stacked for more, as iOS does, or always `'horizontal'` or
57
+ * `'vertical'`, for labels too long to sit side by side.
58
+ */
59
+ export type AlertDialogFooterOrientation = 'auto' | 'horizontal' | 'vertical';
60
+
61
+ /**
62
+ * How a panel moves: the scale and offset it opens from and the curve it
63
+ * settles by, and the scale and offset it fades out to.
64
+ */
65
+ type AlertMotion = {
66
+ readonly fromScale: number;
67
+ readonly fromY: number;
68
+ readonly easing: string;
69
+ readonly toScale: number;
70
+ readonly toY: number;
71
+ };
72
+
73
+ /** The default panel rises and grows in with a slight overshoot. */
74
+ const DEFAULT_MOTION: AlertMotion = {
75
+ fromScale: SCALE.dialogFrom,
76
+ fromY: 10,
77
+ easing: EASING.springSubtle,
78
+ toScale: SCALE.dialogTo,
79
+ toY: 10,
80
+ };
81
+
82
+ /**
83
+ * Apple's alerts shrink into place from a little larger as they fade in, and
84
+ * only fade as they go (frames of UIKit's own, slowed down).
85
+ */
86
+ const APPLE_MOTION: AlertMotion = {
87
+ fromScale: 1.15,
88
+ fromY: 0,
89
+ easing: EASING.sheet,
90
+ toScale: 1,
91
+ toY: 0,
92
+ };
93
+
94
+ /** How each design draws the glass variant's panel. */
95
+ type AlertMetrics = {
96
+ /** The panel's corner radius, in px. */
97
+ readonly radius: number;
98
+ /** The panel's width. */
99
+ readonly width: string;
100
+ /** The space around its content. */
101
+ readonly padding: string;
102
+ readonly motion: AlertMotion;
103
+ };
104
+
105
+ /**
106
+ * Measured from UIKit's alerts on the iOS 27 Simulator, Apple's earlier
107
+ * alerts, and Material 3's dialog spec.
108
+ */
109
+ const METRICS: Readonly<Record<GlassDesign, AlertMetrics>> = {
110
+ // iOS 26's alert: 320pt wide, with its 48pt capsule buttons inset 16pt
111
+ // from the edge, so the corners are concentric with theirs (24 + 16).
112
+ glass: {
113
+ radius: 40,
114
+ width: 'w-full max-w-[320px]',
115
+ padding: 'px-[16px] pb-[16px] pt-[22px]',
116
+ motion: APPLE_MOTION,
117
+ },
118
+ // iOS 15 to 25's alert: 270pt wide, 14pt corners, its buttons edge to
119
+ // edge.
120
+ frosted: {
121
+ radius: 14,
122
+ width: 'w-[270px]',
123
+ padding: '',
124
+ motion: APPLE_MOTION,
125
+ },
126
+ // Material 3's basic dialog: 280 to 560dp wide, 28dp corners, 24dp
127
+ // padding. It grows in from a little smaller, and shrinks back as it fades.
128
+ material: {
129
+ radius: 28,
130
+ width: 'w-full min-w-[280px] max-w-[560px]',
131
+ padding: 'p-[24px]',
132
+ motion: {
133
+ fromScale: SCALE.dialogFrom,
134
+ fromY: 0,
135
+ easing: EASING.decelerate,
136
+ toScale: SCALE.dialogTo,
137
+ toY: 0,
138
+ },
139
+ },
140
+ };
141
+
142
+ /**
143
+ * An alert dialog: a panel that asks the user to confirm or cancel an
144
+ * important action, and closes only when they choose one.
145
+ *
146
+ * ```html
147
+ * <ui-alert-dialog variant="glass" [(open)]="open">
148
+ * <ui-alert-dialog-header>
149
+ * <ui-alert-dialog-title>Delete Account?</ui-alert-dialog-title>
150
+ * <ui-alert-dialog-description>This can't be undone.</ui-alert-dialog-description>
151
+ * </ui-alert-dialog-header>
152
+ * <ui-alert-dialog-footer>
153
+ * <ui-alert-dialog-action variant="cancel">Cancel</ui-alert-dialog-action>
154
+ * <ui-alert-dialog-action variant="destructive" (pressed)="delete()">
155
+ * Delete
156
+ * </ui-alert-dialog-action>
157
+ * </ui-alert-dialog-footer>
158
+ * </ui-alert-dialog>
159
+ * ```
160
+ *
161
+ * With `variant="glass"` it draws the platform's own alert (see
162
+ * AlertDialogVariant), on a `<blur-view ui-glass>`. A blur view can't fade
163
+ * itself: on iOS its material vanishes at less than full opacity. So the
164
+ * glass sits in a plain view that scales and fades, under which iOS draws
165
+ * it as a translucent sheet until it's opaque again, as UIKit draws its own
166
+ * alerts coming and going. The screen's dimming is a layer beside it rather
167
+ * than around it, so it fades on its own.
168
+ *
169
+ * Under Reduce Motion nothing scales, in either variant: the dimming and the
170
+ * panel only fade.
171
+ */
28
172
  @Component({
29
173
  selector: 'ui-alert-dialog',
30
- imports: [LYNX_ELEMENTS],
174
+ imports: [LYNX_ELEMENTS, NgTemplateOutlet, UiGlass],
31
175
  encapsulation: ViewEncapsulation.None,
32
176
  template: `
33
177
  <overlay [attr.visible]="overlayVisible()" [style]="overlayStyle()">
34
- <view #backdrop [class]="backdropClass()">
35
- <view
36
- #panel
37
- [class]="panelClass()"
38
- [accessibility-exclusive-focus]="overlayVisible()"
39
- (catchtap)="onPanelTap()"
40
- >
41
- <ng-content />
42
- </view>
178
+ <!-- The overlay sizes only its first child, to the screen. With the
179
+ @if straight inside it, the glass panel drew in the top-left
180
+ corner on iOS (see investigations/lynx-vs-web-differences.md). -->
181
+ <view class="h-full w-full">
182
+ @if (look() === 'default') {
183
+ <view #backdrop [class]="backdropClass()">
184
+ <view
185
+ #panel
186
+ [class]="panelClass()"
187
+ [accessibility-exclusive-focus]="overlayVisible()"
188
+ (catchtap)="onPanelTap()"
189
+ >
190
+ <ng-container [ngTemplateOutlet]="content" />
191
+ </view>
192
+ </view>
193
+ } @else {
194
+ <view
195
+ class="h-full w-full items-center px-[40px] flex justify-center"
196
+ >
197
+ <view #backdrop [class]="dimClass()"></view>
198
+ <!-- What scales and fades: see the class's comment. A view of its
199
+ own (not flattened into its parent): on Android, the shadow
200
+ of the Material panel inside an animated flattened view drew
201
+ at twice the view's offset, off at the screen's corner. -->
202
+ <view
203
+ #shell
204
+ class="w-full flex-col items-center flex"
205
+ [flatten]="false"
206
+ >
207
+ <blur-view
208
+ #panel
209
+ ui-glass
210
+ shape="rounded"
211
+ [radius]="metrics().radius"
212
+ [class]="panelClass()"
213
+ [accessibility-exclusive-focus]="overlayVisible()"
214
+ (catchtap)="onPanelTap()"
215
+ >
216
+ <ng-container [ngTemplateOutlet]="content" />
217
+ </blur-view>
218
+ </view>
219
+ </view>
220
+ }
43
221
  </view>
44
222
  </overlay>
223
+
224
+ <!-- One copy of the content, for whichever panel the dialog draws:
225
+ Angular projects content once, so it can't be in both branches. -->
226
+ <ng-template #content><ng-content /></ng-template>
45
227
  `,
46
228
  })
47
229
  export class UiAlertDialog implements OnDestroy {
48
230
  readonly open = model(false);
231
+ /** `'default'` or `'glass'`; see AlertDialogVariant. */
232
+ readonly variant = input<AlertDialogVariant>('default');
49
233
  readonly userClass = input<string>('', { alias: 'class' });
50
234
 
51
235
  readonly closed = output<void>();
52
236
 
53
237
  protected readonly overlayVisible = signal(false);
54
238
 
55
- // Required: both are always in the template (the overlay hides with
239
+ readonly #design = injectGlassDesign();
240
+ readonly #settings = inject(LynxAccessibilitySettings);
241
+
242
+ /**
243
+ * What the dialog draws (see AlertDialogLook), which its header, title,
244
+ * description, footer and actions follow.
245
+ */
246
+ readonly look = computed<AlertDialogLook>(() =>
247
+ this.variant() === 'glass' ? this.#design() : 'default',
248
+ );
249
+
250
+ /** How the glass variant draws its panel on this platform. */
251
+ readonly metrics = computed(() => METRICS[this.#design()]);
252
+
253
+ /** How the panel moves as it opens and closes. */
254
+ readonly #motion = computed(() =>
255
+ this.look() === 'default' ? DEFAULT_MOTION : this.metrics().motion,
256
+ );
257
+
258
+ // Required: they're always in the template (the overlay hides with
56
259
  // display:none rather than leaving the tree), and the sequences only read
57
- // them from timers that start after the first render.
260
+ // them from timers that start after the first render. The panel is read as
261
+ // an element: in the glass variant its tag holds the glass component.
58
262
  readonly backdropRef = viewChild.required<ElementRef>('backdrop');
59
- readonly panelRef = viewChild.required<ElementRef>('panel');
263
+ readonly panelRef = viewChild.required('panel', { read: ElementRef });
264
+ /**
265
+ * What scales and fades in the glass variant: the view the glass sits in
266
+ * (see the class's comment). Only read in that variant, where it's in the
267
+ * template.
268
+ */
269
+ readonly shellRef = viewChild.required<ElementRef>('shell');
60
270
  #backdropAnim?: AnimationHandle;
61
271
  #panelAnim?: AnimationHandle;
62
272
  #hasBeenOpen = false;
@@ -107,13 +317,36 @@ export class UiAlertDialog implements OnDestroy {
107
317
  cn('items-center flex justify-center', 'h-full w-full', 'bg-black/50'),
108
318
  );
109
319
 
110
- protected readonly panelClass = computed(() =>
320
+ /**
321
+ * The glass variant dims the screen as its platform does: black at 20%
322
+ * behind Apple's alerts (measured on the iOS 27 Simulator), and Material
323
+ * 3's scrim at 32%. Only a dimming layer, with no blur: neither platform
324
+ * blurs the screen behind an alert, and a plain color draws the same on
325
+ * iOS, Android and the web. (Tailwind's `backdrop-blur-*` would draw
326
+ * nothing there: Lynx has no `backdrop-filter`, and the Lynx preset doesn't
327
+ * generate the classes.)
328
+ */
329
+ protected readonly dimClass = computed(() =>
111
330
  cn(
112
- 'w-4/5 flex-col rounded-lg border border-border bg-background p-6 flex',
113
- this.userClass(),
331
+ 'h-full w-full absolute left-0 top-0',
332
+ this.look() === 'material' ? 'bg-black/[0.32]' : 'bg-black/20',
114
333
  ),
115
334
  );
116
335
 
336
+ protected readonly panelClass = computed(() =>
337
+ this.look() === 'default'
338
+ ? cn(
339
+ 'w-4/5 flex-col rounded-lg border border-border bg-background p-6 flex',
340
+ this.userClass(),
341
+ )
342
+ : cn(
343
+ 'flex-col flex overflow-hidden',
344
+ this.metrics().width,
345
+ this.metrics().padding,
346
+ this.userClass(),
347
+ ),
348
+ );
349
+
117
350
  /**
118
351
  * No-op tap handler for the panel. `catchtap` (vs `bindtap`) already stops
119
352
  * the tap from bubbling to the backdrop — Lynx controls propagation via the
@@ -188,33 +421,56 @@ export class UiAlertDialog implements OnDestroy {
188
421
  // feels like it's emerging from behind the dimmed overlay rather than
189
422
  // appearing simultaneously with it.
190
423
  this.#later(() => {
191
- this.#panelAnim = scaleIn(panel, {
192
- duration: DURATION.slow,
193
- easing: EASING.springSubtle,
194
- fromScale: SCALE.dialogFrom,
195
- fromY: 10,
196
- });
424
+ const motion = this.#motion();
425
+ this.#panelAnim = this.#settings.reduceMotion()
426
+ ? fadeIn(this.#animated(), { duration: DURATION.normal })
427
+ : scaleIn(this.#animated(), {
428
+ duration: DURATION.slow,
429
+ easing: motion.easing,
430
+ fromScale: motion.fromScale,
431
+ fromY: motion.fromY,
432
+ });
197
433
  }, 30);
198
434
  }
199
435
 
200
436
  #animateOut(): void {
201
437
  const backdrop = this.backdropRef().nativeElement;
202
- const panel = this.panelRef().nativeElement;
438
+ const motion = this.#motion();
203
439
 
204
440
  this.#backdropAnim?.cancel();
205
441
  this.#panelAnim?.cancel();
206
442
 
207
- this.#panelAnim = scaleOut(panel, {
208
- duration: 180,
209
- easing: EASING.accelerate,
210
- toScale: SCALE.dialogTo,
211
- toY: 10,
212
- });
443
+ this.#panelAnim = this.#settings.reduceMotion()
444
+ ? fadeOut(this.#animated(), { duration: 180 })
445
+ : scaleOut(this.#animated(), {
446
+ duration: 180,
447
+ easing: EASING.accelerate,
448
+ toScale: motion.toScale,
449
+ toY: motion.toY,
450
+ });
213
451
 
214
452
  this.#backdropAnim = fadeOut(backdrop, { duration: DURATION.normal });
215
453
  }
454
+
455
+ /**
456
+ * What the open and close animations scale and fade: the panel, or in the
457
+ * glass variant the view the glass sits in (see the class's comment).
458
+ */
459
+ #animated(): unknown {
460
+ return this.look() === 'default'
461
+ ? this.panelRef().nativeElement
462
+ : this.shellRef().nativeElement;
463
+ }
216
464
  }
217
465
 
466
+ /**
467
+ * The look of the alert dialog a part is in; `'default'` outside one.
468
+ */
469
+ const injectAlertLook = () => {
470
+ const dialog = inject(UiAlertDialog, { optional: true });
471
+ return computed<AlertDialogLook>(() => dialog?.look() ?? 'default');
472
+ };
473
+
218
474
  @Component({
219
475
  selector: 'ui-alert-dialog-header',
220
476
  imports: [LYNX_ELEMENTS],
@@ -228,8 +484,24 @@ export class UiAlertDialog implements OnDestroy {
228
484
  export class UiAlertDialogHeader {
229
485
  readonly userClass = input<string>('', { alias: 'class' });
230
486
 
487
+ readonly #look = injectAlertLook();
488
+
489
+ /**
490
+ * iOS 26 sets its text 30pt from the panel's edge, further in than its
491
+ * buttons; before iOS 26 Apple centered it, with room above and below
492
+ * before the separator over the buttons.
493
+ */
231
494
  protected readonly containerClass = computed(() =>
232
- cn('flex-col gap-1.5 flex', this.userClass()),
495
+ cn(
496
+ 'flex-col flex',
497
+ {
498
+ default: 'gap-1.5',
499
+ glass: 'gap-[5px] px-[14px]',
500
+ frosted: 'items-center gap-[2px] px-[16px] pb-[20px] pt-[20px]',
501
+ material: 'gap-[16px]',
502
+ }[this.#look()],
503
+ this.userClass(),
504
+ ),
233
505
  );
234
506
  }
235
507
 
@@ -242,8 +514,23 @@ export class UiAlertDialogHeader {
242
514
  export class UiAlertDialogTitle {
243
515
  readonly userClass = input<string>('', { alias: 'class' });
244
516
 
517
+ readonly #look = injectAlertLook();
518
+
519
+ /**
520
+ * Apple's alert titles are 17pt semibold, leading from iOS 26 and centered
521
+ * before it. Material's headline is 24sp regular.
522
+ */
245
523
  protected readonly textClass = computed(() =>
246
- cn('text-lg font-semibold text-foreground', this.userClass()),
524
+ cn(
525
+ {
526
+ default: 'text-lg font-semibold text-foreground',
527
+ glass: 'text-[17px] font-semibold leading-[22px] text-glass-foreground',
528
+ frosted:
529
+ 'text-[17px] font-semibold leading-[22px] text-glass-foreground text-center',
530
+ material: 'text-[24px] leading-[32px] text-glass-foreground',
531
+ }[this.#look()],
532
+ this.userClass(),
533
+ ),
247
534
  );
248
535
  }
249
536
 
@@ -256,25 +543,346 @@ export class UiAlertDialogTitle {
256
543
  export class UiAlertDialogDescription {
257
544
  readonly userClass = input<string>('', { alias: 'class' });
258
545
 
546
+ readonly #look = injectAlertLook();
547
+
548
+ /**
549
+ * iOS 26's message is 15pt in the secondary color; before it, 13pt in the
550
+ * label color. Material's supporting text is 14sp in on-surface-variant.
551
+ */
259
552
  protected readonly textClass = computed(() =>
260
- cn('text-sm text-muted-foreground', this.userClass()),
553
+ cn(
554
+ {
555
+ default: 'text-sm text-muted-foreground',
556
+ glass: 'text-[15px] leading-[20px] text-glass-muted-foreground',
557
+ frosted: 'text-[13px] leading-[16px] text-glass-foreground text-center',
558
+ material: 'text-[14px] leading-[20px] text-glass-muted-foreground',
559
+ }[this.#look()],
560
+ this.userClass(),
561
+ ),
261
562
  );
262
563
  }
263
564
 
565
+ /**
566
+ * `UiAlertDialogAction`, for `contentChildren()`. A call rather than the
567
+ * class itself: the query's metadata keeps an arrow function of its own for
568
+ * a class, which nothing calls, so an app's coverage report would show it as
569
+ * uncovered.
570
+ */
571
+ const actionType = () => UiAlertDialogAction;
572
+
264
573
  @Component({
265
574
  selector: 'ui-alert-dialog-footer',
266
575
  imports: [LYNX_ELEMENTS],
267
576
  encapsulation: ViewEncapsulation.None,
268
577
  template: `
269
578
  <view [class]="containerClass()">
579
+ @if (separated()) {
580
+ <!-- The hairline over the buttons of Apple's alerts before iOS 26. -->
581
+ <view
582
+ class="h-[0.5px] w-full bg-glass-foreground opacity-20 absolute left-0 top-0"
583
+ ></view>
584
+ }
270
585
  <ng-content />
271
586
  </view>
272
587
  `,
273
588
  })
274
589
  export class UiAlertDialogFooter {
275
590
  readonly userClass = input<string>('', { alias: 'class' });
591
+ /**
592
+ * How a glass alert lays out its actions; see AlertDialogFooterOrientation.
593
+ * The default variant always puts them in a row.
594
+ */
595
+ readonly orientation = input<AlertDialogFooterOrientation>('auto');
276
596
 
277
- protected readonly containerClass = computed(() =>
278
- cn('flex-row gap-2 pt-4 flex justify-end', this.userClass()),
597
+ readonly #look = injectAlertLook();
598
+
599
+ /** The actions in the footer, in the order they're written. */
600
+ readonly actions = contentChildren(actionType(), { descendants: true });
601
+
602
+ /**
603
+ * Whether the glass variant stacks the actions: Apple's alerts put two side
604
+ * by side and stack three. Material's dialog keeps its text buttons in a
605
+ * row.
606
+ */
607
+ readonly stacked = computed(() => {
608
+ const orientation = this.orientation();
609
+ if (orientation !== 'auto') return orientation === 'vertical';
610
+ return this.#look() !== 'material' && this.actions().length > 2;
611
+ });
612
+
613
+ /**
614
+ * The actions in the order the glass variant shows them. Apple and Material
615
+ * both put the cancel action on the leading side of a row, and Apple puts
616
+ * it at the bottom of a stack, wherever it's written, as UIKit does.
617
+ */
618
+ readonly order = computed(() => {
619
+ const actions = this.actions();
620
+ const cancels = actions.filter((action) => action.variant() === 'cancel');
621
+ const others = actions.filter((action) => action.variant() !== 'cancel');
622
+ return this.stacked() ? [...others, ...cancels] : [...cancels, ...others];
623
+ });
624
+
625
+ /** Whether an action is preferred, which takes the cancel's emphasis. */
626
+ readonly hasPreferred = computed(() =>
627
+ this.actions().some((action) => action.preferred()),
628
+ );
629
+
630
+ protected readonly separated = computed(() => this.#look() === 'frosted');
631
+
632
+ protected readonly containerClass = computed(() => {
633
+ const look = this.#look();
634
+ if (look === 'default') {
635
+ return cn('flex-row gap-2 pt-4 flex justify-end', this.userClass());
636
+ }
637
+ return cn(
638
+ this.stacked() ? 'flex-col' : 'flex-row',
639
+ 'flex',
640
+ {
641
+ glass: 'mt-[20px] gap-[8px]',
642
+ frosted: 'relative',
643
+ material: 'mt-[24px] gap-[8px] justify-end',
644
+ }[look],
645
+ this.userClass(),
646
+ );
647
+ });
648
+ }
649
+
650
+ /**
651
+ * One of an alert dialog's buttons. Pressing it emits `pressed` and closes
652
+ * the dialog, as every alert button does.
653
+ *
654
+ * ```html
655
+ * <ui-alert-dialog-action variant="cancel">Cancel</ui-alert-dialog-action>
656
+ * <ui-alert-dialog-action variant="destructive" (pressed)="delete()">
657
+ * Delete
658
+ * </ui-alert-dialog-action>
659
+ * ```
660
+ *
661
+ * Put it in a `<ui-alert-dialog-footer>`. Each look draws it as its
662
+ * platform does:
663
+ *
664
+ * - **Default:** an outline button for cancel, a filled primary or
665
+ * destructive one otherwise.
666
+ * - **iOS 26 and later:** a 48pt capsule on a light fill, with the preferred
667
+ * action filled with the tint.
668
+ * - **iOS 15 to 25:** a 44pt text button in the tint between hairlines, the
669
+ * preferred action, or else the cancel, in bold.
670
+ * - **Material:** a text button in the primary color.
671
+ *
672
+ * Destructive actions read in the destructive color everywhere.
673
+ */
674
+ @Component({
675
+ selector: 'ui-alert-dialog-action',
676
+ imports: [LYNX_ELEMENTS],
677
+ encapsulation: ViewEncapsulation.None,
678
+ host: {
679
+ '[class]': 'hostClass()',
680
+ '[style.order]': 'position()',
681
+ '[style.opacity]': 'hostOpacity()',
682
+ // An action with an accessibility label is one element that says it;
683
+ // without one, its label's own text is read, as a button (see the
684
+ // template), as `ui-button` does.
685
+ '[attr.accessibility-element]': "accessibilityLabel() ? 'true' : null",
686
+ '[attr.accessibility-traits]': "accessibilityLabel() ? 'button' : null",
687
+ '[attr.accessibility-label]': 'accessibilityLabel() || null',
688
+ // Touch for native, mouse for Lynx for Web, which never sends touches
689
+ // from a mouse (see `ui-button`).
690
+ '(bindtouchstart)': 'hold(true)',
691
+ '(bindtouchend)': 'hold(false)',
692
+ '(bindtouchcancel)': 'hold(false)',
693
+ '(bindmousedown)': 'hold(true)',
694
+ '(bindmouseup)': 'hold(false)',
695
+ '(bindmouseleave)': 'hold(false)',
696
+ '(bindtap)': 'choose()',
697
+ },
698
+ template: `
699
+ @if (layerClass(); as layer) {
700
+ <!-- The capsule's fill and pressed highlight, or the pressed
701
+ highlight alone: a view of its own, so it can be translucent
702
+ under an opaque label. -->
703
+ <view [class]="layer" [style.opacity]="layerOpacity()"></view>
704
+ }
705
+ @if (separatorClass(); as line) {
706
+ <view [class]="line"></view>
707
+ }
708
+ <text
709
+ [attr.accessibility-traits]="accessibilityLabel() ? null : 'button'"
710
+ [class]="labelClass()"
711
+ ><ng-content
712
+ /></text>
713
+ `,
714
+ })
715
+ export class UiAlertDialogAction {
716
+ /** `'default'`, `'cancel'` or `'destructive'`; see AlertDialogActionVariant. */
717
+ readonly variant = input<AlertDialogActionVariant>('default');
718
+ /**
719
+ * The action to take when the user reads the alert and agrees, such as
720
+ * Save. iOS 26 fills it with the tint and earlier iOS sets it in bold, the
721
+ * way `UIAlertController.preferredAction` draws. Never the cancel action.
722
+ */
723
+ readonly preferred = input(false, { transform: booleanAttribute });
724
+ /** What a screen reader reads, when the label isn't enough. */
725
+ readonly accessibilityLabel = input<string | undefined>(undefined);
726
+ readonly userClass = input<string>('', { alias: 'class' });
727
+
728
+ /** The action was chosen. The dialog closes. */
729
+ readonly pressed = output<void>();
730
+
731
+ readonly #dialog = inject(UiAlertDialog);
732
+ readonly #footer = inject(UiAlertDialogFooter);
733
+
734
+ /** Whether a finger (or the mouse) is down on the action. */
735
+ protected readonly held = signal(false);
736
+
737
+ readonly #look = computed(() => this.#dialog.look());
738
+ readonly #row = computed(() => !this.#footer.stacked());
739
+ readonly #destructive = computed(() => this.variant() === 'destructive');
740
+
741
+ /**
742
+ * Where the action is in its footer as the glass variant shows it (see
743
+ * UiAlertDialogFooter.order); the default variant keeps the written order.
744
+ */
745
+ protected readonly position = computed(() =>
746
+ this.#look() === 'default' ? null : this.#footer.order().indexOf(this),
279
747
  );
748
+
749
+ protected readonly hostClass = computed(() => {
750
+ const destructive = this.#destructive();
751
+ const row = this.#row();
752
+ switch (this.#look()) {
753
+ case 'default':
754
+ return cn(
755
+ 'h-10 items-center rounded-md px-4 py-2 flex justify-center',
756
+ this.variant() === 'cancel'
757
+ ? 'border border-border bg-background'
758
+ : destructive
759
+ ? 'bg-destructive'
760
+ : 'bg-primary',
761
+ this.userClass(),
762
+ );
763
+ case 'glass':
764
+ return cn(
765
+ 'h-[48px] items-center rounded-full px-[16px] flex relative justify-center overflow-hidden',
766
+ row && 'flex-1',
767
+ this.preferred() &&
768
+ (destructive ? 'bg-destructive' : 'bg-glass-tint'),
769
+ this.userClass(),
770
+ );
771
+ case 'frosted':
772
+ return cn(
773
+ 'h-[44px] items-center px-[8px] flex relative justify-center',
774
+ row && 'flex-1',
775
+ this.userClass(),
776
+ );
777
+ default:
778
+ return cn(
779
+ 'h-[40px] items-center rounded-full px-[12px] flex relative justify-center overflow-hidden',
780
+ this.userClass(),
781
+ );
782
+ }
783
+ });
784
+
785
+ /** The default variant's solid buttons dim while pressed. */
786
+ protected readonly hostOpacity = computed(() =>
787
+ this.#look() === 'default' && this.held() ? 0.8 : null,
788
+ );
789
+
790
+ /**
791
+ * iOS 26's capsules sit on a light fill of the glass's foreground, which
792
+ * deepens under the finger (a preferred capsule lightens instead); the
793
+ * other looks only light up while pressed: Material's state layer, in the
794
+ * label's color.
795
+ */
796
+ protected readonly layerClass = computed(() => {
797
+ const look = this.#look();
798
+ if (look === 'default') return null;
799
+ const fill =
800
+ look === 'glass' && this.preferred()
801
+ ? 'bg-glass-tint-foreground'
802
+ : look === 'material'
803
+ ? this.#destructive()
804
+ ? 'bg-destructive'
805
+ : 'bg-primary'
806
+ : 'bg-glass-foreground';
807
+ return cn('h-full w-full absolute left-0 top-0', fill);
808
+ });
809
+
810
+ protected readonly layerOpacity = computed(() => {
811
+ const held = this.held();
812
+ if (this.#look() === 'glass' && !this.preferred()) {
813
+ return held ? 0.2 : 0.12;
814
+ }
815
+ return held ? 0.1 : 0;
816
+ });
817
+
818
+ /**
819
+ * The hairline between the buttons of Apple's alerts before iOS 26: beside
820
+ * every button but the first in a row, and above every button but the
821
+ * first in a stack. The footer draws the one above them all.
822
+ */
823
+ protected readonly separatorClass = computed(() => {
824
+ if (this.#look() !== 'frosted' || this.position() === 0) return null;
825
+ return cn(
826
+ 'bg-glass-foreground opacity-20 absolute left-0 top-0',
827
+ this.#row() ? 'h-full w-[0.5px]' : 'h-[0.5px] w-full',
828
+ );
829
+ });
830
+
831
+ protected readonly labelClass = computed(() => {
832
+ const destructive = this.#destructive();
833
+ switch (this.#look()) {
834
+ case 'default':
835
+ return cn(
836
+ 'text-sm font-medium',
837
+ this.variant() === 'cancel'
838
+ ? 'text-foreground'
839
+ : destructive
840
+ ? 'text-destructive-foreground'
841
+ : 'text-primary-foreground',
842
+ );
843
+ case 'glass':
844
+ return cn(
845
+ 'text-[17px] text-center',
846
+ this.preferred()
847
+ ? cn(
848
+ 'font-semibold',
849
+ destructive
850
+ ? 'text-destructive-foreground'
851
+ : 'text-glass-tint-foreground',
852
+ )
853
+ : cn(
854
+ 'font-medium',
855
+ destructive ? 'text-destructive' : 'text-glass-foreground',
856
+ ),
857
+ );
858
+ case 'frosted':
859
+ // UIKit sets the preferred action in bold, or else the cancel.
860
+ return cn(
861
+ 'text-[17px] text-center',
862
+ destructive ? 'text-destructive' : 'text-glass-tint',
863
+ (this.preferred() ||
864
+ (this.variant() === 'cancel' && !this.#footer.hasPreferred())) &&
865
+ 'font-semibold',
866
+ );
867
+ default:
868
+ return cn(
869
+ 'text-[14px] font-medium text-center',
870
+ destructive ? 'text-destructive' : 'text-primary',
871
+ );
872
+ }
873
+ });
874
+
875
+ protected hold(down: boolean): void {
876
+ this.held.set(down);
877
+ }
878
+
879
+ /**
880
+ * Emits `pressed` and closes the dialog, once: a second tap while the
881
+ * dialog closes does nothing, as on iOS.
882
+ */
883
+ protected choose(): void {
884
+ if (!this.#dialog.open()) return;
885
+ this.pressed.emit();
886
+ this.#dialog.open.set(false);
887
+ }
280
888
  }