@blotch/dolan 22.2.3 → 22.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/dist/commands/add.js +9 -1
  2. package/dist/commands/add.js.map +1 -1
  3. package/dist/commands/add.test.js +36 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +187 -7
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +280 -7
  8. package/dist/commands/doctor.test.js.map +1 -1
  9. package/dist/commands/init.d.ts +2 -2
  10. package/dist/commands/init.js +38 -18
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/init.test.js +107 -1
  13. package/dist/commands/init.test.js.map +1 -1
  14. package/dist/commands/theme.js +1 -1
  15. package/dist/commands/theme.js.map +1 -1
  16. package/dist/commands/update.d.ts +5 -0
  17. package/dist/commands/update.js +126 -45
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +314 -2
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +7 -0
  22. package/dist/lockfile.js +36 -0
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +25 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +48 -2
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +45 -3
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +5 -0
  33. package/dist/test-utils.js +6 -1
  34. package/dist/test-utils.js.map +1 -1
  35. package/dist/testing/fire-lynx-event.d.ts +14 -0
  36. package/dist/testing/fire-lynx-event.js +19 -0
  37. package/dist/testing/fire-lynx-event.js.map +1 -0
  38. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  39. package/dist/testing/fire-lynx-event.test.js +44 -0
  40. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  41. package/dist/testing/lynx-main-thread.d.ts +22 -0
  42. package/dist/testing/lynx-main-thread.js +11 -0
  43. package/dist/testing/lynx-main-thread.js.map +1 -0
  44. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  45. package/dist/testing/lynx-main-thread.test.js +24 -0
  46. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  47. package/dist/testing/set-input-signal.d.ts +5 -4
  48. package/dist/testing/set-input-signal.js +5 -4
  49. package/dist/testing/set-input-signal.js.map +1 -1
  50. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  51. package/dist/ui/components/accordion/accordion.ts +19 -14
  52. package/dist/ui/components/action-sheet/action-sheet.test.ts +272 -187
  53. package/dist/ui/components/action-sheet/action-sheet.ts +40 -34
  54. package/dist/ui/components/alert/alert.test.ts +67 -19
  55. package/dist/ui/components/alert/alert.ts +14 -16
  56. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +206 -139
  57. package/dist/ui/components/alert-dialog/alert-dialog.ts +32 -26
  58. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  59. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  60. package/dist/ui/components/badge/badge.test.ts +57 -23
  61. package/dist/ui/components/badge/badge.ts +12 -14
  62. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  63. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +373 -297
  64. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +34 -33
  65. package/dist/ui/components/button/button.test.ts +611 -2
  66. package/dist/ui/components/button/button.ts +431 -62
  67. package/dist/ui/components/button/index.ts +2 -0
  68. package/dist/ui/components/button-group/button-group.test.ts +484 -2
  69. package/dist/ui/components/button-group/button-group.ts +380 -68
  70. package/dist/ui/components/button-group/index.ts +3 -0
  71. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  72. package/dist/ui/components/card/card.test.ts +82 -62
  73. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  74. package/dist/ui/components/chart/chart-presets.ts +8 -1
  75. package/dist/ui/components/chart/chart-theme.ts +12 -0
  76. package/dist/ui/components/chart/chart.test.ts +1017 -0
  77. package/dist/ui/components/chart/chart.ts +56 -43
  78. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  79. package/dist/ui/components/checkbox/checkbox.ts +23 -23
  80. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  81. package/dist/ui/components/collapsible/collapsible.ts +19 -14
  82. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  83. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  84. package/dist/ui/components/context-menu/index.ts +1 -0
  85. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  86. package/dist/ui/components/dialog/dialog.ts +29 -23
  87. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  88. package/dist/ui/components/dock/dock-scroll.ts +133 -0
  89. package/dist/ui/components/dock/dock.test.ts +749 -0
  90. package/dist/ui/components/dock/dock.ts +960 -0
  91. package/dist/ui/components/dock/index.ts +2 -0
  92. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  93. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  94. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  95. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  96. package/dist/ui/components/glass/glass-design.ts +119 -0
  97. package/dist/ui/components/glass/glass.test.ts +338 -0
  98. package/dist/ui/components/glass/glass.ts +360 -0
  99. package/dist/ui/components/glass/index.ts +18 -0
  100. package/dist/ui/components/icon/icon-theme.test.ts +36 -0
  101. package/dist/ui/components/icon/icon-theme.ts +36 -13
  102. package/dist/ui/components/icon/icon.test.ts +52 -41
  103. package/dist/ui/components/input/input.test.ts +178 -101
  104. package/dist/ui/components/input/input.ts +15 -12
  105. package/dist/ui/components/label/label.test.ts +11 -24
  106. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  107. package/dist/ui/components/list/list.test.ts +80 -49
  108. package/dist/ui/components/menu/index.ts +25 -0
  109. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  110. package/dist/ui/components/menu/menu-placement.ts +159 -0
  111. package/dist/ui/components/menu/menu-popup.test.ts +632 -0
  112. package/dist/ui/components/menu/menu-popup.ts +544 -0
  113. package/dist/ui/components/menu/menu.test.ts +429 -0
  114. package/dist/ui/components/menu/menu.ts +415 -0
  115. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  116. package/dist/ui/components/nav-drawer/nav-drawer.ts +72 -56
  117. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  118. package/dist/ui/components/progress/progress.test.ts +102 -46
  119. package/dist/ui/components/progress/progress.ts +52 -54
  120. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  121. package/dist/ui/components/radio-group/radio-group.ts +24 -24
  122. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  123. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  124. package/dist/ui/components/select/select.test.ts +138 -78
  125. package/dist/ui/components/select/select.ts +17 -11
  126. package/dist/ui/components/separator/separator.test.ts +18 -17
  127. package/dist/ui/components/sheet/sheet.test.ts +64 -11
  128. package/dist/ui/components/sheet/sheet.ts +9 -1
  129. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  130. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  131. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  132. package/dist/ui/components/spinner/spinner.ts +16 -19
  133. package/dist/ui/components/switch/switch.test.ts +116 -46
  134. package/dist/ui/components/switch/switch.ts +8 -13
  135. package/dist/ui/components/tab-bar/index.ts +1 -0
  136. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  137. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  138. package/dist/ui/components/tabs/tabs.test.ts +215 -157
  139. package/dist/ui/components/tabs/tabs.ts +110 -118
  140. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  141. package/dist/ui/components/textarea/textarea.ts +14 -12
  142. package/dist/ui/components/toast/toast.test.ts +434 -350
  143. package/dist/ui/components/toast/toast.ts +42 -51
  144. package/dist/ui/components/toggle/toggle.test.ts +247 -140
  145. package/dist/ui/theme/chart-colors.test.ts +8 -3
  146. package/dist/ui/theme/dark.css +38 -2
  147. package/dist/ui/theme/default.css +55 -7
  148. package/dist/ui/theme/icon-colors.test.ts +8 -3
  149. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  150. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  151. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  152. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  153. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  154. package/dist/ui/theme/tailwind.css +113 -0
  155. package/dist/ui/utils/animate.test.ts +13 -1
  156. package/dist/utils/analyze.d.ts +14 -0
  157. package/dist/utils/analyze.js +15 -0
  158. package/dist/utils/analyze.js.map +1 -1
  159. package/dist/utils/analyze.test.js +14 -1
  160. package/dist/utils/analyze.test.js.map +1 -1
  161. package/dist/utils/installed.d.ts +12 -0
  162. package/dist/utils/installed.js +16 -0
  163. package/dist/utils/installed.js.map +1 -1
  164. package/dist/utils/installed.test.js +17 -1
  165. package/dist/utils/installed.test.js.map +1 -1
  166. package/dist/utils/resolve-paths.d.ts +4 -4
  167. package/dist/utils/resolve-paths.js +5 -7
  168. package/dist/utils/resolve-paths.js.map +1 -1
  169. package/dist/utils/resolve-paths.test.js +9 -5
  170. package/dist/utils/resolve-paths.test.js.map +1 -1
  171. package/dist/utils/tailwind.d.ts +25 -0
  172. package/dist/utils/tailwind.js +51 -0
  173. package/dist/utils/tailwind.js.map +1 -0
  174. package/dist/utils/tailwind.test.d.ts +1 -0
  175. package/dist/utils/tailwind.test.js +71 -0
  176. package/dist/utils/tailwind.test.js.map +1 -0
  177. package/package.json +9 -1
  178. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  179. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  180. package/dist/ui/components/button/button-render.test.ts +0 -132
  181. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  182. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  183. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  184. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  185. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -0,0 +1,415 @@
