@blotch/dolan 22.2.4 → 22.2.6

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 (124) hide show
  1. package/dist/commands/add.test.js +2 -0
  2. package/dist/commands/add.test.js.map +1 -1
  3. package/dist/commands/doctor.js +78 -21
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/doctor.test.js +167 -26
  6. package/dist/commands/doctor.test.js.map +1 -1
  7. package/dist/commands/init.test.js +4 -7
  8. package/dist/commands/init.test.js.map +1 -1
  9. package/dist/commands/update.d.ts +3 -1
  10. package/dist/commands/update.js +78 -15
  11. package/dist/commands/update.js.map +1 -1
  12. package/dist/commands/update.test.js +257 -2
  13. package/dist/commands/update.test.js.map +1 -1
  14. package/dist/lockfile.d.ts +10 -0
  15. package/dist/lockfile.js +20 -1
  16. package/dist/lockfile.js.map +1 -1
  17. package/dist/lockfile.test.js +43 -3
  18. package/dist/lockfile.test.js.map +1 -1
  19. package/dist/registry.js +73 -11
  20. package/dist/registry.js.map +1 -1
  21. package/dist/registry.test.js +16 -3
  22. package/dist/registry.test.js.map +1 -1
  23. package/dist/test-utils.d.ts +24 -2
  24. package/dist/test-utils.js +41 -2
  25. package/dist/test-utils.js.map +1 -1
  26. package/dist/ui/boolean-inputs.test.ts +76 -0
  27. package/dist/ui/components/action-sheet/action-sheet.test.ts +370 -1
  28. package/dist/ui/components/action-sheet/action-sheet.ts +306 -28
  29. package/dist/ui/components/action-sheet/index.ts +1 -0
  30. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +552 -8
  31. package/dist/ui/components/alert-dialog/alert-dialog.ts +644 -36
  32. package/dist/ui/components/alert-dialog/index.ts +5 -0
  33. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  34. package/dist/ui/components/badge/badge.ts +2 -1
  35. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +968 -3
  36. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1021 -95
  37. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  38. package/dist/ui/components/button/button.test.ts +36 -1
  39. package/dist/ui/components/button/button.ts +17 -7
  40. package/dist/ui/components/button-group/button-group.test.ts +53 -9
  41. package/dist/ui/components/button-group/button-group.ts +67 -21
  42. package/dist/ui/components/card/card.test.ts +16 -0
  43. package/dist/ui/components/card/card.ts +2 -1
  44. package/dist/ui/components/chart/chart-presets.ts +6 -6
  45. package/dist/ui/components/chart/chart.test.ts +14 -0
  46. package/dist/ui/components/chart/chart.ts +10 -3
  47. package/dist/ui/components/checkbox/checkbox.ts +2 -1
  48. package/dist/ui/components/collapsible/collapsible.ts +2 -1
  49. package/dist/ui/components/dialog/dialog.ts +6 -6
  50. package/dist/ui/components/dock/dock-scroll.ts +9 -0
  51. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  52. package/dist/ui/components/dock/dock.test.ts +4 -1
  53. package/dist/ui/components/dock/dock.ts +529 -51
  54. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  55. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  56. package/dist/ui/components/dynamic-color/index.ts +5 -0
  57. package/dist/ui/components/glass/glass.test.ts +39 -1
  58. package/dist/ui/components/glass/glass.ts +87 -17
  59. package/dist/ui/components/glass/index.ts +1 -0
  60. package/dist/ui/components/icon/icon-theme.test.ts +28 -0
  61. package/dist/ui/components/icon/icon-theme.ts +38 -7
  62. package/dist/ui/components/input/input.ts +2 -1
  63. package/dist/ui/components/label/label.ts +8 -2
  64. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  65. package/dist/ui/components/list/list.ts +5 -4
  66. package/dist/ui/components/menu/menu-popup.test.ts +18 -14
  67. package/dist/ui/components/menu/menu-popup.ts +24 -3
  68. package/dist/ui/components/menu/menu.test.ts +99 -5
  69. package/dist/ui/components/menu/menu.ts +74 -34
  70. package/dist/ui/components/nav-drawer/nav-drawer.ts +3 -2
  71. package/dist/ui/components/navigation-bar/index.ts +1 -0
  72. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  73. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  74. package/dist/ui/components/popover/index.ts +9 -0
  75. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  76. package/dist/ui/components/popover/popover-panel.ts +603 -0
  77. package/dist/ui/components/popover/popover.test.ts +286 -0
  78. package/dist/ui/components/popover/popover.ts +207 -0
  79. package/dist/ui/components/progress/progress.ts +2 -1
  80. package/dist/ui/components/radio-group/radio-group.ts +2 -1
  81. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  82. package/dist/ui/components/search-field/index.ts +15 -0
  83. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  84. package/dist/ui/components/search-field/keyboard.ts +123 -0
  85. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  86. package/dist/ui/components/search-field/search-field.ts +628 -0
  87. package/dist/ui/components/segmented-control/index.ts +8 -0
  88. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  89. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  90. package/dist/ui/components/select/select.ts +2 -1
  91. package/dist/ui/components/sheet/sheet.test.ts +90 -3
  92. package/dist/ui/components/sheet/sheet.ts +59 -4
  93. package/dist/ui/components/slider/index.ts +1 -0
  94. package/dist/ui/components/slider/slider.test.ts +695 -0
  95. package/dist/ui/components/slider/slider.ts +901 -0
  96. package/dist/ui/components/switch/index.ts +1 -1
  97. package/dist/ui/components/switch/switch.test.ts +464 -48
  98. package/dist/ui/components/switch/switch.ts +613 -43
  99. package/dist/ui/components/tabs/index.ts +7 -1
  100. package/dist/ui/components/tabs/tabs.test.ts +278 -2
  101. package/dist/ui/components/tabs/tabs.ts +233 -30
  102. package/dist/ui/components/textarea/textarea.ts +2 -1
  103. package/dist/ui/components/toggle/index.ts +1 -0
  104. package/dist/ui/components/toggle/toggle.test.ts +310 -4
  105. package/dist/ui/components/toggle/toggle.ts +241 -26
  106. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  107. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  108. package/dist/ui/components/toolbar/index.ts +12 -0
  109. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  110. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  111. package/dist/utils/analyze.d.ts +10 -5
  112. package/dist/utils/analyze.js +3 -2
  113. package/dist/utils/analyze.js.map +1 -1
  114. package/dist/utils/installed.d.ts +13 -1
  115. package/dist/utils/installed.js +17 -0
  116. package/dist/utils/installed.js.map +1 -1
  117. package/dist/utils/installed.test.js +39 -1
  118. package/dist/utils/installed.test.js.map +1 -1
  119. package/dist/utils/tailwind.d.ts +25 -5
  120. package/dist/utils/tailwind.js +53 -14
  121. package/dist/utils/tailwind.js.map +1 -1
  122. package/dist/utils/tailwind.test.js +85 -22
  123. package/dist/utils/tailwind.test.js.map +1 -1
  124. package/package.json +1 -1
