@blotch/dolan 22.2.6 → 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,11 +1,11 @@
1
1
  import {
2
- type ElementRef,
3
2
  Component,
3
+ ElementRef,
4
4
  ViewEncapsulation,
5
5
  computed,
6
6
  effect,
7
+ inject,
7
8
  input,
8
- viewChild,
9
9
  } from '@angular/core';
10
10
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
11
11
  import { cva, type VariantProps } from 'class-variance-authority';
@@ -53,21 +53,30 @@ export type AlertVariant = NonNullable<
53
53
  VariantProps<typeof alertVariants>['variant']
54
54
  >;
55
55
 
56
+ /**
57
+ * The alert is its host, styled through `host`. Angular gives a component's
58
+ * host every static attribute the app writes on it, so a class on <ui-alert>
59
+ * lands on the host whatever the component does: with a styled view inside as
60
+ * well, the padding and border applied twice. Its title lands there too, and
61
+ * goes: it's shown in a <text>, and on the web preview a `title` attribute is
62
+ * a tooltip (and a second match for getByTitle).
63
+ */
56
64
  @Component({
57
65
  selector: 'ui-alert',
58
- standalone: true,
59
66
  imports: [LYNX_ELEMENTS],
60
67
  encapsulation: ViewEncapsulation.None,
68
+ host: {
69
+ '[class]': 'containerClass()',
70
+ '[attr.title]': 'null',
71
+ },
61
72
  template: `
62
- <view #container [class]="containerClass()">
63
- @if (title()) {
64
- <text [class]="titleClass()">{{ title() }}</text>
65
- }
66
- @if (description()) {
67
- <text [class]="descriptionClass()">{{ description() }}</text>
68
- }
69
- <ng-content />
70
- </view>
73
+ @if (title()) {
74
+ <text [class]="titleClass()">{{ title() }}</text>
75
+ }
76
+ @if (description()) {
77
+ <text [class]="descriptionClass()">{{ description() }}</text>
78
+ }
79
+ <ng-content />
71
80
  `,
72
81
  })
