@blotch/dolan 22.2.4 → 22.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/dist/commands/add.test.js +2 -0
  2. package/dist/commands/add.test.js.map +1 -1
  3. package/dist/commands/doctor.js +78 -21
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/doctor.test.js +167 -26
  6. package/dist/commands/doctor.test.js.map +1 -1
  7. package/dist/commands/init.test.js +4 -7
  8. package/dist/commands/init.test.js.map +1 -1
  9. package/dist/commands/update.d.ts +3 -1
  10. package/dist/commands/update.js +78 -15
  11. package/dist/commands/update.js.map +1 -1
  12. package/dist/commands/update.test.js +257 -2
  13. package/dist/commands/update.test.js.map +1 -1
  14. package/dist/lockfile.d.ts +10 -0
  15. package/dist/lockfile.js +20 -1
  16. package/dist/lockfile.js.map +1 -1
  17. package/dist/lockfile.test.js +43 -3
  18. package/dist/lockfile.test.js.map +1 -1
  19. package/dist/registry.js +73 -11
  20. package/dist/registry.js.map +1 -1
  21. package/dist/registry.test.js +16 -3
  22. package/dist/registry.test.js.map +1 -1
  23. package/dist/test-utils.d.ts +24 -2
  24. package/dist/test-utils.js +41 -2
  25. package/dist/test-utils.js.map +1 -1
  26. package/dist/ui/boolean-inputs.test.ts +76 -0
  27. package/dist/ui/components/action-sheet/action-sheet.test.ts +370 -1
  28. package/dist/ui/components/action-sheet/action-sheet.ts +306 -28
  29. package/dist/ui/components/action-sheet/index.ts +1 -0
  30. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +552 -8
  31. package/dist/ui/components/alert-dialog/alert-dialog.ts +644 -36
  32. package/dist/ui/components/alert-dialog/index.ts +5 -0
  33. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  34. package/dist/ui/components/badge/badge.ts +2 -1
  35. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +968 -3
  36. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1021 -95
  37. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  38. package/dist/ui/components/button/button.test.ts +36 -1
  39. package/dist/ui/components/button/button.ts +17 -7
  40. package/dist/ui/components/button-group/button-group.test.ts +53 -9
  41. package/dist/ui/components/button-group/button-group.ts +67 -21
  42. package/dist/ui/components/card/card.test.ts +16 -0
  43. package/dist/ui/components/card/card.ts +2 -1
  44. package/dist/ui/components/chart/chart-presets.ts +6 -6
  45. package/dist/ui/components/chart/chart.test.ts +14 -0
  46. package/dist/ui/components/chart/chart.ts +10 -3
  47. package/dist/ui/components/checkbox/checkbox.ts +2 -1
  48. package/dist/ui/components/collapsible/collapsible.ts +2 -1
  49. package/dist/ui/components/dialog/dialog.ts +6 -6
  50. package/dist/ui/components/dock/dock-scroll.ts +9 -0
  51. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  52. package/dist/ui/components/dock/dock.test.ts +4 -1
  53. package/dist/ui/components/dock/dock.ts +529 -51
  54. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  55. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  56. package/dist/ui/components/dynamic-color/index.ts +5 -0
  57. package/dist/ui/components/glass/glass.test.ts +39 -1
  58. package/dist/ui/components/glass/glass.ts +87 -17
  59. package/dist/ui/components/glass/index.ts +1 -0
  60. package/dist/ui/components/icon/icon-theme.test.ts +28 -0
  61. package/dist/ui/components/icon/icon-theme.ts +38 -7
  62. package/dist/ui/components/input/input.ts +2 -1
  63. package/dist/ui/components/label/label.ts +8 -2
  64. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  65. package/dist/ui/components/list/list.ts +5 -4
  66. package/dist/ui/components/menu/menu-popup.test.ts +18 -14
  67. package/dist/ui/components/menu/menu-popup.ts +24 -3
  68. package/dist/ui/components/menu/menu.test.ts +99 -5
  69. package/dist/ui/components/menu/menu.ts +74 -34
  70. package/dist/ui/components/nav-drawer/nav-drawer.ts +3 -2
  71. package/dist/ui/components/navigation-bar/index.ts +1 -0
  72. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  73. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  74. package/dist/ui/components/popover/index.ts +9 -0
  75. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  76. package/dist/ui/components/popover/popover-panel.ts +603 -0
  77. package/dist/ui/components/popover/popover.test.ts +286 -0
  78. package/dist/ui/components/popover/popover.ts +207 -0
  79. package/dist/ui/components/progress/progress.ts +2 -1
  80. package/dist/ui/components/radio-group/radio-group.ts +2 -1
  81. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  82. package/dist/ui/components/search-field/index.ts +15 -0
  83. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  84. package/dist/ui/components/search-field/keyboard.ts +123 -0
  85. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  86. package/dist/ui/components/search-field/search-field.ts +628 -0
  87. package/dist/ui/components/segmented-control/index.ts +8 -0
  88. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  89. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  90. package/dist/ui/components/select/select.ts +2 -1
  91. package/dist/ui/components/sheet/sheet.test.ts +90 -3
  92. package/dist/ui/components/sheet/sheet.ts +59 -4
  93. package/dist/ui/components/slider/index.ts +1 -0
  94. package/dist/ui/components/slider/slider.test.ts +695 -0
  95. package/dist/ui/components/slider/slider.ts +901 -0
  96. package/dist/ui/components/switch/index.ts +1 -1
  97. package/dist/ui/components/switch/switch.test.ts +464 -48
  98. package/dist/ui/components/switch/switch.ts +613 -43
  99. package/dist/ui/components/tabs/index.ts +7 -1
  100. package/dist/ui/components/tabs/tabs.test.ts +278 -2
  101. package/dist/ui/components/tabs/tabs.ts +233 -30
  102. package/dist/ui/components/textarea/textarea.ts +2 -1
  103. package/dist/ui/components/toggle/index.ts +1 -0
  104. package/dist/ui/components/toggle/toggle.test.ts +310 -4
  105. package/dist/ui/components/toggle/toggle.ts +241 -26
  106. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  107. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  108. package/dist/ui/components/toolbar/index.ts +12 -0
  109. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  110. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  111. package/dist/utils/analyze.d.ts +10 -5
  112. package/dist/utils/analyze.js +3 -2
  113. package/dist/utils/analyze.js.map +1 -1
  114. package/dist/utils/installed.d.ts +13 -1
  115. package/dist/utils/installed.js +17 -0
  116. package/dist/utils/installed.js.map +1 -1
  117. package/dist/utils/installed.test.js +39 -1
  118. package/dist/utils/installed.test.js.map +1 -1
  119. package/dist/utils/tailwind.d.ts +25 -5
  120. package/dist/utils/tailwind.js +53 -14
  121. package/dist/utils/tailwind.js.map +1 -1
  122. package/dist/utils/tailwind.test.js +85 -22
  123. package/dist/utils/tailwind.test.js.map +1 -1
  124. package/package.json +1 -1
