@blotch/dolan 22.2.2 → 22.2.4

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 (259) hide show
  1. package/dist/commands/add.d.ts +6 -2
  2. package/dist/commands/add.js +41 -11
  3. package/dist/commands/add.js.map +1 -1
  4. package/dist/commands/add.test.js +162 -9
  5. package/dist/commands/add.test.js.map +1 -1
  6. package/dist/commands/diff.d.ts +2 -1
  7. package/dist/commands/diff.js +38 -25
  8. package/dist/commands/diff.js.map +1 -1
  9. package/dist/commands/diff.test.js +72 -11
  10. package/dist/commands/diff.test.js.map +1 -1
  11. package/dist/commands/doctor.js +228 -34
  12. package/dist/commands/doctor.js.map +1 -1
  13. package/dist/commands/doctor.test.js +367 -10
  14. package/dist/commands/doctor.test.js.map +1 -1
  15. package/dist/commands/eject.js +7 -0
  16. package/dist/commands/eject.js.map +1 -1
  17. package/dist/commands/eject.test.js +39 -0
  18. package/dist/commands/eject.test.js.map +1 -1
  19. package/dist/commands/info.js +3 -2
  20. package/dist/commands/info.js.map +1 -1
  21. package/dist/commands/init.d.ts +37 -1
  22. package/dist/commands/init.js +169 -40
  23. package/dist/commands/init.js.map +1 -1
  24. package/dist/commands/init.test.js +392 -27
  25. package/dist/commands/init.test.js.map +1 -1
  26. package/dist/commands/install-packages.d.ts +6 -0
  27. package/dist/commands/install-packages.js +20 -0
  28. package/dist/commands/install-packages.js.map +1 -0
  29. package/dist/commands/install-packages.test.d.ts +1 -0
  30. package/dist/commands/install-packages.test.js +55 -0
  31. package/dist/commands/install-packages.test.js.map +1 -0
  32. package/dist/commands/list.js +21 -14
  33. package/dist/commands/list.js.map +1 -1
  34. package/dist/commands/list.test.js +62 -5
  35. package/dist/commands/list.test.js.map +1 -1
  36. package/dist/commands/outdated.js +11 -10
  37. package/dist/commands/outdated.js.map +1 -1
  38. package/dist/commands/outdated.test.js +20 -5
  39. package/dist/commands/outdated.test.js.map +1 -1
  40. package/dist/commands/remove.d.ts +4 -0
  41. package/dist/commands/remove.js +17 -4
  42. package/dist/commands/remove.js.map +1 -1
  43. package/dist/commands/remove.test.js +53 -2
  44. package/dist/commands/remove.test.js.map +1 -1
  45. package/dist/commands/theme.js +1 -1
  46. package/dist/commands/theme.js.map +1 -1
  47. package/dist/commands/update.d.ts +11 -0
  48. package/dist/commands/update.js +174 -58
  49. package/dist/commands/update.js.map +1 -1
  50. package/dist/commands/update.test.js +587 -33
  51. package/dist/commands/update.test.js.map +1 -1
  52. package/dist/index.js +7 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/lockfile.d.ts +7 -0
  55. package/dist/lockfile.js +36 -0
  56. package/dist/lockfile.js.map +1 -1
  57. package/dist/lockfile.test.d.ts +1 -0
  58. package/dist/lockfile.test.js +25 -0
  59. package/dist/lockfile.test.js.map +1 -0
  60. package/dist/registry.d.ts +14 -0
  61. package/dist/registry.js +78 -24
  62. package/dist/registry.js.map +1 -1
  63. package/dist/registry.test.js +119 -38
  64. package/dist/registry.test.js.map +1 -1
  65. package/dist/test-utils.d.ts +5 -0
  66. package/dist/test-utils.js +6 -1
  67. package/dist/test-utils.js.map +1 -1
  68. package/dist/testing/fire-lynx-event.d.ts +14 -0
  69. package/dist/testing/fire-lynx-event.js +19 -0
  70. package/dist/testing/fire-lynx-event.js.map +1 -0
  71. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  72. package/dist/testing/fire-lynx-event.test.js +44 -0
  73. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  74. package/dist/testing/lynx-main-thread.d.ts +22 -0
  75. package/dist/testing/lynx-main-thread.js +11 -0
  76. package/dist/testing/lynx-main-thread.js.map +1 -0
  77. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  78. package/dist/testing/lynx-main-thread.test.js +24 -0
  79. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  80. package/dist/testing/set-input-signal.d.ts +14 -0
  81. package/dist/testing/set-input-signal.js +21 -0
  82. package/dist/testing/set-input-signal.js.map +1 -0
  83. package/dist/testing/set-input-signal.test.d.ts +1 -0
  84. package/dist/testing/set-input-signal.test.js +20 -0
  85. package/dist/testing/set-input-signal.test.js.map +1 -0
  86. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  87. package/dist/ui/components/accordion/accordion.ts +19 -14
  88. package/dist/ui/components/action-sheet/action-sheet.test.ts +283 -151
  89. package/dist/ui/components/action-sheet/action-sheet.ts +77 -43
  90. package/dist/ui/components/alert/alert.test.ts +67 -19
  91. package/dist/ui/components/alert/alert.ts +14 -16
  92. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +208 -94
  93. package/dist/ui/components/alert-dialog/alert-dialog.ts +75 -38
  94. package/dist/ui/components/area-chart/area-chart.test.ts +134 -119
  95. package/dist/ui/components/area-chart/area-chart.ts +100 -315
  96. package/dist/ui/components/area-chart/index.ts +2 -3
  97. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  98. package/dist/ui/components/badge/badge.test.ts +57 -23
  99. package/dist/ui/components/badge/badge.ts +12 -14
  100. package/dist/ui/components/bar-chart/bar-chart.test.ts +172 -45
  101. package/dist/ui/components/bar-chart/bar-chart.ts +140 -294
  102. package/dist/ui/components/bar-chart/index.ts +3 -3
  103. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +386 -128
  104. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +170 -53
  105. package/dist/ui/components/button/button.test.ts +611 -2
  106. package/dist/ui/components/button/button.ts +434 -49
  107. package/dist/ui/components/button/index.ts +2 -0
  108. package/dist/ui/components/button-group/button-group.test.ts +484 -2
  109. package/dist/ui/components/button-group/button-group.ts +380 -68
  110. package/dist/ui/components/button-group/index.ts +3 -0
  111. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +183 -58
  112. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +150 -323
  113. package/dist/ui/components/candlestick-chart/index.ts +3 -4
  114. package/dist/ui/components/card/card.test.ts +82 -62
  115. package/dist/ui/components/chart/chart-focus.test.ts +132 -0
  116. package/dist/ui/components/chart/chart-focus.ts +115 -0
  117. package/dist/ui/components/chart/chart-presets.test.ts +335 -0
  118. package/dist/ui/components/chart/chart-presets.ts +278 -0
  119. package/dist/ui/components/chart/chart-scene.test.ts +589 -0
  120. package/dist/ui/components/chart/chart-scene.ts +483 -0
  121. package/dist/ui/components/chart/chart-theme.test.ts +85 -0
  122. package/dist/ui/components/chart/chart-theme.ts +210 -0
  123. package/dist/ui/components/chart/chart-zoom.test.ts +159 -0
  124. package/dist/ui/components/chart/chart-zoom.ts +162 -0
  125. package/dist/ui/components/chart/chart.test.ts +1017 -0
  126. package/dist/ui/components/chart/chart.ts +934 -0
  127. package/dist/ui/components/chart/index.ts +24 -0
  128. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  129. package/dist/ui/components/checkbox/checkbox.ts +23 -23
  130. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  131. package/dist/ui/components/collapsible/collapsible.ts +19 -14
  132. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  133. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  134. package/dist/ui/components/context-menu/index.ts +1 -0
  135. package/dist/ui/components/dialog/dialog.test.ts +213 -108
  136. package/dist/ui/components/dialog/dialog.ts +72 -35
  137. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  138. package/dist/ui/components/dock/dock-scroll.ts +133 -0
  139. package/dist/ui/components/dock/dock.test.ts +749 -0
  140. package/dist/ui/components/dock/dock.ts +960 -0
  141. package/dist/ui/components/dock/index.ts +2 -0
  142. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  143. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  144. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  145. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  146. package/dist/ui/components/glass/glass-design.ts +119 -0
  147. package/dist/ui/components/glass/glass.test.ts +338 -0
  148. package/dist/ui/components/glass/glass.ts +360 -0
  149. package/dist/ui/components/glass/index.ts +18 -0
  150. package/dist/ui/components/icon/icon-theme.test.ts +169 -0
  151. package/dist/ui/components/icon/icon-theme.ts +227 -0
  152. package/dist/ui/components/icon/icon.test.ts +85 -25
  153. package/dist/ui/components/icon/icon.ts +38 -21
  154. package/dist/ui/components/input/input.test.ts +178 -101
  155. package/dist/ui/components/input/input.ts +15 -12
  156. package/dist/ui/components/label/label.test.ts +11 -24
  157. package/dist/ui/components/line-chart/index.ts +2 -3
  158. package/dist/ui/components/line-chart/line-chart.test.ts +149 -56
  159. package/dist/ui/components/line-chart/line-chart.ts +83 -241
  160. package/dist/ui/components/list/list.test.ts +119 -21
  161. package/dist/ui/components/list/list.ts +21 -15
  162. package/dist/ui/components/menu/index.ts +25 -0
  163. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  164. package/dist/ui/components/menu/menu-placement.ts +159 -0
  165. package/dist/ui/components/menu/menu-popup.test.ts +632 -0
  166. package/dist/ui/components/menu/menu-popup.ts +544 -0
  167. package/dist/ui/components/menu/menu.test.ts +429 -0
  168. package/dist/ui/components/menu/menu.ts +415 -0
  169. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +435 -191
  170. package/dist/ui/components/nav-drawer/nav-drawer.ts +127 -56
  171. package/dist/ui/components/pagination/pagination.test.ts +177 -65
  172. package/dist/ui/components/pagination/pagination.ts +55 -8
  173. package/dist/ui/components/progress/progress.test.ts +102 -46
  174. package/dist/ui/components/progress/progress.ts +52 -54
  175. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  176. package/dist/ui/components/radio-group/radio-group.ts +24 -24
  177. package/dist/ui/components/scatter-chart/index.ts +2 -3
  178. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +131 -26
  179. package/dist/ui/components/scatter-chart/scatter-chart.ts +104 -250
  180. package/dist/ui/components/scroll-area/scroll-area.test.ts +95 -24
  181. package/dist/ui/components/scroll-area/scroll-area.ts +8 -8
  182. package/dist/ui/components/select/select.test.ts +138 -78
  183. package/dist/ui/components/select/select.ts +17 -11
  184. package/dist/ui/components/separator/separator.test.ts +18 -17
  185. package/dist/ui/components/sheet/sheet.test.ts +64 -11
  186. package/dist/ui/components/sheet/sheet.ts +9 -1
  187. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  188. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  189. package/dist/ui/components/spinner/spinner.test.ts +67 -22
  190. package/dist/ui/components/spinner/spinner.ts +48 -41
  191. package/dist/ui/components/switch/switch.test.ts +116 -46
  192. package/dist/ui/components/switch/switch.ts +8 -13
  193. package/dist/ui/components/tab-bar/index.ts +1 -0
  194. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  195. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  196. package/dist/ui/components/tabs/tabs.test.ts +215 -157
  197. package/dist/ui/components/tabs/tabs.ts +110 -118
  198. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  199. package/dist/ui/components/textarea/textarea.ts +14 -12
  200. package/dist/ui/components/toast/toast.test.ts +436 -315
  201. package/dist/ui/components/toast/toast.ts +62 -55
  202. package/dist/ui/components/toggle/toggle.test.ts +247 -140
  203. package/dist/ui/theme/chart-colors.test.ts +35 -0
  204. package/dist/ui/theme/dark.css +46 -2
  205. package/dist/ui/theme/default.css +63 -7
  206. package/dist/ui/theme/icon-colors.test.ts +35 -0
  207. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  208. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  209. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  210. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  211. package/dist/ui/theme/tailwind-plugin.ts +98 -25
  212. package/dist/ui/theme/tailwind.css +113 -0
  213. package/dist/ui/utils/animate.test.ts +13 -1
  214. package/dist/utils/analyze.d.ts +14 -0
  215. package/dist/utils/analyze.js +15 -0
  216. package/dist/utils/analyze.js.map +1 -1
  217. package/dist/utils/analyze.test.js +14 -1
  218. package/dist/utils/analyze.test.js.map +1 -1
  219. package/dist/utils/installed.d.ts +29 -0
  220. package/dist/utils/installed.js +43 -0
  221. package/dist/utils/installed.js.map +1 -0
  222. package/dist/utils/installed.test.d.ts +1 -0
  223. package/dist/utils/installed.test.js +57 -0
  224. package/dist/utils/installed.test.js.map +1 -0
  225. package/dist/utils/interactive.d.ts +9 -0
  226. package/dist/utils/interactive.js +10 -0
  227. package/dist/utils/interactive.js.map +1 -0
  228. package/dist/utils/interactive.test.d.ts +1 -0
  229. package/dist/utils/interactive.test.js +24 -0
  230. package/dist/utils/interactive.test.js.map +1 -0
  231. package/dist/utils/packages.d.ts +36 -0
  232. package/dist/utils/packages.js +70 -0
  233. package/dist/utils/packages.js.map +1 -0
  234. package/dist/utils/packages.test.d.ts +1 -0
  235. package/dist/utils/packages.test.js +89 -0
  236. package/dist/utils/packages.test.js.map +1 -0
  237. package/dist/utils/resolve-paths.d.ts +17 -1
  238. package/dist/utils/resolve-paths.js +21 -3
  239. package/dist/utils/resolve-paths.js.map +1 -1
  240. package/dist/utils/resolve-paths.test.js +38 -2
  241. package/dist/utils/resolve-paths.test.js.map +1 -1
  242. package/dist/utils/tailwind.d.ts +25 -0
  243. package/dist/utils/tailwind.js +51 -0
  244. package/dist/utils/tailwind.js.map +1 -0
  245. package/dist/utils/tailwind.test.d.ts +1 -0
  246. package/dist/utils/tailwind.test.js +71 -0
  247. package/dist/utils/tailwind.test.js.map +1 -0
  248. package/package.json +13 -1
  249. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -121
  250. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -174
  251. package/dist/ui/components/button/button-render.test.ts +0 -113
  252. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  253. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -174
  254. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +0 -188
  255. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +0 -525
  256. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +0 -1140
  257. package/dist/ui/components/cartesian-chart/index.ts +0 -18
  258. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -121
  259. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -157
