@blotch/dolan 22.2.3 → 22.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/dist/commands/add.js +9 -1
  2. package/dist/commands/add.js.map +1 -1
  3. package/dist/commands/add.test.js +36 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +187 -7
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +280 -7
  8. package/dist/commands/doctor.test.js.map +1 -1
  9. package/dist/commands/init.d.ts +2 -2
  10. package/dist/commands/init.js +38 -18
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/init.test.js +107 -1
  13. package/dist/commands/init.test.js.map +1 -1
  14. package/dist/commands/theme.js +1 -1
  15. package/dist/commands/theme.js.map +1 -1
  16. package/dist/commands/update.d.ts +5 -0
  17. package/dist/commands/update.js +126 -45
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +314 -2
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +7 -0
  22. package/dist/lockfile.js +36 -0
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +25 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +48 -2
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +45 -3
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +5 -0
  33. package/dist/test-utils.js +6 -1
  34. package/dist/test-utils.js.map +1 -1
  35. package/dist/testing/fire-lynx-event.d.ts +14 -0
  36. package/dist/testing/fire-lynx-event.js +19 -0
  37. package/dist/testing/fire-lynx-event.js.map +1 -0
  38. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  39. package/dist/testing/fire-lynx-event.test.js +44 -0
  40. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  41. package/dist/testing/lynx-main-thread.d.ts +22 -0
  42. package/dist/testing/lynx-main-thread.js +11 -0
  43. package/dist/testing/lynx-main-thread.js.map +1 -0
  44. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  45. package/dist/testing/lynx-main-thread.test.js +24 -0
  46. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  47. package/dist/testing/set-input-signal.d.ts +5 -4
  48. package/dist/testing/set-input-signal.js +5 -4
  49. package/dist/testing/set-input-signal.js.map +1 -1
  50. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  51. package/dist/ui/components/accordion/accordion.ts +19 -14
  52. package/dist/ui/components/action-sheet/action-sheet.test.ts +272 -187
  53. package/dist/ui/components/action-sheet/action-sheet.ts +40 -34
  54. package/dist/ui/components/alert/alert.test.ts +67 -19
  55. package/dist/ui/components/alert/alert.ts +14 -16
  56. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +206 -139
  57. package/dist/ui/components/alert-dialog/alert-dialog.ts +32 -26
  58. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  59. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  60. package/dist/ui/components/badge/badge.test.ts +57 -23
  61. package/dist/ui/components/badge/badge.ts +12 -14
  62. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  63. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +373 -297
  64. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +34 -33
  65. package/dist/ui/components/button/button.test.ts +611 -2
  66. package/dist/ui/components/button/button.ts +431 -62
  67. package/dist/ui/components/button/index.ts +2 -0
  68. package/dist/ui/components/button-group/button-group.test.ts +484 -2
  69. package/dist/ui/components/button-group/button-group.ts +380 -68
  70. package/dist/ui/components/button-group/index.ts +3 -0
  71. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  72. package/dist/ui/components/card/card.test.ts +82 -62
  73. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  74. package/dist/ui/components/chart/chart-presets.ts +8 -1
  75. package/dist/ui/components/chart/chart-theme.ts +12 -0
  76. package/dist/ui/components/chart/chart.test.ts +1017 -0
  77. package/dist/ui/components/chart/chart.ts +56 -43
  78. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  79. package/dist/ui/components/checkbox/checkbox.ts +23 -23
  80. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  81. package/dist/ui/components/collapsible/collapsible.ts +19 -14
  82. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  83. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  84. package/dist/ui/components/context-menu/index.ts +1 -0
  85. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  86. package/dist/ui/components/dialog/dialog.ts +29 -23
  87. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  88. package/dist/ui/components/dock/dock-scroll.ts +133 -0
  89. package/dist/ui/components/dock/dock.test.ts +749 -0
  90. package/dist/ui/components/dock/dock.ts +960 -0
  91. package/dist/ui/components/dock/index.ts +2 -0
  92. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  93. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  94. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  95. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  96. package/dist/ui/components/glass/glass-design.ts +119 -0
  97. package/dist/ui/components/glass/glass.test.ts +338 -0
  98. package/dist/ui/components/glass/glass.ts +360 -0
  99. package/dist/ui/components/glass/index.ts +18 -0
  100. package/dist/ui/components/icon/icon-theme.test.ts +36 -0
  101. package/dist/ui/components/icon/icon-theme.ts +36 -13
  102. package/dist/ui/components/icon/icon.test.ts +52 -41
  103. package/dist/ui/components/input/input.test.ts +178 -101
  104. package/dist/ui/components/input/input.ts +15 -12
  105. package/dist/ui/components/label/label.test.ts +11 -24
  106. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  107. package/dist/ui/components/list/list.test.ts +80 -49
  108. package/dist/ui/components/menu/index.ts +25 -0
  109. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  110. package/dist/ui/components/menu/menu-placement.ts +159 -0
  111. package/dist/ui/components/menu/menu-popup.test.ts +632 -0
  112. package/dist/ui/components/menu/menu-popup.ts +544 -0
  113. package/dist/ui/components/menu/menu.test.ts +429 -0
  114. package/dist/ui/components/menu/menu.ts +415 -0
  115. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  116. package/dist/ui/components/nav-drawer/nav-drawer.ts +72 -56
  117. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  118. package/dist/ui/components/progress/progress.test.ts +102 -46
  119. package/dist/ui/components/progress/progress.ts +52 -54
  120. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  121. package/dist/ui/components/radio-group/radio-group.ts +24 -24
  122. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  123. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  124. package/dist/ui/components/select/select.test.ts +138 -78
  125. package/dist/ui/components/select/select.ts +17 -11
  126. package/dist/ui/components/separator/separator.test.ts +18 -17
  127. package/dist/ui/components/sheet/sheet.test.ts +64 -11
  128. package/dist/ui/components/sheet/sheet.ts +9 -1
  129. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  130. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  131. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  132. package/dist/ui/components/spinner/spinner.ts +16 -19
  133. package/dist/ui/components/switch/switch.test.ts +116 -46
  134. package/dist/ui/components/switch/switch.ts +8 -13
  135. package/dist/ui/components/tab-bar/index.ts +1 -0
  136. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  137. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  138. package/dist/ui/components/tabs/tabs.test.ts +215 -157
  139. package/dist/ui/components/tabs/tabs.ts +110 -118
  140. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  141. package/dist/ui/components/textarea/textarea.ts +14 -12
  142. package/dist/ui/components/toast/toast.test.ts +434 -350
  143. package/dist/ui/components/toast/toast.ts +42 -51
  144. package/dist/ui/components/toggle/toggle.test.ts +247 -140
  145. package/dist/ui/theme/chart-colors.test.ts +8 -3
  146. package/dist/ui/theme/dark.css +38 -2
  147. package/dist/ui/theme/default.css +55 -7
  148. package/dist/ui/theme/icon-colors.test.ts +8 -3
  149. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  150. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  151. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  152. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  153. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  154. package/dist/ui/theme/tailwind.css +113 -0
  155. package/dist/ui/utils/animate.test.ts +13 -1
  156. package/dist/utils/analyze.d.ts +14 -0
  157. package/dist/utils/analyze.js +15 -0
  158. package/dist/utils/analyze.js.map +1 -1
  159. package/dist/utils/analyze.test.js +14 -1
  160. package/dist/utils/analyze.test.js.map +1 -1
  161. package/dist/utils/installed.d.ts +12 -0
  162. package/dist/utils/installed.js +16 -0
  163. package/dist/utils/installed.js.map +1 -1
  164. package/dist/utils/installed.test.js +17 -1
  165. package/dist/utils/installed.test.js.map +1 -1
  166. package/dist/utils/resolve-paths.d.ts +4 -4
  167. package/dist/utils/resolve-paths.js +5 -7
  168. package/dist/utils/resolve-paths.js.map +1 -1
  169. package/dist/utils/resolve-paths.test.js +9 -5
  170. package/dist/utils/resolve-paths.test.js.map +1 -1
  171. package/dist/utils/tailwind.d.ts +25 -0
  172. package/dist/utils/tailwind.js +51 -0
  173. package/dist/utils/tailwind.js.map +1 -0
  174. package/dist/utils/tailwind.test.d.ts +1 -0
  175. package/dist/utils/tailwind.test.js +71 -0
  176. package/dist/utils/tailwind.test.js.map +1 -0
  177. package/package.json +9 -1
  178. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  179. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  180. package/dist/ui/components/button/button-render.test.ts +0 -132
  181. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  182. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  183. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  184. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  185. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -1,97 +1,121 @@
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 { UiList } from './list';
7
11
 
