@blotch/dolan 22.2.5 → 22.2.7

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 (91) hide show
  1. package/dist/commands/install-packages.test.js +7 -2
  2. package/dist/commands/install-packages.test.js.map +1 -1
  3. package/dist/registry.js +8 -2
  4. package/dist/registry.js.map +1 -1
  5. package/dist/registry.test.js +1 -0
  6. package/dist/registry.test.js.map +1 -1
  7. package/dist/ui/components/accordion/accordion.test.ts +24 -7
  8. package/dist/ui/components/accordion/accordion.ts +34 -44
  9. package/dist/ui/components/action-sheet/action-sheet.test.ts +18 -1
  10. package/dist/ui/components/action-sheet/action-sheet.ts +55 -60
  11. package/dist/ui/components/alert/alert.test.ts +17 -0
  12. package/dist/ui/components/alert/alert.ts +23 -14
  13. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +19 -4
  14. package/dist/ui/components/alert-dialog/alert-dialog.ts +31 -20
  15. package/dist/ui/components/aspect-ratio/aspect-ratio.test.ts +38 -2
  16. package/dist/ui/components/aspect-ratio/aspect-ratio.ts +11 -8
  17. package/dist/ui/components/avatar/avatar.test.ts +7 -0
  18. package/dist/ui/components/avatar/avatar.ts +35 -30
  19. package/dist/ui/components/badge/badge.test.ts +9 -0
  20. package/dist/ui/components/badge/badge.ts +12 -10
  21. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +7 -0
  22. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +5 -0
  23. package/dist/ui/components/button/button.test.ts +29 -3
  24. package/dist/ui/components/button/button.ts +67 -62
  25. package/dist/ui/components/button-group/button-group.test.ts +37 -4
  26. package/dist/ui/components/button-group/button-group.ts +60 -51
  27. package/dist/ui/components/card/card.test.ts +35 -2
  28. package/dist/ui/components/card/card.ts +41 -53
  29. package/dist/ui/components/chart/chart-presets.test.ts +16 -2
  30. package/dist/ui/components/chart/chart-presets.ts +22 -3
  31. package/dist/ui/components/chart/chart.test.ts +35 -6
  32. package/dist/ui/components/chart/chart.ts +14 -3
  33. package/dist/ui/components/checkbox/checkbox.test.ts +11 -4
  34. package/dist/ui/components/checkbox/checkbox.ts +21 -14
  35. package/dist/ui/components/collapsible/collapsible.test.ts +20 -10
  36. package/dist/ui/components/collapsible/collapsible.ts +29 -40
  37. package/dist/ui/components/dialog/dialog.test.ts +17 -1
  38. package/dist/ui/components/dialog/dialog.ts +24 -17
  39. package/dist/ui/components/dock/dock.test.ts +30 -5
  40. package/dist/ui/components/dock/dock.ts +29 -27
  41. package/dist/ui/components/empty-state/empty-state.test.ts +9 -0
  42. package/dist/ui/components/empty-state/empty-state.ts +20 -11
  43. package/dist/ui/components/icon/icon.test.ts +17 -0
  44. package/dist/ui/components/icon/icon.ts +19 -17
  45. package/dist/ui/components/input/input.test.ts +55 -3
  46. package/dist/ui/components/input/input.ts +91 -65
  47. package/dist/ui/components/label/label.test.ts +15 -0
  48. package/dist/ui/components/label/label.ts +12 -4
  49. package/dist/ui/components/list/list.test.ts +13 -3
  50. package/dist/ui/components/list/list.ts +14 -2
  51. package/dist/ui/components/menu/menu.test.ts +8 -0
  52. package/dist/ui/components/menu/menu.ts +4 -0
  53. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +34 -21
  54. package/dist/ui/components/nav-drawer/nav-drawer.ts +60 -68
  55. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +13 -0
  56. package/dist/ui/components/navigation-bar/navigation-bar.ts +5 -0
  57. package/dist/ui/components/pagination/pagination.test.ts +9 -0
  58. package/dist/ui/components/pagination/pagination.ts +52 -49
  59. package/dist/ui/components/popover/popover.test.ts +3 -0
  60. package/dist/ui/components/progress/progress.test.ts +7 -0
  61. package/dist/ui/components/progress/progress.ts +8 -6
  62. package/dist/ui/components/radio-group/radio-group.test.ts +8 -0
  63. package/dist/ui/components/radio-group/radio-group.ts +22 -19
  64. package/dist/ui/components/scroll-area/scroll-area.test.ts +14 -2
  65. package/dist/ui/components/scroll-area/scroll-area.ts +15 -3
  66. package/dist/ui/components/search-field/search-field.test.ts +25 -0
  67. package/dist/ui/components/search-field/search-field.ts +7 -1
  68. package/dist/ui/components/select/select.test.ts +32 -5
  69. package/dist/ui/components/select/select.ts +32 -11
  70. package/dist/ui/components/separator/separator.test.ts +3 -0
  71. package/dist/ui/components/sheet/sheet.test.ts +18 -0
  72. package/dist/ui/components/sheet/sheet.ts +24 -24
  73. package/dist/ui/components/skeleton/skeleton.test.ts +8 -0
  74. package/dist/ui/components/skeleton/skeleton.ts +13 -8
  75. package/dist/ui/components/slider/slider.test.ts +13 -1
  76. package/dist/ui/components/slider/slider.ts +16 -3
  77. package/dist/ui/components/spinner/spinner.test.ts +16 -1
  78. package/dist/ui/components/spinner/spinner.ts +15 -7
  79. package/dist/ui/components/switch/switch.test.ts +13 -2
  80. package/dist/ui/components/switch/switch.ts +15 -1
  81. package/dist/ui/components/tab-bar/tab-bar.test.ts +32 -6
  82. package/dist/ui/components/tab-bar/tab-bar.ts +28 -24
  83. package/dist/ui/components/tabs/tabs.test.ts +34 -32
  84. package/dist/ui/components/tabs/tabs.ts +56 -73
  85. package/dist/ui/components/textarea/textarea.test.ts +11 -1
  86. package/dist/ui/components/textarea/textarea.ts +77 -71
  87. package/dist/ui/components/toggle/toggle.test.ts +47 -16
  88. package/dist/ui/components/toggle/toggle.ts +78 -80
  89. package/dist/ui/components/typography/typography.test.ts +16 -0
  90. package/dist/ui/components/typography/typography.ts +12 -4
  91. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { NgTemplateOutlet } from '@angular/common';
