@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.
- package/dist/commands/install-packages.test.js +7 -2
- package/dist/commands/install-packages.test.js.map +1 -1
- package/dist/registry.js +8 -2
- package/dist/registry.js.map +1 -1
- package/dist/registry.test.js +1 -0
- package/dist/registry.test.js.map +1 -1
- package/dist/ui/components/accordion/accordion.test.ts +24 -7
- package/dist/ui/components/accordion/accordion.ts +34 -44
- package/dist/ui/components/action-sheet/action-sheet.test.ts +18 -1
- package/dist/ui/components/action-sheet/action-sheet.ts +55 -60
- package/dist/ui/components/alert/alert.test.ts +17 -0
- package/dist/ui/components/alert/alert.ts +23 -14
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +19 -4
- package/dist/ui/components/alert-dialog/alert-dialog.ts +31 -20
- package/dist/ui/components/aspect-ratio/aspect-ratio.test.ts +38 -2
- package/dist/ui/components/aspect-ratio/aspect-ratio.ts +11 -8
- package/dist/ui/components/avatar/avatar.test.ts +7 -0
- package/dist/ui/components/avatar/avatar.ts +35 -30
- package/dist/ui/components/badge/badge.test.ts +9 -0
- package/dist/ui/components/badge/badge.ts +12 -10
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +7 -0
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +5 -0
- package/dist/ui/components/button/button.test.ts +59 -3
- package/dist/ui/components/button/button.ts +67 -62
- package/dist/ui/components/button-group/button-group.test.ts +37 -4
- package/dist/ui/components/button-group/button-group.ts +60 -51
- package/dist/ui/components/card/card.test.ts +35 -2
- package/dist/ui/components/card/card.ts +41 -53
- package/dist/ui/components/chart/chart-presets.test.ts +16 -2
- package/dist/ui/components/chart/chart-presets.ts +22 -3
- package/dist/ui/components/chart/chart.test.ts +35 -6
- package/dist/ui/components/chart/chart.ts +14 -3
- package/dist/ui/components/checkbox/checkbox.test.ts +11 -4
- package/dist/ui/components/checkbox/checkbox.ts +21 -14
- package/dist/ui/components/collapsible/collapsible.test.ts +20 -10
- package/dist/ui/components/collapsible/collapsible.ts +29 -40
- package/dist/ui/components/dialog/dialog.test.ts +17 -1
- package/dist/ui/components/dialog/dialog.ts +24 -17
- package/dist/ui/components/dock/dock.test.ts +30 -5
- package/dist/ui/components/dock/dock.ts +29 -27
- package/dist/ui/components/empty-state/empty-state.test.ts +9 -0
- package/dist/ui/components/empty-state/empty-state.ts +20 -11
- package/dist/ui/components/icon/icon.test.ts +134 -2
- package/dist/ui/components/icon/icon.ts +57 -18
- package/dist/ui/components/input/input.test.ts +55 -3
- package/dist/ui/components/input/input.ts +91 -65
- package/dist/ui/components/label/label.test.ts +15 -0
- package/dist/ui/components/label/label.ts +12 -4
- package/dist/ui/components/list/list.test.ts +13 -3
- package/dist/ui/components/list/list.ts +14 -2
- package/dist/ui/components/menu/menu.test.ts +8 -0
- package/dist/ui/components/menu/menu.ts +4 -0
- package/dist/ui/components/nav-drawer/nav-drawer.test.ts +34 -21
- package/dist/ui/components/nav-drawer/nav-drawer.ts +60 -68
- package/dist/ui/components/navigation-bar/navigation-bar.test.ts +13 -0
- package/dist/ui/components/navigation-bar/navigation-bar.ts +5 -0
- package/dist/ui/components/pagination/pagination.test.ts +9 -0
- package/dist/ui/components/pagination/pagination.ts +52 -49
- package/dist/ui/components/popover/popover.test.ts +3 -0
- package/dist/ui/components/progress/progress.test.ts +7 -0
- package/dist/ui/components/progress/progress.ts +8 -6
- package/dist/ui/components/radio-group/radio-group.test.ts +8 -0
- package/dist/ui/components/radio-group/radio-group.ts +22 -19
- package/dist/ui/components/scroll-area/scroll-area.test.ts +14 -2
- package/dist/ui/components/scroll-area/scroll-area.ts +15 -3
- package/dist/ui/components/search-field/search-field.test.ts +25 -0
- package/dist/ui/components/search-field/search-field.ts +7 -1
- package/dist/ui/components/select/select.test.ts +32 -5
- package/dist/ui/components/select/select.ts +32 -11
- package/dist/ui/components/separator/separator.test.ts +3 -0
- package/dist/ui/components/sheet/sheet.test.ts +18 -0
- package/dist/ui/components/sheet/sheet.ts +24 -24
- package/dist/ui/components/skeleton/skeleton.test.ts +8 -0
- package/dist/ui/components/skeleton/skeleton.ts +13 -8
- package/dist/ui/components/slider/slider.test.ts +13 -1
- package/dist/ui/components/slider/slider.ts +16 -3
- package/dist/ui/components/spinner/spinner.test.ts +16 -1
- package/dist/ui/components/spinner/spinner.ts +15 -7
- package/dist/ui/components/switch/switch.test.ts +13 -2
- package/dist/ui/components/switch/switch.ts +15 -1
- package/dist/ui/components/tab-bar/tab-bar.test.ts +32 -6
- package/dist/ui/components/tab-bar/tab-bar.ts +28 -24
- package/dist/ui/components/tabs/tabs.test.ts +34 -32
- package/dist/ui/components/tabs/tabs.ts +56 -73
- package/dist/ui/components/textarea/textarea.test.ts +11 -1
- package/dist/ui/components/textarea/textarea.ts +77 -71
- package/dist/ui/components/toggle/toggle.test.ts +47 -16
- package/dist/ui/components/toggle/toggle.ts +78 -80
- package/dist/ui/components/typography/typography.test.ts +16 -0
- package/dist/ui/components/typography/typography.ts +12 -4
- 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
|
-
|
|
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]="
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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
|
-
|
|
1163
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 {
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
87
|
+
return this.#markup()
|
|
59
88
|
.split('currentColor')
|
|
60
89
|
.join(this.#resolveColor(this.color()));
|
|
61
90
|
}
|
|
62
91
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
69
|
-
|
|
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
|
|
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 `
|
|
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
|