@blotch/dolan 22.2.3 → 22.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/dist/commands/add.js +9 -1
  2. package/dist/commands/add.js.map +1 -1
  3. package/dist/commands/add.test.js +38 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +258 -21
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +425 -11
  8. package/dist/commands/doctor.test.js.map +1 -1
  9. package/dist/commands/init.d.ts +2 -2
  10. package/dist/commands/init.js +38 -18
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/init.test.js +105 -2
  13. package/dist/commands/init.test.js.map +1 -1
  14. package/dist/commands/theme.js +1 -1
  15. package/dist/commands/theme.js.map +1 -1
  16. package/dist/commands/update.d.ts +7 -0
  17. package/dist/commands/update.js +194 -50
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +570 -3
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +17 -0
  22. package/dist/lockfile.js +56 -1
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +65 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +119 -11
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +61 -6
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +27 -0
  33. package/dist/test-utils.js +45 -1
  34. package/dist/test-utils.js.map +1 -1
  35. package/dist/testing/fire-lynx-event.d.ts +14 -0
  36. package/dist/testing/fire-lynx-event.js +19 -0
  37. package/dist/testing/fire-lynx-event.js.map +1 -0
  38. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  39. package/dist/testing/fire-lynx-event.test.js +44 -0
  40. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  41. package/dist/testing/lynx-main-thread.d.ts +22 -0
  42. package/dist/testing/lynx-main-thread.js +11 -0
  43. package/dist/testing/lynx-main-thread.js.map +1 -0
  44. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  45. package/dist/testing/lynx-main-thread.test.js +24 -0
  46. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  47. package/dist/testing/set-input-signal.d.ts +5 -4
  48. package/dist/testing/set-input-signal.js +5 -4
  49. package/dist/testing/set-input-signal.js.map +1 -1
  50. package/dist/ui/boolean-inputs.test.ts +76 -0
  51. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  52. package/dist/ui/components/accordion/accordion.ts +19 -14
  53. package/dist/ui/components/action-sheet/action-sheet.test.ts +637 -183
  54. package/dist/ui/components/action-sheet/action-sheet.ts +339 -55
  55. package/dist/ui/components/action-sheet/index.ts +1 -0
  56. package/dist/ui/components/alert/alert.test.ts +67 -19
  57. package/dist/ui/components/alert/alert.ts +14 -16
  58. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +740 -129
  59. package/dist/ui/components/alert-dialog/alert-dialog.ts +672 -58
  60. package/dist/ui/components/alert-dialog/index.ts +5 -0
  61. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  62. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  63. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  64. package/dist/ui/components/badge/badge.test.ts +57 -23
  65. package/dist/ui/components/badge/badge.ts +14 -15
  66. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  67. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +1314 -273
  68. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1046 -119
  69. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  70. package/dist/ui/components/button/button.test.ts +646 -2
  71. package/dist/ui/components/button/button.ts +443 -64
  72. package/dist/ui/components/button/index.ts +2 -0
  73. package/dist/ui/components/button-group/button-group.test.ts +528 -2
  74. package/dist/ui/components/button-group/button-group.ts +428 -70
  75. package/dist/ui/components/button-group/index.ts +3 -0
  76. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  77. package/dist/ui/components/card/card.test.ts +97 -61
  78. package/dist/ui/components/card/card.ts +2 -1
  79. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  80. package/dist/ui/components/chart/chart-presets.ts +14 -7
  81. package/dist/ui/components/chart/chart-theme.ts +12 -0
  82. package/dist/ui/components/chart/chart.test.ts +1031 -0
  83. package/dist/ui/components/chart/chart.ts +66 -46
  84. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  85. package/dist/ui/components/checkbox/checkbox.ts +25 -24
  86. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  87. package/dist/ui/components/collapsible/collapsible.ts +21 -15
  88. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  89. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  90. package/dist/ui/components/context-menu/index.ts +1 -0
  91. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  92. package/dist/ui/components/dialog/dialog.ts +35 -29
  93. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  94. package/dist/ui/components/dock/dock-scroll.ts +142 -0
  95. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  96. package/dist/ui/components/dock/dock.test.ts +752 -0
  97. package/dist/ui/components/dock/dock.ts +1438 -0
  98. package/dist/ui/components/dock/index.ts +2 -0
  99. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  100. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  101. package/dist/ui/components/dynamic-color/index.ts +5 -0
  102. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  103. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  104. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  105. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  106. package/dist/ui/components/glass/glass-design.ts +119 -0
  107. package/dist/ui/components/glass/glass.test.ts +376 -0
  108. package/dist/ui/components/glass/glass.ts +430 -0
  109. package/dist/ui/components/glass/index.ts +19 -0
  110. package/dist/ui/components/icon/icon-theme.test.ts +64 -0
  111. package/dist/ui/components/icon/icon-theme.ts +70 -16
  112. package/dist/ui/components/icon/icon.test.ts +52 -41
  113. package/dist/ui/components/input/input.test.ts +178 -101
  114. package/dist/ui/components/input/input.ts +17 -13
  115. package/dist/ui/components/label/label.test.ts +11 -24
  116. package/dist/ui/components/label/label.ts +8 -2
  117. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  118. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  119. package/dist/ui/components/list/list.test.ts +80 -49
  120. package/dist/ui/components/list/list.ts +5 -4
  121. package/dist/ui/components/menu/index.ts +25 -0
  122. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  123. package/dist/ui/components/menu/menu-placement.ts +159 -0
  124. package/dist/ui/components/menu/menu-popup.test.ts +636 -0
  125. package/dist/ui/components/menu/menu-popup.ts +565 -0
  126. package/dist/ui/components/menu/menu.test.ts +523 -0
  127. package/dist/ui/components/menu/menu.ts +455 -0
  128. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  129. package/dist/ui/components/nav-drawer/nav-drawer.ts +75 -58
  130. package/dist/ui/components/navigation-bar/index.ts +1 -0
  131. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  132. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  133. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  134. package/dist/ui/components/popover/index.ts +9 -0
  135. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  136. package/dist/ui/components/popover/popover-panel.ts +603 -0
  137. package/dist/ui/components/popover/popover.test.ts +286 -0
  138. package/dist/ui/components/popover/popover.ts +207 -0
  139. package/dist/ui/components/progress/progress.test.ts +102 -46
  140. package/dist/ui/components/progress/progress.ts +54 -55
  141. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  142. package/dist/ui/components/radio-group/radio-group.ts +26 -25
  143. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  144. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  145. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  146. package/dist/ui/components/search-field/index.ts +15 -0
  147. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  148. package/dist/ui/components/search-field/keyboard.ts +123 -0
  149. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  150. package/dist/ui/components/search-field/search-field.ts +628 -0
  151. package/dist/ui/components/segmented-control/index.ts +8 -0
  152. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  153. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  154. package/dist/ui/components/select/select.test.ts +138 -78
  155. package/dist/ui/components/select/select.ts +19 -12
  156. package/dist/ui/components/separator/separator.test.ts +18 -17
  157. package/dist/ui/components/sheet/sheet.test.ts +151 -11
  158. package/dist/ui/components/sheet/sheet.ts +68 -5
  159. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  160. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  161. package/dist/ui/components/slider/index.ts +1 -0
  162. package/dist/ui/components/slider/slider.test.ts +695 -0
  163. package/dist/ui/components/slider/slider.ts +901 -0
  164. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  165. package/dist/ui/components/spinner/spinner.ts +16 -19
  166. package/dist/ui/components/switch/index.ts +1 -1
  167. package/dist/ui/components/switch/switch.test.ts +534 -48
  168. package/dist/ui/components/switch/switch.ts +613 -48
  169. package/dist/ui/components/tab-bar/index.ts +1 -0
  170. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  171. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  172. package/dist/ui/components/tabs/index.ts +7 -1
  173. package/dist/ui/components/tabs/tabs.test.ts +484 -150
  174. package/dist/ui/components/tabs/tabs.ts +344 -149
  175. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  176. package/dist/ui/components/textarea/textarea.ts +16 -13
  177. package/dist/ui/components/toast/toast.test.ts +434 -350
  178. package/dist/ui/components/toast/toast.ts +42 -51
  179. package/dist/ui/components/toggle/index.ts +1 -0
  180. package/dist/ui/components/toggle/toggle.test.ts +559 -146
  181. package/dist/ui/components/toggle/toggle.ts +241 -26
  182. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  183. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  184. package/dist/ui/components/toolbar/index.ts +12 -0
  185. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  186. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  187. package/dist/ui/theme/chart-colors.test.ts +8 -3
  188. package/dist/ui/theme/dark.css +38 -2
  189. package/dist/ui/theme/default.css +55 -7
  190. package/dist/ui/theme/icon-colors.test.ts +8 -3
  191. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  192. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  193. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  194. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  195. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  196. package/dist/ui/theme/tailwind.css +113 -0
  197. package/dist/ui/utils/animate.test.ts +13 -1
  198. package/dist/utils/analyze.d.ts +19 -0
  199. package/dist/utils/analyze.js +16 -0
  200. package/dist/utils/analyze.js.map +1 -1
  201. package/dist/utils/analyze.test.js +14 -1
  202. package/dist/utils/analyze.test.js.map +1 -1
  203. package/dist/utils/installed.d.ts +25 -1
  204. package/dist/utils/installed.js +33 -0
  205. package/dist/utils/installed.js.map +1 -1
  206. package/dist/utils/installed.test.js +55 -1
  207. package/dist/utils/installed.test.js.map +1 -1
  208. package/dist/utils/resolve-paths.d.ts +4 -4
  209. package/dist/utils/resolve-paths.js +5 -7
  210. package/dist/utils/resolve-paths.js.map +1 -1
  211. package/dist/utils/resolve-paths.test.js +9 -5
  212. package/dist/utils/resolve-paths.test.js.map +1 -1
  213. package/dist/utils/tailwind.d.ts +45 -0
  214. package/dist/utils/tailwind.js +90 -0
  215. package/dist/utils/tailwind.js.map +1 -0
  216. package/dist/utils/tailwind.test.d.ts +1 -0
  217. package/dist/utils/tailwind.test.js +134 -0
  218. package/dist/utils/tailwind.test.js.map +1 -0
  219. package/package.json +9 -1
  220. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  221. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  222. package/dist/ui/components/button/button-render.test.ts +0 -132
  223. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  224. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  225. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  226. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  227. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -0,0 +1,2 @@
