@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.
- package/dist/commands/add.test.js +2 -0
- package/dist/commands/add.test.js.map +1 -1
- package/dist/commands/doctor.js +78 -21
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +167 -26
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/init.test.js +4 -7
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/update.d.ts +3 -1
- package/dist/commands/update.js +78 -15
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/update.test.js +257 -2
- package/dist/commands/update.test.js.map +1 -1
- package/dist/lockfile.d.ts +10 -0
- package/dist/lockfile.js +20 -1
- package/dist/lockfile.js.map +1 -1
- package/dist/lockfile.test.js +43 -3
- package/dist/lockfile.test.js.map +1 -1
- package/dist/registry.js +73 -11
- package/dist/registry.js.map +1 -1
- package/dist/registry.test.js +16 -3
- package/dist/registry.test.js.map +1 -1
- package/dist/test-utils.d.ts +24 -2
- package/dist/test-utils.js +41 -2
- package/dist/test-utils.js.map +1 -1
- package/dist/ui/boolean-inputs.test.ts +76 -0
- package/dist/ui/components/action-sheet/action-sheet.test.ts +370 -1
- package/dist/ui/components/action-sheet/action-sheet.ts +306 -28
- package/dist/ui/components/action-sheet/index.ts +1 -0
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +552 -8
- package/dist/ui/components/alert-dialog/alert-dialog.ts +644 -36
- package/dist/ui/components/alert-dialog/index.ts +5 -0
- package/dist/ui/components/area-chart/area-chart.ts +3 -2
- package/dist/ui/components/badge/badge.ts +2 -1
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +968 -3
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1021 -95
- package/dist/ui/components/bottom-sheet/index.ts +7 -1
- package/dist/ui/components/button/button.test.ts +36 -1
- package/dist/ui/components/button/button.ts +17 -7
- package/dist/ui/components/button-group/button-group.test.ts +53 -9
- package/dist/ui/components/button-group/button-group.ts +67 -21
- package/dist/ui/components/card/card.test.ts +16 -0
- package/dist/ui/components/card/card.ts +2 -1
- package/dist/ui/components/chart/chart-presets.ts +6 -6
- package/dist/ui/components/chart/chart.test.ts +14 -0
- package/dist/ui/components/chart/chart.ts +10 -3
- package/dist/ui/components/checkbox/checkbox.ts +2 -1
- package/dist/ui/components/collapsible/collapsible.ts +2 -1
- package/dist/ui/components/dialog/dialog.ts +6 -6
- package/dist/ui/components/dock/dock-scroll.ts +9 -0
- package/dist/ui/components/dock/dock-search.test.ts +594 -0
- package/dist/ui/components/dock/dock.test.ts +4 -1
- package/dist/ui/components/dock/dock.ts +529 -51
- package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
- package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
- package/dist/ui/components/dynamic-color/index.ts +5 -0
- package/dist/ui/components/glass/glass.test.ts +39 -1
- package/dist/ui/components/glass/glass.ts +87 -17
- package/dist/ui/components/glass/index.ts +1 -0
- package/dist/ui/components/icon/icon-theme.test.ts +28 -0
- package/dist/ui/components/icon/icon-theme.ts +38 -7
- package/dist/ui/components/input/input.ts +2 -1
- package/dist/ui/components/label/label.ts +8 -2
- package/dist/ui/components/line-chart/line-chart.ts +3 -2
- package/dist/ui/components/list/list.ts +5 -4
- package/dist/ui/components/menu/menu-popup.test.ts +18 -14
- package/dist/ui/components/menu/menu-popup.ts +24 -3
- package/dist/ui/components/menu/menu.test.ts +99 -5
- package/dist/ui/components/menu/menu.ts +74 -34
- package/dist/ui/components/nav-drawer/nav-drawer.ts +3 -2
- package/dist/ui/components/navigation-bar/index.ts +1 -0
- package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
- package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
- package/dist/ui/components/popover/index.ts +9 -0
- package/dist/ui/components/popover/popover-panel.test.ts +735 -0
- package/dist/ui/components/popover/popover-panel.ts +603 -0
- package/dist/ui/components/popover/popover.test.ts +286 -0
- package/dist/ui/components/popover/popover.ts +207 -0
- package/dist/ui/components/progress/progress.ts +2 -1
- package/dist/ui/components/radio-group/radio-group.ts +2 -1
- package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
- package/dist/ui/components/search-field/index.ts +15 -0
- package/dist/ui/components/search-field/keyboard.test.ts +147 -0
- package/dist/ui/components/search-field/keyboard.ts +123 -0
- package/dist/ui/components/search-field/search-field.test.ts +549 -0
- package/dist/ui/components/search-field/search-field.ts +628 -0
- package/dist/ui/components/segmented-control/index.ts +8 -0
- package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
- package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
- package/dist/ui/components/select/select.ts +2 -1
- package/dist/ui/components/sheet/sheet.test.ts +90 -3
- package/dist/ui/components/sheet/sheet.ts +59 -4
- package/dist/ui/components/slider/index.ts +1 -0
- package/dist/ui/components/slider/slider.test.ts +695 -0
- package/dist/ui/components/slider/slider.ts +901 -0
- package/dist/ui/components/switch/index.ts +1 -1
- package/dist/ui/components/switch/switch.test.ts +464 -48
- package/dist/ui/components/switch/switch.ts +613 -43
- package/dist/ui/components/tabs/index.ts +7 -1
- package/dist/ui/components/tabs/tabs.test.ts +278 -2
- package/dist/ui/components/tabs/tabs.ts +233 -30
- package/dist/ui/components/textarea/textarea.ts +2 -1
- package/dist/ui/components/toggle/index.ts +1 -0
- package/dist/ui/components/toggle/toggle.test.ts +310 -4
- package/dist/ui/components/toggle/toggle.ts +241 -26
- package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
- package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
- package/dist/ui/components/toolbar/index.ts +12 -0
- package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
- package/dist/ui/components/toolbar/toolbar.ts +535 -0
- package/dist/utils/analyze.d.ts +10 -5
- package/dist/utils/analyze.js +3 -2
- package/dist/utils/analyze.js.map +1 -1
- package/dist/utils/installed.d.ts +13 -1
- package/dist/utils/installed.js +17 -0
- package/dist/utils/installed.js.map +1 -1
- package/dist/utils/installed.test.js +39 -1
- package/dist/utils/installed.test.js.map +1 -1
- package/dist/utils/tailwind.d.ts +25 -5
- package/dist/utils/tailwind.js +53 -14
- package/dist/utils/tailwind.js.map +1 -1
- package/dist/utils/tailwind.test.js +85 -22
- package/dist/utils/tailwind.test.js.map +1 -1
- 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
|
+
}
|
|
@@ -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
|
-
/**
|
|
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 #
|
|
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.#
|
|
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.
|
|
268
|
-
* color scheme (`ios-user-interface-style`), which
|
|
269
|
-
* from the device: an app in its light theme on a
|
|
270
|
-
* get dark glass under dark text.
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
332
|
-
*
|
|
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]',
|
|
@@ -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
|
+
});
|