@@ -1 +1,7 @@
1
- export { UiBottomSheet } from './bottom-sheet';
1
+ export {
2
+ UiBottomSheet,
3
+ UiSheetSource,
4
+ type BottomSheetVariant,
5
+ type SheetDetent,
6
+ type SheetSourceTarget,
7
+ } from './bottom-sheet';
@@ -33,6 +33,9 @@ import {
33
33
  <ui-icon name="search" />
34
34
  }
35
35
  Save
36
+ @if (withTrailingIcon()) {
37
+ <ui-icon name="arrow-right" trailing />
38
+ }
36
39
  </ui-button>
37
40
  `,
38
41
  })
@@ -43,6 +46,7 @@ class ButtonHost {
43
46
  readonly loading = signal(false);
44
47
  readonly label = signal<string | undefined>(undefined);
45
48
  readonly withIcon = signal(false);
49
+ readonly withTrailingIcon = signal(false);
46
50
  readonly presses = signal(0);
47
51
  }
48
52
 
@@ -80,6 +84,7 @@ const renderButton = async (
80
84
  glass: () => result.container.querySelector('blur-view')!,
81
85
  spinner: () => result.container.querySelector('svg'),
82
86
  icon: () => result.container.querySelector('svg')!,
87
+ icons: () => [...result.container.querySelectorAll('svg')],
83
88
  update: async (change: () => void) => {
84
89
  change();
85
90
  await waitForUpdate();
@@ -270,6 +275,35 @@ describe('UiButton', () => {
270
275
  );
271
276
  });
272
277
 
278
+ it('puts an icon marked trailing after the label, in the label color', async () => {
279
+ const { host, button, getByText, icon, update } = await renderButton();
280
+ await update(() => host.withTrailingIcon.set(true));
281
+
282
+ expect(button().classList).toContain('gap-2');
283
+ expect(icon().compareDocumentPosition(getByText('Save'))).toBe(
284
+ Node.DOCUMENT_POSITION_PRECEDING,
285
+ );
286
+ expect(icon().getAttribute('content')).toContain(
287
+ ICON_LIGHT_COLORS['primary-foreground'],
288
+ );
289
+ });
290
+
291
+ it('puts one icon on each side of the label', async () => {
292
+ const { host, getByText, icons, update } = await renderButton();
293
+ await update(() => {
294
+ host.withIcon.set(true);
295
+ host.withTrailingIcon.set(true);
296
+ });
297
+
298
+ const [leading, trailing] = icons();
299
+ expect(leading.compareDocumentPosition(getByText('Save'))).toBe(
300
+ Node.DOCUMENT_POSITION_FOLLOWING,
301
+ );
302
+ expect(trailing.compareDocumentPosition(getByText('Save'))).toBe(
303
+ Node.DOCUMENT_POSITION_PRECEDING,
304
+ );
305
+ });
306
+
273
307
  it('shows only the icon in an icon size, named by its accessibility label', async () => {
274
308
  const { host, button, queryByText, getByRole, update } =
275
309
  await renderButton();
@@ -432,10 +466,11 @@ describe('UiButton glass variants', () => {
432
466
  expect(getByRole('button', { name: 'Search' })).toBe(button());
433
467
  });
434
468
 
435
- it('draws the frosted material before iOS 26, and shrinks when pressed', async () => {
469
+ it('draws the thin material before iOS 26, and shrinks when pressed', async () => {
436
470
  const { host, button, glass, update } = await renderButton(IOS_18);
437
471
  await update(() => host.variant.set('glass'));
438
472
 
473
+ expect(glass().getAttribute('blur-material')).toBe('thin');
439
474
  expect(glass().getAttribute('blur-effect')).toBe('extra-light');
440
475
  fireEvent.touchstart(button());
441
476
  expect(scaleOf(button())).toBe('scale(0.97)');
@@ -4,6 +4,7 @@ import {
4
4
  Component,
5
5
  Injector,
6
6
  ViewEncapsulation,
7
+ booleanAttribute,
7
8
  computed,
8
9
  contentChild,
9
10
  inject,
@@ -182,8 +183,8 @@ const materialButtonVariants = cva(
182
183
 
183
184
  /**
184
185
  * How a glass variant looks on this platform: `'glass'` draws it on a
185
- * `<blur-view ui-glass>` (Liquid Glass, or the frosted material before iOS
186
- * 26), `'material'` as a Material button. The solid variants are `'solid'`
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'`
187
188
  * everywhere.
