@blotch/dolan 22.2.5 → 22.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/commands/install-packages.test.js +7 -2
  2. package/dist/commands/install-packages.test.js.map +1 -1
  3. package/dist/registry.js +8 -2
  4. package/dist/registry.js.map +1 -1
  5. package/dist/registry.test.js +1 -0
  6. package/dist/registry.test.js.map +1 -1
  7. package/dist/ui/components/accordion/accordion.test.ts +24 -7
  8. package/dist/ui/components/accordion/accordion.ts +34 -44
  9. package/dist/ui/components/action-sheet/action-sheet.test.ts +18 -1
  10. package/dist/ui/components/action-sheet/action-sheet.ts +55 -60
  11. package/dist/ui/components/alert/alert.test.ts +17 -0
  12. package/dist/ui/components/alert/alert.ts +23 -14
  13. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +19 -4
  14. package/dist/ui/components/alert-dialog/alert-dialog.ts +31 -20
  15. package/dist/ui/components/aspect-ratio/aspect-ratio.test.ts +38 -2
  16. package/dist/ui/components/aspect-ratio/aspect-ratio.ts +11 -8
  17. package/dist/ui/components/avatar/avatar.test.ts +7 -0
  18. package/dist/ui/components/avatar/avatar.ts +35 -30
  19. package/dist/ui/components/badge/badge.test.ts +9 -0
  20. package/dist/ui/components/badge/badge.ts +12 -10
  21. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +7 -0
  22. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +5 -0
  23. package/dist/ui/components/button/button.test.ts +29 -3
  24. package/dist/ui/components/button/button.ts +67 -62
  25. package/dist/ui/components/button-group/button-group.test.ts +37 -4
  26. package/dist/ui/components/button-group/button-group.ts +60 -51
  27. package/dist/ui/components/card/card.test.ts +35 -2
  28. package/dist/ui/components/card/card.ts +41 -53
  29. package/dist/ui/components/chart/chart-presets.test.ts +16 -2
  30. package/dist/ui/components/chart/chart-presets.ts +22 -3
  31. package/dist/ui/components/chart/chart.test.ts +35 -6
  32. package/dist/ui/components/chart/chart.ts +14 -3
  33. package/dist/ui/components/checkbox/checkbox.test.ts +11 -4
  34. package/dist/ui/components/checkbox/checkbox.ts +21 -14
  35. package/dist/ui/components/collapsible/collapsible.test.ts +20 -10
  36. package/dist/ui/components/collapsible/collapsible.ts +29 -40
  37. package/dist/ui/components/dialog/dialog.test.ts +17 -1
  38. package/dist/ui/components/dialog/dialog.ts +24 -17
  39. package/dist/ui/components/dock/dock.test.ts +30 -5
  40. package/dist/ui/components/dock/dock.ts +29 -27
  41. package/dist/ui/components/empty-state/empty-state.test.ts +9 -0
  42. package/dist/ui/components/empty-state/empty-state.ts +20 -11
  43. package/dist/ui/components/icon/icon.test.ts +17 -0
  44. package/dist/ui/components/icon/icon.ts +19 -17
  45. package/dist/ui/components/input/input.test.ts +55 -3
  46. package/dist/ui/components/input/input.ts +91 -65
  47. package/dist/ui/components/label/label.test.ts +15 -0
  48. package/dist/ui/components/label/label.ts +12 -4
  49. package/dist/ui/components/list/list.test.ts +13 -3
  50. package/dist/ui/components/list/list.ts +14 -2
  51. package/dist/ui/components/menu/menu.test.ts +8 -0
  52. package/dist/ui/components/menu/menu.ts +4 -0
  53. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +34 -21
  54. package/dist/ui/components/nav-drawer/nav-drawer.ts +60 -68
  55. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +13 -0
  56. package/dist/ui/components/navigation-bar/navigation-bar.ts +5 -0
  57. package/dist/ui/components/pagination/pagination.test.ts +9 -0
  58. package/dist/ui/components/pagination/pagination.ts +52 -49
  59. package/dist/ui/components/popover/popover.test.ts +3 -0
  60. package/dist/ui/components/progress/progress.test.ts +7 -0
  61. package/dist/ui/components/progress/progress.ts +8 -6
  62. package/dist/ui/components/radio-group/radio-group.test.ts +8 -0
  63. package/dist/ui/components/radio-group/radio-group.ts +22 -19
  64. package/dist/ui/components/scroll-area/scroll-area.test.ts +14 -2
  65. package/dist/ui/components/scroll-area/scroll-area.ts +15 -3
  66. package/dist/ui/components/search-field/search-field.test.ts +25 -0
  67. package/dist/ui/components/search-field/search-field.ts +7 -1
  68. package/dist/ui/components/select/select.test.ts +32 -5
  69. package/dist/ui/components/select/select.ts +32 -11
  70. package/dist/ui/components/separator/separator.test.ts +3 -0
  71. package/dist/ui/components/sheet/sheet.test.ts +18 -0
  72. package/dist/ui/components/sheet/sheet.ts +24 -24
  73. package/dist/ui/components/skeleton/skeleton.test.ts +8 -0
  74. package/dist/ui/components/skeleton/skeleton.ts +13 -8
  75. package/dist/ui/components/slider/slider.test.ts +13 -1
  76. package/dist/ui/components/slider/slider.ts +16 -3
  77. package/dist/ui/components/spinner/spinner.test.ts +16 -1
  78. package/dist/ui/components/spinner/spinner.ts +15 -7
  79. package/dist/ui/components/switch/switch.test.ts +13 -2
  80. package/dist/ui/components/switch/switch.ts +15 -1
  81. package/dist/ui/components/tab-bar/tab-bar.test.ts +32 -6
  82. package/dist/ui/components/tab-bar/tab-bar.ts +28 -24
  83. package/dist/ui/components/tabs/tabs.test.ts +34 -32
  84. package/dist/ui/components/tabs/tabs.ts +56 -73
  85. package/dist/ui/components/textarea/textarea.test.ts +11 -1
  86. package/dist/ui/components/textarea/textarea.ts +77 -71
  87. package/dist/ui/components/toggle/toggle.test.ts +47 -16
  88. package/dist/ui/components/toggle/toggle.ts +78 -80
  89. package/dist/ui/components/typography/typography.test.ts +16 -0
  90. package/dist/ui/components/typography/typography.ts +12 -4
  91. package/package.json +1 -1
