@blotch/dolan 22.2.2 → 22.2.3

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 (154) hide show
  1. package/dist/commands/add.d.ts +6 -2
  2. package/dist/commands/add.js +32 -10
  3. package/dist/commands/add.js.map +1 -1
  4. package/dist/commands/add.test.js +129 -9
  5. package/dist/commands/add.test.js.map +1 -1
  6. package/dist/commands/diff.d.ts +2 -1
  7. package/dist/commands/diff.js +38 -25
  8. package/dist/commands/diff.js.map +1 -1
  9. package/dist/commands/diff.test.js +72 -11
  10. package/dist/commands/diff.test.js.map +1 -1
  11. package/dist/commands/doctor.js +41 -27
  12. package/dist/commands/doctor.js.map +1 -1
  13. package/dist/commands/doctor.test.js +93 -9
  14. package/dist/commands/doctor.test.js.map +1 -1
  15. package/dist/commands/eject.js +7 -0
  16. package/dist/commands/eject.js.map +1 -1
  17. package/dist/commands/eject.test.js +39 -0
  18. package/dist/commands/eject.test.js.map +1 -1
  19. package/dist/commands/info.js +3 -2
  20. package/dist/commands/info.js.map +1 -1
  21. package/dist/commands/init.d.ts +37 -1
  22. package/dist/commands/init.js +149 -40
  23. package/dist/commands/init.js.map +1 -1
  24. package/dist/commands/init.test.js +285 -26
  25. package/dist/commands/init.test.js.map +1 -1
  26. package/dist/commands/install-packages.d.ts +6 -0
  27. package/dist/commands/install-packages.js +20 -0
  28. package/dist/commands/install-packages.js.map +1 -0
  29. package/dist/commands/install-packages.test.d.ts +1 -0
  30. package/dist/commands/install-packages.test.js +55 -0
  31. package/dist/commands/install-packages.test.js.map +1 -0
  32. package/dist/commands/list.js +21 -14
  33. package/dist/commands/list.js.map +1 -1
  34. package/dist/commands/list.test.js +62 -5
  35. package/dist/commands/list.test.js.map +1 -1
  36. package/dist/commands/outdated.js +11 -10
  37. package/dist/commands/outdated.js.map +1 -1
  38. package/dist/commands/outdated.test.js +20 -5
  39. package/dist/commands/outdated.test.js.map +1 -1
  40. package/dist/commands/remove.d.ts +4 -0
  41. package/dist/commands/remove.js +17 -4
  42. package/dist/commands/remove.js.map +1 -1
  43. package/dist/commands/remove.test.js +53 -2
  44. package/dist/commands/remove.test.js.map +1 -1
  45. package/dist/commands/update.d.ts +6 -0
  46. package/dist/commands/update.js +59 -24
  47. package/dist/commands/update.js.map +1 -1
  48. package/dist/commands/update.test.js +273 -31
  49. package/dist/commands/update.test.js.map +1 -1
  50. package/dist/index.js +7 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/registry.d.ts +8 -0
  53. package/dist/registry.js +32 -24
  54. package/dist/registry.js.map +1 -1
  55. package/dist/registry.test.js +76 -37
  56. package/dist/registry.test.js.map +1 -1
  57. package/dist/testing/set-input-signal.d.ts +13 -0
  58. package/dist/testing/set-input-signal.js +20 -0
  59. package/dist/testing/set-input-signal.js.map +1 -0
  60. package/dist/testing/set-input-signal.test.d.ts +1 -0
  61. package/dist/testing/set-input-signal.test.js +20 -0
  62. package/dist/testing/set-input-signal.test.js.map +1 -0
  63. package/dist/ui/components/action-sheet/action-sheet.test.ts +48 -1
  64. package/dist/ui/components/action-sheet/action-sheet.ts +37 -9
  65. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +49 -2
  66. package/dist/ui/components/alert-dialog/alert-dialog.ts +43 -12
  67. package/dist/ui/components/area-chart/area-chart-render.test.ts +52 -110
  68. package/dist/ui/components/area-chart/area-chart.test.ts +58 -123
  69. package/dist/ui/components/area-chart/area-chart.ts +100 -315
  70. package/dist/ui/components/area-chart/index.ts +2 -3
  71. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +47 -163
  72. package/dist/ui/components/bar-chart/bar-chart.test.ts +83 -37
  73. package/dist/ui/components/bar-chart/bar-chart.ts +140 -294
  74. package/dist/ui/components/bar-chart/index.ts +3 -3
  75. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +191 -9
  76. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +136 -20
  77. package/dist/ui/components/button/button-render.test.ts +19 -0
  78. package/dist/ui/components/button/button.ts +18 -2
  79. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +48 -163
  80. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +106 -62
  81. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +150 -323
  82. package/dist/ui/components/candlestick-chart/index.ts +3 -4
  83. package/dist/ui/components/chart/chart-focus.test.ts +132 -0
  84. package/dist/ui/components/chart/chart-focus.ts +115 -0
  85. package/dist/ui/components/chart/chart-presets.test.ts +139 -0
  86. package/dist/ui/components/chart/chart-presets.ts +271 -0
  87. package/dist/ui/components/chart/chart-render.test.ts +730 -0
  88. package/dist/ui/components/chart/chart-scene.test.ts +589 -0
  89. package/dist/ui/components/chart/chart-scene.ts +483 -0
  90. package/dist/ui/components/chart/chart-theme.test.ts +85 -0
  91. package/dist/ui/components/chart/chart-theme.ts +198 -0
  92. package/dist/ui/components/chart/chart-zoom.test.ts +159 -0
  93. package/dist/ui/components/chart/chart-zoom.ts +162 -0
  94. package/dist/ui/components/chart/chart.ts +921 -0
  95. package/dist/ui/components/chart/index.ts +24 -0
  96. package/dist/ui/components/dialog/dialog.test.ts +49 -2
  97. package/dist/ui/components/dialog/dialog.ts +43 -12
  98. package/dist/ui/components/icon/icon-theme.test.ts +133 -0
  99. package/dist/ui/components/icon/icon-theme.ts +204 -0
  100. package/dist/ui/components/icon/icon.test.ts +51 -2
  101. package/dist/ui/components/icon/icon.ts +38 -21
  102. package/dist/ui/components/line-chart/index.ts +2 -3
  103. package/dist/ui/components/line-chart/line-chart-render.test.ts +69 -107
  104. package/dist/ui/components/line-chart/line-chart.test.ts +63 -45
  105. package/dist/ui/components/line-chart/line-chart.ts +83 -241
  106. package/dist/ui/components/list/list.test.ts +69 -2
  107. package/dist/ui/components/list/list.ts +21 -15
  108. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +80 -1
  109. package/dist/ui/components/nav-drawer/nav-drawer.ts +66 -11
  110. package/dist/ui/components/pagination/pagination.test.ts +62 -0
  111. package/dist/ui/components/pagination/pagination.ts +55 -8
  112. package/dist/ui/components/scatter-chart/index.ts +2 -3
  113. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +51 -144
  114. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +57 -25
  115. package/dist/ui/components/scatter-chart/scatter-chart.ts +104 -250
  116. package/dist/ui/components/scroll-area/scroll-area.test.ts +37 -2
  117. package/dist/ui/components/scroll-area/scroll-area.ts +8 -8
  118. package/dist/ui/components/spinner/spinner.test.ts +27 -0
  119. package/dist/ui/components/spinner/spinner.ts +33 -23
  120. package/dist/ui/components/toast/toast.test.ts +37 -0
  121. package/dist/ui/components/toast/toast.ts +20 -4
  122. package/dist/ui/theme/chart-colors.test.ts +30 -0
  123. package/dist/ui/theme/dark.css +8 -0
  124. package/dist/ui/theme/default.css +8 -0
  125. package/dist/ui/theme/icon-colors.test.ts +30 -0
  126. package/dist/ui/theme/tailwind-plugin.ts +10 -1
  127. package/dist/utils/installed.d.ts +17 -0
  128. package/dist/utils/installed.js +27 -0
  129. package/dist/utils/installed.js.map +1 -0
  130. package/dist/utils/installed.test.d.ts +1 -0
  131. package/dist/utils/installed.test.js +41 -0
  132. package/dist/utils/installed.test.js.map +1 -0
  133. package/dist/utils/interactive.d.ts +9 -0
  134. package/dist/utils/interactive.js +10 -0
  135. package/dist/utils/interactive.js.map +1 -0
  136. package/dist/utils/interactive.test.d.ts +1 -0
  137. package/dist/utils/interactive.test.js +24 -0
  138. package/dist/utils/interactive.test.js.map +1 -0
  139. package/dist/utils/packages.d.ts +36 -0
  140. package/dist/utils/packages.js +70 -0
  141. package/dist/utils/packages.js.map +1 -0
  142. package/dist/utils/packages.test.d.ts +1 -0
  143. package/dist/utils/packages.test.js +89 -0
  144. package/dist/utils/packages.test.js.map +1 -0
  145. package/dist/utils/resolve-paths.d.ts +17 -1
  146. package/dist/utils/resolve-paths.js +23 -3
  147. package/dist/utils/resolve-paths.js.map +1 -1
  148. package/dist/utils/resolve-paths.test.js +34 -2
  149. package/dist/utils/resolve-paths.test.js.map +1 -1
  150. package/package.json +5 -1
  151. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +0 -188
  152. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +0 -525
  153. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +0 -1140
  154. package/dist/ui/components/cartesian-chart/index.ts +0 -18
