@blotch/dolan 22.2.6 → 22.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/commands/install-packages.test.js +7 -2
  2. package/dist/commands/install-packages.test.js.map +1 -1
  3. package/dist/registry.js +8 -2
  4. package/dist/registry.js.map +1 -1
  5. package/dist/registry.test.js +1 -0
  6. package/dist/registry.test.js.map +1 -1
  7. package/dist/ui/components/accordion/accordion.test.ts +24 -7
  8. package/dist/ui/components/accordion/accordion.ts +34 -44
  9. package/dist/ui/components/action-sheet/action-sheet.test.ts +18 -1
  10. package/dist/ui/components/action-sheet/action-sheet.ts +55 -60
  11. package/dist/ui/components/alert/alert.test.ts +17 -0
  12. package/dist/ui/components/alert/alert.ts +23 -14
  13. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +19 -4
  14. package/dist/ui/components/alert-dialog/alert-dialog.ts +31 -20
  15. package/dist/ui/components/aspect-ratio/aspect-ratio.test.ts +38 -2
  16. package/dist/ui/components/aspect-ratio/aspect-ratio.ts +11 -8
  17. package/dist/ui/components/avatar/avatar.test.ts +7 -0
  18. package/dist/ui/components/avatar/avatar.ts +35 -30
  19. package/dist/ui/components/badge/badge.test.ts +9 -0
  20. package/dist/ui/components/badge/badge.ts +12 -10
  21. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +7 -0
  22. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +5 -0
  23. package/dist/ui/components/button/button.test.ts +59 -3
  24. package/dist/ui/components/button/button.ts +67 -62
  25. package/dist/ui/components/button-group/button-group.test.ts +37 -4
  26. package/dist/ui/components/button-group/button-group.ts +60 -51
  27. package/dist/ui/components/card/card.test.ts +35 -2
  28. package/dist/ui/components/card/card.ts +41 -53
  29. package/dist/ui/components/chart/chart-presets.test.ts +16 -2
  30. package/dist/ui/components/chart/chart-presets.ts +22 -3
  31. package/dist/ui/components/chart/chart.test.ts +35 -6
  32. package/dist/ui/components/chart/chart.ts +14 -3
  33. package/dist/ui/components/checkbox/checkbox.test.ts +11 -4
  34. package/dist/ui/components/checkbox/checkbox.ts +21 -14
  35. package/dist/ui/components/collapsible/collapsible.test.ts +20 -10
  36. package/dist/ui/components/collapsible/collapsible.ts +29 -40
  37. package/dist/ui/components/dialog/dialog.test.ts +17 -1
  38. package/dist/ui/components/dialog/dialog.ts +24 -17
  39. package/dist/ui/components/dock/dock.test.ts +30 -5
  40. package/dist/ui/components/dock/dock.ts +29 -27
  41. package/dist/ui/components/empty-state/empty-state.test.ts +9 -0
  42. package/dist/ui/components/empty-state/empty-state.ts +20 -11
  43. package/dist/ui/components/icon/icon.test.ts +134 -2
  44. package/dist/ui/components/icon/icon.ts +57 -18
  45. package/dist/ui/components/input/input.test.ts +55 -3
  46. package/dist/ui/components/input/input.ts +91 -65
  47. package/dist/ui/components/label/label.test.ts +15 -0
  48. package/dist/ui/components/label/label.ts +12 -4
  49. package/dist/ui/components/list/list.test.ts +13 -3
  50. package/dist/ui/components/list/list.ts +14 -2
  51. package/dist/ui/components/menu/menu.test.ts +8 -0
  52. package/dist/ui/components/menu/menu.ts +4 -0
  53. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +34 -21
  54. package/dist/ui/components/nav-drawer/nav-drawer.ts +60 -68
  55. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +13 -0
  56. package/dist/ui/components/navigation-bar/navigation-bar.ts +5 -0
  57. package/dist/ui/components/pagination/pagination.test.ts +9 -0
  58. package/dist/ui/components/pagination/pagination.ts +52 -49
  59. package/dist/ui/components/popover/popover.test.ts +3 -0
  60. package/dist/ui/components/progress/progress.test.ts +7 -0
  61. package/dist/ui/components/progress/progress.ts +8 -6
  62. package/dist/ui/components/radio-group/radio-group.test.ts +8 -0
  63. package/dist/ui/components/radio-group/radio-group.ts +22 -19
  64. package/dist/ui/components/scroll-area/scroll-area.test.ts +14 -2
  65. package/dist/ui/components/scroll-area/scroll-area.ts +15 -3
  66. package/dist/ui/components/search-field/search-field.test.ts +25 -0
  67. package/dist/ui/components/search-field/search-field.ts +7 -1
  68. package/dist/ui/components/select/select.test.ts +32 -5
  69. package/dist/ui/components/select/select.ts +32 -11
  70. package/dist/ui/components/separator/separator.test.ts +3 -0
  71. package/dist/ui/components/sheet/sheet.test.ts +18 -0
  72. package/dist/ui/components/sheet/sheet.ts +24 -24
  73. package/dist/ui/components/skeleton/skeleton.test.ts +8 -0
  74. package/dist/ui/components/skeleton/skeleton.ts +13 -8
  75. package/dist/ui/components/slider/slider.test.ts +13 -1
  76. package/dist/ui/components/slider/slider.ts +16 -3
  77. package/dist/ui/components/spinner/spinner.test.ts +16 -1
  78. package/dist/ui/components/spinner/spinner.ts +15 -7
  79. package/dist/ui/components/switch/switch.test.ts +13 -2
  80. package/dist/ui/components/switch/switch.ts +15 -1
  81. package/dist/ui/components/tab-bar/tab-bar.test.ts +32 -6
  82. package/dist/ui/components/tab-bar/tab-bar.ts +28 -24
  83. package/dist/ui/components/tabs/tabs.test.ts +34 -32
  84. package/dist/ui/components/tabs/tabs.ts +56 -73
  85. package/dist/ui/components/textarea/textarea.test.ts +11 -1
  86. package/dist/ui/components/textarea/textarea.ts +77 -71
  87. package/dist/ui/components/toggle/toggle.test.ts +47 -16
  88. package/dist/ui/components/toggle/toggle.ts +78 -80
  89. package/dist/ui/components/typography/typography.test.ts +16 -0
  90. package/dist/ui/components/typography/typography.ts +12 -4
  91. package/package.json +1 -1