188
189
  */
189
190
  type ButtonLook = 'solid' | 'glass' | 'material';
@@ -271,6 +272,7 @@ const buttonIconColor = () => inject(UiButton).foreground;
271
272
  <blur-view
272
273
  ui-glass
273
274
  [variant]="variant() === 'glass-clear' ? 'clear' : 'regular'"
275
+ material="thin"
274
276
  [tint]="
275
277
  variant() === 'glass-prominent' ? 'var(--glass-tint)' : undefined
276
278
  "
@@ -290,12 +292,16 @@ const buttonIconColor = () => inject(UiButton).foreground;
290
292
  </view>
291
293
 
292
294
  <!-- One copy of the content, for whichever form the button takes: Angular
293
- projects content once, so it can't be in both branches above. -->
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 →"). -->
294
300
  <ng-template #content>
295
301
  @if (loading()) {
296
302
  <ui-spinner [size]="spinnerSize()" [color]="foreground()" />
297
303
  } @else {
298
- <ng-content select="ui-icon" />
304
+ <ng-content select="ui-icon:not([trailing])" />
299
305
  @if (showLabel()) {
300
306
  <text
301
307
  [attr.accessibility-traits]="accessibilityLabel() ? null : 'button'"
@@ -303,6 +309,7 @@ const buttonIconColor = () => inject(UiButton).foreground;
303
309
  ><ng-content
304
310
  /></text>
305
311
  }
312
+ <ng-content select="ui-icon[trailing]" />
306
313
  }
307
314
  </ng-template>
