@blotch/dolan 22.2.3 → 22.2.5

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 (227) 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 +38 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +258 -21
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +425 -11
  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 +105 -2
  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 +7 -0
  17. package/dist/commands/update.js +194 -50
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +570 -3
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +17 -0
  22. package/dist/lockfile.js +56 -1
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +65 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +119 -11
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +61 -6
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +27 -0
  33. package/dist/test-utils.js +45 -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/boolean-inputs.test.ts +76 -0
  51. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  52. package/dist/ui/components/accordion/accordion.ts +19 -14
  53. package/dist/ui/components/action-sheet/action-sheet.test.ts +637 -183
  54. package/dist/ui/components/action-sheet/action-sheet.ts +339 -55
  55. package/dist/ui/components/action-sheet/index.ts +1 -0
  56. package/dist/ui/components/alert/alert.test.ts +67 -19
  57. package/dist/ui/components/alert/alert.ts +14 -16
  58. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +740 -129
  59. package/dist/ui/components/alert-dialog/alert-dialog.ts +672 -58
  60. package/dist/ui/components/alert-dialog/index.ts +5 -0
  61. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  62. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  63. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  64. package/dist/ui/components/badge/badge.test.ts +57 -23
  65. package/dist/ui/components/badge/badge.ts +14 -15
  66. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  67. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +1314 -273
  68. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1046 -119
  69. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  70. package/dist/ui/components/button/button.test.ts +646 -2
  71. package/dist/ui/components/button/button.ts +443 -64
  72. package/dist/ui/components/button/index.ts +2 -0
  73. package/dist/ui/components/button-group/button-group.test.ts +528 -2
  74. package/dist/ui/components/button-group/button-group.ts +428 -70
  75. package/dist/ui/components/button-group/index.ts +3 -0
  76. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  77. package/dist/ui/components/card/card.test.ts +97 -61
  78. package/dist/ui/components/card/card.ts +2 -1
  79. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  80. package/dist/ui/components/chart/chart-presets.ts +14 -7
  81. package/dist/ui/components/chart/chart-theme.ts +12 -0
  82. package/dist/ui/components/chart/chart.test.ts +1031 -0
  83. package/dist/ui/components/chart/chart.ts +66 -46
  84. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  85. package/dist/ui/components/checkbox/checkbox.ts +25 -24
  86. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  87. package/dist/ui/components/collapsible/collapsible.ts +21 -15
  88. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  89. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  90. package/dist/ui/components/context-menu/index.ts +1 -0
  91. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  92. package/dist/ui/components/dialog/dialog.ts +35 -29
  93. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  94. package/dist/ui/components/dock/dock-scroll.ts +142 -0
  95. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  96. package/dist/ui/components/dock/dock.test.ts +752 -0
  97. package/dist/ui/components/dock/dock.ts +1438 -0
  98. package/dist/ui/components/dock/index.ts +2 -0
  99. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  100. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  101. package/dist/ui/components/dynamic-color/index.ts +5 -0
  102. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  103. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  104. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  105. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  106. package/dist/ui/components/glass/glass-design.ts +119 -0
  107. package/dist/ui/components/glass/glass.test.ts +376 -0
  108. package/dist/ui/components/glass/glass.ts +430 -0
  109. package/dist/ui/components/glass/index.ts +19 -0
  110. package/dist/ui/components/icon/icon-theme.test.ts +64 -0
  111. package/dist/ui/components/icon/icon-theme.ts +70 -16
  112. package/dist/ui/components/icon/icon.test.ts +52 -41
  113. package/dist/ui/components/input/input.test.ts +178 -101
  114. package/dist/ui/components/input/input.ts +17 -13
  115. package/dist/ui/components/label/label.test.ts +11 -24
  116. package/dist/ui/components/label/label.ts +8 -2
  117. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  118. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  119. package/dist/ui/components/list/list.test.ts +80 -49
  120. package/dist/ui/components/list/list.ts +5 -4
  121. package/dist/ui/components/menu/index.ts +25 -0
  122. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  123. package/dist/ui/components/menu/menu-placement.ts +159 -0
  124. package/dist/ui/components/menu/menu-popup.test.ts +636 -0
  125. package/dist/ui/components/menu/menu-popup.ts +565 -0
  126. package/dist/ui/components/menu/menu.test.ts +523 -0
  127. package/dist/ui/components/menu/menu.ts +455 -0
  128. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  129. package/dist/ui/components/nav-drawer/nav-drawer.ts +75 -58
  130. package/dist/ui/components/navigation-bar/index.ts +1 -0
  131. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  132. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  133. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  134. package/dist/ui/components/popover/index.ts +9 -0
  135. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  136. package/dist/ui/components/popover/popover-panel.ts +603 -0
  137. package/dist/ui/components/popover/popover.test.ts +286 -0
  138. package/dist/ui/components/popover/popover.ts +207 -0
  139. package/dist/ui/components/progress/progress.test.ts +102 -46
  140. package/dist/ui/components/progress/progress.ts +54 -55
  141. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  142. package/dist/ui/components/radio-group/radio-group.ts +26 -25
  143. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  144. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  145. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  146. package/dist/ui/components/search-field/index.ts +15 -0
  147. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  148. package/dist/ui/components/search-field/keyboard.ts +123 -0
  149. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  150. package/dist/ui/components/search-field/search-field.ts +628 -0
  151. package/dist/ui/components/segmented-control/index.ts +8 -0
  152. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  153. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  154. package/dist/ui/components/select/select.test.ts +138 -78
  155. package/dist/ui/components/select/select.ts +19 -12
  156. package/dist/ui/components/separator/separator.test.ts +18 -17
  157. package/dist/ui/components/sheet/sheet.test.ts +151 -11
  158. package/dist/ui/components/sheet/sheet.ts +68 -5
  159. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  160. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  161. package/dist/ui/components/slider/index.ts +1 -0
  162. package/dist/ui/components/slider/slider.test.ts +695 -0
  163. package/dist/ui/components/slider/slider.ts +901 -0
  164. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  165. package/dist/ui/components/spinner/spinner.ts +16 -19
  166. package/dist/ui/components/switch/index.ts +1 -1
  167. package/dist/ui/components/switch/switch.test.ts +534 -48
  168. package/dist/ui/components/switch/switch.ts +613 -48
  169. package/dist/ui/components/tab-bar/index.ts +1 -0
  170. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  171. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  172. package/dist/ui/components/tabs/index.ts +7 -1
  173. package/dist/ui/components/tabs/tabs.test.ts +484 -150
  174. package/dist/ui/components/tabs/tabs.ts +344 -149
  175. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  176. package/dist/ui/components/textarea/textarea.ts +16 -13
  177. package/dist/ui/components/toast/toast.test.ts +434 -350
  178. package/dist/ui/components/toast/toast.ts +42 -51
  179. package/dist/ui/components/toggle/index.ts +1 -0
  180. package/dist/ui/components/toggle/toggle.test.ts +559 -146
  181. package/dist/ui/components/toggle/toggle.ts +241 -26
  182. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  183. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  184. package/dist/ui/components/toolbar/index.ts +12 -0
  185. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  186. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  187. package/dist/ui/theme/chart-colors.test.ts +8 -3
  188. package/dist/ui/theme/dark.css +38 -2
  189. package/dist/ui/theme/default.css +55 -7
  190. package/dist/ui/theme/icon-colors.test.ts +8 -3
  191. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  192. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  193. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  194. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  195. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  196. package/dist/ui/theme/tailwind.css +113 -0
  197. package/dist/ui/utils/animate.test.ts +13 -1
  198. package/dist/utils/analyze.d.ts +19 -0
  199. package/dist/utils/analyze.js +16 -0
  200. package/dist/utils/analyze.js.map +1 -1
  201. package/dist/utils/analyze.test.js +14 -1
  202. package/dist/utils/analyze.test.js.map +1 -1
  203. package/dist/utils/installed.d.ts +25 -1
  204. package/dist/utils/installed.js +33 -0
  205. package/dist/utils/installed.js.map +1 -1
  206. package/dist/utils/installed.test.js +55 -1
  207. package/dist/utils/installed.test.js.map +1 -1
  208. package/dist/utils/resolve-paths.d.ts +4 -4
  209. package/dist/utils/resolve-paths.js +5 -7
  210. package/dist/utils/resolve-paths.js.map +1 -1
  211. package/dist/utils/resolve-paths.test.js +9 -5
  212. package/dist/utils/resolve-paths.test.js.map +1 -1
  213. package/dist/utils/tailwind.d.ts +45 -0
  214. package/dist/utils/tailwind.js +90 -0
  215. package/dist/utils/tailwind.js.map +1 -0
  216. package/dist/utils/tailwind.test.d.ts +1 -0
  217. package/dist/utils/tailwind.test.js +134 -0
  218. package/dist/utils/tailwind.test.js.map +1 -0
  219. package/package.json +9 -1
  220. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  221. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  222. package/dist/ui/components/button/button-render.test.ts +0 -132
  223. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  224. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  225. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  226. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  227. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -3,6 +3,7 @@ import {
3
3
  type OnDestroy,
4
4
  Component,
5
5
  ViewEncapsulation,
6
+ booleanAttribute,
6
7
  computed,
7
8
  contentChildren,
8
9
  effect,
@@ -40,10 +41,10 @@ import { UI_ICON_COLOR } from '../icon/icon-theme';
40
41
  <!-- bg-black/50 dims the content behind the drawer. The fadeIn/fadeOut in
41
42
  #animateIn/#animateOut already tween this element's opacity over
42
43
  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. -->
44
+ matching the dialog/action-sheet/bottom-sheet backdrop. It's a named
45
+ color, not a theme one, because the dim is black in light and dark
46
+ mode alike; bg-black/50 compiles to rgb(0 0 0 / 0.5), which Lynx
47
+ accepts. -->
47
48
  <view
48
49
  #backdrop
49
50
  class="h-full w-full bg-black/50"
@@ -82,8 +83,11 @@ export class UiNavDrawer implements OnDestroy {
82
83
  : 'position: fixed; overflow: visible; width: 100vw; height: 100vh; top: 0; left: 0; z-index: 999; display: none;',
83
84
  );
84
85
 
85
- readonly backdropRef = viewChild<ElementRef>('backdrop');
86
- readonly panelRef = viewChild<ElementRef>('panel');
86
+ // Required: both are always in the template (the overlay hides with
87
+ // display:none rather than leaving the tree), and the sequences only read
88
+ // them from timers that start after the first render.
89
+ readonly backdropRef = viewChild.required<ElementRef>('backdrop');
90
+ readonly panelRef = viewChild.required<ElementRef>('panel');
87
91
  #backdropAnim?: AnimationHandle;
88
92
  #panelAnim?: AnimationHandle;
89
93
  #hasBeenOpen = false;
@@ -91,21 +95,26 @@ export class UiNavDrawer implements OnDestroy {
91
95
  // them.
92
96
  readonly #timers = new Set<ReturnType<typeof setTimeout>>();
93
97
 
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
- }
98
+ /**
99
+ * Plays the open or close sequence whenever `open` changes. A field rather
100
+ * than a constructor: Angular's test build keeps a function for a
101
+ * constructor that nothing calls, which an app's coverage report would show
102
+ * as uncovered.
103
+ *
104
+ * `#hasBeenOpen` prevents the close animation from running on the initial
105
+ * effect evaluation when `open` starts as false. Without this guard, the
106
+ * first run would call #doClose() and emit `closed` before the drawer
107
+ * has ever been opened.
108
+ */
109
+ protected readonly openAndClose = effect(() => {
110
+ const isOpen = this.open();
111
+ if (isOpen) {
112
+ this.#hasBeenOpen = true;
113
+ this.#doOpen();
114
+ } else if (this.#hasBeenOpen) {
115
+ this.#doClose();
116
+ }
117
+ });
109
118
 
110
119
  protected readonly panelClass = computed(() =>
111
120
  cn(
@@ -192,9 +201,8 @@ export class UiNavDrawer implements OnDestroy {
192
201
  }
193
202
 
194
203
  #animateIn(): void {
195
- const backdrop = this.backdropRef()?.nativeElement;
196
- const panel = this.panelRef()?.nativeElement;
197
- if (!backdrop || !panel) return;
204
+ const backdrop = this.backdropRef().nativeElement;
205
+ const panel = this.panelRef().nativeElement;
198
206
 
199
207
  this.#backdropAnim?.cancel();
200
208
  this.#panelAnim?.cancel();
@@ -213,9 +221,8 @@ export class UiNavDrawer implements OnDestroy {
213
221
  }
214
222
 
215
223
  #animateOut(): void {
216
- const backdrop = this.backdropRef()?.nativeElement;
217
- const panel = this.panelRef()?.nativeElement;
218
- if (!backdrop || !panel) return;
224
+ const backdrop = this.backdropRef().nativeElement;
225
+ const panel = this.panelRef().nativeElement;
219
226
 
220
227
  this.#backdropAnim?.cancel();
221
228
  this.#panelAnim?.cancel();
@@ -258,6 +265,13 @@ export class UiNavDrawerHeader {
258
265
  );
259
266
  }
260
267
 
268
+ /**
269
+ * `UiNavDrawerItem`, for `contentChildren()`. A call rather than the class
270
+ * itself: for a class, Angular's test build keeps an arrow function that
271
+ * nothing calls, which an app's coverage report would show as uncovered.
272
+ */
273
+ const navDrawerItem = () => UiNavDrawerItem;
274
+
261
275
  @Component({
262
276
  selector: 'ui-nav-drawer-content',
263
277
  imports: [LYNX_ELEMENTS],
@@ -314,27 +328,29 @@ export class UiNavDrawerContent {
314
328
  // Every nav item projected into this section (Home + each group item). Used to
315
329
  // find the active one when the drawer opens. `descendants: true` reaches items
316
330
  // nested inside the @for group wrappers, not just the direct content children.
317
- readonly items = contentChildren(UiNavDrawerItem, { descendants: true });
331
+ readonly items = contentChildren(navDrawerItem(), { descendants: true });
318
332
 
319
333
  protected readonly contentClass = computed(() =>
320
334
  cn('h-full w-full', this.userClass()),
321
335
  );
322
336
 
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
- }
337
+ /**
338
+ * When the drawer opens, land the user on their current route instead of the
339
+ * top of a potentially long nav list.
340
+ *
341
+ * The <overlay> is display:none until open, so the scroll-view has no
342
+ * laid-out child frames until it's shown — defer a tick so the just-shown
343
+ * content is measured before we read it.
344
+ *
345
+ * This open-driven path is the fallback: it covers the web preview (where
346
+ * contentsizechanged isn't emitted) and re-opens that don't trigger a
347
+ * relayout. On device, onContentLayout() below is the precise trigger. Both
348
+ * call the same instant scroll, so firing twice is a harmless no-op.
349
+ */
350
+ protected readonly revealActiveOnOpen = effect(() => {
351
+ if (!this.#drawer.open()) return;
352
+ setTimeout(() => this.#scrollActiveIntoView(), 0);
353
+ });
338
354
 
339
355
  /**
340
356
  * Fired by the scroll-view once its content finishes layout. Gated on the
@@ -347,30 +363,31 @@ export class UiNavDrawerContent {
347
363
 
348
364
  #scrollActiveIntoView(): void {
349
365
  this.items()
350
- /* v8 ignore start -- .find predicate unreachable when no content-projected items */
351
366
  .find((item) => item.active())
352
- /* v8 ignore stop */
353
367
  ?.scrollIntoView();
354
368
  }
355
369
  }
356
370
 
371
+ /**
372
+ * The color icons inside an item draw with: the destructive color in a
373
+ * destructive item. A function of its own rather than written inline in
374
+ * `providers`: Angular's test build keeps a second copy of an inline factory
375
+ * that nothing calls, which an app's coverage report would show as uncovered.
376
+ */
377
+ const itemIconColor = () => {
378
+ const item = inject(UiNavDrawerItem);
379
+ return computed(() =>
380
+ item.variant() === 'destructive' ? 'var(--destructive)' : undefined,
381
+ );
382
+ };
383
+
357
384
  @Component({
358
385
  selector: 'ui-nav-drawer-item',
359
386
  imports: [LYNX_ELEMENTS],
360
387
  encapsulation: ViewEncapsulation.None,
361
388
  // Icons projected into the item (see the variant below) inject this through
362
389
  // 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
- ],
390
+ providers: [{ provide: UI_ICON_COLOR, useFactory: itemIconColor }],
374
391
  template: `
375
392
  <view
376
393
  #container
@@ -391,8 +408,8 @@ export class UiNavDrawerItem {
391
408
  readonly #drawer = inject(UiNavDrawer);
392
409
 
393
410
  readonly variant = input<'default' | 'destructive'>('default');
394
- readonly active = input(false);
395
- readonly autoClose = input(true);
411
+ readonly active = input(false, { transform: booleanAttribute });
412
+ readonly autoClose = input(true, { transform: booleanAttribute });
396
413
  readonly userClass = input<string>('', { alias: 'class' });
397
414
 
398
415
  readonly pressed = output<void>();
@@ -0,0 +1 @@
1
+ export { UiNavigationBar } from './navigation-bar';
@@ -0,0 +1,425 @@
1
+ import { Location, LocationStrategy } from '@angular/common';
2
+ import { MockLocationStrategy } from '@angular/common/testing';
3
+ import { Component, type Provider, signal, viewChild } from '@angular/core';
4
+ import { Router, RouterOutlet, type Routes } from '@angular/router';
5
+ import {
6
+ fireEvent,
7
+ render,
8
+ waitForUpdate,
9
+ } from '@blotch/angular-lynx-testing-library';
10
+ import {
11
+ LYNX_ELEMENTS,
12
+ LynxAccessibilitySettings,
13
+ LynxSystemInfo,
14
+ provideRouter,
15
+ } from '@blotch/angular-lynx';
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
17
+ import { provideGlass } from '../glass/glass-design';
18
+ import { UiBarScroll, UiBarState } from '../toolbar/bar-scroll';
19
+ import { UiToolbarItem } from '../toolbar/toolbar';
20
+ import { UiNavigationBar } from './navigation-bar';
21
+
22
+ @Component({
23
+ selector: 'notes-page',
24
+ imports: [LYNX_ELEMENTS],
25
+ template: `<text>Notes page</text>`,
26
+ })
27
+ class NotesPage {}
28
+
29
+ @Component({
30
+ selector: 'note-page',
31
+ imports: [LYNX_ELEMENTS],
32
+ template: `<text>Note page</text>`,
33
+ })
34
+ class NotePage {}
35
+
36
+ const routes: Routes = [
37
+ { path: '', component: NotesPage },
38
+ { path: 'note', component: NotePage },
39
+ ];
40
+
41
+ @Component({
42
+ imports: [
43
+ LYNX_ELEMENTS,
44
+ RouterOutlet,
45
+ UiBarScroll,
46
+ UiNavigationBar,
47
+ UiToolbarItem,
48
+ ],
49
+ template: `
50
+ @if (scroll()) {
51
+ <scroll-view ui-bar-scroll id="scroll" scroll-orientation="vertical">
52
+ <view style="height: 2000px"></view>
53
+ </scroll-view>
54
+ }
55
+ <ui-navigation-bar
56
+ id="bar"
57
+ [title]="title()"
58
+ [largeTitle]="largeTitle()"
59
+ [titleCentered]="titleCentered()"
60
+ [backLabel]="backLabel()"
61
+ [backRoute]="backRoute()"
62
+ [backButtonHidden]="backButtonHidden()"
63
+ [class]="barClass()"
64
+ >
65
+ <ui-toolbar-item leading label="Edit" />
66
+ <ui-toolbar-item icon="plus" label="New note" />
67
+ </ui-navigation-bar>
68
+ <router-outlet />
69
+ `,
70
+ })
71
+ class BarHost {
72
+ readonly title = signal('Notes');
73
+ readonly largeTitle = signal(false);
74
+ readonly titleCentered = signal(false);
75
+ readonly backLabel = signal('Back');
76
+ readonly backRoute = signal<string | undefined>(undefined);
77
+ readonly backButtonHidden = signal(false);
78
+ readonly barClass = signal('');
79
+ readonly scroll = signal(false);
80
+ readonly bar = viewChild.required(UiNavigationBar);
81
+ }
82
+
83
+ /** Accessibility settings a test can change, as the host app would. */
84
+ const settings = {
85
+ reduceMotion: signal(false),
86
+ reduceTransparency: signal(false),
87
+ increaseContrast: signal(false),
88
+ };
89
+
90
+ const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
91
+ const IOS_18 = { platform: 'iOS', osVersion: '18.5' };
92
+ const ANDROID = { platform: 'Android', osVersion: '15' };
93
+
94
+ /**
95
+ * Runs change detection until `done` holds: a navigation takes a few ticks.
96
+ */
97
+ const until = async (done: () => boolean): Promise<void> => {
98
+ for (let i = 0; i < 20 && !done(); i++) {
99
+ await waitForUpdate();
100
+ }
101
+ expect(done()).toBe(true);
102
+ };
103
+
104
+ /**
105
+ * Renders the bar on a platform, with the router unless `router` is false.
106
+ * The platform is provided rather than stubbed as a global: the router's
107
+ * setup switches the testing library's threads, which puts back its own
108
+ * SystemInfo before the bar reads it.
109
+ */
110
+ const renderBar = async (
111
+ platform: { platform: string; osVersion: string },
112
+ providers: Provider[] = [],
113
+ ) => {
114
+ const result = await render(BarHost, {
115
+ providers: [
116
+ provideRouter(routes),
117
+ { provide: LynxSystemInfo, useValue: platform },
118
+ { provide: LynxAccessibilitySettings, useValue: settings },
119
+ ...providers,
120
+ ],
121
+ });
122
+ const host = result.componentRef.instance as BarHost;
123
+ const injector = result.componentRef.injector;
124
+ const state = injector.get(UiBarState);
125
+ const { container } = result;
126
+ const bar = () => container.querySelector<HTMLElement>('#bar')!;
127
+ const row = () =>
128
+ Array.from(bar().children).find((child) =>
129
+ child.className.includes('justify-between'),
130
+ ) as HTMLElement;
131
+ /**
132
+ * The bar's two ends: the views holding Edit and New note.
133
+ */
134
+ const leading = () =>
135
+ result.getByRole('button', { name: 'Edit' }).parentElement!;
136
+ const trailing = () =>
137
+ result.getByRole('button', { name: 'New note' }).parentElement!;
138
+ const title = () => result.queryByRole('heading', { name: host.title() });
139
+ /**
140
+ * The view holding the bar's title, not the copy that measures it.
141
+ */
142
+ const titleBox = () =>
143
+ Array.from(row().children).find(
144
+ (child) =>
145
+ child.className.includes('flex-1') ||
146
+ child.className.includes('bottom-0'),
147
+ ) as HTMLElement;
148
+ const back = () => result.queryByRole('button', { name: host.backLabel() });
149
+ const page = () =>
150
+ Array.from(container.querySelectorAll('text'))
151
+ .map((text) => text.textContent ?? '')
152
+ .find((text) => text.endsWith(' page'));
153
+ const update = async (change: () => void) => {
154
+ change();
155
+ await waitForUpdate();
156
+ };
157
+ await until(() => page() === 'Notes page');
158
+ return {
159
+ ...result,
160
+ host,
161
+ injector,
162
+ state,
163
+ bar,
164
+ row,
165
+ leading,
166
+ trailing,
167
+ title,
168
+ titleBox,
169
+ back,
170
+ page,
171
+ update,
172
+ };
173
+ };
174
+
175
+ const style = (element: HTMLElement, name: string) =>
176
+ element.style.getPropertyValue(name);
177
+
178
+ // The components' selectors aren't Lynx elements, so the renderer falls back
179
+ // to a <view> for each and warns.
180
+ beforeEach(() => {
181
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
182
+ });
183
+
184
+ afterEach(() => {
185
+ vi.restoreAllMocks();
186
+ settings.reduceMotion.set(false);
187
+ });
188
+
189
+ describe('UiNavigationBar on iOS 26 and later', () => {
190
+ it('floats over the top of the content below the status bar, with no background', async () => {
191
+ const { bar, state, row } = await renderBar(IOS_26);
192
+ expect(bar().classList).toContain('absolute');
193
+ expect(bar().classList).toContain('top-0');
194
+ expect(bar().classList).toContain('pt-safe');
195
+ expect(bar().classList).not.toContain('bg-background');
196
+ expect(bar().querySelector('[shape="rounded"]')).toBeNull();
197
+ expect(row().className).toContain('h-[44px]');
198
+ expect(style(row(), 'padding-left')).toBe('16px');
199
+ // It tells the scroll view under it how much room it takes.
200
+ expect(state.topBar()?.height()).toBe(44);
201
+ expect(state.topBar()?.largeTitle()).toBeNull();
202
+ });
203
+
204
+ it('shows the title as a heading, with the leading items after the Back button and the rest at the end', async () => {
205
+ const { title, leading, trailing, row, host, update } =
206
+ await renderBar(IOS_26);
207
+ expect(title()?.className).toContain('font-semibold');
208
+ expect(title()?.className).toContain('text-center');
209
+ const ends = Array.from(row().children);
210
+ expect(ends.indexOf(leading())).toBeLessThan(ends.indexOf(trailing()));
211
+
212
+ await update(() => host.title.set('Inbox'));
213
+ expect(title()?.textContent).toBe('Inbox');
214
+ });
215
+
216
+ it('centers the title on the screen when it fits, or between the ends when it doesn’t', async () => {
217
+ const { row, leading, trailing, titleBox, container } =
218
+ await renderBar(IOS_26);
219
+ const copy = () =>
220
+ container.querySelector<HTMLElement>(
221
+ '[accessibility-elements-hidden="true"] text',
222
+ )!;
223
+ const lay = async (element: Element, width: number) => {
224
+ fireEvent.layoutchange(element, { detail: { width, height: 44 } });
225
+ await waitForUpdate();
226
+ };
227
+ await lay(row(), 402);
228
+ await lay(leading(), 44);
229
+ await lay(trailing(), 100);
230
+ await lay(copy(), 80);
231
+ // Clear of the wider end on both sides: 16 + 100 + 8.
232
+ expect(style(titleBox(), 'left')).toBe('124px');
233
+ expect(style(titleBox(), 'right')).toBe('124px');
234
+
235
+ // Too wide for that: between the ends.
236
+ await lay(copy(), 200);
237
+ expect(style(titleBox(), 'left')).toBe('68px');
238
+ expect(style(titleBox(), 'right')).toBe('124px');
239
+ });
240
+
241
+ it('shows a Back button while there’s a screen to go back to, and goes back through the router', async () => {
242
+ const { back, injector, page, host, update } = await renderBar(IOS_26);
243
+ expect(back()).toBeNull();
244
+
245
+ await injector.get(Router).navigateByUrl('/note');
246
+ await until(() => page() === 'Note page');
247
+ await update(() => host.backLabel.set('Notes'));
248
+ expect(back()).not.toBeNull();
249
+ expect(host.bar().canGoBack()).toBe(true);
250
+
251
+ fireEvent.tap(back()!);
252
+ await until(() => page() === 'Notes page');
253
+ expect(back()).toBeNull();
254
+ });
255
+
256
+ it('hides the Back button on a screen that asks', async () => {
257
+ const { back, injector, page, host, update } = await renderBar(IOS_26);
258
+ await injector.get(Router).navigateByUrl('/note');
259
+ await until(() => page() === 'Note page');
260
+ await update(() => host.backButtonHidden.set(true));
261
+ expect(back()).toBeNull();
262
+ });
263
+
264
+ it('goes to backRoute in place of the screen when there’s no history', async () => {
265
+ const { back, injector, page, host, update } = await renderBar(IOS_26);
266
+ await injector.get(Router).navigateByUrl('/note', { replaceUrl: true });
267
+ await until(() => page() === 'Note page');
268
+ expect(back()).toBeNull();
269
+
270
+ await update(() => host.backRoute.set('/'));
271
+ fireEvent.tap(back()!);
272
+ await until(() => page() === 'Notes page');
273
+ // It replaced the note: there's still nothing to go back to.
274
+ expect(host.bar().canGoBack()).toBe(false);
275
+ expect(injector.get(Location).path()).toBe('');
276
+ });
277
+
278
+ it('does nothing on goBack() with nowhere to go', async () => {
279
+ const { host, page } = await renderBar(IOS_26);
280
+ host.bar().goBack();
281
+ await waitForUpdate();
282
+ expect(page()).toBe('Notes page');
283
+ });
284
+
285
+ it('starts the title large at the top of the content, and shows it once it has scrolled under', async () => {
286
+ const { host, state, update, title, titleBox, getAllByRole } =
287
+ await renderBar(IOS_26);
288
+ await update(() => host.largeTitle.set(true));
289
+ // Without a scroll view there's no large title, so the bar keeps it.
290
+ expect(title()).not.toBeNull();
291
+ expect(state.topBar()?.largeTitle()).toBe('Notes');
292
+
293
+ await update(() => host.scroll.set(true));
294
+ // The scroll view draws the large title; the bar's hides.
295
+ expect(getAllByRole('heading', { name: 'Notes' })).toHaveLength(1);
296
+ expect(style(titleBox(), 'opacity')).toBe('0');
297
+ expect(style(titleBox(), 'transition')).toContain('opacity');
298
+
299
+ await update(() => state.largeTitleUnder.set(true));
300
+ expect(getAllByRole('heading', { name: 'Notes' })).toHaveLength(2);
301
+ expect(style(titleBox(), 'opacity')).toBe('1');
302
+ });
303
+
304
+ it('shows the title at once under Reduce Motion', async () => {
305
+ const { host, update, titleBox } = await renderBar(IOS_26);
306
+ await update(() => {
307
+ host.largeTitle.set(true);
308
+ host.scroll.set(true);
309
+ settings.reduceMotion.set(true);
310
+ });
311
+ expect(style(titleBox(), 'transition')).toBe('none');
312
+ });
313
+
314
+ it('takes extra classes', async () => {
315
+ const { host, update, bar } = await renderBar(IOS_26);
316
+ await update(() => host.barClass.set('z-10'));
317
+ expect(bar().classList).toContain('z-10');
318
+ });
319
+ });
320
+
321
+ describe('UiNavigationBar before iOS 26', () => {
322
+ it("is clear at the top of the content, and on the bars' chrome material with a hairline once content scrolls under it", async () => {
323
+ const { bar, state, update, row } = await renderBar(IOS_18);
324
+ const material = () => bar().querySelector('[shape="rounded"]');
325
+ expect(material()).toBeNull();
326
+ expect(style(row(), 'padding-left')).toBe('8px');
327
+
328
+ await update(() => state.scrolledUnderTop.set(true));
329
+ expect(material()?.getAttribute('blur-material')).toBe('chrome');
330
+ expect(material()?.getAttribute('blur-effect')).toBe('extra-light');
331
+ expect(bar().querySelector('.h-\\[0\\.5px\\]')).not.toBeNull();
332
+ });
333
+
334
+ it('shows the previous screen’s title beside the Back button’s chevron', async () => {
335
+ const { back, injector, page, host, update } = await renderBar(IOS_18);
336
+ await injector.get(Router).navigateByUrl('/note');
337
+ await until(() => page() === 'Note page');
338
+ await update(() => host.backLabel.set('Notes'));
339
+ expect(back()?.querySelector('text')?.textContent).toBe('Notes');
340
+ });
341
+ });
342
+
343
+ describe('UiNavigationBar on Android', () => {
344
+ it('is Material 3’s small top app bar, the title at the start', async () => {
345
+ const { bar, row, state, title, titleBox, leading, trailing, container } =
346
+ await renderBar(ANDROID);
347
+ expect(row().className).toContain('h-[64px]');
348
+ expect(state.topBar()?.height()).toBe(64);
349
+ expect(bar().classList).toContain('bg-background');
350
+ expect(title()?.className).toContain('text-[22px]');
351
+ expect(title()?.className).not.toContain('text-center');
352
+ expect(titleBox().className).toContain('flex-1');
353
+ expect(leading().className).toContain('min-w-[12px]');
354
+ expect(trailing().className).toContain('pr-[4px]');
355
+ // Laid out in the row, so it measures no copy.
356
+ expect(
357
+ container.querySelector('[accessibility-elements-hidden="true"]'),
358
+ ).toBeNull();
359
+ });
360
+
361
+ it('centers the title in the center-aligned app bar', async () => {
362
+ const { host, update, title, titleBox, leading } = await renderBar(ANDROID);
363
+ await update(() => host.titleCentered.set(true));
364
+ expect(title()?.className).toContain('text-center');
365
+ expect(titleBox().className).toContain('top-0');
366
+ expect(leading().className).not.toContain('min-w-[12px]');
367
+ });
368
+
369
+ it('turns to the surface container color as content scrolls under it', async () => {
370
+ const { bar, state, update } = await renderBar(ANDROID);
371
+ expect(style(bar(), 'transition')).toContain('background-color');
372
+ await update(() => state.scrolledUnderTop.set(true));
373
+ expect(bar().classList).toContain('bg-secondary');
374
+
375
+ await update(() => settings.reduceMotion.set(true));
376
+ expect(style(bar(), 'transition')).toBe('');
377
+ });
378
+ });
379
+
380
+ /**
381
+ * A bar in an app without the router.
382
+ */
383
+ @Component({
384
+ imports: [LYNX_ELEMENTS, UiNavigationBar],
385
+ template: `<ui-navigation-bar title="Settings" backRoute="/" />`,
386
+ })
387
+ class PlainBarHost {
388
+ readonly bar = viewChild.required(UiNavigationBar);
389
+ }
390
+
391
+ describe('UiNavigationBar with glass on Android', () => {
392
+ it('fades the content beneath it itself, as Android’s scroll view takes no mask', async () => {
393
+ const { bar } = await renderBar(ANDROID, [
394
+ provideGlass({ android: 'glass' }),
395
+ ]);
396
+ const fade = style(bar(), 'background-image');
397
+ expect(fade).toContain('linear-gradient(to bottom');
398
+ expect(fade).toContain('var(--background-rgb)');
399
+ // It isn't Material's bar.
400
+ expect(bar().classList).not.toContain('bg-background');
401
+ });
402
+ });
403
+
404
+ describe('UiNavigationBar without the Lynx router', () => {
405
+ it('has no history to go back through without the router', async () => {
406
+ const result = await render(PlainBarHost, {
407
+ providers: [
408
+ { provide: LynxSystemInfo, useValue: IOS_26 },
409
+ { provide: LynxAccessibilitySettings, useValue: settings },
410
+ ],
411
+ });
412
+ const host = result.componentRef.instance as PlainBarHost;
413
+ expect(host.bar().canGoBack()).toBe(false);
414
+ // backRoute still shows the button, for an app with routes of its own.
415
+ expect(result.getByRole('button', { name: 'Back' })).toBeTruthy();
416
+ });
417
+
418
+ it('has no Back button with a LocationStrategy of the app’s own', async () => {
419
+ const { back, host } = await renderBar(IOS_26, [
420
+ { provide: LocationStrategy, useClass: MockLocationStrategy },
421
+ ]);
422
+ expect(back()).toBeNull();
423
+ expect(host.bar().canGoBack()).toBe(false);
424
+ });
425
+ });