1
+ import {
2
+ Component,
3
+ DestroyRef,
4
+ Directive,
5
+ ElementRef,
6
+ InjectionToken,
7
+ ViewEncapsulation,
8
+ booleanAttribute,
9
+ computed,
10
+ effect,
11
+ inject,
12
+ input,
13
+ model,
14
+ output,
15
+ signal,
16
+ viewChild,
17
+ } from '@angular/core';
18
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
19
+
20
+ import { cn } from '../../utils/cn';
21
+ import { injectGlassDesign } from '../glass/glass-design';
22
+ import { UiIcon } from '../icon/icon';
23
+ import { type IconName } from '../icon/icons';
24
+ import { type MenuElement, UiMenuPopup } from './menu-popup';
25
+
26
+ /**
27
+ * AngularLynx's element, which can add an accessibility trait to the app's
28
+ * own (see LynxElement.setAccessibilityTrait).
29
+ */
30
+ type AccessibleElement = {
31
+ setAccessibilityTrait(trait: string, on: boolean): void;
32
+ };
33
+
34
+ /**
35
+ * What a menu's items need from the menu they're in: `ui-menu` or
36
+ * `ui-context-menu`.
37
+ */
38
+ export type UiMenuHost = {
39
+ /** Closes the menu, as choosing an item does. */
40
+ close(): void;
41
+ };
42
+
43
+ /**
44
+ * The menu an item is in. `ui-menu` and `ui-context-menu` provide it, so the
45
+ * same items, sections and separators work in both.
46
+ */
47
+ export const UI_MENU = new InjectionToken<UiMenuHost>('UI_MENU');
48
+
49
+ /**
50
+ * A menu item: a label, with an optional icon, that does something and
51
+ * closes the menu.
52
+ *
53
+ * ```html
54
+ * <ui-menu-item label="Copy" icon="copy" (pressed)="copy()" />
55
+ * <ui-menu-item label="Delete" icon="trash" variant="destructive" (pressed)="remove()" />
56
+ * ```
57
+ *
58
+ * Each design lays it out as its platform does: Apple's 44pt rows, with the
59
+ * icon before the label from iOS 26 and after it before, and Material 3's
60
+ * 48dp rows with a leading icon. A screen reader reads it as a button with
61
+ * its label, and a destructive item as "Delete, destructive".
62
+ */
63
+ @Component({
64
+ selector: 'ui-menu-item',
65
+ imports: [LYNX_ELEMENTS, UiIcon],
66
+ encapsulation: ViewEncapsulation.None,
67
+ template: `
68
+ <view
69
+ #row
70
+ [class]="rowClass()"
71
+ [accessibility-element]="true"
72
+ accessibility-traits="button"
73
+ [accessibility-label]="spokenLabel()"
74
+ (bindtouchstart)="hold(true)"
75
+ (bindtouchend)="hold(false)"
76
+ (bindtouchcancel)="hold(false)"
77
+ (bindmousedown)="hold(true)"
78
+ (bindmouseup)="hold(false)"
79
+ (bindmouseleave)="hold(false)"
80
+ (bindtap)="choose()"
81
+ >
82
+ <!-- The pressed highlight: the glass's own foreground at a tenth, so it
83
+ suits light and dark glass, and is Material's state layer. -->
84
+ <view
85
+ class="h-full w-full bg-glass-foreground absolute left-0 top-0"
86
+ [style.opacity]="held() ? 0.1 : 0"
87
+ ></view>
88
+ @if (checked() !== undefined) {
89
+ <!-- A checkable item keeps the column for the checkmark when it's
90
+ unchecked, so the labels of a group line up. -->
91
+ <view class="h-5 w-5 items-center flex justify-center">
92
+ @if (checked()) {
93
+ <ui-icon name="check" size="sm" [color]="iconColor()" />
94
+ }
95
+ </view>
96
+ }
97
+ @if (leadingIcon(); as name) {
98
+ <ui-icon [name]="name" [size]="iconSize()" [color]="iconColor()" />
99
+ }
100
+ <text [class]="labelClass()">{{ label() }}</text>
101
+ @if (trailingIcon(); as name) {
102
+ <ui-icon [name]="name" [size]="iconSize()" [color]="iconColor()" />
103
+ }
104
+ </view>
105
+ `,
106
+ })
107
+ export class UiMenuItem {
108
+ readonly #menu = inject(UI_MENU);
109
+ readonly #design = injectGlassDesign();
110
+
111
+ /** What the item does, which is also what a screen reader reads. */
112
+ readonly label = input.required<string>();
113
+ /** An icon for a common action, from the icon set. */
114
+ readonly icon = input<IconName | undefined>(undefined);
115
+ /** `'destructive'` for an action that deletes or can't be undone. */
116
+ readonly variant = input<'default' | 'destructive'>('default');
117
+ /**
118
+ * Whether the item shows a checkmark, for an option that's on, such as the
119
+ * current sort order. Leave it unset for an item that's never checked;
120
+ * set it, true or false, on each item of a group of options, so their
121
+ * labels line up.
122
+ */
123
+ readonly checked = input<boolean | undefined>(undefined);
124
+ /** Shows the item without letting it be chosen. */
125
+ readonly disabled = input(false, { transform: booleanAttribute });
126
+ /**
127
+ * What a screen reader reads, when the label isn't enough. By default the
128
+ * label, and ", destructive" after it for a destructive item.
129
+ */
130
+ readonly accessibilityLabel = input<string | undefined>(undefined);
131
+
132
+ /** The item was chosen. The menu closes. */
133
+ readonly pressed = output<void>();
134
+
135
+ /** Whether a finger (or the mouse) is down on the item. */
136
+ protected readonly held = signal(false);
137
+
138
+ // Required: always in the template.
139
+ readonly row = viewChild.required<ElementRef<AccessibleElement>>('row');
140
+
141
+ readonly #material = computed(() => this.#design() === 'material');
142
+ readonly #destructive = computed(() => this.variant() === 'destructive');
143
+
144
+ /**
145
+ * The icon goes before the label, except before iOS 26, whose menus put it
146
+ * at the end of the row.
147
+ */
148
+ readonly #iconLeads = computed(() => this.#design() !== 'frosted');
149
+ protected readonly leadingIcon = computed(() =>
150
+ this.#iconLeads() ? this.icon() : undefined,
151
+ );
152
+ protected readonly trailingIcon = computed(() =>
153
+ this.#iconLeads() ? undefined : this.icon(),
154
+ );
155
+
156
+ protected readonly rowClass = computed(() =>
157
+ cn(
158
+ 'flex-row items-center gap-3 flex relative',
159
+ // A row grows when its label wraps.
160
+ this.#material()
161
+ ? 'min-h-[48px] px-3 py-3'
162
+ : 'min-h-[44px] px-4 py-[11px]',
163
+ this.disabled() && 'opacity-40',
164
+ ),
165
+ );
166
+
167
+ /**
168
+ * A checked item is `selected` to a screen reader, which iOS reads.
169
+ * Through the runtime, which adds the trait to `button` on iOS and leaves
170
+ * Android, which reads one trait, with `button`.
171
+ */
172
+ protected readonly selectedTrait = effect(() => {
173
+ this.row().nativeElement.setAccessibilityTrait(
174
+ 'selected',
175
+ this.checked() === true,
176
+ );
177
+ });
178
+ protected readonly labelClass = computed(() =>
179
+ cn(
180
+ 'flex-1',
181
+ this.#material() ? 'text-[16px]' : 'text-[17px]',
182
+ this.#destructive() ? 'text-destructive' : 'text-glass-foreground',
183
+ ),
184
+ );
185
+ protected readonly iconSize = computed(() =>
186
+ this.#material() ? 'md' : 'sm',
187
+ );
188
+ protected readonly iconColor = computed(() =>
189
+ this.#destructive() ? 'var(--destructive)' : 'var(--glass-foreground)',
190
+ );
191
+ protected readonly spokenLabel = computed(
192
+ () =>
193
+ this.accessibilityLabel() ??
194
+ (this.#destructive() ? `${this.label()}, destructive` : this.label()),
195
+ );
196
+
197
+ protected hold(down: boolean): void {
198
+ this.held.set(down && !this.disabled());
199
+ }
200
+
201
+ protected choose(): void {
202
+ if (this.disabled()) return;
203
+ this.pressed.emit();
204
+ this.#menu.close();
205
+ }
206
+ }
207
+
208
+ /**
209
+ * A group of menu items, with an optional title above them.
210
+ *
211
+ * ```html
212
+ * <ui-menu-section title="Sort by">
213
+ * <ui-menu-item label="Name" />
214
+ * <ui-menu-item label="Date" />
215
+ * </ui-menu-section>
216
+ * ```
217
+ *
218
+ * Put a `<ui-menu-separator />` between sections.
219
+ */
220
+ @Component({
221
+ selector: 'ui-menu-section',
222
+ imports: [LYNX_ELEMENTS],
223
+ encapsulation: ViewEncapsulation.None,
224
+ template: `
225
+ <view class="flex-col flex">
226
+ @if (title()) {
227
+ <text [class]="titleClass()" accessibility-traits="header">
228
+ {{ title() }}
229
+ </text>
230
+ }
231
+ <ng-content />
232
+ </view>
233
+ `,
234
+ })
235
+ export class UiMenuSection {
236
+ readonly #design = injectGlassDesign();
237
+
238
+ /** A title for the section, such as "Sort by". */
239
+ readonly title = input<string | undefined>(undefined);
240
+
241
+ protected readonly titleClass = computed(() =>
242
+ cn(
243
+ 'pb-1 pt-2 text-[13px] text-glass-muted-foreground',
244
+ this.#design() === 'material' ? 'px-3' : 'px-4',
245
+ ),
246
+ );
247
+ }
248
+
249
+ /**
250
+ * A line between a menu's sections. Before iOS 26, Apple's menus parted
251
+ * sections with a thick band rather than a line.
252
+ */
253
+ @Component({
254
+ selector: 'ui-menu-separator',
255
+ imports: [LYNX_ELEMENTS],
256
+ encapsulation: ViewEncapsulation.None,
257
+ template: `<view [class]="lineClass()"></view>`,
258
+ })
259
+ export class UiMenuSeparator {
260
+ readonly #design = injectGlassDesign();
261
+
262
+ protected readonly lineClass = computed(() => {
263
+ switch (this.#design()) {
264
+ case 'glass':
265
+ return 'mx-4 my-1.5 h-px bg-glass-foreground opacity-20';
266
+ case 'frosted':
267
+ return 'h-2 bg-glass-foreground opacity-10';
268
+ default:
269
+ return 'my-2 h-px bg-border';
270
+ }
271
+ });
272
+ }
273
+
274
+ /**
275
+ * The `ui-menu` a trigger is in, for `UI_MENU`. A named function rather than
276
+ * an inline factory: the component's metadata keeps its own copy of an
277
+ * inline one, which nothing calls, so an app's coverage report would show it
278
+ * as uncovered.
279
+ */
280
+ const pullDownMenu = (): UiMenuHost => inject(UiMenu);
281
+
282
+ /**
283
+ * A pull-down menu: a glass menu that grows out of the button that opens it.
284
+ *
285
+ * ```html
286
+ * <ui-menu>
287
+ * <ui-button uiMenuTrigger variant="outline">Sort</ui-button>
288
+ * <ui-menu-item label="Name" icon="type" (pressed)="sortBy('name')" />
289
+ * <ui-menu-item label="Date" icon="calendar" (pressed)="sortBy('date')" />
290
+ * </ui-menu>
291
+ * ```
292
+ *
293
+ * Mark the element that opens it with `uiMenuTrigger`; everything else
294
+ * inside it is the menu. A tap on the trigger opens it; a tap outside it,
295
+ * choosing an item, or Android's back button closes it. `[(open)]` opens and
296
+ * closes it from code.
297
+ *
298
+ * On iOS 26 and later it's Liquid Glass, and grows out of its trigger: one
299
+ * glass shape whose frame grows from the trigger's to the menu's, which the
300
+ * glass follows as it goes, with the trigger hidden under it. Before iOS 26
301
+ * it's the frosted material. On Android it's a Material 3 menu that unfolds
302
+ * below its trigger, or Liquid Glass with `provideGlass({ android: 'glass' })`.
303
+ * It stays on screen and inside the safe area, and opens upward near the
304
+ * bottom of the screen.
305
+ */
306
+ @Component({
307
+ selector: 'ui-menu',
308
+ imports: [LYNX_ELEMENTS, UiMenuPopup],
309
+ encapsulation: ViewEncapsulation.None,
310
+ providers: [{ provide: UI_MENU, useFactory: pullDownMenu }],
311
+ template: `
312
+ <ng-content />
313
+ <ui-menu-popup
314
+ #popup
315
+ [open]="open()"
316
+ [anchor]="anchor()"
317
+ (dismissed)="close()"
318
+ (closed)="closed.emit()"
319
+ >
320
+ <ng-content select="ui-menu-item, ui-menu-section, ui-menu-separator" />
321
+ </ui-menu-popup>
322
+ `,
323
+ })
324
+ export class UiMenu implements UiMenuHost {
325
+ /** Whether the menu is open. */
326
+ readonly open = model(false);
327
+ /** The menu has closed and its close animation has finished. */
328
+ readonly closed = output<void>();
329
+
330
+ readonly #host = inject<ElementRef<MenuElement>>(ElementRef);
331
+ readonly #trigger = signal<MenuElement | null>(null);
332
+
333
+ // Required: always in the template.
334
+ readonly popup = viewChild.required<UiMenuPopup>('popup');
335
+
336
+ /**
337
+ * What the menu grows out of: its trigger, or the menu's own element when
338
+ * nothing is marked `uiMenuTrigger`.
339
+ */
340
+ protected readonly anchor = computed(
341
+ () => this.#trigger() ?? this.#host.nativeElement,
342
+ );
343
+
344
+ /**
345
+ * Whether the menu covers its trigger now: a glass or frosted menu grows
346
+ * out of it and shrinks back into it, so the trigger hides meanwhile, as
347
+ * on iOS. A Material menu opens beside it.
348
+ */
349
+ readonly coversTrigger = computed(() => {
350
+ const popup = this.popup();
351
+ const phase = popup.phase();
352
+ return (
353
+ popup.design() !== 'material' &&
354
+ phase !== 'closed' &&
355
+ phase !== 'measuring'
356
+ );
357
+ });
358
+
359
+ toggle(): void {
360
+ this.open.update((open) => !open);
361
+ }
362
+
363
+ close(): void {
364
+ this.open.set(false);
365
+ }
366
+
367
+ /**
368
+ * @internal Called by the `uiMenuTrigger` directive.
369
+ */
370
+ setTrigger(trigger: MenuElement | null): void {
371
+ this.#trigger.set(trigger);
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Marks the element that opens a `ui-menu`: a tap on it opens or closes the
377
+ * menu, and the menu grows out of it.
378
+ *
379
+ * ```html
380
+ * <ui-menu>
381
+ * <ui-button uiMenuTrigger>Options</ui-button>
382
+ * …
383
+ * </ui-menu>
384
+ * ```
385
+ */
386
+ @Directive({
387
+ selector: '[uiMenuTrigger]',
388
+ host: {
389
+ '(click)': 'menu.toggle()',
390
+ '[style.opacity]': 'menu.coversTrigger() ? 0 : null',
391
+ },
392
+ })
393
+ export class UiMenuTrigger {
394
+ protected readonly menu = inject(UiMenu);
395
+
396
+ /** Points the menu at this element until the directive is destroyed. */
397
+ protected readonly unregister = registerTrigger(
398
+ this.menu,
399
+ inject<ElementRef<MenuElement>>(ElementRef).nativeElement,
400
+ inject(DestroyRef),
401
+ );
402
+ }
403
+
404
+ /**
405
+ * Makes `trigger` the element `menu` grows out of, until `destroyRef` is
406
+ * destroyed. Returns what takes the registration back early.
407
+ */
408
+ const registerTrigger = (
409
+ menu: UiMenu,
410
+ trigger: MenuElement,
411
+ destroyRef: DestroyRef,
412
+ ): (() => void) => {
413
+ menu.setTrigger(trigger);
414
+ return destroyRef.onDestroy(() => menu.setTrigger(null));
415
+ };