308
315
  `,
@@ -320,8 +327,8 @@ export class UiButton {
320
327
  * either).
321
328
  */
322
329
  readonly size = input<ButtonSize>('default');
323
- readonly disabled = input(false);
324
- readonly loading = input(false);
330
+ readonly disabled = input(false, { transform: booleanAttribute });
331
+ readonly loading = input(false, { transform: booleanAttribute });
325
332
  /**
326
333
  * What a screen reader says for the button. An icon-only button needs one;
327
334
  * a button with a label reads its label without it.
@@ -334,7 +341,10 @@ export class UiButton {
334
341
  readonly pressed = output<void>();
335
342
 
336
343
  readonly containerRef = viewChild<ElementRef>('container');
337
- /** A `<ui-icon>` projected into the button, which sits before the label. */
344
+ /**
345
+ * A `<ui-icon>` projected into the button, which sits before the label, or
346
+ * after it when it's marked `trailing`.
347
+ */
338
348
  readonly projectedIcon = contentChild(iconType());
339
349
 
340
350
  readonly #settings = inject(LynxAccessibilitySettings);
@@ -38,6 +38,9 @@ import {
38
38
  }
39
39
  Copy
40
40
  </ui-button-group-item>
41
+ @if (withCut()) {
42
+ <ui-button-group-item>Cut</ui-button-group-item>
43
+ }
41
44
  <ui-button-group-item
42
45
  [disabled]="pasteDisabled()"
43
46
  (pressed)="presses.push('Paste')"
@@ -53,6 +56,7 @@ class ButtonGroupHost {
53
56
  readonly groupDisabled = signal(false);
54
57
  readonly pasteDisabled = signal(false);
55
58
  readonly withIcon = signal(false);
59
+ readonly withCut = signal(false);
56
60
  readonly copyLabel = signal<string | undefined>(undefined);
57
61
  readonly presses: string[] = [];
58
62
  }
@@ -103,9 +107,19 @@ const renderGroup = async (
103
107
  };
104
108
 
105
109
  const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
110
+ const IOS_18 = { platform: 'iOS', osVersion: '18.5' };
106
111
  const ANDROID = { platform: 'Android', osVersion: '15' };
107
112
  const WEB = { platform: 'web', osVersion: '' };
108
113
 
114
+ /**
115
+ * An item's inline corner radii: top left, top right, bottom right and
116
+ * bottom left.
117
+ */
118
+ const cornersOf = (view: HTMLElement) =>
119
+ ['top-left', 'top-right', 'bottom-right', 'bottom-left'].map((corner) =>
120
+ view.style.getPropertyValue(`border-${corner}-radius`),
121
+ );
122
+
109
123
  /**
110
124
  * The scale an item's inline style holds, if any.
111
125
  */
@@ -328,6 +342,14 @@ describe('UiButtonGroup glass variants', () => {
328
342
  expect(glass().classList).toContain('rounded-full');
329
343
  });
330
344
 
345
+ it('draws the thin material before iOS 26, as the buttons do', async () => {
346
+ const { host, glass, update } = await renderGroup(IOS_18);
347
+ await update(() => host.variant.set('glass'));
348
+
349
+ expect(glass().getAttribute('blur-material')).toBe('thin');
350
+ expect(glass().getAttribute('blur-effect')).toBe('extra-light');
351
+ });
352
+
331
353
  it('highlights a pressed item in corners concentric with the glass', async () => {
332
354
  const { host, item, getByText, update } = await renderGroup(IOS_26);
333
355
  await update(() => host.variant.set('glass'));
@@ -426,12 +448,9 @@ describe('UiButtonGroup glass variants in Material (Android)', () => {
426
448
  });
427
449
 
428
450
  expect(glass()).toBeNull();
429
- for (const name of ['gap-0.5', 'rounded-full', 'overflow-hidden']) {
430
- expect(group().classList).toContain(name);
431
- }
451
+ expect(group().classList).toContain('gap-0.5');
432
452
  expect(group().classList).not.toContain('bg-border');
433
453
  expect(item('Copy').classList).toContain('bg-secondary');
434
- expect(item('Copy').classList).toContain('rounded-[8px]');
435
454
  expect(getByText('Copy').classList).toContain('text-secondary-foreground');
436
455
  expect(icon().getAttribute('content')).toContain(
437
456
  ICON_LIGHT_COLORS['secondary-foreground'],
@@ -440,14 +459,39 @@ describe('UiButtonGroup glass variants in Material (Android)', () => {
440
459
  // Clear glass is the same tonal group here.
441
460
  await update(() => host.variant.set('glass-clear'));
442
461
  expect(item('Copy').classList).toContain('bg-secondary');
462
+ });
443
463
 
444
- // A column's ends are half an item round, or a capsule of square icons.
445
- await update(() => host.orientation.set('vertical'));
464
+ it("rounds the group's ends on its end items, so Increase Contrast's outline follows them", async () => {
465
+ const { host, group, item, update } = await renderGroup(ANDROID);
466
+ await update(() => {
467
+ host.variant.set('glass');
468
+ settings.increaseContrast.set(true);
469
+ });
470
+ // A row is a capsule: half an item round at its ends, small between.
471
+ expect(cornersOf(item('Copy'))).toEqual(['20px', '8px', '8px', '20px']);
472
+ expect(cornersOf(item('Paste'))).toEqual(['8px', '20px', '20px', '8px']);
473
+ expect(item('Copy').classList).toContain('border-foreground');
474
+ // The group doesn't clip the ends, which would cut their outlines.
446
475
  expect(group().classList).not.toContain('rounded-full');
447
- expect(group().style.borderRadius).toBe('20px');
448
- await update(() => host.size.set('icon'));
449
- expect(group().classList).toContain('rounded-full');
476
+ expect(group().classList).not.toContain('overflow-hidden');
450
477
  expect(group().style.borderRadius).toBe('');
478
+
479
+ // An item added between them has small corners all round.
480
+ await update(() => host.withCut.set(true));
481
+ expect(cornersOf(item('Cut'))).toEqual(['8px', '8px', '8px', '8px']);
482
+ expect(cornersOf(item('Paste'))).toEqual(['8px', '20px', '20px', '8px']);
483
+
484
+ // A column rounds its top and bottom, as much as its items are tall.
485
+ await update(() => {
486
+ host.orientation.set('vertical');
487
+ host.size.set('sm');
488
+ });
489
+ expect(cornersOf(item('Copy'))).toEqual(['18px', '18px', '8px', '8px']);
490
+ expect(cornersOf(item('Paste'))).toEqual(['8px', '8px', '18px', '18px']);
491
+
492
+ // The other looks round nothing per item.
493
+ await update(() => host.variant.set('default'));
494
+ expect(cornersOf(item('Copy'))).toEqual(['', '', '', '']);
451
495
  });
452
496
 
453
497
  it('shows the state layer on a pressed item', async () => {
@@ -3,8 +3,10 @@ import {
3
3
  type ElementRef,
4
4
  Component,
5
5
  ViewEncapsulation,
6
+ booleanAttribute,
6
7
  computed,
7
8
  contentChild,
9
+ contentChildren,
8
10
  inject,
9
11
  input,
10
12
  output,
@@ -33,10 +35,9 @@ import { UI_ICON_COLOR } from '../icon/icon-theme';
33
35
  * Per-item styling of the solid variants. Corners are intentionally left
34
36
  * square here: the group container clips the whole bar with `overflow-hidden`
35
37
  * + `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).
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.
40
41
  */
