@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
@@ -27,8 +27,14 @@ import { type IconName, ICONS } from '../icon/icons';
27
27
  */
28
28
  @Component({
29
29
  selector: 'ui-tab-bar',
30
- imports: [LYNX_ELEMENTS],
31
30
  encapsulation: ViewEncapsulation.None,
31
+ // The bar and each item are their host elements, styled through `host`,
32
+ // rather than a view inside the host. Angular gives a component's host
33
+ // every static attribute the app writes on it, so `<ui-tab-bar
34
+ // class="pt-2">` puts `pt-2` on the host whatever the component does with
35
+ // its `class` input: with a styled view inside as well, the padding applied
36
+ // twice.
37
+ //
32
38
  // `pb-safe` pads the bar by the bottom safe-area inset, so the items sit
33
39
  // above the iPhone's home indicator while the bar's background still runs
34
40
  // to the bottom edge, as a native tab bar's does. Lynx reports no insets on
@@ -38,11 +44,11 @@ import { type IconName, ICONS } from '../icon/icons';
38
44
  // native tab bar's container has: it tells VoiceOver that the buttons in it
39
45
  // are tabs. Android reads a single trait and has no tab bar trait, so there
40
46
  // the row stays a plain container.
41
- template: `
42
- <view accessibility-traits="tabbar" [class]="barClass()">
43
- <ng-content />
44
- </view>
45
- `,
47
+ host: {
48
+ 'accessibility-traits': 'tabbar',
49
+ '[class]': 'barClass()',
50
+ },
51
+ template: '<ng-content />',
46
52
  })
47
53
  export class UiTabBar {
48
54
  readonly userClass = input<string>('', { alias: 'class' });
@@ -97,7 +103,7 @@ export class UiTabBar {
97
103
  // `flex-1` has to be on the host, the row's real child: on an inner view
98
104
  // it would share out the host's width instead (same lesson as
99
105
  // ui-nav-drawer-content).
100
- class: 'flex-1 flex-col flex',
106
+ '[class]': 'contentClass()',
101
107
  // One accessibility element per tab, named by its label, so a screen
102
108
  // reader reads "Inbox, 3, button" instead of an unlabeled SVG and a word.
103
109
  // LynxRouterLinkActive adds `selected` to `button` while the route is
@@ -122,25 +128,23 @@ export class UiTabBar {
122
128
  '(bindtap)': 'pressed.emit()',
123
129
  },
124
130
  template: `
125
- <view [class]="contentClass()">
126
- <view class="relative">
127
- <svg [attr.content]="iconSvg()" style="width: 24px; height: 24px;" />
128
- @if (badgeText()) {
129
- <!-- Hangs off the icon's top-right corner, as on a native tab bar.
130
- min-w-4 keeps a one-digit badge round; longer ones grow into
131
- a pill. -->
132
- <view
133
- class="h-4 min-w-4 items-center rounded-full bg-destructive px-1 flex absolute -right-2.5 -top-1 justify-center"
131
+ <view class="relative">
132
+ <svg [attr.content]="iconSvg()" style="width: 24px; height: 24px;" />
133
+ @if (badgeText()) {
134
+ <!-- Hangs off the icon's top-right corner, as on a native tab bar.
135
+ min-w-4 keeps a one-digit badge round; longer ones grow into
136
+ a pill. -->
137
+ <view
138
+ class="h-4 min-w-4 items-center rounded-full bg-destructive px-1 flex absolute -right-2.5 -top-1 justify-center"
139
+ >
140
+ <text
141
+ class="text-[10px] font-semibold leading-4 text-destructive-foreground"
142
+ >{{ badgeText() }}</text
134
143
  >
135
- <text
136
- class="text-[10px] font-semibold leading-4 text-destructive-foreground"
137
- >{{ badgeText() }}</text
138
- >
139
- </view>
140
- }
141
- </view>
142
- <text [class]="labelClass()">{{ label() }}</text>
144
+ </view>
145
+ }
143
146
  </view>
147
+ <text [class]="labelClass()">{{ label() }}</text>
144
148
  `,
145
149
  })