@@ -0,0 +1,144 @@
1
+ import { Component, computed, signal } from '@angular/core';
2
+ import {
3
+ DYNAMIC_COLOR_ROLES,
4
+ LYNX_ELEMENTS,
5
+ LynxTheme,
6
+ type DynamicColors,
7
+ type DynamicColorScheme,
8
+ } from '@blotch/angular-lynx';
9
+ import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
10
+ import { describe, expect, it } from 'vitest';
11
+ import {
12
+ ICON_LIGHT_COLORS,
13
+ injectIconColor,
14
+ provideIconTheme,
15
+ } from '../icon/icon-theme';
16
+ import {
17
+ DYNAMIC_THEME_ROLES,
18
+ UiDynamicColor,
19
+ dynamicThemeColors,
20
+ } from './dynamic-color';
21
+
22
+ /**
23
+ * A scheme in one color, with a primary of its own.
24
+ */
25
+ const scheme = (color: string, primary: string): DynamicColorScheme =>
26
+ ({
27
+ ...Object.fromEntries(DYNAMIC_COLOR_ROLES.map((role) => [role, color])),
28
+ primary,
29
+ }) as DynamicColorScheme;
30
+
31
+ const WALLPAPER: DynamicColors = {
32
+ light: { ...scheme('#fef7ff', '#6750a4'), onSurface: '#1d1b20' },
33
+ dark: { ...scheme('#141218', '#d0bcff'), onSurface: '#e6e0e9' },
34
+ };
35
+
36
+ /**
37
+ * What an icon inside the element draws `var(--primary)` with.
38
+ */
39
+ @Component({
40
+ selector: 'test-swatch',
41
+ imports: [LYNX_ELEMENTS],
42
+ template: `<text class="swatch">{{ primary() }}</text>`,
43
+ })
44
+ class Swatch {
45
+ readonly #resolve = injectIconColor();
46
+ protected readonly primary = computed(() => this.#resolve('var(--primary)'));
47
+ }
48
+
49
+ @Component({
50
+ imports: [LYNX_ELEMENTS, UiDynamicColor, Swatch],
51
+ template: `
52
+ <view class="root" style="height: 100%" uiDynamicColor>
53
+ <test-swatch />
54
+ </view>
55
+ `,
56
+ })
57
+ class Host {}
58
+
59
+ const setup = async (colors: DynamicColors | null) => {
60
+ const dynamicColors = signal(colors);
61
+ const colorScheme = signal<'light' | 'dark'>('light');
62
+ const result = await render(Host, {
63
+ providers: [
64
+ { provide: LynxTheme, useValue: { dynamicColors } },
65
+ provideIconTheme({ colorScheme: () => colorScheme() }),
66
+ ],
67
+ });
68
+ const root = result.container.querySelector('.root') as HTMLElement;
69
+ const swatch = () => result.container.querySelector('.swatch')?.textContent;
70
+ return { dynamicColors, colorScheme, root, swatch };
71
+ };
72
+
73
+ describe('dynamicThemeColors', () => {
74
+ it('gives each theme color its role’s color, and the ring’s subtle form at 40%', () => {
75
+ const colors = dynamicThemeColors(WALLPAPER.light);
76
+ expect(colors['primary']).toBe('rgba(103, 80, 164, 1)');
77
+ expect(colors['ring']).toBe('rgba(103, 80, 164, 1)');
78
+ expect(colors['ring-subtle']).toBe('rgba(103, 80, 164, 0.4)');
79
+ expect(colors['foreground']).toBe('rgba(29, 27, 32, 1)');
80
+ expect(colors['background']).toBe('rgba(254, 247, 255, 1)');
81
+ });
82
+
83
+ it('leaves the destructive and chart colors to the theme', () => {
84
+ const colors = dynamicThemeColors(WALLPAPER.light);
85
+ expect(Object.keys(colors)).not.toContain('destructive');
86
+ expect(Object.keys(colors)).not.toContain('chart-1');
87
+ expect(Object.keys(colors)).not.toContain('glass-dim');
88
+ });
89
+
90
+ it('only maps theme colors that exist', () => {
91
+ for (const token of Object.keys(DYNAMIC_THEME_ROLES)) {
92
+ expect(ICON_LIGHT_COLORS[token]).toBeDefined();
93
+ }
94
+ });
95
+ });
96
+
97
+ describe('UiDynamicColor', () => {
98
+ it('sets the theme’s variables and their -rgb twins to the wallpaper’s colors', async () => {
99
+ const { root } = await setup(WALLPAPER);
100
+ expect(root.style.getPropertyValue('--primary')).toBe(
101
+ 'rgba(103, 80, 164, 1)',
102
+ );
103
+ expect(root.style.getPropertyValue('--primary-rgb')).toBe('103, 80, 164');
104
+ expect(root.style.getPropertyValue('--foreground-rgb')).toBe('29, 27, 32');
105
+ expect(root.style.getPropertyValue('--ring-subtle')).toBe(
106
+ 'rgba(103, 80, 164, 0.4)',
107
+ );
108
+ expect(root.style.getPropertyValue('--ring-subtle-rgb')).toBe('');
109
+ // The element's own style stays.
110
+ expect(root.style.getPropertyValue('height')).toBe('100%');
111
+ });
112
+
113
+ it('sets the scheme the app is in', async () => {
114
+ const { root, colorScheme } = await setup(WALLPAPER);
115
+ colorScheme.set('dark');
116
+ await waitForUpdate();
117
+ expect(root.style.getPropertyValue('--primary')).toBe(
118
+ 'rgba(208, 188, 255, 1)',
119
+ );
120
+ });
121
+
122
+ it('gives icons inside it the same colors', async () => {
123
+ const { swatch, colorScheme } = await setup(WALLPAPER);
124
+ expect(swatch()).toBe('rgba(103, 80, 164, 1)');
125
+ colorScheme.set('dark');
126
+ await waitForUpdate();
127
+ expect(swatch()).toBe('rgba(208, 188, 255, 1)');
128
+ });
129
+
130
+ it('leaves the theme as it is where the host sends no colors, and when they go', async () => {
131
+ const { root, swatch, dynamicColors } = await setup(null);
132
+ expect(root.style.getPropertyValue('--primary')).toBe('');
133
+ expect(swatch()).toBe(ICON_LIGHT_COLORS['primary']);
134
+
135
+ dynamicColors.set(WALLPAPER);
136
+ await waitForUpdate();
137
+ expect(swatch()).toBe('rgba(103, 80, 164, 1)');
138
+
139
+ dynamicColors.set(null);
140
+ await waitForUpdate();
141
+ expect(root.style.getPropertyValue('--primary')).toBe('');
142
+ expect(swatch()).toBe(ICON_LIGHT_COLORS['primary']);
143
+ });
144
+ });
@@ -0,0 +1,146 @@
1
+ import { computed, Directive, inject } from '@angular/core';
2
+ import {
3
+ LynxTheme,
4
+ type DynamicColorRole,
5
+ type DynamicColorScheme,
6
+ } from '@blotch/angular-lynx';
7
+
8
+ import {
9
+ injectColorScheme,
10
+ UI_THEME_COLORS,
11
+ type IconColorTokens,
12
+ type ThemeColors,
13
+ } from '../icon/icon-theme';
14
+
15
+ /**
16
+ * The Material 3 role each theme color takes from the wallpaper, so that
17
+ * everything drawn with the theme, the Material designs and the app's own
18
+ * classes alike, follows it with no colors of its own to set.
19
+ *
20
+ * - The app and its cards stand on the surface, and the Material designs'
21
+ * tonal surfaces (`bg-secondary`: bars, sheets, menus, the switch's track)
22
+ * on a surface container.
23
+ * - Selected items (`bg-accent`) take the secondary container, as Material's
24
+ * selected chips and navigation items do.
25
+ * - The destructive and chart colors aren't the wallpaper's: Material keeps
26
+ * errors red, and a chart's series tell data apart. So they stay the
27
+ * theme's, as do the clear glass's white and the dimming layer under it.
28
+ */
29
+ export const DYNAMIC_THEME_ROLES: Readonly<Record<string, DynamicColorRole>> = {
30
+ background: 'surface',
31
+ foreground: 'onSurface',
32
+ card: 'surface',
33
+ 'card-foreground': 'onSurface',
34
+ primary: 'primary',
35
+ 'primary-foreground': 'onPrimary',
36
+ secondary: 'surfaceContainerHigh',
37
+ 'secondary-foreground': 'onSurface',
38
+ muted: 'surfaceContainerHighest',
39
+ 'muted-foreground': 'onSurfaceVariant',
40
+ accent: 'secondaryContainer',
41
+ 'accent-foreground': 'onSecondaryContainer',
42
+ border: 'outlineVariant',
43
+ input: 'outlineVariant',
44
+ ring: 'primary',
45
+ 'glass-tint': 'primary',
46
+ 'glass-tint-foreground': 'onPrimary',
47
+ 'glass-foreground': 'onSurface',
48
+ 'glass-muted-foreground': 'onSurfaceVariant',
49
+ };
50
+
51
+ /**
52
+ * `#6750a4` as `103, 80, 164`, the form of the theme's `-rgb` twins.
53
+ */
54
+ const channels = (hex: string): string =>
55
+ [1, 3, 5]
56
+ .map((start) => parseInt(hex.slice(start, start + 2), 16))
57
+ .join(', ');
58
+
59
+ /**
60
+ * The theme's colors in a dynamic color scheme, by token name without `--`,
61
+ * each a complete `rgba()`, as the theme and its TypeScript copies write
62
+ * them. `ring-subtle` is the ring at 40%, as in the theme.
63
+ */
64
+ export const dynamicThemeColors = (
65
+ scheme: DynamicColorScheme,
66
+ ): IconColorTokens => {
67
+ const colors: Record<string, string> = {};
68
+ for (const [token, role] of Object.entries(DYNAMIC_THEME_ROLES)) {
69
+ colors[token] = `rgba(${channels(scheme[role])}, 1)`;
70
+ }
71
+ colors['ring-subtle'] = `rgba(${channels(scheme.primary)}, 0.4)`;
72
+ return colors;
73
+ };
74
+
75
+ /**
76
+ * The CSS variables that give a theme color its value in a scheme: the color,
77
+ * and for an opaque one its `-rgb` twin, which opacity modifiers such as
78
+ * `bg-primary/50` read.
79
+ */
80
+ const themeVariables = (scheme: DynamicColorScheme): Record<string, string> => {
81
+ const variables: Record<string, string> = {};
82
+ for (const [token, color] of Object.entries(dynamicThemeColors(scheme))) {
83
+ variables[`--${token}`] = color;
84
+ }
85
+ for (const [token, role] of Object.entries(DYNAMIC_THEME_ROLES)) {
86
+ variables[`--${token}-rgb`] = channels(scheme[role]);
87
+ }
88
+ return variables;
89
+ };
90
+
91
+ const providedColors = () => inject(UiDynamicColor).colors;
92
+
93
+ /**
94
+ * Colors the theme from the user's wallpaper on Android 12 and later
95
+ * (Material You's dynamic color), for everything inside the element it's on.
96
+ * Put it on the app's root view, the one that switches the `dark` class:
97
+ *
98
+ * ```html
99
+ * <view class="h-full bg-background" [class.dark]="theme.isDarkMode()" uiDynamicColor>
100
+ * ```
101
+ *
102
+ * It sets the theme's own CSS variables (`--primary`, `--secondary` and the
103
+ * others, with their `-rgb` twins) to the wallpaper's Material 3 roles (see
104
+ * {@link DYNAMIC_THEME_ROLES}), inline, where they win over the theme's
105
+ * `page` and `.dark` values. So the Material designs, and the app's own
106
+ * `bg-primary` and `text-muted-foreground`, follow the wallpaper without a
107
+ * second set of colors. Icons, glass tints and charts inside it, which draw
108
+ * with a TypeScript copy of the theme, get the same colors.
109
+ *
110
+ * Which scheme it sets, light or dark, follows `provideIconTheme()`'s
111
+ * `colorScheme`, as icons do: make it match the `dark` class.
112
+ *
113
+ * Where the host app sends no wallpaper colors (iOS, the web preview, Lynx
114
+ * Explorer, Android before 12), it sets nothing and the theme stays as it is.
115
+ * The colors come from `LynxTheme.dynamicColors`, which the Android app
116
+ * `ng build --platform android` makes sends.
117
+ */
118
+ @Directive({
119
+ selector: '[uiDynamicColor]',
120
+ host: { '[style]': 'style()' },
121
+ providers: [{ provide: UI_THEME_COLORS, useFactory: providedColors }],
122
+ })
123
+ export class UiDynamicColor {
124
+ readonly #dynamicColors = inject(LynxTheme).dynamicColors;
125
+ readonly #scheme = injectColorScheme();
126
+
127
+ /**
128
+ * The theme's colors in each scheme from the wallpaper, or none, for what
129
+ * draws inside the element and can't read its CSS variables (see
130
+ * UI_THEME_COLORS).
131
+ */
132
+ readonly colors = computed<ThemeColors>(() => {
133
+ const colors = this.#dynamicColors();
134
+ return colors
135
+ ? {
136
+ light: dynamicThemeColors(colors.light),
137
+ dark: dynamicThemeColors(colors.dark),
138
+ }
139
+ : {};
140
+ });
141
+
142
+ protected readonly style = computed(() => {
143
+ const colors = this.#dynamicColors();
144
+ return colors ? themeVariables(colors[this.#scheme()]) : null;
145
+ });
146
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ DYNAMIC_THEME_ROLES,
3
+ UiDynamicColor,
4
+ dynamicThemeColors,
5
+ } from './dynamic-color';
@@ -1,4 +1,4 @@
1
- import { Component, type Provider, signal } from '@angular/core';
1
+ import { Component, type Provider, signal, viewChild } from '@angular/core';
2
2
  import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
3
  import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -11,6 +11,7 @@ import {
11
11
  import {
12
12
  UiGlass,
13
13
  UiGlassGroup,
14
+ type GlassMaterial,
14
15
  type GlassShape,
15
16
  type GlassVariant,
16
17
  } from './glass';
@@ -29,6 +30,8 @@ import { provideGlass } from './glass-design';
29
30
  [radius]="radius()"
30
31
  [tint]="tint()"
31
32
  [interactive]="interactive()"
33
+ [material]="material()"
34
+ [glassId]="glassId()"
32
35
  >
33
36
  <text class="text-glass-foreground">Now playing</text>
34
37
  <ui-icon name="play" />
@@ -43,6 +46,9 @@ class GlassHost {
43
46
  readonly radius = signal(20);
44
47
  readonly tint = signal<string | undefined>(undefined);
45
48
  readonly interactive = signal(false);
49
+ readonly material = signal<GlassMaterial>('regular');
50
+ readonly glassId = signal<string | undefined>(undefined);
51
+ readonly glass = viewChild.required(UiGlass);
46
52
  }
47
53
 
48
54
  /** Accessibility settings a test can change, as the host app would. */
@@ -107,12 +113,32 @@ describe('UiGlass', () => {
107
113
  expect(glass.getAttribute('ios-user-interface-style')).toBe('light');
108
114
  expect(glass.hasAttribute('glass-tint-color')).toBe(false);
109
115
  expect(glass.hasAttribute('glass-interactive')).toBe(false);
116
+ // Liquid Glass has no materials.
117
+ expect(glass.hasAttribute('blur-material')).toBe(false);
110
118
  // The app's own classes stay on the glass, beside its shape.
111
119
  expect(glass.classList).toContain('px-4');
112
120
  expect(glass.classList).toContain('rounded-full');
113
121
  expect(glass.classList).not.toContain('bg-secondary');
114
122
  });
115
123
 
124
+ it('pairs the shape under its glass id, or the one a component pairs it under', async () => {
125
+ const { glass, host, update } = await renderOn(IOS_26);
126
+ expect(glass.hasAttribute('glass-effect-id')).toBe(false);
127
+
128
+ await update(() => host.glassId.set('search'));
129
+ expect(glass.getAttribute('glass-effect-id')).toBe('search');
130
+
131
+ // A menu that grows out of the shape pairs it under its own id, until
132
+ // it lets go.
133
+ await update(() => host.glass().pair('ui-menu-1'));
134
+ expect(glass.getAttribute('glass-effect-id')).toBe('ui-menu-1');
135
+ await update(() => host.glass().pair(null));
136
+ expect(glass.getAttribute('glass-effect-id')).toBe('search');
137
+
138
+ await update(() => host.glassId.set(undefined));
139
+ expect(glass.hasAttribute('glass-effect-id')).toBe(false);
140
+ });
141
+
116
142
  it('keeps its content inside the glass', async () => {
117
143
  const { glass } = await renderOn(IOS_26);
118
144
 
@@ -213,16 +239,27 @@ describe('UiGlass', () => {
213
239
  provideIconTheme({ colorScheme: () => scheme() }),
214
240
  ]);
215
241
 
242
+ // UIKit's material, which the iOS shell's blur-view draws, in the app's
243
+ // scheme. The style before iOS 13 is for hosts without it.
244
+ expect(glass.getAttribute('blur-material')).toBe('regular');
245
+ expect(glass.getAttribute('ios-user-interface-style')).toBe('light');
216
246
  expect(glass.getAttribute('blur-effect')).toBe('extra-light');
217
247
  expect(glass.getAttribute('blur-radius')).toBe('20px');
218
248
 
249
+ await update(() => host.material.set('chrome'));
250
+ expect(glass.getAttribute('blur-material')).toBe('chrome');
251
+
219
252
  await update(() => scheme.set('dark'));
253
+ expect(glass.getAttribute('ios-user-interface-style')).toBe('dark');
220
254
  expect(glass.getAttribute('blur-effect')).toBe('dark');
221
255
 
256
+ // Clear glass draws the dark material in either scheme.
222
257
  await update(() => {
223
258
  scheme.set('light');
224
259
  host.variant.set('clear');
225
260
  });
261
+ expect(glass.getAttribute('blur-material')).toBe('chrome');
262
+ expect(glass.getAttribute('ios-user-interface-style')).toBe('dark');
226
263
  expect(glass.getAttribute('blur-effect')).toBe('dark');
227
264
  });
228
265
 
@@ -262,6 +299,7 @@ describe('UiGlass', () => {
262
299
  // without a radius, unless this is off.
263
300
  expect(glass.getAttribute('enable-auto-blur')).toBe('false');
264
301
  expect(glass.classList).toContain('bg-secondary');
302
+ expect(glass.hasAttribute('blur-material')).toBe(false);
265
303
  expect(group.getAttribute('blur-effect')).toBe('light');
266
304
 
267
305
  // Clear glass is opaque here too, so its foreground stays dark.
@@ -7,6 +7,7 @@ import {
7
7
  inject,
8
8
  input,
9
9
  numberAttribute,
10
+ signal,
10
11
  ViewEncapsulation,
11
12
  } from '@angular/core';
12
13
  import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
@@ -14,9 +15,9 @@ import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
14
15
  import { cn } from '../../utils/cn';
15
16
  import {
16
17
  injectColorScheme,
18
+ injectThemeColors,
17
19
  resolveIconColor,
18
20
  UI_ICON_COLOR,
19
- UI_ICON_THEME,
20
21
  } from '../icon/icon-theme';
21
22
  import { injectGlassDesign, injectSystemDrawsGlass } from './glass-design';
22
23
 
@@ -34,6 +35,20 @@ export type GlassVariant = 'regular' | 'clear';
34
35
  */
35
36
  export type GlassShape = 'capsule' | 'circle' | 'rounded';
36
37
 
38
+ /**
39
+ * The blur material the frosted design draws on iOS 15 to 25: UIKit's
40
+ * adaptive materials, from the most translucent to the most opaque, and the
41
+ * bars' own. Apple gave each role its own: `'chrome'` for bars (the dock,
42
+ * navigation bar and toolbar), `'regular'` for menus, popovers and alerts,
43
+ * `'thin'` for buttons floating over content.
44
+ */
45
+ export type GlassMaterial =
46
+ | 'ultra-thin'
47
+ | 'thin'
48
+ | 'regular'
49
+ | 'thick'
50
+ | 'chrome';
51
+
37
52
  /**
38
53
  * What a glass element draws:
39
54
  *
@@ -55,7 +70,11 @@ const GLASS_BLUR: Readonly<Record<GlassVariant, string>> = {
55
70
  clear: '6px',
56
71
  };
57
72
 
58
- /** The blur of the frosted materials before iOS 26. */
73
+ /**
74
+ * The blur of the frosted fallback, the `blur-effect` styles from before
75
+ * iOS 13, which hosts without the materials draw (see `#attributes`). The
76
+ * materials have Apple's own.
77
+ */
59
78
  const FROSTED_BLUR = '20px';
60
79
 
61
80
  /**
@@ -157,11 +176,30 @@ export class UiGlass {
157
176
  * a Liquid Glass control does: on iOS 26 and later, and in the web preview.
158
177
  */
159
178
  readonly interactive = input(false, { transform: booleanAttribute });
179
+ /**
180
+ * The blur material of the frosted design, on iOS 15 to 25: the one Apple
181
+ * used for the glass's role (see GlassMaterial). Clear glass draws its
182
+ * dark form, in either theme, as Apple's controls over media did. Liquid
183
+ * Glass has one material, and ignores it.
184
+ */
185
+ readonly material = input<GlassMaterial>('regular');
186
+ /**
187
+ * Pairs this shape with another across a change, as SwiftUI's
188
+ * `glassEffectID` does: when a shape with an id goes (removed, or hidden
189
+ * with `visibility`) and one with the same id comes in the same update,
190
+ * the two are one piece of glass changing shape. The new one grows out of
191
+ * the old one's frame and corners, and the old one's content fades out in
192
+ * it. In the same `ui-glass-group`, the group merges the two as they move.
193
+ *
194
+ * On iOS 26 and later, in an app `ng build --platform ios` makes (its
195
+ * blur-view adds this). Elsewhere one shape replaces the other at once.
196
+ */
197
+ readonly glassId = input<string | undefined>(undefined);
160
198
 
161
199
  readonly #settings = inject(LynxAccessibilitySettings);
162
200
  readonly #systemDrawsGlass = injectSystemDrawsGlass();
163
201
  readonly #scheme = injectColorScheme();
164
- readonly #theme = inject(UI_ICON_THEME);
202
+ readonly #colors = injectThemeColors();
165
203
  readonly #host = inject<ElementRef<BlurViewElement>>(ElementRef);
166
204
 
167
205
  /**
@@ -170,6 +208,21 @@ export class UiGlass {
170
208
  */
171
209
  readonly design = injectGlassDesign();
172
210
 
211
+ /**
212
+ * The id a component pairs this shape under, such as a menu that grows out
213
+ * of it (see pair). It takes the place of `glassId` while it's set.
214
+ */
215
+ readonly #pairedId = signal<string | null>(null);
216
+
217
+ /**
218
+ * @internal Pairs the shape under `id` (see glassId), or stops pairing it
219
+ * (null): for a component that grows its own glass out of this shape, such
220
+ * as the menu of a glass button that opens one.
221
+ */
222
+ pair(id: string | null): void {
223
+ this.#pairedId.set(id);
224
+ }
225
+
173
226
  /**
174
227
  * What the glass draws (see GlassSurface): the design's material, or an
175
228
  * opaque surface. Where the runtime draws the glass (Android's opt-in and
@@ -208,7 +261,7 @@ export class UiGlass {
208
261
  */
209
262
  readonly #tintColor = computed(() => {
210
263
  const tint = this.tint();
211
- return tint ? resolveIconColor(tint, this.#theme[this.#scheme()]) : null;
264
+ return tint ? resolveIconColor(tint, this.#colors()) : null;
212
265
  });
213
266
 
214
267
  /**
@@ -264,35 +317,48 @@ export class UiGlass {
264
317
  });
265
318
 
266
319
  /**
267
- * The blur-view's attributes for the surface. Glass is forced to the app's
268
- * color scheme (`ios-user-interface-style`), which iOS would otherwise take
269
- * from the device: an app in its light theme on a phone in dark mode would
270
- * get dark glass under dark text. The frosted materials before iOS 26 don't
271
- * adapt to dark mode, so the scheme picks one. A solid surface turns the
272
- * blur off, and on Android stops the blur-view redrawing the page behind it
273
- * on every frame (`enable-auto-blur`), which it does even with no radius.
320
+ * The blur-view's attributes for the surface. The glass and the materials
321
+ * are forced to the app's color scheme (`ios-user-interface-style`), which
322
+ * iOS would otherwise take from the device: an app in its light theme on a
323
+ * phone in dark mode would get dark glass under dark text. A solid surface
324
+ * turns the blur off, and on Android stops the blur-view redrawing the page
325
+ * behind it on every frame (`enable-auto-blur`), which it does even with no
326
+ * radius.
327
+ *
328
+ * The frosted design draws `blur-material`, which the iOS shell's
329
+ * blur-view adds (packages/native's BlurView.swift): Lynx's own
330
+ * has only the `blur-effect` styles from before iOS 13, a single weight in
331
+ * a single appearance. A host without it, such as Lynx Explorer, ignores
332
+ * the attribute and draws the `blur-effect` instead, picked for the scheme
333
+ * since those styles don't adapt.
274
334
  */
275
335
  readonly #attributes = computed(() => {
276
336
  const surface = this.surface();
277
337
  const variant = this.variant();
278
- const dark = this.#scheme() === 'dark';
338
+ // Apple's controls over media used the dark materials in either theme.
339
+ const dark =
340
+ this.#scheme() === 'dark' ||
341
+ (surface === 'frosted' && variant === 'clear');
279
342
  let effect = 'light';
280
343
  let radius = '0px';
344
+ let material: GlassMaterial | null = null;
281
345
  if (surface === 'glass') {
282
346
  effect = 'glass';
283
347
  radius = GLASS_BLUR[variant];
284
348
  } else if (surface === 'frosted') {
285
- // Apple's controls over media used the dark material in either theme.
286
- effect = dark || variant === 'clear' ? 'dark' : 'extra-light';
349
+ effect = dark ? 'dark' : 'extra-light';
287
350
  radius = FROSTED_BLUR;
351
+ material = this.material();
288
352
  }
289
353
  return {
290
354
  'blur-effect': effect,
291
355
  'blur-radius': radius,
356
+ 'blur-material': material,
292
357
  'enable-auto-blur': surface !== 'solid',
293
358
  'glass-style': variant,
294
359
  'glass-tint-color': this.#tintColor(),
295
360
  'glass-interactive': this.interactive() || null,
361
+ 'glass-effect-id': this.#pairedId() ?? this.glassId() ?? null,
296
362
  'ios-user-interface-style': dark ? 'dark' : 'light',
297
363
  };
298
364
  });
@@ -301,7 +367,7 @@ export class UiGlass {
301
367
  // bindings, which turn every value into a string: Lynx reads
302
368
  // `glass-interactive` and `enable-auto-blur` as booleans. `null` removes an
303
369
  // attribute, which restores Lynx's default.
304
- protected readonly material = effect(() => {
370
+ protected readonly setAttributes = effect(() => {
305
371
  const element = this.#host.nativeElement;
306
372
  for (const [name, value] of Object.entries(this.#attributes())) {
307
373
  element.setAttribute(name, value);
@@ -328,8 +394,12 @@ export class UiGlass {
328
394
  * Material designs have no glass to merge.
329
395
  *
330
396
  * Moving or resizing a shape, with a CSS transition for example, morphs the
331
- * merged glass with it. A shape that's added or removed appears or
332
- * disappears at once: iOS's glass doesn't animate that in Lynx.
397
+ * merged glass with it. On iOS 26, in an app `ng build --platform ios`
398
+ * makes, a shape that's added or removed (or shown and hidden with
399
+ * `visibility`) materializes or dissolves, as UIKit's glass does, and two
400
+ * shapes with the same `glassId` morph into each other (see UiGlass's
401
+ * `glassId`). Elsewhere a shape that's added or removed appears or
402
+ * disappears at once.
333
403
  */
334
404
  @Component({
335
405
  selector: 'blur-view[ui-glass-group]',
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  UiGlass,
3
3
  UiGlassGroup,
4
+ type GlassMaterial,
4
5
  type GlassShape,
5
6
  type GlassSurface,
6
7
  type GlassVariant,
@@ -11,10 +11,12 @@ import {
11
11
  ICON_LIGHT_COLORS,
12
12
  UI_ICON_COLOR,
13
13
  UI_ICON_THEME,
14
+ UI_THEME_COLORS,
14
15
  injectColorScheme,
15
16
  injectIconColor,
16
17
  provideIconTheme,
17
18
  resolveIconColor,
19
+ type ThemeColors,
18
20
  } from './icon-theme';
19
21
 
20
22
  const LIGHT_FOREGROUND = ICON_LIGHT_COLORS['foreground'];
@@ -167,3 +169,29 @@ describe('injectIconColor', () => {
167
169
  expect(resolve(undefined)).toBe(LIGHT_FOREGROUND);
168
170
  });
169
171
  });
172
+
173
+ describe('UI_THEME_COLORS', () => {
174
+ it('replaces the table’s colors for the active scheme, and only those it sets', () => {
175
+ const replaced = signal<ThemeColors>({
176
+ light: { primary: 'rgba(103, 80, 164, 1)' },
177
+ dark: { primary: 'rgba(208, 188, 255, 1)' },
178
+ });
179
+ const dark = signal(false);
180
+ const resolve = setup(
181
+ [
182
+ provideIconTheme({ colorScheme: 'system' }),
183
+ { provide: UI_THEME_COLORS, useValue: replaced },
184
+ ],
185
+ dark,
186
+ );
187
+ expect(resolve('var(--primary)')).toBe('rgba(103, 80, 164, 1)');
188
+ expect(resolve(undefined)).toBe(LIGHT_FOREGROUND);
189
+
190
+ dark.set(true);
191
+ expect(resolve('var(--primary)')).toBe('rgba(208, 188, 255, 1)');
192
+
193
+ // An element with no colors to set leaves the theme's.
194
+ replaced.set({});
195
+ expect(resolve('var(--primary)')).toBe(ICON_DARK_COLORS['primary']);
196
+ });
197
+ });