@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
@@ -1,6 +1,6 @@
1
1
  import {
2
- type ElementRef,
3
2
  Component,
3
+ ElementRef,
4
4
  ViewEncapsulation,
5
5
  computed,
6
6
  contentChildren,
@@ -10,7 +10,6 @@ import {
10
10
  model,
11
11
  output,
12
12
  signal,
13
- viewChild,
14
13
  } from '@angular/core';
15
14
  import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
16
15
 
@@ -53,13 +52,16 @@ const tabsListHost = (): SegmentedHost => inject(UiTabsList).segmentedHost;
53
52
 
54
53
  @Component({
55
54
  selector: 'ui-tabs',
56
- imports: [LYNX_ELEMENTS],
57
55
  encapsulation: ViewEncapsulation.None,
58
- template: `
59
- <view [class]="containerClass()">
60
- <ng-content />
61
- </view>
62
- `,
56
+ // Each part is its host element, styled through `host`, rather than a view
57
+ // inside the host. Angular gives a component's host every static attribute
58
+ // the app writes on it, so `<ui-tabs-content class="p-4">` puts `p-4` on
59
+ // the host whatever the component does with its `class` input: with a
60
+ // styled view inside as well, the padding applied twice. On the host alone,
61
+ // a class also acts where the part sits in its parent's layout (`flex-1`,
62
+ // margins).
63
+ host: { '[class]': 'containerClass()' },
64
+ template: '<ng-content />',
63
65
  })
64
66
  export class UiTabs {
65
67
  readonly value = model<string>('');
@@ -139,7 +141,7 @@ export class UiTabs {
139
141
  */
140
142
  @Component({
141
143
  selector: 'ui-tabs-list',
142
- imports: [LYNX_ELEMENTS, UiSegmentedIndicator],
144
+ imports: [UiSegmentedIndicator],
143
145
  encapsulation: ViewEncapsulation.None,
144
146
  providers: [
145
147
  UiSegmentedControl,
@@ -147,17 +149,16 @@ export class UiTabs {
147
149
  ],
148
150
  // The mouse leaving the row lets go of an indicator dragged in the web
149
151
  // preview, where a drag gets no touchcancel.
152
+ host: {
153
+ 'accessibility-traits': 'tabbar',
154
+ '[class]': 'listClass()',
155
+ '(bindmouseleave)': 'segmentedControl.cancel()',
156
+ },
150
157
  template: `
151
- <view
152
- accessibility-traits="tabbar"
153
- [class]="listClass()"
154
- (bindmouseleave)="segmentedControl.cancel()"
155
- >
156
- @if (segmentedControl.underlay()) {
157
- <ui-segmented-indicator />
158
- }
159
- <ng-content />
160
- </view>
158
+ @if (segmentedControl.underlay()) {
159
+ <ui-segmented-indicator />
160
+ }
161
+ <ng-content />
161
162
  `,
162
163
  })
163
164
  export class UiTabsList {
@@ -208,9 +209,19 @@ export class UiTabsList {
208
209
  imports: [LYNX_ELEMENTS],
209
210
  encapsulation: ViewEncapsulation.None,
210
211
  host: {
211
- '[class]': 'hostClass()',
212
+ '[class]': 'triggerClass()',
213
+ '[style]': 'triggerStyle()',
212
214
  // The segmented control places its indicator from each tab's width.
213
215
  '(bindlayoutchange)': 'measure($event)',
216
+ '(bindtap)': 'onTap()',
217
+ '(bindtouchstart)': 'press($event)',
218
+ '(bindtouchmove)': 'move($event)',
219
+ '(bindtouchend)': 'release()',
220
+ '(bindtouchcancel)': 'cancel()',
221
+ '(bindmousedown)': 'press($event)',
222
+ '(bindmousemove)': 'move($event)',
223
+ '(bindmouseup)': 'release()',
224
+ '(bindmouseleave)': 'leave()',
214
225
  },
215
226
  // The label is the accessibility element, a button (a tab, in the list's
216
227
  // tab bar) named by its own text. A view would need a label of its own.
@@ -218,30 +229,15 @@ export class UiTabsList {
218
229
  // lynxAccessibilityTraits adds the trait to `button` on iOS, and leaves
219
230
  // Android, which reads one trait, with `button`.
220
231
  template: `
221
- <view
222
- #trigger
223
- [class]="triggerClass()"
224
- [style]="triggerStyle()"
225
- (bindtap)="onTap()"
226
- (bindtouchstart)="press($event)"
227
- (bindtouchmove)="move($event)"
228
- (bindtouchend)="release()"
229
- (bindtouchcancel)="cancel()"
230
- (bindmousedown)="press($event)"
231
- (bindmousemove)="move($event)"
232
- (bindmouseup)="release()"
233
- (bindmouseleave)="leave()"
234
- >
235
- @if (stateLayerClass(); as stateLayer) {
236
- <view [class]="stateLayer"></view>
237
- }
238
- <text
239
- accessibility-traits="button"
240
- [lynxAccessibilityTraits]="{ selected: isActive() }"
241
- [class]="textClass()"
242
- ><ng-content
243
- /></text>
244
- </view>
232
+ @if (stateLayerClass(); as stateLayer) {
233
+ <view [class]="stateLayer"></view>
234
+ }
235
+ <text
236
+ accessibility-traits="button"
237
+ [lynxAccessibilityTraits]="{ selected: isActive() }"
238
+ [class]="textClass()"
239
+ ><ng-content
240
+ /></text>
245
241
  `,
246
242
  })
247
243
  export class UiTabsTrigger {
@@ -254,7 +250,7 @@ export class UiTabsTrigger {
254
250
  readonly triggerValue = input.required<string>({ alias: 'value' });
255
251
  readonly userClass = input<string>('', { alias: 'class' });
256
252
 
257
- readonly triggerRef = viewChild.required<ElementRef>('trigger');
253
+ readonly #host = inject(ElementRef).nativeElement;
258
254
  #anim?: AnimationHandle;
259
255
  #previousActive?: boolean;
260
256
 
@@ -270,16 +266,6 @@ export class UiTabsTrigger {
270
266
  this.#segmented?.enabled() ? this.#segmented : null,
271
267
  );
272
268
 
273
- /**
274
- * A segment's host is the track's flex item, an equal share of it. Angular
275
- * puts the app's static `class` on the host as well as on the trigger, so
276
- * the two are merged here: `class="flex-[2]"` makes a wider tab.
277
- */
278
- protected readonly hostClass = computed(() => {
279
- const segment = this.#segment();
280
- return segment ? cn(segment.segmentHostClass, this.userClass()) : '';
281
- });
282
-
283
269
  protected readonly triggerStyle = computed(
284
270
  () => this.#segment()?.segmentStyle(this.triggerValue()) ?? null,
285
271
  );
@@ -296,7 +282,7 @@ export class UiTabsTrigger {
296
282
  /** Animates the trigger's background when it's selected or deselected. */
297
283
  protected readonly animatingSelection = effect(() => {
298
284
  const active = this.isActive();
299
- const el = this.triggerRef().nativeElement;
285
+ const el = this.#host;
300
286
  const wasActive = this.#previousActive;
301
287
  this.#previousActive = active;
302
288
  // The first run only records the starting state, and only a change has
@@ -348,8 +334,12 @@ export class UiTabsTrigger {
348
334
  const segment = this.#segment();
349
335
  if (segment) {
350
336
  // The list provides the segmented control, so a segment is in one.
337
+ // The host is both the segment's slot in the track (an equal share of
338
+ // it) and its own view, so an app's `class="flex-[2]"` makes a wider
339
+ // tab.
351
340
  const place = segmentPlace(this.#list!.triggers(), this);
352
341
  return cn(
342
+ segment.segmentHostClass,
353
343
  segment.segmentClass(this.triggerValue(), place),
354
344
  this.userClass(),
355
345
  );
@@ -420,34 +410,28 @@ export class UiTabsTrigger {
420
410
 
421
411
  @Component({
422
412
  selector: 'ui-tabs-content',
423
- imports: [LYNX_ELEMENTS],
424
413
  encapsulation: ViewEncapsulation.None,
425
414
  host: {
415
+ '[class]': 'contentClass()',
426
416
  // Hide the WHOLE host when inactive so it reserves no layout space.
427
417
  //
428
418
  // The caller writes `class="flex-1"` on <ui-tabs-content>, and Angular
429
419
  // applies that to the host element. All three content hosts live as siblings
430
420
  // inside the flex-col <ui-tabs>, so three flex-1 hosts each grow to fill a
431
421
  // third of the available height — pushing each panel lower than the last
432
- // (the "Done tab starts at the very bottom" stacking). Hiding only the inner
433
- // view doesn't help: the flex-1 host still claims its third.
422
+ // (the "Done tab starts at the very bottom" stacking).
434
423
  //
435
424
  // Toggling the host's own `display` collapses inactive hosts to 0×0 (Lynx
436
425
  // `display: none`) so they claim no space at all, while the single active
437
- // host keeps its flex-1 and fills the tabs area. `flex-direction: column`
438
- // makes the host lay its content out vertically when shown.
426
+ // host keeps its flex-1 and fills the tabs area. The inline display wins
427
+ // over the `flex` in the host's classes.
439
428
  '[style.display]': "isActive() ? 'flex' : 'none'",
440
- '[style.flexDirection]': "'column'",
441
429
  // A hidden panel's 0×0 doesn't stop iOS drawing its text: each line is
442
430
  // laid out at 0×0 too, and its glyphs spill out from the panel's corner,
443
431
  // over the tab list. Clipping the hidden host to its 0×0 hides them.
444
432
  '[style.overflow]': "isActive() ? null : 'hidden'",
445
433
  },
446
- template: `
447
- <view #content [class]="contentClass()">
448
- <ng-content />
449
- </view>
450
- `,
434
+ template: '<ng-content />',
451
435
  })
452
436
  export class UiTabsContent {
453
437
  readonly #tabs = inject(UiTabs);
@@ -456,7 +440,7 @@ export class UiTabsContent {
456
440
  readonly contentValue = input.required<string>({ alias: 'value' });
457
441
  readonly userClass = input<string>('', { alias: 'class' });
458
442
 
459
- readonly contentRef = viewChild.required<ElementRef>('content');
443
+ readonly #host = inject(ElementRef).nativeElement;
460
444
  #anim?: AnimationHandle;
461
445
  #isFirstRender = true;
462
446
 
@@ -476,8 +460,7 @@ export class UiTabsContent {
476
460
  * that vanished and never came back. Keeping every panel mounted and only
477
461
  * toggling `display` never destroys or re-parents anything: inactive panels
478
462
  * collapse to 0×0 (Lynx `display: none`) so they take no space, and the
479
- * active one simply reappears in place. That also means the content view
480
- * always exists, so `contentRef` is required.
463
+ * active one simply reappears in place.
481
464
  */
482
465
  protected readonly revealing = effect(() => {
483
466
  if (!this.isActive()) return;
@@ -485,7 +468,7 @@ export class UiTabsContent {
485
468
  setTimeout(() => {
486
469
  // Under Reduce Motion the panel just appears.
487
470
  if (this.#settings.reduceMotion()) return;
488
- const el = this.contentRef().nativeElement;
471
+ const el = this.#host;
489
472
 
490
473
  this.#anim?.cancel();
491
474
 
@@ -504,8 +487,8 @@ export class UiTabsContent {
504
487
  }, 0);
505
488
  });
506
489
 
507
- // Visibility is controlled at the host (see `host` block); the inner view is
508
- // always a normal flex column that fills the shown host.
490
+ // Visibility is controlled by the host's inline display (see `host`); its
491
+ // classes make it a flex column when shown.
509
492
  protected readonly contentClass = computed(() =>
510
493
  cn('mt-2 flex-col flex', this.userClass()),
511
494
  );
@@ -56,7 +56,7 @@ const renderTextarea = async () => {
56
56
  ...result,
57
57
  host: result.componentRef.instance as TextareaHost,
58
58
  field: () => result.container.querySelector('textarea')!,
59
- // The field's own column; the host element renders as a view too.
59
+ // The field's column: its host.
60
60
  column: () => result.container.querySelector('.flex-col')!,
61
61
  wrapper: () => result.container.querySelector('.bg-muted') as HTMLElement,
62
62
  // The layer that draws the focus ring around the field.
@@ -84,6 +84,16 @@ describe('UiTextarea', () => {
84
84
  expect(queryByText('Add a note')).toBeNull();
85
85
  });
86
86
 
87
+ // Angular gives a component's host every static attribute written on it
88
+ // (issue #181): the class styled the column twice, and the placeholder made
89
+ // a second match for getByPlaceholderText.
90
+ it('puts its class and placeholder on one element each', async () => {
91
+ const { container, field, getAllByPlaceholderText } =
92
+ await renderTextarea();
93
+ expect(container.querySelectorAll('.w-full')).toHaveLength(1);
94
+ expect(getAllByPlaceholderText('Add a note')).toEqual([field()]);
95
+ });
96
+
87
97
  it('shows its label', async () => {
88
98
  const { host, getByText } = await renderTextarea();
89
99
  host.label.set('Notes');
@@ -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();