8
12
  @Component({
9
13
  imports: [UiList, LYNX_ELEMENTS],
10
- template: `<ui-list><text>Item</text></ui-list>`,
14
+ template: `
15
+ <ui-list
16
+ [height]="height()"
17
+ [mainAxisGap]="mainAxisGap()"
18
+ [crossAxisGap]="crossAxisGap()"
19
+ [bounces]="bounces()"
20
+ [scrollBar]="scrollBar()"
21
+ [nestedScroll]="nestedScroll()"
22
+ [sticky]="sticky()"
23
+ class="bg-card"
24
+ (scrolledToStart)="reached.push('start')"
25
+ (scrolledToEnd)="reached.push('end')"
26
+ >
27
+ <text>Item</text>
28
+ </ui-list>
29
+ `,
11
30
  })
12
- class ListHost {}
31
+ class ListHost {
32
+ readonly height = signal('');
33
+ readonly mainAxisGap = signal('');
34
+ readonly crossAxisGap = signal('');
35
+ readonly bounces = signal(true);
36
+ readonly scrollBar = signal(true);
37
+ readonly nestedScroll = signal(false);
38
+ readonly sticky = signal(false);
39
+ readonly reached: string[] = [];
40
+ }
41
+
42
+ const renderList = async () => {
43
+ const result = await render(ListHost);
44
+ return {
45
+ ...result,
46
+ host: result.componentRef.instance as ListHost,
47
+ list: () => result.container.querySelector('list')!,
48
+ };
49
+ };
13
50
 
