@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
@@ -1,75 +1,111 @@
1
- import { Component } from '@angular/core';
2
- 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
+ } from '@blotch/angular-lynx-testing-library';
3
7
  import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
4
8
  import { describe, expect, it, vi } from 'vitest';
5
- import { setInputSignal } from '../../../test-utils/set-input-signal';
9
+ import { lynxMainThread } from '../../../test-utils/lynx-main-thread';
6
10
  import { UiScrollArea } from './scroll-area';
7
11
 
8
12
  @Component({
9
13
  imports: [UiScrollArea, LYNX_ELEMENTS],
10
- template: `<ui-scroll-area><text>Body</text></ui-scroll-area>`,
14
+ template: `
15
+ <ui-scroll-area
16
+ [orientation]="orientation()"
17
+ [height]="height()"
18
+ [bounces]="bounces()"
19
+ [scrollBar]="scrollBar()"
20
+ [nestedScroll]="nestedScroll()"
21
+ class="bg-card"
22
+ contentClass="gap-2"
23
+ (scrolledToStart)="reached.push('start')"
24
+ (scrolledToEnd)="reached.push('end')"
25
+ >
26
+ <text>Body</text>
27
+ </ui-scroll-area>
28
+ `,
11
29
  })
12
- class ScrollAreaHost {}
30
+ class ScrollAreaHost {
31
+ readonly orientation = signal<'vertical' | 'horizontal'>('vertical');
32
+ readonly height = signal('');
33
+ readonly bounces = signal(true);
34
+ readonly scrollBar = signal(true);
35
+ readonly nestedScroll = signal(false);
36
+ readonly reached: string[] = [];
37
+ }
38
+
39
+ const renderScrollArea = async () => {
40
+ const result = await render(ScrollAreaHost);
41
+ return {
42
+ ...result,
43
+ host: result.componentRef.instance as ScrollAreaHost,
44
+ scrollView: () => result.container.querySelector('scroll-view')!,
45
+ /** The view inside the scroll view that lays out the content. */
46
+ content: () => result.getByText('Body').parentElement!,
47
+ };
48
+ };
13
49
 
14
50
  /**
15
51
  * The values each boolean reached Lynx with, through `__SetAttribute`.
16
52
  */
17
53
  const watchAttributes = () => {
18
- const mainThread = (
19
- globalThis as unknown as {
20
- lynxTestingEnv: { mainThread: { globalThis: Record<string, unknown> } };
21
- }
22
- ).lynxTestingEnv.mainThread.globalThis;
23
- const setAttribute = vi.spyOn(mainThread, '__SetAttribute' as never);
54
+ const setAttribute = vi.spyOn(lynxMainThread(), '__SetAttribute');
24
55
  return {
25
56
  last: (name: string): unknown =>
26
- (setAttribute.mock.calls as unknown[][])
27
- .filter((call) => call[1] === name)
28
- .at(-1)?.[2],
57
+ setAttribute.mock.calls.filter((call) => call[1] === name).at(-1)?.[2],
29
58
  restore: () => setAttribute.mockRestore(),
30
59
  };
31
60
  };
32
61
 