@@ -295,8 +295,10 @@ describe('UiCartesianPreset', () => {
295
295
  const { container, componentRef, getByText } = await render(PresetHost);
296
296
  const root = chartRoot(container);
297
297
  expect(root.getAttribute('accessibility-label')).toBe('Sales');
298
- expect(root.getAttribute('class')).toContain('mt-2');
299
- expect(root.getAttribute('style')).toContain('height: 160px');
298
+ // The chart's box, which the root view fills.
299
+ expect(root.parentElement!.getAttribute('style')).toContain(
300
+ 'height: 160px',
301
+ );
300
302
  expect(getByText('Units')).toBeTruthy();
301
303
  expect(getByText('Day')).toBeTruthy();
302
304
 
@@ -305,6 +307,18 @@ describe('UiCartesianPreset', () => {
305
307
  expect(getByText('Week')).toBeTruthy();
306
308
  });
307
309
 
310
+ // Angular gives a component's host every static attribute written on it,
311
+ // so a preset that passed its class on to its chart as well applied it
312
+ // twice (issue #181).
313
+ it("puts its class on one element, the preset's own box", async () => {
314
+ const { container } = await render(PresetHost);
315
+ const box = chartRoot(container).parentElement!;
316
+ expect([...container.querySelectorAll('.mt-2')]).toEqual([
317
+ box.parentElement,
318
+ ]);
319
+ expect(box.parentElement!.classList).toContain('w-full');
320
+ });
321
+
308
322
  it('hands a tap over as the datum, or null off the points', async () => {
309
323
  const { container, componentRef } = await render(PresetHost);
310
324
  const host = componentRef.instance as PresetHost;
@@ -1,10 +1,17 @@
1
- import { Directive, booleanAttribute, input, viewChild } from '@angular/core';
1
+ import {
2
+ Directive,
3
+ booleanAttribute,
4
+ computed,
5
+ input,
6
+ viewChild,
7
+ } from '@angular/core';
2
8
  import { scaleLinear } from '@tanstack/charts/scales/linear';
3
9
  import type {
4
10
  ChartPoint,
5
11
  ChartPositionScaleOptions,
6
12
  } from '@tanstack/charts/types';
7
13
 
14
+ import { cn } from '../../utils/cn';
8
15
  import { UiChart } from './chart';
9
16
  import type { ChartColorSchemeOption } from './chart-theme';
10
17
  import type { ChartZoomAxes } from './chart-zoom';
@@ -184,8 +191,16 @@ const chartType = () => UiChart;
184
191
  * The inputs and zoom API every cartesian preset shares. A preset extends this
185
192
  * and adds its own data and mark inputs, and renders `<ui-chart #chart>`
186
193
  * with the shared bindings.
194
+ *
195
+ * A preset's host is the chart's box in the page, and takes the app's
196
+ * classes; the chart inside fills its width, at the preset's `height`.
197
+ * Angular gives a component's host every static attribute the app writes on
198
+ * it, so a class on `<ui-line-chart>` lands on the preset's host whatever the
199
+ * preset does with its `class` input: passed on to the chart as well, a
200
+ * margin applied twice, and a width class sized the chart a second time
201
+ * inside the first.
187
202
  */
188
- @Directive()
203
+ @Directive({ host: { '[class]': 'hostClass()' } })
189
204
  export abstract class UiCartesianPreset<TDatum> {
190
205
  /** Fixed width in px. Omit to fill the container. */
191
206
  readonly width = input<number>();
@@ -221,6 +236,11 @@ export abstract class UiCartesianPreset<TDatum> {
221
236
 
222
237
  protected readonly chart = viewChild.required(chartType());
223
238
 
239
+ // Like the chart's box: as wide as its parent, and not squeezed by a column.
240
+ protected readonly hostClass = computed(() =>
241
+ cn('w-full flex-shrink-0', this.userClass()),
242
+ );
243
+
224
244
  /**
225
245
  * The shared inputs, as the options every preset's definition takes.
226
246
  */
@@ -268,7 +288,6 @@ export const PRESET_CHART_BINDINGS = `
268
288
  [height]="height()"
269
289
  [label]="label()"
270
290
  [colorScheme]="colorScheme()"
271
- [class]="userClass()"
272
291
  [zoomable]="zoomable()"
273
292
  [zoomAxes]="zoomAxes()"
274
293
  [showZoomControls]="showZoomControls()"
@@ -103,6 +103,14 @@ class ChartHost {
103
103
  readonly chart = viewChild.required(UiChart);
104
104
  }
105
105
 
106
+ @Component({
107
+ imports: [UiChart],
108
+ template: `<ui-chart [definition]="definition" class="mt-2" />`,
109
+ })
110
+ class StaticClassHost {
111
+ readonly definition = barChart();
112
+ }
113
+
106
114
  /**
107
115
  * Renders the host 300px wide, after `configure` sets its inputs. Without a
108
116
  * width the chart waits for its layout (see "measures its layout").
@@ -125,6 +133,12 @@ const renderChart = async (
125
133
  const chartRoot = (container: Element) =>
126
134
  container.querySelector('[accessibility-label]')!;
127
135
 
136
+ /**
137
+ * The chart's box, its host, which its classes and size style and the root
138
+ * view fills.
139
+ */
140
+ const chartBox = (container: Element) => chartRoot(container).parentElement!;
141
+
128
142
  /**
129
143
  * The SVG each layer draws: under the marks, the marks, over the marks.
130
144
  */
@@ -206,6 +220,17 @@ const tap = async (root: Element, at: { x: number; y: number }) => {
206
220
  };
207
221
 
208
222
  describe('UiChart', () => {
223
+ // Angular gives a component's host every static attribute written on it,
224
+ // so the root view taking the class as well applied it twice (issue
225
+ // #181).
226
+ it('puts its class on one element, its box', async () => {
227
+ const { container } = await render(StaticClassHost);
228
+ expect([...container.querySelectorAll('.mt-2')]).toEqual([
229
+ chartBox(container),
230
+ ]);
231
+ expect(chartBox(container).classList).toContain('h-[200px]');
232
+ });
233
+
209
234
  describe('rendering', () => {
210
235
  it('draws nothing until it has a size, and ignores input until then', async () => {
211
236
  const { container, componentRef } = await render(ChartHost);
@@ -234,9 +259,7 @@ describe('UiChart', () => {
234
259
  });
235
260
 
236
261
  it('draws marks, empty focus layers and native labels', async () => {
237
- const { container, root } = await renderChart((host) =>
238
- host.height.set(200),
239
- );
262
+ const { container } = await renderChart((host) => host.height.set(200));
240
263
  const [under, marks, over] = layers(container);
241
264
  expect(layers(container)).toHaveLength(3);
242
265
  expect(marks).toContain('<rect');
@@ -246,8 +269,12 @@ describe('UiChart', () => {
246
269
  expect(labelTexts(container)).toEqual(
247
270
  expect.arrayContaining(['Jan', 'Feb', 'Mar', 'Revenue']),
248
271
  );
249
- expect(root.getAttribute('style')).toContain('width: 300px');
250
- expect(root.getAttribute('style')).toContain('height: 200px');
272
+ expect(chartBox(container).getAttribute('style')).toContain(
273
+ 'width: 300px',
274
+ );
275
+ expect(chartBox(container).getAttribute('style')).toContain(
276
+ 'height: 200px',
277
+ );
251
278
  const layerStyle = container.querySelector('svg')!.getAttribute('style');
252
279
  expect(layerStyle).toContain('width: 300px');
253
280
  expect(layerStyle).toContain('height: 200px');
@@ -375,7 +402,9 @@ describe('UiChart', () => {
375
402
  const root = chartRoot(container);
376
403
  const layerStyle = () =>
377
404
  container.querySelector('svg')?.getAttribute('style');
378
- expect(root.getAttribute('style') ?? '').not.toContain('width');
405
+ expect(chartBox(container).getAttribute('style') ?? '').not.toContain(
406
+ 'width',
407
+ );
379
408
 
380
409
  fireEvent.layoutchange(root, { detail: { width: 250, height: 120 } });
381
410
  await waitForUpdate();
@@ -211,10 +211,21 @@ type TouchPoint = {
211
211
  selector: 'ui-chart',
212
212
  imports: [LYNX_ELEMENTS, LynxGestureDetector, NgTemplateOutlet],
213
213
  encapsulation: ViewEncapsulation.None,
214
+ // The host is the chart's box, sized by its classes and `width`/`height`,
215
+ // and the root view fills it. Angular gives a component's host every
216
+ // static attribute the app writes on it, so a class on <ui-chart> lands on
217
+ // the host whatever the component does with its `class` input: on the root
218
+ // view as well, a margin or padding applied twice. The root stays a view of
219
+ // its own for the pan gesture and the accessibility element, which bind to
220
+ // it. `flex-1`, not `h-full`: a percentage height doesn't reliably resolve
221
+ // on a flex item in Lynx.
222
+ host: {
223
+ '[class]': 'rootClass()',
224
+ '[style]': 'rootStyle()',
225
+ },
214
226
  template: `
215
227
  <view
216
- [class]="rootClass()"
217
- [style]="rootStyle()"
228
+ class="flex-1 relative"
218
229
  [lynxGesture]="zoomable() ? panGesture : noGesture"
219
230
  [accessibility-element]="true"
220
231
  [accessibility-label]="label()"
@@ -385,7 +396,7 @@ export class UiChart {
385
396
  #touchStart: TouchPoint | null = null;
386
397
 
387
398
  protected readonly rootClass = computed(() =>
388
- cn('h-[200px] w-full flex-shrink-0 relative', this.userClass()),
399
+ cn('h-[200px] w-full flex-shrink-0 flex-col flex', this.userClass()),
389
400
  );
390
401
 
391
402
  protected readonly rootStyle = computed(() => {
@@ -41,8 +41,8 @@ const renderCheckbox = async () => {
41
41
  return {
42
42
  ...result,
43
43
  host: result.componentRef.instance as CheckboxHost,
44
- /** The 44pt tap target around the box. */
45
- target: () => result.container.querySelector('.h-11')!,
44
+ /** The 44pt tap target around the box: the checkbox's host. */
45
+ target: () => result.container.firstElementChild!,
46
46
  box: () => result.container.querySelector('.rounded-sm')!,
47
47
  checkmark: () => result.getByText('✓'),
48
48
  };
@@ -61,13 +61,20 @@ describe('UiCheckbox', () => {
61
61
  });
62
62
  afterEach(() => animate.mockRestore());
63
63
 
64
- it('renders an empty box with the given class', async () => {
64
+ it('renders an empty box', async () => {
65
65
  const { box, checkmark } = await renderCheckbox();
66
66
  expect(box().classList).toContain('bg-transparent');
67
- expect(box().classList).toContain('mr-2');
68
67
  expect(checkmark().getAttribute('style')).toContain('opacity: 0');
69
68
  });
70
69
 
70
+ // A class on a component lands on its host too (issue #181): the class is
71
+ // the box's, so the host, the tap target, drops its copy.
72
+ it('puts its class on one element, the box', async () => {
73
+ const { container, box, target } = await renderCheckbox();
74
+ expect([...container.querySelectorAll('.mr-2')]).toEqual([box()]);
75
+ expect((target() as HTMLElement).style.width).toBe('2.75rem');
76
+ });
77
+
71
78
  it('checks and unchecks on tap, emitting `changed`', async () => {
72
79
  const { host, target, box, checkmark } = await renderCheckbox();
73
80
 
@@ -15,25 +15,32 @@ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
15
15
  import { type AnimationHandle, popIn, popOut } from '../../utils/animate';
16
16
  import { cn } from '../../utils/cn';
17
17
 
18
+ /**
19
+ * The host is the checkbox's 44pt tap target, with the box inside, and the
20
+ * app's class is the box's. Angular also gives the host every static
21
+ * attribute the app writes on <ui-checkbox>, so the class styled the target
22
+ * too: a margin applied twice. The host drops it, and takes its own size
23
+ * from an inline style, which dropping the class leaves alone.
24
+ */
18
25
  @Component({
19
26
  selector: 'ui-checkbox',
20
- standalone: true,
21
27
  imports: [LYNX_ELEMENTS],
22
28
  encapsulation: ViewEncapsulation.None,
29
+ host: {
30
+ '[attr.class]': 'null',
31
+ style:
32
+ 'width: 2.75rem; height: 2.75rem; display: flex; align-items: center; justify-content: center;',
33
+ '(bindtap)': 'toggle()',
34
+ },
23
35
  template: `
24
- <view
25
- class="h-11 w-11 items-center flex justify-center"
26
- (bindtap)="toggle()"
27
- >
28
- <view [class]="boxClass()">
29
- <text
30
- #checkmark
31
- [class]="checkClass()"
32
- [style]="checked() ? 'opacity: 1;' : 'opacity: 0;'"
33
- >
34
- ✓
35
- </text>
36
- </view>
36
+ <view [class]="boxClass()">
37
+ <text
38
+ #checkmark
39
+ [class]="checkClass()"
40
+ [style]="checked() ? 'opacity: 1;' : 'opacity: 0;'"
41
+ >
42
+ ✓
43
+ </text>
37
44
  </view>
38
45
  `,
39
46
  })
@@ -68,8 +68,6 @@ const CANCEL = 3;
68
68
  describe('UiCollapsible', () => {
69
69
  it("merges each part's class with its own", async () => {
70
70
  const { container, host, trigger, content } = await renderCollapsible();
71
- // The host element carries the class too, so look for the view that has
72
- // both its own and the given class.
73
71
  expect(container.querySelector('.flex-col.gap-2')).not.toBeNull();
74
72
  expect(trigger().classList).toContain('flex-row');
75
73
  expect(trigger().classList).toContain('px-2');
@@ -80,6 +78,22 @@ describe('UiCollapsible', () => {
80
78
  expect(content()!.classList).toContain('pt-1');
81
79
  });
82
80
 
81
+ // A class on a component lands on its host too (issue #181): each part is
82
+ // its host, so each class applies once, and closed content, which is
83
+ // hidden, takes no room for its padding.
84
+ it('puts each class on one element', async () => {
85
+ const { container, host, trigger, content } = await renderCollapsible();
86
+ expect(container.querySelectorAll('.gap-2')).toHaveLength(1);
87
+ expect([...container.querySelectorAll('.px-2')]).toEqual([trigger()]);
88
+ const [closed] = container.querySelectorAll('.pt-1');
89
+ expect(closed!.classList).toContain('hidden');
90
+
91
+ host.open.set(true);
92
+ await waitForUpdate();
93
+ expect([...container.querySelectorAll('.pt-1')]).toEqual([content()]);
94
+ expect(content()!.classList).not.toContain('hidden');
95
+ });
96
+
83
97
  it('opens and closes when the trigger is tapped', async () => {
84
98
  const { host, trigger, content } = await renderCollapsible();
85
99
  // Closed, the content isn't mounted at all.
@@ -150,7 +164,7 @@ describe('UiCollapsible', () => {
150
164
  { transform: 'translateY(-8px)', opacity: 0 },
151
165
  { transform: 'translateY(0)', opacity: 1 },
152
166
  ],
153
- // No fill: once it ends, the content's inline resting style applies.
167
+ // No fill: once it ends, the content's own style applies.
154
168
  expect.objectContaining({ fill: 'none' }),
155
169
  ]);
156
170
  const [firstKey] = startedOn(content());
@@ -160,13 +174,9 @@ describe('UiCollapsible', () => {
160
174
  fireEvent.tap(trigger());
161
175
  await waitForUpdate();
162
176
  await waitForUpdate();
163
- // Reopening stops the previous reveal before it plays a new one on the
164
- // content's new view.
165
- expect(animate).toHaveBeenCalledWith(expect.anything(), [
166
- CANCEL,
167
- firstKey,
168
- ]);
169
- expect(startedOn(content())).toHaveLength(1);
177
+ // Reopening stops the previous reveal before it plays a new one.
178
+ expect(animate).toHaveBeenCalledWith(content(), [CANCEL, firstKey]);
179
+ expect(startedOn(content())).toHaveLength(2);
170
180
  });
171
181
 
172
182
  it("doesn't animate content that closed before its reveal began", async () => {
@@ -1,6 +1,6 @@
1
1
  import {
2
- type ElementRef,
3
2
  Component,
3
+ ElementRef,
4
4
  ViewEncapsulation,
5
5
  booleanAttribute,
6
6
  computed,
@@ -8,23 +8,23 @@ import {
8
8
  inject,
9
9
  input,
10
10
  model,
11
- viewChild,
12
11
  } from '@angular/core';
13
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
14
12
 
15
13
  import { type AnimationHandle, revealIn } from '../../utils/animate';
16
14
  import { cn } from '../../utils/cn';
17
15
 
16
+ /**
17
+ * Each part is its host element, styled through `host`, rather than a view
18
+ * inside the host. Angular gives a component's host every static attribute the
19
+ * app writes on it, so `<ui-collapsible class="rounded-lg border">` puts those
20
+ * classes on the host whatever the component does: with a styled view inside
21
+ * as well, the border was drawn twice.
22
+ */
18
23
  @Component({
19
24
  selector: 'ui-collapsible',
20
- standalone: true,
21
- imports: [LYNX_ELEMENTS],
22
25
  encapsulation: ViewEncapsulation.None,
23
- template: `
24
- <view [class]="containerClass()">
25
- <ng-content />
26
- </view>
27
- `,
26
+ host: { '[class]': 'containerClass()' },
27
+ template: '<ng-content />',
28
28
  })
29
29
  export class UiCollapsible {
30
30
  readonly open = model(false);
@@ -44,14 +44,12 @@ export class UiCollapsible {
44
44
 
45
45
  @Component({
46
46
  selector: 'ui-collapsible-trigger',
47
- standalone: true,
48
- imports: [LYNX_ELEMENTS],
49
47
  encapsulation: ViewEncapsulation.None,
50
- template: `
51
- <view [class]="triggerClass()" (bindtap)="onTap()">
52
- <ng-content />
53
- </view>
54
- `,
48
+ host: {
49
+ '[class]': 'triggerClass()',
50
+ '(bindtap)': 'onTap()',
51
+ },
52
+ template: '<ng-content />',
55
53
  })
56
54
  export class UiCollapsibleTrigger {
57
55
  readonly #collapsible = inject(UiCollapsible);
@@ -72,26 +70,19 @@ export class UiCollapsibleTrigger {
72
70
 
73
71
  @Component({
74
72
  selector: 'ui-collapsible-content',
75
- standalone: true,
76
- imports: [LYNX_ELEMENTS],
77
73
  encapsulation: ViewEncapsulation.None,
78
74
  // Mount the content only while open, via `@if`. On Lynx this works because the
79
75
  // renderer recreates a destroyed element's native painting node when it is
80
76
  // re-inserted (see LynxElement.#recreateSubtree in @blotch/angular-lynx): when
81
- // the panel reopens, the projected `<ng-content>` — recycled with the re-created
82
- // wrapper — is rebuilt from cache and renders again on every open. The inline
83
- // opacity/transform is the resting state; revealIn animates from opacity:0 /
84
- // translateY(-8px) with fill:'none' so the inline style takes over once the
85
- // animation ends.
77
+ // the panel reopens, the projected `<ng-content>` is rebuilt from cache and
78
+ // renders again on every open. While closed the host is hidden, so the
79
+ // classes the app wrote on it (which Angular puts on the host either way)
80
+ // take no room. revealIn animates from opacity:0 / translateY(-8px) with
81
+ // fill:'none', so the host's own style takes over once the animation ends.
82
+ host: { '[class]': 'contentClass()' },
86
83
  template: `
87
84
  @if (collapsible.open()) {
88
- <view
89
- #content
90
- [class]="contentClass()"
91
- style="opacity: 1; transform: translateY(0);"
92
- >
93
- <ng-content />
94
- </view>
85
+ <ng-content />
95
86
  }
96
87
  `,
97
88
  })
@@ -99,7 +90,7 @@ export class UiCollapsibleContent {
99
90
  protected readonly collapsible = inject(UiCollapsible);
100
91
  readonly userClass = input<string>('', { alias: 'class' });
101
92
 
102
- readonly contentRef = viewChild<ElementRef>('content');
93
+ readonly #host = inject(ElementRef).nativeElement;
103
94
  #anim?: AnimationHandle;
104
95
 
105
96
  /**
@@ -107,22 +98,20 @@ export class UiCollapsibleContent {
107
98
  * constructor: Angular's test build keeps a function for a constructor that
108
99
  * nothing calls, which an app's coverage report would show as uncovered.
109
100
  *
110
- * contentRef only resolves while the `@if` content is mounted; defer one
111
- * macrotask so the freshly (re)created element is committed to the native
112
- * tree before we animate it. By then the panel may have closed again, which
113
- * leaves nothing to animate.
101
+ * Deferred one macrotask so the re-inserted content is committed to the
102
+ * native tree before we animate it. By then the panel may have closed
103
+ * again, which leaves nothing to animate.
114
104
  */
115
105
  protected readonly revealing = effect(() => {
116
106
  if (!this.collapsible.open()) return;
117
107
  setTimeout(() => {
118
- const el = this.contentRef()?.nativeElement;
119
- if (!el) return;
108
+ if (!this.collapsible.open()) return;
120
109
  this.#anim?.cancel();
121
- this.#anim = revealIn(el, { fromY: -8, fill: 'none' });
110
+ this.#anim = revealIn(this.#host, { fromY: -8, fill: 'none' });
122
111
  }, 0);
123
112
  });
124
113
 
125
114
  protected readonly contentClass = computed(() =>
126
- cn('flex-col flex', this.userClass()),
115
+ this.collapsible.open() ? cn('flex-col flex', this.userClass()) : 'hidden',
127
116
  );
128
117
  }
@@ -123,7 +123,6 @@ describe('UiDialog', () => {
123
123
  expect(getByText('Title').classList).toContain('text-xl');
124
124
  expect(getByText('Description').classList).toContain('text-sm');
125
125
  expect(getByText('Description').classList).toContain('italic');
126
- // Each part's own view, not its host element, which keeps the bare class.
127
126
  const header = container.querySelector('.flex-col.gap-2');
128
127
  expect(header?.classList).not.toContain('gap-1.5');
129
128
  const footer = container.querySelector('.pt-4');
@@ -131,6 +130,23 @@ describe('UiDialog', () => {
131
130
  expect(footer?.classList).not.toContain('justify-end');
132
131
  });
133
132
 
133
+ // Angular gives a component's host every static attribute written on it
134
+ // (issue #181), so each class also styled a host: the dialog's in the page,
135
+ // where it is written, and each part's around its own view or text.
136
+ it('puts each class on one element: the panel, or the part', async () => {
137
+ const { container, getByText, panel } = await renderDialog();
138
+ expect([...container.querySelectorAll('.max-w-sm')]).toEqual([panel()]);
139
+ // The header's: the footer has a gap-2 of its own.
140
+ expect(container.querySelectorAll('.flex-col.gap-2')).toHaveLength(1);
141
+ expect(container.querySelectorAll('.justify-start')).toHaveLength(1);
142
+ expect([...container.querySelectorAll('.text-xl')]).toEqual([
143
+ getByText('Title'),
144
+ ]);
145
+ expect([...container.querySelectorAll('.italic')]).toEqual([
146
+ getByText('Description'),
147
+ ]);
148
+ });
149
+
134
150
  it('stays hidden until it is opened', async () => {
135
151
  const { host, overlay, panel } = await renderDialog();
136
152
  await after(CLOSED);
@@ -11,7 +11,7 @@ import {
11
11
  signal,
12
12
  viewChild,
13
13
  } from '@angular/core';
14
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
14
+ import { LYNX_ELEMENTS, registerHostElement } from '@blotch/angular-lynx';
15
15
 
16
16
  import {
17
17
  type AnimationHandle,
@@ -29,6 +29,11 @@ import { cn } from '../../utils/cn';
29
29
  selector: 'ui-dialog',
30
30
  imports: [LYNX_ELEMENTS],
31
31
  encapsulation: ViewEncapsulation.None,
32
+ // The dialog's class is its panel's, inside the overlay. Angular also gives
33
+ // the host every static attribute the app writes on <ui-dialog>, and the
34
+ // host sits in the page's layout where the dialog is written, so a
35
+ // `class="p-8"` padded the page there too. The host drops it.
36
+ host: { '[attr.class]': 'null' },
32
37
  template: `
33
38
  <overlay [attr.visible]="overlayVisible()" [style]="overlayStyle()">
34
39
  <view #backdrop [class]="backdropClass()" (bindtap)="onBackdropTap()">
@@ -220,15 +225,17 @@ export class UiDialog implements OnDestroy {
220
225
  }
221
226
  }
222
227
 
228
+ /**
229
+ * The parts are their host elements, styled through `host`: a class the app
230
+ * writes on a part lands on its host anyway (see UiDialog), so a view or text
231
+ * inside it styled with the same class applied it twice. The title and
232
+ * description are text, so their hosts are `<text>` elements.
233
+ */
223
234
  @Component({
224
235
  selector: 'ui-dialog-header',
225
- imports: [LYNX_ELEMENTS],
226
236
  encapsulation: ViewEncapsulation.None,
227
- template: `
228
- <view [class]="containerClass()">
229
- <ng-content />
230
- </view>
231
- `,
237
+ host: { '[class]': 'containerClass()' },
238
+ template: '<ng-content />',
232
239
  })
233
240
  export class UiDialogHeader {
234
241
  readonly userClass = input<string>('', { alias: 'class' });
@@ -240,9 +247,9 @@ export class UiDialogHeader {
240
247
 
241
248
  @Component({
242
249
  selector: 'ui-dialog-title',
243
- imports: [LYNX_ELEMENTS],
244
250
  encapsulation: ViewEncapsulation.None,
245
- template: `<text [class]="textClass()"><ng-content /></text>`,
251
+ host: { '[class]': 'textClass()' },
252
+ template: '<ng-content />',
246
253
  })
247
254
  export class UiDialogTitle {
248
255
  readonly userClass = input<string>('', { alias: 'class' });
@@ -252,11 +259,13 @@ export class UiDialogTitle {
252
259
  );
253
260
  }
254
261
 
262
+ registerHostElement(UiDialogTitle, 'text');
263
+
255
264
  @Component({
256
265
  selector: 'ui-dialog-description',
257
- imports: [LYNX_ELEMENTS],
258
266
  encapsulation: ViewEncapsulation.None,
259
- template: `<text [class]="textClass()"><ng-content /></text>`,
267
+ host: { '[class]': 'textClass()' },
268
+ template: '<ng-content />',
260
269
  })
261
270
  export class UiDialogDescription {
262
271
  readonly userClass = input<string>('', { alias: 'class' });
@@ -266,15 +275,13 @@ export class UiDialogDescription {
266
275
  );
267
276
  }
268
277
 
278
+ registerHostElement(UiDialogDescription, 'text');
279
+
269
280
  @Component({
270
281
  selector: 'ui-dialog-footer',
271
- imports: [LYNX_ELEMENTS],
272
282
  encapsulation: ViewEncapsulation.None,
273
- template: `
274
- <view [class]="containerClass()">
275
- <ng-content />
276
- </view>
277
- `,
283
+ host: { '[class]': 'containerClass()' },
284
+ template: '<ng-content />',
278
285
  })
279
286
  export class UiDialogFooter {
280
287
  readonly userClass = input<string>('', { alias: 'class' });
@@ -101,6 +101,16 @@ class DockHost {
101
101
  readonly items = viewChildren(UiDockItem);
102
102
  }
103
103
 
104
+ @Component({
105
+ imports: [UiDock, UiDockItem],
106
+ template: `
107
+ <ui-dock>
108
+ <ui-dock-item route="/" icon="home" label="Home" class="pt-4" />
109
+ </ui-dock>
110
+ `,
111
+ })
112
+ class StaticClassHost {}
113
+
104
114
  /**
105
115
  * A dock with only an action of the app's own, and no items.
106
116
  */
@@ -685,15 +695,30 @@ describe('UiDockItem', () => {
685
695
  expect(scale()).toBe('scale(1)');
686
696
  });
687
697
 
688
- it("merges an item's class into its content, the given class winning", async () => {
698
+ it("merges an item's class with its own, the given class winning", async () => {
689
699
  const { host, item } = await renderDock(IOS_26);
690
- const content = () => item('Inbox').querySelector('view')!;
691
- expect(content().classList).toContain('gap-0.5');
700
+ expect(item('Inbox').classList).toContain('gap-0.5');
692
701
 
693
702
  host.inboxClass.set('gap-2');
694
703
  await waitForUpdate();
695
- expect(content().classList).toContain('gap-2');
696
- expect(content().classList).not.toContain('gap-0.5');
704
+ expect(item('Inbox').classList).toContain('gap-2');
705
+ expect(item('Inbox').classList).not.toContain('gap-0.5');
706
+ });
707
+
708
+ // Angular gives a component's host every static attribute written on it,
709
+ // so an item that also styled a view inside its host applied the class
710
+ // twice (issue #181).
711
+ it('puts a class written on an item on that one element', async () => {
712
+ const { container } = await render(StaticClassHost, {
713
+ providers: [
714
+ provideRouter(routes),
715
+ onPlatform(IOS_26),
716
+ { provide: LynxAccessibilitySettings, useValue: settings },
717
+ ],
718
+ });
719
+ expect([...container.querySelectorAll('.pt-4')]).toEqual([
720
+ container.querySelector('[accessibility-label="Home"]'),
721
+ ]);
697
722
  });
698
723
  });
699
724