@blotch/dolan 22.2.2 → 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 (259) hide show
  1. package/dist/commands/add.d.ts +6 -2
  2. package/dist/commands/add.js +41 -11
  3. package/dist/commands/add.js.map +1 -1
  4. package/dist/commands/add.test.js +162 -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 +228 -34
  12. package/dist/commands/doctor.js.map +1 -1
  13. package/dist/commands/doctor.test.js +367 -10
  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 +169 -40
  23. package/dist/commands/init.js.map +1 -1
  24. package/dist/commands/init.test.js +392 -27
  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/theme.js +1 -1
  46. package/dist/commands/theme.js.map +1 -1
  47. package/dist/commands/update.d.ts +11 -0
  48. package/dist/commands/update.js +174 -58
  49. package/dist/commands/update.js.map +1 -1
  50. package/dist/commands/update.test.js +587 -33
  51. package/dist/commands/update.test.js.map +1 -1
  52. package/dist/index.js +7 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/lockfile.d.ts +7 -0
  55. package/dist/lockfile.js +36 -0
  56. package/dist/lockfile.js.map +1 -1
  57. package/dist/lockfile.test.d.ts +1 -0
  58. package/dist/lockfile.test.js +25 -0
  59. package/dist/lockfile.test.js.map +1 -0
  60. package/dist/registry.d.ts +14 -0
  61. package/dist/registry.js +78 -24
  62. package/dist/registry.js.map +1 -1
  63. package/dist/registry.test.js +119 -38
  64. package/dist/registry.test.js.map +1 -1
  65. package/dist/test-utils.d.ts +5 -0
  66. package/dist/test-utils.js +6 -1
  67. package/dist/test-utils.js.map +1 -1
  68. package/dist/testing/fire-lynx-event.d.ts +14 -0
  69. package/dist/testing/fire-lynx-event.js +19 -0
  70. package/dist/testing/fire-lynx-event.js.map +1 -0
  71. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  72. package/dist/testing/fire-lynx-event.test.js +44 -0
  73. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  74. package/dist/testing/lynx-main-thread.d.ts +22 -0
  75. package/dist/testing/lynx-main-thread.js +11 -0
  76. package/dist/testing/lynx-main-thread.js.map +1 -0
  77. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  78. package/dist/testing/lynx-main-thread.test.js +24 -0
  79. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  80. package/dist/testing/set-input-signal.d.ts +14 -0
  81. package/dist/testing/set-input-signal.js +21 -0
  82. package/dist/testing/set-input-signal.js.map +1 -0
  83. package/dist/testing/set-input-signal.test.d.ts +1 -0
  84. package/dist/testing/set-input-signal.test.js +20 -0
  85. package/dist/testing/set-input-signal.test.js.map +1 -0
  86. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  87. package/dist/ui/components/accordion/accordion.ts +19 -14
  88. package/dist/ui/components/action-sheet/action-sheet.test.ts +283 -151
  89. package/dist/ui/components/action-sheet/action-sheet.ts +77 -43
  90. package/dist/ui/components/alert/alert.test.ts +67 -19
  91. package/dist/ui/components/alert/alert.ts +14 -16
  92. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +208 -94
  93. package/dist/ui/components/alert-dialog/alert-dialog.ts +75 -38
  94. package/dist/ui/components/area-chart/area-chart.test.ts +134 -119
  95. package/dist/ui/components/area-chart/area-chart.ts +100 -315
  96. package/dist/ui/components/area-chart/index.ts +2 -3
  97. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  98. package/dist/ui/components/badge/badge.test.ts +57 -23
  99. package/dist/ui/components/badge/badge.ts +12 -14
  100. package/dist/ui/components/bar-chart/bar-chart.test.ts +172 -45
  101. package/dist/ui/components/bar-chart/bar-chart.ts +140 -294
  102. package/dist/ui/components/bar-chart/index.ts +3 -3
  103. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +386 -128
  104. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +170 -53
  105. package/dist/ui/components/button/button.test.ts +611 -2
  106. package/dist/ui/components/button/button.ts +434 -49
  107. package/dist/ui/components/button/index.ts +2 -0
  108. package/dist/ui/components/button-group/button-group.test.ts +484 -2
  109. package/dist/ui/components/button-group/button-group.ts +380 -68
  110. package/dist/ui/components/button-group/index.ts +3 -0
  111. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +183 -58
  112. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +150 -323
  113. package/dist/ui/components/candlestick-chart/index.ts +3 -4
  114. package/dist/ui/components/card/card.test.ts +82 -62
  115. package/dist/ui/components/chart/chart-focus.test.ts +132 -0
  116. package/dist/ui/components/chart/chart-focus.ts +115 -0
  117. package/dist/ui/components/chart/chart-presets.test.ts +335 -0
  118. package/dist/ui/components/chart/chart-presets.ts +278 -0
  119. package/dist/ui/components/chart/chart-scene.test.ts +589 -0
  120. package/dist/ui/components/chart/chart-scene.ts +483 -0
  121. package/dist/ui/components/chart/chart-theme.test.ts +85 -0
  122. package/dist/ui/components/chart/chart-theme.ts +210 -0
  123. package/dist/ui/components/chart/chart-zoom.test.ts +159 -0
  124. package/dist/ui/components/chart/chart-zoom.ts +162 -0
  125. package/dist/ui/components/chart/chart.test.ts +1017 -0
  126. package/dist/ui/components/chart/chart.ts +934 -0
  127. package/dist/ui/components/chart/index.ts +24 -0
  128. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  129. package/dist/ui/components/checkbox/checkbox.ts +23 -23
  130. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  131. package/dist/ui/components/collapsible/collapsible.ts +19 -14
  132. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  133. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  134. package/dist/ui/components/context-menu/index.ts +1 -0
  135. package/dist/ui/components/dialog/dialog.test.ts +213 -108
  136. package/dist/ui/components/dialog/dialog.ts +72 -35
  137. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  138. package/dist/ui/components/dock/dock-scroll.ts +133 -0
  139. package/dist/ui/components/dock/dock.test.ts +749 -0
  140. package/dist/ui/components/dock/dock.ts +960 -0
  141. package/dist/ui/components/dock/index.ts +2 -0
  142. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  143. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  144. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  145. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  146. package/dist/ui/components/glass/glass-design.ts +119 -0
  147. package/dist/ui/components/glass/glass.test.ts +338 -0
  148. package/dist/ui/components/glass/glass.ts +360 -0
  149. package/dist/ui/components/glass/index.ts +18 -0
  150. package/dist/ui/components/icon/icon-theme.test.ts +169 -0
  151. package/dist/ui/components/icon/icon-theme.ts +227 -0
  152. package/dist/ui/components/icon/icon.test.ts +85 -25
  153. package/dist/ui/components/icon/icon.ts +38 -21
  154. package/dist/ui/components/input/input.test.ts +178 -101
  155. package/dist/ui/components/input/input.ts +15 -12
  156. package/dist/ui/components/label/label.test.ts +11 -24
  157. package/dist/ui/components/line-chart/index.ts +2 -3
  158. package/dist/ui/components/line-chart/line-chart.test.ts +149 -56
  159. package/dist/ui/components/line-chart/line-chart.ts +83 -241
  160. package/dist/ui/components/list/list.test.ts +119 -21
  161. package/dist/ui/components/list/list.ts +21 -15
  162. package/dist/ui/components/menu/index.ts +25 -0
  163. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  164. package/dist/ui/components/menu/menu-placement.ts +159 -0
  165. package/dist/ui/components/menu/menu-popup.test.ts +632 -0
  166. package/dist/ui/components/menu/menu-popup.ts +544 -0
  167. package/dist/ui/components/menu/menu.test.ts +429 -0
  168. package/dist/ui/components/menu/menu.ts +415 -0
  169. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +435 -191
  170. package/dist/ui/components/nav-drawer/nav-drawer.ts +127 -56
  171. package/dist/ui/components/pagination/pagination.test.ts +177 -65
  172. package/dist/ui/components/pagination/pagination.ts +55 -8
  173. package/dist/ui/components/progress/progress.test.ts +102 -46
  174. package/dist/ui/components/progress/progress.ts +52 -54
  175. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  176. package/dist/ui/components/radio-group/radio-group.ts +24 -24
  177. package/dist/ui/components/scatter-chart/index.ts +2 -3
  178. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +131 -26
  179. package/dist/ui/components/scatter-chart/scatter-chart.ts +104 -250
  180. package/dist/ui/components/scroll-area/scroll-area.test.ts +95 -24
  181. package/dist/ui/components/scroll-area/scroll-area.ts +8 -8
  182. package/dist/ui/components/select/select.test.ts +138 -78
  183. package/dist/ui/components/select/select.ts +17 -11
  184. package/dist/ui/components/separator/separator.test.ts +18 -17
  185. package/dist/ui/components/sheet/sheet.test.ts +64 -11
  186. package/dist/ui/components/sheet/sheet.ts +9 -1
  187. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  188. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  189. package/dist/ui/components/spinner/spinner.test.ts +67 -22
  190. package/dist/ui/components/spinner/spinner.ts +48 -41
  191. package/dist/ui/components/switch/switch.test.ts +116 -46
  192. package/dist/ui/components/switch/switch.ts +8 -13
  193. package/dist/ui/components/tab-bar/index.ts +1 -0
  194. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  195. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  196. package/dist/ui/components/tabs/tabs.test.ts +215 -157
  197. package/dist/ui/components/tabs/tabs.ts +110 -118
  198. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  199. package/dist/ui/components/textarea/textarea.ts +14 -12
  200. package/dist/ui/components/toast/toast.test.ts +436 -315
  201. package/dist/ui/components/toast/toast.ts +62 -55
  202. package/dist/ui/components/toggle/toggle.test.ts +247 -140
  203. package/dist/ui/theme/chart-colors.test.ts +35 -0
  204. package/dist/ui/theme/dark.css +46 -2
  205. package/dist/ui/theme/default.css +63 -7
  206. package/dist/ui/theme/icon-colors.test.ts +35 -0
  207. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  208. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  209. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  210. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  211. package/dist/ui/theme/tailwind-plugin.ts +98 -25
  212. package/dist/ui/theme/tailwind.css +113 -0
  213. package/dist/ui/utils/animate.test.ts +13 -1
  214. package/dist/utils/analyze.d.ts +14 -0
  215. package/dist/utils/analyze.js +15 -0
  216. package/dist/utils/analyze.js.map +1 -1
  217. package/dist/utils/analyze.test.js +14 -1
  218. package/dist/utils/analyze.test.js.map +1 -1
  219. package/dist/utils/installed.d.ts +29 -0
  220. package/dist/utils/installed.js +43 -0
  221. package/dist/utils/installed.js.map +1 -0
  222. package/dist/utils/installed.test.d.ts +1 -0
  223. package/dist/utils/installed.test.js +57 -0
  224. package/dist/utils/installed.test.js.map +1 -0
  225. package/dist/utils/interactive.d.ts +9 -0
  226. package/dist/utils/interactive.js +10 -0
  227. package/dist/utils/interactive.js.map +1 -0
  228. package/dist/utils/interactive.test.d.ts +1 -0
  229. package/dist/utils/interactive.test.js +24 -0
  230. package/dist/utils/interactive.test.js.map +1 -0
  231. package/dist/utils/packages.d.ts +36 -0
  232. package/dist/utils/packages.js +70 -0
  233. package/dist/utils/packages.js.map +1 -0
  234. package/dist/utils/packages.test.d.ts +1 -0
  235. package/dist/utils/packages.test.js +89 -0
  236. package/dist/utils/packages.test.js.map +1 -0
  237. package/dist/utils/resolve-paths.d.ts +17 -1
  238. package/dist/utils/resolve-paths.js +21 -3
  239. package/dist/utils/resolve-paths.js.map +1 -1
  240. package/dist/utils/resolve-paths.test.js +38 -2
  241. package/dist/utils/resolve-paths.test.js.map +1 -1
  242. package/dist/utils/tailwind.d.ts +25 -0
  243. package/dist/utils/tailwind.js +51 -0
  244. package/dist/utils/tailwind.js.map +1 -0
  245. package/dist/utils/tailwind.test.d.ts +1 -0
  246. package/dist/utils/tailwind.test.js +71 -0
  247. package/dist/utils/tailwind.test.js.map +1 -0
  248. package/package.json +13 -1
  249. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -121
  250. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -174
  251. package/dist/ui/components/button/button-render.test.ts +0 -113
  252. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  253. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -174
  254. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +0 -188
  255. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +0 -525
  256. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +0 -1140
  257. package/dist/ui/components/cartesian-chart/index.ts +0 -18
  258. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -121
  259. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -157
