@blotch/dolan 22.2.6 → 22.2.8

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 +59 -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 +134 -2
  44. package/dist/ui/components/icon/icon.ts +57 -18
  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
@@ -141,7 +141,13 @@ const iconSvg = (icon: IconName, color: string): string =>
141
141
  selector: 'ui-search-field',
142
142
  imports: [LYNX_ELEMENTS, NgTemplateOutlet, UiGlass, UiGlassGroup],
143
143
  encapsulation: ViewEncapsulation.None,
144
- host: { '[class]': 'hostClass()' },
144
+ // Angular gives a component's host every static attribute the app writes on
145
+ // it. The placeholder belongs to the <input>, so the host drops its copy: a
146
+ // second element with it is a second match for getByPlaceholderText.
147
+ host: {
148
+ '[class]': 'hostClass()',
149
+ '[attr.placeholder]': 'null',
150
+ },
145
151
  template: `
146
152
  <ng-template #field>
147
153
  @if (showsBack()) {
@@ -41,7 +41,9 @@ const renderSelect = async () => {
41
41
  ...result,
42
42
  host: result.componentRef.instance as SelectHost,
43
43
  overlay,
44
- trigger: container.querySelector('.border-input')!,
44
+ // The field is the host; the row inside it takes the taps.
45
+ field: container.querySelector('.border-input')!,
46
+ trigger: container.querySelector('.border-input')!.firstElementChild!,
45
47
  triggerText: () => container.querySelector('.border-input text')!,
46
48
  // An item's tappable row, found by its label in the sheet.
47
49
  item: (label: string) => within(overlay).getByText(label).parentElement!,
@@ -66,10 +68,35 @@ const waitForClose = (overlay: Element) =>
66
68
 
67
69
  describe('UiSelect', () => {
68
70
  it('shows the placeholder, muted, until a value is chosen', async () => {
69
- const { triggerText, trigger } = await renderSelect();
71
+ const { triggerText, field } = await renderSelect();
70
72
  expect(triggerText().textContent).toBe('Pick a fruit');
71
73
  expect(triggerText().classList).toContain('text-muted-foreground');
72
- expect(trigger.classList).toContain('w-48');
74
+ expect(field.classList).toContain('w-48');
75
+ });
76
+
77
+ // Static attributes land on a component's host too (issue #181): the
78
+ // width applies once, to the field, and the placeholder is only shown.
79
+ it('puts its class on one element, and its placeholder only in its text', async () => {
80
+ @Component({
81
+ imports: [UiSelect, UiSelectItem],
82
+ template: `
83
+ <ui-select placeholder="Pick a fruit" class="w-48">
84
+ <ui-select-item value="apple" label="Apple" class="font-bold" />
85
+ </ui-select>
86
+ `,
87
+ })
88
+ class StaticAttributesHost {}
89
+
90
+ const { container, getByText, queryAllByPlaceholderText } =
91
+ await render(StaticAttributesHost);
92
+ expect([...container.querySelectorAll('.w-48')]).toEqual([
93
+ container.querySelector('.border-input'),
94
+ ]);
95
+ expect(queryAllByPlaceholderText('Pick a fruit')).toEqual([]);
96
+ expect(getByText('Pick a fruit')).toBeTruthy();
97
+ expect([...container.querySelectorAll('.font-bold')]).toEqual([
98
+ getByText('Apple').parentElement,
99
+ ]);
73
100
  });
74
101
 
75
102
  it("shows the chosen item's label", async () => {
@@ -122,10 +149,10 @@ describe('UiSelect', () => {
122
149
  });
123
150
 
124
151
  it("dims and doesn't open while disabled", async () => {
125
- const { host, overlay, trigger, getByText } = await renderSelect();
152
+ const { host, overlay, field, trigger, getByText } = await renderSelect();
126
153
  host.disabled.set(true);
127
154
  await waitForUpdate();
128
- expect(trigger.classList).toContain('opacity-50');
155
+ expect(field.classList).toContain('opacity-50');
129
156
 
130
157
  fireEvent.tap(trigger);
131
158
  fireEvent.tap(getByText('Choose'));
@@ -25,17 +25,34 @@ const CHECK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" f
25
25
  */
26
26
  const selectItem = () => UiSelectItem;
27
27
 
28
+ /**
29
+ * The host is the field: its border, fill and height, and the app's class.
30
+ * Angular gives a component's host every static attribute the app writes on
31
+ * it, so a class on <ui-select> lands on the host whatever the component
32
+ * does: passed on to a field inside it as well, a margin applied twice. The
33
+ * trigger that takes the tap fills the field, beside the sheet rather than
34
+ * around it, so a tap in the sheet doesn't bubble to the trigger. The
35
+ * placeholder is shown in a text, so the host drops its copy: an element with
36
+ * the attribute is a match for getByPlaceholderText.
37
+ */
28
38
  @Component({
29
39
  selector: 'ui-select',
30
- standalone: true,
31
40
  imports: [LYNX_ELEMENTS, UiBottomSheet],
32
41
  encapsulation: ViewEncapsulation.None,
42
+ host: {
43
+ '[class]': 'hostClass()',
44
+ '[attr.placeholder]': 'null',
45
+ },
33
46
  // `sheetOpen` is bound both ways by hand rather than with `[(open)]`: for a
34
47
  // two-way binding Angular also compiles a fallback that assigns the field,
35
48
  // which never runs for a signal and so shows as an uncovered branch in an
36
49
  // app's coverage report.
37
50
  template: `
