@blotch/dolan 22.2.3 → 22.2.4

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 (185) hide show
  1. package/dist/commands/add.js +9 -1
  2. package/dist/commands/add.js.map +1 -1
  3. package/dist/commands/add.test.js +36 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +187 -7
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +280 -7
  8. package/dist/commands/doctor.test.js.map +1 -1
  9. package/dist/commands/init.d.ts +2 -2
  10. package/dist/commands/init.js +38 -18
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/init.test.js +107 -1
  13. package/dist/commands/init.test.js.map +1 -1
  14. package/dist/commands/theme.js +1 -1
  15. package/dist/commands/theme.js.map +1 -1
  16. package/dist/commands/update.d.ts +5 -0
  17. package/dist/commands/update.js +126 -45
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +314 -2
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +7 -0
  22. package/dist/lockfile.js +36 -0
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +25 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +48 -2
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +45 -3
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +5 -0
  33. package/dist/test-utils.js +6 -1
  34. package/dist/test-utils.js.map +1 -1
  35. package/dist/testing/fire-lynx-event.d.ts +14 -0
  36. package/dist/testing/fire-lynx-event.js +19 -0
  37. package/dist/testing/fire-lynx-event.js.map +1 -0
  38. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  39. package/dist/testing/fire-lynx-event.test.js +44 -0
  40. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  41. package/dist/testing/lynx-main-thread.d.ts +22 -0
  42. package/dist/testing/lynx-main-thread.js +11 -0
  43. package/dist/testing/lynx-main-thread.js.map +1 -0
  44. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  45. package/dist/testing/lynx-main-thread.test.js +24 -0
  46. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  47. package/dist/testing/set-input-signal.d.ts +5 -4
  48. package/dist/testing/set-input-signal.js +5 -4
  49. package/dist/testing/set-input-signal.js.map +1 -1
  50. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  51. package/dist/ui/components/accordion/accordion.ts +19 -14
  52. package/dist/ui/components/action-sheet/action-sheet.test.ts +272 -187
  53. package/dist/ui/components/action-sheet/action-sheet.ts +40 -34
  54. package/dist/ui/components/alert/alert.test.ts +67 -19
  55. package/dist/ui/components/alert/alert.ts +14 -16
  56. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +206 -139
  57. package/dist/ui/components/alert-dialog/alert-dialog.ts +32 -26
  58. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  59. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  60. package/dist/ui/components/badge/badge.test.ts +57 -23
  61. package/dist/ui/components/badge/badge.ts +12 -14
  62. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  63. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +373 -297
  64. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +34 -33
  65. package/dist/ui/components/button/button.test.ts +611 -2
  66. package/dist/ui/components/button/button.ts +431 -62
  67. package/dist/ui/components/button/index.ts +2 -0
  68. package/dist/ui/components/button-group/button-group.test.ts +484 -2
  69. package/dist/ui/components/button-group/button-group.ts +380 -68
  70. package/dist/ui/components/button-group/index.ts +3 -0
  71. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  72. package/dist/ui/components/card/card.test.ts +82 -62
  73. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  74. package/dist/ui/components/chart/chart-presets.ts +8 -1
  75. package/dist/ui/components/chart/chart-theme.ts +12 -0
  76. package/dist/ui/components/chart/chart.test.ts +1017 -0
  77. package/dist/ui/components/chart/chart.ts +56 -43
  78. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  79. package/dist/ui/components/checkbox/checkbox.ts +23 -23
  80. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  81. package/dist/ui/components/collapsible/collapsible.ts +19 -14
  82. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  83. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  84. package/dist/ui/components/context-menu/index.ts +1 -0
  85. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  86. package/dist/ui/components/dialog/dialog.ts +29 -23
  87. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  88. package/dist/ui/components/dock/dock-scroll.ts +133 -0
  89. package/dist/ui/components/dock/dock.test.ts +749 -0
  90. package/dist/ui/components/dock/dock.ts +960 -0
  91. package/dist/ui/components/dock/index.ts +2 -0
  92. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  93. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  94. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  95. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  96. package/dist/ui/components/glass/glass-design.ts +119 -0
  97. package/dist/ui/components/glass/glass.test.ts +338 -0
  98. package/dist/ui/components/glass/glass.ts +360 -0
  99. package/dist/ui/components/glass/index.ts +18 -0
  100. package/dist/ui/components/icon/icon-theme.test.ts +36 -0
  101. package/dist/ui/components/icon/icon-theme.ts +36 -13
  102. package/dist/ui/components/icon/icon.test.ts +52 -41
  103. package/dist/ui/components/input/input.test.ts +178 -101
  104. package/dist/ui/components/input/input.ts +15 -12
  105. package/dist/ui/components/label/label.test.ts +11 -24
  106. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  107. package/dist/ui/components/list/list.test.ts +80 -49
  108. package/dist/ui/components/menu/index.ts +25 -0
  109. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  110. package/dist/ui/components/menu/menu-placement.ts +159 -0
  111. package/dist/ui/components/menu/menu-popup.test.ts +632 -0
  112. package/dist/ui/components/menu/menu-popup.ts +544 -0
  113. package/dist/ui/components/menu/menu.test.ts +429 -0
  114. package/dist/ui/components/menu/menu.ts +415 -0
  115. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  116. package/dist/ui/components/nav-drawer/nav-drawer.ts +72 -56
  117. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  118. package/dist/ui/components/progress/progress.test.ts +102 -46
  119. package/dist/ui/components/progress/progress.ts +52 -54
  120. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  121. package/dist/ui/components/radio-group/radio-group.ts +24 -24
  122. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  123. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  124. package/dist/ui/components/select/select.test.ts +138 -78
  125. package/dist/ui/components/select/select.ts +17 -11
  126. package/dist/ui/components/separator/separator.test.ts +18 -17
  127. package/dist/ui/components/sheet/sheet.test.ts +64 -11
  128. package/dist/ui/components/sheet/sheet.ts +9 -1
  129. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  130. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  131. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  132. package/dist/ui/components/spinner/spinner.ts +16 -19
  133. package/dist/ui/components/switch/switch.test.ts +116 -46
  134. package/dist/ui/components/switch/switch.ts +8 -13
  135. package/dist/ui/components/tab-bar/index.ts +1 -0
  136. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  137. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  138. package/dist/ui/components/tabs/tabs.test.ts +215 -157
  139. package/dist/ui/components/tabs/tabs.ts +110 -118
  140. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  141. package/dist/ui/components/textarea/textarea.ts +14 -12
  142. package/dist/ui/components/toast/toast.test.ts +434 -350
  143. package/dist/ui/components/toast/toast.ts +42 -51
  144. package/dist/ui/components/toggle/toggle.test.ts +247 -140
  145. package/dist/ui/theme/chart-colors.test.ts +8 -3
  146. package/dist/ui/theme/dark.css +38 -2
  147. package/dist/ui/theme/default.css +55 -7
  148. package/dist/ui/theme/icon-colors.test.ts +8 -3
  149. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  150. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  151. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  152. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  153. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  154. package/dist/ui/theme/tailwind.css +113 -0
  155. package/dist/ui/utils/animate.test.ts +13 -1
  156. package/dist/utils/analyze.d.ts +14 -0
  157. package/dist/utils/analyze.js +15 -0
  158. package/dist/utils/analyze.js.map +1 -1
  159. package/dist/utils/analyze.test.js +14 -1
  160. package/dist/utils/analyze.test.js.map +1 -1
  161. package/dist/utils/installed.d.ts +12 -0
  162. package/dist/utils/installed.js +16 -0
  163. package/dist/utils/installed.js.map +1 -1
  164. package/dist/utils/installed.test.js +17 -1
  165. package/dist/utils/installed.test.js.map +1 -1
  166. package/dist/utils/resolve-paths.d.ts +4 -4
  167. package/dist/utils/resolve-paths.js +5 -7
  168. package/dist/utils/resolve-paths.js.map +1 -1
  169. package/dist/utils/resolve-paths.test.js +9 -5
  170. package/dist/utils/resolve-paths.test.js.map +1 -1
  171. package/dist/utils/tailwind.d.ts +25 -0
  172. package/dist/utils/tailwind.js +51 -0
  173. package/dist/utils/tailwind.js.map +1 -0
  174. package/dist/utils/tailwind.test.d.ts +1 -0
  175. package/dist/utils/tailwind.test.js +71 -0
  176. package/dist/utils/tailwind.test.js.map +1 -0
  177. package/package.json +9 -1
  178. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  179. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  180. package/dist/ui/components/button/button-render.test.ts +0 -132
  181. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  182. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  183. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  184. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  185. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -40,10 +40,10 @@ import { UI_ICON_COLOR } from '../icon/icon-theme';