146
150
  export class UiTabBarItem {
@@ -57,11 +57,11 @@ class TabsHost {
57
57
  const renderTabs = async () => {
58
58
  const result = await render(TabsHost);
59
59
  /**
60
- * A trigger's own view, which holds the tap listener, around its label.
60
+ * A trigger: its host, which holds the tap listener, around its label.
61
61
  */
62
62
  const trigger = (label: string) => result.getByText(label).parentElement!;
63
63
  /**
64
- * A panel's own view, which wraps its projected text.
64
+ * A panel: its host, around its projected text.
65
65
  */
66
66
  const panel = (text: string) => result.getByText(text).parentElement!;
67
67
  return {
@@ -69,9 +69,8 @@ const renderTabs = async () => {
69
69
  host: result.componentRef.instance as TabsHost,
70
70
  trigger,
71
71
  panel,
72
- /** The display of a panel's host element, which shows or hides it. */
73
- displayOf: (text: string) =>
74
- (panel(text).parentElement as HTMLElement).style.display,
72
+ /** The display of a panel, which shows or hides it. */
73
+ displayOf: (text: string) => panel(text).style.display,
75
74
  tap: async (label: string) => {
76
75
  fireEvent.tap(trigger(label));
77
76
  await waitForUpdate();
@@ -87,8 +86,6 @@ const CANCEL = 3;
87
86
  describe('UiTabs', () => {
88
87
  it("merges each part's class with its own", async () => {
89
88
  const { container, trigger, panel } = await renderTabs();
90
- // Each host element carries the given class too, so look for the view
91
- // that has both its own and the given class.
92
89
  expect(container.querySelector('.flex-col.w-80')).not.toBeNull();
93
90
  expect(container.querySelector('.rounded-lg.mb-2')).not.toBeNull();
94
91
  expect(
@@ -100,6 +97,21 @@ describe('UiTabs', () => {
100
97
  expect(panel('Account panel').classList).toContain('gap-2');
101
98
  });
102
99
 
100
+ // Angular gives a component's host every static attribute written on it,
101
+ // so a part that also styled a view inside its host applied the class
102
+ // twice (issue #181).
103
+ it('puts a class written on a part on that one element', async () => {
104
+ const { container, trigger, panel } = await renderTabs();
105
+ expect(container.querySelectorAll('.w-80')).toHaveLength(1);
106
+ expect(container.querySelectorAll('.mb-2')).toHaveLength(1);
107
+ expect([...container.querySelectorAll('.px-4')]).toEqual([
108
+ trigger('Account'),
109
+ ]);
110
+ expect([...container.querySelectorAll('.gap-2')]).toEqual([
111
+ panel('Account panel'),
112
+ ]);
113
+ });
114
+
103
115
  it("shows only the selected tab's panel, and highlights its trigger", async () => {
104
116
  const { getByText, trigger, displayOf } = await renderTabs();
105
117
  expect(displayOf('Account panel')).toBe('flex');
@@ -140,9 +152,8 @@ describe('UiTabs', () => {
140
152
 
141
153
  it('clips a hidden panel, whose text iOS would draw from its corner', async () => {
142
154
  const { panel } = await renderTabs();
143
- const hostOf = (text: string) => panel(text).parentElement as HTMLElement;
144
- expect(hostOf('Billing panel').style.overflow).toBe('hidden');
145
- expect(hostOf('Account panel').style.overflow).toBe('');
155
+ expect(panel('Billing panel').style.overflow).toBe('hidden');
156
+ expect(panel('Account panel').style.overflow).toBe('');
146
157
  });
147
158
 
148
159
  it('shows the tab its value is set to, without reporting a change', async () => {
@@ -287,10 +298,7 @@ describe('UiTabs', () => {
287
298
  await waitForUpdate();
288
299
  expect(animate).not.toHaveBeenCalled();
289
300
  expect(
290
- (
291
- result.getByText('Billing panel').parentElement!
292
- .parentElement as HTMLElement
293
- ).style.display,
301
+ result.getByText('Billing panel').parentElement!.style.display,
294
302
  ).toBe('flex');
295
303
  });
296
304
 
@@ -385,14 +393,10 @@ describe('UiTabsList, segmented', () => {
385
393
  providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
386
394
  });
387
395
  /**
388
- * A trigger's own view, around its label.
396
+ * A trigger: its host, the track's flex item, around its label.
389
397
  */
390
398
  const trigger = (label: string) => result.getByText(label).parentElement!;
391
- /**
392
- * A trigger's host, the track's flex item.
393
- */
394
- const slot = (label: string) => trigger(label).parentElement!;
395
- const track = () => slot('Photos').parentElement!;
399
+ const track = () => trigger('Photos').parentElement!;
396
400
  /**
397
401
  * The indicator's frame, which moves between the tabs.
398
402
  */
@@ -402,7 +406,6 @@ describe('UiTabsList, segmented', () => {
402
406
  ...result,
403
407
  host: result.componentRef.instance as SegmentedTabsHost,
404
408
  trigger,
405
- slot,
406
409
  track,
407
410
  frame,
408
411
  measure: async () => {
@@ -411,23 +414,22 @@ describe('UiTabsList, segmented', () => {
411
414
  ['Albums', 90],
412
415
  ['People', 180],
413
416
  ] as const) {
414
- fireEvent.layoutchange(slot(label), { detail: { width } });
417
+ fireEvent.layoutchange(trigger(label), { detail: { width } });
415
418
  }
416
419
  await waitForUpdate();
417
420
  },
418
- /** The display of a panel's host element, which shows or hides it. */
421
+ /** The display of a panel, which shows or hides it. */
419
422
  displayOf: (text: string) =>
420
- (result.getByText(text).parentElement!.parentElement as HTMLElement)
421
- .style.display,
423
+ result.getByText(text).parentElement!.style.display,
422
424
  };
423
425
  };
424
426
 
425
427
  it("draws the platform's segmented control, filling its width, its tabs sharing it", async () => {
426
- const { track, slot, trigger, frame, getByText } = await renderSegmented();
428
+ const { track, trigger, frame, getByText } = await renderSegmented();
427
429
  expect(track().classList).toContain('rounded-full');
428
430
  expect(track().classList).not.toContain('self-start');
429
- expect(slot('Albums').classList).toContain('flex-1');
430
- expect(slot('People').classList).toContain('flex-[2]');
431
+ expect(trigger('Albums').classList).toContain('flex-1');
432
+ expect(trigger('People').classList).toContain('flex-[2]');
431
433
  expect(trigger('Photos').classList).toContain('justify-center');
432
434
  expect(getByText('Photos').classList).toContain('font-medium');
433
435
  expect(getByText('Albums').classList).not.toContain('font-medium');
@@ -508,18 +510,18 @@ describe('UiTabsList, segmented', () => {
508
510
  });
509
511
 
510
512
  it('goes back to the default tabs when the variant changes', async () => {
511
- const { host, track, slot, frame } = await renderSegmented();
513
+ const { host, track, trigger, frame } = await renderSegmented();
512
514
  host.variant.set('default');
513
515
  await waitForUpdate();
514
516
  expect(track().classList).toContain('self-start');
515
- expect(slot('Photos').classList).not.toContain('flex-1');
517
+ expect(trigger('Photos').classList).not.toContain('flex-1');
516
518
  expect(frame()).toBeNull();
517
519
  });
518
520
 
519
521
  it('lets a trigger outside a list take touches as the default tabs do', async () => {
520
522
  const result = await render(LoneTriggerHost);
521
523
  const trigger = result.getByText('Alone').parentElement!;
522
- fireEvent.layoutchange(trigger.parentElement!, { detail: { width: 80 } });
524
+ fireEvent.layoutchange(trigger, { detail: { width: 80 } });
523
525
  fireEvent.touchstart(trigger, { touches: [{ pageX: 10 }] });
524
526
  fireEvent.touchmove(trigger, { touches: [{ pageX: 60 }] });
525
527
  fireEvent.touchend(trigger);
@@ -528,6 +530,6 @@ describe('UiTabsList, segmented', () => {
528
530
  fireEvent.tap(trigger);
529
531
  await waitForUpdate();
530
532
  expect(trigger.classList).toContain('bg-background');
531
- expect(trigger.parentElement!.classList).not.toContain('flex-1');
533
+ expect(trigger.classList).not.toContain('flex-1');
532
534
  });
533
535
  });
@@ -1,6 +1,6 @@
1
1
  import {
2
- type ElementRef,
3
2
  Component,
3
+ ElementRef,
4
4
  ViewEncapsulation,
5
5
  computed,
6
6
  contentChildren,
@@ -10,7 +10,6 @@ import {
10
10
  model,
11
11
  output,
12
12
  signal,
13
- viewChild,
14
13
  } from '@angular/core';
15
14
  import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
16
15
 
@@ -53,13 +52,16 @@ const tabsListHost = (): SegmentedHost => inject(UiTabsList).segmentedHost;
53
52
 
54
53
  @Component({
55
54
  selector: 'ui-tabs',
56
- imports: [LYNX_ELEMENTS],
57
55
  encapsulation: ViewEncapsulation.None,
58
- template: `
59
- <view [class]="containerClass()">
60
- <ng-content />
61
- </view>
62
- `,
56
+ // Each part is its host element, styled through `host`, rather than a view
57
+ // inside the host. Angular gives a component's host every static attribute
58
+ // the app writes on it, so `<ui-tabs-content class="p-4">` puts `p-4` on
59
+ // the host whatever the component does with its `class` input: with a
60
+ // styled view inside as well, the padding applied twice. On the host alone,
61
+ // a class also acts where the part sits in its parent's layout (`flex-1`,
62
+ // margins).
63
+ host: { '[class]': 'containerClass()' },
64
+ template: '<ng-content />',
63
65
  })
64
66
  export class UiTabs {
65
67
  readonly value = model<string>('');
@@ -139,7 +141,7 @@ export class UiTabs {
139
141
  */
140
142
  @Component({
141
143
  selector: 'ui-tabs-list',
142
- imports: [LYNX_ELEMENTS, UiSegmentedIndicator],
144
+ imports: [UiSegmentedIndicator],
143
145
  encapsulation: ViewEncapsulation.None,
144
146
  providers: [
145
147
  UiSegmentedControl,
@@ -147,17 +149,16 @@ export class UiTabs {
147
149
  ],
148
150
  // The mouse leaving the row lets go of an indicator dragged in the web
149
151
  // preview, where a drag gets no touchcancel.
152
+ host: {
153
+ 'accessibility-traits': 'tabbar',
154
+ '[class]': 'listClass()',
155
+ '(bindmouseleave)': 'segmentedControl.cancel()',
156
+ },
150
157
  template: `
151
- <view
152
- accessibility-traits="tabbar"
153
- [class]="listClass()"
154
- (bindmouseleave)="segmentedControl.cancel()"
155
- >
156
- @if (segmentedControl.underlay()) {
157
- <ui-segmented-indicator />
158
- }
159
- <ng-content />
160
- </view>
158
+ @if (segmentedControl.underlay()) {
159
+ <ui-segmented-indicator />
160
+ }
161
+ <ng-content />
161
162
  `,
162
163
  })
163
164
  export class UiTabsList {
@@ -208,9 +209,19 @@ export class UiTabsList {
208
209
  imports: [LYNX_ELEMENTS],
209
210
  encapsulation: ViewEncapsulation.None,
210
211
  host: {
211
- '[class]': 'hostClass()',
212
+ '[class]': 'triggerClass()',
213
+ '[style]': 'triggerStyle()',
212
214
  // The segmented control places its indicator from each tab's width.
213
215
  '(bindlayoutchange)': 'measure($event)',
216
+ '(bindtap)': 'onTap()',
217
+ '(bindtouchstart)': 'press($event)',
218
+ '(bindtouchmove)': 'move($event)',
219
+ '(bindtouchend)': 'release()',
220
+ '(bindtouchcancel)': 'cancel()',
221
+ '(bindmousedown)': 'press($event)',
222
+ '(bindmousemove)': 'move($event)',
223
+ '(bindmouseup)': 'release()',
224
+ '(bindmouseleave)': 'leave()',
214
225
  },
215
226
  // The label is the accessibility element, a button (a tab, in the list's
216
227
  // tab bar) named by its own text. A view would need a label of its own.
@@ -218,30 +229,15 @@ export class UiTabsList {
218
229
  // lynxAccessibilityTraits adds the trait to `button` on iOS, and leaves
219
230
  // Android, which reads one trait, with `button`.
220
231
  template: `
221
- <view
222
- #trigger
223
- [class]="triggerClass()"
224
- [style]="triggerStyle()"
225
- (bindtap)="onTap()"
226
- (bindtouchstart)="press($event)"
227
- (bindtouchmove)="move($event)"
228
- (bindtouchend)="release()"
229
- (bindtouchcancel)="cancel()"
230
- (bindmousedown)="press($event)"
231
- (bindmousemove)="move($event)"
232
- (bindmouseup)="release()"
233
- (bindmouseleave)="leave()"
234
- >
235
- @if (stateLayerClass(); as stateLayer) {
236
- <view [class]="stateLayer"></view>
237
- }
238
- <text
239
- accessibility-traits="button"
240
- [lynxAccessibilityTraits]="{ selected: isActive() }"
241
- [class]="textClass()"
242
- ><ng-content
243
- /></text>
244
- </view>
232
+ @if (stateLayerClass(); as stateLayer) {
233
+ <view [class]="stateLayer"></view>
234
+ }
235
+ <text
236
+ accessibility-traits="button"
237
+ [lynxAccessibilityTraits]="{ selected: isActive() }"
238
+ [class]="textClass()"
239
+ ><ng-content
240
+ /></text>
245
241
  `,
246
242
  })
247
243
  export class UiTabsTrigger {
@@ -254,7 +250,7 @@ export class UiTabsTrigger {
254
250
  readonly triggerValue = input.required<string>({ alias: 'value' });
255
251
  readonly userClass = input<string>('', { alias: 'class' });
256
252
 
257
- readonly triggerRef = viewChild.required<ElementRef>('trigger');
253
+ readonly #host = inject(ElementRef).nativeElement;
258
254
  #anim?: AnimationHandle;
259
255
  #previousActive?: boolean;
260
256
 
@@ -270,16 +266,6 @@ export class UiTabsTrigger {
270
266
  this.#segmented?.enabled() ? this.#segmented : null,
271
267
  );
272
268
 
273
- /**
274
- * A segment's host is the track's flex item, an equal share of it. Angular
275
- * puts the app's static `class` on the host as well as on the trigger, so
276
- * the two are merged here: `class="flex-[2]"` makes a wider tab.
277
- */
278
- protected readonly hostClass = computed(() => {
279
- const segment = this.#segment();
280
- return segment ? cn(segment.segmentHostClass, this.userClass()) : '';
281
- });
282
-
283
269
  protected readonly triggerStyle = computed(
284
270
  () => this.#segment()?.segmentStyle(this.triggerValue()) ?? null,
285
271
  );
@@ -296,7 +282,7 @@ export class UiTabsTrigger {
296
282
  /** Animates the trigger's background when it's selected or deselected. */
297
283
  protected readonly animatingSelection = effect(() => {
298
284
  const active = this.isActive();
299
- const el = this.triggerRef().nativeElement;
285
+ const el = this.#host;
300
286
  const wasActive = this.#previousActive;
301
287
  this.#previousActive = active;
302
288
  // The first run only records the starting state, and only a change has
@@ -348,8 +334,12 @@ export class UiTabsTrigger {
348
334
  const segment = this.#segment();
349
335
  if (segment) {
350
336
  // The list provides the segmented control, so a segment is in one.
337
+ // The host is both the segment's slot in the track (an equal share of
338
+ // it) and its own view, so an app's `class="flex-[2]"` makes a wider
339
+ // tab.
351
340
  const place = segmentPlace(this.#list!.triggers(), this);
352
341
  return cn(
342
+ segment.segmentHostClass,
353
343
  segment.segmentClass(this.triggerValue(), place),
354
344
  this.userClass(),
355
345
  );
@@ -420,34 +410,28 @@ export class UiTabsTrigger {
420
410
 
421
411
  @Component({
422
412
  selector: 'ui-tabs-content',
423
- imports: [LYNX_ELEMENTS],
424
413
  encapsulation: ViewEncapsulation.None,
425
414
  host: {
415
+ '[class]': 'contentClass()',
426
416
  // Hide the WHOLE host when inactive so it reserves no layout space.
427
417
  //
428
418
  // The caller writes `class="flex-1"` on <ui-tabs-content>, and Angular
429
419
  // applies that to the host element. All three content hosts live as siblings
430
420
  // inside the flex-col <ui-tabs>, so three flex-1 hosts each grow to fill a
431
421
  // third of the available height — pushing each panel lower than the last
432
- // (the "Done tab starts at the very bottom" stacking). Hiding only the inner
433
- // view doesn't help: the flex-1 host still claims its third.
422
+ // (the "Done tab starts at the very bottom" stacking).
434
423
  //
435
424
  // Toggling the host's own `display` collapses inactive hosts to 0×0 (Lynx
436
425
  // `display: none`) so they claim no space at all, while the single active
437
- // host keeps its flex-1 and fills the tabs area. `flex-direction: column`
438
- // makes the host lay its content out vertically when shown.
426
+ // host keeps its flex-1 and fills the tabs area. The inline display wins
427
+ // over the `flex` in the host's classes.
439
428
  '[style.display]': "isActive() ? 'flex' : 'none'",
440
- '[style.flexDirection]': "'column'",
441
429
  // A hidden panel's 0×0 doesn't stop iOS drawing its text: each line is
442
430
  // laid out at 0×0 too, and its glyphs spill out from the panel's corner,
443
431
  // over the tab list. Clipping the hidden host to its 0×0 hides them.
444
432
  '[style.overflow]': "isActive() ? null : 'hidden'",
445
433
  },
446
- template: `
447
- <view #content [class]="contentClass()">
448
- <ng-content />
449
- </view>
450
- `,
434
+ template: '<ng-content />',
451
435
  })
452
436
  export class UiTabsContent {
453
437
  readonly #tabs = inject(UiTabs);
@@ -456,7 +440,7 @@ export class UiTabsContent {
456
440
  readonly contentValue = input.required<string>({ alias: 'value' });
457
441
  readonly userClass = input<string>('', { alias: 'class' });
458
442
 
459
- readonly contentRef = viewChild.required<ElementRef>('content');
443
+ readonly #host = inject(ElementRef).nativeElement;
460
444
  #anim?: AnimationHandle;
461
445
  #isFirstRender = true;
462
446
 
@@ -476,8 +460,7 @@ export class UiTabsContent {
476
460
  * that vanished and never came back. Keeping every panel mounted and only
477
461
  * toggling `display` never destroys or re-parents anything: inactive panels
478
462
  * collapse to 0×0 (Lynx `display: none`) so they take no space, and the
479
- * active one simply reappears in place. That also means the content view
480
- * always exists, so `contentRef` is required.
463
+ * active one simply reappears in place.
481
464
  */
482
465
  protected readonly revealing = effect(() => {
483
466
  if (!this.isActive()) return;
@@ -485,7 +468,7 @@ export class UiTabsContent {
485
468
  setTimeout(() => {
486
469
  // Under Reduce Motion the panel just appears.
487
470
  if (this.#settings.reduceMotion()) return;
488
- const el = this.contentRef().nativeElement;
471
+ const el = this.#host;
489
472
 
490
473
  this.#anim?.cancel();
491
474
 
@@ -504,8 +487,8 @@ export class UiTabsContent {
504
487
  }, 0);
505
488
  });
506
489
 
507
- // Visibility is controlled at the host (see `host` block); the inner view is
508
- // always a normal flex column that fills the shown host.
490
+ // Visibility is controlled by the host's inline display (see `host`); its
491
+ // classes make it a flex column when shown.
509
492
  protected readonly contentClass = computed(() =>
510
493
  cn('mt-2 flex-col flex', this.userClass()),
511
494
  );
@@ -56,7 +56,7 @@ const renderTextarea = async () => {
56
56
  ...result,
57
57
  host: result.componentRef.instance as TextareaHost,
58
58
  field: () => result.container.querySelector('textarea')!,
59
- // The field's own column; the host element renders as a view too.
59
+ // The field's column: its host.
60
60
  column: () => result.container.querySelector('.flex-col')!,
61
61
  wrapper: () => result.container.querySelector('.bg-muted') as HTMLElement,
62
62
  // The layer that draws the focus ring around the field.
@@ -84,6 +84,16 @@ describe('UiTextarea', () => {
84
84
  expect(queryByText('Add a note')).toBeNull();
85
85
  });
86
86
 
87
+ // Angular gives a component's host every static attribute written on it
88
+ // (issue #181): the class styled the column twice, and the placeholder made
89
+ // a second match for getByPlaceholderText.
90
+ it('puts its class and placeholder on one element each', async () => {
91
+ const { container, field, getAllByPlaceholderText } =
92
+ await renderTextarea();
93
+ expect(container.querySelectorAll('.w-full')).toHaveLength(1);
94
+ expect(getAllByPlaceholderText('Add a note')).toEqual([field()]);
95
+ });
96
+
87
97
  it('shows its label', async () => {
88
98
  const { host, getByText } = await renderTextarea();
89
99
  host.label.set('Notes');