@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
@@ -1,15 +1,15 @@
1
1
  import {
2
- type ElementRef,
3
2
  Component,
3
+ ElementRef,
4
4
  ViewEncapsulation,
5
5
  booleanAttribute,
6
6
  computed,
7
7
  effect,
8
+ inject,
8
9
  input,
9
10
  model,
10
11
  output,
11
12
  signal,
12
- viewChild,
13
13
  } from '@angular/core';
14
14
  import { type FormValueControl } from '@angular/forms/signals';
15
15
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
@@ -19,69 +19,88 @@ import { cn } from '../../utils/cn';
19
19
 
20
20
  @Component({
21
21
  selector: 'ui-input',
22
- standalone: true,
23
22
  imports: [LYNX_ELEMENTS],
24
23
  encapsulation: ViewEncapsulation.None,
24
+ // The host is the field's column. Angular gives a component's host every
25
+ // static attribute the app writes on it, so a class on <ui-input> lands
26
+ // there whatever the component does: a column inside the host applied it
27
+ // twice. A placeholder lands there too, and goes: it belongs to the
28
+ // <input>, and a second element with it is a second match for
29
+ // getByPlaceholderText.
30
+ host: {
31
+ '[class]': 'containerClass()',
32
+ '[attr.placeholder]': 'null',
33
+ },
25
34
  template: `
26
- <view #container [class]="containerClass()">
27
- @if (label()) {
28
- <text [class]="labelClass()">{{ label() }}</text>
29
- }
30
- <!-- Focus ring is an inline box-shadow on a dedicated overlay layer, not a
31
- Tailwind ring-* class (the Lynx preset doesn't wire up --tw-ring-*, so
32
- ring-* renders nothing on device). It rides its OWN layer so it can
33
- fade: box-shadow is animatable:no on Lynx (the core rejects a
34
- transition: box-shadow), but opacity is animatable, so we transition
35
- the layer's opacity instead. The overlay is the FIRST child so Lynx,
36
- which paints in strict source order, keeps the <input> on top and
37
- tappable. Source order alone is NOT enough on web: CSS paints a
38
- positioned (absolute) sibling above a static one regardless of source
39
- order, so this transparent overlay would swallow every tap — no focus,
40
- no typing. The <input> below therefore carries position:relative +
41
- z-index:1 so it sits above the overlay on BOTH platforms (required on
42
- web). On Lynx, though, a z-indexed element is stacked against its
43
- nearest stacking context, and a scroll-view isn't one unless it sets
44
- z-index itself — so the native input would stay fixed on screen while
45
- the page scrolled (only its placeholder visibly floats). The wrapper's
46
- z-0 makes it that stacking context, keeping the input's z-index local
47
- so it scrolls with the field. pointer-events is avoided — it errors
48
- the Lynx build. The shadow is OUTSET only: Lynx doesn't render inset box-shadows,
49
- so the crisp 1px "border" is an outset ring hugging the edge, not a
50
- real inset border. -->
51
- <view [class]="inputWrapperClass()">
52
- <view
53
- class="rounded-xl opacity-0 absolute bottom-0 left-0 right-0 top-0"
54
- [style.transition]="'opacity 150ms ease'"
55
- [style.box-shadow]="ringShadow()"
56
- [style.opacity]="ringVisible() ? 1 : 0"
57
- ></view>
58
- <!-- [attr.disabled]="disabled() || undefined": passing 'undefined'
59
- removes the attribute entirely; passing 'false' would set
60
- disabled="false" which Lynx still treats as disabled. -->
61
- <!-- [value] not [attr.value]: [attr.value] calls renderer.setAttribute()
62
- which routes to __SetAttribute — a no-op for live text on Lynx native
63
- inputs. [value] is an Angular input binding that triggers
64
- LynxInput.ngOnChanges, where the setValue UIMethod override lives.
65
- Without this, form model resets would update the signal but the
66
- native input would keep showing the user's old typed text. -->
67
- <input
68
- [attr.placeholder]="placeholder()"
69
- [attr.type]="type()"
70
- [value]="value()"
71
- [attr.disabled]="disabled() || undefined"
72
- class="text-sm text-foreground"
73
- style="border: none; background: transparent; height: 100%; width: 100%; position: relative; z-index: 1;"
74
- (bindinput)="onInput($any($event))"
75
- (bindfocus)="onFocus()"
76
- (bindblur)="onBlur()"
77
- />
78
- </view>
79
- @if (error()) {
80
- <text [class]="errorClass()">{{ error() }}</text>
81
- } @else if (helperText()) {
82
- <text [class]="helperClass()">{{ helperText() }}</text>
83
- }
35
+ <!-- The <input> below carries the label as its accessibility-label, so
36
+ screen readers read it with the field. Hidden here, or VoiceOver and
37
+ TalkBack would stop on it a second time. -->
38
+ @if (label()) {
39
+ <text [class]="labelClass()" accessibility-elements-hidden="true">{{
40
+ label()
41
+ }}</text>
42
+ }
43
+ <!-- Focus ring is an inline box-shadow on a dedicated overlay layer, not a
44
+ Tailwind ring-* class (the Lynx preset doesn't wire up --tw-ring-*, so
45
+ ring-* renders nothing on device). It rides its OWN layer so it can
46
+ fade: box-shadow is animatable:no on Lynx (the core rejects a
47
+ transition: box-shadow), but opacity is animatable, so we transition
48
+ the layer's opacity instead. The overlay is the FIRST child so Lynx,
49
+ which paints in strict source order, keeps the <input> on top and
50
+ tappable. Source order alone is NOT enough on web: CSS paints a
51
+ positioned (absolute) sibling above a static one regardless of source
52
+ order, so this transparent overlay would swallow every tap — no focus,
53
+ no typing. The <input> below therefore carries position:relative +
54
+ z-index:1 so it sits above the overlay on BOTH platforms (required on
55
+ web). On Lynx, though, a z-indexed element is stacked against its
56
+ nearest stacking context, and a scroll-view isn't one unless it sets
57
+ z-index itself — so the native input would stay fixed on screen while
58
+ the page scrolled (only its placeholder visibly floats). The wrapper's
59
+ z-0 makes it that stacking context, keeping the input's z-index local
60
+ so it scrolls with the field. pointer-events is avoided — it errors
61
+ the Lynx build. The shadow is OUTSET only: Lynx doesn't render inset box-shadows,
62
+ so the crisp 1px "border" is an outset ring hugging the edge, not a
63
+ real inset border. -->
64
+ <view [class]="inputWrapperClass()">
65
+ <view
66
+ class="rounded-xl opacity-0 absolute bottom-0 left-0 right-0 top-0"
67
+ [style.transition]="'opacity 150ms ease'"
68
+ [style.box-shadow]="ringShadow()"
69
+ [style.opacity]="ringVisible() ? 1 : 0"
70
+ ></view>
71
+ <!-- [attr.disabled]="disabled() || undefined": passing 'undefined'
72
+ removes the attribute entirely; passing 'false' would set
73
+ disabled="false" which Lynx still treats as disabled. -->
74
+ <!-- [value] not [attr.value]: [attr.value] calls renderer.setAttribute()
75
+ which routes to __SetAttribute — a no-op for live text on Lynx native
76
+ inputs. [value] is an Angular input binding that triggers
77
+ LynxInput.ngOnChanges, where the setValue UIMethod override lives.
78
+ Without this, form model resets would update the signal but the
79
+ native input would keep showing the user's old typed text. -->
80
+ <!-- accessibility-element: on iOS, Lynx makes only <text> and <image>
81
+ accessibility elements by default, so the UITextField under an
82
+ <input> isn't one. VoiceOver then read the placeholder inside it,
83
+ or nothing once it held text. As one, it reads its
84
+ accessibility-label, the label, then its text. -->
85
+ <input
86
+ accessibility-element="true"
87
+ [attr.accessibility-label]="label() || null"
88
+ [attr.placeholder]="placeholder()"
89
+ [attr.type]="type()"
90
+ [value]="value()"
91
+ [attr.disabled]="disabled() || undefined"
92
+ class="text-sm text-foreground"
93
+ style="border: none; background: transparent; height: 100%; width: 100%; position: relative; z-index: 1;"
94
+ (bindinput)="onInput($any($event))"
95
+ (bindfocus)="onFocus()"
96
+ (bindblur)="onBlur()"
97
+ />
84
98
  </view>
99
+ @if (error()) {
100
+ <text [class]="errorClass()">{{ error() }}</text>
101
+ } @else if (helperText()) {
102
+ <text [class]="helperClass()">{{ helperText() }}</text>
103
+ }
85
104
  `,
86
105
  })