@@ -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: `
@@ -39,10 +40,10 @@ import { UiIcon } from '../icon';
39
40
  <!-- bg-black/50 dims the content behind the drawer. The fadeIn/fadeOut in
40
41
  #animateIn/#animateOut already tween this element's opacity over
41
42
  DURATION.normal, so the dim animates in lockstep with the panel slide —
42
- matching the dialog/action-sheet/bottom-sheet backdrop. A named color
43
- with an opacity modifier is required here: bg-black/50 compiles to
44
- rgb(0 0 0 / 0.5), which Lynx accepts, whereas opacity modifiers on
45
- 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. -->
46
47
  <view
47
48
  #backdrop
48
49
  class="h-full w-full bg-black/50"
@@ -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' });
@@ -80,27 +82,38 @@ export class UiNavDrawer {
80
82
  : 'position: fixed; overflow: visible; width: 100vw; height: 100vh; top: 0; left: 0; z-index: 999; display: none;',
81
83
  );
82
84
 
83
- readonly backdropRef = viewChild<ElementRef>('backdrop');
84
- 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');
85
90
  #backdropAnim?: AnimationHandle;
86
91
  #panelAnim?: AnimationHandle;
87
92
  #hasBeenOpen = false;
93
+ // The open and close sequences' pending timers, so ngOnDestroy can stop
94
+ // them.
95
+ readonly #timers = new Set<ReturnType<typeof setTimeout>>();
88
96
 
89
- constructor() {
90
- // `#hasBeenOpen` prevents the close animation from running on the initial
91
- // effect evaluation when `open` starts as false. Without this guard, the
92
- // first run would call #doClose() and emit `closed` before the drawer
93
- // has ever been opened.
94
- effect(() => {
95
- const isOpen = this.open();
96
- if (isOpen) {
97
- this.#hasBeenOpen = true;
98
- this.#doOpen();
99
- } else if (this.#hasBeenOpen) {
100
- this.#doClose();
101
- }
102
- });
103
- }
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
+ });
104
117
 