38
- <view [class]="triggerClass()" (bindtap)="toggle()">
51
+ <!-- The trigger fills the field: the row stretches it to its height. -->
52
+ <view
53
+ class="flex-1 flex-row items-center px-3 py-2 flex justify-between"
54
+ (bindtap)="toggle()"
55
+ >
39
56
  <text [class]="valueTextClass()">{{ displayText() }}</text>
40
57
  <svg [attr.content]="chevronSvg" style="width: 16px; height: 16px;" />
41
58
  </view>
@@ -73,9 +90,9 @@ export class UiSelect {
73
90
  return item?.label() ?? val;
74
91
  });
75
92
 
76
- protected readonly triggerClass = computed(() =>
93
+ protected readonly hostClass = computed(() =>
77
94
  cn(
78
- 'h-10 flex-row items-center rounded-md border border-input bg-background px-3 py-2 flex justify-between',
95
+ 'h-10 flex-row rounded-md border border-input bg-background flex',
79
96
  this.disabled() && 'opacity-50',
80
97
  this.userClass(),
81
98
  ),
@@ -110,18 +127,22 @@ export class UiSelect {
110
127
  }
111
128
  }
112
129
 
130
+ /**
131
+ * Each item is its host, styled through `host` (see UiSelect).
132
+ */
113
133
  @Component({
114
134
  selector: 'ui-select-item',
115
- standalone: true,
116
135
  imports: [LYNX_ELEMENTS],
117
136
  encapsulation: ViewEncapsulation.None,
137
+ host: {
138
+ '[class]': 'containerClass()',
139
+ '(bindtap)': 'onTap()',
140
+ },
118
141
  template: `
119
- <view [class]="containerClass()" (bindtap)="onTap()">
120
- <text [class]="labelClass()">{{ label() }}</text>
121
- @if (isSelected()) {
122
- <svg [attr.content]="checkSvg" style="width: 16px; height: 16px;" />
123
- }
124
- </view>
142
+ <text [class]="labelClass()">{{ label() }}</text>
143
+ @if (isSelected()) {
144
+ <svg [attr.content]="checkSvg" style="width: 16px; height: 16px;" />
145
+ }
125
146
  `,
126
147
  })
127
148
  export class UiSelectItem {
@@ -18,6 +18,9 @@ describe('UiSeparator', () => {
18
18
  const line = container.querySelector('.bg-border')!;
19
19
  expect(line.classList).toContain('h-px');
20
20
  expect(line.classList).toContain('my-2');
21
+ // One element: a static class lands on the host, so a view inside it
22
+ // would take it twice (issue #181).
23
+ expect(container.querySelectorAll('.my-2')).toHaveLength(1);
21
24
 
22
25
  (componentRef.instance as SeparatorHost).orientation.set('vertical');
23
26
  await waitForUpdate();
@@ -100,6 +100,24 @@ describe('UiSheet', () => {
100
100
  expect(container.querySelector('.pt-4')?.classList).toContain('flex-row');
101
101
  });
102
102
 
103
+ // Angular gives a component's host every static attribute written on it
104
+ // (issue #181), so each class also styled a host: the sheet's in the page,
105
+ // where it is written, and each part's around its own view or text.
106
+ it('puts each class on one element: the content, or the part', async () => {
107
+ const { container, getByText } = await renderSheet();
108
+ const content = container.querySelector('.px-6')!;
109
+ expect([...container.querySelectorAll('.gap-4')]).toEqual([content]);
110
+ // In the content: the bottom sheet draws views of its own with these.
111
+ expect(content.querySelectorAll('.items-center')).toHaveLength(1);
112
+ expect(content.querySelectorAll('.flex-row')).toHaveLength(1);
113
+ expect([...container.querySelectorAll('.text-xl')]).toEqual([
114
+ getByText('Title'),
115
+ ]);
116
+ expect([...container.querySelectorAll('.italic')]).toEqual([
117
+ getByText('Body'),
118
+ ]);
119
+ });
120
+
103
121
  it('opens with `open`, and reports a dismissal through it and `closed`', async () => {
104
122
  const { host, overlay, backdrop } = await renderSheet();
105
123
  expect(overlay.getAttribute('visible')).toBe('false');
@@ -7,7 +7,7 @@ import {
7
7
  output,
8
8
  viewChild,
9
9
  } from '@angular/core';
10
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
10
+ import { LYNX_ELEMENTS, registerHostElement } from '@blotch/angular-lynx';
11
11
 
12
12
  import {
13
13
  type BottomSheetVariant,
@@ -32,17 +32,25 @@ const bottomSheetType = () => UiBottomSheet;
32
32
  * variant and the detents, which pass straight through.
33
33
  *
34
34
  * The consumer's `class` and the built-in padding are applied to an inner
35
- * content wrapper (not the panel) — the panel chrome (rounded corners, border,
36
- * background) comes from the primitive.
35
+ * content wrapper (not the panel, nor the sheet's host) — the panel chrome
36
+ * (rounded corners, border, background) comes from the primitive.
37
37
  *
38
38
  * Mark the control that presents it with `[uiSheetSource]="sheet"`, and the
39
39
  * glass sheet grows out of it (see UiSheetSource).
40
40
  */
41
41
  @Component({
42
42
  selector: 'ui-sheet',
43
- standalone: true,
44
43
  imports: [LYNX_ELEMENTS, UiBottomSheet],
45
44
  encapsulation: ViewEncapsulation.None,
45
+ // The sheet's class is its content's, inside the overlay. Angular also
46
+ // gives the host every static attribute the app writes on <ui-sheet>, and
47
+ // the host sits in the page's layout where the sheet is written, so a
48
+ // `class="px-8"` padded the page there too. The host drops it. The parts
49
+ // below are their host elements for the same reason: a class on a part
50
+ // lands on its host anyway, so a view or text inside it styled with the
51
+ // same class applied it twice. The title and description are text, so
52
+ // their hosts are `<text>` elements.
53
+ host: { '[attr.class]': 'null' },
46
54
  // `open` is bound both ways by hand rather than with `[(open)]`: for a
47
55
  // two-way binding Angular also compiles a fallback that assigns the field,
48
56
  // which never runs for a signal and so shows as an uncovered branch in an
@@ -110,14 +118,9 @@ export class UiSheet implements SheetSourceTarget {
110
118
 
111
119
  @Component({
112
120
  selector: 'ui-sheet-header',
113
- standalone: true,
114
- imports: [LYNX_ELEMENTS],
115
121
  encapsulation: ViewEncapsulation.None,
116
- template: `
117
- <view [class]="containerClass()">
118
- <ng-content />
119
- </view>
120
- `,
122
+ host: { '[class]': 'containerClass()' },
123
+ template: '<ng-content />',
121
124
  })
122
125
  export class UiSheetHeader {
123
126
  readonly userClass = input<string>('', { alias: 'class' });
@@ -129,10 +132,9 @@ export class UiSheetHeader {
129
132
 
130
133
  @Component({
131
134
  selector: 'ui-sheet-title',
132
- standalone: true,
133
- imports: [LYNX_ELEMENTS],
134
135
  encapsulation: ViewEncapsulation.None,
135
- template: `<text [class]="textClass()"><ng-content /></text>`,
136
+ host: { '[class]': 'textClass()' },
137
+ template: '<ng-content />',
136
138
  })
137
139
  export class UiSheetTitle {
138
140
  readonly userClass = input<string>('', { alias: 'class' });
@@ -142,12 +144,13 @@ export class UiSheetTitle {
142
144
  );
143
145
  }
144
146
 
147
+ registerHostElement(UiSheetTitle, 'text');
148
+
145
149
  @Component({
146
150
  selector: 'ui-sheet-description',
147
- standalone: true,
148
- imports: [LYNX_ELEMENTS],
149
151
  encapsulation: ViewEncapsulation.None,
150
- template: `<text [class]="textClass()"><ng-content /></text>`,
152
+ host: { '[class]': 'textClass()' },
153
+ template: '<ng-content />',
151
154
  })
152
155
  export class UiSheetDescription {
153
156
  readonly userClass = input<string>('', { alias: 'class' });
@@ -157,16 +160,13 @@ export class UiSheetDescription {
157
160
  );
158
161
  }
159
162
 
163
+ registerHostElement(UiSheetDescription, 'text');
164
+
160
165
  @Component({
161
166
  selector: 'ui-sheet-footer',
162
- standalone: true,
163
- imports: [LYNX_ELEMENTS],
164
167
  encapsulation: ViewEncapsulation.None,
165
- template: `
166
- <view [class]="containerClass()">
167
- <ng-content />
168
- </view>
169
- `,
168
+ host: { '[class]': 'containerClass()' },
169
+ template: '<ng-content />',
170
170
  })
171
171
  export class UiSheetFooter {
172
172
  readonly userClass = input<string>('', { alias: 'class' });
@@ -18,6 +18,14 @@ describe('UiSkeleton', () => {
18
18
  expect(skeleton?.classList).toContain('w-32');
19
19
  });
20
20
 
21
+ // A class on a component lands on its host too (issue #181): the host is
22
+ // the placeholder, so its size is set once.
23
+ it('is one element, with the class written on it', async () => {
24
+ const { container } = await render(SkeletonHost);
25
+ expect(container.querySelectorAll('.h-4')).toHaveLength(1);
26
+ expect(container.querySelector('.h-4')?.classList).toContain('bg-muted');
27
+ });
28
+
21
29
  it('pulses', async () => {
22
30
  const animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
23
31
  const { container } = await render(SkeletonHost);
@@ -1,28 +1,33 @@
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
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
11
10
 
12
11
  import { pulse } from '../../utils/animate';
13
12
  import { cn } from '../../utils/cn';
14
13
 
14
+ /**
15
+ * The skeleton is its host, styled through `host`. Angular gives a
16
+ * component's host every static attribute the app writes on it, so a class on
17
+ * <ui-skeleton> lands on the host whatever the component does: with a styled
18
+ * view inside as well, a margin applied twice, and the placeholder's size had
19
+ * to be set on both.
20
+ */
15
21
  @Component({
16
22
  selector: 'ui-skeleton',
17
- standalone: true,
18
- imports: [LYNX_ELEMENTS],
19
23
  encapsulation: ViewEncapsulation.None,
20
- template: `<view #skeleton [class]="skeletonClass()" />`,
24
+ host: { '[class]': 'skeletonClass()' },
25
+ template: '',
21
26
  })
22
27
  export class UiSkeleton {
23
28
  readonly userClass = input<string>('', { alias: 'class' });
24
29
 
25
- readonly skeletonRef = viewChild.required<ElementRef>('skeleton');
30
+ readonly #host = inject(ElementRef).nativeElement;
26
31
 
27
32
  /**
28
33
  * Pulses the skeleton. A field rather than a constructor: Angular's test
@@ -30,7 +35,7 @@ export class UiSkeleton {
30
35
  * app's coverage report would show as uncovered.
31
36
  */
32
37
  protected readonly pulsing = effect(() => {
33
- pulse(this.skeletonRef().nativeElement);
38
+ pulse(this.#host);
34
39
  });
35
40
 
36
41
  protected readonly skeletonClass = computed(() =>
@@ -141,10 +141,22 @@ describe('UiSlider', () => {
141
141
  settings.reduceTransparency.set(false);
142
142
  });
143
143
 
144
+ // Static attributes land on a component's host too (issue #181): the
145
+ // margin applies once, and only the slider itself is named Volume.
146
+ it('puts its class and accessibility label on one element each', async () => {
147
+ const { container, slider, getAllByAccessibilityLabel } =
148
+ await renderSlider();
149
+ expect([...container.querySelectorAll('.mt-2')]).toEqual([
150
+ slider.parentElement,
151
+ ]);
152
+ expect(getAllByAccessibilityLabel('Volume')).toEqual([slider]);
153
+ });
154
+
144
155
  it('shows its value on a track in the theme colors', async () => {
145
156
  const { slider, track, fill, thumb } = await renderSlider();
146
157
 
147
- expect(slider.classList).toContain('mt-2');
158
+ expect(slider.parentElement!.classList).toContain('mt-2');
159
+ expect(slider.classList).toContain('w-full');
148
160
  expect(style(slider, 'height')).toBe('20px');
149
161
  expect(track.classList).toContain('bg-secondary');
150
162
  expect(fill.classList).toContain('bg-primary');
@@ -304,6 +304,19 @@ type Press = {
304
304
  selector: 'ui-slider',
305
305
  imports: [LYNX_ELEMENTS, UiGlass],
306
306
  encapsulation: ViewEncapsulation.None,
307
+ // The host holds the slider, with the app's class. Angular gives a
308
+ // component's host every static attribute the app writes on it, so a class
309
+ // on <ui-slider> lands on the host whatever the component does: passed on
310
+ // to the root as well, a margin or padding applied twice. The root fills
311
+ // the host, and stays a view of its own: Lynx reads `consume-slide-event` as
312
+ // an array and `accessibility-element` as a boolean, which only a property
313
+ // binding on a Lynx element passes (a host binding would make them
314
+ // strings). Its accessibility-label is the root's, so the host drops its
315
+ // copy: two elements named the same would each be read.
316
+ host: {
317
+ '[class]': 'hostClass()',
318
+ '[attr.accessibility-label]': 'null',
319
+ },
307
320
  // Only the root takes touches: everything inside it ignores them, so a
308
321
  // touch's `x` on iOS and Android is relative to the slider, wherever the
309
322
  // finger lands (Lynx gives `x` relative to the element touched).
@@ -497,9 +510,9 @@ export class UiSlider implements FormValueControl<number> {
497
510
  : `${Math.round(this.#fraction() * 100)}%`;
498
511
  });
499
512
 
500
- protected readonly rootClass = computed(() =>
501
- cn('w-full relative', this.userClass()),
502
- );
513
+ protected readonly hostClass = computed(() => cn(this.userClass()));
514
+
515
+ protected readonly rootClass = computed(() => cn('w-full relative'));
503
516
 
504
517
  protected readonly rootStyle = computed(() => ({
505
518
  height: px(this.#geometry().height),
@@ -17,12 +17,18 @@ class SpinnerHost {
17
17
  readonly className = signal('');
18
18
  }
19
19
 
20
+ @Component({
21
+ imports: [UiSpinner],
22
+ template: `<ui-spinner class="mx-2" />`,
23
+ })
24
+ class StaticClassHost {}
25
+
20
26
  const renderSpinner = async () => {
21
27
  const result = await render(SpinnerHost);
22
28
  return {
23
29
  ...result,
24
30
  host: result.componentRef.instance as SpinnerHost,
25
- // The spinner's own view: the one that holds the svg.
31
+ // The spinner: its host, which holds the svg.
26
32
  spinner: () => result.container.querySelector('svg')!.parentElement!,
27
33
  svgContent: () =>
28
34
  result.container.querySelector('svg')!.getAttribute('content'),
@@ -75,6 +81,15 @@ describe('UiSpinner', () => {
75
81
  expect(spinner().getAttribute('class')).toBe('ml-2');
76
82
  });
77
83
 
84
+ // A class on a component lands on its host too (issue #181): the host is
85
+ // the spinner, so the margin applies once.
86
+ it('puts a static class on one element, the spinner', async () => {
87
+ const { container } = await render(StaticClassHost);
88
+ expect([...container.querySelectorAll('.mx-2')]).toEqual([
89
+ container.querySelector('svg')!.parentElement,
90
+ ]);
91
+ });
92
+
78
93
  it('turns continuously', async () => {
79
94
  const animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
80
95
  const { spinner } = await renderSpinner();
@@ -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
 
@@ -20,15 +20,23 @@ const SIZE_MAP = { xs: 16, sm: 20, md: 24, lg: 32 } as const;
20
20
  selector: 'ui-spinner',
21
21
  imports: [LYNX_ELEMENTS],
22
22
  encapsulation: ViewEncapsulation.None,
23
+ // The host is the box that turns, sized and styled through `host`, with the
24
+ // drawing inside. Angular gives a component's host every static attribute
25
+ // the app writes on it, so a class on <ui-spinner> lands on the host
26
+ // whatever the component does: with a styled box inside as well, a margin
27
+ // applied twice.
28
+ //
23
29
  // SVG markup is passed via [attr.content] rather than as child elements.
24
30
  // Lynx's native <svg> element can't contain child nodes in the flat element
25
31
  // model — the renderer doesn't support nested SVG elements. Passing the full
26
32
  // SVG markup string as the `content` attribute is the Lynx-native way to
27
33
  // embed vector graphics.
34
+ host: {
35
+ '[class]': 'containerClass()',
36
+ '[style]': 'sizeStyle()',
37
+ },
28
38
  template: `
29
- <view #spinner [class]="containerClass()" [style]="sizeStyle()">
30
- <svg [attr.content]="svgContent()" style="width: 100%; height: 100%;" />
31
- </view>
39
+ <svg [attr.content]="svgContent()" style="width: 100%; height: 100%;" />
32
40
  `,
33
41
  })
34
42
  export class UiSpinner {
@@ -43,7 +51,7 @@ export class UiSpinner {
43
51
 
44
52
  readonly #resolveColor = injectIconColor();
45
53
 
46
- readonly spinnerRef = viewChild.required<ElementRef>('spinner');
54
+ readonly #host = inject(ElementRef).nativeElement;
47
55
 
48
56
  /**
49
57
  * Turns the spinner for as long as it's shown. A field rather than a
@@ -51,7 +59,7 @@ export class UiSpinner {
51
59
  * nothing calls, which an app's coverage report would show as uncovered.
52
60
  */
53
61
  protected readonly rotation = effect(() => {
54
- this.spinnerRef().nativeElement.animate(
62
+ this.#host.animate(
55
63
  [{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }],
56
64
  {
57
65
  duration: 1600,
@@ -119,10 +119,21 @@ describe('UiSwitch', () => {
119
119
  settings.reduceTransparency.set(false);
120
120
  });
121
121
 
122
- it('renders off, with the thumb at the left and the given class', async () => {
122
+ // Static attributes land on a component's host too (issue #181): the
123
+ // margin applies once, and only the switch itself is named Wi-Fi.
124
+ it('puts its class and accessibility label on one element each', async () => {
125
+ const { container, track, getAllByAccessibilityLabel } =
126
+ await renderSwitch();
127
+ expect([...container.querySelectorAll('.ml-2')]).toEqual([
128
+ track().parentElement,
129
+ ]);
130
+ expect(getAllByAccessibilityLabel('Wi-Fi')).toEqual([track()]);
131
+ });
132
+
133
+ it('renders off, with the thumb at the left, in a host with the given class', async () => {
123
134
  const { track, fill, thumb } = await renderSwitch();
124
135
  expect(fill().classList).toContain('bg-input');
125
- expect(track().classList).toContain('ml-2');
136
+ expect(track().parentElement!.classList).toContain('ml-2');
126
137
  expect(style(track(), 'width')).toBe('44px');
127
138
  expect(thumb().classList).toContain('bg-background');
128
139
  // Centered 12px in: 2px from the edge for a 20px thumb.
@@ -264,6 +264,19 @@ type Press = {
264
264
  selector: 'ui-switch',
265
265
  imports: [LYNX_ELEMENTS, UiGlass],
266
266
  encapsulation: ViewEncapsulation.None,
267
+ // The host holds the switch, with the app's class. Angular gives a
268
+ // component's host every static attribute the app writes on it, so a class
269
+ // on <ui-switch> lands on the host whatever the component does: passed on
270
+ // to the root as well, a margin applied twice. The root stays a view of its
271
+ // own: Lynx reads `consume-slide-event` as an array and
272
+ // `accessibility-element` as a boolean, which only a property binding on a
273
+ // Lynx element passes (a host binding would make them strings). Its
274
+ // accessibility-label is the root's, so the host drops its copy: two
275
+ // elements named the same would each be read.
276
+ host: {
277
+ '[class]': 'hostClass()',
278
+ '[attr.accessibility-label]': 'null',
279
+ },
267
280
  // Only the root takes touches: what's inside ignores them, so every touch
268
281
  // and its `x` belong to the switch.
269
282
  //
@@ -406,12 +419,13 @@ export class UiSwitch implements FormCheckboxControl {
406
419
  () => this.#systemDrawsGlass || !this.#settings.reduceTransparency(),
407
420
  );
408
421
 
422
+ protected readonly hostClass = computed(() => cn(this.userClass()));
423
+
409
424
  protected readonly rootClass = computed(() =>
410
425
  cn(
411
426
  'relative',
412
427
  this.disabled() &&
413
428
  (this.look() === 'material' ? 'opacity-[0.38]' : 'opacity-50'),
414
- this.userClass(),
415
429
  ),
416
430
  );
417
431
 
@@ -71,6 +71,16 @@ class TabBarHost {
71
71
  readonly items = viewChildren(UiTabBarItem);
72
72
  }
73
73
 
74
+ @Component({
75
+ imports: [UiTabBar, UiTabBarItem],
76
+ template: `
77
+ <ui-tab-bar class="mt-1">
78
+ <ui-tab-bar-item route="/" icon="home" label="Home" class="pt-4" />
79
+ </ui-tab-bar>
80
+ `,
81
+ })
82
+ class StaticClassHost {}
83
+
74
84
  /** The theme's light colors, as icon-theme.ts resolves them for an SVG. */
75
85
  const PRIMARY = 'rgba(24, 24, 27, 1)';
76
86
  const MUTED_FOREGROUND = 'rgba(113, 113, 122, 1)';
@@ -132,7 +142,9 @@ describe('UiTabBar', () => {
132
142
 
133
143
  it('lays the bar out as a row above the bottom safe area', async () => {
134
144
  const { container, componentRef } = await render(UiTabBar);
135
- const bar = container.querySelector('[accessibility-traits="tabbar"]')!;
145
+ // Rendered on its own, the bar's host is the root the test renders into.
146
+ const bar = container;
147
+ expect(bar.getAttribute('accessibility-traits')).toBe('tabbar');
136
148
  expect(bar.classList).toContain('flex-row');
137
149
  expect(bar.classList).toContain('pb-safe');
138
150
  expect(bar.classList).toContain('border-t');
@@ -267,14 +279,28 @@ describe('UiTabBar', () => {
267
279
  expect(scale()).toBe('scale(1)');
268
280
  });
269
281
 
270
- it("merges an item's class into its content, the given class winning", async () => {
282
+ it("merges an item's class with its own, the given class winning", async () => {
271
283
  const { host, tab } = await renderTabBar();
272
- const content = () => tab('Inbox').querySelector('view')!;
273
- expect(content().classList).toContain('pt-2');
284
+ expect(tab('Inbox').classList).toContain('pt-2');
274
285
 
275
286
  host.inboxClass.set('pt-3');
276
287
  await waitForUpdate();
277
- expect(content().classList).toContain('pt-3');
278
- expect(content().classList).not.toContain('pt-2');
288
+ expect(tab('Inbox').classList).toContain('pt-3');
289
+ expect(tab('Inbox').classList).not.toContain('pt-2');
290
+ });
291
+
292
+ // Angular gives a component's host every static attribute written on it,
293
+ // so a bar or item that also styled a view inside its host applied the
294
+ // class twice (issue #181).
295
+ it('puts a class written on the bar or an item on that one element', async () => {
296
+ const { container } = await render(StaticClassHost, {
297
+ providers: [provideRouter(routes)],
298
+ });
299
+ expect([...container.querySelectorAll('.mt-1')]).toEqual([
300
+ container.querySelector('[accessibility-traits="tabbar"]'),
301
+ ]);
302
+ expect([...container.querySelectorAll('.pt-4')]).toEqual([
303
+ container.querySelector('[accessibility-label="Home"]'),
304
+ ]);
279
305
  });
280
306
  });