@@ -1,15 +1,15 @@
1
1
  import {
2
- type ElementRef,
3
2
  Component,
3
+ ElementRef,
4
4
  ViewEncapsulation,
5
5
  booleanAttribute,
6
6
  computed,
7
7
  effect,
8
+ inject,
8
9
  input,
9
10
  model,
10
11
  output,
11
12
  signal,
12
- viewChild,
13
13
  } from '@angular/core';
14
14
  import { type FormValueControl } from '@angular/forms/signals';
15
15
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
@@ -19,75 +19,82 @@ import { cn } from '../../utils/cn';
19
19
 
20
20
  @Component({
21
21
  selector: 'ui-textarea',
22
- standalone: true,
23
22
  imports: [LYNX_ELEMENTS],
24
23
  encapsulation: ViewEncapsulation.None,
24
+ // The host is the field's column. Angular gives a component's host every
25
+ // static attribute the app writes on it, so a class on <ui-textarea> lands
26
+ // there whatever the component does: a column inside the host applied it
27
+ // twice. A placeholder lands there too, and goes: it belongs to the
28
+ // <textarea>, and a second element with it is a second match for
29
+ // getByPlaceholderText.
30
+ host: {
31
+ '[class]': 'containerClass()',
32
+ '[attr.placeholder]': 'null',
33
+ },
25
34
  template: `
26
- <view #container [class]="containerClass()">
27
- @if (label()) {
28
- <text [class]="labelClass()">{{ label() }}</text>
29
- }
30
- <!-- The min/max height lives on this wrapper, not the textarea: a native
31
- Lynx textarea only auto-grows when it drives its container's height
32
- (via height:100% below). Pinning min/max-height on the textarea itself
33
- locks it to a fixed box that scrolls but never grows. -->
34
- <view [class]="textareaWrapperClass()" [style]="wrapperStyle()">
35
- <!-- Focus ring on its own overlay layer so it can fade (see input.ts for
36
- the full rationale): box-shadow is animatable:no on Lynx, opacity is,
37
- so we transition the layer's opacity. First child = painted behind
38
- the textarea on Lynx (strict source order). Source order alone is NOT
39
- enough on web: CSS paints a positioned (absolute) sibling above a
40
- static one regardless of source order, so this transparent overlay
41
- would swallow every tap — no focus, no typing. The textarea below
42
- carries position:relative + z-index:1 so it sits above the overlay on
43
- BOTH platforms (required on web). The wrapper's z-0 makes it the
44
- stacking context for that z-index; without it Lynx stacks the native
45
- textarea against an ancestor outside the scroll-view, so it stays
46
- fixed on screen while the page scrolls. No pointer-events
47
- (which errors the Lynx build). OUTSET only; Lynx doesn't render inset
48
- box-shadows. -->
49
- <view
50
- class="rounded-xl opacity-0 absolute bottom-0 left-0 right-0 top-0"
51
- [style.transition]="'opacity 150ms ease'"
52
- [style.box-shadow]="ringShadow()"
53
- [style.opacity]="ringVisible() ? 1 : 0"
54
- ></view>
55
- <!-- [attr.disabled]="disabled() || undefined": passing 'undefined'
56
- removes the attribute entirely; passing 'false' would set
57
- disabled="false" which Lynx still treats as disabled. -->
58
- <!-- [value] not [attr.value]: same reason as UiInput — routes through
59
- LynxTextarea.ngOnChanges so value changes call invoke("setValue")
60
- rather than the ineffective __SetAttribute path. -->
61
- <!-- [attr.enable-scroll-bar]: only surface the scrollbar once maxLines is
62
- set (i.e. the field can actually scroll). Native scrolling past
63
- max-height works regardless; this just shows the indicator.
64
- height:100% makes the textarea fill (and grow) the wrapper; the
65
- pinned line-height keeps the wrapper's line math exact. -->
66
- <!-- [attr.placeholder]: suppress the placeholder whenever a value is
67
- present. The browser hides a placeholder under a value automatically,
68
- but Lynx's iOS textarea renders the placeholder as a separate overlay
69
- view that only hides on textViewDidChange — so an initial value set via
70
- the setValue UIMethod leaves the placeholder stacked on top of it. Not
71
- emitting the attribute when there's a value matches web semantics and
72
- avoids the overlap on all platforms. -->
73
- <textarea
74
- [attr.placeholder]="value() ? null : placeholder()"
75
- [value]="value()"
76
- [attr.disabled]="disabled() || undefined"
77
- [attr.enable-scroll-bar]="maxLines() != null ? 'true' : null"
78
- class="text-sm text-foreground"
79
- style="border: none; background: transparent; height: 100%; width: 100%; line-height: 20px; position: relative; z-index: 1;"
80
- (bindinput)="onInput($any($event))"
81
- (bindfocus)="onFocus()"
82
- (bindblur)="onBlur()"
83
- ></textarea>
84
- </view>
85
- @if (error()) {
86
- <text [class]="errorClass()">{{ error() }}</text>
87
- } @else if (helperText()) {
88
- <text [class]="helperClass()">{{ helperText() }}</text>
89
- }
35
+ @if (label()) {
36
+ <text [class]="labelClass()">{{ label() }}</text>
37
+ }
38
+ <!-- The min/max height lives on this wrapper, not the textarea: a native
39
+ Lynx textarea only auto-grows when it drives its container's height
40
+ (via height:100% below). Pinning min/max-height on the textarea itself
41
+ locks it to a fixed box that scrolls but never grows. -->
42
+ <view [class]="textareaWrapperClass()" [style]="wrapperStyle()">
43
+ <!-- Focus ring on its own overlay layer so it can fade (see input.ts for
44
+ the full rationale): box-shadow is animatable:no on Lynx, opacity is,
45
+ so we transition the layer's opacity. First child = painted behind
46
+ the textarea on Lynx (strict source order). Source order alone is NOT
47
+ enough on web: CSS paints a positioned (absolute) sibling above a
48
+ static one regardless of source order, so this transparent overlay
49
+ would swallow every tap — no focus, no typing. The textarea below
50
+ carries position:relative + z-index:1 so it sits above the overlay on
51
+ BOTH platforms (required on web). The wrapper's z-0 makes it the
52
+ stacking context for that z-index; without it Lynx stacks the native
53
+ textarea against an ancestor outside the scroll-view, so it stays
54
+ fixed on screen while the page scrolls. No pointer-events
55
+ (which errors the Lynx build). OUTSET only; Lynx doesn't render inset
56
+ box-shadows. -->
57
+ <view
58
+ class="rounded-xl opacity-0 absolute bottom-0 left-0 right-0 top-0"
59
+ [style.transition]="'opacity 150ms ease'"
60
+ [style.box-shadow]="ringShadow()"
61
+ [style.opacity]="ringVisible() ? 1 : 0"
62
+ ></view>
63
+ <!-- [attr.disabled]="disabled() || undefined": passing 'undefined'
64
+ removes the attribute entirely; passing 'false' would set
65
+ disabled="false" which Lynx still treats as disabled. -->
66
+ <!-- [value] not [attr.value]: same reason as UiInput — routes through
67
+ LynxTextarea.ngOnChanges so value changes call invoke("setValue")
68
+ rather than the ineffective __SetAttribute path. -->
69
+ <!-- [attr.enable-scroll-bar]: only surface the scrollbar once maxLines is
70
+ set (i.e. the field can actually scroll). Native scrolling past
71
+ max-height works regardless; this just shows the indicator.
72
+ height:100% makes the textarea fill (and grow) the wrapper; the
73
+ pinned line-height keeps the wrapper's line math exact. -->
74
+ <!-- [attr.placeholder]: suppress the placeholder whenever a value is
75
+ present. The browser hides a placeholder under a value automatically,
76
+ but Lynx's iOS textarea renders the placeholder as a separate overlay
77
+ view that only hides on textViewDidChange — so an initial value set via
78
+ the setValue UIMethod leaves the placeholder stacked on top of it. Not
79
+ emitting the attribute when there's a value matches web semantics and
80
+ avoids the overlap on all platforms. -->
81
+ <textarea
82
+ [attr.placeholder]="value() ? null : placeholder()"
83
+ [value]="value()"
84
+ [attr.disabled]="disabled() || undefined"
85
+ [attr.enable-scroll-bar]="maxLines() != null ? 'true' : null"
86
+ class="text-sm text-foreground"
87
+ style="border: none; background: transparent; height: 100%; width: 100%; line-height: 20px; position: relative; z-index: 1;"
88
+ (bindinput)="onInput($any($event))"
89
+ (bindfocus)="onFocus()"
90
+ (bindblur)="onBlur()"
91
+ ></textarea>
90
92
  </view>
93
+ @if (error()) {
94
+ <text [class]="errorClass()">{{ error() }}</text>
95
+ } @else if (helperText()) {
96
+ <text [class]="helperClass()">{{ helperText() }}</text>
97
+ }
91
98
  `,
92
99
  })