@@ -0,0 +1,2 @@
1
+ export { UiDock, UiDockAccessory, UiDockAction, UiDockItem } from './dock';
2
+ export { UiDockScroll, UiDockState } from './dock-scroll';
@@ -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';
@@ -0,0 +1,338 @@
1
+ import { Component, type Provider, signal } from '@angular/core';
2
+ import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
+ import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { UiIcon } from '../icon/icon';
6
+ import {
7
+ ICON_DARK_COLORS,
8
+ ICON_LIGHT_COLORS,
9
+ provideIconTheme,
10
+ } from '../icon/icon-theme';
11
+ import {
12
+ UiGlass,
13
+ UiGlassGroup,
14
+ type GlassShape,
15
+ type GlassVariant,
16
+ } from './glass';
17
+ import { provideGlass } from './glass-design';
18
+
19
+ @Component({
20
+ imports: [LYNX_ELEMENTS, UiGlass, UiGlassGroup, UiIcon],
21
+ template: `
22
+ <blur-view ui-glass-group [spacing]="spacing()" class="flex-row gap-2">
23
+ <blur-view
24
+ ui-glass
25
+ id="glass"
26
+ class="px-4 py-2"
27
+ [variant]="variant()"
28
+ [shape]="shape()"
29
+ [radius]="radius()"
30
+ [tint]="tint()"
31
+ [interactive]="interactive()"
32
+ >
33
+ <text class="text-glass-foreground">Now playing</text>
34
+ <ui-icon name="play" />
35
+ </blur-view>
36
+ </blur-view>
37
+ `,
38
+ })
39
+ class GlassHost {
40
+ readonly spacing = signal(12);
41
+ readonly variant = signal<GlassVariant>('regular');
42
+ readonly shape = signal<GlassShape>('capsule');
43
+ readonly radius = signal(20);
44
+ readonly tint = signal<string | undefined>(undefined);
45
+ readonly interactive = signal(false);
46
+ }
47
+
48
+ /** Accessibility settings a test can change, as the host app would. */
49
+ const settings = {
50
+ reduceMotion: signal(false),
51
+ reduceTransparency: signal(false),
52
+ increaseContrast: signal(false),
53
+ };
54
+
55
+ /**
56
+ * Renders the host on a platform, as `SystemInfo` reports it, or with no
57
+ * SystemInfo at all.
58
+ */
59
+ const renderOn = async (
60
+ platform: { platform: string; osVersion: string } | undefined,
61
+ providers: Provider[] = [],
62
+ ) => {
63
+ vi.stubGlobal('SystemInfo', platform);
64
+ const result = await render(GlassHost, {
65
+ providers: [
66
+ { provide: LynxAccessibilitySettings, useValue: settings },
67
+ ...providers,
68
+ ],
69
+ });
70
+ const host = result.componentRef.instance as GlassHost;
71
+ const glass = result.container.querySelector('#glass')!;
72
+ const group = glass.parentElement!;
73
+ const update = async (change: () => void) => {
74
+ change();
75
+ await waitForUpdate();
76
+ };
77
+ return { ...result, host, glass, group, update };
78
+ };
79
+
80
+ const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
81
+ const IOS_18 = { platform: 'iOS', osVersion: '18.5' };
82
+ const ANDROID = { platform: 'Android', osVersion: '15' };
83
+ const WEB = { platform: 'web', osVersion: '' };
84
+
85
+ // The icon's selector isn't a Lynx element, so the renderer falls back to a
86
+ // <view> for it and warns.
87
+ beforeEach(() => {
88
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
89
+ });
90
+
91
+ describe('UiGlass', () => {
92
+ afterEach(() => {
93
+ vi.restoreAllMocks();
94
+ vi.unstubAllGlobals();
95
+ settings.reduceTransparency.set(false);
96
+ settings.increaseContrast.set(false);
97
+ });
98
+
99
+ it('draws Liquid Glass on iOS 26, in the app color scheme', async () => {
100
+ const { glass } = await renderOn(IOS_26);
101
+
102
+ expect(glass.tagName.toLowerCase()).toBe('blur-view');
103
+ expect(glass.getAttribute('blur-effect')).toBe('glass');
104
+ expect(glass.getAttribute('glass-style')).toBe('regular');
105
+ expect(glass.getAttribute('blur-radius')).toBe('16px');
106
+ expect(glass.getAttribute('enable-auto-blur')).toBe('true');
107
+ expect(glass.getAttribute('ios-user-interface-style')).toBe('light');
108
+ expect(glass.hasAttribute('glass-tint-color')).toBe(false);
109
+ expect(glass.hasAttribute('glass-interactive')).toBe(false);
110
+ // The app's own classes stay on the glass, beside its shape.
111
+ expect(glass.classList).toContain('px-4');
112
+ expect(glass.classList).toContain('rounded-full');
113
+ expect(glass.classList).not.toContain('bg-secondary');
114
+ });
115
+
116
+ it('keeps its content inside the glass', async () => {
117
+ const { glass } = await renderOn(IOS_26);
118
+
119
+ expect(glass.querySelector('text')?.textContent).toBe('Now playing');
120
+ });
121
+
122
+ it('takes the variant, tint and interactivity', async () => {
123
+ const { glass, host, update } = await renderOn(IOS_26);
124
+
125
+ await update(() => {
126
+ host.variant.set('clear');
127
+ host.tint.set('#2563eb');
128
+ host.interactive.set(true);
129
+ });
130
+
131
+ expect(glass.getAttribute('glass-style')).toBe('clear');
132
+ expect(glass.getAttribute('blur-radius')).toBe('6px');
133
+ expect(glass.getAttribute('glass-tint-color')).toBe('#2563eb');
134
+ expect(glass.getAttribute('glass-interactive')).toBe('true');
135
+
136
+ await update(() => host.interactive.set(false));
137
+ expect(glass.hasAttribute('glass-interactive')).toBe(false);
138
+ });
139
+
140
+ it("resolves a theme token tint to the scheme's value, and forces the scheme", async () => {
141
+ const scheme = signal<'light' | 'dark'>('light');
142
+ const { glass, host, update } = await renderOn(IOS_26, [
143
+ provideIconTheme({ colorScheme: () => scheme() }),
144
+ ]);
145
+
146
+ await update(() => host.tint.set('var(--glass-tint)'));
147
+ expect(glass.getAttribute('glass-tint-color')).toBe(
148
+ ICON_LIGHT_COLORS['glass-tint'],
149
+ );
150
+
151
+ await update(() => scheme.set('dark'));
152
+ expect(glass.getAttribute('glass-tint-color')).toBe(
153
+ ICON_DARK_COLORS['glass-tint'],
154
+ );
155
+ expect(glass.getAttribute('ios-user-interface-style')).toBe('dark');
156
+ });
157
+
158
+ it('turns the foreground white on clear glass, which sits over media', async () => {
159
+ const { glass, host, update } = await renderOn(IOS_26);
160
+ const icon = () => glass.querySelector('svg')!.getAttribute('content');
161
+
162
+ expect(icon()).toContain(ICON_LIGHT_COLORS['glass-foreground']);
163
+
164
+ await update(() => host.variant.set('clear'));
165
+
166
+ expect(glass.classList).toContain(
167
+ '[--glass-foreground:var(--glass-clear-foreground)]',
168
+ );
169
+ expect(glass.classList).toContain(
170
+ '[--glass-muted-foreground:var(--glass-clear-foreground)]',
171
+ );
172
+ expect(icon()).toContain(ICON_LIGHT_COLORS['glass-clear-foreground']);
173
+ });
174
+
175
+ it('gives a rounded shape its radius, and capsules and circles round ends', async () => {
176
+ const { glass, host, update } = await renderOn(IOS_26);
177
+ const radius = () =>
178
+ (glass as HTMLElement).style.getPropertyValue('border-radius');
179
+
180
+ expect(radius()).toBe('');
181
+
182
+ await update(() => {
183
+ host.shape.set('rounded');
184
+ host.radius.set(24);
185
+ });
186
+ expect(glass.classList).not.toContain('rounded-full');
187
+ expect(radius()).toBe('24px');
188
+
189
+ await update(() => host.shape.set('circle'));
190
+ expect(glass.classList).toContain('rounded-full');
191
+ expect(radius()).toBe('');
192
+ });
193
+
194
+ it('leaves Reduce Transparency and Increase Contrast to iOS, except for muted text', async () => {
195
+ const { glass, update } = await renderOn(IOS_26);
196
+
197
+ await update(() => {
198
+ settings.reduceTransparency.set(true);
199
+ settings.increaseContrast.set(true);
200
+ });
201
+
202
+ // UIKit adapts its own glass: no opaque surface or border here.
203
+ expect(glass.getAttribute('blur-effect')).toBe('glass');
204
+ expect(glass.classList).not.toContain('border');
205
+ expect(glass.classList).toContain(
206
+ '[--glass-muted-foreground:var(--glass-foreground)]',
207
+ );
208
+ });
209
+
210
+ it('draws a frosted material before iOS 26, which has no glass', async () => {
211
+ const scheme = signal<'light' | 'dark'>('light');
212
+ const { glass, host, update } = await renderOn(IOS_18, [
213
+ provideIconTheme({ colorScheme: () => scheme() }),
214
+ ]);
215
+
216
+ expect(glass.getAttribute('blur-effect')).toBe('extra-light');
217
+ expect(glass.getAttribute('blur-radius')).toBe('20px');
218
+
219
+ await update(() => scheme.set('dark'));
220
+ expect(glass.getAttribute('blur-effect')).toBe('dark');
221
+
222
+ await update(() => {
223
+ scheme.set('light');
224
+ host.variant.set('clear');
225
+ });
226
+ expect(glass.getAttribute('blur-effect')).toBe('dark');
227
+ });
228
+
229
+ it('fills a frosted material with its tint, under the content', async () => {
230
+ const { glass, host, update } = await renderOn(IOS_18);
231
+ /**
232
+ * The fill: the view that draws a background, before the content.
233
+ */
234
+ const fill = () =>
235
+ Array.from(glass.children).find(
236
+ (child) => (child as HTMLElement).style.backgroundColor,
237
+ ) as HTMLElement;
238
+
239
+ expect(fill()).toBeUndefined();
240
+
241
+ await update(() => host.tint.set('#2563eb'));
242
+ // Drawn first, so the content sits on it.
243
+ expect(Array.from(glass.children).indexOf(fill())).toBeLessThan(
244
+ Array.from(glass.children).indexOf(glass.querySelector('text')!),
245
+ );
246
+ expect(fill().style.getPropertyValue('background-color')).toBe(
247
+ 'rgb(37, 99, 235)',
248
+ );
249
+ expect(fill().classList).toContain('rounded-full');
250
+
251
+ await update(() => host.shape.set('rounded'));
252
+ expect(fill().classList).not.toContain('rounded-full');
253
+ expect(fill().style.getPropertyValue('border-radius')).toBe('20px');
254
+ });
255
+
256
+ it('draws an opaque Material surface on Android by default', async () => {
257
+ const { glass, group, host, update } = await renderOn(ANDROID);
258
+
259
+ expect(glass.getAttribute('blur-effect')).toBe('light');
260
+ expect(glass.getAttribute('blur-radius')).toBe('0px');
261
+ // A blur-view redraws what's behind it on every frame on Android, even
262
+ // without a radius, unless this is off.
263
+ expect(glass.getAttribute('enable-auto-blur')).toBe('false');
264
+ expect(glass.classList).toContain('bg-secondary');
265
+ expect(group.getAttribute('blur-effect')).toBe('light');
266
+
267
+ // Clear glass is opaque here too, so its foreground stays dark.
268
+ await update(() => host.variant.set('clear'));
269
+ expect(glass.classList).not.toContain(
270
+ '[--glass-foreground:var(--glass-clear-foreground)]',
271
+ );
272
+
273
+ // A tint fills the surface, as Material fills a button.
274
+ await update(() => host.tint.set('#2563eb'));
275
+ expect(glass.classList).not.toContain('bg-secondary');
276
+ expect(
277
+ (glass as HTMLElement).style.getPropertyValue('background-color'),
278
+ ).toBe('rgb(37, 99, 235)');
279
+ expect(
280
+ (glass as HTMLElement).style.getPropertyValue('box-shadow'),
281
+ ).not.toBe('');
282
+ });
283
+
284
+ it('draws glass on Android when the app opts in, and follows the settings there', async () => {
285
+ const { glass, update } = await renderOn(ANDROID, [
286
+ provideGlass({ android: 'glass' }),
287
+ ]);
288
+
289
+ expect(glass.getAttribute('blur-effect')).toBe('glass');
290
+ expect(glass.classList).not.toContain('bg-secondary');
291
+
292
+ await update(() => settings.increaseContrast.set(true));
293
+ expect(glass.classList).toContain('border');
294
+ expect(glass.classList).toContain('border-glass-foreground');
295
+
296
+ await update(() => settings.reduceTransparency.set(true));
297
+ expect(glass.getAttribute('blur-effect')).toBe('light');
298
+ expect(glass.classList).toContain('bg-secondary');
299
+ });
300
+
301
+ it('draws glass in the web preview', async () => {
302
+ const { glass } = await renderOn(WEB);
303
+
304
+ expect(glass.getAttribute('blur-effect')).toBe('glass');
305
+ });
306
+
307
+ it('draws a Material surface where there is no SystemInfo to read', async () => {
308
+ const { glass } = await renderOn(undefined);
309
+
310
+ expect(glass.classList).toContain('bg-secondary');
311
+ });
312
+ });
313
+
314
+ describe('UiGlassGroup', () => {
315
+ afterEach(() => {
316
+ vi.restoreAllMocks();
317
+ vi.unstubAllGlobals();
318
+ });
319
+
320
+ it('merges the glass in it within spacing', async () => {
321
+ const { group, host, update } = await renderOn(IOS_26);
322
+
323
+ expect(group.getAttribute('blur-effect')).toBe('glass-container');
324
+ expect(group.getAttribute('spacing')).toBe('12');
325
+ expect(group.getAttribute('enable-auto-blur')).toBe('false');
326
+ expect(group.classList).toContain('flex-row');
327
+
328
+ await update(() => host.spacing.set(24));
329
+ expect(group.getAttribute('spacing')).toBe('24');
330
+ });
331
+
332
+ it('is a plain container where there is no glass to merge', async () => {
333
+ const { group } = await renderOn(IOS_18);
334
+
335
+ expect(group.getAttribute('blur-effect')).toBe('light');
336
+ expect(group.getAttribute('blur-radius')).toBe('0px');
337
+ });
338
+ });