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