@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
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type ElementRef,
3
+ type OnDestroy,
3
4
  Component,
4
5
  ViewEncapsulation,
5
6
  computed,
@@ -28,10 +29,10 @@ import {
28
29
  } from '../../utils/animate';
29
30
  import { cn } from '../../utils/cn';
30
31
  import { UiIcon } from '../icon';
32
+ import { UI_ICON_COLOR } from '../icon/icon-theme';
31
33
 
32
34
  @Component({
33
35
  selector: 'ui-nav-drawer',
34
- standalone: true,
35
36
  imports: [LYNX_ELEMENTS],
36
37
  encapsulation: ViewEncapsulation.None,
37
38
  template: `
@@ -52,6 +53,7 @@ import { UiIcon } from '../icon';
52
53
  #panel
53
54
  [class]="panelClass()"
54
55
  [style]="panelPositionStyle()"
56
+ [accessibility-exclusive-focus]="overlayVisible()"
55
57
  (catchtap)="onPanelTap()"
56
58
  >
57
59
  <ng-content />
@@ -60,7 +62,7 @@ import { UiIcon } from '../icon';
60
62
  </overlay>
61
63
  `,
62
64
  })
63
- export class UiNavDrawer {
65
+ export class UiNavDrawer implements OnDestroy {
64
66
  readonly open = model(false);
65
67
  readonly side = input<'left' | 'right'>('left');
66
68
  readonly userClass = input<string>('', { alias: 'class' });
@@ -85,6 +87,9 @@ export class UiNavDrawer {
85
87
  #backdropAnim?: AnimationHandle;
86
88
  #panelAnim?: AnimationHandle;
87
89
  #hasBeenOpen = false;
90
+ // The open and close sequences' pending timers, so ngOnDestroy can stop
91
+ // them.
92
+ readonly #timers = new Set<ReturnType<typeof setTimeout>>();
88
93
 
89
94
  constructor() {
90
95
  // `#hasBeenOpen` prevents the close animation from running on the initial
@@ -135,6 +140,28 @@ export class UiNavDrawer {
135
140
  this.open.set(false);
136
141
  }
137
142
 
143
+ /**
144
+ * The drawer can be destroyed while its open or close sequence is still
145
+ * running, for example by an `@if` around it or a route change right after
146
+ * closing it. The rest of the sequence then ran on the destroyed component:
147
+ * it animated detached elements and emitted `closed` from a destroyed
148
+ * output, which Angular warns about in dev builds (NG0953).
149
+ */
150
+ ngOnDestroy(): void {
151
+ for (const timer of this.#timers) clearTimeout(timer);
152
+ }
153
+
154
+ /**
155
+ * `setTimeout`, tracked so that ngOnDestroy can cancel it.
156
+ */
157
+ #later(callback: () => void, ms: number): void {
158
+ const timer = setTimeout(() => {
159
+ this.#timers.delete(timer);
160
+ callback();
161
+ }, ms);
162
+ this.#timers.add(timer);
163
+ }
164
+
138
165
  /**
139
166
  * Two-phase open: first make the overlay visible (so native elements exist),
140
167
  * then animate in on the next frame. Without the nested setTimeout, the
@@ -142,9 +169,9 @@ export class UiNavDrawer {
142
169
  * yet — Lynx element.animate() requires the element to be in the tree.
143
170
  */
144
171
  #doOpen(): void {
145
- setTimeout(() => {
172
+ this.#later(() => {
146
173
  this.overlayVisible.set(true);
147
- setTimeout(() => this.#animateIn(), 0);
174
+ this.#later(() => this.#animateIn(), 0);
148
175
  }, 0);
149
176
  }
150
177
 
@@ -155,9 +182,9 @@ export class UiNavDrawer {
155
182
  * cause a visual glitch (elements disappear before fade completes).
156
183
  */
157
184
  #doClose(): void {
158
- setTimeout(() => {
185
+ this.#later(() => {
159
186
  this.#animateOut();
160
- setTimeout(() => {
187
+ this.#later(() => {
161
188
  this.overlayVisible.set(false);
162
189
  this.closed.emit();
163
190
  }, DURATION.normal + 20);
@@ -178,6 +205,11 @@ export class UiNavDrawer {
178
205
  duration: DURATION.slow,
179
206
  easing: EASING.sheet,
180
207
  });
208
+ // Move a screen reader into the panel. It would otherwise stay on whatever
209
+ // opened it, which is now behind the overlay. With the panel's
210
+ // accessibility-exclusive-focus (see the template), it then can't leave
211
+ // the panel until it closes.
212
+ panel.invoke?.('requestAccessibilityFocus');
181
213
  }
182
214
 
183
215
  #animateOut(): void {
@@ -199,7 +231,6 @@ export class UiNavDrawer {
199
231
 
200
232
  @Component({
201
233
  selector: 'ui-nav-drawer-header',
202
- standalone: true,
203
234
  imports: [LYNX_ELEMENTS],
204
235
  encapsulation: ViewEncapsulation.None,
205
236
  template: `
@@ -229,7 +260,6 @@ export class UiNavDrawerHeader {
229
260
 
230
261
  @Component({
231
262
  selector: 'ui-nav-drawer-content',
232
- standalone: true,
233
263
  imports: [LYNX_ELEMENTS],
234
264
  encapsulation: ViewEncapsulation.None,
235
265
  // Fixes: a long item list scrolled nowhere and spilled over the header and
@@ -326,9 +356,21 @@ export class UiNavDrawerContent {
326
356
 
327
357
  @Component({
328
358
  selector: 'ui-nav-drawer-item',
329
- standalone: true,
330
359
  imports: [LYNX_ELEMENTS],
331
360
  encapsulation: ViewEncapsulation.None,
361
+ // Icons projected into the item (see the variant below) inject this through
362
+ // the item's element injector, which content projection keeps them under.
363
+ providers: [
364
+ {
365
+ provide: UI_ICON_COLOR,
366
+ useFactory: () => {
367
+ const item = inject(UiNavDrawerItem);
368
+ return computed(() =>
369
+ item.variant() === 'destructive' ? 'var(--destructive)' : undefined,
370
+ );
371
+ },
372
+ },
373
+ ],
332
374
  template: `
333
375
  <view
334
376
  #container
@@ -358,10 +400,18 @@ export class UiNavDrawerItem {
358
400
  readonly containerRef = viewChild<ElementRef>('container');
359
401
  #pressAnim?: AnimationHandle;
360
402
 
403
+ // `destructive` recolors the item's content, which the item doesn't render
404
+ // itself: the label and icon are projected. Lynx doesn't inherit `color`, so
405
+ // a text class here wouldn't reach the label. CSS variables do inherit, so
406
+ // the item points `--foreground` at `--destructive` for everything inside
407
+ // it: a label styled `text-foreground` (as items are) turns destructive.
408
+ // Icons draw with SVG, which can't read CSS, so they get the same color
409
+ // through UI_ICON_COLOR (see providers above).
361
410
  protected readonly containerClass = computed(() =>
362
411
  cn(
363
412
  'flex-row items-center gap-3 px-4 py-3 flex',
364
413
  this.active() ? 'bg-accent' : 'bg-transparent',
414
+ this.variant() === 'destructive' && '[--foreground:var(--destructive)]',
365
415
  this.userClass(),
366
416
  ),
367
417
  );
@@ -421,7 +471,6 @@ export class UiNavDrawerItem {
421
471
 
422
472
  @Component({
423
473
  selector: 'ui-nav-drawer-footer',
424
- standalone: true,
425
474
  imports: [LYNX_ELEMENTS],
426
475
  encapsulation: ViewEncapsulation.None,
427
476
  template: `
@@ -449,13 +498,17 @@ export class UiNavDrawerFooter {
449
498
 
450
499
  @Component({
451
500
  selector: 'ui-nav-drawer-trigger',
452
- standalone: true,
453
501
  imports: [LYNX_ELEMENTS, UiIcon],
454
502
  encapsulation: ViewEncapsulation.None,
455
503
  template: `
504
+ <!-- The trigger is only an icon, so it carries the words a screen reader
505
+ says for it. -->
456
506
  <view
457
507
  #container
458
508
  [class]="containerClass()"
509
+ [accessibility-element]="true"
510
+ accessibility-traits="button"
511
+ [accessibility-label]="label()"
459
512
  (bindtouchstart)="onPressStart()"
460
513
  (bindtouchend)="onPressEnd()"
461
514
  (bindtouchcancel)="onPressCancel()"
@@ -469,6 +522,8 @@ export class UiNavDrawerFooter {
469
522
  `,
470
523
  })
471
524
  export class UiNavDrawerTrigger {
525
+ /** What a screen reader says for the trigger. */
526
+ readonly label = input('Open navigation menu');
472
527
  readonly userClass = input<string>('', { alias: 'class' });
473
528
 
474
529
  readonly pressed = output<void>();
@@ -1,8 +1,17 @@
1
1
  import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
2
2
  import { describe, expect, it } from 'vitest';
3
3
  import { setInputSignal } from '../../../test-utils/set-input-signal';
4
+ import { ICON_LIGHT_COLORS } from '../icon/icon-theme';
4
5
  import { UiPagination } from './pagination';
5
6
 
7
+ /**
8
+ * The screen reader labels of the row's buttons, in order.
9
+ */
10
+ const labels = (container: Element): (string | null)[] =>
11
+ Array.from(container.querySelectorAll('[accessibility-element]')).map((el) =>
12
+ el.getAttribute('accessibility-label'),
13
+ );
14
+
6
15
  describe('UiPagination', () => {
7
16
  const setup = async (totalPages: number, page = 1, siblingCount = 1) => {
8
17
  const { componentRef, container } = await render(UiPagination);
@@ -93,4 +102,57 @@ describe('UiPagination', () => {
93
102
  );
94
103
  expect(primary).toBeTruthy();
95
104
  });
105
+
106
+ it('labels every button for screen readers', async () => {
107
+ const { container } = await setup(3, 2);
108
+ expect(labels(container)).toEqual([
109
+ 'Previous page',
110
+ 'Page 1',
111
+ 'Page 2, current page',
112
+ 'Page 3',
113
+ 'Next page',
114
+ ]);
115
+ const buttons = container.querySelectorAll('[accessibility-element]');
116
+ for (const button of Array.from(buttons)) {
117
+ expect(button.getAttribute('accessibility-traits')).toBe('button');
118
+ }
119
+ });
120
+
121
+ it('takes its own labels', async () => {
122
+ const { inst, container } = await setup(2, 1);
123
+ setInputSignal(inst.previousLabel, 'Back');
124
+ setInputSignal(inst.nextLabel, 'Forward');
125
+ setInputSignal(inst.pageLabel, (page: number, current: boolean) =>
126
+ current ? `Results ${page}, showing` : `Results ${page}`,
127
+ );
128
+ await waitForUpdate();
129
+ expect(labels(container)).toEqual([
130
+ 'Back',
131
+ 'Results 1, showing',
132
+ 'Results 2',
133
+ 'Forward',
134
+ ]);
135
+ });
136
+
137
+ it('hides the gaps from screen readers', async () => {
138
+ const { container } = await setup(10, 5);
139
+ const gaps = Array.from(container.querySelectorAll('text'))
140
+ .filter((el) => el.textContent === '...')
141
+ .map((el) =>
142
+ el.parentElement?.getAttribute('accessibility-elements-hidden'),
143
+ );
144
+ expect(gaps).toEqual(['true', 'true']);
145
+ });
146
+
147
+ it("draws the arrows in the theme's foreground", async () => {
148
+ const { container } = await setup(3, 2);
149
+ const arrows = Array.from(container.querySelectorAll('svg')).map((el) =>
150
+ el.getAttribute('content'),
151
+ );
152
+ expect(arrows).toHaveLength(2);
153
+ for (const arrow of arrows) {
154
+ expect(arrow).toContain(ICON_LIGHT_COLORS['foreground']);
155
+ expect(arrow).not.toContain('currentColor');
156
+ }
157
+ });
96
158
  });
@@ -8,20 +8,40 @@ import {
8
8
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
9
9
 
10
10
  import { cn } from '../../utils/cn';
11
+ import { injectIconColor } from '../icon/icon-theme';
11
12
 
12
13
  const CHEVRON_LEFT = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>`;
13
14
  const CHEVRON_RIGHT = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>`;
14
15
 
16
+ /**
17
+ * What a screen reader says for a page button, unless `pageLabel` is set.
18
+ */
19
+ const defaultPageLabel = (page: number, current: boolean): string =>
20
+ current ? `Page ${page}, current page` : `Page ${page}`;
21
+
15
22
  @Component({
16
23
  selector: 'ui-pagination',
17
- standalone: true,
18
24
  imports: [LYNX_ELEMENTS],
19
25
  encapsulation: ViewEncapsulation.None,
26
+ // Each button is one accessibility element with a label: without them a
27
+ // screen reader found the arrows unlabeled (they're only an SVG) and read
28
+ // each number as plain text, with nothing saying it's a button or which page
29
+ // is current. The "..." gaps are hidden, since they aren't controls.
30
+ //
31
+ // The chevrons are drawn in the theme's foreground, like the page numbers
32
+ // next to them. An SVG can't read the text color classes, so the color is
33
+ // resolved and written into the markup (see icon-theme.ts).
20
34
  template: `
21
35
  <view [class]="containerClass()">
22
- <view [class]="navButtonClass(page() <= 1)" (bindtap)="prev()">
36
+ <view
37
+ [class]="navButtonClass(page() <= 1)"
38
+ [accessibility-element]="true"
39
+ accessibility-traits="button"
40
+ [accessibility-label]="previousLabel()"
41
+ (bindtap)="prev()"
42
+ >
23
43
  <svg
24
- [attr.content]="chevronLeftSvg"
44
+ [attr.content]="chevronLeftSvg()"
25
45
  style="width: 16px; height: 16px;"
26
46
  />
27
47
  </view>
@@ -30,6 +50,11 @@ const CHEVRON_RIGHT = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 2
30
50
  @if (item.type === 'page') {
31
51
  <view
32
52
  [class]="pageButtonClass(item.value === page())"
53
+ [accessibility-element]="true"
54
+ accessibility-traits="button"
55
+ [accessibility-label]="
56
+ pageLabel()(item.value, item.value === page())
57
+ "
33
58
  (bindtap)="goTo(item.value)"
34
59
  >
35
60
  <text [class]="pageTextClass(item.value === page())">{{
@@ -37,15 +62,24 @@ const CHEVRON_RIGHT = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 2
37
62
  }}</text>
38
63
  </view>
39
64
  } @else {
40
- <view class="h-8 w-8 items-center flex justify-center">
65
+ <view
66
+ class="h-8 w-8 items-center flex justify-center"
67
+ [accessibility-elements-hidden]="true"
68
+ >
41
69
  <text class="text-xs text-muted-foreground">...</text>
42
70
  </view>
43
71
  }
44
72
  }
45
73
 
46
- <view [class]="navButtonClass(page() >= totalPages())" (bindtap)="next()">
74
+ <view
75
+ [class]="navButtonClass(page() >= totalPages())"
76
+ [accessibility-element]="true"
77
+ accessibility-traits="button"
78
+ [accessibility-label]="nextLabel()"
79
+ (bindtap)="next()"
80
+ >
47
81
  <svg
48
- [attr.content]="chevronRightSvg"
82
+ [attr.content]="chevronRightSvg()"
49
83
  style="width: 16px; height: 16px;"
50
84
  />
51
85
  </view>
@@ -56,10 +90,23 @@ export class UiPagination {
56
90
  readonly page = model(1);
57
91
  readonly totalPages = input.required<number>();
58
92
  readonly siblingCount = input(1);
93
+ /** Screen reader label for the previous arrow. */
94
+ readonly previousLabel = input('Previous page');
95
+ /** Screen reader label for the next arrow. */
96
+ readonly nextLabel = input('Next page');
97
+ /** Screen reader label for a page button, given its page and whether it's current. */
98
+ readonly pageLabel =
99
+ input<(page: number, current: boolean) => string>(defaultPageLabel);
59
100
  readonly userClass = input<string>('', { alias: 'class' });
60
101
 
61
- protected readonly chevronLeftSvg = CHEVRON_LEFT;
62
- protected readonly chevronRightSvg = CHEVRON_RIGHT;
102
+ readonly #resolveColor = injectIconColor();
103
+ // split/join, not replaceAll(): Lynx's main thread (PrimJS, ES2019) lacks it.
104
+ protected readonly chevronLeftSvg = computed(() =>
105
+ CHEVRON_LEFT.split('currentColor').join(this.#resolveColor(undefined)),
106
+ );
107
+ protected readonly chevronRightSvg = computed(() =>
108
+ CHEVRON_RIGHT.split('currentColor').join(this.#resolveColor(undefined)),
109
+ );
63
110
 
64
111
  // Sliding-window pagination algorithm:
65
112
  // Always shows page 1 and the last page. Around the current page, shows
@@ -1,6 +1,5 @@
1
1
  export {
2
+ type ScatterChartOptions,
2
3
  UiScatterChart,
3
- UiScatterSeries,
4
- computeDots,
5
- type DotShape,
4
+ scatterChartDefinition,
6
5
  } from './scatter-chart';
@@ -1,157 +1,64 @@
1
- import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
1
+ import { render } from '@blotch/angular-lynx-testing-library';
2
+ import { createChartRuntime } from '@tanstack/charts/runtime';
3
+ import type {
4
+ ChartDefinition,
5
+ ChartPoint,
6
+ SceneDot,
7
+ SceneNode,
8
+ } from '@tanstack/charts/types';
2
9
  import { describe, expect, it, vi } from 'vitest';
3
10
  import { setInputSignal } from '../../../test-utils/set-input-signal';
4
- import { UiCartesianChart } from '../cartesian-chart/cartesian-chart';
5
- import { UiScatterChart, UiScatterSeries } from './scatter-chart';
11
+ import { UiScatterChart } from './scatter-chart';
6
12
 
7
- describe('UiScatterChart', () => {
8
- it('renders with default configuration', async () => {
9
- const { container } = await render(UiScatterChart);
10
- expect(container).toBeTruthy();
11
- });
13
+ const data = [
14
+ { x: 150, y: 50 },
15
+ { x: 160, y: 65 },
16
+ { x: 170, y: 70 },
17
+ ];
12
18
 
13
- it('derives axis domains from the data extremes', async () => {
14
- const { componentRef } = await render(UiScatterChart);
15
- const inst = componentRef.instance as UiScatterChart;
16
- setInputSignal(inst.data, [
17
- { x: 0, y: 0 },
18
- { x: 5, y: 8 },
19
- { x: 10, y: 3 },
20
- ]);
21
- await waitForUpdate();
22
- const cast = inst as unknown as {
23
- xAxis: () => { domain: readonly [number, number] };
24
- yAxis: () => { domain: readonly [number, number] };
25
- effectivePadding: () => {
26
- top?: number;
27
- right?: number;
28
- bottom?: number;
29
- left?: number;
30
- };
31
- };
32
- expect(cast.xAxis().domain[0]).toBeLessThanOrEqual(0);
33
- expect(cast.xAxis().domain[1]).toBeGreaterThanOrEqual(10);
34
- expect(cast.yAxis().domain[0]).toBeLessThanOrEqual(0);
35
- expect(cast.yAxis().domain[1]).toBeGreaterThanOrEqual(8);
36
- // Default SCATTER_INSET on every side.
37
- expect(cast.effectivePadding().top).toBeCloseTo(0.04);
38
- expect(cast.effectivePadding().right).toBeCloseTo(0.04);
39
- expect(cast.effectivePadding().bottom).toBeCloseTo(0.04);
40
- expect(cast.effectivePadding().left).toBeCloseTo(0.04);
19
+ /**
20
+ * Every dot in a definition's scene.
21
+ */
22
+ const dots = (definition: ChartDefinition<any, any, any>) => {
23
+ const scene = createChartRuntime().render(definition, {
24
+ width: 300,
25
+ height: 200,
41
26
  });
27
+ const found: SceneDot[] = [];
28
+ const walk = (nodes: readonly SceneNode[]) => {
29
+ for (const node of nodes) {
30
+ if (node.kind === 'group') {
31
+ if (!node.focus) walk(node.children);
32
+ } else if (node.kind === 'dot') found.push(node);
33
+ }
34
+ };
35
+ walk(scene.nodes);
36
+ return { scene, found };
37
+ };
42
38
 
43
- it('honors explicit xMin / xMax / yMin / yMax overrides', async () => {
44
- const { componentRef } = await render(UiScatterChart);
45
- const inst = componentRef.instance as UiScatterChart;
46
- setInputSignal(inst.data, [
47
- { x: 0, y: 0 },
48
- { x: 10, y: 10 },
49
- ]);
50
- setInputSignal(inst.xMin, -5);
51
- setInputSignal(inst.xMax, 20);
52
- setInputSignal(inst.yMin, -1);
53
- setInputSignal(inst.yMax, 15);
54
- await waitForUpdate();
55
- const cast = inst as unknown as {
56
- xAxis: () => { domain: readonly [number, number] };
57
- yAxis: () => { domain: readonly [number, number] };
58
- };
59
- expect(cast.xAxis().domain[0]).toBeLessThanOrEqual(-5);
60
- expect(cast.xAxis().domain[1]).toBeGreaterThanOrEqual(20);
61
- expect(cast.yAxis().domain[0]).toBeLessThanOrEqual(-1);
62
- expect(cast.yAxis().domain[1]).toBeGreaterThanOrEqual(15);
63
- });
39
+ describe('UiScatterChart', () => {
40
+ type Internals = {
41
+ definition: () => ChartDefinition<any, any, any>;
42
+ onSelect(point: ChartPoint | null): void;
43
+ };
64
44
 
65
- it('user padding overrides individual sides of the default inset', async () => {
45
+ it('builds its definition from its inputs', async () => {
66
46
  const { componentRef } = await render(UiScatterChart);
67
- const inst = componentRef.instance as UiScatterChart;
68
- setInputSignal(inst.padding, { top: 0.2 });
69
- await waitForUpdate();
70
- const cast = inst as unknown as {
71
- effectivePadding: () => {
72
- top?: number;
73
- right?: number;
74
- bottom?: number;
75
- left?: number;
76
- };
77
- };
78
- expect(cast.effectivePadding().top).toBeCloseTo(0.2);
79
- expect(cast.effectivePadding().left).toBeCloseTo(0.04);
47
+ const chart = componentRef.instance as UiScatterChart;
48
+ setInputSignal(chart.data, data);
49
+ setInputSignal(chart.radius, 7);
50
+ const { found } = dots((chart as unknown as Internals).definition());
51
+ expect(found[0]!.radius).toBe(7);
80
52
  });
81
53
 
82
- it('exposes the default tick formatter used by the child cartesian chart', async () => {
54
+ it('emits the tapped datum', async () => {
83
55
  const { componentRef } = await render(UiScatterChart);
84
- const inst = componentRef.instance as UiScatterChart;
85
- expect(inst.xTickFormat()(3.14159)).toBe('3.14');
86
- expect(inst.yTickFormat()(1.005)).toBe('1');
87
- });
88
- });
89
-
90
- describe('UiScatterSeries', () => {
91
- const renderSeries = async () => {
92
- const parentResult = await render(UiCartesianChart);
93
- const parent = parentResult.componentRef.instance as UiCartesianChart;
94
- setInputSignal(parent.xDomain, [0, 10]);
95
- setInputSignal(parent.yDomain, [0, 10]);
96
- await waitForUpdate();
97
- const seriesResult = await render(UiScatterSeries, {
98
- providers: [{ provide: UiCartesianChart, useValue: parent }],
99
- });
100
- const series = seriesResult.componentRef.instance as UiScatterSeries;
101
- setInputSignal(series.data, [
102
- { x: 0, y: 0 },
103
- { x: 5, y: 5 },
104
- { x: 10, y: 10 },
105
- ]);
106
- await waitForUpdate();
107
- return { parent, series };
108
- };
109
-
110
- it('renders a circular dot per datum with the fallback color', async () => {
111
- const { series } = await renderSeries();
112
- const cast = series as unknown as {
113
- dots: () => { style: string; point: { x: number; y: number } }[];
114
- };
115
- const dots = cast.dots();
116
- expect(dots.length).toBe(3);
117
- for (const d of dots) {
118
- expect(d.style).toContain('background-color: var(--primary)');
119
- expect(d.style).toContain('border-radius');
120
- }
121
- });
122
-
123
- it('cycles through the per-dot palette when colors are provided', async () => {
124
- const { series } = await renderSeries();
125
- setInputSignal(series.colors, ['#f00', '#0f0']);
126
- await waitForUpdate();
127
- const cast = series as unknown as { dots: () => { style: string }[] };
128
- const dots = cast.dots();
129
- expect(dots[0].style).toContain('#f00');
130
- expect(dots[1].style).toContain('#0f0');
131
- expect(dots[2].style).toContain('#f00');
132
- });
133
-
134
- it('per-dot sizes drive per-dot radii for a bubble chart', async () => {
135
- const { series } = await renderSeries();
136
- setInputSignal(series.sizes, [10, 6]);
137
- await waitForUpdate();
138
- const cast = series as unknown as { dots: () => { style: string }[] };
139
- const dots = cast.dots();
140
- // radius 10 → width 20px
141
- expect(dots[0].style).toContain('width: 20.00px');
142
- // radius 6 → width 12px
143
- expect(dots[1].style).toContain('width: 12.00px');
144
- // No entry → falls back to uniform radius (default 4) → width 8px
145
- expect(dots[2].style).toContain('width: 8.00px');
146
- });
147
-
148
- it('emits pointTap with the source point when a dot is tapped', async () => {
149
- const { series } = await renderSeries();
150
- const cb = vi.fn();
151
- series.pointTap.subscribe(cb);
152
- (
153
- series as unknown as { onPointTap: (p: { x: number; y: number }) => void }
154
- ).onPointTap({ x: 3, y: 4 });
155
- expect(cb).toHaveBeenCalledWith({ x: 3, y: 4 });
56
+ const chart = componentRef.instance as UiScatterChart;
57
+ const tapped = vi.fn();
58
+ chart.pointTap.subscribe(tapped);
59
+ const internals = chart as unknown as Internals;
60
+ internals.onSelect({ datum: data[2] } as ChartPoint);
61
+ internals.onSelect(null);
62
+ expect(tapped.mock.calls).toEqual([[data[2]]]);
156
63
  });
157
64
  });