@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
@@ -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 {
@@ -1,6 +1,6 @@
1
- import { Component, signal } from '@angular/core';
1
+ import { Component, ErrorHandler, signal } from '@angular/core';
2
2
  import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
- import { describe, expect, it } from 'vitest';
3
+ import { describe, expect, it, vi } from 'vitest';
4
4
  import { UiIcon } from './icon';
5
5
  import {
6
6
  ICON_DARK_COLORS,
@@ -105,6 +105,138 @@ 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
+
125
+ /** A one-color mark, written as an app would: no namespace. */
126
+ const ONE_COLOR_MARK =
127
+ '<svg viewBox="0 0 16 16"><path fill="currentColor" d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0Z"/></svg>';
128
+
129
+ /** A multi-color mark, which already has the namespace. */
130
+ const TWO_COLOR_MARK =
131
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 1"><path fill="#4285f4" d="M0 0h1v1H0z"/><path fill="#34a853" d="M1 0h1v1H1z"/></svg>';
132
+
133
+ @Component({
134
+ imports: [UiIcon],
135
+ template: `<ui-icon [svg]="svg()" [color]="color()" size="lg" />`,
136
+ })
137
+ class SvgHost {
138
+ readonly svg = signal(ONE_COLOR_MARK);
139
+ readonly color = signal<string | undefined>(undefined);
140
+ }
141
+
142
+ const renderSvg = async (providers: Parameters<typeof render>[1] = {}) => {
143
+ const result = await render(SvgHost, providers);
144
+ return {
145
+ ...result,
146
+ host: result.componentRef.instance as SvgHost,
147
+ content: () =>
148
+ result.container.querySelector('svg')!.getAttribute('content')!,
149
+ };
150
+ };
151
+
152
+ describe("UiIcon with an app's own svg", () => {
153
+ it('draws the markup at the size given, with currentColor in the theme foreground', async () => {
154
+ const { container, content } = await renderSvg();
155
+ expect(container.querySelector('svg')!.getAttribute('style')).toContain(
156
+ '32px',
157
+ );
158
+ expect(content()).toContain('d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0Z"');
159
+ expect(content()).toContain(`fill="${ICON_LIGHT_COLORS['foreground']}"`);
160
+ expect(content()).not.toContain('currentColor');
161
+ });
162
+
163
+ it('follows the color scheme and the color input, as the set does', async () => {
164
+ const { host, content } = await renderSvg({
165
+ providers: [provideIconTheme({ colorScheme: 'dark' })],
166
+ });
167
+ expect(content()).toContain(ICON_DARK_COLORS['foreground']);
168
+
169
+ host.color.set('var(--primary)');
170
+ await waitForUpdate();
171
+ expect(content()).toContain(ICON_DARK_COLORS['primary']);
172
+ });
173
+
174
+ it('keeps the colors a multi-color mark writes out', async () => {
175
+ const { host, content } = await renderSvg();
176
+ host.svg.set(TWO_COLOR_MARK);
177
+ host.color.set('rgb(255, 0, 0)');
178
+ await waitForUpdate();
179
+ expect(content()).toBe(TWO_COLOR_MARK);
180
+ });
181
+
182
+ // The web preview draws the markup as an image, which a browser leaves
183
+ // blank without the namespace.
184
+ it('adds the SVG namespace when the markup has none, and only then', async () => {
185
+ const { host, content } = await renderSvg();
186
+ expect(content()).toMatch(
187
+ /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg" viewBox="0 0 16 16">/,
188
+ );
189
+
190
+ host.svg.set(TWO_COLOR_MARK);
191
+ await waitForUpdate();
192
+ expect(content().match(/xmlns=/g)).toHaveLength(1);
193
+ });
194
+ });
195
+
196
+ @Component({
197
+ imports: [UiIcon],
198
+ template: `<ui-icon [name]="name()" [svg]="svg()" />`,
199
+ })
200
+ class NameAndSvgHost {
201
+ readonly name = signal<IconName | undefined>(undefined);
202
+ readonly svg = signal<string | undefined>(undefined);
203
+ }
204
+
205
+ /**
206
+ * Renders the host, keeping the errors Angular reports: an error in change
207
+ * detection goes to the app's ErrorHandler rather than out of render().
208
+ */
209
+ const renderNameAndSvg = async () => {
210
+ const handleError = vi.fn();
211
+ const result = await render(NameAndSvgHost, {
212
+ providers: [{ provide: ErrorHandler, useValue: { handleError } }],
213
+ });
214
+ return {
215
+ host: result.componentRef.instance as NameAndSvgHost,
216
+ handleError,
217
+ };
218
+ };
219
+
220
+ const NAME_OR_SVG = new Error('<ui-icon> needs a name or an svg, not both.');
221
+
222
+ describe('UiIcon without exactly one of name and svg', () => {
223
+ it('reports an error with neither', async () => {
224
+ const { handleError } = await renderNameAndSvg();
225
+ expect(handleError).toHaveBeenCalledWith(NAME_OR_SVG);
226
+ });
227
+
228
+ it('reports an error with both', async () => {
229
+ const { host, handleError } = await renderNameAndSvg();
230
+ host.name.set('check');
231
+ await waitForUpdate();
232
+ handleError.mockClear();
233
+
234
+ host.svg.set(ONE_COLOR_MARK);
235
+ await waitForUpdate();
236
+ expect(handleError).toHaveBeenCalledWith(NAME_OR_SVG);
237
+ });
238
+ });
239
+
108
240
  describe('ICONS registry', () => {
109
241
  it('exports at least one icon', () => {
110
242
  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,25 +8,54 @@ 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
+ * Gives the markup's root `<svg>` the SVG namespace when it has none. The web
12
+ * preview draws `content` as an image of the markup, and a browser draws
13
+ * nothing for an SVG image without the namespace. An app's own SVG often
14
+ * leaves it out, since an `<svg>` written in an HTML page doesn't need it.
15
+ */
16
+ const withSvgNamespace = (markup: string): string =>
17
+ markup.replace(/<svg\b([^>]*)>/, (tag, attributes: string) =>
18
+ /\sxmlns\s*=/.test(attributes)
19
+ ? tag
20
+ : `<svg xmlns="http://www.w3.org/2000/svg"${attributes}>`,
21
+ );
22
+
23
+ /**
24
+ * The icon is its host, an `<svg>` (registerHostElement, below). Angular gives
25
+ * a component's host every static attribute the app writes on it, so
26
+ * `<ui-icon class="mb-3">` puts `mb-3` on the host whatever the component
27
+ * does: with a styled `<svg>` inside a view host as well, the margin applied
28
+ * twice.
29
+ *
11
30
  * SVG markup is passed via [attr.content] rather than as child elements.
12
31
  * Lynx's native <svg> element can't contain child nodes in the flat element
13
32
  * model. Passing the full SVG string as the `content` attribute is how Lynx
14
33
  * renders vector graphics without a DOM tree.
34
+ *
35
+ * It draws an icon from the set (`name`) or the app's own markup (`svg`),
36
+ * such as a sign-in provider's mark, which the set can't hold.
15
37
  */
16
38
  @Component({
17
39
  selector: 'ui-icon',
18
- imports: [LYNX_ELEMENTS],
19
40
  encapsulation: ViewEncapsulation.None,
20
- template: `
21
- <svg
22
- [attr.content]="svgContent()"
23
- [attr.style]="sizeStyle()"
24
- [class]="svgClass()"
25
- />
26
- `,
41
+ host: {
42
+ '[attr.content]': 'svgContent()',
43
+ '[style.width.px]': 'sizePx()',
44
+ '[style.height.px]': 'sizePx()',
45
+ '[class]': 'svgClass()',
46
+ },
47
+ template: '',
27
48
  })
28
49
  export class UiIcon {
29
- readonly name = input.required<IconName>();
50
+ /** An icon from the set (see `ICONS`). Give this or `svg`. */
51
+ readonly name = input<IconName | undefined>(undefined);
52
+ /**
53
+ * SVG markup to draw instead of an icon from the set. `currentColor` in it
54
+ * takes the icon's color, as in the set's icons, so a one-color mark
55
+ * follows the theme; colors written out stay, so a multi-color one keeps
56
+ * them. Give this or `name`.
57
+ */
58
+ readonly svg = input<string | undefined>(undefined);
30
59
  readonly size = input<'xs' | 'sm' | 'md' | 'lg'>('md');
31
60
  /**
32
61
  * A literal color or a theme token (`var(--primary)`). Without one, the icon
@@ -55,17 +84,27 @@ export class UiIcon {
55
84
  // climbs on every render/interaction as each tick re-runs and re-throws.
56
85
  // split/join is ES2019-safe and is preferred over `replace(/.../g)` here
57
86
  // because the search term is a plain literal and needs no regex escaping.
58
- return ICONS[this.name()]
87
+ return this.#markup()
59
88
  .split('currentColor')
60
89
  .join(this.#resolveColor(this.color()));
61
90
  }
62
91
 
63
- protected sizeStyle(): string {
64
- const px = SIZE_MAP[this.size()];
65
- return `width: ${px}px; height: ${px}px;`;
92
+ /**
93
+ * The markup to draw. Neither input, or both, is a mistake in the template
94
+ * that the compiler can't catch, since neither input can be required, so
95
+ * it fails loudly rather than drawing an empty box or picking one.
96
+ */
97
+ #markup(): string {
98
+ const name = this.name();
99
+ const svg = this.svg();
100
+ if (svg !== undefined && name === undefined) return withSvgNamespace(svg);
101
+ if (name !== undefined && svg === undefined) return ICONS[name];
102
+ throw new Error('<ui-icon> needs a name or an svg, not both.');
66
103
  }
67
104
 
68
- protected svgClass(): string {
69
- return cn(this.userClass());
70
- }
105
+ protected readonly sizePx = computed(() => SIZE_MAP[this.size()]);
106
+
107
+ protected readonly svgClass = computed(() => cn(this.userClass()));
71
108
  }
109
+
110
+ 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