@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
@@ -1115,8 +1115,12 @@ export class UiDock implements SearchFieldContainer {
1115
1115
  ],
1116
1116
  host: {
1117
1117
  // The host is the tap target and takes an equal share of the bar's row,
1118
- // which the selection capsule's width counts on.
1119
- class: 'flex-1 flex-col flex',
1118
+ // which the selection capsule's width counts on. It's also the item's
1119
+ // column, styled here rather than on a view inside it: Angular gives a
1120
+ // component's host every static attribute the app writes on it, so a
1121
+ // class on <ui-dock-item> lands on the host whatever the component does
1122
+ // with its `class` input, and a styled view inside applied it twice.
1123
+ '[class]': 'contentClass()',
1120
1124
  // One accessibility element per item, named by its label, so a screen
1121
1125
  // reader reads "Inbox, 3, button" instead of an unlabeled SVG and a word.
1122
1126
  // LynxRouterLinkActive adds `selected` to `button` while the route is
@@ -1140,35 +1144,33 @@ export class UiDock implements SearchFieldContainer {
1140
1144
  '(bindtap)': 'pressed.emit()',
1141
1145
  },
1142
1146
  template: `
1143
- <view [class]="contentClass()">
1144
- <view [class]="iconFrameClass()">
1145
- @if (design() === 'material') {
1146
- <!-- Material's active indicator: a pill behind the icon that
1147
- widens from its center as the item is selected. -->
1147
+ <view [class]="iconFrameClass()">
1148
+ @if (design() === 'material') {
1149
+ <!-- Material's active indicator: a pill behind the icon that
1150
+ widens from its center as the item is selected. -->
1151
+ <view
1152
+ class="h-8 w-14 rounded-full bg-primary absolute left-0 top-0"
1153
+ [style]="indicatorStyle()"
1154
+ ></view>
1155
+ }
1156
+ <view class="relative">
1157
+ <svg [attr.content]="iconSvg()" style="width: 24px; height: 24px;" />
1158
+ @if (badgeText()) {
1159
+ <!-- Hangs off the icon's top-right corner, as on a native tab
1160
+ bar. min-w-4 keeps a one-digit badge round; longer ones
1161
+ grow into a pill. -->
1148
1162
  <view
1149
- class="h-8 w-14 rounded-full bg-primary absolute left-0 top-0"
1150
- [style]="indicatorStyle()"
1151
- ></view>
1152
- }
1153
- <view class="relative">
1154
- <svg [attr.content]="iconSvg()" style="width: 24px; height: 24px;" />
1155
- @if (badgeText()) {
1156
- <!-- Hangs off the icon's top-right corner, as on a native tab
1157
- bar. min-w-4 keeps a one-digit badge round; longer ones
1158
- grow into a pill. -->
1159
- <view
1160
- class="h-4 min-w-4 items-center rounded-full bg-destructive px-1 flex absolute -right-2.5 -top-1 justify-center"
1163
+ class="h-4 min-w-4 items-center rounded-full bg-destructive px-1 flex absolute -right-2.5 -top-1 justify-center"
1164
+ >
1165
+ <text
1166
+ class="text-[10px] font-semibold leading-4 text-destructive-foreground"
1167
+ >{{ badgeText() }}</text
1161
1168
  >
1162
- <text
1163
- class="text-[10px] font-semibold leading-4 text-destructive-foreground"
1164
- >{{ badgeText() }}</text
1165
- >
1166
- </view>
1167
- }
1168
- </view>
1169
+ </view>
1170
+ }
1169
1171
  </view>
1170
- <text [class]="labelClass()">{{ label() }}</text>
1171
1172
  </view>
1173
+ <text [class]="labelClass()">{{ label() }}</text>
1172
1174
  `,
1173
1175
  })
