@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,14 +1,19 @@
1
+ import { NgTemplateOutlet } from '@angular/common';
1
2
  import {
2
3
  type ElementRef,
3
4
  Component,
4
5
  ViewEncapsulation,
6
+ booleanAttribute,
5
7
  computed,
8
+ contentChild,
9
+ contentChildren,
6
10
  inject,
7
11
  input,
8
12
  output,
13
+ signal,
9
14
  viewChild,
10
15
  } from '@angular/core';
11
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
16
+ import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
12
17
  import { cva, type VariantProps } from 'class-variance-authority';
13
18
 
14
19
  import {
@@ -17,14 +22,22 @@ import {
17
22
  pressRelease,
18
23
  } from '../../utils/animate';
19
24
  import { cn } from '../../utils/cn';
25
+ import { concentricRadius } from '../glass/concentric-radius';
26
+ import { UiGlass } from '../glass/glass';
27
+ import {
28
+ injectGlassDesign,
29
+ injectSystemDrawsGlass,
30
+ } from '../glass/glass-design';
31
+ import { UiIcon } from '../icon/icon';
32
+ import { UI_ICON_COLOR } from '../icon/icon-theme';
20
33
 
21
34
  /**
22
- * Per-item styling. Corners are intentionally left square here: the group
23
- * container clips the whole bar with `overflow-hidden` + `rounded-md`, so only
24
- * the outer edges round off while every internal seam stays crisp. Rounding
25
- * individual items by position would require `:first-child`/`:last-child`
26
- * selectors, which Lynx's CSS engine does not support (no component in the
27
- * library relies on positional selectors — see CLAUDE.md).
35
+ * Per-item styling of the solid variants. Corners are intentionally left
36
+ * square here: the group container clips the whole bar with `overflow-hidden`
37
+ * + `rounded-md`, so only the outer edges round off while every internal seam
38
+ * stays crisp, and the items need no positional styling (Lynx's CSS engine
39
+ * has no `:first-child`/`:last-child`). Their outline is the container's
40
+ * border, which the clip doesn't cut.
28
41
  */
29
42
  export const buttonGroupItemVariants = cva('items-center flex justify-center', {
30
43
  variants: {
@@ -38,6 +51,7 @@ export const buttonGroupItemVariants = cva('items-center flex justify-center', {
38
51
  default: 'h-10 px-4',
39
52
  sm: 'h-9 px-3',
40
53
  lg: 'h-11 px-6',
54
+ icon: 'h-10 w-10',
41
55
  },
42
56
  },
43
57
  defaultVariants: {
@@ -46,32 +60,96 @@ export const buttonGroupItemVariants = cva('items-center flex justify-center', {
46
60
  },
47
61
  });
48
62
 
49
- const buttonGroupTextVariants = cva('font-medium', {
50
- variants: {
51
- variant: {
52
- default: 'text-secondary-foreground',
53
- outline: 'text-foreground',
54
- },
55
- size: {
56
- default: 'text-sm',
57
- sm: 'text-xs',
58
- lg: 'text-base',
59
- },
60
- },
61
- defaultVariants: {
62
- variant: 'default',
63
- size: 'default',
64
- },
65
- });
66
-
67
- export type ButtonGroupVariant = NonNullable<
63
+ /** A variant drawn with solid colors, on every platform. */
64
+ export type SolidButtonGroupVariant = NonNullable<
68
65
  VariantProps<typeof buttonGroupItemVariants>['variant']
69
66
  >;
67
+
68
+ /**
69
+ * The glass variants: the group is one glass shape with plain items in it,
70
+ * the way iOS groups toolbar items on one glass background. `glass-clear` is
71
+ * for a group over photos and video.
72
+ */
73
+ export type GlassButtonGroupVariant = 'glass' | 'glass-clear';
74
+
75
+ export type ButtonGroupVariant =
76
+ | SolidButtonGroupVariant
77
+ | GlassButtonGroupVariant;
70
78
  export type ButtonGroupSize = NonNullable<
71
79
  VariantProps<typeof buttonGroupItemVariants>['size']
72
80
  >;
73
81
  export type ButtonGroupOrientation = 'horizontal' | 'vertical';
74
82
 
83
+ /**
84
+ * How a group looks on this platform: the solid variants everywhere, and the
85
+ * glass ones as one glass shape (`'glass'`) or, on Android by default, as
86
+ * Material 3 Expressive's connected button group (`'material'`).
87
+ */
88
+ export type ButtonGroupLook = 'solid' | 'glass' | 'material';
89
+
90
+ /** Each size's item height, in px, for the corner radii. */
91
+ const ITEM_HEIGHTS: Readonly<Record<ButtonGroupSize, number>> = {
92
+ default: 40,
93
+ sm: 36,
94
+ lg: 44,
95
+ icon: 40,
96
+ };
97
+
98
+ /** How far the items sit inside a glass group's edge, in px (`p-1`). */
99
+ const GLASS_INSET = 4;
100
+
101
+ /**
102
+ * The inner corners of Material's connected button group, in px: the
103
+ * group's ends are fully round, the corners between items small.
104
+ */
105
+ const MATERIAL_INNER_RADIUS = 8;
106
+
107
+ const LABEL_SIZES: Readonly<Record<ButtonGroupSize, string>> = {
108
+ default: 'text-sm',
109
+ sm: 'text-xs',
110
+ lg: 'text-base',
111
+ icon: 'text-sm',
112
+ };
113
+
114
+ const SOLID_LABEL_COLORS: Readonly<Record<SolidButtonGroupVariant, string>> = {
115
+ default: 'text-secondary-foreground',
116
+ outline: 'text-foreground',
117
+ };
118
+
119
+ /** The token of each solid variant's label color, for projected icons. */
120
+ const SOLID_FOREGROUNDS: Readonly<Record<SolidButtonGroupVariant, string>> = {
121
+ default: 'var(--secondary-foreground)',
122
+ outline: 'var(--foreground)',
123
+ };
124
+
125
+ /** The size classes of an item in a glass or Material group. */
126
+ const ITEM_SIZES: Readonly<Record<ButtonGroupSize, string>> = {
127
+ default: 'h-10 px-4',
128
+ sm: 'h-9 px-3',
129
+ lg: 'h-11 px-6',
130
+ icon: 'h-10 w-10',
131
+ };
132
+
133
+ /**
134
+ * `UiIcon`, for `contentChild()`. A call rather than the class itself: for a
135
+ * class, Angular's test build keeps an arrow function that nothing calls,
136
+ * which an app's coverage report would show as uncovered.
137
+ */
138
+ const iconType = () => UiIcon;
139
+
140
+ /**
141
+ * `UiButtonGroupItem`, for the group's `contentChildren()` (see iconType).
142
+ */
143
+ const itemType = () => UiButtonGroupItem;
144
+
145
+ /**
146
+ * The color an icon projected into an item draws with when it's given none:
147
+ * the items' label color (see UI_ICON_COLOR). Named, not inline: the
148
+ * component's metadata keeps its own copy of an inline factory, which
149
+ * nothing calls.
150
+ */
151
+ const groupIconColor = () => inject(UiButtonGroup).foreground;
152
+
75
153
  /**
76
154
  * A row (or column) of connected action buttons — a toolbar of related
77
155
  * commands rendered as a single, seamless control.
@@ -81,36 +159,190 @@ export type ButtonGroupOrientation = 'horizontal' | 'vertical';
81
159
  * the `UiToggleGroup`/`UiToggleGroupItem` pattern. Unlike a toggle group this
82
160
  * carries no selection state: every item is an independent action that emits
83
161
  * `pressed`.
162
+ *
163
+ * With a glass variant the group is one glass shape with plain items in it,
164
+ * as iOS 26 groups toolbar items on one background, rather than a glass
165
+ * shape per item merged in a `ui-glass-group`: merged circles join with
166
+ * pinched waists, not the straight edges of a toolbar's capsule, and glass
167
+ * isn't layered on glass. On Android it's Material's connected button group
168
+ * by default.
84
169
  */
85
170
  @Component({
86
171
  selector: 'ui-button-group',
87
- standalone: true,
88
- imports: [LYNX_ELEMENTS],
172
+ imports: [LYNX_ELEMENTS, NgTemplateOutlet, UiGlass],
89
173
  encapsulation: ViewEncapsulation.None,
174
+ // Icons projected into the items inject this: they're declared inside the
175
+ // group, so the group's element injector is one of theirs.
176
+ providers: [{ provide: UI_ICON_COLOR, useFactory: groupIconColor }],
90
177
  template: `
91
- <view [class]="containerClass()">
92
- <ng-content />
93
- </view>
178
+ @if (look() === 'glass') {
179
+ <!-- The frame is the group's box and, under clear glass, its dimming
180
+ layer, in the glass's shape. Before iOS 26 the glass is the thin
181
+ material, as the buttons' is. -->
182
+ <view [class]="frameClass()" [style.border-radius.px]="cornerRadius()">
183
+ <blur-view
184
+ ui-glass
185
+ [shape]="capsule() ? 'capsule' : 'rounded'"
186
+ [radius]="radius()"
187
+ [variant]="variant() === 'glass-clear' ? 'clear' : 'regular'"
188
+ material="thin"
189
+ [interactive]="interactive()"
190
+ [class]="glassClass()"
191
+ >
192
+ <ng-container [ngTemplateOutlet]="items" />
193
+ </blur-view>
194
+ </view>
195
+ } @else {
196
+ <view [class]="containerClass()">
197
+ <ng-container [ngTemplateOutlet]="items" />
198
+ </view>
199
+ }
200
+
201
+ <!-- One copy of the items, for whichever form the group takes: Angular
202
+ projects content once. -->
203
+ <ng-template #items><ng-content /></ng-template>
94
204
  `,
95
205
  })
96
206
  export class UiButtonGroup {
97
207
  readonly orientation = input<ButtonGroupOrientation>('horizontal');
208
+ /**
209
+ * `'default'` or `'outline'`, or `'glass'` / `'glass-clear'` for one glass
210
+ * background (see GlassButtonGroupVariant).
211
+ */
98
212
  readonly variant = input<ButtonGroupVariant>('default');
213
+ /** The items' height and padding; `'icon'` makes square icon-only items. */
99
214
  readonly size = input<ButtonGroupSize>('default');
100
- readonly disabled = input(false);
215
+ readonly disabled = input(false, { transform: booleanAttribute });
101
216
  readonly userClass = input<string>('', { alias: 'class' });
102
217
 
218
+ readonly #settings = inject(LynxAccessibilitySettings);
219
+ readonly #design = injectGlassDesign();
220
+ readonly #systemDrawsGlass = injectSystemDrawsGlass();
221
+
222
+ /** The items in the group, in order. */
223
+ readonly items = contentChildren(itemType(), { descendants: true });
224
+
225
+ /** The form the group takes on this platform, for its items. */
226
+ readonly look = computed<ButtonGroupLook>(() => {
227
+ const variant = this.variant();
228
+ if (variant !== 'glass' && variant !== 'glass-clear') return 'solid';
229
+ return this.#design() === 'material' ? 'material' : 'glass';
230
+ });
231
+
232
+ /**
233
+ * The glass's corner radius, in px: half an item's height plus the inset,
234
+ * the ends a capsule of the items would have. A column of wider items
235
+ * takes it for its corners.
236
+ */
237
+ protected readonly radius = computed(
238
+ () => ITEM_HEIGHTS[this.size()] / 2 + GLASS_INSET,
239
+ );
240
+
241
+ /**
242
+ * The corner radius of an item's pressed highlight, in px. In glass it's
243
+ * concentric with the group's corners, so the gap between them stays even
244
+ * all the way round; in Material's connected group it's the small inner
245
+ * corner, and an item at one of the group's ends clips it to its round
246
+ * end (see materialEndRadius).
247
+ */
248
+ readonly itemRadius = computed(() =>
249
+ this.look() === 'material'
250
+ ? MATERIAL_INNER_RADIUS
251
+ : concentricRadius(this.radius(), GLASS_INSET),
252
+ );
253
+
254
+ /**
255
+ * A row, or a column of square icon items, is a capsule. A column of
256
+ * wider items has round corners instead. A capsule takes `rounded-full`
257
+ * rather than a radius of half its height, which iOS draws with
258
+ * squared-off ends.
259
+ */
260
+ protected readonly capsule = computed(
261
+ () => this.orientation() === 'horizontal' || this.size() === 'icon',
262
+ );
263
+
264
+ /** The frame's corners, under glass that isn't a capsule. */
265
+ protected readonly cornerRadius = computed(() =>
266
+ this.capsule() ? null : this.radius(),
267
+ );
268
+
269
+ /**
270
+ * How round Material's group is at its ends, in px: half an item's
271
+ * height, which makes a capsule of a row or of a column of square icons,
272
+ * and round corners on a column of wider items. The items at the ends
273
+ * draw them (see UiButtonGroupItem's cornerStyle).
274
+ */
275
+ readonly materialEndRadius = computed(() => ITEM_HEIGHTS[this.size()] / 2);
276
+
277
+ /**
278
+ * As UiButton's: the glass reacts to touch, unless Reduce Motion is on
279
+ * where the runtime draws it.
280
+ */
281
+ protected readonly interactive = computed(
282
+ () =>
283
+ !this.disabled() &&
284
+ (this.#systemDrawsGlass || !this.#settings.reduceMotion()),
285
+ );
286
+
287
+ /**
288
+ * Clear glass that's see-through (not the opaque surface Reduce
289
+ * Transparency makes of the runtime's glass) sits on the dimming layer,
290
+ * and its items turn white.
291
+ */
292
+ readonly #dimmed = computed(
293
+ () =>
294
+ this.variant() === 'glass-clear' &&
295
+ this.look() === 'glass' &&
296
+ (this.#systemDrawsGlass || !this.#settings.reduceTransparency()),
297
+ );
298
+
299
+ /** The items' label color, as a `var()`, for projected icons. */
300
+ readonly foreground = computed(() => {
301
+ const variant = this.variant();
302
+ if (variant !== 'glass' && variant !== 'glass-clear') {
303
+ return SOLID_FOREGROUNDS[variant];
304
+ }
305
+ if (this.look() === 'material') return 'var(--secondary-foreground)';
306
+ return this.#dimmed()
307
+ ? 'var(--glass-clear-foreground)'
308
+ : 'var(--glass-foreground)';
309
+ });
310
+
311
+ readonly #direction = computed(() =>
312
+ this.orientation() === 'vertical' ? 'flex-col' : 'flex-row',
313
+ );
314
+
315
+ protected readonly frameClass = computed(() =>
316
+ cn(
317
+ // `self-start` stops the group stretching to fill a column parent so
318
+ // it hugs its buttons like a real toolbar. Pass `class="w-full"` to
319
+ // override.
320
+ 'flex-col self-start flex',
321
+ this.capsule() && 'rounded-full',
322
+ this.#dimmed() && 'bg-glass-dim',
323
+ this.userClass(),
324
+ ),
325
+ );
326
+
327
+ protected readonly glassClass = computed(() =>
328
+ cn('p-1 flex', this.#direction()),
329
+ );
330
+
103
331
  protected readonly containerClass = computed(() =>
104
332
  cn(
105
- // The dividers between items are the group's own `bg-border` showing
106
- // through a 1px `gap-px` seam — no per-item borders needed, which keeps
107
- // items position-agnostic. `overflow-hidden` + `rounded-md` then clip the
108
- // square item corners so the bar reads as one rounded control.
109
- 'gap-px rounded-md border border-border bg-border flex overflow-hidden',
110
- // `self-start` stops the bar stretching to fill a column parent so it
111
- // hugs its buttons like a real toolbar. Pass `class="w-full"` to override.
333
+ this.look() === 'material'
334
+ ? // Material's connected group: the items, 2px apart, show the page
335
+ // between them. No clip: the end items round the group's ends
336
+ // themselves, and a clip would cut their outlines.
337
+ 'gap-0.5 flex'
338
+ : // The dividers between items are the group's own `bg-border`
339
+ // showing through a 1px `gap-px` seam — no per-item borders needed,
340
+ // which keeps items position-agnostic. `overflow-hidden` +
341
+ // `rounded-md` then clip the square item corners so the bar reads
342
+ // as one rounded control.
343
+ 'gap-px rounded-md border border-border bg-border flex overflow-hidden',
112
344
  'self-start',
113
- this.orientation() === 'vertical' ? 'flex-col' : 'flex-row',
345
+ this.#direction(),
114
346
  this.userClass(),
115
347
  ),
116
348
  );
@@ -122,13 +354,16 @@ export class UiButtonGroup {
122
354
  */
123
355
  @Component({
124
356
  selector: 'ui-button-group-item',
125
- standalone: true,
126
357
  imports: [LYNX_ELEMENTS],
127
358
  encapsulation: ViewEncapsulation.None,
128
359
  template: `
129
360
  <view
130
361
  #container
131
362
  [class]="containerClass()"
363
+ [style]="cornerStyle()"
364
+ [attr.accessibility-element]="accessibilityLabel() ? 'true' : null"
365
+ [attr.accessibility-traits]="accessibilityLabel() ? 'button' : null"
366
+ [attr.accessibility-label]="accessibilityLabel() || null"
132
367
  (bindtouchstart)="onPressStart()"
133
368
  (bindtouchend)="onPressEnd()"
134
369
  (bindtouchcancel)="onPressCancel()"
@@ -137,68 +372,177 @@ export class UiButtonGroup {
137
372
  (bindmouseleave)="onPressCancel()"
138
373
  (bindtap)="onTap()"
139
374
  >
140
- <text [class]="labelClass()"><ng-content /></text>
375
+ @if (highlightClass(); as highlight) {
376
+ <view [class]="highlight" [style.border-radius.px]="radius()"></view>
377
+ }
378
+ <ng-content select="ui-icon" />
379
+ @if (showLabel()) {
380
+ <text
381
+ [attr.accessibility-traits]="accessibilityLabel() ? null : 'button'"
382
+ [class]="labelClass()"
383
+ ><ng-content
384
+ /></text>
385
+ }
141
386
  </view>
142
387
  `,
143
388
  })
144
389
  export class UiButtonGroupItem {
145
390
  readonly #group = inject(UiButtonGroup);
391
+ readonly #settings = inject(LynxAccessibilitySettings);
146
392
 
147
- readonly disabled = input(false);
393
+ readonly disabled = input(false, { transform: booleanAttribute });
394
+ /**
395
+ * What a screen reader says for the item. An icon-only item needs one; an
396
+ * item with a label reads its label without it.
397
+ */
398
+ readonly accessibilityLabel = input<string | undefined>(undefined, {
399
+ alias: 'accessibility-label',
400
+ });
148
401
  readonly userClass = input<string>('', { alias: 'class' });
149
402
 
150
403
  readonly pressed = output<void>();
151
404
 
152
405
  readonly containerRef = viewChild<ElementRef>('container');
406
+ /** A `<ui-icon>` projected into the item, which sits before the label. */
407
+ readonly projectedIcon = contentChild(iconType());
153
408
  #pressAnim?: AnimationHandle;
154
409
 
410
+ /** A finger (or the mouse) is down on the item. */
411
+ protected readonly pressing = signal(false);
412
+
155
413
  // Disabled when either the whole group or this specific item is disabled.
156
414
  protected readonly isDisabled = computed(
157
415
  () => this.#group.disabled() || this.disabled(),
158
416
  );
159
417
 
160
- protected readonly containerClass = computed(() =>
161
- cn(
162
- buttonGroupItemVariants({
163
- variant: this.#group.variant(),
164
- size: this.#group.size(),
165
- }),
166
- this.isDisabled() && 'opacity-50',
167
- this.userClass(),
168
- ),
418
+ protected readonly radius = computed(() => this.#group.itemRadius());
419
+
420
+ /**
421
+ * Whether the item is the group's first and its last, which round the
422
+ * group's ends in Material. Found from the group's items, as Lynx's CSS
423
+ * has no `:first-child`/`:last-child`.
424
+ */
425
+ readonly #first = computed(() => this.#group.items()[0] === this);
426
+ readonly #last = computed(() => {
427
+ const items = this.#group.items();
428
+ return items[items.length - 1] === this;
429
+ });
430
+
431
+ /**
432
+ * A Material item's corners: small between items, and round at the
433
+ * group's ends, as Material's connected buttons are. The end items draw
434
+ * the ends themselves, rather than the group clipping them, so that the
435
+ * outline Increase Contrast gives them follows the curve. Inline, as how
436
+ * round the ends are depends on the size.
437
+ */
438
+ protected readonly cornerStyle = computed(() => {
439
+ if (this.#group.look() !== 'material') return null;
440
+ const inner = `${MATERIAL_INNER_RADIUS}px`;
441
+ const end = `${this.#group.materialEndRadius()}px`;
442
+ // The left or top corners, and the right or bottom ones.
443
+ const start = this.#first() ? end : inner;
444
+ const finish = this.#last() ? end : inner;
445
+ const column = this.#group.orientation() === 'vertical';
446
+ return {
447
+ 'border-top-left-radius': start,
448
+ 'border-top-right-radius': column ? start : finish,
449
+ 'border-bottom-right-radius': finish,
450
+ 'border-bottom-left-radius': column ? finish : start,
451
+ };
452
+ });
453
+
454
+ /**
455
+ * Glass and Material items show a highlight while pressed, in place: the
456
+ * glass around them is what reacts on iOS, and Material marks a press with
457
+ * a state layer. Solid items shrink, or dim under Reduce Motion.
458
+ */
459
+ readonly #highlights = computed(() => this.#group.look() !== 'solid');
460
+
461
+ protected readonly showLabel = computed(
462
+ () => !(this.projectedIcon() && this.#group.size() === 'icon'),
169
463
  );
170
464
 
171
- protected readonly labelClass = computed(() =>
172
- cn(
173
- buttonGroupTextVariants({
174
- variant: this.#group.variant(),
175
- size: this.#group.size(),
176
- }),
177
- ),
465
+ readonly #gap = computed(
466
+ () => !!this.projectedIcon() && this.showLabel() && 'gap-2',
467
+ );
468
+
469
+ protected readonly containerClass = computed(() => {
470
+ const variant = this.#group.variant();
471
+ const size = this.#group.size();
472
+ const look = this.#group.look();
473
+ const disabled = this.isDisabled() && 'opacity-50';
474
+ if (variant !== 'glass' && variant !== 'glass-clear') {
475
+ return cn(
476
+ buttonGroupItemVariants({ variant, size }),
477
+ this.#gap(),
478
+ this.pressing() && this.#settings.reduceMotion() && 'opacity-70',
479
+ disabled,
480
+ this.userClass(),
481
+ );
482
+ }
483
+ return cn(
484
+ 'flex-row items-center flex relative justify-center',
485
+ ITEM_SIZES[size],
486
+ look === 'material' &&
487
+ cn(
488
+ 'bg-secondary overflow-hidden',
489
+ this.#settings.increaseContrast() && 'border border-foreground',
490
+ ),
491
+ this.#gap(),
492
+ disabled,
493
+ this.userClass(),
494
+ );
495
+ });
496
+
497
+ /**
498
+ * The pressed highlight: the label's color at a low opacity, under the
499
+ * content, in the item's concentric corners. It comes and goes with the
500
+ * finger, with no motion.
501
+ */
502
+ protected readonly highlightClass = computed(
503
+ () =>
504
+ this.pressing() &&
505
+ this.#highlights() &&
506
+ cn(
507
+ 'h-full w-full absolute left-0 top-0',
508
+ this.#group.look() === 'material'
509
+ ? 'bg-secondary-foreground opacity-10'
510
+ : 'bg-glass-foreground opacity-15',
511
+ ),
178
512
  );
179
513
 
514
+ protected readonly labelClass = computed(() => {
515
+ const variant = this.#group.variant();
516
+ let color: string;
517
+ if (variant !== 'glass' && variant !== 'glass-clear') {
518
+ color = SOLID_LABEL_COLORS[variant];
519
+ } else if (this.#group.look() === 'material') {
520
+ color = 'text-secondary-foreground';
521
+ } else {
522
+ // The glass points this at the clear foreground on clear glass.
523
+ color = 'text-glass-foreground';
524
+ }
525
+ return cn('font-medium', LABEL_SIZES[this.#group.size()], color);
526
+ });
527
+
180
528
  protected onPressStart(): void {
181
529
  if (this.isDisabled()) return;
182
- // See UiButton — press animations use el.setStyle-driven transitions that
183
- // are inert in the JIT test harness. Verified on-device.
184
- /* v8 ignore next 2 */
530
+ this.pressing.set(true);
531
+ if (!this.#scales()) return;
532
+ // See UiButton: press feedback is an el.setStyle()-driven CSS transition.
185
533
  this.#pressAnim?.cancel();
186
534
  this.#pressAnim = pressDown(this.containerRef()?.nativeElement);
187
535
  }
188
536
 
189
537
  protected onPressEnd(): void {
190
538
  if (this.isDisabled()) return;
191
- /* v8 ignore next 2 */
192
- this.#pressAnim?.cancel();
193
- this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
539
+ this.#release();
194
540
  }
195
541
 
196
542
  protected onPressCancel(): void {
197
- // Restore scale on cancel too — otherwise a touch stolen by a scroll
198
- // gesture leaves the item stuck in its pressed-down state.
199
- /* v8 ignore next 2 */
200
- this.#pressAnim?.cancel();
201
- this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
543
+ // Release on cancel too — otherwise a touch stolen by a scroll gesture
544
+ // leaves the item stuck in its pressed-down state.
545
+ this.#release();
202
546
  }
203
547
 
204
548
  protected onTap(): void {
@@ -206,4 +550,18 @@ export class UiButtonGroupItem {
206
550
  this.pressed.emit();
207
551
  }
208
552
  }
553
+
554
+ /**
555
+ * Solid items shrink under the finger, unless Reduce Motion is on.
556
+ */
557
+ #scales(): boolean {
558
+ return !this.#highlights() && !this.#settings.reduceMotion();
559
+ }
560
+
561
+ #release(): void {
562
+ this.pressing.set(false);
563
+ if (!this.#scales()) return;
564
+ this.#pressAnim?.cancel();
565
+ this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
566
+ }
209
567
  }
@@ -2,7 +2,10 @@ export {
2
2
  UiButtonGroup,
3
3
  UiButtonGroupItem,
4
4
  buttonGroupItemVariants,
5
+ type ButtonGroupLook,
5
6
  type ButtonGroupVariant,
6
7
  type ButtonGroupSize,
7
8
  type ButtonGroupOrientation,
9
+ type GlassButtonGroupVariant,
10
+ type SolidButtonGroupVariant,
8
11
  } from './button-group';