@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
@@ -16,16 +16,18 @@ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
16
16
  import { type AnimationHandle, popIn, popOut } from '../../utils/animate';
17
17
  import { cn } from '../../utils/cn';
18
18
 
19
+ /**
20
+ * The group and each item are their host elements, styled through `host`.
21
+ * Angular gives a component's host every static attribute the app writes on
22
+ * it, so a class on <ui-radio-group> or <ui-radio-group-item> lands on the
23
+ * host whatever the component does: with a styled view inside as well, a gap
24
+ * or padding applied twice.
25
+ */
19
26
  @Component({
20
27
  selector: 'ui-radio-group',
21
- standalone: true,
22
- imports: [LYNX_ELEMENTS],
23
28
  encapsulation: ViewEncapsulation.None,
24
- template: `
25
- <view [class]="containerClass()">
26
- <ng-content />
27
- </view>
28
- `,
29
+ host: { '[class]': 'containerClass()' },
30
+ template: '<ng-content />',
29
31
  })
30
32
  export class UiRadioGroup {
31
33
  readonly value = model<string>('');
@@ -47,22 +49,23 @@ export class UiRadioGroup {
47
49
 
48
50
  @Component({
49
51
  selector: 'ui-radio-group-item',
50
- standalone: true,
51
52
  imports: [LYNX_ELEMENTS],
52
53
  encapsulation: ViewEncapsulation.None,
54
+ host: {
55
+ '[class]': 'itemClass()',
56
+ '(bindtap)': 'select()',
57
+ },
53
58
  template: `
54
- <view [class]="itemClass()" (bindtap)="select()">
55
- <view [class]="circleClass()">
56
- <view
57
- #dot
58
- [class]="dotClass()"
59
- [style]="
60
- isSelected() ? 'opacity: 1;' : 'opacity: 0; transform: scale(0.6);'
61
- "
62
- />
63
- </view>
64
- <text [class]="labelClass()"><ng-content /></text>
59
+ <view [class]="circleClass()">
60
+ <view
61
+ #dot
62
+ [class]="dotClass()"
63
+ [style]="
64
+ isSelected() ? 'opacity: 1;' : 'opacity: 0; transform: scale(0.6);'
65
+ "
66
+ />
65
67
  </view>
68
+ <text [class]="labelClass()"><ng-content /></text>
66
69
  `,
67
70
  })
68
71
  export class UiRadioGroupItem {
@@ -42,6 +42,8 @@ const renderScrollArea = async () => {
42
42
  ...result,
43
43
  host: result.componentRef.instance as ScrollAreaHost,
44
44
  scrollView: () => result.container.querySelector('scroll-view')!,
45
+ /** The scroll area's box, its host, which the scroll view fills. */
46
+ box: () => result.container.querySelector('scroll-view')!.parentElement!,
45
47
  /** The view inside the scroll view that lays out the content. */
46
48
  content: () => result.getByText('Body').parentElement!,
47
49
  };
@@ -61,13 +63,23 @@ const watchAttributes = () => {
61
63
 
62
64
  describe('UiScrollArea', () => {
63
65
  it('renders projected content in a vertical scroll view', async () => {
64
- const { scrollView, content } = await renderScrollArea();
66
+ const { scrollView, box, content } = await renderScrollArea();
65
67
  expect(scrollView().getAttribute('scroll-orientation')).toBe('vertical');
66
- expect(scrollView().classList).toContain('bg-card');
68
+ expect(scrollView().classList).toContain('grow');
69
+ expect(box().classList).toContain('w-full');
70
+ expect(box().classList).toContain('bg-card');
67
71
  expect(content().classList).toContain('flex-col');
68
72
  expect(content().classList).toContain('gap-2');
69
73
  });
70
74
 
75
+ // Angular gives a component's host every static attribute written on it,
76
+ // so the scroll view taking the class as well applied it twice (issue
77
+ // #181).
78
+ it('puts its class on one element, its box', async () => {
79
+ const { container, box } = await renderScrollArea();
80
+ expect([...container.querySelectorAll('.bg-card')]).toEqual([box()]);
81
+ });
82
+
71
83
  it('gives the scroll view an explicit height, which it needs to scroll', async () => {
72
84
  const { host, scrollView } = await renderScrollArea();
73
85
  expect(scrollView().getAttribute('style') ?? '').not.toContain('height');
@@ -19,6 +19,18 @@ import { cn } from '../../utils/cn';
19
19
  // removed the attribute when false, and a missing attribute means Lynx's
20
20
  // default: on, for `bounces` and `scroll-bar-enable`. So neither could be
21
21
  // turned off. (`[attr.x]` would send the string "false" instead.)
22
+ //
23
+ // The host is the scroll area's box, and the <scroll-view> fills it.
24
+ // Angular gives a component's host every static attribute the app writes
25
+ // on it, so a class on <ui-scroll-area> lands on the host whatever the
26
+ // component does with its `class` input: on the <scroll-view> as well, a
27
+ // margin or padding applied twice. Padding the box insets the scroll view;
28
+ // `contentClass` pads what scrolls. `grow` fills a box with a height of its
29
+ // own (`h-64`, `flex-1`), and a box without one takes the scroll view's,
30
+ // which `height` sets: the scroll view keeps its own height as its flex
31
+ // basis. Not `h-full`: a percentage height doesn't reliably resolve on a
32
+ // flex item in Lynx.
33
+ host: { '[class]': 'hostClass()' },
22
34
  template: `
23
35
  <scroll-view
24
36
  [attr.scroll-orientation]="orientation()"
@@ -27,7 +39,7 @@ import { cn } from '../../utils/cn';
27
39
  [enable-nested-scroll]="nestedScroll()"
28
40
  [attr.upper-threshold]="upperThreshold()"
29
41
  [attr.lower-threshold]="lowerThreshold()"
30
- [class]="scrollClass()"
42
+ class="grow"
31
43
  [style]="scrollStyle()"
32
44
  (bindscrolltoupper)="scrolledToStart.emit()"
33
45
  (bindscrolltolower)="scrolledToEnd.emit()"
@@ -55,8 +67,8 @@ export class UiScrollArea {
55
67
  readonly scrolledToStart = output<void>();
56
68
  readonly scrolledToEnd = output<void>();
57
69
 
58
- protected readonly scrollClass = computed(() =>
59
- cn('w-full', this.userClass()),
70
+ protected readonly hostClass = computed(() =>
71
+ cn('w-full flex-col flex', this.userClass()),
60
72
  );
61
73
 
62
74
  // Explicit height is required for scroll-view to scroll in Lynx
@@ -41,6 +41,12 @@ class FieldHost {
41
41
  readonly field = viewChild.required(UiSearchField);
42
42
  }
43
43
 
44
+ @Component({
45
+ imports: [UiSearchField],
46
+ template: `<ui-search-field placeholder="Songs and artists" class="mx-4" />`,
47
+ })
48
+ class StaticAttributesHost {}
49
+
44
50
  /** A bar that holds the field in a shape of its own, as the dock does. */
45
51
  const container = {
46
52
  searching: signal(false),
@@ -203,6 +209,25 @@ afterEach(() => {
203
209
  Object.assign(screen, { fieldBottom: 820, pageBottom: 874, fails: false });
204
210
  });
205
211
 
212
+ describe('UiSearchField’s static attributes', () => {
213
+ // Angular gives a component's host every static attribute written on it
214
+ // (issue #181): the placeholder made a second match for
215
+ // getByPlaceholderText.
216
+ it('puts its class on one element, and its placeholder only on its input', async () => {
217
+ vi.stubGlobal('SystemInfo', IOS_26);
218
+ const { container, getAllByPlaceholderText } = await render(
219
+ StaticAttributesHost,
220
+ {
221
+ providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
222
+ },
223
+ );
224
+ expect(container.querySelectorAll('.mx-4')).toHaveLength(1);
225
+ expect(getAllByPlaceholderText('Songs and artists')).toEqual([
226
+ container.querySelector('input'),
227
+ ]);
228
+ });
229
+ });
230
+
206
231
  describe('UiSearchField on iOS 26 and later', () => {
207
232
  it('is a glass capsule with a magnifier and a search box that searches on return', async () => {
208
233
  const { getByRole, field, fieldHost, glass, icons } =
@@ -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();