1
+ export { UiDock, UiDockAccessory, UiDockAction, UiDockItem } from './dock';
2
+ export { UiDockScroll, UiDockState } from './dock-scroll';
@@ -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,60 +1,49 @@
1
- import { Component } from '@angular/core';
1
+ import { Component, signal } from '@angular/core';
2
2
  import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
3
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
4
  import { describe, expect, it } from 'vitest';
5
- import { setInputSignal } from '../../../test-utils/set-input-signal';
5
+ import type { IconName } from '../icon/icons';
6
6
  import { UiEmptyState } from './empty-state';
7
7
 
8
8
  @Component({
9
- standalone: true,
10
9
  imports: [UiEmptyState, LYNX_ELEMENTS],
11
10
  template: `
12
11
  <ui-empty-state
13
- icon="search"
12
+ [icon]="icon()"
14
13
  title="No results"
15
- description="Try adjusting your search"
14
+ [description]="description()"
15
+ class="py-6"
16
16
  >
17
- <text>Extra content</text>
17
+ <text>Clear the filters</text>
18
18
  </ui-empty-state>
19
19
  `,
20
20
  })
21
- class EmptyStateHost {}
22
-
23
- @Component({
24
- standalone: true,
25
- imports: [UiEmptyState, LYNX_ELEMENTS],
26
- template: `<ui-empty-state title="Empty"></ui-empty-state>`,
27
- })
28
- class MinimalEmptyStateHost {}
21
+ class EmptyStateHost {
22
+ readonly icon = signal<IconName | undefined>(undefined);
23
+ readonly description = signal('');
24
+ }
29
25
 
