@blotch/dolan 22.2.4 → 22.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -23,7 +23,9 @@ import { LynxTheme } from '@blotch/angular-lynx';
23
23
  *
24
24
  * The tables mirror `default.css` and `dark.css` (a test in the theme folder
25
25
  * keeps them in step). If you change a color there, change it here too, or pass
26
- * your own with {@link provideIconTheme}.
26
+ * your own with {@link provideIconTheme}. Colors set at runtime, such as
27
+ * Android's wallpaper colors, come from an element above instead (see
28
+ * {@link UI_THEME_COLORS}).
27
29
  */
28
30
 
29
31
  /** Theme token name (without `--`) to a complete CSS color. */
@@ -207,6 +209,40 @@ export const injectColorScheme = (): Signal<IconColorScheme> => {
207
209
  );
208
210
  };
209
211
 
212
+ /** Theme colors for each scheme, each a complete CSS color, as in the tables. */
213
+ export type ThemeColors = {
214
+ readonly light?: IconColorTokens;
215
+ readonly dark?: IconColorTokens;
216
+ };
217
+
218
+ /**
219
+ * Theme colors that replace the tables' for everything inside an element,
220
+ * as a signal: an element that sets the theme's CSS variables at runtime
221
+ * provides the values it sets, since SVGs and native attributes can't read
222
+ * them. `UiDynamicColor` provides Android's wallpaper colors this way. Icons,
223
+ * glass tints and charts inside it resolve their tokens against them.
224
+ */
225
+ export const UI_THEME_COLORS = new InjectionToken<Signal<ThemeColors>>(
226
+ 'UI_THEME_COLORS',
227
+ );
228
+
229
+ /**
230
+ * The color table for the app's color scheme, for a component's constructor:
231
+ * the theme's copy, with what an element above sets in its place (see
232
+ * {@link UI_THEME_COLORS}). A signal, so it follows scheme and theme changes.
233
+ */
234
+ export const injectThemeColors = (): Signal<IconColorTokens> => {
235
+ const theme = inject(UI_ICON_THEME);
236
+ const scheme = injectColorScheme();
237
+ const replaced = inject(UI_THEME_COLORS, { optional: true });
238
+
239
+ return computed(() => {
240
+ const active = scheme();
241
+ const colors = replaced?.()[active];
242
+ return colors ? { ...theme[active], ...colors } : theme[active];
243
+ });
244
+ };
245
+
210
246
  /**
211
247
  * For an SVG component's constructor: a function that resolves its `color`
212
248
  * input (or, without one, the surrounding {@link UI_ICON_COLOR}, then the
@@ -214,14 +250,9 @@ export const injectColorScheme = (): Signal<IconColorScheme> => {
214
250
  * `computed()` calling it follows scheme and theme changes.
215
251
  */
216
252
  export const injectIconColor = (): ((color: string | undefined) => string) => {
217
- const theme = inject(UI_ICON_THEME);
218
- const scheme = injectColorScheme();
253
+ const tokens = injectThemeColors();
219
254
  const inherited = inject(UI_ICON_COLOR, { optional: true });
220
255
 
221
- const tokens = computed(() =>
222
- scheme() === 'dark' ? theme.dark : theme.light,
223
- );
224
-
225
256
  return (color) =>
226
257
  resolveIconColor(color ?? inherited?.() ?? DEFAULT_ICON_COLOR, tokens());
227
258
  };
@@ -2,6 +2,7 @@ import {
2
2
  type ElementRef,
3
3
  Component,
4
4
  ViewEncapsulation,
5
+ booleanAttribute,
5
6
  computed,
6
7
  effect,
7
8
  input,
@@ -90,7 +91,7 @@ export class UiInput implements FormValueControl<string> {
90
91
  readonly type = input<string>('text');
91
92
  readonly helperText = input<string>('');
92
93
  readonly error = input<string>('');
93
- readonly disabled = input(false);
94
+ readonly disabled = input(false, { transform: booleanAttribute });
94
95
  readonly userClass = input<string>('', { alias: 'class' });
95
96
 
96
97
  readonly changed = output<string>();
@@ -1,4 +1,10 @@
1
- import { Component, ViewEncapsulation, computed, input } from '@angular/core';
1
+ import {
2
+ Component,
3
+ ViewEncapsulation,
4
+ booleanAttribute,
5
+ computed,
6
+ input,
7
+ } from '@angular/core';
2
8
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
3
9
 
4
10
  import { cn } from '../../utils/cn';
@@ -11,7 +17,7 @@ import { cn } from '../../utils/cn';
11
17
  template: `<text [class]="labelClass()"><ng-content /></text>`,
12
18
  })
