@blotch/dolan 22.2.7 → 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.
|
@@ -102,6 +102,23 @@ const renderButton = async (
|
|
|
102
102
|
})
|
|
103
103
|
class StaticAttributesHost {}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* A sign-in button with the provider's mark, an app's own SVG.
|
|
107
|
+
*/
|
|
108
|
+
@Component({
|
|
109
|
+
imports: [UiButton, UiIcon],
|
|
110
|
+
template: `
|
|
111
|
+
<ui-button variant="outline">
|
|
112
|
+
<ui-icon [svg]="mark" />
|
|
113
|
+
Continue with GitHub
|
|
114
|
+
</ui-button>
|
|
115
|
+
`,
|
|
116
|
+
})
|
|
117
|
+
class OwnSvgHost {
|
|
118
|
+
readonly mark =
|
|
119
|
+
'<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>';
|
|
120
|
+
}
|
|
121
|
+
|
|
105
122
|
const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
|
|
106
123
|
const IOS_18 = { platform: 'iOS', osVersion: '18.5' };
|
|
107
124
|
const ANDROID = { platform: 'Android', osVersion: '15' };
|
|
@@ -314,6 +331,19 @@ describe('UiButton', () => {
|
|
|
314
331
|
);
|
|
315
332
|
});
|
|
316
333
|
|
|
334
|
+
it("puts an icon of the app's own SVG before the label, in the label color", async () => {
|
|
335
|
+
const { container, getByText } = await render(OwnSvgHost);
|
|
336
|
+
const icon = container.querySelector('svg')!;
|
|
337
|
+
|
|
338
|
+
expect(container.firstElementChild!.classList).toContain('gap-2');
|
|
339
|
+
expect(
|
|
340
|
+
icon.compareDocumentPosition(getByText('Continue with GitHub')),
|
|
341
|
+
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
|
342
|
+
expect(icon.getAttribute('content')).toContain(
|
|
343
|
+
`fill="${ICON_LIGHT_COLORS['foreground']}"`,
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
|
|
317
347
|
it('puts one icon on each side of the label', async () => {
|
|
318
348
|
const { host, getByText, icons, update } = await renderButton();
|
|
319
349
|
await update(() => {
|
|
@@ -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,
|
|
@@ -122,6 +122,121 @@ describe('UiIcon on its own', () => {
|
|
|
122
122
|
});
|
|
123
123
|
});
|
|
124
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
|
+
|
|
125
240
|
describe('ICONS registry', () => {
|
|
126
241
|
it('exports at least one icon', () => {
|
|
127
242
|
expect(Object.keys(ICONS).length).toBeGreaterThan(0);
|
|
@@ -7,6 +7,19 @@ import { type IconName, ICONS } from './icons';
|
|
|
7
7
|
|
|
8
8
|
const SIZE_MAP = { xs: 16, sm: 20, md: 24, lg: 32 } as const;
|
|
9
9
|
|
|
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
|
+
|
|
10
23
|
/**
|
|
11
24
|
* The icon is its host, an `<svg>` (registerHostElement, below). Angular gives
|
|
12
25
|
* a component's host every static attribute the app writes on it, so
|
|
@@ -18,6 +31,9 @@ const SIZE_MAP = { xs: 16, sm: 20, md: 24, lg: 32 } as const;
|
|
|
18
31
|
* Lynx's native <svg> element can't contain child nodes in the flat element
|
|
19
32
|
* model. Passing the full SVG string as the `content` attribute is how Lynx
|
|
20
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.
|
|
21
37
|
*/
|
|
22
38
|
@Component({
|
|
23
39
|
selector: 'ui-icon',
|
|
@@ -31,7 +47,15 @@ const SIZE_MAP = { xs: 16, sm: 20, md: 24, lg: 32 } as const;
|
|
|
31
47
|
template: '',
|
|
32
48
|
})
|
|
33
49
|
export class UiIcon {
|
|
34
|
-
|
|
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);
|
|
35
59
|
readonly size = input<'xs' | 'sm' | 'md' | 'lg'>('md');
|
|
36
60
|
/**
|
|
37
61
|
* A literal color or a theme token (`var(--primary)`). Without one, the icon
|
|
@@ -60,11 +84,24 @@ export class UiIcon {
|
|
|
60
84
|
// climbs on every render/interaction as each tick re-runs and re-throws.
|
|
61
85
|
// split/join is ES2019-safe and is preferred over `replace(/.../g)` here
|
|
62
86
|
// because the search term is a plain literal and needs no regex escaping.
|
|
63
|
-
return
|
|
87
|
+
return this.#markup()
|
|
64
88
|
.split('currentColor')
|
|
65
89
|
.join(this.#resolveColor(this.color()));
|
|
66
90
|
}
|
|
67
91
|
|
|
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.');
|
|
103
|
+
}
|
|
104
|
+
|
|
68
105
|
protected readonly sizePx = computed(() => SIZE_MAP[this.size()]);
|
|
69
106
|
|
|
70
107
|
protected readonly svgClass = computed(() => cn(this.userClass()));
|