@blotch/dolan 22.2.7 → 22.2.9

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(() => {
@@ -20,8 +20,10 @@ import {
20
20
  import { toSignal } from '@angular/core/rxjs-interop';
21
21
  import { RouterLink, RouterLinkActive } from '@angular/router';
22
22
  import {
23
+ type LayoutChangeDetail,
23
24
  LYNX_ELEMENTS,
24
25
  LynxAccessibilitySettings,
26
+ type LynxEvent,
25
27
  LynxRouterLinkActive,
26
28
  } from '@blotch/angular-lynx';
27
29
 
@@ -205,9 +207,6 @@ const CAPSULE_OPACITY = 0.1;
205
207
  */
206
208
  const ACCESSORY_ICON_COLOR = signal('var(--glass-foreground)').asReadonly();
207
209
 
208
- /** What Lynx's `layoutchange` event carries, as far as the dock reads it. */
209
- type LayoutChangeEvent = Event & { detail: { width: number } };
210
-
211
210
  /**
212
211
  * A CSS length in px.
213
212
  */
@@ -1014,8 +1013,8 @@ export class UiDock implements SearchFieldContainer {
1014
1013
  );
1015
1014
  });
1016
1015
 
1017
- protected measure(event: Event): void {
1018
- this.width.set((event as LayoutChangeEvent).detail.width);
1016
+ protected measure({ detail }: LynxEvent<LayoutChangeDetail>): void {
1017
+ this.width.set(detail.width);
1019
1018
  }
1020
1019
 
1021
1020
  /**
@@ -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()));
@@ -91,7 +91,7 @@ import { cn } from '../../utils/cn';
91
91
  [attr.disabled]="disabled() || undefined"
92
92
  class="text-sm text-foreground"
93
93
  style="border: none; background: transparent; height: 100%; width: 100%; position: relative; z-index: 1;"
94
- (bindinput)="onInput($any($event))"
94
+ (bindinput)="onInput($event)"
95
95
  (bindfocus)="onFocus()"
96
96
  (bindblur)="onBlur()"
97
97
  />
@@ -12,8 +12,10 @@ import {
12
12
  } from '@angular/core';
13
13
  import { ROUTES, Router } from '@angular/router';
14
14
  import {
15
+ type LayoutChangeDetail,
15
16
  LYNX_ELEMENTS,
16
17
  LynxAccessibilitySettings,
18
+ type LynxEvent,
17
19
  LynxLocationStrategy,
18
20
  } from '@blotch/angular-lynx';
19
21
 
@@ -48,9 +50,6 @@ const ROW_PADDING: Readonly<Record<GlassDesign, number>> = {
48
50
  /** The space between a centered title and the controls beside it, in px. */
49
51
  const TITLE_GAP = 8;
50
52
 
51
- /** What Lynx's `layoutchange` event carries, as far as the bar reads it. */
52
- type LayoutChangeEvent = Event & { detail: { width: number } };
53
-
54
53
  /**
55
54
  * How the inline title comes in as the large title scrolls under the bar,
56
55
  * and how Material's bar changes color as content scrolls under it.
@@ -379,20 +378,20 @@ export class UiNavigationBar {
379
378
  ),
380
379
  );
381
380
 
382
- protected measureRow(event: Event): void {
383
- this.rowWidth.set((event as LayoutChangeEvent).detail.width);
381
+ protected measureRow({ detail }: LynxEvent<LayoutChangeDetail>): void {
382
+ this.rowWidth.set(detail.width);
384
383
  }
385
384
 
386
- protected measureTitle(event: Event): void {
387
- this.titleWidth.set((event as LayoutChangeEvent).detail.width);
385
+ protected measureTitle({ detail }: LynxEvent<LayoutChangeDetail>): void {
386
+ this.titleWidth.set(detail.width);
388
387
  }
389
388
 
390
- protected measureLeading(event: Event): void {
391
- this.leadingWidth.set((event as LayoutChangeEvent).detail.width);
389
+ protected measureLeading({ detail }: LynxEvent<LayoutChangeDetail>): void {
390
+ this.leadingWidth.set(detail.width);
392
391
  }
393
392
 
394
- protected measureTrailing(event: Event): void {
395
- this.trailingWidth.set((event as LayoutChangeEvent).detail.width);
393
+ protected measureTrailing({ detail }: LynxEvent<LayoutChangeDetail>): void {
394
+ this.trailingWidth.set(detail.width);
396
395
  }
397
396
 
398
397
  /**
@@ -184,11 +184,11 @@ const iconSvg = (icon: IconName, color: string): string =>
184
184
  [value]="value()"
185
185
  [class]="inputClass()"
186
186
  [style.font-size.px]="look().fontSize"
187
- (bindinput)="onInput($any($event))"
188
- (bindconfirm)="onConfirm($any($event))"
187
+ (bindinput)="onInput($event)"
188
+ (bindconfirm)="onConfirm($event)"
189
189
  (bindfocus)="onFocus()"
190
190
  (bindblur)="onBlur()"
191
- (bindkeyboardheightchange)="onKeyboard($any($event))"
191
+ (bindkeyboardheightchange)="onKeyboard($event)"
192
192
  />
193
193
  @if (value() && !disabled()) {
194
194
  <view
@@ -85,7 +85,7 @@ import { cn } from '../../utils/cn';
85
85
  [attr.enable-scroll-bar]="maxLines() != null ? 'true' : null"
86
86
  class="text-sm text-foreground"
87
87
  style="border: none; background: transparent; height: 100%; width: 100%; line-height: 20px; position: relative; z-index: 1;"
88
- (bindinput)="onInput($any($event))"
88
+ (bindinput)="onInput($event)"
89
89
  (bindfocus)="onFocus()"
90
90
  (bindblur)="onBlur()"
91
91
  ></textarea>
@@ -12,7 +12,13 @@ import {
12
12
  type Signal,
13
13
  type WritableSignal,
14
14
  } from '@angular/core';
15
- import { LYNX_ELEMENTS, LynxSystemInfo } from '@blotch/angular-lynx';
15
+ import {
16
+ type LayoutChangeDetail,
17
+ LYNX_ELEMENTS,
18
+ type LynxEvent,
19
+ LynxSystemInfo,
20
+ type ScrollDetail,
21
+ } from '@blotch/angular-lynx';
16
22
 
17
23
  import { cn } from '../../utils/cn';
18
24
  import { type GlassDesign, injectGlassDesign } from '../glass/glass-design';
@@ -131,10 +137,6 @@ export const edgeFadeOverlay = (toward: 'bottom' | 'top'): string => {
131
137
  return `linear-gradient(to ${toward}, ${stops.join(', ')})`;
132
138
  };
133
139
 
134
- /** What Lynx sends with `scroll` and `layoutchange`, as far as this reads. */
135
- type ScrollDetail = { scrollTop: number; scrollHeight: number };
136
- type LayoutDetail = { height: number };
137
-
138
140
  /**
139
141
  * The platform, as SystemInfo names it, or undefined where there's no
140
142
  * SystemInfo to read, such as in unit tests, where LynxSystemInfo throws.
@@ -234,19 +236,19 @@ export class UiBarScroll {
234
236
  this.#renderer.listen(
235
237
  this.#element,
236
238
  'bindscroll',
237
- (event: { detail: ScrollDetail }) => this.#scrolled(event.detail),
239
+ (event: LynxEvent<ScrollDetail>) => this.#scrolled(event.detail),
238
240
  ),
239
241
  // iOS and Android report the content's height when it changes, which a
240
242
  // frosted toolbar needs before anything scrolls.
241
243
  this.#renderer.listen(
242
244
  this.#element,
243
245
  'bindcontentsizechanged',
244
- (event: { detail: ScrollDetail }) => this.#scrolled(event.detail),
246
+ (event: LynxEvent<ScrollDetail>) => this.#scrolled(event.detail),
245
247
  ),
246
248
  this.#renderer.listen(
247
249
  this.#element,
248
250
  'bindlayoutchange',
249
- (event: { detail: LayoutDetail }) => {
251
+ (event: LynxEvent<LayoutChangeDetail>) => {
250
252
  this.viewport.set(event.detail.height);
251
253
  this.#update();
252
254
  },
@@ -334,16 +336,12 @@ export class UiBarScroll {
334
336
  return `linear-gradient(to bottom, ${stops.join(', ')})`;
335
337
  });
336
338
 
337
- protected measureTop(event: Event): void {
338
- this.topSpace.set(
339
- (event as Event & { detail: LayoutDetail }).detail.height,
340
- );
339
+ protected measureTop({ detail }: LynxEvent<LayoutChangeDetail>): void {
340
+ this.topSpace.set(detail.height);
341
341
  }
342
342
 
343
- protected measureBottom(event: Event): void {
344
- this.bottomSpace.set(
345
- (event as Event & { detail: LayoutDetail }).detail.height,
346
- );
343
+ protected measureBottom({ detail }: LynxEvent<LayoutChangeDetail>): void {
344
+ this.bottomSpace.set(detail.height);
347
345
  }
348
346
 
349
347
  #scrolled({ scrollTop, scrollHeight }: ScrollDetail): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blotch/dolan",
3
- "version": "22.2.7",
3
+ "version": "22.2.9",
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": {