13
19
  export class UiLabel {
14
- readonly disabled = input(false);
20
+ readonly disabled = input(false, { transform: booleanAttribute });
15
21
  readonly userClass = input<string>('', { alias: 'class' });
16
22
 
17
23
  protected readonly labelClass = computed(() =>
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  Component,
3
3
  ViewEncapsulation,
4
+ booleanAttribute,
4
5
  computed,
5
6
  input,
6
7
  output,
@@ -90,8 +91,8 @@ export class UiLineChart extends UiCartesianPreset<XYDatum> {
90
91
  readonly color = input('var(--primary)');
91
92
  readonly strokeWidth = input(2);
92
93
  readonly dotRadius = input(3);
93
- readonly showDots = input(true);
94
- readonly smooth = input(false);
94
+ readonly showDots = input(true, { transform: booleanAttribute });
95
+ readonly smooth = input(false, { transform: booleanAttribute });
95
96
 
96
97
  /** The tapped point's datum. */
97
98
  readonly pointTap = output<XYDatum>();
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  Component,
3
3
  ViewEncapsulation,
4
+ booleanAttribute,
4
5
  computed,
5
6
  input,
6
7
  output,
@@ -45,10 +46,10 @@ export class UiList {
45
46
  readonly spanCount = input(1);
46
47
  readonly orientation = input<'vertical' | 'horizontal'>('vertical');
47
48
  readonly height = input<string>('');
48
- readonly bounces = input(true);
49
- readonly scrollBar = input(true);
50
- readonly nestedScroll = input(false);
51
- readonly sticky = input(false);
49
+ readonly bounces = input(true, { transform: booleanAttribute });
50
+ readonly scrollBar = input(true, { transform: booleanAttribute });
51
+ readonly nestedScroll = input(false, { transform: booleanAttribute });
52
+ readonly sticky = input(false, { transform: booleanAttribute });
52
53
  readonly stickyOffset = input(0);
53
54
  readonly preloadBufferCount = input(0);
54
55
  readonly mainAxisGap = input('');
@@ -138,6 +138,8 @@ const renderPopup = async ({
138
138
  host,
139
139
  invoke,
140
140
  held,
141
+ /** How many measurements `held` is keeping back. */
142
+ pendingCount: () => pending.length,
141
143
  /** Answers the measurements `held` kept back. */
142
144
  release: () => {
143
145
  for (const answer of pending.splice(0)) answer();
@@ -199,6 +201,16 @@ const settle = async () => {
199
201
  for (let i = 0; i < 10; i++) await Promise.resolve();
200
202
  };
201
203
 
204
+ /**
205
+ * Waits until the menu has asked for the measurements `held` keeps back, so
206
+ * that `release()` has them to answer. Waiting for the overlay isn't enough:
207
+ * it shows first, and the menu measures itself only after LAYOUT_DELAY, a
208
+ * real timer, which a busy CI runner can fire well after that. Released
209
+ * before then, nothing is answered and the menu waits for good.
210
+ */
211
+ const waitForHeld = (rendered: Rendered) =>
212
+ vi.waitFor(() => expect(rendered.pendingCount()).toBeGreaterThan(0));
213
+
202
214
  // The renderer warns that <ui-menu-popup> isn't a Lynx element.
203
215
  beforeEach(() => {
204
216
  vi.spyOn(console, 'warn').mockImplementation(() => {});
@@ -226,9 +238,8 @@ describe('UiMenuPopup', () => {
226
238
  rendered.held.menu = true;
227
239
  host.open.set(true);
228
240
  // Measured off screen at its natural size first.
229
- await vi.waitFor(() =>
230
- expect(overlay().getAttribute('visible')).toBe('true'),
231
- );
241
+ await waitForHeld(rendered);
242
+ expect(overlay().getAttribute('visible')).toBe('true');
232
243
  expect(style(panel(), 'left')).toBe('-10000px');
233
244
  expect(style(panel(), 'width')).toBe('');
234
245
  expect(style(panel(), 'transition')).toBe('none');
@@ -393,9 +404,8 @@ describe('UiMenuPopup', () => {
393
404
 
394
405
  rendered.held.menu = true;
395
406
  host.open.set(true);
396
- await vi.waitFor(() =>
397
- expect(overlay().getAttribute('visible')).toBe('true'),
398
- );
407
+ await waitForHeld(rendered);
408
+ expect(overlay().getAttribute('visible')).toBe('true');
399
409
  expect(slot().children).toHaveLength(0);
400
410
  rendered.release();
401
411
  await settle();
@@ -568,10 +578,7 @@ describe('UiMenuPopup', () => {
568
578
  rendered.held.menu = true;
569
579
 
570
580
  rendered.host.open.set(true);
571
- await vi.waitFor(() =>
572
- expect(rendered.overlay().getAttribute('visible')).toBe('true'),
573
- );
574
- await new Promise((resolve) => setTimeout(resolve, 50));
581
+ await waitForHeld(rendered);
575
582
  rendered.host.open.set(false);
576
583
  rendered.tick();
577
584
  rendered.release();
@@ -587,10 +594,7 @@ describe('UiMenuPopup', () => {
587
594
  rendered.held.menu = true;
588
595
 
589
596
  rendered.host.open.set(true);
590
- await vi.waitFor(() =>
591
- expect(rendered.overlay().getAttribute('visible')).toBe('true'),
592
- );
593
- await new Promise((resolve) => setTimeout(resolve, 50));
597
+ await waitForHeld(rendered);
594
598
  rendered.release();
595
599
  await settle();
596
600
  // At its start frame now; closing doesn't let it finish growing.
@@ -4,6 +4,7 @@ import {
4
4
  ElementRef,
5
5
  Renderer2,
6
6
  ViewEncapsulation,
7
+ booleanAttribute,
7
8
  computed,
8
9
  effect,
9
10
  inject,
@@ -133,8 +134,10 @@ export const measureMenuElement = async (
133
134
  * placeMenu), and then grows it from its start frame (see menuStartFrame)
134
135
  * with CSS transitions, which Lynx plays on iOS, Android and the web alike.
135
136
  * The menu is one glass shape whose frame grows: iOS's Liquid Glass follows
136
- * a moving or resizing shape frame by frame, where a shape that appears or
137
- * disappears pops.
137
+ * a moving or resizing shape frame by frame. It shows at the trigger's place
138
+ * in the same update that hides the trigger, and hides in the one that shows
139
+ * the trigger again, so the shell can pair a glass trigger with it (see
140
+ * glassId).
138
141
  *
139
142
  * A context menu's element is lifted by moving the element itself into the
140
143
  * overlay, above the dimming layer, at the same place on screen, and back
@@ -198,7 +201,9 @@ export const measureMenuElement = async (
198
201
  [style.top.px]="frame()?.top ?? 0"
199
202
  [style.width.px]="frame()?.width"
200
203
  [style.height.px]="frame()?.height"
204
+ [glassId]="glassId()"
201
205
  [style.opacity]="panelOpacity()"
206
+ [style.visibility]="panelVisibility()"
202
207
  [style.transition]="panelTransition()"
203
208
  [style.align-items]="
204
209
  placement()?.horizontal === 'right' ? 'flex-end' : 'flex-start'
@@ -233,7 +238,7 @@ export const measureMenuElement = async (
233
238
  })
234
239
  export class UiMenuPopup implements OnDestroy {
235
240
  /** Whether the menu is open. */
236
- readonly open = input(false);
241
+ readonly open = input(false, { transform: booleanAttribute });
237
242
  /** The element the menu opens from: a button, or the pressed element. */
238
243
  readonly anchor = input.required<MenuElement>();
239
244
  /** A `'pull-down'` or a `'context'` menu; see MenuKind. */
@@ -243,6 +248,11 @@ export class UiMenuPopup implements OnDestroy {
243
248
  * in. Without one, the element isn't lifted.
244
249
  */
245
250
  readonly anchorHome = input<MenuElement | null>(null);
251
+ /**
252
+ * The id the menu's glass shares with its trigger's (see UiGlass's
253
+ * `glassId`), so that on iOS the menu takes over a glass trigger's glass.
254
+ */
255
+ readonly glassId = input<string | undefined>(undefined);
246
256
 
247
257
  /** The user tapped outside the menu, or pressed Android's back button. */
248
258
  readonly dismissed = output<void>();
@@ -349,6 +359,17 @@ export class UiMenuPopup implements OnDestroy {
349
359
  protected readonly panelTransition = computed(() =>
350
360
  this.#transition(['left', 'top', 'width', 'height', 'opacity']),
351
361
  );
362
+
363
+ /**
364
+ * The menu's glass is hidden until it's at its start frame, so it shows in
365
+ * the same update that hides the trigger it grows out of, and hides in the
366
+ * one that shows the trigger again: on iOS the shell pairs the two across
367
+ * those changes (see glassId).
368
+ */
369
+ protected readonly panelVisibility = computed(() => {
370
+ const phase = this.phase();
371
+ return phase === 'closed' || phase === 'measuring' ? 'hidden' : null;
372
+ });
352
373
  protected readonly contentTransition = computed(() =>
353
374
  this.#transition(['opacity']),
354
375
  );
@@ -8,6 +8,7 @@ import {
8
8
  import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
9
9
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
10
  import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
11
+ import { UiGlass } from '../glass/glass';
11
12
  import {
12
13
  UiMenu,
13
14
  UiMenuItem,
@@ -19,6 +20,7 @@ import {
19
20
  @Component({
20
21
  imports: [
21
22
  LYNX_ELEMENTS,
23
+ UiGlass,
22
24
  UiMenu,
23
25
  UiMenuTrigger,
24
26
  UiMenuItem,
@@ -31,7 +33,18 @@ import {
31
33
  (openChange)="open.set($event)"
32
34
  (closed)="closed()"
33
35
  >
34
- @if (withTrigger()) {
36
+ @if (glassTrigger()) {
37
+ <blur-view
38
+ ui-glass
39
+ uiMenuTrigger
40
+ class="trigger"
41
+ [accessibility-element]="true"
42
+ accessibility-traits="button"
43
+ accessibility-label="Sort"
44
+ >
45
+ <text>Sort</text>
46
+ </blur-view>
47
+ } @else if (withTrigger()) {
35
48
  <view
36
49
  uiMenuTrigger
37
50
  class="trigger"
@@ -69,6 +82,8 @@ import {
69
82
  disabled
70
83
  (pressed)="archived()"
71
84
  />
85
+ <ui-menu-item label="Bold" checked />
86
+ <ui-menu-item label="Pin" [checked]="pinned()" />
72
87
  </ui-menu-section>
73
88
  </ui-menu>
74
89
  `,
@@ -76,7 +91,9 @@ import {
76
91
  class MenuHost {
77
92
  readonly open = signal(false);
78
93
  readonly withTrigger = signal(true);
94
+ readonly glassTrigger = signal(false);
79
95
  readonly sort = signal<'name' | 'date'>('date');
96
+ readonly pinned = signal<boolean | undefined>(undefined);
80
97
  readonly closed = vi.fn();
81
98
  readonly shared = vi.fn();
82
99
  readonly deleted = vi.fn();
@@ -223,13 +240,44 @@ describe('UiMenu', () => {
223
240
  { relativeTo: 'screen' },
224
241
  expect.any(Function),
225
242
  );
226
- expect(style(trigger(), 'opacity')).toBe('0');
243
+ expect(style(trigger(), 'visibility')).toBe('hidden');
227
244
 
228
245
  // A tap on the trigger again closes it, and the trigger comes back.
229
246
  fireEvent.tap(trigger());
230
247
  await waitForClose(rendered);
231
248
  expect(host.open()).toBe(false);
232
- expect(style(trigger(), 'opacity')).toBe('');
249
+ expect(style(trigger(), 'visibility')).toBe('');
250
+ // A trigger that isn't glass has no glass to hand over.
251
+ expect(trigger().hasAttribute('glass-effect-id')).toBe(false);
252
+ });
253
+
254
+ it("takes a glass trigger's glass over, shown as the trigger hides", async () => {
255
+ const rendered = await renderMenu();
256
+ rendered.host.glassTrigger.set(true);
257
+ await waitForUpdate();
258
+ const { trigger, panel } = rendered;
259
+
260
+ // The trigger and the menu share an id of the menu's own, for the shell
261
+ // to pair the two across the change.
262
+ const id = trigger().getAttribute('glass-effect-id');
263
+ expect(id).toMatch(/^ui-menu-\d+$/);
264
+ expect(panel().getAttribute('glass-effect-id')).toBe(id);
265
+ expect(style(panel(), 'visibility')).toBe('hidden');
266
+
267
+ fireEvent.tap(trigger());
268
+ await waitForOpen(rendered);
269
+ expect(style(panel(), 'visibility')).toBe('');
270
+ expect(style(trigger(), 'visibility')).toBe('hidden');
271
+
272
+ fireEvent.tap(trigger());
273
+ await waitForClose(rendered);
274
+ expect(style(panel(), 'visibility')).toBe('hidden');
275
+ expect(style(trigger(), 'visibility')).toBe('');
276
+
277
+ // A trigger that goes takes its glass with it.
278
+ rendered.host.glassTrigger.set(false);
279
+ await waitForUpdate();
280
+ expect(trigger().tagName.toLowerCase()).toBe('view');
233
281
  });
234
282
 
235
283
  it('closes when an item is chosen, after telling it', async () => {
@@ -279,7 +327,7 @@ describe('UiMenu', () => {
279
327
  fireEvent.tap(rendered.trigger());
280
328
  await waitForOpen(rendered);
281
329
 
282
- expect(style(rendered.trigger(), 'opacity')).toBe('');
330
+ expect(style(rendered.trigger(), 'visibility')).toBe('');
283
331
  // Below the trigger, a gap away.
284
332
  expect(style(rendered.panel(), 'top')).toBe(`${80 + 44 + 8}px`);
285
333
  });
@@ -321,6 +369,22 @@ describe('UiMenuItem', () => {
321
369
  await waitForClose(rendered);
322
370
  });
323
371
 
372
+ it('is checked by a bare checked attribute, and unset when bound to undefined', async () => {
373
+ const rendered = await renderOpenMenu();
374
+ const bold = row(rendered, 'Bold');
375
+ const pin = row(rendered, 'Pin');
376
+
377
+ expect(bold.getAttribute('accessibility-traits')).toBe('button,selected');
378
+ expect(bold.querySelector('.h-5.w-5 svg')).not.toBeNull();
379
+ // Unset, it's an item that's never checked: no checkmark column.
380
+ expect(pin.querySelector('.h-5.w-5')).toBeNull();
381
+
382
+ rendered.host.pinned.set(false);
383
+ await waitForUpdate();
384
+ expect(pin.querySelector('.h-5.w-5')).not.toBeNull();
385
+ expect(pin.querySelector('.h-5.w-5 svg')).toBeNull();
386
+ });
387
+
324
388
  it('highlights while pressed, by touch or mouse', async () => {
325
389
  const rendered = await renderOpenMenu();
326
390
  const share = row(rendered, 'Share');
@@ -335,13 +399,43 @@ describe('UiMenuItem', () => {
335
399
  ]) {
336
400
  fireLynxEvent(share, down!);
337
401
  await waitForUpdate();
338
- expect(style(highlight, 'opacity')).toBe('0.1');
402
+ expect(style(highlight, 'opacity')).toBe('0.08');
339
403
  fireLynxEvent(share, up!);
340
404
  await waitForUpdate();
341
405
  expect(style(highlight, 'opacity')).toBe('0');
342
406
  }
343
407
  });
344
408
 
409
+ it('highlights a pressed row as its platform does', async () => {
410
+ const pressed = async (
411
+ platform: { platform: string; osVersion: string } | null,
412
+ ) => {
413
+ const rendered = await renderOpenMenu(platform);
414
+ const share = row(rendered, 'Share');
415
+ fireLynxEvent(share, 'touchstart');
416
+ await waitForUpdate();
417
+ const highlight = share.firstElementChild!;
418
+ return {
419
+ classes: highlight.className.split(' '),
420
+ opacity: style(highlight, 'opacity'),
421
+ };
422
+ };
423
+
424
+ // iOS 26's capsule, inset from the menu's sides.
425
+ const glass = await pressed(IOS_26);
426
+ expect(glass.classes).toEqual(
427
+ expect.arrayContaining(['left-[10px]', 'right-[10px]', 'rounded-full']),
428
+ );
429
+ expect(glass.opacity).toBe('0.08');
430
+ // Edge to edge before iOS 26, and as Material's state layer.
431
+ for (const platform of [IOS_18, null]) {
432
+ const { classes, opacity } = await pressed(platform);
433
+ expect(classes).toEqual(expect.arrayContaining(['left-0', 'right-0']));
434
+ expect(classes).not.toContain('rounded-full');
435
+ expect(opacity).toBe('0.1');
436
+ }
437
+ });
438
+
345
439
  it("can't be pressed or chosen when disabled", async () => {
346
440
  const rendered = await renderOpenMenu();
347
441
  const archive = row(rendered, 'Archive, unavailable offline');