@@ -0,0 +1,24 @@
1
+ export {
2
+ UiChart,
3
+ UiChartTooltip,
4
+ type UiChartTooltipContext,
5
+ tooltip,
6
+ } from './chart';
7
+ export {
8
+ type ChartColorScheme,
9
+ type ChartColorSchemeOption,
10
+ type ChartColorTokens,
11
+ CHART_DARK_COLORS,
12
+ CHART_LIGHT_COLORS,
13
+ DEFAULT_CHART_THEME,
14
+ UI_CHART_THEME,
15
+ type UiChartTheme,
16
+ provideChartTheme,
17
+ resolveChartPaint,
18
+ } from './chart-theme';
19
+ export {
20
+ type CartesianPresetOptions,
21
+ type ChartPadding,
22
+ type XYDatum,
23
+ } from './chart-presets';
24
+ export { type ChartViewport, type ChartZoomAxes } from './chart-zoom';
@@ -1,7 +1,8 @@
1
1
  import { Component, signal } from '@angular/core';
2
2
  import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
3
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
- import { describe, expect, it } from 'vitest';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { DURATION } from '../../utils/animate';
5
6
  import { setInputSignal } from '../../../test-utils/set-input-signal';
6
7
  import {
7
8
  UiDialog,
@@ -12,7 +13,6 @@ import {
12
13
  } from './dialog';
13
14
 
14
15
  @Component({
15
- standalone: true,
16
16
  imports: [
17
17
  UiDialog,
18
18
  UiDialogHeader,
@@ -36,6 +36,47 @@ class DialogHost {
36
36
  }
37
37
 
38
38
  describe('UiDialog', () => {
39
+ it('stops its close timers when it is destroyed while closing', async () => {
40
+ const warn = vi.spyOn(console, 'warn');
41
+ const { componentRef, unmount } = await render(UiDialog);
42
+ const inst = componentRef.instance as UiDialog;
43
+ inst.open.set(true);
44
+ for (let i = 0; i < 5; i++) await waitForUpdate();
45
+ inst.open.set(false);
46
+ await waitForUpdate();
47
+ // Destroyed during the close animation, as an `@if` around the dialog
48
+ // would do. Its close timer (DURATION.normal + 20) used to fire anyway
49
+ // and emit `closed` from the destroyed output.
50
+ unmount();
51
+ await new Promise((r) => setTimeout(r, DURATION.normal + 50));
52
+ const warnings = warn.mock.calls.map(([message]) => String(message));
53
+ warn.mockRestore();
54
+ expect(warnings.filter((message) => message.includes('NG0953'))).toEqual(
55
+ [],
56
+ );
57
+ });
58
+
59
+ it('moves a screen reader into the panel and keeps it there while open', async () => {
60
+ const { componentRef, container } = await render(UiDialog);
61
+ const inst = componentRef.instance as UiDialog;
62
+ const panel = container.querySelector(
63
+ '[accessibility-exclusive-focus]',
64
+ ) as Element & { invoke?: unknown };
65
+ expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('false');
66
+ const invoke = vi.fn();
67
+ panel.invoke = invoke;
68
+ (inst as unknown as { backdropRef: unknown }).backdropRef = signal({
69
+ nativeElement: panel,
70
+ });
71
+ (inst as unknown as { panelRef: unknown }).panelRef = signal({
72
+ nativeElement: panel,
73
+ });
74
+ inst.open.set(true);
75
+ for (let i = 0; i < 5; i++) await waitForUpdate();
76
+ expect(invoke).toHaveBeenCalledWith('requestAccessibilityFocus');
77
+ expect(panel.getAttribute('accessibility-exclusive-focus')).toBe('true');
78
+ });
79
+
39
80
  it('renders header, title, description, and footer', async () => {
40
81
  const { getByText } = await render(DialogHost);
41
82
  expect(getByText('Title')).toBeTruthy();
@@ -91,12 +132,18 @@ describe('UiDialog', () => {
91
132
  // Covers the early-return branches in #animateIn / #animateOut.
92
133
  const { componentRef } = await render(UiDialog);
93
134
  const inst = componentRef.instance as UiDialog;
135
+ const closed = vi.fn();
136
+ inst.closed.subscribe(closed);
94
137
  inst.open.set(true);
95
138
  await waitForUpdate();
96
139
  await waitForUpdate();
97
140
  inst.open.set(false);
98
141
  await waitForUpdate();
142
+ // Wait out the close sequence, so #animateOut runs (and bails) while the
143
+ // component is still alive. Destroying it clears the sequence's timers.
144
+ await new Promise((r) => setTimeout(r, DURATION.normal + 50));
99
145
  expect(inst.open()).toBe(false);
146
+ expect(closed).toHaveBeenCalledOnce();
100
147
  });
101
148
 
102
149
  it('overlayStyle switches between visible and hidden variants', async () => {
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type ElementRef,
3
+ type OnDestroy,
3
4
  Component,
4
5
  ViewEncapsulation,
5
6
  computed,
@@ -26,20 +27,24 @@ import { cn } from '../../utils/cn';
26
27
 
27
28
  @Component({
28
29
  selector: 'ui-dialog',
29
- standalone: true,
30
30
  imports: [LYNX_ELEMENTS],
31
31
  encapsulation: ViewEncapsulation.None,
32
32
  template: `
33
33
  <overlay [attr.visible]="overlayVisible()" [style]="overlayStyle()">
34
34
  <view #backdrop [class]="backdropClass()" (bindtap)="onBackdropTap()">
35
- <view #panel [class]="panelClass()" (catchtap)="onPanelTap()">
35
+ <view
36
+ #panel
37
+ [class]="panelClass()"
38
+ [accessibility-exclusive-focus]="overlayVisible()"
39
+ (catchtap)="onPanelTap()"
40
+ >
36
41
  <ng-content />
37
42
  </view>
38
43
  </view>
39
44
  </overlay>
40
45
  `,
41
46
  })
42
- export class UiDialog {
47
+ export class UiDialog implements OnDestroy {
43
48
  readonly open = model(false);
44
49
  readonly userClass = input<string>('', { alias: 'class' });
45
50
 
@@ -52,6 +57,9 @@ export class UiDialog {
52
57
  #backdropAnim?: AnimationHandle;
53
58
  #panelAnim?: AnimationHandle;
54
59
  #hasBeenOpen = false;
60
+ // The open and close sequences' pending timers, so ngOnDestroy can stop
61
+ // them.
62
+ readonly #timers = new Set<ReturnType<typeof setTimeout>>();
55
63
 
56
64
  constructor() {
57
65
  // `#hasBeenOpen` prevents the close animation from running on the initial
@@ -110,15 +118,37 @@ export class UiDialog {
110
118
  */
111
119
  protected onPanelTap(): void {}
112
120
 
121
+ /**
122
+ * The dialog can be destroyed while its open or close sequence is still
123
+ * running, for example by an `@if` around it or a route change right after
124
+ * closing it. The rest of the sequence then ran on the destroyed component:
125
+ * it animated detached elements and emitted `closed` from a destroyed
126
+ * output, which Angular warns about in dev builds (NG0953).
127
+ */
128
+ ngOnDestroy(): void {
129
+ for (const timer of this.#timers) clearTimeout(timer);
130
+ }
131
+
132
+ /**
133
+ * `setTimeout`, tracked so that ngOnDestroy can cancel it.
134
+ */
135
+ #later(callback: () => void, ms: number): void {
136
+ const timer = setTimeout(() => {
137
+ this.#timers.delete(timer);
138
+ callback();
139
+ }, ms);
140
+ this.#timers.add(timer);
141
+ }
142
+
113
143
  /**
114
144
  * Two-phase open: make overlay visible first (so native elements exist in
115
145
  * the tree), then animate on the next frame. See nav-drawer for the same
116
146
  * pattern — Lynx animate() requires elements to be mounted before use.
117
147
  */
118
148
  #doOpen(): void {
119
- setTimeout(() => {
149
+ this.#later(() => {
120
150
  this.overlayVisible.set(true);
121
- setTimeout(() => this.#animateIn(), 0);
151
+ this.#later(() => this.#animateIn(), 0);
122
152
  }, 0);
123
153
  }
124
154
 
@@ -127,9 +157,9 @@ export class UiDialog {
127
157
  * causing elements to disappear before the animation finishes.
128
158
  */
129
159
  #doClose(): void {
130
- setTimeout(() => {
160
+ this.#later(() => {
131
161
  this.#animateOut();
132
- setTimeout(() => {
162
+ this.#later(() => {
133
163
  this.overlayVisible.set(false);
134
164
  this.closed.emit();
135
165
  }, DURATION.normal + 20);
@@ -146,9 +176,14 @@ export class UiDialog {
146
176
 
147
177
  // Backdrop fades in first
148
178
  this.#backdropAnim = fadeIn(backdrop, { duration: DURATION.normal });
179
+ // Move a screen reader into the panel. It would otherwise stay on whatever
180
+ // opened it, which is now behind the overlay. With the panel's
181
+ // accessibility-exclusive-focus (see the template), it then can't leave
182
+ // the panel until it closes.
183
+ panel.invoke?.('requestAccessibilityFocus');
149
184
 
150
185
  // Panel scales in with gentle spring (staggered 30ms after backdrop)
151
- setTimeout(() => {
186
+ this.#later(() => {
152
187
  this.#panelAnim = scaleIn(panel, {
153
188
  duration: DURATION.slow,
154
189
  easing: EASING.springSubtle,
@@ -181,7 +216,6 @@ export class UiDialog {
181
216
 
182
217
  @Component({
183
218
  selector: 'ui-dialog-header',
184
- standalone: true,
185
219
  imports: [LYNX_ELEMENTS],
186
220
  encapsulation: ViewEncapsulation.None,
187
221
  template: `
@@ -200,7 +234,6 @@ export class UiDialogHeader {
200
234
 
201
235
  @Component({
202
236
  selector: 'ui-dialog-title',
203
- standalone: true,
204
237
  imports: [LYNX_ELEMENTS],
205
238
  encapsulation: ViewEncapsulation.None,
206
239
  template: `<text [class]="textClass()"><ng-content /></text>`,
@@ -215,7 +248,6 @@ export class UiDialogTitle {
215
248
 
216
249
  @Component({
217
250
  selector: 'ui-dialog-description',
218
- standalone: true,
219
251
  imports: [LYNX_ELEMENTS],
220
252
  encapsulation: ViewEncapsulation.None,
221
253
  template: `<text [class]="textClass()"><ng-content /></text>`,
@@ -230,7 +262,6 @@ export class UiDialogDescription {
230
262
 
231
263
  @Component({
232
264
  selector: 'ui-dialog-footer',
233
- standalone: true,
234
265
  imports: [LYNX_ELEMENTS],
235
266
  encapsulation: ViewEncapsulation.None,
236
267
  template: `
@@ -0,0 +1,133 @@
1
+ import {
2
+ Injector,
3
+ type Provider,
4
+ runInInjectionContext,
5
+ signal,
6
+ } from '@angular/core';
7
+ import { LynxTheme } from '@blotch/angular-lynx';
8
+ import { describe, expect, it } from 'vitest';
9
+ import {
10
+ ICON_DARK_COLORS,
11
+ ICON_LIGHT_COLORS,
12
+ UI_ICON_COLOR,
13
+ UI_ICON_THEME,
14
+ injectIconColor,
15
+ provideIconTheme,
16
+ resolveIconColor,
17
+ } from './icon-theme';
18
+
19
+ const LIGHT_FOREGROUND = ICON_LIGHT_COLORS['foreground'];
20
+ const DARK_FOREGROUND = ICON_DARK_COLORS['foreground'];
21
+
22
+ /**
23
+ * Runs injectIconColor() against a device appearance and extra providers. A
24
+ * plain Injector has no root scope, so it gets the default theme explicitly;
25
+ * the token's own factory is covered by the component tests.
26
+ */
27
+ const setup = (providers: Provider[] = [], dark = signal(false)) => {
28
+ const injector = Injector.create({
29
+ providers: [
30
+ { provide: LynxTheme, useValue: { isDarkMode: dark } },
31
+ {
32
+ provide: UI_ICON_THEME,
33
+ useValue: { light: ICON_LIGHT_COLORS, dark: ICON_DARK_COLORS },
34
+ },
35
+ ...providers,
36
+ ],
37
+ });
38
+ return runInInjectionContext(injector, () => injectIconColor());
39
+ };
40
+
41
+ describe('resolveIconColor', () => {
42
+ it('passes a literal color through', () => {
43
+ expect(resolveIconColor('#2563eb', ICON_LIGHT_COLORS)).toBe('#2563eb');
44
+ expect(resolveIconColor(' rgba(1, 2, 3, 1) ', ICON_LIGHT_COLORS)).toBe(
45
+ 'rgba(1, 2, 3, 1)',
46
+ );
47
+ });
48
+
49
+ it('swaps a theme token for its value', () => {
50
+ expect(resolveIconColor('var(--primary)', ICON_LIGHT_COLORS)).toBe(
51
+ ICON_LIGHT_COLORS['primary'],
52
+ );
53
+ expect(resolveIconColor('var( --destructive )', ICON_DARK_COLORS)).toBe(
54
+ ICON_DARK_COLORS['destructive'],
55
+ );
56
+ });
57
+
58
+ it("falls back to the var()'s fallback for an unknown token", () => {
59
+ expect(resolveIconColor('var(--brand, #ff0000)', ICON_LIGHT_COLORS)).toBe(
60
+ '#ff0000',
61
+ );
62
+ // The fallback may itself be a token.
63
+ expect(
64
+ resolveIconColor('var(--brand, var(--primary))', ICON_LIGHT_COLORS),
65
+ ).toBe(ICON_LIGHT_COLORS['primary']);
66
+ });
67
+
68
+ it('falls back to the foreground for an unknown token with no fallback', () => {
69
+ expect(resolveIconColor('var(--brand)', ICON_LIGHT_COLORS)).toBe(
70
+ LIGHT_FOREGROUND,
71
+ );
72
+ });
73
+
74
+ it('leaves an unknown token as it is when the table has no foreground', () => {
75
+ expect(resolveIconColor('var(--brand)', {})).toBe('var(--brand)');
76
+ });
77
+ });
78
+
79
+ describe('provideIconTheme', () => {
80
+ it('merges the overrides over the default tables', () => {
81
+ const provider = provideIconTheme({
82
+ light: { primary: '#123456' },
83
+ colorScheme: 'dark',
84
+ }) as { provide: unknown; useValue: Record<string, unknown> };
85
+ expect(provider.provide).toBe(UI_ICON_THEME);
86
+ expect(provider.useValue['light']).toEqual({
87
+ ...ICON_LIGHT_COLORS,
88
+ primary: '#123456',
89
+ });
90
+ expect(provider.useValue['dark']).toEqual(ICON_DARK_COLORS);
91
+ expect(provider.useValue['colorScheme']).toBe('dark');
92
+ });
93
+ });
94
+
95
+ describe('injectIconColor', () => {
96
+ it('draws with the light foreground by default, even on a dark device', () => {
97
+ const resolve = setup([], signal(true));
98
+ expect(resolve(undefined)).toBe(LIGHT_FOREGROUND);
99
+ });
100
+
101
+ it('resolves an explicit color against the active table', () => {
102
+ const resolve = setup([provideIconTheme({ colorScheme: 'dark' })]);
103
+ expect(resolve('var(--primary)')).toBe(ICON_DARK_COLORS['primary']);
104
+ expect(resolve('#00ff00')).toBe('#00ff00');
105
+ });
106
+
107
+ it("follows the device appearance with 'system'", () => {
108
+ const dark = signal(false);
109
+ const resolve = setup([provideIconTheme({ colorScheme: 'system' })], dark);
110
+ expect(resolve(undefined)).toBe(LIGHT_FOREGROUND);
111
+ dark.set(true);
112
+ expect(resolve(undefined)).toBe(DARK_FOREGROUND);
113
+ });
114
+
115
+ it("reads the app's own theme state from a function", () => {
116
+ const scheme = signal<'light' | 'dark'>('dark');
117
+ const resolve = setup([provideIconTheme({ colorScheme: () => scheme() })]);
118
+ expect(resolve(undefined)).toBe(DARK_FOREGROUND);
119
+ scheme.set('light');
120
+ expect(resolve(undefined)).toBe(LIGHT_FOREGROUND);
121
+ });
122
+
123
+ it("uses the container's color when the icon sets none", () => {
124
+ const inherited = signal<string | undefined>('var(--destructive)');
125
+ const resolve = setup([{ provide: UI_ICON_COLOR, useValue: inherited }]);
126
+ expect(resolve(undefined)).toBe(ICON_LIGHT_COLORS['destructive']);
127
+ // The icon's own color still wins.
128
+ expect(resolve('var(--primary)')).toBe(ICON_LIGHT_COLORS['primary']);
129
+ // A container that sets no color leaves the foreground.
130
+ inherited.set(undefined);
131
+ expect(resolve(undefined)).toBe(LIGHT_FOREGROUND);
132
+ });
133
+ });
@@ -0,0 +1,204 @@
1
+ import {
2
+ computed,
3
+ inject,
4
+ InjectionToken,
5
+ type Provider,
6
+ type Signal,
7
+ } from '@angular/core';
8
+ import { LynxTheme } from '@blotch/angular-lynx';
9
+
10
+ /**
11
+ * Colors for the components that draw with Lynx's `<svg>`: `<ui-icon>`,
12
+ * `<ui-spinner>`, and the parts built on them (pagination's chevrons, a loading
13
+ * button's spinner).
14
+ *
15
+ * ## Why colors live in TypeScript as well as CSS
16
+ * `<svg>` parses its markup by itself. It never sees the page's CSS: on the web
17
+ * preview the markup becomes an `<img>`, and the native elements in Lynx 4.1
18
+ * read only a literal `current-color`. So `var(--foreground)` and text color
19
+ * classes can't reach a stroke, and Lynx has no computed-style API to read a
20
+ * variable's value from JS. Instead these components take `var(--token)` colors
21
+ * and swap each for its value in the active table below before they write the
22
+ * markup. This mirrors how `<ui-chart>` colors its marks (`chart-theme.ts`).
23
+ *
24
+ * The tables mirror `default.css` and `dark.css` (a test in the theme folder
25
+ * keeps them in step). If you change a color there, change it here too, or pass
26
+ * your own with {@link provideIconTheme}.
27
+ */
28
+
29
+ /** Theme token name (without `--`) to a complete CSS color. */
30
+ export type IconColorTokens = Readonly<Record<string, string>>;
31
+
32
+ export type IconColorScheme = 'light' | 'dark';
33
+
34
+ /**
35
+ * Which table the colors come from: a fixed scheme, `'system'` to follow the
36
+ * device appearance (`LynxTheme`), or a function reading the app's own theme
37
+ * state.
38
+ *
39
+ * It has to match how the app switches its CSS theme, since the text next to an
40
+ * icon reads the CSS variables. The ui theme's dark mode is opt-in (the app
41
+ * toggles the `.dark` class), so the default is `'light'`: an icon in an app
42
+ * without dark mode stays visible when the device is dark.
43
+ */
44
+ export type IconColorSchemeOption =
45
+ | IconColorScheme
46
+ | 'system'
47
+ | (() => IconColorScheme);
48
+
49
+ export type UiIconTheme = {
50
+ /** Token values for light mode; mirrors `default.css`. */
51
+ readonly light: IconColorTokens;
52
+ /** Token values for dark mode; mirrors `dark.css`. */
53
+ readonly dark: IconColorTokens;
54
+ /** See {@link IconColorSchemeOption}; `'light'` when unset. */
55
+ readonly colorScheme?: IconColorSchemeOption;
56
+ };
57
+
58
+ export const ICON_LIGHT_COLORS: IconColorTokens = {
59
+ background: 'rgba(255, 255, 255, 1)',
60
+ foreground: 'rgba(9, 9, 11, 1)',
61
+ card: 'rgba(255, 255, 255, 1)',
62
+ 'card-foreground': 'rgba(9, 9, 11, 1)',
63
+ primary: 'rgba(24, 24, 27, 1)',
64
+ 'primary-foreground': 'rgba(250, 250, 250, 1)',
65
+ secondary: 'rgba(244, 244, 245, 1)',
66
+ 'secondary-foreground': 'rgba(24, 24, 27, 1)',
67
+ muted: 'rgba(244, 244, 245, 1)',
68
+ 'muted-foreground': 'rgba(113, 113, 122, 1)',
69
+ accent: 'rgba(244, 244, 245, 1)',
70
+ 'accent-foreground': 'rgba(24, 24, 27, 1)',
71
+ destructive: 'rgba(239, 68, 68, 1)',
72
+ 'destructive-foreground': 'rgba(250, 250, 250, 1)',
73
+ 'destructive-subtle': 'rgba(239, 68, 68, 0.1)',
74
+ border: 'rgba(228, 228, 231, 1)',
75
+ input: 'rgba(228, 228, 231, 1)',
76
+ ring: 'rgba(24, 24, 27, 1)',
77
+ 'ring-subtle': 'rgba(24, 24, 27, 0.4)',
78
+ 'chart-1': 'rgba(231, 110, 80, 1)',
79
+ 'chart-2': 'rgba(42, 157, 144, 1)',
80
+ 'chart-3': 'rgba(39, 71, 84, 1)',
81
+ 'chart-4': 'rgba(232, 196, 104, 1)',
82
+ 'chart-5': 'rgba(244, 164, 98, 1)',
83
+ };
84
+
85
+ export const ICON_DARK_COLORS: IconColorTokens = {
86
+ background: 'rgba(9, 9, 11, 1)',
87
+ foreground: 'rgba(250, 250, 250, 1)',
88
+ card: 'rgba(9, 9, 11, 1)',
89
+ 'card-foreground': 'rgba(250, 250, 250, 1)',
90
+ primary: 'rgba(250, 250, 250, 1)',
91
+ 'primary-foreground': 'rgba(24, 24, 27, 1)',
92
+ secondary: 'rgba(39, 39, 42, 1)',
93
+ 'secondary-foreground': 'rgba(250, 250, 250, 1)',
94
+ muted: 'rgba(39, 39, 42, 1)',
95
+ 'muted-foreground': 'rgba(161, 161, 170, 1)',
96
+ accent: 'rgba(39, 39, 42, 1)',
97
+ 'accent-foreground': 'rgba(250, 250, 250, 1)',
98
+ destructive: 'rgba(127, 29, 29, 1)',
99
+ 'destructive-foreground': 'rgba(250, 250, 250, 1)',
100
+ 'destructive-subtle': 'rgba(127, 29, 29, 0.1)',
101
+ border: 'rgba(39, 39, 42, 1)',
102
+ input: 'rgba(39, 39, 42, 1)',
103
+ ring: 'rgba(212, 212, 216, 1)',
104
+ 'ring-subtle': 'rgba(212, 212, 216, 0.4)',
105
+ 'chart-1': 'rgba(38, 98, 217, 1)',
106
+ 'chart-2': 'rgba(46, 184, 138, 1)',
107
+ 'chart-3': 'rgba(232, 140, 48, 1)',
108
+ 'chart-4': 'rgba(175, 87, 219, 1)',
109
+ 'chart-5': 'rgba(226, 54, 112, 1)',
110
+ };
111
+
112
+ export const UI_ICON_THEME = new InjectionToken<UiIconTheme>('UI_ICON_THEME', {
113
+ providedIn: 'root',
114
+ factory: () => ({ light: ICON_LIGHT_COLORS, dark: ICON_DARK_COLORS }),
115
+ });
116
+
117
+ /**
118
+ * Overrides icon colors, merged over the defaults: pass only the tokens you
119
+ * changed in your CSS, or just a color scheme.
120
+ *
121
+ * ```ts
122
+ * // An app that switches .dark with the device appearance:
123
+ * providers: [provideIconTheme({ colorScheme: 'system' })]
124
+ * ```
125
+ */
126
+ export const provideIconTheme = (theme: {
127
+ light?: IconColorTokens;
128
+ dark?: IconColorTokens;
129
+ colorScheme?: IconColorSchemeOption;
130
+ }): Provider => ({
131
+ provide: UI_ICON_THEME,
132
+ useValue: {
133
+ light: { ...ICON_LIGHT_COLORS, ...theme.light },
134
+ dark: { ...ICON_DARK_COLORS, ...theme.dark },
135
+ colorScheme: theme.colorScheme,
136
+ } satisfies UiIconTheme,
137
+ });
138
+
139
+ /**
140
+ * The color icons inside an element use when they're given none. A container
141
+ * whose variant recolors its content (a destructive nav drawer item) provides
142
+ * it, and every `<ui-icon>` projected into it picks it up through DI, the way
143
+ * text would inherit `color` on the web.
144
+ */
145
+ export const UI_ICON_COLOR = new InjectionToken<Signal<string | undefined>>(
146
+ 'UI_ICON_COLOR',
147
+ );
148
+
149
+ /** What an icon draws with when nothing sets its color. */
150
+ export const DEFAULT_ICON_COLOR = 'var(--foreground)';
151
+
152
+ /**
153
+ * Turns a color into one an SVG parser can draw: `var(--token)` (with or
154
+ * without a fallback) becomes the token's value. Anything else (`#2563eb`,
155
+ * `rgba(…)`) is already concrete and passes through.
156
+ */
157
+ export const resolveIconColor = (
158
+ color: string,
159
+ tokens: IconColorTokens,
160
+ ): string => {
161
+ const value = color.trim();
162
+ if (!value.startsWith('var(') || !value.endsWith(')')) return value;
163
+
164
+ const inner = value.slice(4, -1);
165
+ // A custom property name can't contain a comma, so the first comma always
166
+ // separates the name from the fallback.
167
+ const comma = inner.indexOf(',');
168
+ const name = (comma < 0 ? inner : inner.slice(0, comma)).trim();
169
+ const token = tokens[name.replace(/^--/, '')];
170
+ if (token !== undefined) return token;
171
+ // An unknown token falls back the way CSS does: to the var()'s fallback when
172
+ // it has one, and otherwise to the foreground so the icon stays visible.
173
+ return comma < 0
174
+ ? (tokens['foreground'] ?? value)
175
+ : resolveIconColor(inner.slice(comma + 1), tokens);
176
+ };
177
+
178
+ /**
179
+ * For an SVG component's constructor: a function that resolves its `color`
180
+ * input (or, without one, the surrounding {@link UI_ICON_COLOR}, then the
181
+ * foreground) against the active color table. It reads signals, so a
182
+ * `computed()` calling it follows scheme and theme changes.
183
+ */
184
+ export const injectIconColor = (): ((color: string | undefined) => string) => {
185
+ const theme = inject(UI_ICON_THEME);
186
+ const appearance = inject(LynxTheme);
187
+ const inherited = inject(UI_ICON_COLOR, { optional: true });
188
+
189
+ const tokens = computed(() => {
190
+ const option = theme.colorScheme ?? 'light';
191
+ const scheme =
192
+ option === 'system'
193
+ ? appearance.isDarkMode()
194
+ ? 'dark'
195
+ : 'light'
196
+ : typeof option === 'function'
197
+ ? option()
198
+ : option;
199
+ return scheme === 'dark' ? theme.dark : theme.light;
200
+ });
201
+
202
+ return (color) =>
203
+ resolveIconColor(color ?? inherited?.() ?? DEFAULT_ICON_COLOR, tokens());
204
+ };
@@ -1,13 +1,18 @@
1
- import { Component } from '@angular/core';
1
+ import { Component, signal } from '@angular/core';
2
2
  import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
3
3
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
4
  import { describe, expect, it } from 'vitest';
5
5
  import { setInputSignal } from '../../../test-utils/set-input-signal';
6
6
  import { UiIcon } from './icon';
7
+ import {
8
+ ICON_DARK_COLORS,
9
+ ICON_LIGHT_COLORS,
10
+ UI_ICON_COLOR,
11
+ provideIconTheme,
12
+ } from './icon-theme';
7
13
  import { ICONS, type IconName } from './icons';
8
14
 
9
15
  @Component({
10
- standalone: true,
11
16
  imports: [UiIcon, LYNX_ELEMENTS],
12
17
  template: `<ui-icon [name]="name" [size]="size" [color]="color" />`,
13
18
  })
@@ -43,6 +48,50 @@ describe('UiIcon', () => {
43
48
  expect(svg?.getAttribute('content')).toContain('rgb(255, 0, 0)');
44
49
  expect(svg?.getAttribute('content')).not.toContain('currentColor');
45
50
  });
51
+
52
+ it("draws in the light theme's foreground when no color is set", async () => {
53
+ const { componentRef, container } = await render(UiIcon);
54
+ setInputSignal((componentRef.instance as UiIcon).name, 'check' as IconName);
55
+ await waitForUpdate();
56
+ const content = container.querySelector('svg')?.getAttribute('content');
57
+ expect(content).toContain(ICON_LIGHT_COLORS['foreground']);
58
+ expect(content).not.toContain('currentColor');
59
+ });
60
+
61
+ it('resolves a theme token against the active color scheme', async () => {
62
+ const { componentRef, container } = await render(UiIcon, {
63
+ providers: [provideIconTheme({ colorScheme: 'dark' })],
64
+ });
65
+ const icon = componentRef.instance as UiIcon;
66
+ setInputSignal(icon.name, 'check' as IconName);
67
+ setInputSignal(icon.color, 'var(--primary)');
68
+ await waitForUpdate();
69
+ expect(container.querySelector('svg')?.getAttribute('content')).toContain(
70
+ ICON_DARK_COLORS['primary'],
71
+ );
72
+ });
73
+
74
+ it("takes its container's color when it sets none", async () => {
75
+ const { componentRef, container } = await render(UiIcon, {
76
+ providers: [
77
+ { provide: UI_ICON_COLOR, useValue: signal('var(--destructive)') },
78
+ ],
79
+ });
80
+ setInputSignal((componentRef.instance as UiIcon).name, 'x' as IconName);
81
+ await waitForUpdate();
82
+ expect(container.querySelector('svg')?.getAttribute('content')).toContain(
83
+ ICON_LIGHT_COLORS['destructive'],
84
+ );
85
+ });
86
+
87
+ it('applies the class input to the svg', async () => {
88
+ const { componentRef, container } = await render(UiIcon);
89
+ const icon = componentRef.instance as UiIcon;
90
+ setInputSignal(icon.name, 'check' as IconName);
91
+ setInputSignal(icon.userClass, 'mr-2');
92
+ await waitForUpdate();
93
+ expect(container.querySelector('svg')?.getAttribute('class')).toBe('mr-2');
94
+ });
46
95
  });
47
96
 
48
97
  describe('ICONS registry', () => {