1174
1176
  export class UiDockItem {
@@ -33,6 +33,15 @@ describe('UiEmptyState', () => {
33
33
  expect(container.querySelector('.py-6')).toBeTruthy();
34
34
  });
35
35
 
36
+ // Static attributes land on a component's host too (issue #181): the
37
+ // padding once, on the empty state itself, and the title only as its text.
38
+ it('puts its class on one element, and its title only in its text', async () => {
39
+ const { container, queryAllByTitle } = await render(EmptyStateHost);
40
+ expect(container.querySelectorAll('.py-6')).toHaveLength(1);
41
+ expect(container.querySelector('.py-6')?.classList).toContain('flex-col');
42
+ expect(queryAllByTitle('No results')).toEqual([]);
43
+ });
44
+
36
45
  it('adds the icon and description when they are set', async () => {
37
46
  const { componentRef, container, getByText } = await render(EmptyStateHost);
38
47
  const host = componentRef.instance as EmptyStateHost;
@@ -5,22 +5,31 @@ import { cn } from '../../utils/cn';
5
5
  import { type IconName } from '../icon/icons';
6
6
  import { UiIcon } from '../icon/icon';
7
7
 
8
+ /**
9
+ * The empty state is its host, styled through `host`. Angular gives a
10
+ * component's host every static attribute the app writes on it, so a class on
11
+ * <ui-empty-state> lands on the host whatever the component does: with a
12
+ * styled view inside as well, the padding applied twice. Its title lands
13
+ * there too, and goes: it's shown in a <text>, and on the web preview a
14
+ * `title` attribute is a tooltip (and a second match for getByTitle).
15
+ */
8
16
  @Component({
9
17
  selector: 'ui-empty-state',
10
- standalone: true,
11
18
  imports: [LYNX_ELEMENTS, UiIcon],
12
19
  encapsulation: ViewEncapsulation.None,
20
+ host: {
21
+ '[class]': 'containerClass()',
22
+ '[attr.title]': 'null',
23
+ },
13
24
  template: `
14
- <view [class]="containerClass()">
15
- @if (icon()) {
16
- <ui-icon [name]="icon()!" size="lg" class="mb-3" />
17
- }
18
- <text [class]="titleClass()">{{ title() }}</text>
19
- @if (description()) {
20
- <text [class]="descriptionClass()">{{ description() }}</text>
21
- }
22
- <ng-content />
23
- </view>
25
+ @if (icon()) {
26
+ <ui-icon [name]="icon()!" size="lg" class="mb-3" />
27
+ }
28
+ <text [class]="titleClass()">{{ title() }}</text>
29
+ @if (description()) {
30
+ <text [class]="descriptionClass()">{{ description() }}</text>
31
+ }
32
+ <ng-content />
24
33
  `,
25
34
  })
26
35
  export class UiEmptyState {
@@ -105,6 +105,23 @@ describe('UiIcon', () => {
105
105
  });
106
106
  });
107
107
 
108
+ @Component({
109
+ imports: [UiIcon],
110
+ template: `<ui-icon name="search" class="mb-3" />`,
111
+ })
112
+ class StaticClassHost {}
113
+
114
+ describe('UiIcon on its own', () => {
115
+ // A class on a component lands on its host too (issue #181): the host is
116
+ // the <svg>, so the margin applies once.
117
+ it('is one svg, with the class written on it', async () => {
118
+ const { container } = await render(StaticClassHost);
119
+ const svgs = [...container.querySelectorAll('svg')];
120
+ expect(svgs).toHaveLength(1);
121
+ expect([...container.querySelectorAll('.mb-3')]).toEqual(svgs);
122
+ });
123
+ });
124
+
108
125
  describe('ICONS registry', () => {
109
126
  it('exports at least one icon', () => {
110
127
  expect(Object.keys(ICONS).length).toBeGreaterThan(0);
@@ -1,5 +1,5 @@
1
- import { Component, ViewEncapsulation, input } from '@angular/core';
2
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
1
+ import { Component, ViewEncapsulation, computed, input } from '@angular/core';
2
+ import { registerHostElement } from '@blotch/angular-lynx';
3
3
 
4
4
  import { cn } from '../../utils/cn';
5
5
  import { injectIconColor } from './icon-theme';
@@ -8,6 +8,12 @@ import { type IconName, ICONS } from './icons';
8
8
  const SIZE_MAP = { xs: 16, sm: 20, md: 24, lg: 32 } as const;
9
9
 
10
10
  /**
11
+ * The icon is its host, an `<svg>` (registerHostElement, below). Angular gives
12
+ * a component's host every static attribute the app writes on it, so
13
+ * `<ui-icon class="mb-3">` puts `mb-3` on the host whatever the component
14
+ * does: with a styled `<svg>` inside a view host as well, the margin applied
15
+ * twice.
16
+ *
11
17
  * SVG markup is passed via [attr.content] rather than as child elements.
12
18
  * Lynx's native <svg> element can't contain child nodes in the flat element
13
19
  * model. Passing the full SVG string as the `content` attribute is how Lynx
@@ -15,15 +21,14 @@ const SIZE_MAP = { xs: 16, sm: 20, md: 24, lg: 32 } as const;
15
21
  */
16
22
  @Component({
17
23
  selector: 'ui-icon',
18
- imports: [LYNX_ELEMENTS],
19
24
  encapsulation: ViewEncapsulation.None,
20
- template: `
21
- <svg
22
- [attr.content]="svgContent()"
23
- [attr.style]="sizeStyle()"
24
- [class]="svgClass()"
25
- />
26
- `,
25
+ host: {
26
+ '[attr.content]': 'svgContent()',
27
+ '[style.width.px]': 'sizePx()',
28
+ '[style.height.px]': 'sizePx()',
29
+ '[class]': 'svgClass()',
30
+ },
31
+ template: '',
27
32
  })
28
33
  export class UiIcon {
29
34
  readonly name = input.required<IconName>();
@@ -60,12 +65,9 @@ export class UiIcon {
60
65
  .join(this.#resolveColor(this.color()));
61
66
  }
62
67
 
63
- protected sizeStyle(): string {
64
- const px = SIZE_MAP[this.size()];
65
- return `width: ${px}px; height: ${px}px;`;
66
- }
68
+ protected readonly sizePx = computed(() => SIZE_MAP[this.size()]);
67
69
 
68
- protected svgClass(): string {
69
- return cn(this.userClass());
70
- }
70
+ protected readonly svgClass = computed(() => cn(this.userClass()));
71
71
  }
72
+
73
+ registerHostElement(UiIcon, 'svg');
@@ -1,4 +1,5 @@
1
1
  import { Component, signal } from '@angular/core';
2
+ import { FormField, form } from '@angular/forms/signals';
2
3
  import {
3
4
  fireEvent,
4
5
  render,
@@ -34,6 +35,7 @@ import { UiInput } from './input';
34
35
  (changed)="changes.push($event)"
35
36
  (focused)="events.push('focused')"
36
37
  (blurred)="events.push('blurred')"
38
+ (touch)="events.push('touch')"
37
39
  />
38
40
  `,
39
41
  })
@@ -53,7 +55,7 @@ const renderInput = async () => {
53
55
  ...result,
54
56
  host: result.componentRef.instance as InputHost,
55
57
  field: () => result.container.querySelector('input')!,
56
- // The field's own column; the host element renders as a view too.
58
+ // The field's column: its host.
57
59
  column: () => result.container.querySelector('.flex-col')!,
58
60
  wrapper: () => result.container.querySelector('.bg-muted')!,
59
61
  // The layer that draws the focus ring around the field.
@@ -82,6 +84,15 @@ describe('UiInput', () => {
82
84
  expect(queryByText('you@example.com')).toBeNull();
83
85
  });
84
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 } = await renderInput();
92
+ expect(container.querySelectorAll('.w-full')).toHaveLength(1);
93
+ expect(getAllByPlaceholderText('you@example.com')).toEqual([field()]);
94
+ });
95
+
85
96
  it('shows its label', async () => {
86
97
  const { host, getByText } = await renderInput();
87
98
  host.label.set('Email');
@@ -89,6 +100,23 @@ describe('UiInput', () => {
89
100
  expect(getByText('Email').classList).toContain('font-medium');
90
101
  });
91
102
 
103
+ // Issue #180: the label sat beside the field, so screen readers read the
104
+ // placeholder, or nothing once the field held text.
105
+ it('names the native field after its label, for screen readers', async () => {
106
+ const { host, field, getByRole, getByText } = await renderInput();
107
+ // A screen reader reaches the field with or without a label.
108
+ expect(field().getAttribute('accessibility-element')).toBe('true');
109
+ expect(field().hasAttribute('accessibility-label')).toBe(false);
110
+
111
+ host.label.set('Email');
112
+ await waitForUpdate();
113
+ expect(getByRole('textbox', { name: 'Email' })).toBe(field());
114
+ // The label the field reads isn't a second stop of its own.
115
+ expect(
116
+ getByText('Email').getAttribute('accessibility-elements-hidden'),
117
+ ).toBe('true');
118
+ });
119
+
92
120
  it('updates `value` and emits `changed` as the user types', async () => {
93
121
  const { host, field } = await renderInput();
94
122
  // Lynx puts the typed text in the event's detail, not on its target.
@@ -126,7 +154,7 @@ describe('UiInput', () => {
126
154
  setAttribute.mockRestore();
127
155
  });
128
156
 
129
- it('shows a focus ring while focused, and emits `focused` and `blurred`', async () => {
157
+ it('shows a focus ring while focused, and emits `focused`, `blurred` and `touch`', async () => {
130
158
  const { host, field, ring } = await renderInput();
131
159
  expect(ring().style.getPropertyValue('opacity')).toBe('0');
132
160
 
@@ -140,7 +168,7 @@ describe('UiInput', () => {
140
168
  fireEvent.blur(field());
141
169
  await waitForUpdate();
142
170
  expect(ring().style.getPropertyValue('opacity')).toBe('0');
143
- expect(host.events).toEqual(['focused', 'blurred']);
171
+ expect(host.events).toEqual(['focused', 'blurred', 'touch']);
144
172
  });
145
173
 
146
174
  it('shows its helper text', async () => {
@@ -200,6 +228,30 @@ describe('UiInput', () => {
200
228
  ]);
201
229
  });
202
230
 
231
+ // Issue #180: without `touch`, a field's error could only show once
232
+ // submit() marked every field touched.
233
+ it('marks a Signal Forms field touched when it loses focus', async () => {
234
+ @Component({
235
+ imports: [FormField, UiInput],
236
+ template: `<ui-input label="Email" [formField]="signIn.email" />`,
237
+ })
238
+ class SignalHost {
239
+ readonly model = signal({ email: '' });
240
+ readonly signIn = form(this.model);
241
+ }
242
+ const { componentRef, getByRole } = await render(SignalHost);
243
+ const host = componentRef.instance as SignalHost;
244
+ const email = getByRole('textbox', { name: 'Email' });
245
+
246
+ fireEvent.focus(email);
247
+ fireEvent.input(email, { detail: { value: 'jane@example.com' } });
248
+ expect(host.model().email).toBe('jane@example.com');
249
+ expect(host.signIn.email().touched()).toBe(false);
250
+
251
+ fireEvent.blur(email);
252
+ expect(host.signIn.email().touched()).toBe(true);
253
+ });
254
+
203
255
  it('makes the wrapper a stacking context so the field scrolls with it', async () => {
204
256
  // On Lynx a z-indexed element inside a scroll-view only follows the scroll
205
257
  // when an ancestor inside the scroll content is a stacking context. Without
@@ -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,69 +19,88 @@ import { cn } from '../../utils/cn';
19
19
 
20
20
  @Component({
21
21
  selector: 'ui-input',
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-input> 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
+ // <input>, 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
- <!-- Focus ring is an inline box-shadow on a dedicated overlay layer, not a
31
- Tailwind ring-* class (the Lynx preset doesn't wire up --tw-ring-*, so
32
- ring-* renders nothing on device). It rides its OWN layer so it can
33
- fade: box-shadow is animatable:no on Lynx (the core rejects a
34
- transition: box-shadow), but opacity is animatable, so we transition
35
- the layer's opacity instead. The overlay is the FIRST child so Lynx,
36
- which paints in strict source order, keeps the <input> on top and
37
- tappable. Source order alone is NOT enough on web: CSS paints a
38
- positioned (absolute) sibling above a static one regardless of source
39
- order, so this transparent overlay would swallow every tap — no focus,
40
- no typing. The <input> below therefore carries position:relative +
41
- z-index:1 so it sits above the overlay on BOTH platforms (required on
42
- web). On Lynx, though, a z-indexed element is stacked against its
43
- nearest stacking context, and a scroll-view isn't one unless it sets
44
- z-index itself — so the native input would stay fixed on screen while
45
- the page scrolled (only its placeholder visibly floats). The wrapper's
46
- z-0 makes it that stacking context, keeping the input's z-index local
47
- so it scrolls with the field. pointer-events is avoided — it errors
48
- the Lynx build. The shadow is OUTSET only: Lynx doesn't render inset box-shadows,
49
- so the crisp 1px "border" is an outset ring hugging the edge, not a
50
- real inset border. -->
51
- <view [class]="inputWrapperClass()">
52
- <view
53
- class="rounded-xl opacity-0 absolute bottom-0 left-0 right-0 top-0"
54
- [style.transition]="'opacity 150ms ease'"
55
- [style.box-shadow]="ringShadow()"
56
- [style.opacity]="ringVisible() ? 1 : 0"
57
- ></view>
58
- <!-- [attr.disabled]="disabled() || undefined": passing 'undefined'
59
- removes the attribute entirely; passing 'false' would set
60
- disabled="false" which Lynx still treats as disabled. -->
61
- <!-- [value] not [attr.value]: [attr.value] calls renderer.setAttribute()
62
- which routes to __SetAttribute — a no-op for live text on Lynx native
63
- inputs. [value] is an Angular input binding that triggers
64
- LynxInput.ngOnChanges, where the setValue UIMethod override lives.
65
- Without this, form model resets would update the signal but the
66
- native input would keep showing the user's old typed text. -->
67
- <input
68
- [attr.placeholder]="placeholder()"
69
- [attr.type]="type()"
70
- [value]="value()"
71
- [attr.disabled]="disabled() || undefined"
72
- class="text-sm text-foreground"
73
- style="border: none; background: transparent; height: 100%; width: 100%; position: relative; z-index: 1;"
74
- (bindinput)="onInput($any($event))"
75
- (bindfocus)="onFocus()"
76
- (bindblur)="onBlur()"
77
- />
78
- </view>
79
- @if (error()) {
80
- <text [class]="errorClass()">{{ error() }}</text>
81
- } @else if (helperText()) {
82
- <text [class]="helperClass()">{{ helperText() }}</text>
83
- }
35
+ <!-- The <input> below carries the label as its accessibility-label, so
36
+ screen readers read it with the field. Hidden here, or VoiceOver and
37
+ TalkBack would stop on it a second time. -->
38
+ @if (label()) {
39
+ <text [class]="labelClass()" accessibility-elements-hidden="true">{{
40
+ label()
41
+ }}</text>
42
+ }
43
+ <!-- Focus ring is an inline box-shadow on a dedicated overlay layer, not a
44
+ Tailwind ring-* class (the Lynx preset doesn't wire up --tw-ring-*, so
45
+ ring-* renders nothing on device). It rides its OWN layer so it can
46
+ fade: box-shadow is animatable:no on Lynx (the core rejects a
47
+ transition: box-shadow), but opacity is animatable, so we transition
48
+ the layer's opacity instead. The overlay is the FIRST child so Lynx,
49
+ which paints in strict source order, keeps the <input> on top and
50
+ tappable. Source order alone is NOT enough on web: CSS paints a
51
+ positioned (absolute) sibling above a static one regardless of source
52
+ order, so this transparent overlay would swallow every tap — no focus,
53
+ no typing. The <input> below therefore carries position:relative +
54
+ z-index:1 so it sits above the overlay on BOTH platforms (required on
55
+ web). On Lynx, though, a z-indexed element is stacked against its
56
+ nearest stacking context, and a scroll-view isn't one unless it sets
57
+ z-index itself — so the native input would stay fixed on screen while
58
+ the page scrolled (only its placeholder visibly floats). The wrapper's
59
+ z-0 makes it that stacking context, keeping the input's z-index local
60
+ so it scrolls with the field. pointer-events is avoided — it errors
61
+ the Lynx build. The shadow is OUTSET only: Lynx doesn't render inset box-shadows,
62
+ so the crisp 1px "border" is an outset ring hugging the edge, not a
63
+ real inset border. -->
64
+ <view [class]="inputWrapperClass()">
65
+ <view
66
+ class="rounded-xl opacity-0 absolute bottom-0 left-0 right-0 top-0"
67
+ [style.transition]="'opacity 150ms ease'"
68
+ [style.box-shadow]="ringShadow()"
69
+ [style.opacity]="ringVisible() ? 1 : 0"
70
+ ></view>
71
+ <!-- [attr.disabled]="disabled() || undefined": passing 'undefined'
72
+ removes the attribute entirely; passing 'false' would set
73
+ disabled="false" which Lynx still treats as disabled. -->
74
+ <!-- [value] not [attr.value]: [attr.value] calls renderer.setAttribute()
75
+ which routes to __SetAttribute — a no-op for live text on Lynx native
76
+ inputs. [value] is an Angular input binding that triggers
77
+ LynxInput.ngOnChanges, where the setValue UIMethod override lives.
78
+ Without this, form model resets would update the signal but the
79
+ native input would keep showing the user's old typed text. -->
80
+ <!-- accessibility-element: on iOS, Lynx makes only <text> and <image>
81
+ accessibility elements by default, so the UITextField under an
82
+ <input> isn't one. VoiceOver then read the placeholder inside it,
83
+ or nothing once it held text. As one, it reads its
84
+ accessibility-label, the label, then its text. -->
85
+ <input
86
+ accessibility-element="true"
87
+ [attr.accessibility-label]="label() || null"
88
+ [attr.placeholder]="placeholder()"
89
+ [attr.type]="type()"
90
+ [value]="value()"
91
+ [attr.disabled]="disabled() || undefined"
92
+ class="text-sm text-foreground"
93
+ style="border: none; background: transparent; height: 100%; width: 100%; position: relative; z-index: 1;"
94
+ (bindinput)="onInput($any($event))"
95
+ (bindfocus)="onFocus()"
96
+ (bindblur)="onBlur()"
97
+ />
84
98
  </view>
99
+ @if (error()) {
100
+ <text [class]="errorClass()">{{ error() }}</text>
101
+ } @else if (helperText()) {
102
+ <text [class]="helperClass()">{{ helperText() }}</text>
103
+ }
85
104
  `,
86
105
  })
87
106
  export class UiInput implements FormValueControl<string> {
@@ -97,8 +116,15 @@ export class UiInput implements FormValueControl<string> {
97
116
  readonly changed = output<string>();
98
117
  readonly focused = output<void>();
99
118
  readonly blurred = output<void>();
119
+ /**
120
+ * The field lost focus, for forms, which mark it as touched as they do a
121
+ * native input (FormUiControl's `touch`). Signal Forms, reactive forms and
122
+ * template-driven forms all listen for it; a `touched` model's change
123
+ * isn't read by any of them.
124
+ */
125
+ readonly touch = output<void>();
100
126
 
101
- readonly containerRef = viewChild<ElementRef>('container');
127
+ readonly #host = inject(ElementRef).nativeElement;
102
128
  readonly #isFocused = signal(false);
103
129
  #shakeAnim?: AnimationHandle;
104
130
  #previousError = '';
@@ -111,10 +137,9 @@ export class UiInput implements FormValueControl<string> {
111
137
  */
112
138
  protected readonly shakeOnError = effect(() => {
113
139
  const err = this.error();
114
- const el = this.containerRef()?.nativeElement;
115
- if (err && !this.#previousError && el) {
140
+ if (err && !this.#previousError) {
116
141
  this.#shakeAnim?.cancel();
117
- this.#shakeAnim = shake(el);
142
+ this.#shakeAnim = shake(this.#host);
118
143
  }
119
144
  this.#previousError = err;
120
145
  });
@@ -179,6 +204,7 @@ export class UiInput implements FormValueControl<string> {
179
204
  protected onBlur(): void {
180
205
  this.#isFocused.set(false);
181
206
  this.blurred.emit();
207
+ this.touch.emit();
182
208
  }
183
209
 
184
210
  /**
@@ -11,6 +11,12 @@ class LabelHost {
11
11
  readonly disabled = signal(false);
12
12
  }
13
13
 
14
+ @Component({
15
+ imports: [UiLabel],
16
+ template: `<ui-label class="mb-2">Email</ui-label>`,
17
+ })
18
+ class StaticClassHost {}
19
+
14
20
  describe('UiLabel', () => {
15
21
  it('renders its text, dimmed while disabled', async () => {
16
22
  const { componentRef, getByText } = await render(LabelHost);
@@ -22,4 +28,13 @@ describe('UiLabel', () => {
22
28
  await waitForUpdate();
23
29
  expect(label.classList).toContain('opacity-50');
24
30
  });
31
+
32
+ // A class on a component lands on its host too (issue #181): the label is
33
+ // one <text>, so the class styles one element.
34
+ it('is one text, with the class written on it', async () => {
35
+ const { container, getByText } = await render(StaticClassHost);
36
+ const label = getByText('Email');
37
+ expect(label.tagName.toLowerCase()).toBe('text');
38
+ expect([...container.querySelectorAll('.mb-2')]).toEqual([label]);
39
+ });
25
40
  });
@@ -5,16 +5,22 @@ import {
5
5
  computed,
6
6
  input,
7
7
  } from '@angular/core';
8
- import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
8
+ import { registerHostElement } from '@blotch/angular-lynx';
9
9
 
10
10
  import { cn } from '../../utils/cn';
11
11
 
12
+ /**
13
+ * The label is its host, a `<text>` (registerHostElement, below), styled
14
+ * through `host`. Angular gives a component's host every static attribute the
15
+ * app writes on it, so `<ui-label class="mb-2">` puts `mb-2` on the host
16
+ * whatever the component does: with a styled `<text>` inside a view host as
17
+ * well, the margin applied twice.
18
+ */
12
19
  @Component({
13
20
  selector: 'ui-label',
14
- standalone: true,
15
- imports: [LYNX_ELEMENTS],
16
21
  encapsulation: ViewEncapsulation.None,
17
- template: `<text [class]="labelClass()"><ng-content /></text>`,
22
+ host: { '[class]': 'labelClass()' },
23
+ template: '<ng-content />',
18
24
  })
19
25
  export class UiLabel {
20
26
  readonly disabled = input(false, { transform: booleanAttribute });
@@ -28,3 +34,5 @@ export class UiLabel {
28
34
  ),
29
35
  );
30
36
  }
37
+
38
+ registerHostElement(UiLabel, 'text');
@@ -45,6 +45,8 @@ const renderList = async () => {
45
45
  ...result,
46
46
  host: result.componentRef.instance as ListHost,
47
47
  list: () => result.container.querySelector('list')!,
48
+ /** The list's box, its host, which the <list> fills. */
49
+ box: () => result.container.querySelector('list')!.parentElement!,
48
50
  };
49
51
  };
50
52
 
@@ -65,12 +67,20 @@ describe('UiList', () => {
65
67
  // Lynx `<list>` is virtualized: items join the native tree only through
66
68
  // its componentAtIndex callback, which a unit test doesn't drive. So this
67
69
  // checks the list itself rather than the projected item.
68
- const { list } = await renderList();
70
+ const { list, box } = await renderList();
69
71
  expect(list().getAttribute('list-type')).toBe('single');
70
72
  expect(list().getAttribute('span-count')).toBe('1');
71
73
  expect(list().getAttribute('scroll-orientation')).toBe('vertical');
72
- expect(list().classList).toContain('w-full');
73
- expect(list().classList).toContain('bg-card');
74
+ expect(list().classList).toContain('grow');
75
+ expect(box().classList).toContain('w-full');
76
+ expect(box().classList).toContain('bg-card');
77
+ });
78
+
79
+ // Angular gives a component's host every static attribute written on it,
80
+ // so the <list> taking the class as well applied it twice (issue #181).
81
+ it('puts its class on one element, its box', async () => {
82
+ const { container, box } = await renderList();
83
+ expect([...container.querySelectorAll('.bg-card')]).toEqual([box()]);
74
84
  });
75
85
 
76
86
  it('gives the list an explicit height, which it needs to scroll', async () => {