40
40
  <!-- bg-black/50 dims the content behind the drawer. The fadeIn/fadeOut in
41
41
  #animateIn/#animateOut already tween this element's opacity over
42
42
  DURATION.normal, so the dim animates in lockstep with the panel slide —
43
- matching the dialog/action-sheet/bottom-sheet backdrop. A named color
44
- with an opacity modifier is required here: bg-black/50 compiles to
45
- rgb(0 0 0 / 0.5), which Lynx accepts, whereas opacity modifiers on
46
- semantic tokens don't. -->
43
+ matching the dialog/action-sheet/bottom-sheet backdrop. It's a named
44
+ color, not a theme one, because the dim is black in light and dark
45
+ mode alike; bg-black/50 compiles to rgb(0 0 0 / 0.5), which Lynx
46
+ accepts. -->
47
47
  <view
48
48
  #backdrop
49
49
  class="h-full w-full bg-black/50"
@@ -82,8 +82,11 @@ export class UiNavDrawer implements OnDestroy {
82
82
  : 'position: fixed; overflow: visible; width: 100vw; height: 100vh; top: 0; left: 0; z-index: 999; display: none;',
83
83
  );
84
84
 
85
- readonly backdropRef = viewChild<ElementRef>('backdrop');
86
- readonly panelRef = viewChild<ElementRef>('panel');
85
+ // Required: both are always in the template (the overlay hides with
86
+ // display:none rather than leaving the tree), and the sequences only read
87
+ // them from timers that start after the first render.
88
+ readonly backdropRef = viewChild.required<ElementRef>('backdrop');
89
+ readonly panelRef = viewChild.required<ElementRef>('panel');
87
90
  #backdropAnim?: AnimationHandle;
