@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
@@ -1,13 +1,23 @@
1
+ import { NgTemplateOutlet } from '@angular/common';
1
2
  import {
2
3
  type ElementRef,
3
4
  Component,
5
+ Injector,
4
6
  ViewEncapsulation,
7
+ booleanAttribute,
5
8
  computed,
9
+ contentChild,
10
+ inject,
6
11
  input,
7
12
  output,
13
+ signal,
8
14
  viewChild,
9
15
  } from '@angular/core';
10
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
16
+ import {
17
+ LYNX_ELEMENTS,
18
+ LynxAccessibilitySettings,
19
+ LynxSystemInfo,
20
+ } from '@blotch/angular-lynx';
11
21
  import { cva, type VariantProps } from 'class-variance-authority';
12
22
 
13
23
  import {
@@ -16,8 +26,19 @@ import {
16
26
  pressRelease,
17
27
  } from '../../utils/animate';
18
28
  import { cn } from '../../utils/cn';
29
+ import { UiGlass } from '../glass/glass';
30
+ import {
31
+ injectGlassDesign,
32
+ injectSystemDrawsGlass,
33
+ } from '../glass/glass-design';
34
+ import { UiIcon } from '../icon/icon';
35
+ import { UI_ICON_COLOR } from '../icon/icon-theme';
19
36
  import { UiSpinner } from '../spinner/spinner';
20
37
 
38
+ /**
39
+ * The container classes of the solid variants. The glass variants draw a
40
+ * `<blur-view>`, which classes alone can't, so they aren't here.
41
+ */
21
42
  export const buttonVariants = cva(
22
43
  'items-center rounded-md flex justify-center',
23
44
  {
@@ -34,6 +55,7 @@ export const buttonVariants = cva(
34
55
  sm: 'h-9 rounded-md px-3',
35
56
  lg: 'h-11 rounded-md px-8',
36
57
  icon: 'h-10 w-10',
58
+ 'icon-circle': 'h-10 w-10 rounded-full',
37
59
  },
38
60
  },
39
61
  defaultVariants: {
@@ -43,52 +65,174 @@ export const buttonVariants = cva(
43
65
  },
44
66
  );
45
67
 
46
- const buttonTextVariants = cva('font-medium', {
47
- variants: {
48
- variant: {
49
- default: 'text-primary-foreground',
50
- destructive: 'text-destructive-foreground',
51
- outline: 'text-foreground',
52
- secondary: 'text-secondary-foreground',
53
- ghost: 'text-foreground',
54
- },
55
- size: {
56
- default: 'text-sm',
57
- sm: 'text-xs',
58
- lg: 'text-base',
59
- icon: 'text-sm',
60
- },
61
- },
62
- defaultVariants: {
63
- variant: 'default',
64
- size: 'default',
65
- },
66
- });
68
+ /** A variant drawn with solid colors, on every platform. */
69
+ export type SolidButtonVariant = NonNullable<
70
+ VariantProps<typeof buttonVariants>['variant']
71
+ >;
67
72
 
68
73
  /**
69
- * The spinner's color for each variant: the same token as the label's text
70
- * class above. The spinner draws with SVG, which can't read the text classes,
71
- * so it gets the token and resolves it itself (see icon-theme.ts).
74
+ * Liquid Glass variants, SwiftUI's button styles: `glass` (`.glass`),
75
+ * `glass-prominent` (`.glassProminent`, tinted for the screen's main action)
76
+ * and `glass-clear` (`.glass(.clear)`, for buttons over photos and video).
72
77
  */
73
- const SPINNER_COLORS = {
78
+ export type GlassButtonVariant = 'glass' | 'glass-prominent' | 'glass-clear';
79
+
80
+ export type ButtonVariant = SolidButtonVariant | GlassButtonVariant;
81
+ export type ButtonSize = NonNullable<
82
+ VariantProps<typeof buttonVariants>['size']
83
+ >;
84
+
85
+ const GLASS_VARIANTS: ReadonlySet<ButtonVariant> = new Set<ButtonVariant>([
86
+ 'glass',
87
+ 'glass-prominent',
88
+ 'glass-clear',
89
+ ]);
90
+
91
+ /**
92
+ * Whether a variant is drawn with glass, narrowing it for the maps below.
93
+ */
94
+ const isGlassVariant = (
95
+ variant: ButtonVariant,
96
+ ): variant is GlassButtonVariant => GLASS_VARIANTS.has(variant);
97
+
98
+ /** The sizes for a single icon, which then needs no label. */
99
+ const ICON_SIZES: ReadonlySet<ButtonSize> = new Set<ButtonSize>([
100
+ 'icon',
101
+ 'icon-circle',
102
+ ]);
103
+
104
+ const LABEL_SIZES: Readonly<Record<ButtonSize, string>> = {
105
+ default: 'text-sm',
106
+ sm: 'text-xs',
107
+ lg: 'text-base',
108
+ icon: 'text-sm',
109
+ 'icon-circle': 'text-sm',
110
+ };
111
+
112
+ const SOLID_LABEL_COLORS: Readonly<Record<SolidButtonVariant, string>> = {
113
+ default: 'text-primary-foreground',
114
+ destructive: 'text-destructive-foreground',
115
+ outline: 'text-foreground',
116
+ secondary: 'text-secondary-foreground',
117
+ ghost: 'text-foreground',
118
+ };
119
+
120
+ /**
121
+ * The token of each solid variant's label color above, for what draws with
122
+ * SVG: the spinner and projected icons can't read the text classes, so they
123
+ * get the token and resolve it themselves (see icon-theme.ts).
124
+ */
125
+ const SOLID_FOREGROUNDS: Readonly<Record<SolidButtonVariant, string>> = {
74
126
  default: 'var(--primary-foreground)',
75
127
  destructive: 'var(--destructive-foreground)',
76
128
  outline: 'var(--foreground)',
77
129
  secondary: 'var(--secondary-foreground)',
78
130
  ghost: 'var(--foreground)',
79
- } as const;
131
+ };
80
132
 
81
- export type ButtonVariant = NonNullable<
82
- VariantProps<typeof buttonVariants>['variant']
83
- >;
84
- export type ButtonSize = NonNullable<
85
- VariantProps<typeof buttonVariants>['size']
86
- >;
133
+ /**
134
+ * A glass button's box, which its glass fills. Glass buttons are capsules,
135
+ * as SwiftUI draws them, so an icon size is a circle.
136
+ */
137
+ const glassFrameVariants = cva('flex-col rounded-full flex', {
138
+ variants: {
139
+ size: {
140
+ default: 'h-10',
141
+ sm: 'h-9',
142
+ lg: 'h-11',
143
+ icon: 'h-10 w-10',
144
+ 'icon-circle': 'h-10 w-10',
145
+ },
146
+ },
147
+ });
148
+
149
+ const GLASS_PADDING: Readonly<Record<ButtonSize, string>> = {
150
+ default: 'px-4',
151
+ sm: 'px-3',
152
+ lg: 'px-8',
153
+ icon: '',
154
+ 'icon-circle': '',
155
+ };
156
+
157
+ /**
158
+ * The glass variants as Material 3 Expressive buttons, Android's default
159
+ * (see GlassDesign): `glass` and `glass-clear` are filled tonal buttons,
160
+ * `glass-prominent` a filled one in the glass tint, and the icon sizes
161
+ * Material's round icon buttons. Material's buttons sit flat on the page,
162
+ * with no shadow, and are fully rounded.
163
+ */
164
+ const materialButtonVariants = cva(
165
+ 'flex-row items-center rounded-full flex relative justify-center overflow-hidden',
166
+ {
167
+ variants: {
168
+ variant: {
169
+ glass: 'bg-secondary',
170
+ 'glass-clear': 'bg-secondary',
171
+ 'glass-prominent': 'bg-glass-tint',
172
+ },
173
+ size: {
174
+ default: 'h-10 px-4',
175
+ sm: 'h-9 px-3',
176
+ lg: 'h-11 px-8',
177
+ icon: 'h-10 w-10',
178
+ 'icon-circle': 'h-10 w-10',
179
+ },
180
+ },
181
+ },
182
+ );
183
+
184
+ /**
185
+ * How a glass variant looks on this platform: `'glass'` draws it on a
186
+ * `<blur-view ui-glass>` (Liquid Glass, or before iOS 26 the thin material
187
+ * Apple used for buttons over content), `'material'` as a Material button. The solid variants are `'solid'`
188
+ * everywhere.
189
+ */
190
+ type ButtonLook = 'solid' | 'glass' | 'material';
191
+
192
+ /**
193
+ * How the button answers a finger: it shrinks (`'scale'`), dims at once
194
+ * (`'dim'`, under Reduce Motion), shows Material's pressed state layer
195
+ * (`'layer'`), or leaves it to interactive glass, which reacts by itself
196
+ * (`'none'`).
197
+ */
198
+ type PressFeedback = 'scale' | 'dim' | 'layer' | 'none';
199
+
200
+ /**
201
+ * `UiIcon`, for `contentChild()`. A call rather than the class itself: for a
202
+ * class, Angular's test build keeps an arrow function that nothing calls,
203
+ * which an app's coverage report would show as uncovered.
204
+ */
205
+ const iconType = () => UiIcon;
206
+
207
+ /**
208
+ * The platform, as SystemInfo names it, or undefined where there's no
209
+ * SystemInfo to read, such as in unit tests, where LynxSystemInfo throws.
210
+ */
211
+ const platformOf = (injector: Injector): string | undefined => {
212
+ try {
213
+ return injector.get(LynxSystemInfo).platform;
214
+ } catch {
215
+ return undefined;
216
+ }
217
+ };
218
+
219
+ /**
220
+ * The color an icon projected into the button draws with when it's given
221
+ * none: its label's (see UI_ICON_COLOR). A named function, not an inline
222
+ * factory: the component's metadata keeps its own copy of an inline one,
223
+ * which nothing calls, so an app's coverage report would show it uncovered.
224
+ */
225
+ const buttonIconColor = () => inject(UiButton).foreground;
87
226
 
88
227
  @Component({
89
228
  selector: 'ui-button',
90
- imports: [LYNX_ELEMENTS, UiSpinner],
229
+ imports: [LYNX_ELEMENTS, NgTemplateOutlet, UiGlass, UiSpinner],
91
230
  encapsulation: ViewEncapsulation.None,
231
+ // Icons projected into the button inject this through its host's element
232
+ // injector, which is where Angular looks for projected content: the glass
233
+ // inside the template provides one too, but only for the template's own
234
+ // nodes.
235
+ providers: [{ provide: UI_ICON_COLOR, useFactory: buttonIconColor }],
92
236
  template: `
93
237
  <!-- Press feedback binds BOTH touch and mouse events on purpose. Native
94
238
  (iOS/Android) only ever fires touch; Lynx web runs in the browser, where
@@ -99,10 +243,17 @@ export type ButtonSize = NonNullable<
99
243
  touch devices, so there's no double-fire. The scale animation itself lives
100
244
  in pressDown/pressRelease — see their JSDoc for why it's a CSS transition
101
245
  (which serializes to the DOM on Lynx web) and not el.animate() (whose Web
102
- Animation runs on web-core's offscreen node and never reaches the screen). -->
246
+ Animation runs on web-core's offscreen node and never reaches the screen).
247
+
248
+ An icon-only button has no text for a screen reader to read, so with
249
+ an accessibility-label the whole button becomes one element that says
250
+ it. Without one, the label's own text is read, as a button. -->
103
251
  <view
104
252
  #container
105
253
  [class]="containerClass()"
254
+ [attr.accessibility-element]="accessibilityLabel() ? 'true' : null"
255
+ [attr.accessibility-traits]="accessibilityLabel() ? 'button' : null"
256
+ [attr.accessibility-label]="accessibilityLabel() || null"
106
257
  (bindtouchstart)="onPressStart()"
107
258
  (bindtouchend)="onPressEnd()"
108
259
  (bindtouchcancel)="onPressCancel()"
@@ -111,76 +262,304 @@ export type ButtonSize = NonNullable<
111
262
  (bindmouseleave)="onPressCancel()"
112
263
  (bindtap)="onTap()"
113
264
  >
114
- @if (loading()) {
115
- <ui-spinner [size]="spinnerSize()" [color]="spinnerColor()" />
265
+ @if (look() === 'glass') {
266
+ <!-- The container is the button's box, and the glass fills it. Under
267
+ clear glass the container is the dimming layer (see dimmed).
268
+ The content sits in the glass, where iOS's interactive glass
269
+ reacts to touches on it, and is what dims when disabled, as a
270
+ disabled SwiftUI glass button dims its label: glass under a
271
+ faded parent turns into a washed-out patch on iOS. -->
272
+ <blur-view
273
+ ui-glass
274
+ [variant]="variant() === 'glass-clear' ? 'clear' : 'regular'"
275
+ material="thin"
276
+ [tint]="
277
+ variant() === 'glass-prominent' ? 'var(--glass-tint)' : undefined
278
+ "
279
+ [interactive]="interactive()"
280
+ [class]="glassClass()"
281
+ >
282
+ <view [class]="glassContentClass()">
283
+ <ng-container [ngTemplateOutlet]="content" />
284
+ </view>
285
+ </blur-view>
116
286
  } @else {
117
- <text [class]="labelClass()"><ng-content /></text>
287
+ @if (stateLayerClass(); as layer) {
288
+ <view [class]="layer"></view>
289
+ }
290
+ <ng-container [ngTemplateOutlet]="content" />
118
291
  }
119
292
  </view>
293
+
294
+ <!-- One copy of the content, for whichever form the button takes: Angular
295
+ projects content once, so it can't be in both branches above.
296
+
297
+ The label's text has to be inside a <text>, so the content can't keep
298
+ its own order: an icon goes before the label, and one marked
299
+ trailing after it, for an icon that points onward ("Next →"). -->
300
+ <ng-template #content>
301
+ @if (loading()) {
302
+ <ui-spinner [size]="spinnerSize()" [color]="foreground()" />
303
+ } @else {
304
+ <ng-content select="ui-icon:not([trailing])" />
305
+ @if (showLabel()) {
306
+ <text
307
+ [attr.accessibility-traits]="accessibilityLabel() ? null : 'button'"
308
+ [class]="labelClass()"
309
+ ><ng-content
310
+ /></text>
311
+ }
312
+ <ng-content select="ui-icon[trailing]" />
313
+ }
314
+ </ng-template>
120
315
  `,
121
316
  })
122
317
  export class UiButton {
318
+ /**
319
+ * The look: a solid variant, or a glass one (`'glass'`,
320
+ * `'glass-prominent'`, `'glass-clear'`; see GlassButtonVariant). Keep glass
321
+ * for buttons that float over the content.
322
+ */
123
323
  readonly variant = input<ButtonVariant>('default');
324
+ /**
325
+ * The height and padding. `'icon'` and `'icon-circle'` fit one projected
326
+ * `<ui-icon>`: a rounded square and a circle (glass buttons are circles in
327
+ * either).
328
+ */
124
329
  readonly size = input<ButtonSize>('default');
125
- readonly disabled = input(false);
126
- readonly loading = input(false);
330
+ readonly disabled = input(false, { transform: booleanAttribute });
331
+ readonly loading = input(false, { transform: booleanAttribute });
332
+ /**
333
+ * What a screen reader says for the button. An icon-only button needs one;
334
+ * a button with a label reads its label without it.
335
+ */
336
+ readonly accessibilityLabel = input<string | undefined>(undefined, {
337
+ alias: 'accessibility-label',
338
+ });
127
339
  readonly userClass = input<string>('', { alias: 'class' });
128
340
 
129
341
  readonly pressed = output<void>();
130
342
 
131
343
  readonly containerRef = viewChild<ElementRef>('container');
344
+ /**
345
+ * A `<ui-icon>` projected into the button, which sits before the label, or
346
+ * after it when it's marked `trailing`.
347
+ */
348
+ readonly projectedIcon = contentChild(iconType());
349
+
350
+ readonly #settings = inject(LynxAccessibilitySettings);
351
+ readonly #design = injectGlassDesign();
352
+ readonly #systemDrawsGlass = injectSystemDrawsGlass();
353
+ readonly #platform = platformOf(inject(Injector));
132
354
  #pressAnim?: AnimationHandle;
133
355
 
134
- protected readonly containerClass = computed(() =>
135
- cn(
136
- buttonVariants({ variant: this.variant(), size: this.size() }),
137
- (this.disabled() || this.loading()) && 'opacity-50',
356
+ /** A finger (or the mouse) is down on the button. */
357
+ protected readonly pressing = signal(false);
358
+
359
+ readonly #inactive = computed(() => this.disabled() || this.loading());
360
+
361
+ protected readonly look = computed<ButtonLook>(() => {
362
+ if (!isGlassVariant(this.variant())) return 'solid';
363
+ return this.#design() === 'material' ? 'material' : 'glass';
364
+ });
365
+
366
+ /**
367
+ * The glass reacts to touch while the button can be pressed. Where the
368
+ * runtime draws it (the web preview), that's motion Reduce Motion asks to
369
+ * leave out; iOS tones its own glass down under the setting.
370
+ */
371
+ protected readonly interactive = computed(
372
+ () =>
373
+ !this.#inactive() &&
374
+ (this.#systemDrawsGlass || !this.#settings.reduceMotion()),
375
+ );
376
+
377
+ /**
378
+ * For the glass look: what's behind shows through the glass, which is
379
+ * drawn as glass or a frosted material, not the opaque surface it turns
380
+ * into under Reduce Transparency where the runtime draws it (as
381
+ * UiGlass.surface decides).
382
+ */
383
+ readonly #seeThrough = computed(
384
+ () => this.#systemDrawsGlass || !this.#settings.reduceTransparency(),
385
+ );
386
+
387
+ /**
388
+ * Interactive glass that answers a press itself, swelling under the
389
+ * finger: UIKit's on iOS 26 and later, and the runtime's in the web
390
+ * preview. Android's blur-view has no interactive glass, and the frosted
391
+ * materials before iOS 26 don't react.
392
+ */
393
+ readonly #glassReacts = computed(
394
+ () =>
395
+ this.#design() === 'glass' &&
396
+ this.#seeThrough() &&
397
+ this.interactive() &&
398
+ this.#platform !== 'Android',
399
+ );
400
+
401
+ /**
402
+ * See PressFeedback. Shrinking on top of interactive glass would fight its
403
+ * swell, Material's buttons show a state layer, and under Reduce Motion
404
+ * the button dims rather than moving.
405
+ */
406
+ readonly #feedback = computed<PressFeedback>(() => {
407
+ const look = this.look();
408
+ if (look === 'material') return 'layer';
409
+ if (look === 'glass' && this.#glassReacts()) return 'none';
410
+ return this.#settings.reduceMotion() ? 'dim' : 'scale';
411
+ });
412
+
413
+ readonly #dimmedByPress = computed(
414
+ () => this.pressing() && this.#feedback() === 'dim',
415
+ );
416
+
417
+ /**
418
+ * Clear glass sits on a dark layer at about a third, so its white label
419
+ * stays legible over bright photos and video: Apple's rule for clear
420
+ * glass. The layer is the container, under the glass.
421
+ */
422
+ readonly #dimmed = computed(
423
+ () => this.variant() === 'glass-clear' && this.#seeThrough(),
424
+ );
425
+
426
+ protected readonly showLabel = computed(
427
+ () => !(this.projectedIcon() && ICON_SIZES.has(this.size())),
428
+ );
429
+
430
+ /** Space between an icon and the label beside it. */
431
+ readonly #gap = computed(
432
+ () => !!this.projectedIcon() && this.showLabel() && 'gap-2',
433
+ );
434
+
435
+ /**
436
+ * The color of the label, the spinner and projected icons, as a `var()`.
437
+ * On clear glass it's the clear foreground, which stays white over media.
438
+ */
439
+ readonly foreground = computed(() => {
440
+ const variant = this.variant();
441
+ if (!isGlassVariant(variant)) return SOLID_FOREGROUNDS[variant];
442
+ if (variant === 'glass-prominent') return 'var(--glass-tint-foreground)';
443
+ if (this.look() === 'material') return 'var(--secondary-foreground)';
444
+ return this.#dimmed()
445
+ ? 'var(--glass-clear-foreground)'
446
+ : 'var(--glass-foreground)';
447
+ });
448
+
449
+ protected readonly containerClass = computed(() => {
450
+ const variant = this.variant();
451
+ const size = this.size();
452
+ const inactive = this.#inactive();
453
+ if (!isGlassVariant(variant)) {
454
+ return cn(
455
+ buttonVariants({ variant, size }),
456
+ this.#gap(),
457
+ this.#dimmedByPress() && 'opacity-70',
458
+ inactive && 'opacity-50',
459
+ this.userClass(),
460
+ );
461
+ }
462
+ if (this.look() === 'material') {
463
+ return cn(
464
+ materialButtonVariants({ variant, size }),
465
+ this.#gap(),
466
+ // A tonal button's edge barely shows on the page, so Increase
467
+ // Contrast outlines it, as glass gets an edge there.
468
+ variant !== 'glass-prominent' &&
469
+ this.#settings.increaseContrast() &&
470
+ 'border border-foreground',
471
+ inactive && 'opacity-50',
472
+ this.userClass(),
473
+ );
474
+ }
475
+ return cn(
476
+ glassFrameVariants({ size }),
477
+ this.#dimmed() && 'bg-glass-dim',
138
478
  this.userClass(),
479
+ );
480
+ });
481
+
482
+ protected readonly glassClass = computed(() =>
483
+ cn(
484
+ 'flex-1 flex-row items-center flex justify-center',
485
+ GLASS_PADDING[this.size()],
486
+ ),
487
+ );
488
+
489
+ protected readonly glassContentClass = computed(() =>
490
+ cn(
491
+ 'flex-row items-center flex justify-center',
492
+ this.#gap(),
493
+ this.#dimmedByPress() && 'opacity-70',
494
+ this.#inactive() && 'opacity-50',
139
495
  ),
140
496
  );