14
51
  /**
15
52
  * The values each boolean reached Lynx with, through `__SetAttribute`.
16
53
  */
17
54
  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);
55
+ const setAttribute = vi.spyOn(lynxMainThread(), '__SetAttribute');
24
56
  return {
25
57
  last: (name: string): unknown =>
26
- (setAttribute.mock.calls as unknown[][])
27
- .filter((call) => call[1] === name)
28
- .at(-1)?.[2],
58
+ setAttribute.mock.calls.filter((call) => call[1] === name).at(-1)?.[2],
29
59
  restore: () => setAttribute.mockRestore(),
30
60
  };
31
61
  };
32
62
 
33
63
  describe('UiList', () => {
34
- it('renders projected content', async () => {
35
- // Lynx `<list>` is virtualized — items are only added to the native tree by
36
- // the componentAtIndex callback, which isn't driven from a unit test. Just
37
- // assert the wrapper mounts (its own template bindings + host bindings are
38
- // covered) without asserting on the item's text.
39
- const { container } = await render(ListHost);
40
- expect(container).toBeTruthy();
64
+ it('renders a single-column vertical list with the given class', async () => {
65
+ // Lynx `<list>` is virtualized: items join the native tree only through
66
+ // its componentAtIndex callback, which a unit test doesn't drive. So this
67
+ // checks the list itself rather than the projected item.
68
+ const { list } = await renderList();
69
+ expect(list().getAttribute('list-type')).toBe('single');
70
+ expect(list().getAttribute('span-count')).toBe('1');
71
+ expect(list().getAttribute('scroll-orientation')).toBe('vertical');
72
+ expect(list().classList).toContain('w-full');
73
+ expect(list().classList).toContain('bg-card');
41
74
  });
42
75
 
43
- it('emits an explicit height style when the height input is set', async () => {
44
- // JIT template bindings don't propagate through input(); flip the signal
45
- // directly so listStyle exercises its truthy branch.
46
- const { container, componentRef } = await render(UiList);
47
- setInputSignal((componentRef.instance as UiList).height, '400px');
48
- await waitForUpdate();
76
+ it('gives the list an explicit height, which it needs to scroll', async () => {
77
+ const { host, list } = await renderList();
78
+ expect(list().getAttribute('style') ?? '').not.toContain('height');
49
79
 
50
- const list = container.querySelector('list');
51
- expect(list?.getAttribute('style')).toContain('height: 400px');
80
+ host.height.set('400px');
81
+ await waitForUpdate();
82
+ expect(list().getAttribute('style')).toContain('height: 400px');
52
83
  });
53
84
 
54
85
  it('sets the gaps as list styles, which Lynx reads, not attributes', async () => {
55
86
  // jsdom drops style properties it doesn't know, so the gaps never show in
56
87
  // the style attribute: watch what reaches Lynx's inline style API instead.
57
- const mainThread = (
58
- globalThis as unknown as {
59
- lynxTestingEnv: { mainThread: { globalThis: Record<string, unknown> } };
60
- }
61
- ).lynxTestingEnv.mainThread.globalThis;
62
- const addInlineStyle = vi.spyOn(mainThread, '__AddInlineStyle' as never);
63
- const { container, componentRef } = await render(UiList);
64
- const list = componentRef.instance as UiList;
65
- setInputSignal(list.mainAxisGap, '8px');
66
- setInputSignal(list.crossAxisGap, '12px');
88
+ const addInlineStyle = vi.spyOn(lynxMainThread(), '__AddInlineStyle');
89
+ const { host, list } = await renderList();
90
+ host.mainAxisGap.set('8px');
91
+ host.crossAxisGap.set('12px');
67
92
  await waitForUpdate();
68
93
 
69
- const element = container.querySelector('list');
70
94
  expect(addInlineStyle).toHaveBeenCalledWith(
71
- element,
95
+ list(),
72
96
  'list-main-axis-gap',
73
97
  '8px',
74
98
  );
75
99
  expect(addInlineStyle).toHaveBeenCalledWith(
76
- element,
100
+ list(),
77
101
  'list-cross-axis-gap',
78
102
  '12px',
79
103
  );
80
- expect(element?.getAttribute('list-main-axis-gap')).toBeNull();
104
+ expect(list().getAttribute('list-main-axis-gap')).toBeNull();
81
105
  addInlineStyle.mockRestore();
82
106
  });
83
107
 
84
108
  it('hands Lynx real booleans, so the defaults that are on can be turned off', async () => {
85
109
  // A false used to remove the attribute, which leaves Lynx's default: on.
86
110
  const attributes = watchAttributes();
87
- const { componentRef } = await render(UiList);
88
- const inst = componentRef.instance as UiList;
111
+ const { host } = await renderList();
89
112
  expect(attributes.last('bounces')).toBe(true);
90
113
  expect(attributes.last('scroll-bar-enable')).toBe(true);
91
- setInputSignal(inst.bounces, false);
92
- setInputSignal(inst.scrollBar, false);
93
- setInputSignal(inst.nestedScroll, true);
94
- setInputSignal(inst.sticky, true);
114
+
115
+ host.bounces.set(false);
116
+ host.scrollBar.set(false);
117
+ host.nestedScroll.set(true);
118
+ host.sticky.set(true);
95
119
  await waitForUpdate();
96
120
  expect(attributes.last('bounces')).toBe(false);
97
121
  expect(attributes.last('scroll-bar-enable')).toBe(false);
@@ -99,4 +123,11 @@ describe('UiList', () => {
99
123
  expect(attributes.last('sticky')).toBe(true);
100
124
  attributes.restore();
101
125
  });
126
+
127
+ it('emits when scrolled to the start or the end', async () => {
128
+ const { host, list } = await renderList();
129
+ fireEvent.scrolltolower(list());
130
+ fireEvent.scrolltoupper(list());
131
+ expect(host.reached).toEqual(['end', 'start']);
132
+ });
102
133
  });
@@ -0,0 +1,25 @@
1
+ export {
2
+ UI_MENU,
3
+ UiMenu,
4
+ UiMenuItem,
5
+ UiMenuSection,
6
+ UiMenuSeparator,
7
+ UiMenuTrigger,
8
+ type UiMenuHost,
9
+ } from './menu';
10
+ export {
11
+ UiMenuPopup,
12
+ measureMenuElement,
13
+ type MenuElement,
14
+ type MenuKind,
15
+ type MenuPhase,
16
+ } from './menu-popup';
17
+ export {
18
+ menuStartFrame,
19
+ placeMenu,
20
+ type MenuPlacement,
21
+ type MenuPlacementMode,
22
+ type MenuPlacementOptions,
23
+ type MenuRect,
24
+ type MenuSize,
25
+ } from './menu-placement';
@@ -0,0 +1,153 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ type MenuPlacementOptions,
4
+ type MenuRect,
5
+ menuStartFrame,
6
+ placeMenu,
7
+ } from './menu-placement';
8
+
9
+ /** A phone's screen inside its safe area, in points. */
10
+ const AREA = { width: 402, height: 778 };
11
+ const MENU = { width: 250, height: 200 };
12
+
13
+ const rect = (
14
+ left: number,
15
+ top: number,
16
+ width: number,
17
+ height: number,
18
+ ): MenuRect => ({ left, top, width, height });
19
+
20
+ const place = (options: Partial<MenuPlacementOptions> & { anchor: MenuRect }) =>
21
+ placeMenu({ area: AREA, menu: MENU, mode: 'beside', ...options });
22
+
23
+ describe('placeMenu', () => {
24
+ it('opens below an anchor with room below, lined up with its left edge', () => {
25
+ const placement = place({ anchor: rect(16, 100, 370, 96) });
26
+
27
+ expect(placement).toEqual({
28
+ frame: rect(16, 204, 250, 200),
29
+ anchor: rect(16, 100, 370, 96),
30
+ vertical: 'top',
31
+ horizontal: 'left',
32
+ });
33
+ });
34
+
35
+ it('lines up with the right edge of an anchor in the right half', () => {
36
+ const placement = place({ anchor: rect(340, 10, 44, 44) });
37
+
38
+ expect(placement.horizontal).toBe('right');
39
+ expect(placement.frame.left).toBe(384 - 250);
40
+ });
41
+
42
+ it('keeps the menu inside the area horizontally', () => {
43
+ expect(place({ anchor: rect(380, 10, 60, 44) }).frame.left).toBe(
44
+ 402 - 8 - 250,
45
+ );
46
+ expect(place({ anchor: rect(-40, 10, 44, 44) }).frame.left).toBe(8);
47
+ });
48
+
49
+ it('opens above an anchor near the bottom', () => {
50
+ const placement = place({ anchor: rect(16, 700, 100, 44) });
51
+
52
+ expect(placement.vertical).toBe('bottom');
53
+ expect(placement.frame.top).toBe(700 - 8 - 200);
54
+ });
55
+
56
+ it('moves a lifted anchor up when the menu fits on neither side', () => {
57
+ const anchor = rect(16, 150, 370, 450);
58
+ const placement = place({ anchor, movableAnchor: true });
59
+
60
+ // The anchor moves up until the menu fits below it, inside the margin.
61
+ const anchorTop = 778 - 8 - 200 - 8 - 450;
62
+ expect(placement.anchor).toEqual({ ...anchor, top: anchorTop });
63
+ expect(placement.frame.top).toBe(anchorTop + 450 + 8);
64
+ expect(placement.vertical).toBe('top');
65
+ });
66
+
67
+ it('covers an anchor it can neither open beside nor move', () => {
68
+ const anchor = rect(16, 100, 370, 600);
69
+
70
+ // Too tall to move out of the way, or not movable: the menu sits at the
71
+ // bottom of the area, over the anchor.
72
+ for (const movableAnchor of [true, false]) {
73
+ const placement = place({ anchor, movableAnchor });
74
+ expect(placement.anchor).toEqual(anchor);
75
+ expect(placement.frame.top).toBe(778 - 8 - 200);
76
+ }
77
+ });
78
+
79
+ it('shrinks a menu taller or wider than the area to fit', () => {
80
+ const placement = place({
81
+ anchor: rect(16, 100, 44, 44),
82
+ menu: { width: 500, height: 1000 },
83
+ });
84
+
85
+ expect(placement.frame).toEqual(rect(8, 8, 402 - 16, 778 - 16));
86
+ });
87
+
88
+ it('grows a pull-down menu over its anchor, from its top edge', () => {
89
+ const placement = place({ anchor: rect(16, 100, 80, 44), mode: 'grow' });
90
+
91
+ expect(placement.frame).toEqual(rect(16, 100, 250, 200));
92
+ expect(placement.vertical).toBe('top');
93
+ });
94
+
95
+ it('grows a pull-down menu from the bottom edge of an anchor near the bottom', () => {
96
+ const placement = place({ anchor: rect(16, 720, 80, 44), mode: 'grow' });
97
+
98
+ expect(placement.frame.top).toBe(764 - 200);
99
+ expect(placement.vertical).toBe('bottom');
100
+ });
101
+
102
+ it('takes its own gap and margin', () => {
103
+ const placement = place({
104
+ anchor: rect(0, 100, 80, 40),
105
+ gap: 0,
106
+ margin: 0,
107
+ });
108
+
109
+ expect(placement.frame).toEqual(rect(0, 140, 250, 200));
110
+ });
111
+ });
112
+
113
+ describe('menuStartFrame', () => {
114
+ const below = placeMenu({
115
+ anchor: rect(16, 100, 80, 44),
116
+ area: AREA,
117
+ menu: MENU,
118
+ mode: 'beside',
119
+ });
120
+ const aboveRight = placeMenu({
121
+ anchor: rect(300, 700, 80, 44),
122
+ area: AREA,
123
+ menu: MENU,
124
+ mode: 'beside',
125
+ });
126
+
127
+ it('starts a pull-down menu as its anchor', () => {
128
+ expect(menuStartFrame(below, 'anchor')).toEqual(below.anchor);
129
+ });
130
+
131
+ it('starts a context menu small, in the corner nearest its anchor', () => {
132
+ expect(menuStartFrame(below, 'corner')).toEqual(
133
+ rect(16, 152, 250 * 0.3, 200 * 0.3),
134
+ );
135
+ const { frame } = aboveRight;
136
+ expect(menuStartFrame(aboveRight, 'corner')).toEqual(
137
+ rect(
138
+ frame.left + frame.width - 250 * 0.3,
139
+ frame.top + frame.height - 200 * 0.3,
140
+ 250 * 0.3,
141
+ 200 * 0.3,
142
+ ),
143
+ );
144
+ });
145
+
146
+ it('starts a Material menu as a sliver along the edge nearest its anchor', () => {
147
+ expect(menuStartFrame(below, 'edge')).toEqual(rect(16, 152, 250, 0));
148
+ const { frame } = aboveRight;
149
+ expect(menuStartFrame(aboveRight, 'edge')).toEqual(
150
+ rect(frame.left, frame.top + frame.height, 250, 0),
151
+ );
152
+ });
153
+ });
@@ -0,0 +1,159 @@
1
+ /** A box on screen, in px. */
2
+ export type MenuRect = {
3
+ readonly left: number;
4
+ readonly top: number;
5
+ readonly width: number;
6
+ readonly height: number;
7
+ };
8
+
9
+ /** A size, in px. */
10
+ export type MenuSize = { readonly width: number; readonly height: number };
11
+
12
+ /**
13
+ * How a menu sits against the element that opened it.
14
+ *
15
+ * - `'grow'`: over it, from its top edge (or its bottom edge, near the bottom
16
+ * of the screen), as Apple's pull-down menus grow out of their button.
17
+ * - `'beside'`: below it, or above it near the bottom of the screen, a gap
18
+ * away. Context menus open beside the pressed element, and Material menus
19
+ * beside their anchor.
20
+ */
21
+ export type MenuPlacementMode = 'grow' | 'beside';
22
+
23
+ export type MenuPlacementOptions = {
24
+ /** The element that opened the menu, in the area's coordinates. */
25
+ readonly anchor: MenuRect;
26
+ /** The area the menu has to stay in: the screen inside its safe area. */
27
+ readonly area: MenuSize;
28
+ /** The menu's size at rest. */
29
+ readonly menu: MenuSize;
30
+ readonly mode: MenuPlacementMode;
31
+ /**
32
+ * Whether the anchor may move to make room, as the lifted element of a
33
+ * context menu does when the menu fits neither below nor above it.
34
+ */
35
+ readonly movableAnchor?: boolean;
36
+ /** The space between the menu and a `'beside'` anchor. */
37
+ readonly gap?: number;
38
+ /** The space the menu keeps from the area's edges. */
39
+ readonly margin?: number;
40
+ };
41
+
42
+ export type MenuPlacement = {
43
+ /** The menu's box at rest. */
44
+ readonly frame: MenuRect;
45
+ /**
46
+ * Where the anchor sits while the menu is open: where it was, unless
47
+ * `movableAnchor` let it move to make room.
48
+ */
49
+ readonly anchor: MenuRect;
50
+ /**
51
+ * The menu edge nearest the anchor, which the menu grows from and its
52
+ * content is pinned to: it hangs from its `'top'` below or over the anchor,
53
+ * and stands on its `'bottom'` above it.
54
+ */
55
+ readonly vertical: 'top' | 'bottom';
56
+ /**
57
+ * The anchor edge the menu lines up with: its `'right'` edge for an anchor
58
+ * entirely in the right half of the area, such as a button in a corner,
59
+ * so the menu opens toward the middle of the screen, and its `'left'` edge
60
+ * otherwise, which includes a row as wide as the screen.
61
+ */
62
+ readonly horizontal: 'left' | 'right';
63
+ };
64
+
65
+ const clamp = (value: number, min: number, max: number): number =>
66
+ Math.min(Math.max(value, min), Math.max(min, max));
67
+
68
+ /**
69
+ * Where a menu goes on screen, beside or over the element that opened it,
70
+ * kept inside the area (the screen within its safe area): below the element,
71
+ * or above it when there's no room below, lined up with whichever of its
72
+ * edges is nearer the middle of the screen.
73
+ */
74
+ export const placeMenu = ({
75
+ anchor,
76
+ area,
77
+ menu,
78
+ mode,
79
+ movableAnchor = false,
80
+ gap = 8,
81
+ margin = 8,
82
+ }: MenuPlacementOptions): MenuPlacement => {
83
+ const width = clamp(menu.width, 0, area.width - 2 * margin);
84
+ const height = clamp(menu.height, 0, area.height - 2 * margin);
85
+ const horizontal = anchor.left < area.width / 2 ? 'left' : 'right';
86
+ const left = clamp(
87
+ horizontal === 'left' ? anchor.left : anchor.left + anchor.width - width,
88
+ margin,
89
+ area.width - margin - width,
90
+ );
91
+ const bottomLimit = area.height - margin;
92
+ const anchorBottom = anchor.top + anchor.height;
93
+
94
+ const place = (
95
+ top: number,
96
+ vertical: 'top' | 'bottom',
97
+ anchorTop = anchor.top,
98
+ ): MenuPlacement => ({
99
+ frame: {
100
+ left,
101
+ top: clamp(top, margin, bottomLimit - height),
102
+ width,
103
+ height,
104
+ },
105
+ anchor: { ...anchor, top: anchorTop },
106
+ vertical,
107
+ horizontal,
108
+ });
109
+
110
+ if (mode === 'grow') {
111
+ // Over the anchor, from its top edge, or else from its bottom edge.
112
+ return anchor.top + height <= bottomLimit
113
+ ? place(anchor.top, 'top')
114
+ : place(anchorBottom - height, 'bottom');
115
+ }
116
+
117
+ if (anchorBottom + gap + height <= bottomLimit) {
118
+ return place(anchorBottom + gap, 'top');
119
+ }
120
+ if (anchor.top - gap - height >= margin) {
121
+ return place(anchor.top - gap - height, 'bottom');
122
+ }
123
+ // Room on neither side. A lifted element moves up until the menu fits
124
+ // below it, as iOS moves a context menu's preview; if even that can't
125
+ // make room, or the anchor can't move, the menu covers the anchor.
126
+ const anchorTop = bottomLimit - height - gap - anchor.height;
127
+ if (movableAnchor && anchorTop >= margin) {
128
+ return place(anchorTop + anchor.height + gap, 'top', anchorTop);
129
+ }
130
+ return place(bottomLimit - height, 'top');
131
+ };
132
+
133
+ /**
134
+ * Where a menu starts growing from, and shrinks back to, as it opens and
135
+ * closes.
136
+ *
137
+ * - From the anchor itself for a `'grow'` menu: one shape whose frame grows,
138
+ * which Liquid Glass follows frame by frame (its glass can't morph one
139
+ * shape into another).
140
+ * - From a small box in the corner nearest the anchor for a context menu.
141
+ * - From a sliver along the edge nearest the anchor for Material, whose
142
+ * menus unfold from their anchor.
143
+ */
144
+ export const menuStartFrame = (
145
+ placement: MenuPlacement,
146
+ from: 'anchor' | 'corner' | 'edge',
147
+ ): MenuRect => {
148
+ const { frame, anchor, vertical, horizontal } = placement;
149
+ if (from === 'anchor') return anchor;
150
+ const scale = from === 'corner' ? 0.3 : 1;
151
+ const width = frame.width * scale;
152
+ const height = from === 'corner' ? frame.height * scale : 0;
153
+ return {
154
+ left: horizontal === 'left' ? frame.left : frame.left + frame.width - width,
155
+ top: vertical === 'top' ? frame.top : frame.top + frame.height - height,
156
+ width,
157
+ height,
158
+ };
159
+ };