41
42
  export const buttonGroupItemVariants = cva('items-center flex justify-center', {
42
43
  variants: {
@@ -136,6 +137,11 @@ const ITEM_SIZES: Readonly<Record<ButtonGroupSize, string>> = {
136
137
  */
137
138
  const iconType = () => UiIcon;
138
139
 
140
+ /**
141
+ * `UiButtonGroupItem`, for the group's `contentChildren()` (see iconType).
142
+ */
143
+ const itemType = () => UiButtonGroupItem;
144
+
139
145
  /**
140
146
  * The color an icon projected into an item draws with when it's given none:
141
147
  * the items' label color (see UI_ICON_COLOR). Named, not inline: the
@@ -171,13 +177,15 @@ const groupIconColor = () => inject(UiButtonGroup).foreground;
171
177
  template: `
172
178
  @if (look() === 'glass') {
173
179
  <!-- The frame is the group's box and, under clear glass, its dimming
174
- layer, in the glass's shape. -->
180
+ layer, in the glass's shape. Before iOS 26 the glass is the thin
181
+ material, as the buttons' is. -->
175
182
  <view [class]="frameClass()" [style.border-radius.px]="cornerRadius()">
176
183
  <blur-view
177
184
  ui-glass
178
185
  [shape]="capsule() ? 'capsule' : 'rounded'"
179
186
  [radius]="radius()"
180
187
  [variant]="variant() === 'glass-clear' ? 'clear' : 'regular'"
188
+ material="thin"
181
189
  [interactive]="interactive()"
182
190
  [class]="glassClass()"
183
191
  >
@@ -185,10 +193,7 @@ const groupIconColor = () => inject(UiButtonGroup).foreground;
185
193
  </blur-view>
186
194
  </view>
187
195
  } @else {
188
- <view
189
- [class]="containerClass()"
190
- [style.border-radius.px]="materialRadius()"
191
- >
196
+ <view [class]="containerClass()">
192
197
  <ng-container [ngTemplateOutlet]="items" />
193
198
  </view>
194
199
  }
@@ -207,13 +212,16 @@ export class UiButtonGroup {
207
212
  readonly variant = input<ButtonGroupVariant>('default');
208
213
  /** The items' height and padding; `'icon'` makes square icon-only items. */
209
214
  readonly size = input<ButtonGroupSize>('default');
210
- readonly disabled = input(false);
215
+ readonly disabled = input(false, { transform: booleanAttribute });
211
216
  readonly userClass = input<string>('', { alias: 'class' });
212
217
 
213
218
  readonly #settings = inject(LynxAccessibilitySettings);
214
219
  readonly #design = injectGlassDesign();
215
220
  readonly #systemDrawsGlass = injectSystemDrawsGlass();
216
221
 
222
+ /** The items in the group, in order. */
223
+ readonly items = contentChildren(itemType(), { descendants: true });
224
+
217
225
  /** The form the group takes on this platform, for its items. */
218
226
  readonly look = computed<ButtonGroupLook>(() => {
219
227
  const variant = this.variant();
@@ -234,7 +242,8 @@ export class UiButtonGroup {
234
242
  * The corner radius of an item's pressed highlight, in px. In glass it's
235
243
  * concentric with the group's corners, so the gap between them stays even
236
244
  * 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.
245
+ * corner, and an item at one of the group's ends clips it to its round
246
+ * end (see materialEndRadius).
238
247
  */
239
248
  readonly itemRadius = computed(() =>
240
249
  this.look() === 'material'
@@ -257,12 +266,13 @@ export class UiButtonGroup {
257
266
  this.capsule() ? null : this.radius(),
258
267
  );
259
268
 
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
- );
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);
266
276
 
267
277
  /**
268
278
  * As UiButton's: the glass reacts to touch, unless Reduce Motion is on
@@ -322,8 +332,9 @@ export class UiButtonGroup {
322
332
  cn(
323
333
  this.look() === 'material'
324
334
  ? // 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')
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'
327
338
  : // The dividers between items are the group's own `bg-border`
328
339
  // showing through a 1px `gap-px` seam — no per-item borders needed,
329
340
  // which keeps items position-agnostic. `overflow-hidden` +
@@ -349,6 +360,7 @@ export class UiButtonGroup {
349
360
  <view
350
361
  #container
351
362
  [class]="containerClass()"
363
+ [style]="cornerStyle()"
352
364
  [attr.accessibility-element]="accessibilityLabel() ? 'true' : null"
353
365
  [attr.accessibility-traits]="accessibilityLabel() ? 'button' : null"
354
366
  [attr.accessibility-label]="accessibilityLabel() || null"
@@ -378,7 +390,7 @@ export class UiButtonGroupItem {
378
390
  readonly #group = inject(UiButtonGroup);
379
391
  readonly #settings = inject(LynxAccessibilitySettings);
380
392
 
381
- readonly disabled = input(false);
393
+ readonly disabled = input(false, { transform: booleanAttribute });
382
394
  /**
383
395
  * What a screen reader says for the item. An icon-only item needs one; an
384
396
  * item with a label reads its label without it.
@@ -405,6 +417,40 @@ export class UiButtonGroupItem {
405
417
 
406
418
  protected readonly radius = computed(() => this.#group.itemRadius());
407
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
+
408
454
  /**
409
455
  * Glass and Material items show a highlight while pressed, in place: the
410
456
  * glass around them is what reacts on iOS, and Material marks a press with
@@ -439,7 +485,7 @@ export class UiButtonGroupItem {
439
485
  ITEM_SIZES[size],
440
486
  look === 'material' &&
441
487
  cn(
442
- 'rounded-[8px] bg-secondary overflow-hidden',
488
+ 'bg-secondary overflow-hidden',
443
489
  this.#settings.increaseContrast() && 'border border-foreground',
444
490
  ),
445
491
  this.#gap(),
@@ -40,6 +40,12 @@ class CardHost {
40
40
  readonly pressable = signal(false);
41
41
  }
42
42
 
43
+ @Component({
44
+ imports: [UiCard, LYNX_ELEMENTS],
45
+ template: `<ui-card pressable><text>Order</text></ui-card>`,
46
+ })
47
+ class BarePressableHost {}
48
+
43
49
  /**
44
50
  * The card's own view. Each component's host element renders as a view too,
45
51
  * so it's found by the card's class rather than by being the first.
@@ -112,6 +118,16 @@ describe('UiCard', () => {
112
118
  expect(scaleOf(view)).toBe('scale(1)');
113
119
  });
114
120
 
121
+ it('is pressable with a bare pressable attribute', async () => {
122
+ const { container } = await render(BarePressableHost);
123
+ const view = cardView(container);
124
+
125
+ fireEvent.touchstart(view);
126
+ expect(scaleOf(view)).toBe('scale(0.985)');
127
+ fireEvent.touchend(view);
128
+ expect(scaleOf(view)).toBe('scale(1)');
129
+ });
130
+
115
131
  it('scales down while pressed by mouse, and back on release or leave', async () => {
116
132
  const { container, componentRef } = await render(CardHost);
117
133
  (componentRef.instance as CardHost).pressable.set(true);
@@ -2,6 +2,7 @@ import {
2
2
  type ElementRef,
3
3
  Component,
4
4
  ViewEncapsulation,
5
+ booleanAttribute,
5
6
  computed,
6
7
  input,
7
8
  viewChild,
@@ -40,7 +41,7 @@ export class UiCard {
40
41
  /**
41
42
  * When true, the card responds to touch with a subtle scale animation
42
43
  */
43
- readonly pressable = input(false);
44
+ readonly pressable = input(false, { transform: booleanAttribute });
44
45
  readonly userClass = input<string>('', { alias: 'class' });
45
46
 
46
47
  readonly containerRef = viewChild<ElementRef>('container');
@@ -1,4 +1,4 @@
1
- import { Directive, input, viewChild } from '@angular/core';
1
+ import { Directive, booleanAttribute, input, viewChild } from '@angular/core';
2
2
  import { scaleLinear } from '@tanstack/charts/scales/linear';
3
3
  import type {
4
4
  ChartPoint,
@@ -193,9 +193,9 @@ export abstract class UiCartesianPreset<TDatum> {
193
193
  /** Target number of ticks per axis; the axis picks nice values near it. */
194
194
  readonly tickCount = input(5);
195
195
  /** Horizontal gridlines at the y ticks. */
196
- readonly showGrid = input(true);
196
+ readonly showGrid = input(true, { transform: booleanAttribute });
197
197
  /** Vertical gridlines at the x ticks. */
198
- readonly showXGrid = input(false);
198
+ readonly showXGrid = input(false, { transform: booleanAttribute });
199
199
  readonly xAxisLabel = input('');
200
200
  readonly yAxisLabel = input('');
201
201
  readonly padding = input<ChartPadding>({});
@@ -206,14 +206,14 @@ export abstract class UiCartesianPreset<TDatum> {
206
206
  readonly xTickFormat = input<(value: number) => string>();
207
207
  readonly yTickFormat = input<(value: number) => string>();
208
208
  /** Shows a tooltip for the touched point; a tap pins it. */
209
- readonly tooltip = input(true);
209
+ readonly tooltip = input(true, { transform: booleanAttribute });
210
210
  /** What a screen reader announces for the chart. */
211
211
  readonly label = input('Chart');
212
212
  /** Light, dark or `'system'` colors; the chart theme's setting when unset. */
213
213
  readonly colorScheme = input<Exclude<ChartColorSchemeOption, Function>>();
214
- readonly zoomable = input(false);
214
+ readonly zoomable = input(false, { transform: booleanAttribute });
215
215
  readonly zoomAxes = input<ChartZoomAxes>('xy');
216
- readonly showZoomControls = input(true);
216
+ readonly showZoomControls = input(true, { transform: booleanAttribute });
217
217
  readonly minZoom = input(1);
218
218
  readonly maxZoom = input(8);
219
219
  readonly zoomStep = input(1.4);
@@ -18,6 +18,7 @@ import {
18
18
  } from '@tanstack/charts/universal';
19
19
  import { describe, expect, it, vi } from 'vitest';
20
20
  import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
21
+ import { UI_THEME_COLORS } from '../icon/icon-theme';
21
22
  import { UiChart, UiChartTooltip, tooltip } from './chart';
22
23
  import {
23
24
  CHART_DARK_COLORS,
@@ -337,6 +338,19 @@ describe('UiChart', () => {
337
338
  await primaryFill([provideChartTheme({ colorScheme: () => 'dark' })]),
338
339
  ).toBe(CHART_DARK_COLORS['primary']);
339
340
  });
341
+
342
+ it('paints with colors set at runtime above it, such as the wallpaper’s', async () => {
343
+ const wallpaper = {
344
+ provide: UI_THEME_COLORS,
345
+ useValue: signal({ dark: { primary: 'rgba(208, 188, 255, 1)' } }),
346
+ };
347
+ expect(await primaryFill([wallpaper], 'dark')).toBe(
348
+ 'rgba(208, 188, 255, 1)',
349
+ );
350
+ expect(await primaryFill([wallpaper], 'light')).toBe(
351
+ CHART_LIGHT_COLORS['primary'],
352
+ );
353
+ });
340
354
  });
341
355
 
342
356
  it('gives labels the foreground token when the theme sets no foreground', async () => {
@@ -4,6 +4,7 @@ import {
4
4
  Directive,
5
5
  TemplateRef,
6
6
  ViewEncapsulation,
7
+ booleanAttribute,
7
8
  computed,
8
9
  contentChild,
9
10
  effect,
@@ -46,6 +47,7 @@ import type {
46
47
  } from '@tanstack/charts/types';
47
48
 
48
49
  import { cn } from '../../utils/cn';
50
+ import { UI_THEME_COLORS } from '../icon/icon-theme';
49
51
  import { adjacentFocusPoint, createChartFocusModel } from './chart-focus';
50
52
  import {
51
53
  type ChartLabel,
@@ -342,9 +344,9 @@ export class UiChart {
342
344
  * light unless the app says otherwise; see `ChartColorSchemeOption`.
343
345
  */
344
346
  readonly colorScheme = input<Exclude<ChartColorSchemeOption, Function>>();
345
- readonly zoomable = input(false);
347
+ readonly zoomable = input(false, { transform: booleanAttribute });
346
348
  readonly zoomAxes = input<ChartZoomAxes>('xy');
347
- readonly showZoomControls = input(true);
349
+ readonly showZoomControls = input(true, { transform: booleanAttribute });
348
350
  readonly minZoom = input(1);
349
351
  readonly maxZoom = input(8);
350
352
  /** Factor each zoom button press zooms by. */
@@ -363,6 +365,7 @@ export class UiChart {
363
365
 
364
366
  readonly #theme = inject(UI_CHART_THEME);
365
367
  readonly #appearance = inject(LynxTheme);
368
+ readonly #replacedColors = inject(UI_THEME_COLORS, { optional: true });
366
369
  readonly #idPrefix = `chart${nextChartId++}`;
367
370
  readonly #runtime = createChartRuntime({ defaultTheme: this.#theme.chart });
368
371
  readonly #measureText = lynxTextMeasurer();
@@ -468,7 +471,11 @@ export class UiChart {
468
471
  : typeof option === 'function'
469
472
  ? option()
470
473
  : option;
471
- return scheme === 'dark' ? this.#theme.dark : this.#theme.light;
474
+ // Colors set at runtime above the chart, such as Android's wallpaper
475
+ // colors, take the place of the theme's (see UI_THEME_COLORS).
476
+ const replaced = this.#replacedColors?.()[scheme];
477
+ const colors = scheme === 'dark' ? this.#theme.dark : this.#theme.light;
478
+ return replaced ? { ...colors, ...replaced } : colors;
472
479
  });
473
480
 
474
481
  readonly #svgOptions = computed(() => {
@@ -2,6 +2,7 @@ import {
2
2
  type ElementRef,
3
3
  Component,
4
4
  ViewEncapsulation,
5
+ booleanAttribute,
5
6
  computed,
6
7
  effect,
7
8
  input,
@@ -38,7 +39,7 @@ import { cn } from '../../utils/cn';
38
39
  })
39
40
  export class UiCheckbox {
40
41
  readonly checked = model(false);
41
- readonly disabled = input(false);
42
+ readonly disabled = input(false, { transform: booleanAttribute });
42
43
  readonly userClass = input<string>('', { alias: 'class' });
43
44
 
44
45
  readonly changed = output<boolean>();
@@ -2,6 +2,7 @@ import {
2
2
  type ElementRef,
3
3
  Component,
4
4
  ViewEncapsulation,
5
+ booleanAttribute,
5
6
  computed,
6
7
  effect,
7
8
  inject,
@@ -27,7 +28,7 @@ import { cn } from '../../utils/cn';
27
28
  })
28
29
  export class UiCollapsible {
29
30
  readonly open = model(false);
30
- readonly disabled = input(false);
31
+ readonly disabled = input(false, { transform: booleanAttribute });
31
32
  readonly userClass = input<string>('', { alias: 'class' });
32
33
 
33
34
  protected readonly containerClass = computed(() =>