93
100
  export class UiTextarea implements FormValueControl<string> {
@@ -109,7 +116,7 @@ export class UiTextarea implements FormValueControl<string> {
109
116
  readonly focused = output<void>();
110
117
  readonly blurred = output<void>();
111
118
 
112
- readonly containerRef = viewChild<ElementRef>('container');
119
+ readonly #host = inject(ElementRef).nativeElement;
113
120
  readonly #isFocused = signal(false);
114
121
  #shakeAnim?: AnimationHandle;
115
122
  #previousError = '';
@@ -121,10 +128,9 @@ export class UiTextarea implements FormValueControl<string> {
121
128
  */
122
129
  protected readonly shakeOnError = effect(() => {
123
130
  const err = this.error();
124
- const el = this.containerRef()?.nativeElement;
125
- if (err && !this.#previousError && el) {
131
+ if (err && !this.#previousError) {
126
132
  this.#shakeAnim?.cancel();
127
- this.#shakeAnim = shake(el);
133
+ this.#shakeAnim = shake(this.#host);
128
134
  }
129
135
  this.#previousError = err;
130
136
  });
@@ -71,8 +71,8 @@ class ToggleGroupHost {
71
71
  }
72
72
 
73
73
  /**
74
- * The view (the one that takes presses) a toggle or toggle group item
75
- * projects its label into.
74
+ * The toggle or toggle group item a label is projected into: its host, which
75
+ * takes the presses.
76
76
  */
77
77
  const viewOf = (label: Element) => label.parentElement!;
78
78
 
@@ -129,6 +129,14 @@ describe('UiToggle', () => {
129
129
  };
130
130
  };
131
131
 
132
+ // Angular gives a component's host every static attribute written on it,
133
+ // so a toggle that also styled a view inside its host applied the class
134
+ // twice (issue #181).
135
+ it('puts its class on one element, the toggle', async () => {
136
+ const { container, toggle } = await renderToggle();
137
+ expect([...container.querySelectorAll('.w-20')]).toEqual([toggle()]);
138
+ });
139
+
132
140
  it('renders its content unpressed, with the given class', async () => {
133
141
  const { toggle } = await renderToggle();
134
142
  expect(toggle().classList).toContain('bg-transparent');
@@ -217,6 +225,15 @@ describe('UiToggleGroup', () => {
217
225
  expect(group().classList).toContain('w-full');
218
226
  });
219
227
 
228
+ // Angular gives a component's host every static attribute written on it,
229
+ // so a group or item that also styled a view inside its host applied the
230
+ // class twice (issue #181).
231
+ it('puts a class written on the group or an item on that one element', async () => {
232
+ const { container, group, item } = await renderGroup();
233
+ expect([...container.querySelectorAll('.w-full')]).toEqual([group()]);
234
+ expect([...container.querySelectorAll('.flex-1')]).toEqual([item('Left')]);
235
+ });
236
+
220
237
  it('selects one item at a time, and deselects it on a second tap', async () => {
221
238
  const { host, item } = await renderGroup();
222
239
  await tap(item('Left'));
@@ -338,6 +355,18 @@ class SegmentedGroupHost {
338
355
  readonly disabled = signal(false);
339
356
  }
340
357
 
358
+ @Component({
359
+ imports: [UiToggleGroup, UiToggleGroupItem, LYNX_ELEMENTS],
360
+ template: `
361
+ <ui-toggle-group variant="segmented">
362
+ <ui-toggle-group-item value="bold" accessibility-label="Bold"
363
+ ><text>B</text></ui-toggle-group-item
364
+ >
365
+ </ui-toggle-group>
366
+ `,
367
+ })
368
+ class StaticLabelHost {}
369
+
341
370
  /**
342
371
  * A group inside something that colors its icons, such as glass.
343
372
  */
@@ -388,18 +417,15 @@ describe('UiToggleGroup, segmented', () => {
388
417
  const result = await render(SegmentedGroupHost, {
389
418
  providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
390
419
  });
420
+ const track = () => viewOf(result.getByText('Day')).parentElement!;
391
421
  /**
392
- * An item's own view: around its label, or around the star icon.
422
+ * An item, the track's flex item: around its label, or the last, around
423
+ * the star icon.
393
424
  */
394
425
  const item = (label: string) =>
395
426
  label === 'Star'
396
- ? result.container.querySelector('svg')!.parentElement!.parentElement!
427
+ ? (track().lastElementChild as HTMLElement)
397
428
  : viewOf(result.getByText(label));
398
- /**
399
- * An item's host, the track's flex item.
400
- */
401
- const slot = (label: string) => item(label).parentElement!;
402
- const track = () => slot('Day').parentElement!;
403
429
  /**
404
430
  * The indicator's frame, which moves between the items.
405
431
  */
@@ -407,7 +433,7 @@ describe('UiToggleGroup, segmented', () => {
407
433
  track().querySelector<HTMLElement>('.h-full .bottom-0.top-0');
408
434
  const measure = async () => {
409
435
  for (const label of ['Day', 'Week', 'Month', 'Star']) {
410
- fireEvent.layoutchange(slot(label), {
436
+ fireEvent.layoutchange(item(label), {
411
437
  detail: { width: label === 'Month' ? 160 : 80 },
412
438
  });
413
439
  }
@@ -417,7 +443,6 @@ describe('UiToggleGroup, segmented', () => {
417
443
  ...result,
418
444
  host: result.componentRef.instance as SegmentedGroupHost,
419
445
  item,
420
- slot,
421
446
  track,
422
447
  frame,
423
448
  measure,
@@ -429,13 +454,13 @@ describe('UiToggleGroup, segmented', () => {
429
454
  };
430
455
 
431
456
  it("draws the platform's segmented control, its items sharing the track", async () => {
432
- const { track, slot, item, frame } = await renderSegmented();
457
+ const { track, item, frame } = await renderSegmented();
433
458
  expect(track().classList).toContain('rounded-full');
434
459
  expect(track().classList).toContain('bg-muted');
435
- expect(slot('Day').classList).toContain('flex-1');
436
- // An item's own class sizes its slot too.
437
- expect(slot('Month').classList).toContain('flex-[2]');
438
- expect(slot('Month').classList).not.toContain('flex-1');
460
+ expect(item('Day').classList).toContain('flex-1');
461
+ // An item's own class sizes it in the track.
462
+ expect(item('Month').classList).toContain('flex-[2]');
463
+ expect(item('Month').classList).not.toContain('flex-1');
439
464
  expect(item('Day').classList).toContain('justify-center');
440
465
  expect(frame()).not.toBeNull();
441
466
  });
@@ -448,6 +473,12 @@ describe('UiToggleGroup, segmented', () => {
448
473
  expect(host.range()).toEqual(['Day']);
449
474
  });
450
475
 
476
+ // The accessibility label names one element, the item (issue #181).
477
+ it('names one element with an accessibility label written on an item', async () => {
478
+ const { getAllByAccessibilityLabel } = await render(StaticLabelHost);
479
+ expect(getAllByAccessibilityLabel('Bold')).toHaveLength(1);
480
+ });
481
+
451
482
  it('marks the selected item selected for screen readers', async () => {
452
483
  const { getByRole, tap } = await renderSegmented();
453
484
  expect(getByRole('button', { name: 'Week', selected: true })).toBeTruthy();
@@ -1,16 +1,16 @@
1
1
  import {
2
- type ElementRef,
3
2
  Component,
3
+ ElementRef,
4
4
  ViewEncapsulation,
5
5
  booleanAttribute,
6
6
  computed,
7
7
  contentChildren,
8
+ effect,
8
9
  inject,
9
10
  input,
10
11
  model,
11
- viewChild,
12
12
  } from '@angular/core';
13
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
13
+ import { type BaseLynxElement, LYNX_ELEMENTS } from '@blotch/angular-lynx';
14
14
  import { cva, type VariantProps } from 'class-variance-authority';
15
15
 
16
16
  import {
@@ -86,23 +86,25 @@ const itemIconColor = () => {
86
86
 
87
87
  @Component({
88
88
  selector: 'ui-toggle',
89
- imports: [LYNX_ELEMENTS],
90
89
  encapsulation: ViewEncapsulation.None,
91
- template: `
92
- <view
93
- #container
94
- [class]="containerClass()"
95
- (bindtouchstart)="onPressStart()"
96
- (bindtouchend)="onPressEnd()"
97
- (bindtouchcancel)="onPressCancel()"
98
- (bindmousedown)="onPressStart()"
99
- (bindmouseup)="onPressEnd()"
100
- (bindmouseleave)="onPressCancel()"
101
- (bindtap)="onTap()"
102
- >
103
- <ng-content />
104
- </view>
105
- `,
90
+ // Each toggle, group and item is its host element, styled through `host`,
91
+ // rather than a view inside the host. Angular gives a component's host
92
+ // every static attribute the app writes on it, so `<ui-toggle
93
+ // class="px-6">` puts `px-6` on the host whatever the component does with
94
+ // its `class` input: with a styled view inside as well, the padding applied
95
+ // twice. On the host alone, a class also acts where the toggle sits in its
96
+ // parent's layout, and an item's accessibility-label names one element.
97
+ host: {
98
+ '[class]': 'containerClass()',
99
+ '(bindtouchstart)': 'onPressStart()',
100
+ '(bindtouchend)': 'onPressEnd()',
101
+ '(bindtouchcancel)': 'onPressCancel()',
102
+ '(bindmousedown)': 'onPressStart()',
103
+ '(bindmouseup)': 'onPressEnd()',
104
+ '(bindmouseleave)': 'onPressCancel()',
105
+ '(bindtap)': 'onTap()',
106
+ },
107
+ template: '<ng-content />',
106
108
  })
107
109
  export class UiToggle {
108
110
  readonly pressed = model(false);
@@ -111,7 +113,7 @@ export class UiToggle {
111
113
  readonly disabled = input(false, { transform: booleanAttribute });
112
114
  readonly userClass = input<string>('', { alias: 'class' });
113
115
 
114
- readonly containerRef = viewChild<ElementRef>('container');
116
+ readonly #host = inject(ElementRef).nativeElement;
115
117
  #pressAnim?: AnimationHandle;
116
118
 
117
119
  protected readonly containerClass = computed(() =>
@@ -126,18 +128,18 @@ export class UiToggle {
126
128
  protected onPressStart(): void {
127
129
  if (this.disabled()) return;
128
130
  this.#pressAnim?.cancel();
129
- this.#pressAnim = pressDown(this.containerRef()?.nativeElement);
131
+ this.#pressAnim = pressDown(this.#host);
130
132
  }
131
133
 
132
134
  protected onPressEnd(): void {
133
135
  if (this.disabled()) return;
134
136
  this.#pressAnim?.cancel();
135
- this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
137
+ this.#pressAnim = pressRelease(this.#host);
136
138
  }
137
139
 
138
140
  protected onPressCancel(): void {
139
141
  this.#pressAnim?.cancel();
140
- this.#pressAnim = pressRelease(this.containerRef()?.nativeElement);
142
+ this.#pressAnim = pressRelease(this.#host);
141
143
  }
142
144
 
143
145
  protected onTap(): void {
@@ -163,7 +165,7 @@ export class UiToggle {
163
165
  */
164
166
  @Component({
165
167
  selector: 'ui-toggle-group',
166
- imports: [LYNX_ELEMENTS, UiSegmentedIndicator],
168
+ imports: [UiSegmentedIndicator],
167
169
  encapsulation: ViewEncapsulation.None,
168
170
  providers: [
169
171
  UiSegmentedControl,
@@ -171,16 +173,15 @@ export class UiToggle {
171
173
  ],
172
174
  // The mouse leaving the row lets go of an indicator dragged in the web
173
175
  // preview, where a drag gets no touchcancel.
176
+ host: {
177
+ '[class]': 'containerClass()',
178
+ '(bindmouseleave)': 'segmentedControl.cancel()',
179
+ },
174
180
  template: `
175
- <view
176
- [class]="containerClass()"
177
- (bindmouseleave)="segmentedControl.cancel()"
178
- >
179
- @if (segmentedControl.underlay()) {
180
- <ui-segmented-indicator />
181
- }
182
- <ng-content />
183
- </view>
181
+ @if (segmentedControl.underlay()) {
182
+ <ui-segmented-indicator />
183
+ }
184
+ <ng-content />
184
185
  `,
185
186
  })
186
187
  export class UiToggleGroup implements SegmentedHost {
@@ -254,38 +255,32 @@ export class UiToggleGroup implements SegmentedHost {
254
255
  encapsulation: ViewEncapsulation.None,
255
256
  // Icons projected into the item inject this: they're declared inside it.
256
257
  providers: [{ provide: UI_ICON_COLOR, useFactory: itemIconColor }],
258
+ // The accessibility attributes are attribute bindings: a property binding
259
+ // on a component's host is checked against the DOM schema, which has no
260
+ // `accessibility-label`.
257
261
  host: {
258
- '[class]': 'hostClass()',
262
+ '[class]': 'containerClass()',
263
+ '[style]': 'containerStyle()',
264
+ '[attr.accessibility-element]': "accessibilityLabel() ? 'true' : null",
265
+ '[attr.accessibility-traits]': "accessibilityLabel() ? 'button' : null",
266
+ '[attr.accessibility-label]': 'accessibilityLabel() || null',
259
267
  // The segmented control places its indicator from each item's width.
260
268
  '(bindlayoutchange)': 'measure($event)',
269
+ '(bindtouchstart)': 'onPressStart($event)',
270
+ '(bindtouchmove)': 'onPressMove($event)',
271
+ '(bindtouchend)': 'onPressEnd()',
272
+ '(bindtouchcancel)': 'onPressCancel()',
273
+ '(bindmousedown)': 'onPressStart($event)',
274
+ '(bindmousemove)': 'onPressMove($event)',
275
+ '(bindmouseup)': 'onPressEnd()',
276
+ '(bindmouseleave)': 'onPointerLeave()',
277
+ '(bindtap)': 'onTap()',
261
278
  },
262
279
  template: `
263
- <!-- A labeled item is selected to a screen reader while pressed, which
264
- iOS reads. lynxAccessibilityTraits adds the trait to button on iOS,
265
- and leaves Android, which reads one trait, with button. -->
266
- <view
267
- #container
268
- [class]="containerClass()"
269
- [style]="containerStyle()"
270
- [attr.accessibility-element]="accessibilityLabel() ? 'true' : null"
271
- [attr.accessibility-traits]="accessibilityLabel() ? 'button' : null"
272
- [attr.accessibility-label]="accessibilityLabel() || null"
273
- [lynxAccessibilityTraits]="{ selected: selectedTrait() }"
274
- (bindtouchstart)="onPressStart($event)"
275
- (bindtouchmove)="onPressMove($event)"
276
- (bindtouchend)="onPressEnd()"
277
- (bindtouchcancel)="onPressCancel()"
278
- (bindmousedown)="onPressStart($event)"
279
- (bindmousemove)="onPressMove($event)"
280
- (bindmouseup)="onPressEnd()"
281
- (bindmouseleave)="onPointerLeave()"
282
- (bindtap)="onTap()"
283
- >
284
- @if (stateLayerClass(); as stateLayer) {
285
- <view [class]="stateLayer"></view>
286
- }
287
- <ng-content />
288
- </view>
280
+ @if (stateLayerClass(); as stateLayer) {
281
+ <view [class]="stateLayer"></view>
282
+ }
283
+ <ng-content />
289
284
  `,
290
285
  })
291
286
  export class UiToggleGroupItem {
@@ -303,17 +298,27 @@ export class UiToggleGroupItem {
303
298
  });
304
299
  readonly userClass = input<string>('', { alias: 'class' });
305
300
 
306
- // Required: always in the template.
307
- readonly containerRef = viewChild.required<ElementRef>('container');
301
+ readonly #host: BaseLynxElement = inject(ElementRef).nativeElement;
308
302
  #pressAnim?: AnimationHandle;
309
303
 
310
304
  protected readonly isSelected = computed(() =>
311
305
  this.#group.isSelected(this.itemValue()),
312
306
  );
313
- /** Only a labeled item is an accessibility element to be `selected`. */
314
- protected readonly selectedTrait = computed(
315
- () => !!this.accessibilityLabel() && this.isSelected(),
316
- );
307
+
308
+ /**
309
+ * A labeled item is `selected` to a screen reader while pressed, which iOS
310
+ * reads. The trait is added to the host's own `button`, as
311
+ * lynxAccessibilityTraits does on an element in a template: on iOS it
312
+ * reads `button,selected`, and Android, which reads one trait, keeps
313
+ * `button`. Only a labeled item is an accessibility element to be
314
+ * `selected`.
315
+ */
316
+ protected readonly markingSelected = effect(() => {
317
+ this.#host.setAccessibilityTrait(
318
+ 'selected',
319
+ !!this.accessibilityLabel() && this.isSelected(),
320
+ );
321
+ });
317
322
 
318
323
  /** Whether the item is a segment of a segmented control. */
319
324
  readonly #segment = computed(() => this.#segmented.enabled());
@@ -327,20 +332,13 @@ export class UiToggleGroupItem {
327
332
  this.#segment() ? this.#segmented.iconColor(this.itemValue()) : undefined,
328
333
  );
329
334
 
330
- /**
331
- * A segment's host is the track's flex item, an equal share of it. Angular
332
- * puts the app's static `class` on the host as well as on the item, so the
333
- * two are merged here: `class="flex-[2]"` makes a wider segment.
334
- */
335
- protected readonly hostClass = computed(() =>
336
- this.#segment()
337
- ? cn(this.#segmented.segmentHostClass, this.userClass())
338
- : '',
339
- );
340
-
341
335
  protected readonly containerClass = computed(() => {
342
336
  if (this.#segment()) {
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
+ // segment.
343
340
  return cn(
341
+ this.#segmented.segmentHostClass,
344
342
  this.#segmented.segmentClass(
345
343
  this.itemValue(),
346
344
  segmentPlace(this.#group.items(), this),
@@ -384,7 +382,7 @@ export class UiToggleGroupItem {
384
382
  return;
385
383
  }
386
384
  this.#pressAnim?.cancel();
387
- this.#pressAnim = pressDown(this.containerRef().nativeElement);
385
+ this.#pressAnim = pressDown(this.#host);
388
386
  }
389
387
 
390
388
  protected onPressMove(event: unknown): void {
@@ -398,7 +396,7 @@ export class UiToggleGroupItem {
398
396
  return;
399
397
  }
400
398
  this.#pressAnim?.cancel();
401
- this.#pressAnim = pressRelease(this.containerRef().nativeElement);
399
+ this.#pressAnim = pressRelease(this.#host);
402
400
  }
403
401
 
404
402
  protected onPressCancel(): void {
@@ -428,6 +426,6 @@ export class UiToggleGroupItem {
428
426
 
429
427
  #release(): void {
430
428
  this.#pressAnim?.cancel();
431
- this.#pressAnim = pressRelease(this.containerRef().nativeElement);
429
+ this.#pressAnim = pressRelease(this.#host);
432
430
  }
433
431
  }
@@ -22,6 +22,12 @@ class TypographyHost {
22
22
  | 'muted' = 'p';
23
23
  }
24
24
 
25
+ @Component({
26
+ imports: [UiText],
27
+ template: `<ui-text variant="muted" class="mb-4 text-center">Note</ui-text>`,
28
+ })
29
+ class StaticClassHost {}
30
+
25
31
  describe('typographyVariants', () => {
26
32
  it('returns default variant classes', () => {
27
33
  expect(typographyVariants()).toContain('text-base');
@@ -52,4 +58,14 @@ describe('UiText', () => {
52
58
  const { getByText } = await render(UiText);
53
59
  expect(getByText).toBeTruthy();
54
60
  });
61
+
62
+ // A class on a component lands on its host too (issue #181): the text is
63
+ // one <text>, so the class styles one element.
64
+ it('is one text, with the class written on it', async () => {
65
+ const { container, getByText } = await render(StaticClassHost);
66
+ const text = getByText('Note');
67
+ expect(text.tagName.toLowerCase()).toBe('text');
68
+ expect(text.classList).toContain('text-muted-foreground');
69
+ expect([...container.querySelectorAll('.mb-4')]).toEqual([text]);
70
+ });
55
71
  });
@@ -1,5 +1,5 @@
1
1
  import { Component, ViewEncapsulation, computed, input } from '@angular/core';
2
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
2
+ import { registerHostElement } from '@blotch/angular-lynx';
3
3
  import { cva, type VariantProps } from 'class-variance-authority';
4
4
 
5
5
  import { cn } from '../../utils/cn';
@@ -27,12 +27,18 @@ export type TypographyVariant = NonNullable<
27
27
  VariantProps<typeof typographyVariants>['variant']
28
28
  >;
29
29
 
30
+ /**
31
+ * The text is its host, a `<text>` (registerHostElement, below), styled
32
+ * through `host`. Angular gives a component's host every static attribute the
33
+ * app writes on it, so `<ui-text class="mb-4">` puts `mb-4` on the host
34
+ * whatever the component does: with a styled `<text>` inside a view host as
35
+ * well, the margin applied twice.
36
+ */
30
37
  @Component({
31
38
  selector: 'ui-text',
32
- standalone: true,
33
- imports: [LYNX_ELEMENTS],
34
39
  encapsulation: ViewEncapsulation.None,
35
- template: `<text [class]="textClass()"><ng-content /></text>`,
40
+ host: { '[class]': 'textClass()' },
41
+ template: '<ng-content />',
36
42
  })
37
43
  export class UiText {
38
44
  readonly variant = input<TypographyVariant>('p');
@@ -42,3 +48,5 @@ export class UiText {
42
48
  cn(typographyVariants({ variant: this.variant() }), this.userClass()),
43
49
  );
44
50
  }
51
+
52
+ registerHostElement(UiText, 'text');