87
106
  export class UiInput implements FormValueControl<string> {
@@ -97,8 +116,15 @@ export class UiInput implements FormValueControl<string> {
97
116
  readonly changed = output<string>();
98
117
  readonly focused = output<void>();
99
118
  readonly blurred = output<void>();
119
+ /**
120
+ * The field lost focus, for forms, which mark it as touched as they do a
121
+ * native input (FormUiControl's `touch`). Signal Forms, reactive forms and
122
+ * template-driven forms all listen for it; a `touched` model's change
123
+ * isn't read by any of them.
124
+ */
125
+ readonly touch = output<void>();
100
126
 
101
- readonly containerRef = viewChild<ElementRef>('container');
127
+ readonly #host = inject(ElementRef).nativeElement;
102
128
  readonly #isFocused = signal(false);
103
129
  #shakeAnim?: AnimationHandle;
104
130
  #previousError = '';
@@ -111,10 +137,9 @@ export class UiInput implements FormValueControl<string> {
111
137
  */
112
138
  protected readonly shakeOnError = effect(() => {
113
139
  const err = this.error();
114
- const el = this.containerRef()?.nativeElement;
115
- if (err && !this.#previousError && el) {
140
+ if (err && !this.#previousError) {
116
141
  this.#shakeAnim?.cancel();
117
- this.#shakeAnim = shake(el);
142
+ this.#shakeAnim = shake(this.#host);
118
143
  }
119
144
  this.#previousError = err;
120
145
  });
@@ -179,6 +204,7 @@ export class UiInput implements FormValueControl<string> {
179
204
  protected onBlur(): void {
180
205
  this.#isFocused.set(false);
181
206
  this.blurred.emit();
207
+ this.touch.emit();
182
208
  }
183
209
 
184
210
  /**
@@ -11,6 +11,12 @@ class LabelHost {
11
11
  readonly disabled = signal(false);
12
12
  }
13
13
 
14
+ @Component({
15
+ imports: [UiLabel],
16
+ template: `<ui-label class="mb-2">Email</ui-label>`,
17
+ })
18
+ class StaticClassHost {}
19
+
14
20
  describe('UiLabel', () => {
15
21
  it('renders its text, dimmed while disabled', async () => {
16
22
  const { componentRef, getByText } = await render(LabelHost);
@@ -22,4 +28,13 @@ describe('UiLabel', () => {
22
28
  await waitForUpdate();
23
29
  expect(label.classList).toContain('opacity-50');
24
30
  });
31
+
32
+ // A class on a component lands on its host too (issue #181): the label is
33
+ // one <text>, so the class styles one element.
34
+ it('is one text, with the class written on it', async () => {
35
+ const { container, getByText } = await render(StaticClassHost);
36
+ const label = getByText('Email');
37
+ expect(label.tagName.toLowerCase()).toBe('text');
38
+ expect([...container.querySelectorAll('.mb-2')]).toEqual([label]);
39
+ });
25
40
  });
@@ -5,16 +5,22 @@ import {
5
5
  computed,
6
6
  input,
7
7
  } from '@angular/core';
8
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
8
+ import { registerHostElement } from '@blotch/angular-lynx';
9
9
 
10
10
  import { cn } from '../../utils/cn';
11
11
 
12
+ /**
13
+ * The label is its host, a `<text>` (registerHostElement, below), styled
14
+ * through `host`. Angular gives a component's host every static attribute the
15
+ * app writes on it, so `<ui-label class="mb-2">` puts `mb-2` on the host
16
+ * whatever the component does: with a styled `<text>` inside a view host as
17
+ * well, the margin applied twice.
18
+ */
12
19
  @Component({
13
20
  selector: 'ui-label',
14
- standalone: true,
15
- imports: [LYNX_ELEMENTS],
16
21
  encapsulation: ViewEncapsulation.None,
17
- template: `<text [class]="labelClass()"><ng-content /></text>`,
22
+ host: { '[class]': 'labelClass()' },
23
+ template: '<ng-content />',
18
24
  })
19
25
  export class UiLabel {
20
26
  readonly disabled = input(false, { transform: booleanAttribute });
@@ -28,3 +34,5 @@ export class UiLabel {
28
34
  ),
29
35
  );
30
36
  }
37
+
38
+ registerHostElement(UiLabel, 'text');
@@ -45,6 +45,8 @@ const renderList = async () => {
45
45
  ...result,
46
46
  host: result.componentRef.instance as ListHost,
47
47
  list: () => result.container.querySelector('list')!,
48
+ /** The list's box, its host, which the <list> fills. */
49
+ box: () => result.container.querySelector('list')!.parentElement!,
48
50
  };
49
51
  };
50
52
 
@@ -65,12 +67,20 @@ describe('UiList', () => {
65
67
  // Lynx `<list>` is virtualized: items join the native tree only through
66
68
  // its componentAtIndex callback, which a unit test doesn't drive. So this
67
69
  // checks the list itself rather than the projected item.
68
- const { list } = await renderList();
70
+ const { list, box } = await renderList();
69
71
  expect(list().getAttribute('list-type')).toBe('single');
70
72
  expect(list().getAttribute('span-count')).toBe('1');
71
73
  expect(list().getAttribute('scroll-orientation')).toBe('vertical');
72
- expect(list().classList).toContain('w-full');
73
- expect(list().classList).toContain('bg-card');
74
+ expect(list().classList).toContain('grow');
75
+ expect(box().classList).toContain('w-full');
76
+ expect(box().classList).toContain('bg-card');
77
+ });
78
+
79
+ // Angular gives a component's host every static attribute written on it,
80
+ // so the <list> taking the class as well applied it twice (issue #181).
81
+ it('puts its class on one element, its box', async () => {
82
+ const { container, box } = await renderList();
83
+ expect([...container.querySelectorAll('.bg-card')]).toEqual([box()]);
74
84
  });
75
85
 
76
86
  it('gives the list an explicit height, which it needs to scroll', async () => {
@@ -19,6 +19,16 @@ import { cn } from '../../utils/cn';
19
19
  // the attribute when false, and a missing attribute means Lynx's default: on,
20
20
  // for `bounces` and `scroll-bar-enable`. So neither could be turned off.
21
21
  // (`[attr.x]` would send the string "false" instead.)
22
+ //
23
+ // The host is the list's box, and the <list> fills it. Angular gives a
24
+ // component's host every static attribute the app writes on it, so a class
25
+ // on <ui-list> lands on the host whatever the component does with its
26
+ // `class` input: on the <list> as well, a margin or padding applied twice.
27
+ // `grow` fills a box with a height of its own (`h-64`, `flex-1`), and a
28
+ // box without one takes the list's, which `height` sets: the list keeps
29
+ // its own height as its flex basis. Not `h-full`: a percentage height
30
+ // doesn't reliably resolve on a flex item in Lynx.
31
+ host: { '[class]': 'hostClass()' },
22
32
  template: `
23
33
  <list
24
34
  [attr.list-type]="listType()"
@@ -32,7 +42,7 @@ import { cn } from '../../utils/cn';
32
42
  [attr.preload-buffer-count]="preloadBufferCount()"
33
43
  [attr.lower-threshold-item-count]="lowerThresholdItemCount()"
34
44
  [attr.upper-threshold-item-count]="upperThresholdItemCount()"
35
- [class]="listClass()"
45
+ class="grow"
36
46
  [style]="listStyle()"
37
47
  (bindscrolltoupper)="scrolledToStart.emit()"
38
48
  (bindscrolltolower)="scrolledToEnd.emit()"
@@ -61,7 +71,9 @@ export class UiList {
61
71
  readonly scrolledToStart = output<void>();
62
72
  readonly scrolledToEnd = output<void>();
63
73
 
64
- protected readonly listClass = computed(() => cn('w-full', this.userClass()));
74
+ protected readonly hostClass = computed(() =>
75
+ cn('w-full flex-col flex', this.userClass()),
76
+ );
65
77
 
66
78
  // Explicit height is required for list to scroll in Lynx. The gaps go here
67
79
  // too: they're CSS properties that Lynx's list reads from its style, and as
@@ -490,6 +490,14 @@ describe('UiMenuSection and UiMenuSeparator', () => {
490
490
  expect(rendered.menu.getAllByRole('heading')).toHaveLength(1);
491
491
  });
492
492
 
493
+ // Angular gives a component's host every static attribute written on it
494
+ // (issue #181): the title was also the host's tooltip in the web preview.
495
+ it("doesn't give its title to its host as well", async () => {
496
+ const rendered = await renderOpenMenu();
497
+
498
+ expect(rendered.container.querySelectorAll('[title]')).toHaveLength(0);
499
+ });
500
+
493
501
  it('indents a Material section title as Material does', async () => {
494
502
  const rendered = await renderOpenMenu(null);
495
503
 
@@ -231,6 +231,10 @@ export class UiMenuItem {
231
231
  selector: 'ui-menu-section',
232
232
  imports: [LYNX_ELEMENTS],
233
233
  encapsulation: ViewEncapsulation.None,
234
+ // The title is the section's text. Angular also gives the host every static
235
+ // attribute the app writes on it, and a `title` on the host is a tooltip in
236
+ // the web preview and a second match for getByTitle, so the host drops it.
237
+ host: { '[attr.title]': 'null' },
234
238
  template: `
235
239
  <view class="flex-col flex">
236
240
  @if (title()) {
@@ -1,4 +1,4 @@
1
- import { Component, signal, viewChild } from '@angular/core';
1
+ import { Component, ElementRef, signal, viewChild } from '@angular/core';
2
2
  import {
3
3
  fireEvent,
4
4
  render,
@@ -100,7 +100,8 @@ class NavDrawerHost {
100
100
  readonly closes = signal(0);
101
101
  readonly presses = signal(0);
102
102
  readonly drawer = viewChild.required(UiNavDrawer);
103
- readonly settings = viewChild.required<UiNavDrawerItem>('settings');
103
+ // The settings item's element: its host.
104
+ readonly settings = viewChild.required('settings', { read: ElementRef });
104
105
  }
105
106
 
106
107
  const renderDrawer = async () => {
@@ -116,7 +117,7 @@ const renderDrawer = async () => {
116
117
  backdrop: () => panel().parentElement!,
117
118
  scrollView: () => container.querySelector('scroll-view')!,
118
119
  trigger: () => container.querySelector('[accessibility-element]')!,
119
- // An item's own view, which holds its projected label.
120
+ // An item, its host: the view that holds its projected label.
120
121
  item: (label: string) => getByText(label).parentElement!,
121
122
  svg: (selector: string) => container.querySelector(`svg${selector}`)!,
122
123
  };
@@ -174,11 +175,12 @@ describe('UiNavDrawer', () => {
174
175
  expect(panel().classList).toContain('bg-background');
175
176
  expect(panel().classList).toContain('shadow-lg');
176
177
  expect(scrollView().classList).toContain('h-full');
177
- expect(scrollView().classList).toContain('pt-2');
178
+ // The content's class is on its host, around the scroll view.
179
+ expect(scrollView().parentElement!.classList).toContain('flex-1');
180
+ expect(scrollView().parentElement!.classList).toContain('pt-2');
178
181
  expect(item('Delete account').classList).toContain('mt-4');
179
182
  expect(trigger().classList).toContain('h-10');
180
183
  expect(trigger().classList).toContain('ml-2');
181
- // Each part's own view, not its host element, which keeps the bare class.
182
184
  const header = container.querySelector('.border-b');
183
185
  expect(header?.classList).toContain('gap-3');
184
186
  expect(header?.classList).not.toContain('gap-1.5');
@@ -187,6 +189,29 @@ describe('UiNavDrawer', () => {
187
189
  expect(footer?.classList).not.toContain('gap-2');
188
190
  });
189
191
 
192
+ // Angular gives a component's host every static attribute written on it
193
+ // (issue #181), so each class also styled a host: the drawer's in the
194
+ // page, where it is written, and each part's around its own view.
195
+ it('puts each class on one element: the panel, or the part', async () => {
196
+ const { container, getByText, panel, scrollView, item, trigger } =
197
+ await renderDrawer();
198
+ expect([...container.querySelectorAll('.shadow-lg')]).toEqual([panel()]);
199
+ expect([...container.querySelectorAll('.pt-2')]).toEqual([
200
+ scrollView().parentElement,
201
+ ]);
202
+ expect([...container.querySelectorAll('.mt-4')]).toEqual([
203
+ item('Delete account'),
204
+ ]);
205
+ expect([...container.querySelectorAll('.ml-2')]).toEqual([trigger()]);
206
+ // The header's label sits right in it: no view between them. (Items
207
+ // have a gap-3 of their own.)
208
+ expect(getByText('Menu').parentElement!.classList).toContain('gap-3');
209
+ expect(getByText('Menu').parentElement!.classList).toContain('border-b');
210
+ expect([...container.querySelectorAll('.gap-4')]).toEqual([
211
+ getByText('Version 1').parentElement,
212
+ ]);
213
+ });
214
+
190
215
  it('stays hidden until it is opened', async () => {
191
216
  const { host, overlay, panel } = await renderDrawer();
192
217
  await after(CLOSED);
@@ -354,10 +379,7 @@ describe('UiNavDrawer', () => {
354
379
  describe('UiNavDrawerContent', () => {
355
380
  it('scrolls the active item into view when the drawer opens', async () => {
356
381
  const { host } = await renderDrawer();
357
- const scroll = vi.spyOn(
358
- host.settings().containerRef()!.nativeElement,
359
- 'invoke',
360
- );
382
+ const scroll = vi.spyOn(host.settings().nativeElement, 'invoke');
361
383
  host.open.set(true);
362
384
  await after(OPENED);
363
385
  // Instantly and centered, so the drawer slides in already scrolled.
@@ -371,20 +393,14 @@ describe('UiNavDrawerContent', () => {
371
393
  const { host, scrollView } = await renderDrawer();
372
394
  host.open.set(true);
373
395
  await after(OPENED);
374
- const scroll = vi.spyOn(
375
- host.settings().containerRef()!.nativeElement,
376
- 'invoke',
377
- );
396
+ const scroll = vi.spyOn(host.settings().nativeElement, 'invoke');
378
397
  fireEvent.contentsizechanged(scrollView());
379
398
  expect(scroll).toHaveBeenCalledWith('scrollIntoView', expect.any(Object));
380
399
  });
381
400
 
382
401
  it("doesn't scroll while the drawer is closed", async () => {
383
402
  const { host, scrollView } = await renderDrawer();
384
- const scroll = vi.spyOn(
385
- host.settings().containerRef()!.nativeElement,
386
- 'invoke',
387
- );
403
+ const scroll = vi.spyOn(host.settings().nativeElement, 'invoke');
388
404
  // Content collapsing as the overlay hides relays out too.
389
405
  fireEvent.contentsizechanged(scrollView());
390
406
  await after(OPENED);
@@ -394,10 +410,7 @@ describe('UiNavDrawerContent', () => {
394
410
  it("doesn't scroll when no item is active", async () => {
395
411
  const { host } = await renderDrawer();
396
412
  host.active.set('');
397
- const scroll = vi.spyOn(
398
- host.settings().containerRef()!.nativeElement,
399
- 'invoke',
400
- );
413
+ const scroll = vi.spyOn(host.settings().nativeElement, 'invoke');
401
414
  host.open.set(true);
402
415
  await after(OPENED);
403
416
  expect(scroll).not.toHaveBeenCalled();