@blotch/dolan 22.2.6 → 22.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/commands/install-packages.test.js +7 -2
  2. package/dist/commands/install-packages.test.js.map +1 -1
  3. package/dist/registry.js +8 -2
  4. package/dist/registry.js.map +1 -1
  5. package/dist/registry.test.js +1 -0
  6. package/dist/registry.test.js.map +1 -1
  7. package/dist/ui/components/accordion/accordion.test.ts +24 -7
  8. package/dist/ui/components/accordion/accordion.ts +34 -44
  9. package/dist/ui/components/action-sheet/action-sheet.test.ts +18 -1
  10. package/dist/ui/components/action-sheet/action-sheet.ts +55 -60
  11. package/dist/ui/components/alert/alert.test.ts +17 -0
  12. package/dist/ui/components/alert/alert.ts +23 -14
  13. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +19 -4
  14. package/dist/ui/components/alert-dialog/alert-dialog.ts +31 -20
  15. package/dist/ui/components/aspect-ratio/aspect-ratio.test.ts +38 -2
  16. package/dist/ui/components/aspect-ratio/aspect-ratio.ts +11 -8
  17. package/dist/ui/components/avatar/avatar.test.ts +7 -0
  18. package/dist/ui/components/avatar/avatar.ts +35 -30
  19. package/dist/ui/components/badge/badge.test.ts +9 -0
  20. package/dist/ui/components/badge/badge.ts +12 -10
  21. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +7 -0
  22. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +5 -0
  23. package/dist/ui/components/button/button.test.ts +29 -3
  24. package/dist/ui/components/button/button.ts +67 -62
  25. package/dist/ui/components/button-group/button-group.test.ts +37 -4
  26. package/dist/ui/components/button-group/button-group.ts +60 -51
  27. package/dist/ui/components/card/card.test.ts +35 -2
  28. package/dist/ui/components/card/card.ts +41 -53
  29. package/dist/ui/components/chart/chart-presets.test.ts +16 -2
  30. package/dist/ui/components/chart/chart-presets.ts +22 -3
  31. package/dist/ui/components/chart/chart.test.ts +35 -6
  32. package/dist/ui/components/chart/chart.ts +14 -3
  33. package/dist/ui/components/checkbox/checkbox.test.ts +11 -4
  34. package/dist/ui/components/checkbox/checkbox.ts +21 -14
  35. package/dist/ui/components/collapsible/collapsible.test.ts +20 -10
  36. package/dist/ui/components/collapsible/collapsible.ts +29 -40
  37. package/dist/ui/components/dialog/dialog.test.ts +17 -1
  38. package/dist/ui/components/dialog/dialog.ts +24 -17
  39. package/dist/ui/components/dock/dock.test.ts +30 -5
  40. package/dist/ui/components/dock/dock.ts +29 -27
  41. package/dist/ui/components/empty-state/empty-state.test.ts +9 -0
  42. package/dist/ui/components/empty-state/empty-state.ts +20 -11
  43. package/dist/ui/components/icon/icon.test.ts +17 -0
  44. package/dist/ui/components/icon/icon.ts +19 -17
  45. package/dist/ui/components/input/input.test.ts +55 -3
  46. package/dist/ui/components/input/input.ts +91 -65
  47. package/dist/ui/components/label/label.test.ts +15 -0
  48. package/dist/ui/components/label/label.ts +12 -4
  49. package/dist/ui/components/list/list.test.ts +13 -3
  50. package/dist/ui/components/list/list.ts +14 -2
  51. package/dist/ui/components/menu/menu.test.ts +8 -0
  52. package/dist/ui/components/menu/menu.ts +4 -0
  53. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +34 -21
  54. package/dist/ui/components/nav-drawer/nav-drawer.ts +60 -68
  55. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +13 -0
  56. package/dist/ui/components/navigation-bar/navigation-bar.ts +5 -0
  57. package/dist/ui/components/pagination/pagination.test.ts +9 -0
  58. package/dist/ui/components/pagination/pagination.ts +52 -49
  59. package/dist/ui/components/popover/popover.test.ts +3 -0
  60. package/dist/ui/components/progress/progress.test.ts +7 -0
  61. package/dist/ui/components/progress/progress.ts +8 -6
  62. package/dist/ui/components/radio-group/radio-group.test.ts +8 -0
  63. package/dist/ui/components/radio-group/radio-group.ts +22 -19
  64. package/dist/ui/components/scroll-area/scroll-area.test.ts +14 -2
  65. package/dist/ui/components/scroll-area/scroll-area.ts +15 -3
  66. package/dist/ui/components/search-field/search-field.test.ts +25 -0
  67. package/dist/ui/components/search-field/search-field.ts +7 -1
  68. package/dist/ui/components/select/select.test.ts +32 -5
  69. package/dist/ui/components/select/select.ts +32 -11
  70. package/dist/ui/components/separator/separator.test.ts +3 -0
  71. package/dist/ui/components/sheet/sheet.test.ts +18 -0
  72. package/dist/ui/components/sheet/sheet.ts +24 -24
  73. package/dist/ui/components/skeleton/skeleton.test.ts +8 -0
  74. package/dist/ui/components/skeleton/skeleton.ts +13 -8
  75. package/dist/ui/components/slider/slider.test.ts +13 -1
  76. package/dist/ui/components/slider/slider.ts +16 -3
  77. package/dist/ui/components/spinner/spinner.test.ts +16 -1
  78. package/dist/ui/components/spinner/spinner.ts +15 -7
  79. package/dist/ui/components/switch/switch.test.ts +13 -2
  80. package/dist/ui/components/switch/switch.ts +15 -1
  81. package/dist/ui/components/tab-bar/tab-bar.test.ts +32 -6
  82. package/dist/ui/components/tab-bar/tab-bar.ts +28 -24
  83. package/dist/ui/components/tabs/tabs.test.ts +34 -32
  84. package/dist/ui/components/tabs/tabs.ts +56 -73
  85. package/dist/ui/components/textarea/textarea.test.ts +11 -1
  86. package/dist/ui/components/textarea/textarea.ts +77 -71
  87. package/dist/ui/components/toggle/toggle.test.ts +47 -16
  88. package/dist/ui/components/toggle/toggle.ts +78 -80
  89. package/dist/ui/components/typography/typography.test.ts +16 -0
  90. package/dist/ui/components/typography/typography.ts +12 -4
  91. package/package.json +1 -1
@@ -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();
@@ -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');