@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,7 +1,7 @@
1
1
  import {
2
- type ElementRef,
3
2
  type OnDestroy,
4
3
  Component,
4
+ ElementRef,
5
5
  ViewEncapsulation,
6
6
  booleanAttribute,
7
7
  computed,
@@ -36,6 +36,14 @@ import { UI_ICON_COLOR } from '../icon/icon-theme';
36
36
  selector: 'ui-nav-drawer',
37
37
  imports: [LYNX_ELEMENTS],
38
38
  encapsulation: ViewEncapsulation.None,
39
+ // The drawer's class is its panel's, inside the overlay. Angular also gives
40
+ // the host every static attribute the app writes on <ui-nav-drawer>, and
41
+ // the host sits in the page's layout where the drawer is written, so a
42
+ // `class="p-8"` padded the page there too. The host drops it. The parts
43
+ // below are their host elements for the same reason: a class on a part
44
+ // lands on its host anyway, so a view inside it styled with the same class
45
+ // applied it twice.
46
+ host: { '[attr.class]': 'null' },
39
47
  template: `
40
48
  <overlay [attr.visible]="overlayVisible()" [style]="overlayStyle()">
41
49
  <!-- bg-black/50 dims the content behind the drawer. The fadeIn/fadeOut in
@@ -238,13 +246,9 @@ export class UiNavDrawer implements OnDestroy {
238
246
 
239
247
  @Component({
240
248
  selector: 'ui-nav-drawer-header',
241
- imports: [LYNX_ELEMENTS],
242
249
  encapsulation: ViewEncapsulation.None,
243
- template: `
244
- <view [class]="containerClass()">
245
- <ng-content />
246
- </view>
247
- `,
250
+ host: { '[class]': 'containerClass()' },
251
+ template: '<ng-content />',
248
252
  })
249
253
  export class UiNavDrawerHeader {
250
254
  readonly userClass = input<string>('', { alias: 'class' });
@@ -299,9 +303,11 @@ const navDrawerItem = () => UiNavDrawerItem;
299
303
  // makes the host lay the scroll-view out vertically to fill that height. The
300
304
  // panel's flex-col stretches the host to full width by default (Lynx
301
305
  // align-items: stretch), so no width class is needed on the host.
302
- host: {
303
- class: 'flex-1 flex-col flex',
304
- },
306
+ //
307
+ // The app's class goes on the host too, rather than on the scroll-view:
308
+ // Angular gives the host every static attribute written on it anyway, so
309
+ // with the scroll-view styled as well, a padding applied twice.
310
+ host: { '[class]': 'hostClass()' },
305
311
  // With the host now a definite height, the scroll-view fills it with `h-full`
306
312
  // (the explicit height Lynx requires to actually scroll) and `w-full` to span
307
313
  // the host width. This is the bound the scroll-view was missing above.
@@ -313,7 +319,7 @@ const navDrawerItem = () => UiNavDrawerItem;
313
319
  template: `
314
320
  <scroll-view
315
321
  scroll-orientation="vertical"
316
- [class]="contentClass()"
322
+ class="h-full w-full"
317
323
  (bindcontentsizechanged)="onContentLayout()"
318
324
  >
319
325
  <ng-content />
@@ -330,8 +336,8 @@ export class UiNavDrawerContent {
330
336
  // nested inside the @for group wrappers, not just the direct content children.
331
337
  readonly items = contentChildren(navDrawerItem(), { descendants: true });
332
338
 
333
- protected readonly contentClass = computed(() =>
334
- cn('h-full w-full', this.userClass()),
339
+ protected readonly hostClass = computed(() =>
340
+ cn('flex-1 flex-col flex', this.userClass()),
335
341
  );
336
342
 
337
343
  /**
@@ -383,26 +389,21 @@ const itemIconColor = () => {
383
389
 
384
390
  @Component({
385
391
  selector: 'ui-nav-drawer-item',
386
- imports: [LYNX_ELEMENTS],
387
392
  encapsulation: ViewEncapsulation.None,
388
393
  // Icons projected into the item (see the variant below) inject this through
389
394
  // the item's element injector, which content projection keeps them under.
390
395
  providers: [{ provide: UI_ICON_COLOR, useFactory: itemIconColor }],
391
- template: `
392
- <view
393
- #container
394
- [class]="containerClass()"
395
- (bindtouchstart)="onPressStart()"
396
- (bindtouchend)="onPressEnd()"
397
- (bindtouchcancel)="onPressCancel()"
398
- (bindmousedown)="onPressStart()"
399
- (bindmouseup)="onPressEnd()"
400
- (bindmouseleave)="onPressCancel()"
401
- (bindtap)="onTap()"
402
- >
403
- <ng-content />
404
- </view>
405
- `,
396
+ host: {
397
+ '[class]': 'containerClass()',
398
+ '(bindtouchstart)': 'onPressStart()',
399
+ '(bindtouchend)': 'onPressEnd()',
400
+ '(bindtouchcancel)': 'onPressCancel()',
401
+ '(bindmousedown)': 'onPressStart()',
402
+ '(bindmouseup)': 'onPressEnd()',
403
+ '(bindmouseleave)': 'onPressCancel()',
404
+ '(bindtap)': 'onTap()',
405
+ },
406
+ template: '<ng-content />',
406
407
  })
407
408
  export class UiNavDrawerItem {
408
409
  readonly #drawer = inject(UiNavDrawer);
@@ -414,7 +415,7 @@ export class UiNavDrawerItem {
414
415
 
415
416
  readonly pressed = output<void>();
416
417
 
417
- readonly containerRef = viewChild<ElementRef>('container');
418
+ readonly #host = inject(ElementRef).nativeElement;
418
419
  #pressAnim?: AnimationHandle;
419
420
 
420
421
  // `destructive` recolors the item's content, which the item doesn't render
@@ -435,20 +436,17 @@ export class UiNavDrawerItem {
435
436
 
436
437
  protected onPressStart(): void {
437
438
  this.#pressAnim?.cancel();
438
- this.#pressAnim = pressDown(
439
- this.containerRef()?.nativeElement,
440
- SCALE.pressDownLight,
441
- );
439
+ this.#pressAnim = pressDown(this.#host, SCALE.pressDownLight);
442
440
  }
443
441
 
444
442
  protected onPressEnd(): void {
445
443
  this.#pressAnim?.cancel();
446
- this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
444
+ this.#pressAnim = pressRelease(this.#host);
447
445
  }
448
446
 
449
447
  protected onPressCancel(): void {
450
448
  this.#pressAnim?.cancel();
451
- this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
449
+ this.#pressAnim = pressRelease(this.#host);
452
450
  }
453
451
 
454
452
  protected onTap(): void {
@@ -478,7 +476,7 @@ export class UiNavDrawerItem {
478
476
  // - Native Lynx (iOS/Android) reads params.scrollIntoViewOptions.
479
477
  // - Web maps invoke() onto the DOM element.scrollIntoView(params), which
480
478
  // reads top-level ScrollIntoViewOptions (block/behavior).
481
- this.containerRef()?.nativeElement.invoke('scrollIntoView', {
479
+ this.#host.invoke('scrollIntoView', {
482
480
  scrollIntoViewOptions: { block: 'center', behavior: 'auto' },
483
481
  block: 'center',
484
482
  behavior: 'auto',
@@ -488,13 +486,9 @@ export class UiNavDrawerItem {
488
486
 
489
487
  @Component({
490
488
  selector: 'ui-nav-drawer-footer',
491
- imports: [LYNX_ELEMENTS],
492
489
  encapsulation: ViewEncapsulation.None,
493
- template: `
494
- <view [class]="containerClass()">
495
- <ng-content />
496
- </view>
497
- `,
490
+ host: { '[class]': 'containerClass()' },
491
+ template: '<ng-content />',
498
492
  })
499
493
  export class UiNavDrawerFooter {
500
494
  readonly userClass = input<string>('', { alias: 'class' });
@@ -515,28 +509,26 @@ export class UiNavDrawerFooter {
515
509
 
516
510
  @Component({
517
511
  selector: 'ui-nav-drawer-trigger',
518
- imports: [LYNX_ELEMENTS, UiIcon],
512
+ imports: [UiIcon],
519
513
  encapsulation: ViewEncapsulation.None,
520
- template: `
521
- <!-- The trigger is only an icon, so it carries the words a screen reader
522
- says for it. -->
523
- <view
524
- #container
525
- [class]="containerClass()"
526
- [accessibility-element]="true"
527
- accessibility-traits="button"
528
- [accessibility-label]="label()"
529
- (bindtouchstart)="onPressStart()"
530
- (bindtouchend)="onPressEnd()"
531
- (bindtouchcancel)="onPressCancel()"
532
- (bindmousedown)="onPressStart()"
533
- (bindmouseup)="onPressEnd()"
534
- (bindmouseleave)="onPressCancel()"
535
- (bindtap)="onTap()"
536
- >
537
- <ui-icon name="menu" size="md" />
538
- </view>
539
- `,
514
+ // The trigger is only an icon, so it carries the words a screen reader
515
+ // says for it. The label is an attribute binding: a property binding on a
516
+ // component's host is checked against the DOM schema, which has no
517
+ // `accessibility-label`.
518
+ host: {
519
+ '[class]': 'containerClass()',
520
+ 'accessibility-element': 'true',
521
+ 'accessibility-traits': 'button',
522
+ '[attr.accessibility-label]': 'label()',
523
+ '(bindtouchstart)': 'onPressStart()',
524
+ '(bindtouchend)': 'onPressEnd()',
525
+ '(bindtouchcancel)': 'onPressCancel()',
526
+ '(bindmousedown)': 'onPressStart()',
527
+ '(bindmouseup)': 'onPressEnd()',
528
+ '(bindmouseleave)': 'onPressCancel()',
529
+ '(bindtap)': 'onTap()',
530
+ },
531
+ template: '<ui-icon name="menu" size="md" />',
540
532
  })
541
533
  export class UiNavDrawerTrigger {
542
534
  /** What a screen reader says for the trigger. */
@@ -545,7 +537,7 @@ export class UiNavDrawerTrigger {
545
537
 
546
538
  readonly pressed = output<void>();
547
539
 
548
- readonly containerRef = viewChild<ElementRef>('container');
540
+ readonly #host = inject(ElementRef).nativeElement;
549
541
  #pressAnim?: AnimationHandle;
550
542
 
551
543
  protected readonly containerClass = computed(() =>
@@ -557,17 +549,17 @@ export class UiNavDrawerTrigger {
557
549
 
558
550
  protected onPressStart(): void {
559
551
  this.#pressAnim?.cancel();
560
- this.#pressAnim = pressDown(this.containerRef()?.nativeElement);
552
+ this.#pressAnim = pressDown(this.#host);
561
553
  }
562
554
 
563
555
  protected onPressEnd(): void {
564
556
  this.#pressAnim?.cancel();
565
- this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
557
+ this.#pressAnim = pressRelease(this.#host);
566
558
  }
567
559
 
568
560
  protected onPressCancel(): void {
569
561
  this.#pressAnim?.cancel();
570
- this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
562
+ this.#pressAnim = pressRelease(this.#host);
571
563
  }
572
564
 
573
565
  protected onTap(): void {
@@ -415,6 +415,19 @@ describe('UiNavigationBar without the Lynx router', () => {
415
415
  expect(result.getByRole('button', { name: 'Back' })).toBeTruthy();
416
416
  });
417
417
 
418
+ // Angular gives a component's host every static attribute written on it:
419
+ // the title was a tooltip over the bar in the web preview (issue #181).
420
+ it('shows a title written as an attribute only in the bar', async () => {
421
+ const result = await render(PlainBarHost, {
422
+ providers: [
423
+ { provide: LynxSystemInfo, useValue: IOS_26 },
424
+ { provide: LynxAccessibilitySettings, useValue: settings },
425
+ ],
426
+ });
427
+ expect(result.container.querySelectorAll('[title]')).toHaveLength(0);
428
+ expect(result.getByRole('heading', { name: 'Settings' })).toBeTruthy();
429
+ });
430
+
418
431
  it('has no Back button with a LocationStrategy of the app’s own', async () => {
419
432
  const { back, host } = await renderBar(IOS_26, [
420
433
  { provide: LocationStrategy, useClass: MockLocationStrategy },
@@ -118,8 +118,13 @@ const historyOf = (injector: Injector): LynxLocationStrategy | null => {
118
118
  selector: 'ui-navigation-bar',
119
119
  imports: [LYNX_ELEMENTS, UiGlass, UiToolbarItem],
120
120
  encapsulation: ViewEncapsulation.None,
121
+ // Angular gives a component's host every static attribute the app writes
122
+ // on it, so a `title="Inbox"` lands on the host as well as in the title's
123
+ // text, where a browser shows it as a tooltip over the bar in the web
124
+ // preview, and getByTitle finds it. The host drops it.
121
125
  host: {
122
126
  '[class]': 'hostClass()',
127
+ '[attr.title]': 'null',
123
128
  '[style.transition]': 'containerTransition()',
124
129
  '[style.background-image]': 'edgeFade()',
125
130
  },
@@ -91,6 +91,15 @@ describe('UiPagination', () => {
91
91
  expect(container.querySelector('.gap-1')?.classList).toContain('mt-4');
92
92
  });
93
93
 
94
+ // Angular gives a component's host every static attribute written on it,
95
+ // so a row inside the host as well applied the class twice (issue #181).
96
+ it('puts its class on one element, the row', async () => {
97
+ const { container } = await renderPagination(7, 3);
98
+ expect([...container.querySelectorAll('.mt-4')]).toEqual([
99
+ container.querySelector('.gap-1'),
100
+ ]);
101
+ });
102
+
94
103
  it('leaves a gap before the last page near the start', async () => {
95
104
  const { container } = await renderPagination(10, 2);
96
105
  expect(shown(container)).toEqual(['1', '2', '3', '...', '10']);
@@ -31,58 +31,61 @@ const defaultPageLabel = (page: number, current: boolean): string =>
31
31
  // The chevrons are drawn in the theme's foreground, like the page numbers
32
32
  // next to them. An SVG can't read the text color classes, so the color is
33
33
  // resolved and written into the markup (see icon-theme.ts).
34
+ //
35
+ // The host is the row, styled through `host` rather than as a view inside
36
+ // it: Angular gives a component's host every static attribute the app
37
+ // writes on it, so a class on <ui-pagination> lands on the host whatever
38
+ // the component does with its `class` input, and a row inside the host
39
+ // applied it twice.
40
+ host: { '[class]': 'containerClass()' },
34
41
  template: `
35
- <view [class]="containerClass()">
36
- <view
37
- [class]="navButtonClass(page() <= 1)"
38
- [accessibility-element]="true"
39
- accessibility-traits="button"
40
- [accessibility-label]="previousLabel()"
41
- (bindtap)="prev()"
42
- >
43
- <svg
44
- [attr.content]="chevronLeftSvg()"
45
- style="width: 16px; height: 16px;"
46
- />
47
- </view>
48
-
49
- @for (item of visiblePages(); track item.key) {
50
- @if (item.type === 'page') {
51
- <view
52
- [class]="pageButtonClass(item.value === page())"
53
- [accessibility-element]="true"
54
- accessibility-traits="button"
55
- [accessibility-label]="
56
- pageLabel()(item.value, item.value === page())
57
- "
58
- (bindtap)="goTo(item.value)"
59
- >
60
- <text [class]="pageTextClass(item.value === page())">{{
61
- item.value
62
- }}</text>
63
- </view>
64
- } @else {
65
- <view
66
- class="h-8 w-8 items-center flex justify-center"
67
- [accessibility-elements-hidden]="true"
68
- >
69
- <text class="text-xs text-muted-foreground">...</text>
70
- </view>
71
- }
42
+ <view
43
+ [class]="navButtonClass(page() <= 1)"
44
+ [accessibility-element]="true"
45
+ accessibility-traits="button"
46
+ [accessibility-label]="previousLabel()"
47
+ (bindtap)="prev()"
48
+ >
49
+ <svg
50
+ [attr.content]="chevronLeftSvg()"
51
+ style="width: 16px; height: 16px;"
52
+ />
53
+ </view>
54
+
55
+ @for (item of visiblePages(); track item.key) {
56
+ @if (item.type === 'page') {
57
+ <view
58
+ [class]="pageButtonClass(item.value === page())"
59
+ [accessibility-element]="true"
60
+ accessibility-traits="button"
61
+ [accessibility-label]="pageLabel()(item.value, item.value === page())"
62
+ (bindtap)="goTo(item.value)"
63
+ >
64
+ <text [class]="pageTextClass(item.value === page())">{{
65
+ item.value
66
+ }}</text>
67
+ </view>
68
+ } @else {
69
+ <view
70
+ class="h-8 w-8 items-center flex justify-center"
71
+ [accessibility-elements-hidden]="true"
72
+ >
73
+ <text class="text-xs text-muted-foreground">...</text>
74
+ </view>
72
75
  }
76
+ }
73
77
 
74
- <view
75
- [class]="navButtonClass(page() >= totalPages())"
76
- [accessibility-element]="true"
77
- accessibility-traits="button"
78
- [accessibility-label]="nextLabel()"
79
- (bindtap)="next()"
80
- >
81
- <svg
82
- [attr.content]="chevronRightSvg()"
83
- style="width: 16px; height: 16px;"
84
- />
85
- </view>
78
+ <view
79
+ [class]="navButtonClass(page() >= totalPages())"
80
+ [accessibility-element]="true"
81
+ accessibility-traits="button"
82
+ [accessibility-label]="nextLabel()"
83
+ (bindtap)="next()"
84
+ >
85
+ <svg
86
+ [attr.content]="chevronRightSvg()"
87
+ style="width: 16px; height: 16px;"
88
+ />
86
89
  </view>
87
90
  `,
88
91
  })
@@ -282,5 +282,8 @@ describe('UiPopoverContent', () => {
282
282
  expect(content.classList).toContain('w-60');
283
283
  expect(content.classList).toContain('p-3');
284
284
  expect(content.classList).not.toContain('p-4');
285
+ // Angular gives a component's host every static attribute written on it
286
+ // (issue #181): the content is its host, so the class is on one element.
287
+ expect([...rendered.container.querySelectorAll('.p-3')]).toEqual([content]);
285
288
  });
286
289
  });
@@ -53,6 +53,13 @@ describe('UiProgress', () => {
53
53
  });
54
54
  afterEach(() => animate.mockRestore());
55
55
 
56
+ // A class on a component lands on its host too (issue #181): the host is
57
+ // the track, so its height is set once.
58
+ it('puts its class on one element, the track', async () => {
59
+ const { container, track } = await renderProgress();
60
+ expect([...container.querySelectorAll('.h-3')]).toEqual([track()]);
61
+ });
62
+
56
63
  it('fills the track by the value out of max', async () => {
57
64
  const { fill, track } = await renderProgress();
58
65
  expect(fill().getAttribute('style')).toContain('width: 40%');
@@ -13,16 +13,18 @@ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
13
13
  import { type AnimationHandle, DURATION, EASING } from '../../utils/animate';
14
14
  import { cn } from '../../utils/cn';
15
15
 
16
+ /**
17
+ * The track is the host, styled through `host`, with the fill inside.
18
+ * Angular gives a component's host every static attribute the app writes on
19
+ * it, so a class on <ui-progress> lands on the host whatever the component
20
+ * does: with a styled track inside as well, a margin applied twice.
21
+ */
16
22
  @Component({
17
23
  selector: 'ui-progress',
18
- standalone: true,
19
24
  imports: [LYNX_ELEMENTS],
20
25
  encapsulation: ViewEncapsulation.None,
21
- template: `
22
- <view [class]="trackClass()">
23
- <view #fill [class]="fillClass()" [style]="fillStyle()" />
24
- </view>
25
- `,
26
+ host: { '[class]': 'trackClass()' },
27
+ template: `<view #fill [class]="fillClass()" [style]="fillStyle()" />`,
26
28
  })
27
29
  export class UiProgress {
28
30
  readonly value = input(0);
@@ -78,6 +78,14 @@ describe('UiRadioGroup', () => {
78
78
  expect(item('Banana').classList).toContain('h-11');
79
79
  });
80
80
 
81
+ // A class on a component lands on its host too (issue #181): the group and
82
+ // each item are their hosts, so each class applies once.
83
+ it('puts each class on one element', async () => {
84
+ const { container, item } = await renderGroup();
85
+ expect(container.querySelectorAll('.mt-2')).toHaveLength(1);
86
+ expect([...container.querySelectorAll('.pl-1')]).toEqual([item('Apple')]);
87
+ });
88
+
81
89
  it('shows the dot of the selected item only', async () => {
82
90
  const { dot } = await renderGroup();
83
91
  expect(dot('Apple').getAttribute('style')).toContain('opacity: 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 } =