@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,8 +1,23 @@
1
1
  import { Component, signal } from '@angular/core';
2
- import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
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
- import { describe, expect, it } from 'vitest';
5
- import { setInputSignal } from '../../../test-utils/set-input-signal';
8
+ import {
9
+ type MockInstance,
10
+ afterEach,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ it,
15
+ vi,
16
+ } from 'vitest';
17
+ import {
18
+ type LynxMainThread,
19
+ lynxMainThread,
20
+ } from '../../../test-utils/lynx-main-thread';
6
21
  import {
7
22
  UiCollapsible,
8
23
  UiCollapsibleContent,
@@ -10,103 +25,165 @@ import {
10
25
  } from './collapsible';
11
26
 
12
27
  @Component({
13
- standalone: true,
14
- imports: [UiCollapsible, LYNX_ELEMENTS],
15
- template: `<ui-collapsible><text>Content</text></ui-collapsible>`,
28
+ imports: [
29
+ UiCollapsible,
30
+ UiCollapsibleTrigger,
31
+ UiCollapsibleContent,
32
+ LYNX_ELEMENTS,
33
+ ],
34
+ template: `
35
+ <ui-collapsible [(open)]="open" [disabled]="disabled()" class="gap-2">
36
+ <ui-collapsible-trigger class="px-2">
37
+ <text>Toggle</text>
38
+ </ui-collapsible-trigger>
39
+ <ui-collapsible-content class="pt-1">
40
+ <text>Details</text>
41
+ </ui-collapsible-content>
42
+ </ui-collapsible>
43
+ `,
16
44
  })
17
- class CollapsibleHost {}
45
+ class CollapsibleHost {
46
+ readonly open = signal(false);
47
+ readonly disabled = signal(false);
48
+ }
49
+
50
+ const renderCollapsible = async () => {
51
+ const result = await render(CollapsibleHost);
52
+ return {
53
+ ...result,
54
+ host: result.componentRef.instance as CollapsibleHost,
55
+ // The trigger's own view holds the tap listener; the projected text is
56
+ // inside it.
57
+ trigger: () => result.getByText('Toggle').parentElement!,
58
+ // The content's own view wraps the projected text while it's open.
59
+ content: () => result.queryByText('Details')?.parentElement ?? null,
60
+ };
61
+ };
62
+
63
+ /** `__ElementAnimate`'s first argument for starting an animation. */
64
+ const START = 0;
65
+ /** `__ElementAnimate`'s first argument for canceling an animation. */
66
+ const CANCEL = 3;
18
67
 
19
68
  describe('UiCollapsible', () => {
20
- it('renders projected content', async () => {
21
- const { getByText } = await render(CollapsibleHost);
22
- expect(getByText('Content')).toBeTruthy();
23
- });
69
+ it("merges each part's class with its own", async () => {
70
+ const { container, host, trigger, content } = await renderCollapsible();
71
+ // The host element carries the class too, so look for the view that has
72
+ // both its own and the given class.
73
+ expect(container.querySelector('.flex-col.gap-2')).not.toBeNull();
74
+ expect(trigger().classList).toContain('flex-row');
75
+ expect(trigger().classList).toContain('px-2');
24
76
 
25
- it('toggle() flips the open state', async () => {
26
- const { componentRef } = await render(UiCollapsible);
27
- const inst = componentRef.instance as UiCollapsible;
28
- expect(inst.open()).toBe(false);
29
- inst.toggle();
30
- expect(inst.open()).toBe(true);
31
- inst.toggle();
32
- expect(inst.open()).toBe(false);
77
+ host.open.set(true);
78
+ await waitForUpdate();
79
+ expect(content()!.classList).toContain('flex-col');
80
+ expect(content()!.classList).toContain('pt-1');
33
81
  });
34
82
 
35
- it('toggle() is a no-op when disabled', async () => {
36
- const { componentRef } = await render(UiCollapsible);
37
- const inst = componentRef.instance as UiCollapsible;
38
- setInputSignal(inst.disabled, true);
39
- inst.toggle();
40
- expect(inst.open()).toBe(false);
41
- });
42
- });
83
+ it('opens and closes when the trigger is tapped', async () => {
84
+ const { host, trigger, content } = await renderCollapsible();
85
+ // Closed, the content isn't mounted at all.
86
+ expect(content()).toBeNull();
43
87
 
44
- describe('UiCollapsibleTrigger', () => {
45
- it('onTap delegates to the parent collapsible.toggle()', async () => {
46
- const parentResult = await render(UiCollapsible);
47
- const parent = parentResult.componentRef.instance as UiCollapsible;
88
+ fireEvent.tap(trigger());
89
+ await waitForUpdate();
90
+ expect(content()).not.toBeNull();
91
+ expect(host.open()).toBe(true);
48
92
 
49
- const triggerResult = await render(UiCollapsibleTrigger, {
50
- providers: [{ provide: UiCollapsible, useValue: parent }],
51
- });
52
- const trigger = triggerResult.componentRef.instance as UiCollapsibleTrigger;
53
- (trigger as unknown as { onTap: () => void }).onTap();
54
- expect(parent.open()).toBe(true);
93
+ fireEvent.tap(trigger());
94
+ await waitForUpdate();
95
+ expect(content()).toBeNull();
96
+ expect(host.open()).toBe(false);
55
97
  });
56
98
 
57
- it('applies disabled dim class when parent is disabled', async () => {
58
- const parentResult = await render(UiCollapsible);
59
- const parent = parentResult.componentRef.instance as UiCollapsible;
60
- setInputSignal(parent.disabled, true);
61
-
62
- const triggerResult = await render(UiCollapsibleTrigger, {
63
- providers: [{ provide: UiCollapsible, useValue: parent }],
64
- });
99
+ it('opens when its `open` is set', async () => {
100
+ const { host, content } = await renderCollapsible();
101
+ host.open.set(true);
65
102
  await waitForUpdate();
66
- const view = triggerResult.container.querySelector('view');
67
- expect(view?.getAttribute('class')).toContain('opacity-50');
103
+ expect(content()).not.toBeNull();
68
104
  });
69
- });
70
105
 
71
- describe('UiCollapsibleContent', () => {
72
- it('runs the reveal animation on open via setTimeout', async () => {
73
- const parentResult = await render(UiCollapsible);
74
- const parent = parentResult.componentRef.instance as UiCollapsible;
106
+ it('dims its trigger and ignores taps while disabled', async () => {
107
+ const { host, trigger, content } = await renderCollapsible();
108
+ expect(trigger().classList).not.toContain('opacity-50');
75
109
 
76
- const contentResult = await render(UiCollapsibleContent, {
77
- providers: [{ provide: UiCollapsible, useValue: parent }],
78
- });
79
- const content = contentResult.componentRef.instance as UiCollapsibleContent;
80
- // JIT + jsdom doesn't populate the `#content` viewChild — patch it with a
81
- // signal returning the outer view so the setTimeout body's `el` check
82
- // passes and revealIn actually runs.
83
- const view = contentResult.container.querySelector('view') as Element;
84
- (content as unknown as { contentRef: unknown }).contentRef = signal({
85
- nativeElement: view,
86
- });
87
-
88
- parent.open.set(true);
110
+ host.disabled.set(true);
89
111
  await waitForUpdate();
90
- await waitForUpdate(); // wait for the effect's setTimeout(_, 0) to fire
112
+ expect(trigger().classList).toContain('opacity-50');
91
113
 
92
- // Toggle off then on again to hit the cancel path with `#anim` already set.
93
- parent.open.set(false);
94
- await waitForUpdate();
95
- parent.open.set(true);
96
- await waitForUpdate();
114
+ fireEvent.tap(trigger());
97
115
  await waitForUpdate();
98
- expect(parent.open()).toBe(true);
116
+ expect(content()).toBeNull();
117
+ expect(host.open()).toBe(false);
99
118
  });
100
119
 
101
- it('bails out inside setTimeout when contentRef is undefined', async () => {
102
- const parentResult = await render(UiCollapsible);
103
- const parent = parentResult.componentRef.instance as UiCollapsible;
104
- await render(UiCollapsibleContent, {
105
- providers: [{ provide: UiCollapsible, useValue: parent }],
120
+ describe('reveal animation', () => {
121
+ // Watched per test: the testing library resets the main thread after each.
122
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
123
+ beforeEach(() => {
124
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
125
+ });
126
+ afterEach(() => animate.mockRestore());
127
+
128
+ /**
129
+ * The keys of the animations started on an element.
130
+ */
131
+ const startedOn = (element: Element | null) =>
132
+ animate.mock.calls
133
+ .filter(
134
+ ([target, [operation]]) => target === element && operation === START,
135
+ )
136
+ .map(([, [, key]]) => key);
137
+
138
+ it('slides the content down into place each time it opens', async () => {
139
+ const { trigger, content } = await renderCollapsible();
140
+
141
+ fireEvent.tap(trigger());
142
+ // The first update runs the effect, which waits a macrotask so the
143
+ // content is committed to Lynx's tree; the second lets that run.
144
+ await waitForUpdate();
145
+ await waitForUpdate();
146
+ expect(animate).toHaveBeenCalledWith(content(), [
147
+ START,
148
+ expect.any(String),
149
+ [
150
+ { transform: 'translateY(-8px)', opacity: 0 },
151
+ { transform: 'translateY(0)', opacity: 1 },
152
+ ],
153
+ // No fill: once it ends, the content's inline resting style applies.
154
+ expect.objectContaining({ fill: 'none' }),
155
+ ]);
156
+ const [firstKey] = startedOn(content());
157
+
158
+ fireEvent.tap(trigger());
159
+ await waitForUpdate();
160
+ fireEvent.tap(trigger());
161
+ await waitForUpdate();
162
+ await waitForUpdate();
163
+ // Reopening stops the previous reveal before it plays a new one on the
164
+ // content's new view.
165
+ expect(animate).toHaveBeenCalledWith(expect.anything(), [
166
+ CANCEL,
167
+ firstKey,
168
+ ]);
169
+ expect(startedOn(content())).toHaveLength(1);
170
+ });
171
+
172
+ it("doesn't animate content that closed before its reveal began", async () => {
173
+ const { componentRef, trigger, content } = await renderCollapsible();
174
+
175
+ fireEvent.tap(trigger());
176
+ // The effect has scheduled the reveal for the next macrotask.
177
+ await waitForUpdate();
178
+ // Close it before then, and render that straight away.
179
+ fireEvent.tap(trigger());
180
+ componentRef.changeDetectorRef.detectChanges();
181
+ expect(content()).toBeNull();
182
+
183
+ await waitForUpdate();
184
+ expect(
185
+ animate.mock.calls.filter(([, [operation]]) => operation === START),
186
+ ).toEqual([]);
106
187
  });
107
- parent.open.set(true);
108
- await waitForUpdate();
109
- await waitForUpdate();
110
- expect(parent.open()).toBe(true);
111
188
  });
112
189
  });
@@ -101,20 +101,25 @@ export class UiCollapsibleContent {
101
101
  readonly contentRef = viewChild<ElementRef>('content');
102
102
  #anim?: AnimationHandle;
103
103
 
104
- constructor() {
105
- // Animate the reveal whenever the panel opens. contentRef only resolves while
106
- // the `@if` content is mounted; defer one macrotask so the freshly (re)created
107
- // element is committed to the native tree before we animate it.
108
- effect(() => {
109
- if (!this.collapsible.open()) return;
110
- setTimeout(() => {
111
- const el = this.contentRef()?.nativeElement;
112
- if (!el) return;
113
- this.#anim?.cancel();
114
- this.#anim = revealIn(el, { fromY: -8, fill: 'none' });
115
- }, 0);
116
- });
117
- }
104
+ /**
105
+ * Animates the reveal whenever the panel opens. A field rather than a
106
+ * constructor: Angular's test build keeps a function for a constructor that
107
+ * nothing calls, which an app's coverage report would show as uncovered.
108
+ *
109
+ * contentRef only resolves while the `@if` content is mounted; defer one
110
+ * macrotask so the freshly (re)created element is committed to the native
111
+ * tree before we animate it. By then the panel may have closed again, which
112
+ * leaves nothing to animate.
113
+ */
114
+ protected readonly revealing = effect(() => {
115
+ if (!this.collapsible.open()) return;
116
+ setTimeout(() => {
117
+ const el = this.contentRef()?.nativeElement;
118
+ if (!el) return;
119
+ this.#anim?.cancel();
120
+ this.#anim = revealIn(el, { fromY: -8, fill: 'none' });
121
+ }, 0);
122
+ });
118
123
 
119
124
  protected readonly contentClass = computed(() =>
120
125
  cn('flex-col flex', this.userClass()),
@@ -0,0 +1,224 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ waitForUpdate,
6
+ within,
7
+ } from '@blotch/angular-lynx-testing-library';
8
+ import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import { UiMenuItem, UiMenuSeparator } from '../menu/menu';
11
+ import { UiContextMenu } from './context-menu';
12
+
13
+ @Component({
14
+ imports: [LYNX_ELEMENTS, UiContextMenu, UiMenuItem, UiMenuSeparator],
15
+ template: `
16
+ <ui-context-menu
17
+ [open]="open()"
18
+ (openChange)="open.set($event)"
19
+ [disabled]="disabled()"
20
+ (closed)="closed()"
21
+ >
22
+ <view class="card" (bindtap)="opened()">
23
+ <text>Kyoto</text>
24
+ </view>
25
+ <ui-menu-item label="Share" icon="upload" (pressed)="shared()" />
26
+ <ui-menu-separator />
27
+ <ui-menu-item
28
+ label="Delete"
29
+ icon="trash"
30
+ variant="destructive"
31
+ (pressed)="deleted()"
32
+ />
33
+ </ui-context-menu>
34
+ `,
35
+ })
36
+ class ContextMenuHost {
37
+ readonly open = signal(false);
38
+ readonly disabled = signal(false);
39
+ readonly closed = vi.fn();
40
+ readonly opened = vi.fn();
41
+ readonly shared = vi.fn();
42
+ readonly deleted = vi.fn();
43
+ }
44
+
45
+ /**
46
+ * The Lynx function that runs an element's UI methods, such as measuring it
47
+ * (`boundingClientRect`). The testing library defines it on the global
48
+ * object.
49
+ */
50
+ type InvokeUIMethod = {
51
+ __InvokeUIMethod: (
52
+ element: Element,
53
+ method: string,
54
+ params: Record<string, unknown>,
55
+ callback: (result: { code: number; data?: unknown }) => void,
56
+ ) => void;
57
+ };
58
+
59
+ /** Accessibility settings a test can change, as the host app would. */
60
+ const settings = {
61
+ reduceMotion: signal(false),
62
+ reduceTransparency: signal(false),
63
+ increaseContrast: signal(false),
64
+ };
65
+
66
+ const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
67
+
68
+ /**
69
+ * Renders the context menu on a platform (none for Material), measuring
70
+ * elements as `boundingClientRect` would on a phone: the safe area (the
71
+ * element that holds the menu) a phone's screen, the menu's content 120pt
72
+ * tall, and anything else, the pressed element, a card near the top.
73
+ */
74
+ const renderContextMenu = async (
75
+ platform: { platform: string; osVersion: string } | null = IOS_26,
76
+ ) => {
77
+ vi.stubGlobal('SystemInfo', platform ?? undefined);
78
+ vi.spyOn(
79
+ globalThis as unknown as InvokeUIMethod,
80
+ '__InvokeUIMethod',
81
+ ).mockImplementation((element, method, _params, callback) => {
82
+ if (method !== 'boundingClientRect') {
83
+ callback({ code: 0 });
84
+ return;
85
+ }
86
+ const data = element.querySelector(':scope > blur-view[ui-glass]')
87
+ ? { left: 0, top: 62, width: 402, height: 778 }
88
+ : element.parentElement?.tagName.toLowerCase() === 'scroll-view'
89
+ ? { left: 0, top: 0, width: 250, height: 120 }
90
+ : { left: 16, top: 162, width: 370, height: 96 };
91
+ callback({ code: 0, data });
92
+ });
93
+ const result = await render(ContextMenuHost, {
94
+ providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
95
+ });
96
+ const host = result.componentRef.instance as ContextMenuHost;
97
+ const overlay = result.container.querySelector('overlay')!;
98
+ return {
99
+ ...result,
100
+ host,
101
+ overlay,
102
+ menu: within(overlay as HTMLElement),
103
+ card: () => result.getByText('Kyoto').parentElement!,
104
+ /** The element the card is in: its home, or the overlay's slot. */
105
+ holder: () =>
106
+ result.getByText('Kyoto').parentElement!.parentElement!.parentElement!,
107
+ panel: () => overlay.querySelector('blur-view[ui-glass]')!,
108
+ };
109
+ };
110
+
111
+ type Rendered = Awaited<ReturnType<typeof renderContextMenu>>;
112
+
113
+ /**
114
+ * Waits until the menu has opened.
115
+ */
116
+ const waitForOpen = (rendered: Rendered) =>
117
+ vi.waitFor(() =>
118
+ expect(rendered.panel().getAttribute('accessibility-exclusive-focus')).toBe(
119
+ 'true',
120
+ ),
121
+ );
122
+
123
+ /**
124
+ * Waits until the menu has closed and its animation has finished.
125
+ */
126
+ const waitForClose = async (rendered: Rendered) => {
127
+ await vi.waitFor(() => expect(rendered.host.closed).toHaveBeenCalled());
128
+ await waitForUpdate();
129
+ };
130
+
131
+ /**
132
+ * Long-presses an element. The press bubbles, as Lynx's touch events do, to
133
+ * the context menu around the element.
134
+ */
135
+ const longPress = (element: Element) =>
136
+ fireEvent.longpress(element, { bubbles: true });
137
+
138
+ const style = (element: Element, name: string) =>
139
+ (element as HTMLElement).style.getPropertyValue(name);
140
+
141
+ // The renderer warns that the components' selectors aren't Lynx elements.
142
+ beforeEach(() => {
143
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
144
+ });
145
+
146
+ afterEach(() => {
147
+ vi.restoreAllMocks();
148
+ vi.unstubAllGlobals();
149
+ });
150
+
151
+ describe('UiContextMenu', () => {
152
+ it('opens on a long press, lifting the element above the dimmed screen', async () => {
153
+ const rendered = await renderContextMenu();
154
+ const home = rendered.holder();
155
+
156
+ longPress(rendered.card());
157
+ await waitForOpen(rendered);
158
+
159
+ expect(rendered.host.open()).toBe(true);
160
+ // The element itself is in the menu's overlay now, lifted, and the spot
161
+ // it left keeps its size.
162
+ expect(rendered.overlay.contains(rendered.card())).toBe(true);
163
+ expect(style(rendered.holder(), 'transform')).toBe('scale(1.03)');
164
+ expect(style(home, 'width')).toBe('370px');
165
+ expect(style(home, 'height')).toBe('96px');
166
+ expect(rendered.overlay.querySelector('.bg-glass-dim')).not.toBeNull();
167
+ // The menu opens below it.
168
+ expect(style(rendered.panel(), 'top')).toBe(`${100 + 96 + 8}px`);
169
+ });
170
+
171
+ it('runs the chosen item and closes, putting the element back', async () => {
172
+ const rendered = await renderContextMenu();
173
+ longPress(rendered.card());
174
+ await waitForOpen(rendered);
175
+
176
+ fireEvent.tap(
177
+ rendered.menu.getByRole('button', { name: 'Delete, destructive' }),
178
+ );
179
+
180
+ expect(rendered.host.deleted).toHaveBeenCalledTimes(1);
181
+ expect(rendered.host.open()).toBe(false);
182
+ await waitForClose(rendered);
183
+ expect(rendered.overlay.contains(rendered.card())).toBe(false);
184
+ expect(style(rendered.holder(), 'height')).toBe('');
185
+ // A tap on the element still reaches it.
186
+ fireEvent.tap(rendered.card());
187
+ expect(rendered.host.opened).toHaveBeenCalledTimes(1);
188
+ });
189
+
190
+ it('closes on a tap outside the menu', async () => {
191
+ const rendered = await renderContextMenu();
192
+ rendered.host.open.set(true);
193
+ await waitForOpen(rendered);
194
+
195
+ fireEvent.tap(rendered.overlay.firstElementChild!);
196
+
197
+ expect(rendered.host.open()).toBe(false);
198
+ await waitForClose(rendered);
199
+ });
200
+
201
+ it("doesn't open on a long press when disabled", async () => {
202
+ const rendered = await renderContextMenu();
203
+ rendered.host.disabled.set(true);
204
+ await waitForUpdate();
205
+
206
+ longPress(rendered.card());
207
+ await waitForUpdate();
208
+
209
+ expect(rendered.host.open()).toBe(false);
210
+ });
211
+
212
+ it('opens a Material menu beside the element on Android, leaving it in place', async () => {
213
+ const rendered = await renderContextMenu(null);
214
+ const home = rendered.holder();
215
+
216
+ longPress(rendered.card());
217
+ await waitForOpen(rendered);
218
+
219
+ expect(home.contains(rendered.card())).toBe(true);
220
+ expect(style(home, 'height')).toBe('');
221
+ expect(rendered.overlay.querySelector('.bg-glass-dim')).toBeNull();
222
+ expect(rendered.menu.getByRole('button', { name: 'Share' })).toBeTruthy();
223
+ });
224
+ });
@@ -0,0 +1,110 @@
1
+ import {
2
+ Component,
3
+ type ElementRef,
4
+ ViewEncapsulation,
5
+ booleanAttribute,
6
+ inject,
7
+ input,
8
+ model,
9
+ output,
10
+ viewChild,
11
+ } from '@angular/core';
12
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
13
+
14
+ import { type MenuElement, UiMenuPopup } from '../menu/menu-popup';
15
+ import { UI_MENU, type UiMenuHost } from '../menu/menu';
16
+
17
+ /**
18
+ * The `ui-context-menu` an item is in, for `UI_MENU`. A named function rather
19
+ * than an inline factory: the component's metadata keeps its own copy of an
20
+ * inline one, which nothing calls, so an app's coverage report would show it
21
+ * as uncovered.
22
+ */
23
+ const contextMenu = (): UiMenuHost => inject(UiContextMenu);
24
+
25
+ /**
26
+ * A context menu: a long press on an element lifts it and opens a menu of
27
+ * actions beside it, while the rest of the screen dims.
28
+ *
29
+ * ```html
30
+ * <ui-context-menu>
31
+ * <view class="rounded-xl bg-card p-4">
32
+ * <text class="text-foreground">Trip to Kyoto</text>
33
+ * </view>
34
+ * <ui-menu-item label="Share" icon="upload" (pressed)="share()" />
35
+ * <ui-menu-item label="Favorite" icon="heart" (pressed)="favorite()" />
36
+ * <ui-menu-separator />
37
+ * <ui-menu-item label="Delete" icon="trash" variant="destructive" (pressed)="remove()" />
38
+ * </ui-context-menu>
39
+ * ```
40
+ *
41
+ * The items (`ui-menu-item`, `ui-menu-section`, `ui-menu-separator`) are the
42
+ * menu, and everything else is the element it opens from. Taps on the
43
+ * element still reach it: Lynx tells a tap from a long press. A tap outside
44
+ * the menu, choosing an item, or Android's back button closes it, and
45
+ * `[(open)]` opens and closes it from code.
46
+ *
47
+ * On iOS the element lifts above a dimming layer (a blur before iOS 26) and
48
+ * the menu, Liquid Glass from iOS 26, opens below it, or above it near the
49
+ * bottom of the screen; when it fits on neither side, the element moves up
50
+ * to make room. The element itself moves into the menu's overlay, so it
51
+ * stays live and looks exactly as it did. On Android it's a Material 3 menu
52
+ * beside the element, without the lift or the dimming, unless the app opts
53
+ * into glass with `provideGlass({ android: 'glass' })`.
54
+ *
55
+ * Apple asks for a context menu's first actions to match the swipe actions
56
+ * of the row it's on, when there are any, so the two ways to reach an
57
+ * action agree.
58
+ */
59
+ @Component({
60
+ selector: 'ui-context-menu',
61
+ imports: [LYNX_ELEMENTS, UiMenuPopup],
62
+ encapsulation: ViewEncapsulation.None,
63
+ providers: [{ provide: UI_MENU, useFactory: contextMenu }],
64
+ template: `
65
+ <!-- Keeps the element's size while it's lifted into the overlay, so the
66
+ page doesn't move under the menu. -->
67
+ <view
68
+ #home
69
+ class="flex-col flex"
70
+ [style.width.px]="popup.placeholder()?.width"
71
+ [style.height.px]="popup.placeholder()?.height"
72
+ >
73
+ <view #element class="flex-col flex" (bindlongpress)="onLongPress()">
74
+ <ng-content />
75
+ </view>
76
+ </view>
77
+ <ui-menu-popup
78
+ #popup
79
+ kind="context"
80
+ [open]="open()"
81
+ [anchor]="elementView().nativeElement"
82
+ [anchorHome]="homeView().nativeElement"
83
+ (dismissed)="close()"
84
+ (closed)="closed.emit()"
85
+ >
86
+ <ng-content select="ui-menu-item, ui-menu-section, ui-menu-separator" />
87
+ </ui-menu-popup>
88
+ `,
89
+ })
90
+ export class UiContextMenu implements UiMenuHost {
91
+ /** Whether the menu is open. */
92
+ readonly open = model(false);
93
+ /** Turns the long press off. */
94
+ readonly disabled = input(false, { transform: booleanAttribute });
95
+
96
+ /** The menu has closed and its close animation has finished. */
97
+ readonly closed = output<void>();
98
+
99
+ // Required: both are always in the template.
100
+ readonly elementView = viewChild.required<ElementRef<MenuElement>>('element');
101
+ readonly homeView = viewChild.required<ElementRef<MenuElement>>('home');
102
+
103
+ protected onLongPress(): void {
104
+ if (!this.disabled()) this.open.set(true);
105
+ }
106
+
107
+ close(): void {
108
+ this.open.set(false);
109
+ }
110
+ }
@@ -0,0 +1 @@
1
+ export { UiContextMenu } from './context-menu';