88
91
  #panelAnim?: AnimationHandle;
89
92
  #hasBeenOpen = false;
@@ -91,21 +94,26 @@ export class UiNavDrawer implements OnDestroy {
91
94
  // them.
92
95
  readonly #timers = new Set<ReturnType<typeof setTimeout>>();
93
96
 
94
- constructor() {
95
- // `#hasBeenOpen` prevents the close animation from running on the initial
96
- // effect evaluation when `open` starts as false. Without this guard, the
97
- // first run would call #doClose() and emit `closed` before the drawer
98
- // has ever been opened.
99
- effect(() => {
100
- const isOpen = this.open();
101
- if (isOpen) {
102
- this.#hasBeenOpen = true;
103
- this.#doOpen();
104
- } else if (this.#hasBeenOpen) {
105
- this.#doClose();
106
- }
107
- });
108
- }
97
+ /**
98
+ * Plays the open or close sequence whenever `open` changes. A field rather
99
+ * than a constructor: Angular's test build keeps a function for a
100
+ * constructor that nothing calls, which an app's coverage report would show
101
+ * as uncovered.
102
+ *
103
+ * `#hasBeenOpen` prevents the close animation from running on the initial
104
+ * effect evaluation when `open` starts as false. Without this guard, the
105
+ * first run would call #doClose() and emit `closed` before the drawer
106
+ * has ever been opened.
107
+ */
108
+ protected readonly openAndClose = effect(() => {
109
+ const isOpen = this.open();
110
+ if (isOpen) {
111
+ this.#hasBeenOpen = true;
112
+ this.#doOpen();
113
+ } else if (this.#hasBeenOpen) {
114
+ this.#doClose();
115
+ }
116
+ });
109
117
 
110
118
  protected readonly panelClass = computed(() =>
111
119
  cn(
@@ -192,9 +200,8 @@ export class UiNavDrawer implements OnDestroy {
192
200
  }
193
201
 
194
202
  #animateIn(): void {
195
- const backdrop = this.backdropRef()?.nativeElement;
196
- const panel = this.panelRef()?.nativeElement;
197
- if (!backdrop || !panel) return;
203
+ const backdrop = this.backdropRef().nativeElement;
204
+ const panel = this.panelRef().nativeElement;
198
205
 
199
206
  this.#backdropAnim?.cancel();
200
207
  this.#panelAnim?.cancel();
@@ -213,9 +220,8 @@ export class UiNavDrawer implements OnDestroy {
213
220
  }
214
221
 
215
222
  #animateOut(): void {
216
- const backdrop = this.backdropRef()?.nativeElement;
217
- const panel = this.panelRef()?.nativeElement;
218
- if (!backdrop || !panel) return;
223
+ const backdrop = this.backdropRef().nativeElement;
224
+ const panel = this.panelRef().nativeElement;
219
225
 
220
226
  this.#backdropAnim?.cancel();
221
227
  this.#panelAnim?.cancel();
@@ -258,6 +264,13 @@ export class UiNavDrawerHeader {
258
264
  );
259
265
  }
260
266
 
267
+ /**
268
+ * `UiNavDrawerItem`, for `contentChildren()`. A call rather than the class
269
+ * itself: for a class, Angular's test build keeps an arrow function that
270
+ * nothing calls, which an app's coverage report would show as uncovered.
271
+ */
272
+ const navDrawerItem = () => UiNavDrawerItem;
273
+
261
274
  @Component({
262
275
  selector: 'ui-nav-drawer-content',
263
276
  imports: [LYNX_ELEMENTS],
@@ -314,27 +327,29 @@ export class UiNavDrawerContent {
314
327
  // Every nav item projected into this section (Home + each group item). Used to
315
328
  // find the active one when the drawer opens. `descendants: true` reaches items
316
329
  // nested inside the @for group wrappers, not just the direct content children.
317
- readonly items = contentChildren(UiNavDrawerItem, { descendants: true });
330
+ readonly items = contentChildren(navDrawerItem(), { descendants: true });
318
331
 
319
332
  protected readonly contentClass = computed(() =>
320
333
  cn('h-full w-full', this.userClass()),
321
334
  );
322
335
 
323
- constructor() {
324
- // When the drawer opens, land the user on their current route instead of the
325
- // top of a potentially long nav list. The <overlay> is display:none until
326
- // open, so the scroll-view has no laid-out child frames until it's shown —
327
- // defer a tick so the just-shown content is measured before we read it.
328
- //
329
- // This open-driven path is the fallback: it covers the web preview (where
330
- // contentsizechanged isn't emitted) and re-opens that don't trigger a
331
- // relayout. On device, onContentLayout() below is the precise trigger. Both
332
- // call the same instant scroll, so firing twice is a harmless no-op.
333
- effect(() => {
334
- if (!this.#drawer.open()) return;
335
- setTimeout(() => this.#scrollActiveIntoView(), 0);
336
- });
337
- }
336
+ /**
337
+ * When the drawer opens, land the user on their current route instead of the
338
+ * top of a potentially long nav list.
339
+ *
340
+ * The <overlay> is display:none until open, so the scroll-view has no
341
+ * laid-out child frames until it's shown — defer a tick so the just-shown
342
+ * content is measured before we read it.
343
+ *
344
+ * This open-driven path is the fallback: it covers the web preview (where
345
+ * contentsizechanged isn't emitted) and re-opens that don't trigger a
346
+ * relayout. On device, onContentLayout() below is the precise trigger. Both
347
+ * call the same instant scroll, so firing twice is a harmless no-op.
348
+ */
349
+ protected readonly revealActiveOnOpen = effect(() => {
350
+ if (!this.#drawer.open()) return;
351
+ setTimeout(() => this.#scrollActiveIntoView(), 0);
352
+ });
338
353
 
339
354
  /**
340
355
  * Fired by the scroll-view once its content finishes layout. Gated on the
@@ -347,30 +362,31 @@ export class UiNavDrawerContent {
347
362
 
348
363
  #scrollActiveIntoView(): void {
349
364
  this.items()
350
- /* v8 ignore start -- .find predicate unreachable when no content-projected items */
351
365
  .find((item) => item.active())
352
- /* v8 ignore stop */
353
366
  ?.scrollIntoView();
354
367
  }
355
368
  }
356
369
 
370
+ /**
371
+ * The color icons inside an item draw with: the destructive color in a
372
+ * destructive item. A function of its own rather than written inline in
373
+ * `providers`: Angular's test build keeps a second copy of an inline factory
374
+ * that nothing calls, which an app's coverage report would show as uncovered.
375
+ */
376
+ const itemIconColor = () => {
377
+ const item = inject(UiNavDrawerItem);
378
+ return computed(() =>
379
+ item.variant() === 'destructive' ? 'var(--destructive)' : undefined,
380
+ );
381
+ };
382
+
357
383
  @Component({
358
384
  selector: 'ui-nav-drawer-item',
359
385
  imports: [LYNX_ELEMENTS],
360
386
  encapsulation: ViewEncapsulation.None,
361
387
  // Icons projected into the item (see the variant below) inject this through
362
388
  // 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
- ],
389
+ providers: [{ provide: UI_ICON_COLOR, useFactory: itemIconColor }],
374
390
  template: `
375
391
  <view
376
392
  #container
@@ -1,110 +1,170 @@
1
- import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
1
+ import { Component, signal } from '@angular/core';
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ waitForUpdate,
6
+ within,
7
+ } from '@blotch/angular-lynx-testing-library';
2
8
  import { describe, expect, it } from 'vitest';
3
- import { setInputSignal } from '../../../test-utils/set-input-signal';
4
9
  import { ICON_LIGHT_COLORS } from '../icon/icon-theme';
5
10
  import { UiPagination } from './pagination';
6
11
 
12
+ @Component({
13
+ imports: [UiPagination],
14
+ template: `
15
+ <ui-pagination
16
+ [(page)]="page"
17
+ [totalPages]="totalPages()"
18
+ [siblingCount]="siblingCount()"
19
+ class="mt-4"
20
+ />
21
+ `,
22
+ })
23
+ class PaginationHost {
24
+ readonly page = signal(1);
25
+ readonly totalPages = signal(5);
26
+ readonly siblingCount = signal(1);
27
+ }
28
+
29
+ @Component({
30
+ imports: [UiPagination],
31
+ template: `
32
+ <ui-pagination
33
+ [totalPages]="2"
34
+ previousLabel="Back"
35
+ nextLabel="Forward"
36
+ [pageLabel]="resultsLabel"
37
+ />
38
+ `,
39
+ })
40
+ class LabeledPaginationHost {
41
+ readonly resultsLabel = (page: number, current: boolean): string =>
42
+ current ? `Results ${page}, showing` : `Results ${page}`;
43
+ }
44
+
7
45
  /**
8
- * The screen reader labels of the row's buttons, in order.
46
+ * Renders the row at a page of a total, with `siblingCount` pages on each
47
+ * side of the current one.
9
48
  */
10
- const labels = (container: Element): (string | null)[] =>
11
- Array.from(container.querySelectorAll('[accessibility-element]')).map((el) =>
12
- el.getAttribute('accessibility-label'),
49
+ const renderPagination = async (
50
+ totalPages: number,
51
+ page = 1,
52
+ siblingCount = 1,
53
+ ) => {
54
+ const result = await render(PaginationHost);
55
+ const host = result.componentRef.instance as PaginationHost;
56
+ host.totalPages.set(totalPages);
57
+ host.siblingCount.set(siblingCount);
58
+ host.page.set(page);
59
+ await waitForUpdate();
60
+ return {
61
+ ...result,
62
+ host,
63
+ /** The button a screen reader reads as `label`. */
64
+ button: (label: string) => result.getByRole('button', { name: label }),
65
+ };
66
+ };
67
+
68
+ /**
69
+ * What the row shows between its arrows, in order: page numbers and gaps.
70
+ */
71
+ const shown = (container: Element): (string | null)[] =>
72
+ Array.from(container.querySelectorAll('text')).map(
73
+ (el) => el.textContent?.trim() ?? null,
13
74
  );
14
75
 
15
- describe('UiPagination', () => {
16
- const setup = async (totalPages: number, page = 1, siblingCount = 1) => {
17
- const { componentRef, container } = await render(UiPagination);
18
- const inst = componentRef.instance as UiPagination;
19
- setInputSignal(inst.totalPages, totalPages);
20
- setInputSignal(inst.siblingCount, siblingCount);
21
- inst.page.set(page);
22
- await waitForUpdate();
23
- return { inst, container };
24
- };
76
+ /**
77
+ * The screen reader labels of the row's buttons, in order. getAllByRole()
78
+ * finds only what a screen reader reaches as a button, so this also checks
79
+ * each one's traits and accessibility-element.
80
+ */
81
+ const labels = (container: Element): (string | null)[] =>
82
+ within(container as HTMLElement)
83
+ .getAllByRole('button')
84
+ .map((button) => button.getAttribute('accessibility-label'));
25
85
 
26
- it('renders the pagination controls', async () => {
27
- const { container } = await setup(5, 2);
28
- expect(container).toBeTruthy();
86
+ describe('UiPagination', () => {
87
+ it('lists every page when they all fit, with the given class', async () => {
88
+ // With one sibling, up to 5 + 1 * 2 = 7 pages fit without gaps.
89
+ const { container } = await renderPagination(7, 3);
90
+ expect(shown(container)).toEqual(['1', '2', '3', '4', '5', '6', '7']);
91
+ expect(container.querySelector('.gap-1')?.classList).toContain('mt-4');
29
92
  });
30
93
 
31
- it('lists every page when total fits the un-truncated window', async () => {
32
- // With siblings=1 the threshold is 5+2 = 7, so a total of 5 stays inline.
33
- const { container } = await setup(5, 3);
34
- // Each page should render its numeric label.
35
- const text = container.textContent ?? '';
36
- expect(text).toContain('1');
37
- expect(text).toContain('5');
38
- expect(text).not.toContain('...');
94
+ it('leaves a gap before the last page near the start', async () => {
95
+ const { container } = await renderPagination(10, 2);
96
+ expect(shown(container)).toEqual(['1', '2', '3', '...', '10']);
39
97
  });
40
98
 
41
- it('inserts a trailing ellipsis when the window is near the start', async () => {
42
- // total=10, page=2, siblings=1 → rangeStart=2, rangeEnd=3; gap on right only.
43
- const { container } = await setup(10, 2);
44
- expect(container.textContent ?? '').toContain('...');
99
+ it('leaves a gap after the first page near the end', async () => {
100
+ const { container } = await renderPagination(10, 9);
101
+ expect(shown(container)).toEqual(['1', '...', '8', '9', '10']);
45
102
  });
46
103
 
47
- it('inserts a leading ellipsis when the window is near the end', async () => {
48
- // total=10, page=9, siblings=1 → rangeStart=8, rangeEnd=9; gap on left only.
49
- const { container } = await setup(10, 9);
50
- expect(container.textContent ?? '').toContain('...');
104
+ it('leaves gaps on both sides in the middle', async () => {
105
+ const { container } = await renderPagination(10, 5);
106
+ expect(shown(container)).toEqual(['1', '...', '4', '5', '6', '...', '10']);
51
107
  });
52
108
 
53
- it('inserts leading and trailing ellipses when the window sits in the middle', async () => {
54
- // total=10, page=5, siblings=1 → rangeStart=4, rangeEnd=6; gaps on both sides.
55
- const { container } = await setup(10, 5);
56
- // Two ellipses in the DOM as separate <text> nodes.
57
- expect(
58
- container.textContent?.match(/\.\.\./g)?.length,
59
- ).toBeGreaterThanOrEqual(1);
109
+ it('widens the window around the current page with more siblings', async () => {
110
+ const { container } = await renderPagination(12, 6, 2);
111
+ expect(shown(container)).toEqual([
112
+ '1',
113
+ '...',
114
+ '4',
115
+ '5',
116
+ '6',
117
+ '7',
118
+ '8',
119
+ '...',
120
+ '12',
121
+ ]);
60
122
  });
61
123
 
62
- it('prev() decrements page when > 1', async () => {
63
- const { inst } = await setup(5, 3);
64
- (inst as unknown as { prev: () => void }).prev();
65
- await waitForUpdate();
66
- expect(inst.page()).toBe(2);
124
+ it('highlights the current page', async () => {
125
+ const { button } = await renderPagination(3, 2);
126
+ expect(button('Page 2, current page').classList).toContain('bg-primary');
127
+ expect(button('Page 1').classList).toContain('bg-transparent');
128
+ expect(button('Page 2, current page').textContent?.trim()).toBe('2');
67
129
  });
68
130
 
69
- it('prev() is a no-op at page 1', async () => {
70
- const { inst } = await setup(5, 1);
71
- (inst as unknown as { prev: () => void }).prev();
131
+ it('goes to a tapped page', async () => {
132
+ const { host, button } = await renderPagination(5, 1);
133
+ fireEvent.tap(button('Page 4'));
72
134
  await waitForUpdate();
73
- expect(inst.page()).toBe(1);
135
+ expect(host.page()).toBe(4);
136
+ expect(button('Page 4, current page').classList).toContain('bg-primary');
74
137
  });
75
138
 
76
- it('next() increments page when < totalPages', async () => {
77
- const { inst } = await setup(5, 3);
78
- (inst as unknown as { next: () => void }).next();
139
+ it('steps back and forward with the arrows', async () => {
140
+ const { host, button } = await renderPagination(5, 3);
141
+ fireEvent.tap(button('Previous page'));
79
142
  await waitForUpdate();
80
- expect(inst.page()).toBe(4);
81
- });
143
+ expect(host.page()).toBe(2);
82
144
 
83
- it('next() is a no-op at the last page', async () => {
84
- const { inst } = await setup(5, 5);
85
- (inst as unknown as { next: () => void }).next();
145
+ fireEvent.tap(button('Next page'));
146
+ fireEvent.tap(button('Next page'));
86
147
  await waitForUpdate();
87
- expect(inst.page()).toBe(5);
148
+ expect(host.page()).toBe(4);
88
149
  });
89
150
 
90
- it('goTo() jumps the page directly', async () => {
91
- const { inst } = await setup(5, 1);
92
- (inst as unknown as { goTo: (n: number) => void }).goTo(4);
151
+ it("dims the arrow that can't go further, and ignores its taps", async () => {
152
+ const first = await renderPagination(5, 1);
153
+ expect(first.button('Previous page').classList).toContain('opacity-50');
154
+ expect(first.button('Next page').classList).not.toContain('opacity-50');
155
+ fireEvent.tap(first.button('Previous page'));
93
156
  await waitForUpdate();
94
- expect(inst.page()).toBe(4);
95
- });
157
+ expect(first.host.page()).toBe(1);
96
158
 
97
- it('active page carries the primary variant class', async () => {
98
- const { container } = await setup(3, 2);
99
- // The active page's tile background is bg-primary; the others are transparent.
100
- const primary = Array.from(container.querySelectorAll('view')).find((el) =>
101
- el.getAttribute('class')?.includes('bg-primary'),
102
- );
103
- expect(primary).toBeTruthy();
159
+ const last = await renderPagination(5, 5);
160
+ expect(last.button('Next page').classList).toContain('opacity-50');
161
+ fireEvent.tap(last.button('Next page'));
162
+ await waitForUpdate();
163
+ expect(last.host.page()).toBe(5);
104
164
  });
105
165
 
106
166
  it('labels every button for screen readers', async () => {
107
- const { container } = await setup(3, 2);
167
+ const { container } = await renderPagination(3, 2);
108
168
  expect(labels(container)).toEqual([
109
169
  'Previous page',
110
170
  'Page 1',
@@ -112,20 +172,10 @@ describe('UiPagination', () => {
112
172
  'Page 3',
113
173
  'Next page',
114
174
  ]);
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
175
  });
120
176
 
121
177
  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();
178
+ const { container } = await render(LabeledPaginationHost);
129
179
  expect(labels(container)).toEqual([
130
180
  'Back',
131
181
  'Results 1, showing',
@@ -135,7 +185,7 @@ describe('UiPagination', () => {
135
185
  });
136
186
 
137
187
  it('hides the gaps from screen readers', async () => {
138
- const { container } = await setup(10, 5);
188
+ const { container } = await renderPagination(10, 5);
139
189
  const gaps = Array.from(container.querySelectorAll('text'))
140
190
  .filter((el) => el.textContent === '...')
141
191
  .map((el) =>
@@ -145,7 +195,7 @@ describe('UiPagination', () => {
145
195
  });
146
196
 
147
197
  it("draws the arrows in the theme's foreground", async () => {
148
- const { container } = await setup(3, 2);
198
+ const { container } = await renderPagination(3, 2);
149
199
  const arrows = Array.from(container.querySelectorAll('svg')).map((el) =>
150
200
  el.getAttribute('content'),
151
201
  );