2
2
  import {
3
- type ElementRef,
4
3
  Component,
4
+ ElementRef,
5
5
  Injector,
6
6
  ViewEncapsulation,
7
7
  booleanAttribute,
@@ -11,7 +11,6 @@ import {
11
11
  input,
12
12
  output,
13
13
  signal,
14
- viewChild,
15
14
  } from '@angular/core';
16
15
  import {
17
16
  LYNX_ELEMENTS,
@@ -233,63 +232,69 @@ const buttonIconColor = () => inject(UiButton).foreground;
233
232
  // inside the template provides one too, but only for the template's own
234
233
  // nodes.
235
234
  providers: [{ provide: UI_ICON_COLOR, useFactory: buttonIconColor }],
235
+ // The host is the button's box. Angular gives a component's host every
236
+ // static attribute the app writes on it, so a class on <ui-button> lands
237
+ // there whatever the component does: on a box inside the host as well, a
238
+ // margin or padding applied twice. An accessibility-label lands there too,
239
+ // which is where it belongs: the box is the button a screen reader reads.
240
+ //
241
+ // Press feedback binds BOTH touch and mouse events on purpose. Native
242
+ // (iOS/Android) only ever fires touch; Lynx web runs in the browser, where a
243
+ // cursor press fires mousedown/up and NEVER touchstart — so touch-only
244
+ // bindings gave zero visual feedback on web while (bindtap) still fired the
245
+ // action (a "the button works but doesn't react" split). Binding both makes
246
+ // the press-and-hold identical on every platform; mouse events are inert on
247
+ // touch devices, so there's no double-fire. The scale animation itself lives
248
+ // in pressDown/pressRelease — see their JSDoc for why it's a CSS transition
249
+ // (which serializes to the DOM on Lynx web) and not el.animate() (whose Web
250
+ // Animation runs on web-core's offscreen node and never reaches the screen).
251
+ //
252
+ // An icon-only button has no text for a screen reader to read, so with an
253
+ // accessibility-label the whole button becomes one element that says it.
254
+ // Without one, the label's own text is read, as a button. The attributes
255
+ // are attribute bindings: a property binding on a component's host is
256
+ // checked against the DOM schema, which has no `accessibility-label`.
257
+ host: {
258
+ '[class]': 'containerClass()',
259
+ '[attr.accessibility-element]': "accessibilityLabel() ? 'true' : null",
260
+ '[attr.accessibility-traits]': "accessibilityLabel() ? 'button' : null",
261
+ '[attr.accessibility-label]': 'accessibilityLabel() || null',
262
+ '(bindtouchstart)': 'onPressStart()',
263
+ '(bindtouchend)': 'onPressEnd()',
264
+ '(bindtouchcancel)': 'onPressCancel()',
265
+ '(bindmousedown)': 'onPressStart()',
266
+ '(bindmouseup)': 'onPressEnd()',
267
+ '(bindmouseleave)': 'onPressCancel()',
268
+ '(bindtap)': 'onTap()',
269
+ },
236
270
  template: `
237
- <!-- Press feedback binds BOTH touch and mouse events on purpose. Native
238
- (iOS/Android) only ever fires touch; Lynx web runs in the browser, where
239
- a cursor press fires mousedown/up and NEVER touchstart — so touch-only
240
- bindings gave zero visual feedback on web while (bindtap) still fired the
241
- action (a "the button works but doesn't react" split). Binding both makes
242
- the press-and-hold identical on every platform; mouse events are inert on
243
- touch devices, so there's no double-fire. The scale animation itself lives
244
- in pressDown/pressRelease — see their JSDoc for why it's a CSS transition
245
- (which serializes to the DOM on Lynx web) and not el.animate() (whose Web
246
- Animation runs on web-core's offscreen node and never reaches the screen).
247
-
248
- An icon-only button has no text for a screen reader to read, so with
249
- an accessibility-label the whole button becomes one element that says
250
- it. Without one, the label's own text is read, as a button. -->
251
- <view
252
- #container
253
- [class]="containerClass()"
254
- [attr.accessibility-element]="accessibilityLabel() ? 'true' : null"
255
- [attr.accessibility-traits]="accessibilityLabel() ? 'button' : null"
256
- [attr.accessibility-label]="accessibilityLabel() || null"
257
- (bindtouchstart)="onPressStart()"
258
- (bindtouchend)="onPressEnd()"
259
- (bindtouchcancel)="onPressCancel()"
260
- (bindmousedown)="onPressStart()"
261
- (bindmouseup)="onPressEnd()"
262
- (bindmouseleave)="onPressCancel()"
263
- (bindtap)="onTap()"
264
- >
265
- @if (look() === 'glass') {
266
- <!-- The container is the button's box, and the glass fills it. Under
267
- clear glass the container is the dimming layer (see dimmed).
268
- The content sits in the glass, where iOS's interactive glass
269
- reacts to touches on it, and is what dims when disabled, as a
270
- disabled SwiftUI glass button dims its label: glass under a
271
- faded parent turns into a washed-out patch on iOS. -->
272
- <blur-view
273
- ui-glass
274
- [variant]="variant() === 'glass-clear' ? 'clear' : 'regular'"
275
- material="thin"
276
- [tint]="
277
- variant() === 'glass-prominent' ? 'var(--glass-tint)' : undefined
278
- "
279
- [interactive]="interactive()"
280
- [class]="glassClass()"
281
- >
282
- <view [class]="glassContentClass()">
283
- <ng-container [ngTemplateOutlet]="content" />
284
- </view>
285
- </blur-view>
286
- } @else {
287
- @if (stateLayerClass(); as layer) {
288
- <view [class]="layer"></view>
289
- }
290
- <ng-container [ngTemplateOutlet]="content" />
271
+ @if (look() === 'glass') {
272
+ <!-- The host is the button's box, and the glass fills it. Under
273
+ clear glass the host is the dimming layer (see dimmed).
274
+ The content sits in the glass, where iOS's interactive glass
275
+ reacts to touches on it, and is what dims when disabled, as a
276
+ disabled SwiftUI glass button dims its label: glass under a
277
+ faded parent turns into a washed-out patch on iOS. -->
278
+ <blur-view
279
+ ui-glass
280
+ [variant]="variant() === 'glass-clear' ? 'clear' : 'regular'"
281
+ material="thin"
282
+ [tint]="
283
+ variant() === 'glass-prominent' ? 'var(--glass-tint)' : undefined
284
+ "
285
+ [interactive]="interactive()"
286
+ [class]="glassClass()"
287
+ >
288
+ <view [class]="glassContentClass()">
289
+ <ng-container [ngTemplateOutlet]="content" />
290
+ </view>
291
+ </blur-view>
292
+ } @else {
293
+ @if (stateLayerClass(); as layer) {
294
+ <view [class]="layer"></view>
291
295
  }
292
- </view>
296
+ <ng-container [ngTemplateOutlet]="content" />
297
+ }
293
298
 
294
299
  <!-- One copy of the content, for whichever form the button takes: Angular
295
300
  projects content once, so it can't be in both branches above.
@@ -340,13 +345,13 @@ export class UiButton {
340
345
 
341
346
  readonly pressed = output<void>();
342
347
 
343
- readonly containerRef = viewChild<ElementRef>('container');
344
348
  /**
345
349
  * A `<ui-icon>` projected into the button, which sits before the label, or
346
350
  * after it when it's marked `trailing`.
347
351
  */
348
352
  readonly projectedIcon = contentChild(iconType());
349
353
 
354
+ readonly #host = inject(ElementRef).nativeElement;
350
355
  readonly #settings = inject(LynxAccessibilitySettings);
351
356
  readonly #design = injectGlassDesign();
352
357
  readonly #systemDrawsGlass = injectSystemDrawsGlass();
@@ -417,7 +422,7 @@ export class UiButton {
417
422
  /**
418
423
  * Clear glass sits on a dark layer at about a third, so its white label
419
424
  * stays legible over bright photos and video: Apple's rule for clear
420
- * glass. The layer is the container, under the glass.
425
+ * glass. The layer is the host, under the glass.
421
426
  */
422
427
  readonly #dimmed = computed(
423
428
  () => this.variant() === 'glass-clear' && this.#seeThrough(),
@@ -534,9 +539,9 @@ export class UiButton {
534
539
  this.pressing.set(true);
535
540
  if (this.#feedback() !== 'scale') return;
536
541
  // pressDown/pressRelease drive el.setStyle() based CSS transitions on the
537
- // container ref (see pressDown for why it isn't el.animate()).
542
+ // host (see pressDown for why it isn't el.animate()).
538
543
  this.#pressAnim?.cancel();
539
- this.#pressAnim = pressDown(this.containerRef()?.nativeElement);
544
+ this.#pressAnim = pressDown(this.#host);
540
545
  }
541
546
 
542
547
  protected onPressEnd(): void {
@@ -560,6 +565,6 @@ export class UiButton {
560
565
  this.pressing.set(false);
561
566
  if (this.#feedback() !== 'scale') return;
562
567
  this.#pressAnim?.cancel();
563
- this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
568
+ this.#pressAnim = pressRelease(this.#host);
564
569
  }
565
570
  }
@@ -61,6 +61,18 @@ class ButtonGroupHost {
61
61
  readonly presses: string[] = [];
62
62
  }
63
63
 
64
+ @Component({
65
+ imports: [UiButtonGroup, UiButtonGroupItem, UiIcon],
66
+ template: `
67
+ <ui-button-group size="icon">
68
+ <ui-button-group-item accessibility-label="Copy">
69
+ <ui-icon name="copy" />
70
+ </ui-button-group-item>
71
+ </ui-button-group>
72
+ `,
73
+ })
74
+ class StaticLabelHost {}
75
+
64
76
  /** Accessibility settings a test can change, as the host app would. */
65
77
  const settings = {
66
78
  reduceMotion: signal(false),
@@ -87,16 +99,15 @@ const renderGroup = async (
87
99
  ...result,
88
100
  host: result.componentRef.instance as ButtonGroupHost,
89
101
  /**
90
- * The group's own box, around the items' hosts: the bar of the solid
102
+ * The group's box, its host, around the items: the bar of the solid
91
103
  * variants, Material's connected group, or the glass's frame.
92
104
  */
93
105
  group: (): HTMLElement => {
94
- const parent =
95
- result.getByText('Paste').parentElement!.parentElement!.parentElement!;
106
+ const parent = result.getByText('Paste').parentElement!.parentElement!;
96
107
  return parent.localName === 'blur-view' ? parent.parentElement! : parent;
97
108
  },
98
109
  glass: () => result.container.querySelector<HTMLElement>('blur-view')!,
99
- // An item's view (the one that takes presses) holds its label.
110
+ // An item (its host, which takes presses) holds its label.
100
111
  item: (label: string) => result.getByText(label).parentElement!,
101
112
  icon: () => result.container.querySelector('svg')!,
102
113
  update: async (change: () => void) => {
@@ -141,6 +152,28 @@ afterEach(() => {
141
152
  });
142
153
 
143
154
  describe('UiButtonGroup', () => {
155
+ // Angular gives a component's host every static attribute written on it,
156
+ // so a group or item that also styled a view inside its host applied the
157
+ // class twice, and the label named two elements (issue #181).
158
+ it('puts a class written on the group or an item on that one element', async () => {
159
+ const { container, group, item } = await renderGroup();
160
+ expect([...container.querySelectorAll('.w-full')]).toEqual([group()]);
161
+ expect([...container.querySelectorAll('.flex-1')]).toEqual([item('Copy')]);
162
+ });
163
+
164
+ it('names one element with an accessibility label written on an item', async () => {
165
+ vi.stubGlobal('SystemInfo', undefined);
166
+ const { container, getAllByAccessibilityLabel } = await render(
167
+ StaticLabelHost,
168
+ {
169
+ providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
170
+ },
171
+ );
172
+ expect(getAllByAccessibilityLabel('Copy')).toEqual([
173
+ container.querySelector('.h-10'),
174
+ ]);
175
+ });
176
+
144
177
  it('lays its items out in a row, or in a column when vertical', async () => {
145
178
  const { host, group } = await renderGroup();
146
179
  expect(group().classList).toContain('flex-row');
@@ -1,7 +1,7 @@
1
1
  import { NgTemplateOutlet } from '@angular/common';
2
2
  import {
3
- type ElementRef,
4
3
  Component,
4
+ ElementRef,
5
5
  ViewEncapsulation,
6
6
  booleanAttribute,
7
7
  computed,
@@ -11,7 +11,6 @@ import {
11
11
  input,
12
12
  output,
13
13
  signal,
14
- viewChild,
15
14
  } from '@angular/core';
16
15
  import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
17
16
  import { cva, type VariantProps } from 'class-variance-authority';
@@ -174,28 +173,37 @@ const groupIconColor = () => inject(UiButtonGroup).foreground;
174
173
  // Icons projected into the items inject this: they're declared inside the
175
174
  // group, so the group's element injector is one of theirs.
176
175
  providers: [{ provide: UI_ICON_COLOR, useFactory: groupIconColor }],
176
+ // The group and each item are their host elements, styled through `host`,
177
+ // rather than a view inside the host. Angular gives a component's host
178
+ // every static attribute the app writes on it, so `<ui-button-group
179
+ // class="mt-4">` puts `mt-4` on the host whatever the component does with
180
+ // its `class` input: with a styled view inside as well, the margin applied
181
+ // twice. On the host alone, a class also acts where the group sits in its
182
+ // parent's layout (`w-full`), and an item's accessibility-label names one
183
+ // element.
184
+ //
185
+ // Under glass the host is the frame: the group's box and, under clear
186
+ // glass, its dimming layer, in the glass's shape. Before iOS 26 the glass
187
+ // is the thin material, as the buttons' is.
188
+ host: {
189
+ '[class]': "look() === 'glass' ? frameClass() : containerClass()",
190
+ '[style.border-radius.px]': "look() === 'glass' ? cornerRadius() : null",
191
+ },
177
192
  template: `
178
193
  @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()">
194
+ <blur-view
195
+ ui-glass
196
+ [shape]="capsule() ? 'capsule' : 'rounded'"
197
+ [radius]="radius()"
198
+ [variant]="variant() === 'glass-clear' ? 'clear' : 'regular'"
199
+ material="thin"
200
+ [interactive]="interactive()"
201
+ [class]="glassClass()"
202
+ >
197
203
  <ng-container [ngTemplateOutlet]="items" />
198
- </view>
204
+ </blur-view>
205
+ } @else {
206
+ <ng-container [ngTemplateOutlet]="items" />
199
207
  }
200
208
 
201
209
  <!-- One copy of the items, for whichever form the group takes: Angular
@@ -356,34 +364,35 @@ export class UiButtonGroup {
356
364
  selector: 'ui-button-group-item',
357
365
  imports: [LYNX_ELEMENTS],
358
366
  encapsulation: ViewEncapsulation.None,
367
+ // The accessibility attributes are attribute bindings: a property binding
368
+ // on a component's host is checked against the DOM schema, which has no
369
+ // `accessibility-label`.
370
+ host: {
371
+ '[class]': 'containerClass()',
372
+ '[style]': 'cornerStyle()',
373
+ '[attr.accessibility-element]': "accessibilityLabel() ? 'true' : null",
374
+ '[attr.accessibility-traits]': "accessibilityLabel() ? 'button' : null",
375
+ '[attr.accessibility-label]': 'accessibilityLabel() || null',
376
+ '(bindtouchstart)': 'onPressStart()',
377
+ '(bindtouchend)': 'onPressEnd()',
378
+ '(bindtouchcancel)': 'onPressCancel()',
379
+ '(bindmousedown)': 'onPressStart()',
380
+ '(bindmouseup)': 'onPressEnd()',
381
+ '(bindmouseleave)': 'onPressCancel()',
382
+ '(bindtap)': 'onTap()',
383
+ },
359
384
  template: `
360
- <view
361
- #container
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"
367
- (bindtouchstart)="onPressStart()"
368
- (bindtouchend)="onPressEnd()"
369
- (bindtouchcancel)="onPressCancel()"
370
- (bindmousedown)="onPressStart()"
371
- (bindmouseup)="onPressEnd()"
372
- (bindmouseleave)="onPressCancel()"
373
- (bindtap)="onTap()"
374
- >
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
- }
386
- </view>
385
+ @if (highlightClass(); as highlight) {
386
+ <view [class]="highlight" [style.border-radius.px]="radius()"></view>
387
+ }
388
+ <ng-content select="ui-icon" />
389
+ @if (showLabel()) {
390
+ <text
391
+ [attr.accessibility-traits]="accessibilityLabel() ? null : 'button'"
392
+ [class]="labelClass()"
393
+ ><ng-content
394
+ /></text>
395
+ }
387
396
  `,
388
397
  })
389
398
  export class UiButtonGroupItem {
@@ -402,7 +411,7 @@ export class UiButtonGroupItem {
402
411
 
403
412
  readonly pressed = output<void>();
404
413
 
405
- readonly containerRef = viewChild<ElementRef>('container');
414
+ readonly #host = inject(ElementRef).nativeElement;
406
415
  /** A `<ui-icon>` projected into the item, which sits before the label. */
407
416
  readonly projectedIcon = contentChild(iconType());
408
417
  #pressAnim?: AnimationHandle;
@@ -531,7 +540,7 @@ export class UiButtonGroupItem {
531
540
  if (!this.#scales()) return;
532
541
  // See UiButton: press feedback is an el.setStyle()-driven CSS transition.
533
542
  this.#pressAnim?.cancel();
534
- this.#pressAnim = pressDown(this.containerRef()?.nativeElement);
543
+ this.#pressAnim = pressDown(this.#host);
535
544
  }
536
545
 
537
546
  protected onPressEnd(): void {
@@ -562,6 +571,6 @@ export class UiButtonGroupItem {
562
571
  this.pressing.set(false);
563
572
  if (!this.#scales()) return;
564
573
  this.#pressAnim?.cancel();
565
- this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
574
+ this.#pressAnim = pressRelease(this.#host);
566
575
  }
567
576
  }
@@ -46,9 +46,19 @@ class CardHost {
46
46
  })
47
47
  class BarePressableHost {}
48
48
 
49
+ @Component({
50
+ imports: [UiCardContent, UiCardTitle, LYNX_ELEMENTS],
51
+ template: `
52
+ <ui-card-content class="pt-6"><text>Body</text></ui-card-content>
53
+ <ui-card-content class="p-2"><text>Tight</text></ui-card-content>
54
+ <ui-card-title class="mb-2">Title</ui-card-title>
55
+ `,
56
+ })
57
+ class StaticClassHost {}
58
+
49
59
  /**
50
- * The card's own view. Each component's host element renders as a view too,
51
- * so it's found by the card's class rather than by being the first.
60
+ * The card: its host, found by the card's class rather than by being the
61
+ * first view (the test host renders as a view too).
52
62
  */
53
63
  const cardView = (container: Element) => container.querySelector('.bg-card')!;
54
64
 
@@ -88,6 +98,29 @@ describe('UiCard', () => {
88
98
  expect(getByText('Desc').getAttribute('class')).toContain('italic');
89
99
  });
90
100
 
101
+ // Angular gives a component's host every static attribute written on it,
102
+ // so a part that also styled a view inside its host applied the class
103
+ // twice: double the padding (issue #181).
104
+ it('puts a class written on a part on that one element', async () => {
105
+ const { container, getByText } = await render(StaticClassHost);
106
+ expect(container.querySelectorAll('.pt-6')).toHaveLength(1);
107
+ expect(container.querySelectorAll('.mb-2')).toHaveLength(1);
108
+ // The title is a single <text>, with the class.
109
+ expect(getByText('Title').tagName.toLowerCase()).toBe('text');
110
+ expect(getByText('Title').getAttribute('class')).toContain('mb-2');
111
+ });
112
+
113
+ it("lets a part's class override its own", async () => {
114
+ const { getByText } = await render(StaticClassHost);
115
+ const tight = getByText('Tight').parentElement!;
116
+ expect(tight.getAttribute('class')).toContain('p-2');
117
+ expect(tight.getAttribute('class')).not.toContain('p-6');
118
+ const body = getByText('Body').parentElement!;
119
+ expect(body.getAttribute('class')).toContain('p-6');
120
+ expect(body.getAttribute('class')).toContain('pt-6');
121
+ expect(body.getAttribute('class')).not.toContain('pt-0');
122
+ });
123
+
91
124
  it("doesn't respond to presses unless it's pressable", async () => {
92
125
  const { container } = await render(CardHost);
93
126
  const view = cardView(container);
@@ -1,13 +1,13 @@
1
1
  import {
2
- type ElementRef,
3
2
  Component,
3
+ ElementRef,
4
4
  ViewEncapsulation,
5
5
  booleanAttribute,
6
6
  computed,
7
+ inject,
7
8
  input,
8
- viewChild,
9
9
  } from '@angular/core';
10
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
10
+ import { registerHostElement } from '@blotch/angular-lynx';
11
11
 
12
12
  import {
13
13
  type AnimationHandle,
@@ -17,25 +17,29 @@ import {
17
17
  } from '../../utils/animate';
18
18
  import { cn } from '../../utils/cn';
19
19
 
20
+ /**
21
+ * Each part is its host element, styled through `host`, rather than a view
22
+ * (or text) inside the host. Angular gives a component's host every static
23
+ * attribute the app writes on it, so `<ui-card-content class="pt-6">` puts
24
+ * `pt-6` on the host whatever the component does with its `class` input: with
25
+ * a styled view inside as well, the padding applied twice. On the host alone,
26
+ * a class also acts where the part sits in its parent's layout (`flex-1`,
27
+ * margins). The title and description are text, so their hosts are `<text>`
28
+ * elements (registerHostElement, below).
29
+ */
20
30
  @Component({
21
31
  selector: 'ui-card',
22
- standalone: true,
23
- imports: [LYNX_ELEMENTS],
24
32
  encapsulation: ViewEncapsulation.None,
25
- template: `
26
- <view
27
- #container
28
- [class]="containerClass()"
29
- (bindtouchstart)="onPressStart()"
30
- (bindtouchend)="onPressEnd()"
31
- (bindtouchcancel)="onPressCancel()"
32
- (bindmousedown)="onPressStart()"
33
- (bindmouseup)="onPressEnd()"
34
- (bindmouseleave)="onPressCancel()"
35
- >
36
- <ng-content />
37
- </view>
38
- `,
33
+ host: {
34
+ '[class]': 'containerClass()',
35
+ '(bindtouchstart)': 'onPressStart()',
36
+ '(bindtouchend)': 'onPressEnd()',
37
+ '(bindtouchcancel)': 'onPressCancel()',
38
+ '(bindmousedown)': 'onPressStart()',
39
+ '(bindmouseup)': 'onPressEnd()',
40
+ '(bindmouseleave)': 'onPressCancel()',
41
+ },
42
+ template: '<ng-content />',
39
43
  })
40
44
  export class UiCard {
41
45
  /**
@@ -44,7 +48,7 @@ export class UiCard {
44
48
  readonly pressable = input(false, { transform: booleanAttribute });
45
49
  readonly userClass = input<string>('', { alias: 'class' });
46
50
 
47
- readonly containerRef = viewChild<ElementRef>('container');
51
+ readonly #host = inject(ElementRef).nativeElement;
48
52
  #pressAnim?: AnimationHandle;
49
53
 
50
54
  protected readonly containerClass = computed(() =>
@@ -54,16 +58,13 @@ export class UiCard {
54
58
  protected onPressStart(): void {
55
59
  if (!this.pressable()) return;
56
60
  this.#pressAnim?.cancel();
57
- this.#pressAnim = pressDown(
58
- this.containerRef()?.nativeElement,
59
- SCALE.pressDownLight,
60
- );
61
+ this.#pressAnim = pressDown(this.#host, SCALE.pressDownLight);
61
62
  }
62
63
 
63
64
  protected onPressEnd(): void {
64
65
  if (!this.pressable()) return;
65
66
  this.#pressAnim?.cancel();
66
- this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
67
+ this.#pressAnim = pressRelease(this.#host);
67
68
  }
68
69
 
69
70
  protected onPressCancel(): void {
@@ -71,20 +72,15 @@ export class UiCard {
71
72
  // mid-press would leave the card stuck in its pressed-down state.
72
73
  if (!this.pressable()) return;
73
74
  this.#pressAnim?.cancel();
74
- this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
75
+ this.#pressAnim = pressRelease(this.#host);
75
76
  }
76
77
  }
77
78
 
78
79
  @Component({
79
80
  selector: 'ui-card-header',
80
- standalone: true,
81
- imports: [LYNX_ELEMENTS],
82
81
  encapsulation: ViewEncapsulation.None,
83
- template: `
84
- <view [class]="containerClass()">
85
- <ng-content />
86
- </view>
87
- `,
82
+ host: { '[class]': 'containerClass()' },
83
+ template: '<ng-content />',
88
84
  })
89
85
  export class UiCardHeader {
90
86
  readonly userClass = input<string>('', { alias: 'class' });
@@ -96,10 +92,9 @@ export class UiCardHeader {
96
92
 
97
93
  @Component({
98
94
  selector: 'ui-card-title',
99
- standalone: true,
100
- imports: [LYNX_ELEMENTS],
101
95
  encapsulation: ViewEncapsulation.None,
102
- template: `<text [class]="textClass()"><ng-content /></text>`,
96
+ host: { '[class]': 'textClass()' },
97
+ template: '<ng-content />',
103
98
  })
104
99
  export class UiCardTitle {
105
100
  readonly userClass = input<string>('', { alias: 'class' });
@@ -109,12 +104,13 @@ export class UiCardTitle {
109
104
  );
110
105
  }
111
106
 
107
+ registerHostElement(UiCardTitle, 'text');
108
+
112
109
  @Component({
113
110
  selector: 'ui-card-description',
114
- standalone: true,
115
- imports: [LYNX_ELEMENTS],
116
111
  encapsulation: ViewEncapsulation.None,
117
- template: `<text [class]="textClass()"><ng-content /></text>`,
112
+ host: { '[class]': 'textClass()' },
113
+ template: '<ng-content />',
118
114
  })
119
115
  export class UiCardDescription {
120
116
  readonly userClass = input<string>('', { alias: 'class' });
@@ -124,16 +120,13 @@ export class UiCardDescription {
124
120
  );
125
121
  }
126
122
 
123
+ registerHostElement(UiCardDescription, 'text');
124
+
127
125
  @Component({
128
126
  selector: 'ui-card-content',
129
- standalone: true,
130
- imports: [LYNX_ELEMENTS],
131
127
  encapsulation: ViewEncapsulation.None,
132
- template: `
133
- <view [class]="containerClass()">
134
- <ng-content />
135
- </view>
136
- `,
128
+ host: { '[class]': 'containerClass()' },
129
+ template: '<ng-content />',
137
130
  })
138
131
  export class UiCardContent {
139
132
  readonly userClass = input<string>('', { alias: 'class' });
@@ -145,14 +138,9 @@ export class UiCardContent {
145
138
 
146
139
  @Component({
147
140
  selector: 'ui-card-footer',
148
- standalone: true,
149
- imports: [LYNX_ELEMENTS],
150
141
  encapsulation: ViewEncapsulation.None,
151
- template: `
152
- <view [class]="containerClass()">
153
- <ng-content />
154
- </view>
155
- `,
142
+ host: { '[class]': 'containerClass()' },
143
+ template: '<ng-content />',
156
144
  })
157
145
  export class UiCardFooter {
158
146
  readonly userClass = input<string>('', { alias: 'class' });