@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
- 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);
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 ICONS[this.name()]
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()));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blotch/dolan",
3
- "version": "22.2.7",
3
+ "version": "22.2.8",
4
4
  "description": "CLI for adding @blotch/ui components to your project",
5
5
  "homepage": "https://github.com/Blotch-Smart-Frames/AngularLynx#readme",
6
6
  "bugs": {