30
26
  describe('UiEmptyState', () => {
31
- it('renders with an icon, title, description, and projected content', async () => {
32
- // Text-based queries against the JSDOM container occasionally miss content
33
- // rendered into Lynx `<text>` elements that aren't unwrapped by
34
- // @testing-library/dom's text traversal. A container assertion is enough
35
- // to prove the full template (including the @if(icon)/@if(description)
36
- // branches and the <ng-content /> slot) mounted without throwing.
37
- const { container } = await render(EmptyStateHost);
38
- expect(container).toBeTruthy();
27
+ it('shows only the title and projected content by default', async () => {
28
+ const { container, getByText, queryByText } = await render(EmptyStateHost);
29
+ expect(getByText('No results').classList).toContain('font-semibold');
30
+ expect(getByText('Clear the filters')).toBeTruthy();
31
+ expect(queryByText('Try adjusting your search')).toBeNull();
32
+ expect(container.querySelector('svg')).toBeNull();
33
+ expect(container.querySelector('.py-6')).toBeTruthy();
39
34
  });
40
35
 
41
- it('renders with only the required title (no icon, no description)', async () => {
42
- const { container } = await render(MinimalEmptyStateHost);
43
- expect(container).toBeTruthy();
44
- });
45
-
46
- it('renders the description class when a description is programmatically set', async () => {
47
- // JIT template bindings don't propagate through `input()`; drive the input
48
- // via setInputSignal so the @if(description()) branch actually renders and
49
- // its computed class runs.
50
- const { componentRef, container } = await render(UiEmptyState);
51
- const inst = componentRef.instance as UiEmptyState;
52
- setInputSignal(inst.title, 'Title');
53
- setInputSignal(inst.description, 'A description');
36
+ it('adds the icon and description when they are set', async () => {
37
+ const { componentRef, container, getByText } = await render(EmptyStateHost);
38
+ const host = componentRef.instance as EmptyStateHost;
39
+ host.icon.set('search');
40
+ host.description.set('Try adjusting your search');
54
41
  await waitForUpdate();
55
-
56
- // Both text runs are present after CD flush.
57
- const textNodes = container.querySelectorAll('text');
58
- expect(textNodes.length).toBeGreaterThanOrEqual(2);
42
+ expect(getByText('Try adjusting your search').classList).toContain(
43
+ 'text-muted-foreground',
44
+ );
45
+ expect(container.querySelector('svg')?.getAttribute('content')).toContain(
46
+ '<svg',
47
+ );
59
48
  });
60
49
  });
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { concentricRadius } from './concentric-radius';
3
+
4
+ describe('concentricRadius', () => {
5
+ it('takes the inset off the outer radius, so the corners share a center', () => {
6
+ expect(concentricRadius(24, 8)).toBe(16);
7
+ expect(concentricRadius(20, 0)).toBe(20);
8
+ });
9
+
10
+ it('gives square corners rather than a negative radius', () => {
11
+ expect(concentricRadius(8, 12)).toBe(0);
12
+ });
13
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The corner radius for a shape nested `inset` px inside a shape whose corner
3
+ * radius is `outerRadius`, so that the two corners share a center and the gap
4
+ * between them stays the same width all the way round. Apple calls this
5
+ * concentricity, and Liquid Glass relies on it: a button inset 8px in a glass
6
+ * panel with 24px corners gets 16px corners.
7
+ *
8
+ * It never goes below 0: a shape inset further than the outer radius has
9
+ * square corners. Capsules and circles need no helper, because a capsule
10
+ * inset evenly inside a capsule is a capsule (`shape="capsule"`).
11
+ *
12
+ * ```ts
13
+ * readonly innerRadius = computed(() => concentricRadius(this.radius(), 8));
14
+ * ```
15
+ */
16
+ export const concentricRadius = (outerRadius: number, inset: number): number =>
17
+ Math.max(0, outerRadius - inset);
@@ -0,0 +1,78 @@
1
+ import { Injector, runInInjectionContext } from '@angular/core';
2
+ import { LynxSystemInfo } from '@blotch/angular-lynx';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import {
5
+ glassDesignFor,
6
+ injectGlassDesign,
7
+ injectSystemDrawsGlass,
8
+ provideGlass,
9
+ UI_GLASS_OPTIONS,
10
+ } from './glass-design';
11
+
12
+ describe('glassDesignFor', () => {
13
+ it('gives iOS 26 and later Liquid Glass, and earlier iOS the frosted materials', () => {
14
+ expect(glassDesignFor('iOS', '26.0', {})).toBe('glass');
15
+ expect(glassDesignFor('iOS', '27.1', {})).toBe('glass');
16
+ expect(glassDesignFor('iOS', '25.4', {})).toBe('frosted');
17
+ expect(glassDesignFor('iOS', '15.0', {})).toBe('frosted');
18
+ });
19
+
20
+ it('counts an iOS version not known yet as current', () => {
21
+ expect(glassDesignFor('iOS', '', {})).toBe('glass');
22
+ });
23
+
24
+ it('gives Android Material unless the app opts into glass', () => {
25
+ expect(glassDesignFor('Android', '15', {})).toBe('material');
26
+ expect(glassDesignFor('Android', '15', { android: 'glass' })).toBe('glass');
27
+ });
28
+
29
+ it('draws glass in the web preview, and Material where there is none', () => {
30
+ expect(glassDesignFor('web', '', {})).toBe('glass');
31
+ expect(glassDesignFor('Harmony', '5.0', { android: 'glass' })).toBe(
32
+ 'material',
33
+ );
34
+ expect(glassDesignFor(undefined, '', {})).toBe('material');
35
+ });
36
+ });
37
+
38
+ describe('provideGlass', () => {
39
+ it('provides the options', () => {
40
+ expect(provideGlass({ android: 'glass' })).toEqual({
41
+ provide: UI_GLASS_OPTIONS,
42
+ useValue: { android: 'glass' },
43
+ });
44
+ });
45
+ });
46
+
47
+ describe('injectGlassDesign', () => {
48
+ afterEach(() => vi.unstubAllGlobals());
49
+
50
+ /**
51
+ * Runs `fn` in an injector. A plain injector has no root scope, so it gets
52
+ * LynxSystemInfo, which the app's root injector would make, explicitly.
53
+ */
54
+ const inject = <T>(fn: () => T, options = {}) =>
55
+ runInInjectionContext(
56
+ Injector.create({
57
+ providers: [
58
+ LynxSystemInfo,
59
+ { provide: UI_GLASS_OPTIONS, useValue: options },
60
+ ],
61
+ }),
62
+ fn,
63
+ );
64
+
65
+ it("reads the platform and version from SystemInfo, with the app's options", () => {
66
+ vi.stubGlobal('SystemInfo', { platform: 'Android', osVersion: '14' });
67
+
68
+ expect(inject(injectGlassDesign)()).toBe('material');
69
+ expect(inject(injectGlassDesign, { android: 'glass' })()).toBe('glass');
70
+ expect(inject(injectSystemDrawsGlass)).toBe(false);
71
+ });
72
+
73
+ it('knows that iOS draws its own glass', () => {
74
+ vi.stubGlobal('SystemInfo', { platform: 'iOS', osVersion: '26.0' });
75
+
76
+ expect(inject(injectSystemDrawsGlass)).toBe(true);
77
+ });
78
+ });
@@ -0,0 +1,119 @@
1
+ import {
2
+ computed,
3
+ inject,
4
+ InjectionToken,
5
+ Injector,
6
+ type Provider,
7
+ type Signal,
8
+ } from '@angular/core';
9
+ import { LynxSystemInfo } from '@blotch/angular-lynx';
10
+
11
+ /**
12
+ * The look the glass components take on this platform. One API covers all
13
+ * three, and each component builds its own form of each: the dock is a
14
+ * floating glass capsule with `'glass'`, a tab bar on a blur material with
15
+ * `'frosted'`, and a navigation bar with `'material'`.
16
+ *
17
+ * - `'glass'`: Liquid Glass. iOS 26 and later draw it with UIKit's glass,
18
+ * the web preview draws it with WebGL, and Android, when the app opts in
19
+ * with {@link provideGlass}, approximates it with a blur, a tint and a rim.
20
+ * - `'frosted'`: Apple's design before Liquid Glass, for iOS 15 to 25, whose
21
+ * `blur-view` has no glass: frosted blur materials.
22
+ * - `'material'`: Material 3 Expressive, Android's default, which uses
23
+ * opaque tonal surfaces instead of glass.
24
+ */
25
+ export type GlassDesign = 'glass' | 'frosted' | 'material';
26
+
27
+ export type GlassOptions = {
28
+ /**
29
+ * What Android gets: `'material'` (the default), or `'glass'` for the same
30
+ * look as iOS. Android's glass is a blur with a tint and a rim over it,
31
+ * without the refraction. Blurring costs more below Android 12, where Lynx
32
+ * can't use `RenderEffect`.
33
+ */
34
+ readonly android?: 'material' | 'glass';
35
+ };
36
+
37
+ /** The app's glass options; see {@link provideGlass}. */
38
+ export const UI_GLASS_OPTIONS = new InjectionToken<GlassOptions>(
39
+ 'UI_GLASS_OPTIONS',
40
+ { providedIn: 'root', factory: () => ({}) },
41
+ );
42
+
43
+ /**
44
+ * Sets the glass options for the whole app, in `app.config.ts`:
45
+ *
46
+ * ```ts
47
+ * // Liquid Glass on Android too, instead of Material.
48
+ * providers: [provideGlass({ android: 'glass' })]
49
+ * ```
50
+ */
51
+ export const provideGlass = (options: GlassOptions): Provider => ({
52
+ provide: UI_GLASS_OPTIONS,
53
+ useValue: options,
54
+ });
55
+
56
+ /** The first iOS whose `blur-view` draws Liquid Glass. */
57
+ export const FIRST_GLASS_IOS = 26;
58
+
59
+ /**
60
+ * The design for a platform, as `SystemInfo` names it, and its OS version.
61
+ *
62
+ * Before iOS 26, Lynx's `blur-view` doesn't know `blur-effect="glass"`: it
63
+ * logs an error and draws a `light` blur that ignores dark mode. So older
64
+ * iOS gets `'frosted'`. An iOS version that isn't known yet (empty) counts as
65
+ * current: on the native main thread it arrives from the background thread
66
+ * after the first frame, unless the host app sends it (see
67
+ * `LynxSystemInfo.osVersion`), and iOS 26 and later is where most phones are.
68
+ * Platforms with no glass, such as HarmonyOS, get `'material'`.
69
+ */
70
+ export const glassDesignFor = (
71
+ platform: string | undefined,
72
+ osVersion: string,
73
+ options: GlassOptions,
74
+ ): GlassDesign => {
75
+ switch (platform) {
76
+ case 'iOS':
77
+ return parseInt(osVersion, 10) < FIRST_GLASS_IOS ? 'frosted' : 'glass';
78
+ case 'Android':
79
+ return options.android ?? 'material';
80
+ case 'web':
81
+ return 'glass';
82
+ default:
83
+ return 'material';
84
+ }
85
+ };
86
+
87
+ /**
88
+ * LynxSystemInfo, or undefined where there's no SystemInfo to read, such as
89
+ * in unit tests, where LynxSystemInfo throws.
90
+ */
91
+ const systemInfo = (injector: Injector): LynxSystemInfo | undefined => {
92
+ try {
93
+ return injector.get(LynxSystemInfo);
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ };
98
+
99
+ /**
100
+ * The glass design on this platform, for a component's constructor or field
101
+ * initializers. A signal: on the native main thread, iOS's version can arrive
102
+ * after the first frame.
103
+ */
104
+ export const injectGlassDesign = (): Signal<GlassDesign> => {
105
+ const options = inject(UI_GLASS_OPTIONS);
106
+ const info = systemInfo(inject(Injector));
107
+ return computed(() =>
108
+ glassDesignFor(info?.platform, info?.osVersion ?? '', options),
109
+ );
110
+ };
111
+
112
+ /**
113
+ * Whether the system draws the glass and its materials itself: iOS. UIKit
114
+ * adapts them to Reduce Transparency, Increase Contrast and iOS 27's Liquid
115
+ * Glass setting on its own. Elsewhere, Android's approximation and the web
116
+ * preview's drawing can't, so the components adapt what they draw.
117
+ */
118
+ export const injectSystemDrawsGlass = (): boolean =>
119
+ systemInfo(inject(Injector))?.platform === 'iOS';