33
62
  describe('UiScrollArea', () => {
34
- it('renders projected content in the scroll view', async () => {
35
- const { getByText } = await render(ScrollAreaHost);
36
- expect(getByText('Body')).toBeTruthy();
63
+ it('renders projected content in a vertical scroll view', async () => {
64
+ const { scrollView, content } = await renderScrollArea();
65
+ expect(scrollView().getAttribute('scroll-orientation')).toBe('vertical');
66
+ expect(scrollView().classList).toContain('bg-card');
67
+ expect(content().classList).toContain('flex-col');
68
+ expect(content().classList).toContain('gap-2');
37
69
  });
38
70
 
39
- it('emits an explicit height when the height input is set', async () => {
40
- const { componentRef, container } = await render(UiScrollArea);
41
- setInputSignal((componentRef.instance as UiScrollArea).height, '250px');
71
+ it('gives the scroll view an explicit height, which it needs to scroll', async () => {
72
+ const { host, scrollView } = await renderScrollArea();
73
+ expect(scrollView().getAttribute('style') ?? '').not.toContain('height');
74
+
75
+ host.height.set('250px');
42
76
  await waitForUpdate();
43
- const scroll = container.querySelector('scroll-view');
44
- expect(scroll?.getAttribute('style')).toContain('height: 250px');
77
+ expect(scrollView().getAttribute('style')).toContain('height: 250px');
45
78
  });
46
79
 
47
- it('flips the content layout for horizontal orientation', async () => {
48
- const { componentRef, container } = await render(UiScrollArea);
49
- setInputSignal(
50
- (componentRef.instance as UiScrollArea).orientation,
51
- 'horizontal',
52
- );
80
+ it('lays the content out in a row when horizontal', async () => {
81
+ const { host, scrollView, content } = await renderScrollArea();
82
+ host.orientation.set('horizontal');
53
83
  await waitForUpdate();
54
- // The inner content wrapper picks up flex-row for the horizontal branch.
55
- const rowEl = container.querySelector('.flex-row');
56
- expect(rowEl).toBeTruthy();
84
+ expect(scrollView().getAttribute('scroll-orientation')).toBe('horizontal');
85
+ expect(content().classList).toContain('flex-row');
57
86
  });
58
87
 
59
88
  it('hands Lynx real booleans, so the defaults that are on can be turned off', async () => {
60
89
  // A false used to remove the attribute, which leaves Lynx's default: on.
61
90
  const attributes = watchAttributes();
62
- const { componentRef } = await render(UiScrollArea);
63
- const inst = componentRef.instance as UiScrollArea;
91
+ const { host } = await renderScrollArea();
64
92
  expect(attributes.last('bounces')).toBe(true);
65
93
  expect(attributes.last('scroll-bar-enable')).toBe(true);
66
- setInputSignal(inst.bounces, false);
67
- setInputSignal(inst.scrollBar, false);
68
- setInputSignal(inst.nestedScroll, true);
94
+
95
+ host.bounces.set(false);
96
+ host.scrollBar.set(false);
97
+ host.nestedScroll.set(true);
69
98
  await waitForUpdate();
70
99
  expect(attributes.last('bounces')).toBe(false);
71
100
  expect(attributes.last('scroll-bar-enable')).toBe(false);
72
101
  expect(attributes.last('enable-nested-scroll')).toBe(true);
73
102
  attributes.restore();
74
103
  });
104
+
105
+ it('emits when scrolled to the start or the end', async () => {
106
+ const { host, scrollView } = await renderScrollArea();
107
+ fireEvent.scrolltolower(scrollView());
108
+ fireEvent.scrolltoupper(scrollView());
109
+ expect(host.reached).toEqual(['end', 'start']);
110
+ });
75
111
  });
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  Component,
3
3
  ViewEncapsulation,
4
+ booleanAttribute,
4
5
  computed,
5
6
  input,
6
7
  output,
@@ -40,9 +41,9 @@ import { cn } from '../../utils/cn';
40
41
  export class UiScrollArea {
41
42
  readonly orientation = input<'vertical' | 'horizontal'>('vertical');
42
43
  readonly height = input<string>('');
43
- readonly bounces = input(true);
44
- readonly scrollBar = input(true);
45
- readonly nestedScroll = input(false);
44
+ readonly bounces = input(true, { transform: booleanAttribute });
45
+ readonly scrollBar = input(true, { transform: booleanAttribute });
46
+ readonly nestedScroll = input(false, { transform: booleanAttribute });
46
47
  readonly upperThreshold = input(0);
47
48
  readonly lowerThreshold = input(0);
48
49
  readonly userClass = input<string>('', { alias: 'class' });
@@ -0,0 +1,15 @@
1
+ export {
2
+ UI_SEARCH_FIELD_CONTAINER,
3
+ UiSearchField,
4
+ type SearchFieldContainer,
5
+ } from './search-field';
6
+ export {
7
+ KEYBOARD_GAP,
8
+ keyboardHeightOf,
9
+ keyboardLift,
10
+ keyboardScreen,
11
+ measureForKeyboard,
12
+ type KeyboardHeightEvent,
13
+ type KeyboardRect,
14
+ type KeyboardScreen,
15
+ } from './keyboard';
@@ -0,0 +1,147 @@
1
+ import { Injector, type Renderer2 } from '@angular/core';
2
+ import { LynxSystemInfo } from '@blotch/angular-lynx';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import {
5
+ type KeyboardScreen,
6
+ keyboardHeightOf,
7
+ keyboardLift,
8
+ keyboardScreen,
9
+ measureForKeyboard,
10
+ } from './keyboard';
11
+
12
+ const IPHONE: KeyboardScreen = {
13
+ platform: 'iOS',
14
+ pixelRatio: 3,
15
+ screenHeight: 874,
16
+ };
17
+ const PIXEL: KeyboardScreen = {
18
+ platform: 'Android',
19
+ pixelRatio: 2.625,
20
+ screenHeight: 914,
21
+ };
22
+
23
+ /**
24
+ * The Lynx function that runs an element's UI methods, which the testing
25
+ * library defines on the global object.
26
+ */
27
+ type InvokeUIMethod = {
28
+ __InvokeUIMethod: (
29
+ element: unknown,
30
+ method: string,
31
+ params: Record<string, unknown>,
32
+ callback: (result: { code: number; data?: unknown }) => void,
33
+ ) => void;
34
+ };
35
+
36
+ afterEach(() => {
37
+ vi.restoreAllMocks();
38
+ });
39
+
40
+ describe('keyboardHeightOf', () => {
41
+ it('takes iOS’s points as they are', () => {
42
+ expect(keyboardHeightOf({ detail: { height: 336 } }, IPHONE)).toBe(336);
43
+ });
44
+
45
+ it('turns Android’s physical pixels into px', () => {
46
+ expect(keyboardHeightOf({ detail: { height: 819 } }, PIXEL)).toBe(312);
47
+ });
48
+ });
49
+
50
+ describe('keyboardLift', () => {
51
+ it('measures iOS’s keyboard up from the bottom of the screen', () => {
52
+ // The keyboard's top is at 874 - 336 = 538; the field's bottom clears it
53
+ // by the gap.
54
+ expect(keyboardLift({ bottom: 820 }, { bottom: 840 }, 336, IPHONE, 8)).toBe(
55
+ 290,
56
+ );
57
+ });
58
+
59
+ it('measures Android’s up from the bottom of the Lynx view', () => {
60
+ // The keyboard's top is at 890 - 312 = 578.
61
+ expect(keyboardLift({ bottom: 855 }, { bottom: 890 }, 312, PIXEL, 8)).toBe(
62
+ 285,
63
+ );
64
+ });
65
+
66
+ it('is 0 when the keyboard doesn’t reach the element, or is down', () => {
67
+ expect(keyboardLift({ bottom: 300 }, { bottom: 874 }, 336, IPHONE, 8)).toBe(
68
+ 0,
69
+ );
70
+ expect(keyboardLift({ bottom: 820 }, { bottom: 874 }, 0, IPHONE, 8)).toBe(
71
+ 0,
72
+ );
73
+ });
74
+ });
75
+
76
+ describe('keyboardScreen', () => {
77
+ it('is LynxSystemInfo where there is one', () => {
78
+ const info = { platform: 'iOS', pixelRatio: 3, screenHeight: 874 };
79
+ const injector = Injector.create({
80
+ providers: [{ provide: LynxSystemInfo, useValue: info }],
81
+ });
82
+ expect(keyboardScreen(injector)).toBe(info);
83
+ });
84
+
85
+ it('is null where LynxSystemInfo has no SystemInfo to read, and throws', () => {
86
+ const injector = Injector.create({
87
+ providers: [
88
+ {
89
+ provide: LynxSystemInfo,
90
+ useFactory: () => {
91
+ throw new Error('SystemInfo is not available in this environment');
92
+ },
93
+ },
94
+ ],
95
+ });
96
+ expect(keyboardScreen(injector)).toBeNull();
97
+ });
98
+ });
99
+
100
+ describe('measureForKeyboard', () => {
101
+ /** A page holding a column holding the field, as Lynx elements. */
102
+ const page = { element: 'page', tagName: 'page' };
103
+ const column = { element: 'column' };
104
+ const field = { element: 'field' };
105
+ // The page has a parent of its own in the testing library, which the
106
+ // walk stops short of.
107
+ const parents = new Map<unknown, unknown>([
108
+ [field, column],
109
+ [column, page],
110
+ [page, { element: 'document' }],
111
+ ]);
112
+ const renderer = {
113
+ parentNode: (node: unknown) => parents.get(node) ?? null,
114
+ } as unknown as Renderer2;
115
+
116
+ it('measures the element and the page it’s in, on screen', async () => {
117
+ const invoke = vi
118
+ .spyOn(globalThis as unknown as InvokeUIMethod, '__InvokeUIMethod')
119
+ .mockImplementation((element, _method, _params, callback) =>
120
+ callback({
121
+ code: 0,
122
+ data: { bottom: element === 'page' ? 874 : 820 },
123
+ }),
124
+ );
125
+
126
+ await expect(measureForKeyboard(renderer, field)).resolves.toEqual({
127
+ element: expect.objectContaining({ bottom: 820 }),
128
+ page: expect.objectContaining({ bottom: 874 }),
129
+ });
130
+ expect(invoke).toHaveBeenCalledWith(
131
+ 'field',
132
+ 'boundingClientRect',
133
+ { relativeTo: 'screen' },
134
+ expect.any(Function),
135
+ );
136
+ });
137
+
138
+ it('is null where an element can’t be measured', async () => {
139
+ vi.spyOn(
140
+ globalThis as unknown as InvokeUIMethod,
141
+ '__InvokeUIMethod',
142
+ ).mockImplementation((_element, _method, _params, callback) =>
143
+ callback({ code: 1 }),
144
+ );
145
+ await expect(measureForKeyboard(renderer, field)).resolves.toBeNull();
146
+ });
147
+ });
@@ -0,0 +1,123 @@
1
+ import { type Injector, type Renderer2 } from '@angular/core';
2
+ import { LynxSystemInfo, MainThreadElement } from '@blotch/angular-lynx';
3
+
4
+ /**
5
+ * How far above the keyboard a field that rises clear of it sits, in px: the
6
+ * gap the dock keeps from the home indicator.
7
+ */
8
+ export const KEYBOARD_GAP = 8;
9
+
10
+ /** What a field's `<input>` sends with its `keyboardheightchange` event. */
11
+ export type KeyboardHeightEvent = { detail: { height: number } };
12
+
13
+ /** The platform, as far as the keyboard's geometry needs it. */
14
+ export type KeyboardScreen = {
15
+ readonly platform: string;
16
+ readonly pixelRatio: number;
17
+ /** The screen's height in px. */
18
+ readonly screenHeight: number;
19
+ };
20
+
21
+ /**
22
+ * The platform from LynxSystemInfo, or null where there's no SystemInfo to
23
+ * read: LynxSystemInfo throws in unit tests without one. Looked up when the
24
+ * keyboard shows, which is when it's needed.
25
+ */
26
+ export const keyboardScreen = (injector: Injector): KeyboardScreen | null => {
27
+ try {
28
+ return injector.get(LynxSystemInfo);
29
+ } catch {
30
+ return null;
31
+ }
32
+ };
33
+
34
+ /** A box on screen, as far as the keyboard's geometry needs it, in px. */
35
+ export type KeyboardRect = { readonly bottom: number };
36
+
37
+ /**
38
+ * The keyboard's height in px, from an `<input>`'s `keyboardheightchange`
39
+ * event, which arrives on the main thread while the input is focused: as the
40
+ * keyboard shows, when it changes size (the emoji keyboard, the predictive
41
+ * bar), and with 0 as it hides.
42
+ *
43
+ * Lynx measures it differently on each platform. iOS sends points, measured
44
+ * up from the bottom of the window. Android sends physical pixels, measured
45
+ * up from the bottom of the Lynx view: an app whose window shrinks above the
46
+ * keyboard, as the Android app `ng build --platform android` makes does,
47
+ * gets 0 once it has.
48
+ */
49
+ export const keyboardHeightOf = (
50
+ event: KeyboardHeightEvent,
51
+ screen: KeyboardScreen,
52
+ ): number =>
53
+ screen.platform === 'Android'
54
+ ? event.detail.height / screen.pixelRatio
55
+ : event.detail.height;
56
+
57
+ /**
58
+ * How far an element has to rise for its bottom edge to clear the keyboard
59
+ * by `gap`, in px: 0 when the keyboard doesn't reach it. `element` and
60
+ * `page` are their boxes on screen, `page` the Lynx page's, whose bottom is
61
+ * the Lynx view's: iOS measures the keyboard from the window's bottom edge,
62
+ * and Android from the Lynx view's (see keyboardHeightOf()).
63
+ */
64
+ export const keyboardLift = (
65
+ element: KeyboardRect,
66
+ page: KeyboardRect,
67
+ keyboard: number,
68
+ screen: KeyboardScreen,
69
+ gap: number,
70
+ ): number => {
71
+ if (keyboard <= 0) return 0;
72
+ const keyboardTop =
73
+ screen.platform === 'iOS'
74
+ ? screen.screenHeight - keyboard
75
+ : page.bottom - keyboard;
76
+ return Math.max(0, Math.round(element.bottom + gap - keyboardTop));
77
+ };
78
+
79
+ /** AngularLynx's wrapper of a Lynx element, around the element itself. */
80
+ type LynxElementRef = { readonly element: unknown; readonly tagName?: string };
81
+
82
+ /**
83
+ * An element's box on screen, or null where it can't be measured (the web
84
+ * preview measures from the `<lynx-view>`, which has no keyboard to clear).
85
+ * Lynx gives a script an element's position only through its
86
+ * `boundingClientRect` UI method, which answers later.
87
+ */
88
+ const measureOnScreen = async (
89
+ element: LynxElementRef,
90
+ ): Promise<KeyboardRect | null> => {
91
+ try {
92
+ return await new MainThreadElement(
93
+ element.element as ConstructorParameters<typeof MainThreadElement>[0],
94
+ ).getBoundingClientRect({ relativeTo: 'screen' });
95
+ } catch {
96
+ return null;
97
+ }
98
+ };
99
+
100
+ /**
101
+ * Measures `element` and the page it's in, the `<page>` above it, for
102
+ * keyboardLift(). Null when either can't be measured.
103
+ */
104
+ export const measureForKeyboard = async (
105
+ renderer: Renderer2,
106
+ element: LynxElementRef,
107
+ ): Promise<{ element: KeyboardRect; page: KeyboardRect } | null> => {
108
+ let page = element;
109
+ for (
110
+ let parent = renderer.parentNode(page) as LynxElementRef | null;
111
+ parent && page.tagName !== 'page';
112
+ parent = renderer.parentNode(parent) as LynxElementRef | null
113
+ ) {
114
+ page = parent;
115
+ }
116
+ const [elementRect, pageRect] = await Promise.all([
117
+ measureOnScreen(element),
118
+ measureOnScreen(page),
119
+ ]);
120
+ return elementRect && pageRect
121
+ ? { element: elementRect, page: pageRect }
122
+ : null;
123
+ };