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