73
82
  export class UiAlert {
@@ -76,7 +85,7 @@ export class UiAlert {
76
85
  readonly description = input<string>('');
77
86
  readonly userClass = input<string>('', { alias: 'class' });
78
87
 
79
- readonly containerRef = viewChild.required<ElementRef>('container');
88
+ readonly #host = inject(ElementRef).nativeElement;
80
89
 
81
90
  /**
82
91
  * Reveals the alert with a subtle entrance on first render. A field rather
@@ -85,7 +94,7 @@ export class UiAlert {
85
94
  * as uncovered.
86
95
  */
87
96
  protected readonly entrance = effect(() => {
88
- revealIn(this.containerRef().nativeElement, { fromY: 4 });
97
+ revealIn(this.#host, { fromY: 4 });
89
98
  });
90
99
 
91
100
  protected readonly containerClass = computed(() =>
@@ -139,7 +139,6 @@ describe('UiAlertDialog', () => {
139
139
  expect(getByText('Sure?').classList).toContain('text-xl');
140
140
  expect(getByText('Cannot undo').classList).toContain('text-sm');
141
141
  expect(getByText('Cannot undo').classList).toContain('italic');
142
- // Each part's own view, not its host element, which keeps the bare class.
143
142
  const header = container.querySelector('.flex-col.gap-2');
144
143
  expect(header?.classList).not.toContain('gap-1.5');
145
144
  const footer = container.querySelector('.pt-4');
@@ -147,6 +146,23 @@ describe('UiAlertDialog', () => {
147
146
  expect(footer?.classList).not.toContain('justify-end');
148
147
  });
149
148
 
149
+ // Angular gives a component's host every static attribute written on it
150
+ // (issue #181), so each class also styled a host: the dialog's in the page,
151
+ // where it is written, and each part's around its own view or text.
152
+ it('puts each class on one element: the panel, or the part', async () => {
153
+ const { container, getByText, panel } = await renderAlertDialog();
154
+ expect([...container.querySelectorAll('.max-w-sm')]).toEqual([panel()]);
155
+ // The header's: the footer has a gap-2 of its own.
156
+ expect(container.querySelectorAll('.flex-col.gap-2')).toHaveLength(1);
157
+ expect(container.querySelectorAll('.justify-start')).toHaveLength(1);
158
+ expect([...container.querySelectorAll('.text-xl')]).toEqual([
159
+ getByText('Sure?'),
160
+ ]);
161
+ expect([...container.querySelectorAll('.italic')]).toEqual([
162
+ getByText('Cannot undo'),
163
+ ]);
164
+ });
165
+
150
166
  it('stays hidden until it is opened', async () => {
151
167
  const { host, overlay, panel } = await renderAlertDialog();
152
168
  await after(CLOSED);
@@ -793,7 +809,6 @@ it("draws an alert dialog's parts in the default look outside one", async () =>
793
809
  expect(getByText('Alone').classList).toContain('text-lg');
794
810
  expect(getByText('Outside a dialog').classList).toContain('text-sm');
795
811
  expect(getByText('Footer').parentElement!.classList).toContain('pt-4');
796
- expect(getByText('Alone').parentElement!.parentElement!.classList).toContain(
797
- 'gap-1.5',
798
- );
812
+ // The title is the header's own child: the header is its host.
813
+ expect(getByText('Alone').parentElement!.classList).toContain('gap-1.5');
799
814
  });
@@ -15,7 +15,11 @@ import {
15
15
  signal,
16
16
  viewChild,
17
17
  } from '@angular/core';
18
- import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
18
+ import {
19
+ LYNX_ELEMENTS,
20
+ LynxAccessibilitySettings,
21
+ registerHostElement,
22
+ } from '@blotch/angular-lynx';
19
23
 
20
24
  import {
21
25
  type AnimationHandle,
@@ -173,6 +177,14 @@ const METRICS: Readonly<Record<GlassDesign, AlertMetrics>> = {
173
177
  selector: 'ui-alert-dialog',
174
178
  imports: [LYNX_ELEMENTS, NgTemplateOutlet, UiGlass],
175
179
  encapsulation: ViewEncapsulation.None,
180
+ // The dialog's class is its panel's, inside the overlay. Angular also gives
181
+ // the host every static attribute the app writes on <ui-alert-dialog>, and
182
+ // the host sits in the page's layout where the dialog is written, so a
183
+ // `class="p-8"` padded the page there too. The host drops it. The parts
184
+ // below are their host elements for the same reason: a class on a part
185
+ // lands on its host anyway, so a view or text inside it styled with the
186
+ // same class applied it twice.
187
+ host: { '[attr.class]': 'null' },
176
188
  template: `
177
189
  <overlay [attr.visible]="overlayVisible()" [style]="overlayStyle()">
178
190
  <!-- The overlay sizes only its first child, to the screen. With the
@@ -473,13 +485,9 @@ const injectAlertLook = () => {
473
485
 
474
486
  @Component({
475
487
  selector: 'ui-alert-dialog-header',
476
- imports: [LYNX_ELEMENTS],
477
488
  encapsulation: ViewEncapsulation.None,
478
- template: `
479
- <view [class]="containerClass()">
480
- <ng-content />
481
- </view>
482
- `,
489
+ host: { '[class]': 'containerClass()' },
490
+ template: '<ng-content />',
483
491
  })
484
492
  export class UiAlertDialogHeader {
485
493
  readonly userClass = input<string>('', { alias: 'class' });
@@ -507,9 +515,9 @@ export class UiAlertDialogHeader {
507
515
 
508
516
  @Component({
509
517
  selector: 'ui-alert-dialog-title',
510
- imports: [LYNX_ELEMENTS],
511
518
  encapsulation: ViewEncapsulation.None,
512
- template: `<text [class]="textClass()"><ng-content /></text>`,
519
+ host: { '[class]': 'textClass()' },
520
+ template: '<ng-content />',
513
521
  })
514
522
  export class UiAlertDialogTitle {
515
523
  readonly userClass = input<string>('', { alias: 'class' });
@@ -534,11 +542,13 @@ export class UiAlertDialogTitle {
534
542
  );
535
543
  }
536
544
 
545
+ registerHostElement(UiAlertDialogTitle, 'text');
546
+
537
547
  @Component({
538
548
  selector: 'ui-alert-dialog-description',
539
- imports: [LYNX_ELEMENTS],
540
549
  encapsulation: ViewEncapsulation.None,
541
- template: `<text [class]="textClass()"><ng-content /></text>`,
550
+ host: { '[class]': 'textClass()' },
551
+ template: '<ng-content />',
542
552
  })
543
553
  export class UiAlertDialogDescription {
544
554
  readonly userClass = input<string>('', { alias: 'class' });
@@ -562,6 +572,8 @@ export class UiAlertDialogDescription {
562
572
  );
563
573
  }
564
574
 
575
+ registerHostElement(UiAlertDialogDescription, 'text');
576
+
565
577
  /**
566
578
  * `UiAlertDialogAction`, for `contentChildren()`. A call rather than the
567
579
  * class itself: the query's metadata keeps an arrow function of its own for
@@ -574,16 +586,15 @@ const actionType = () => UiAlertDialogAction;
574
586
  selector: 'ui-alert-dialog-footer',
575
587
  imports: [LYNX_ELEMENTS],
576
588
  encapsulation: ViewEncapsulation.None,
589
+ host: { '[class]': 'containerClass()' },
577
590
  template: `
578
- <view [class]="containerClass()">
579
- @if (separated()) {
580
- <!-- The hairline over the buttons of Apple's alerts before iOS 26. -->
581
- <view
582
- class="h-[0.5px] w-full bg-glass-foreground opacity-20 absolute left-0 top-0"
583
- ></view>
584
- }
585
- <ng-content />
586
- </view>
591
+ @if (separated()) {
592
+ <!-- The hairline over the buttons of Apple's alerts before iOS 26. -->
593
+ <view
594
+ class="h-[0.5px] w-full bg-glass-foreground opacity-20 absolute left-0 top-0"
595
+ ></view>
596
+ }
597
+ <ng-content />
587
598
  `,
588
599
  })
589
600
  export class UiAlertDialogFooter {
@@ -1,10 +1,46 @@
1
+ import { Component } from '@angular/core';
1
2
  import { render } from '@blotch/angular-lynx-testing-library';
3
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
2
4
  import { describe, expect, it } from 'vitest';
3
5
  import { UiAspectRatio } from './aspect-ratio';
4
6
 
7
+ @Component({
8
+ imports: [UiAspectRatio, LYNX_ELEMENTS],
9
+ template: `
10
+ <ui-aspect-ratio [ratio]="16 / 9" class="rounded-md">
11
+ <text>Media</text>
12
+ </ui-aspect-ratio>
13
+ `,
14
+ })
15
+ class AspectRatioHost {}
16
+
17
+ @Component({
18
+ imports: [UiAspectRatio],
19
+ template: `<ui-aspect-ratio />`,
20
+ })
21
+ class DefaultHost {}
22
+
23
+ /**
24
+ * The ratio the box's inline style holds. jsdom writes `aspect-ratio: 1` back
25
+ * as `1` or, in newer versions (an app's), as `1 / 1`.
26
+ */
27
+ const ratioOf = (box: HTMLElement) =>
28
+ parseFloat(box.style.getPropertyValue('aspect-ratio'));
29
+
5
30
  describe('UiAspectRatio', () => {
6
31
  it('renders with the default 1:1 ratio', async () => {
7
- const { container } = await render(UiAspectRatio);
8
- expect(container).toBeTruthy();
32
+ const { container } = await render(DefaultHost);
33
+ const box = container.querySelector('.overflow-hidden') as HTMLElement;
34
+ expect(ratioOf(box)).toBe(1);
35
+ });
36
+
37
+ // A class on a component lands on its host too (issue #181): the host is
38
+ // the box, so the class styles one element, the one with the ratio.
39
+ it('is one box, with the ratio and the class written on it', async () => {
40
+ const { container, getByText } = await render(AspectRatioHost);
41
+ const box = getByText('Media').parentElement as HTMLElement;
42
+ expect([...container.querySelectorAll('.rounded-md')]).toEqual([box]);
43
+ expect(box.classList).toContain('overflow-hidden');
44
+ expect(ratioOf(box)).toBe(16 / 9);
9
45
  });
10
46
  });
@@ -1,18 +1,21 @@
1
1
  import { Component, ViewEncapsulation, computed, input } from '@angular/core';
2
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
3
2
 
4
3
  import { cn } from '../../utils/cn';
5
4
 
5
+ /**
6
+ * The box is the host, styled through `host`. Angular gives a component's
7
+ * host every static attribute the app writes on it, so a class on
8
+ * <ui-aspect-ratio> lands on the host whatever the component does: with a
9
+ * styled view inside as well, a margin or padding applied twice.
10
+ */
6
11
  @Component({
7
12
  selector: 'ui-aspect-ratio',
8
- standalone: true,
9
- imports: [LYNX_ELEMENTS],
10
13
  encapsulation: ViewEncapsulation.None,
11
- template: `
12
- <view [class]="containerClass()" [style]="containerStyle()">
13
- <ng-content />
14
- </view>
15
- `,
14
+ host: {
15
+ '[class]': 'containerClass()',
16
+ '[style]': 'containerStyle()',
17
+ },
18
+ template: '<ng-content />',
16
19
  })
17
20
  export class UiAspectRatio {
18
21
  readonly ratio = input(1);
@@ -41,6 +41,13 @@ describe('UiAvatar', () => {
41
41
  expect(avatar().classList).toContain('border');
42
42
  });
43
43
 
44
+ // A class on a component lands on its host too (issue #181): the host is
45
+ // the circle, so the border is drawn once.
46
+ it('puts its class on one element, the circle', async () => {
47
+ const { container, avatar } = await renderAvatar();
48
+ expect([...container.querySelectorAll('.border')]).toEqual([avatar()]);
49
+ });
50
+
44
51
  it('covers the image with a placeholder while it loads', async () => {
45
52
  const { image, skeleton, initials } = await renderAvatar(
46
53
  'https://example.com/avatar.png',
@@ -10,42 +10,47 @@ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
10
10
  import { UiSkeleton } from '../skeleton/skeleton';
11
11
  import { cn } from '../../utils/cn';
12
12
 
13
+ /**
14
+ * The avatar's circle is its host, styled through `host`, with the image or
15
+ * initials inside. Angular gives a component's host every static attribute the
16
+ * app writes on it, so a class on <ui-avatar> lands on the host whatever the
17
+ * component does: with a styled circle inside as well, a margin or border
18
+ * applied twice.
19
+ */
13
20
  @Component({
14
21
  selector: 'ui-avatar',
15
- standalone: true,
16
22
  imports: [LYNX_ELEMENTS, UiSkeleton],
17
23
  encapsulation: ViewEncapsulation.None,
24
+ host: { '[class]': 'containerClass()' },
18
25
  template: `
19
- <view [class]="containerClass()">
20
- @if (src() && !errored()) {
21
- <!-- The <image> stays mounted whenever src is set so its bindload /
22
- binderror events can fire — an @if-swapped-out image would never
23
- start loading, so the load event that reveals it would never come.
24
- Native Lynx <image> renders nothing until loaded (there is no
25
- broken-image glyph like the web), so while loading the box is
26
- simply empty; the skeleton below sits on top of it as a placeholder.
27
- onImageLoad() flips the loaded state, removing the skeleton to
28
- reveal the image underneath. -->
29
- <image
30
- [attr.src]="src()"
31
- [class]="imageClass()"
32
- (bindload)="onImageLoad()"
33
- (binderror)="onImageError()"
34
- />
35
- <!-- Overlay placeholder shown only while the image is loading. It paints
36
- above the <image> because it comes later in source order (Lynx paint
37
- order follows the DOM, so no z-index is needed). -->
38
- @if (!loaded()) {
39
- <ui-skeleton [class]="skeletonClass()" />
40
- }
41
- } @else {
42
- <!-- Shared by the no-src case and the load-error case: initials if a
43
- fallback was provided, otherwise a plain muted circle. -->
44
- <view [class]="fallbackClass()">
45
- <text [class]="initialsClass()">{{ fallback() }}</text>
46
- </view>
26
+ @if (src() && !errored()) {
27
+ <!-- The <image> stays mounted whenever src is set so its bindload /
28
+ binderror events can fire — an @if-swapped-out image would never
29
+ start loading, so the load event that reveals it would never come.
30
+ Native Lynx <image> renders nothing until loaded (there is no
31
+ broken-image glyph like the web), so while loading the box is
32
+ simply empty; the skeleton below sits on top of it as a placeholder.
33
+ onImageLoad() flips the loaded state, removing the skeleton to
34
+ reveal the image underneath. -->
35
+ <image
36
+ [attr.src]="src()"
37
+ [class]="imageClass()"
38
+ (bindload)="onImageLoad()"
39
+ (binderror)="onImageError()"
40
+ />
41
+ <!-- Overlay placeholder shown only while the image is loading. It paints
42
+ above the <image> because it comes later in source order (Lynx paint
43
+ order follows the DOM, so no z-index is needed). -->
44
+ @if (!loaded()) {
45
+ <ui-skeleton [class]="skeletonClass()" />
47
46
  }
48
- </view>
47
+ } @else {
48
+ <!-- Shared by the no-src case and the load-error case: initials if a
49
+ fallback was provided, otherwise a plain muted circle. -->
50
+ <view [class]="fallbackClass()">
51
+ <text [class]="initialsClass()">{{ fallback() }}</text>
52
+ </view>
53
+ }
49
54
  `,
50
55
  })
51
56
  export class UiAvatar {
@@ -53,6 +53,15 @@ describe('UiBadge', () => {
53
53
  expect(label.parentElement?.classList).toContain('border-border');
54
54
  });
55
55
 
56
+ // A class on a component lands on its host too (issue #181): the host is
57
+ // the pill, so the margin applies once.
58
+ it('puts its class on one element, the pill', async () => {
59
+ const { container, getByText } = await render(BadgeHost);
60
+ expect([...container.querySelectorAll('.ml-1')]).toEqual([
61
+ getByText('Status').parentElement,
62
+ ]);
63
+ });
64
+
56
65
  it('pops in on first render', async () => {
57
66
  const animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
58
67
  const { getByText } = await render(BadgeHost);
@@ -1,12 +1,12 @@
1
1
  import {
2
- type ElementRef,
3
2
  Component,
3
+ ElementRef,
4
4
  ViewEncapsulation,
5
5
  booleanAttribute,
6
6
  computed,
7
7
  effect,
8
+ inject,
8
9
  input,
9
- viewChild,
10
10
  } from '@angular/core';
11
11
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
12
12
  import { cva, type VariantProps } from 'class-variance-authority';
@@ -53,16 +53,18 @@ export type BadgeVariant = NonNullable<
53
53
  VariantProps<typeof badgeVariants>['variant']
54
54
  >;
55
55
 
56
+ /**
57
+ * The badge's pill is its host, styled through `host`, with its label inside.
58
+ * Angular gives a component's host every static attribute the app writes on
59
+ * it, so a class on <ui-badge> lands on the host whatever the component does:
60
+ * with a styled pill inside as well, a margin applied twice.
61
+ */
56
62
  @Component({
57
63
  selector: 'ui-badge',
58
- standalone: true,
59
64
  imports: [LYNX_ELEMENTS],
60
65
  encapsulation: ViewEncapsulation.None,
61
- template: `
62
- <view #container [class]="containerClass()">
63
- <text [class]="labelClass()"><ng-content /></text>
64
- </view>
65
- `,
66
+ host: { '[class]': 'containerClass()' },
67
+ template: `<text [class]="labelClass()"><ng-content /></text>`,
66
68
  })
67
69
  export class UiBadge {
68
70
  readonly variant = input<BadgeVariant>('default');
@@ -72,7 +74,7 @@ export class UiBadge {
72
74
  readonly animated = input(true, { transform: booleanAttribute });
73
75
  readonly userClass = input<string>('', { alias: 'class' });
74
76
 
75
- readonly containerRef = viewChild.required<ElementRef>('container');
77
+ readonly #host = inject(ElementRef).nativeElement;
76
78
 
77
79
  /**
78
80
  * Pops the badge in on first render, unless `animated` is off. A field
@@ -82,7 +84,7 @@ export class UiBadge {
82
84
  */
83
85
  protected readonly entrance = effect(() => {
84
86
  if (this.animated()) {
85
- popIn(this.containerRef().nativeElement, { duration: 200 });
87
+ popIn(this.#host, { duration: 200 });
86
88
  }
87
89
  });
88
90
 
@@ -132,6 +132,13 @@ describe('UiBottomSheet', () => {
132
132
  expect(panel.classList).toContain('bg-background');
133
133
  });
134
134
 
135
+ // Angular gives a component's host every static attribute written on it
136
+ // (issue #181): the class also styled the sheet's host, in the page.
137
+ it('puts its class on the panel alone', async () => {
138
+ const { container, panel } = await renderSheet();
139
+ expect([...container.querySelectorAll('.max-h-96')]).toEqual([panel]);
140
+ });
141
+
135
142
  it('fades the backdrop in and slides the panel up when opened', async () => {
136
143
  const { host, backdrop, panel } = await renderSheet();
137
144
  await openSheet(host);
@@ -263,6 +263,11 @@ const layoutWidth = (event: unknown): number =>
263
263
  selector: 'ui-bottom-sheet',
264
264
  imports: [LYNX_ELEMENTS, UiGlass],
265
265
  encapsulation: ViewEncapsulation.None,
266
+ // The sheet's class is its panel's, inside the overlay. Angular also gives
267
+ // the host every static attribute the app writes on <ui-bottom-sheet>, and
268
+ // the host sits in the page's layout where the sheet is written, so a
269
+ // `class="px-8"` padded the page there too. The host drops it.
270
+ host: { '[attr.class]': 'null' },
266
271
  template: `
267
272
  <!-- Android's back button closes the sheet: while an overlay shows, it
268
273
  takes the press and fires requestclose. -->
@@ -77,10 +77,10 @@ const renderButton = async (
77
77
  ...result,
78
78
  host,
79
79
  /**
80
- * The button's own box, the first child of its host: it takes the
81
- * presses, and holds the glass on a glass variant.
80
+ * The button's box, its host: it takes the presses, and holds the glass
81
+ * on a glass variant.
82
82
  */
83
- button: () => result.container.firstElementChild!.firstElementChild!,
83
+ button: () => result.container.firstElementChild!,
84
84
  glass: () => result.container.querySelector('blur-view')!,
85
85
  spinner: () => result.container.querySelector('svg'),
86
86
  icon: () => result.container.querySelector('svg')!,
@@ -92,6 +92,16 @@ const renderButton = async (
92
92
  };
93
93
  };
94
94
 
95
+ @Component({
96
+ imports: [UiButton, UiIcon],
97
+ template: `
98
+ <ui-button size="icon" accessibility-label="Search" class="mt-2">
99
+ <ui-icon name="search" />
100
+ </ui-button>
101
+ `,
102
+ })
103
+ class StaticAttributesHost {}
104
+
95
105
  const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
96
106
  const IOS_18 = { platform: 'iOS', osVersion: '18.5' };
97
107
  const ANDROID = { platform: 'Android', osVersion: '15' };
@@ -118,6 +128,22 @@ afterEach(() => {
118
128
  });
119
129
 
120
130
  describe('UiButton', () => {
131
+ // Angular gives a component's host every static attribute written on it
132
+ // (issue #181): the class styled a box inside the host as well, and the
133
+ // label named two elements.
134
+ it('puts its class and accessibility label on one element, the button', async () => {
135
+ vi.stubGlobal('SystemInfo', undefined);
136
+ const { container, getAllByAccessibilityLabel } = await render(
137
+ StaticAttributesHost,
138
+ {
139
+ providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
140
+ },
141
+ );
142
+ const button = container.firstElementChild!;
143
+ expect([...container.querySelectorAll('.mt-2')]).toEqual([button]);
144
+ expect(getAllByAccessibilityLabel('Search')).toEqual([button]);
145
+ });
146
+
121
147
  it('renders its label with the classes for its variant and size', async () => {
122
148
  const { button, getByText } = await renderButton();
123
149
  expect(button().classList).toContain('bg-primary');