141
497
 
142
- protected readonly labelClass = computed(() =>
143
- cn(buttonTextVariants({ variant: this.variant(), size: this.size() })),
498
+ /**
499
+ * Material's pressed state: the label's color over the container at 10%,
500
+ * under the content. It appears with the press and goes with the release,
501
+ * with no motion.
502
+ */
503
+ protected readonly stateLayerClass = computed(
504
+ () =>
505
+ this.pressing() &&
506
+ this.#feedback() === 'layer' &&
507
+ cn(
508
+ 'h-full w-full opacity-10 absolute left-0 top-0',
509
+ this.variant() === 'glass-prominent'
510
+ ? 'bg-glass-tint-foreground'
511
+ : 'bg-secondary-foreground',
512
+ ),
144
513
  );
145
514
 
515
+ protected readonly labelClass = computed(() => {
516
+ const variant = this.variant();
517
+ let color: string;
518
+ if (!isGlassVariant(variant)) color = SOLID_LABEL_COLORS[variant];
519
+ else if (variant === 'glass-prominent') {
520
+ color = 'text-glass-tint-foreground';
521
+ } else if (this.look() === 'material') color = 'text-secondary-foreground';
522
+ // The glass points this at the clear foreground on clear glass.
523
+ else color = 'text-glass-foreground';
524
+ return cn('font-medium', LABEL_SIZES[this.size()], color);
525
+ });
526
+
146
527
  protected readonly spinnerSize = computed(() => {
147
528
  const s = this.size();
148
529
  return s === 'sm' ? ('xs' as const) : ('sm' as const);
149
530
  });
150
531
 
151
- protected readonly spinnerColor = computed(
152
- () => SPINNER_COLORS[this.variant()],
153
- );
154
-
155
532
  protected onPressStart(): void {
156
- if (this.disabled() || this.loading()) return;
533
+ if (this.#inactive()) return;
534
+ this.pressing.set(true);
535
+ if (this.#feedback() !== 'scale') return;
157
536
  // pressDown/pressRelease drive el.setStyle() based CSS transitions on the
158
- // container ref. In the JIT test harness the view-child isn't reliably
159
- // wired up (see the label spec note), and Lynx web renderer's press
160
- // feedback is verified end-to-end on-device.
161
- /* v8 ignore next 2 */
537
+ // container ref (see pressDown for why it isn't el.animate()).
162
538
  this.#pressAnim?.cancel();
163
539
  this.#pressAnim = pressDown(this.containerRef()?.nativeElement);
164
540
  }
165
541
 
166
542
  protected onPressEnd(): void {
167
- if (this.disabled() || this.loading()) return;
168
- /* v8 ignore next 2 */
169
- this.#pressAnim?.cancel();
170
- this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
543
+ if (this.#inactive()) return;
544
+ this.#release();
171
545
  }
172
546
 
173
547
  protected onPressCancel(): void {
174
- // Also restore scale on cancel — without this, a canceled touch (e.g. scroll
548
+ // Also release on cancel — without this, a canceled touch (e.g. scroll
175
549
  // gesture taking over) leaves the button stuck in its pressed-down state.
176
- /* v8 ignore next 2 */
177
- this.#pressAnim?.cancel();
178
- this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
550
+ this.#release();
179
551
  }
180
552
 
181
553
  protected onTap(): void {
182
- if (!this.disabled() && !this.loading()) {
554
+ if (!this.#inactive()) {
183
555
  this.pressed.emit();
184
556
  }
185
557
  }
558
+
559
+ #release(): void {
560
+ this.pressing.set(false);
561
+ if (this.#feedback() !== 'scale') return;
562
+ this.#pressAnim?.cancel();
563
+ this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
564
+ }
186
565
  }
@@ -3,4 +3,6 @@ export {
3
3
  buttonVariants,
4
4
  type ButtonVariant,
5
5
  type ButtonSize,
6
+ type GlassButtonVariant,
7
+ type SolidButtonVariant,
6
8
  } from './button';