105
118
  protected readonly panelClass = computed(() =>
106
119
  cn(
@@ -135,6 +148,28 @@ export class UiNavDrawer {
135
148
  this.open.set(false);
136
149
  }
137
150
 
151
+ /**
152
+ * The drawer can be destroyed while its open or close sequence is still
153
+ * running, for example by an `@if` around it or a route change right after
154
+ * closing it. The rest of the sequence then ran on the destroyed component:
155
+ * it animated detached elements and emitted `closed` from a destroyed
156
+ * output, which Angular warns about in dev builds (NG0953).
157
+ */
158
+ ngOnDestroy(): void {
159
+ for (const timer of this.#timers) clearTimeout(timer);
160
+ }
161
+
162
+ /**
163
+ * `setTimeout`, tracked so that ngOnDestroy can cancel it.
164
+ */
165
+ #later(callback: () => void, ms: number): void {
166
+ const timer = setTimeout(() => {
167
+ this.#timers.delete(timer);
168
+ callback();
169
+ }, ms);
170
+ this.#timers.add(timer);
171
+ }
172
+
138
173
  /**
139
174
  * Two-phase open: first make the overlay visible (so native elements exist),
140
175
  * then animate in on the next frame. Without the nested setTimeout, the
@@ -142,9 +177,9 @@ export class UiNavDrawer {
142
177
  * yet — Lynx element.animate() requires the element to be in the tree.
143
178
  */
144
179
  #doOpen(): void {
145
- setTimeout(() => {
180
+ this.#later(() => {
146
181
  this.overlayVisible.set(true);
147
- setTimeout(() => this.#animateIn(), 0);
182
+ this.#later(() => this.#animateIn(), 0);
148
183
  }, 0);
149
184
  }
150
185
 
@@ -155,9 +190,9 @@ export class UiNavDrawer {
155
190
  * cause a visual glitch (elements disappear before fade completes).
156
191
  */
157
192
  #doClose(): void {
158
- setTimeout(() => {
193
+ this.#later(() => {
159
194
  this.#animateOut();
160
- setTimeout(() => {
195
+ this.#later(() => {
161
196
  this.overlayVisible.set(false);
162
197
  this.closed.emit();
163
198
  }, DURATION.normal + 20);
@@ -165,9 +200,8 @@ export class UiNavDrawer {
165
200
  }
166
201
 
167
202
  #animateIn(): void {
168
- const backdrop = this.backdropRef()?.nativeElement;
169
- const panel = this.panelRef()?.nativeElement;
170
- if (!backdrop || !panel) return;
203
+ const backdrop = this.backdropRef().nativeElement;
204
+ const panel = this.panelRef().nativeElement;
171
205
 
172
206
  this.#backdropAnim?.cancel();
173
207
  this.#panelAnim?.cancel();
@@ -178,12 +212,16 @@ export class UiNavDrawer {
178
212
  duration: DURATION.slow,
179
213
  easing: EASING.sheet,
180
214
  });
215
+ // Move a screen reader into the panel. It would otherwise stay on whatever
216
+ // opened it, which is now behind the overlay. With the panel's
217
+ // accessibility-exclusive-focus (see the template), it then can't leave
218
+ // the panel until it closes.
219
+ panel.invoke?.('requestAccessibilityFocus');
181
220
  }
182
221
 
183
222
  #animateOut(): void {
184
- const backdrop = this.backdropRef()?.nativeElement;
185
- const panel = this.panelRef()?.nativeElement;
186
- if (!backdrop || !panel) return;
223
+ const backdrop = this.backdropRef().nativeElement;
224
+ const panel = this.panelRef().nativeElement;
187
225
 
188
226
  this.#backdropAnim?.cancel();
189
227
  this.#panelAnim?.cancel();
@@ -199,7 +237,6 @@ export class UiNavDrawer {
199
237
 
200
238
  @Component({
201
239
  selector: 'ui-nav-drawer-header',
202
- standalone: true,
203
240
  imports: [LYNX_ELEMENTS],
204
241
  encapsulation: ViewEncapsulation.None,
205
242
  template: `
@@ -227,9 +264,15 @@ export class UiNavDrawerHeader {
227
264
  );
228
265
  }
229
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
+
230
274
  @Component({
231
275
  selector: 'ui-nav-drawer-content',
232
- standalone: true,
233
276
  imports: [LYNX_ELEMENTS],
234
277
  encapsulation: ViewEncapsulation.None,
235
278
  // Fixes: a long item list scrolled nowhere and spilled over the header and
@@ -284,27 +327,29 @@ export class UiNavDrawerContent {
284
327
  // Every nav item projected into this section (Home + each group item). Used to
285
328
  // find the active one when the drawer opens. `descendants: true` reaches items
286
329
  // nested inside the @for group wrappers, not just the direct content children.
287
- readonly items = contentChildren(UiNavDrawerItem, { descendants: true });
330
+ readonly items = contentChildren(navDrawerItem(), { descendants: true });
288
331
 
289
332
  protected readonly contentClass = computed(() =>
290
333
  cn('h-full w-full', this.userClass()),
291
334
  );
292
335
 
293
- constructor() {
294
- // When the drawer opens, land the user on their current route instead of the
295
- // top of a potentially long nav list. The <overlay> is display:none until
296
- // open, so the scroll-view has no laid-out child frames until it's shown —
297
- // defer a tick so the just-shown content is measured before we read it.
298
- //
299
- // This open-driven path is the fallback: it covers the web preview (where
300
- // contentsizechanged isn't emitted) and re-opens that don't trigger a
301
- // relayout. On device, onContentLayout() below is the precise trigger. Both
302
- // call the same instant scroll, so firing twice is a harmless no-op.
303
- effect(() => {
304
- if (!this.#drawer.open()) return;
305
- setTimeout(() => this.#scrollActiveIntoView(), 0);
306
- });
307
- }
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
+ });
308
353
 
309
354
  /**
310
355
  * Fired by the scroll-view once its content finishes layout. Gated on the
@@ -317,18 +362,31 @@ export class UiNavDrawerContent {
317
362
 
318
363
  #scrollActiveIntoView(): void {
319
364
  this.items()
320
- /* v8 ignore start -- .find predicate unreachable when no content-projected items */
321
365
  .find((item) => item.active())
322
- /* v8 ignore stop */
323
366
  ?.scrollIntoView();
324
367
  }
325
368
  }
326
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
+
327
383
  @Component({
328
384
  selector: 'ui-nav-drawer-item',
329
- standalone: true,
330
385
  imports: [LYNX_ELEMENTS],
331
386
  encapsulation: ViewEncapsulation.None,
387
+ // Icons projected into the item (see the variant below) inject this through
388
+ // the item's element injector, which content projection keeps them under.
389
+ providers: [{ provide: UI_ICON_COLOR, useFactory: itemIconColor }],
332
390
  template: `
333
391
  <view
334
392
  #container
@@ -358,10 +416,18 @@ export class UiNavDrawerItem {
358
416
  readonly containerRef = viewChild<ElementRef>('container');
359
417
  #pressAnim?: AnimationHandle;
360
418
 
419
+ // `destructive` recolors the item's content, which the item doesn't render
420
+ // itself: the label and icon are projected. Lynx doesn't inherit `color`, so
421
+ // a text class here wouldn't reach the label. CSS variables do inherit, so
422
+ // the item points `--foreground` at `--destructive` for everything inside
423
+ // it: a label styled `text-foreground` (as items are) turns destructive.
424
+ // Icons draw with SVG, which can't read CSS, so they get the same color
425
+ // through UI_ICON_COLOR (see providers above).
361
426
  protected readonly containerClass = computed(() =>
362
427
  cn(
363
428
  'flex-row items-center gap-3 px-4 py-3 flex',
364
429
  this.active() ? 'bg-accent' : 'bg-transparent',
430
+ this.variant() === 'destructive' && '[--foreground:var(--destructive)]',
365
431
  this.userClass(),
366
432
  ),
367
433
  );
@@ -421,7 +487,6 @@ export class UiNavDrawerItem {
421
487
 
422
488
  @Component({
423
489
  selector: 'ui-nav-drawer-footer',
424
- standalone: true,
425
490
  imports: [LYNX_ELEMENTS],
426
491
  encapsulation: ViewEncapsulation.None,
427
492
  template: `
@@ -449,13 +514,17 @@ export class UiNavDrawerFooter {
449
514
 
450
515
  @Component({
451
516
  selector: 'ui-nav-drawer-trigger',
452
- standalone: true,
453
517
  imports: [LYNX_ELEMENTS, UiIcon],
454
518
  encapsulation: ViewEncapsulation.None,
455
519
  template: `
520
+ <!-- The trigger is only an icon, so it carries the words a screen reader
521
+ says for it. -->
456
522
  <view
457
523
  #container
458
524
  [class]="containerClass()"
525
+ [accessibility-element]="true"
526
+ accessibility-traits="button"
527
+ [accessibility-label]="label()"
459
528
  (bindtouchstart)="onPressStart()"
460
529
  (bindtouchend)="onPressEnd()"
461
530
  (bindtouchcancel)="onPressCancel()"
@@ -469,6 +538,8 @@ export class UiNavDrawerFooter {
469
538
  `,
470
539
  })
471
540
  export class UiNavDrawerTrigger {
541
+ /** What a screen reader says for the trigger. */
542
+ readonly label = input('Open navigation menu');
472
543
  readonly userClass = input<string>('', { alias: 'class' });
473
544
 
474
545
  readonly pressed = output<void>();
@@ -1,96 +1,208 @@
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';
9
+ import { ICON_LIGHT_COLORS } from '../icon/icon-theme';
4
10
  import { UiPagination } from './pagination';
5
11
 
6
- describe('UiPagination', () => {
7
- const setup = async (totalPages: number, page = 1, siblingCount = 1) => {
8
- const { componentRef, container } = await render(UiPagination);
9
- const inst = componentRef.instance as UiPagination;
10
- setInputSignal(inst.totalPages, totalPages);
11
- setInputSignal(inst.siblingCount, siblingCount);
12
- inst.page.set(page);
13
- await waitForUpdate();
14
- return { inst, container };
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
+
45
+ /**
46
+ * Renders the row at a page of a total, with `siblingCount` pages on each
47
+ * side of the current one.
48
+ */
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 }),
15
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,
74
+ );
16
75
 
17
- it('renders the pagination controls', async () => {
18
- const { container } = await setup(5, 2);
19
- expect(container).toBeTruthy();
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'));
85
+
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');
20
92
  });
21
93
 
22
- it('lists every page when total fits the un-truncated window', async () => {
23
- // With siblings=1 the threshold is 5+2 = 7, so a total of 5 stays inline.
24
- const { container } = await setup(5, 3);
25
- // Each page should render its numeric label.
26
- const text = container.textContent ?? '';
27
- expect(text).toContain('1');
28
- expect(text).toContain('5');
29
- 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']);
30
97
  });
31
98
 
32
- it('inserts a trailing ellipsis when the window is near the start', async () => {
33
- // total=10, page=2, siblings=1 → rangeStart=2, rangeEnd=3; gap on right only.
34
- const { container } = await setup(10, 2);
35
- 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']);
36
102
  });
37
103
 
38
- it('inserts a leading ellipsis when the window is near the end', async () => {
39
- // total=10, page=9, siblings=1 → rangeStart=8, rangeEnd=9; gap on left only.
40
- const { container } = await setup(10, 9);
41
- 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']);
42
107
  });
43
108
 
44
- it('inserts leading and trailing ellipses when the window sits in the middle', async () => {
45
- // total=10, page=5, siblings=1 → rangeStart=4, rangeEnd=6; gaps on both sides.
46
- const { container } = await setup(10, 5);
47
- // Two ellipses in the DOM as separate <text> nodes.
48
- expect(
49
- container.textContent?.match(/\.\.\./g)?.length,
50
- ).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
+ ]);
51
122
  });
52
123
 
53
- it('prev() decrements page when > 1', async () => {
54
- const { inst } = await setup(5, 3);
55
- (inst as unknown as { prev: () => void }).prev();
56
- await waitForUpdate();
57
- 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');
58
129
  });
59
130
 
60
- it('prev() is a no-op at page 1', async () => {
61
- const { inst } = await setup(5, 1);
62
- (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'));
63
134
  await waitForUpdate();
64
- expect(inst.page()).toBe(1);
135
+ expect(host.page()).toBe(4);
136
+ expect(button('Page 4, current page').classList).toContain('bg-primary');
65
137
  });
66
138
 
67
- it('next() increments page when < totalPages', async () => {
68
- const { inst } = await setup(5, 3);
69
- (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'));
70
142
  await waitForUpdate();
71
- expect(inst.page()).toBe(4);
72
- });
143
+ expect(host.page()).toBe(2);
73
144
 
74
- it('next() is a no-op at the last page', async () => {
75
- const { inst } = await setup(5, 5);
76
- (inst as unknown as { next: () => void }).next();
145
+ fireEvent.tap(button('Next page'));
146
+ fireEvent.tap(button('Next page'));
77
147
  await waitForUpdate();
78
- expect(inst.page()).toBe(5);
148
+ expect(host.page()).toBe(4);
79
149
  });
80
150
 
81
- it('goTo() jumps the page directly', async () => {
82
- const { inst } = await setup(5, 1);
83
- (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'));
84
156
  await waitForUpdate();
85
- expect(inst.page()).toBe(4);
157
+ expect(first.host.page()).toBe(1);
158
+
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);
164
+ });
165
+
166
+ it('labels every button for screen readers', async () => {
167
+ const { container } = await renderPagination(3, 2);
168
+ expect(labels(container)).toEqual([
169
+ 'Previous page',
170
+ 'Page 1',
171
+ 'Page 2, current page',
172
+ 'Page 3',
173
+ 'Next page',
174
+ ]);
175
+ });
176
+
177
+ it('takes its own labels', async () => {
178
+ const { container } = await render(LabeledPaginationHost);
179
+ expect(labels(container)).toEqual([
180
+ 'Back',
181
+ 'Results 1, showing',
182
+ 'Results 2',
183
+ 'Forward',
184
+ ]);
185
+ });
186
+
187
+ it('hides the gaps from screen readers', async () => {
188
+ const { container } = await renderPagination(10, 5);
189
+ const gaps = Array.from(container.querySelectorAll('text'))
190
+ .filter((el) => el.textContent === '...')
191
+ .map((el) =>
192
+ el.parentElement?.getAttribute('accessibility-elements-hidden'),
193
+ );
194
+ expect(gaps).toEqual(['true', 'true']);
86
195
  });
87
196
 
88
- it('active page carries the primary variant class', async () => {
89
- const { container } = await setup(3, 2);
90
- // The active page's tile background is bg-primary; the others are transparent.
91
- const primary = Array.from(container.querySelectorAll('view')).find((el) =>
92
- el.getAttribute('class')?.includes('bg-primary'),
197
+ it("draws the arrows in the theme's foreground", async () => {
198
+ const { container } = await renderPagination(3, 2);
199
+ const arrows = Array.from(container.querySelectorAll('svg')).map((el) =>
200
+ el.getAttribute('content'),
93
201
  );
94
- expect(primary).toBeTruthy();
202
+ expect(arrows).toHaveLength(2);
203
+ for (const arrow of arrows) {
204
+ expect(arrow).toContain(ICON_LIGHT_COLORS['foreground']);
205
+ expect(arrow).not.toContain('currentColor');
206
+ }
95
207
  });
96
208
  });