@astryxdesign/core 0.5.2-canary.c9c8564 → 0.5.2-canary.e4f8e4e
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.
- package/dist/BottomSheet/BottomSheet.d.ts +3 -1
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +4 -3
- package/dist/BottomSheet/BottomSheetEdgeTint.d.ts +2 -2
- package/dist/BottomSheet/BottomSheetEdgeTint.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetEdgeTint.js +7 -10
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetSwitcher.js +1 -1
- package/dist/Button/Button.d.ts +2 -1
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +11 -4
- package/dist/Carousel/Carousel.d.ts.map +1 -1
- package/dist/Carousel/Carousel.js +2 -2
- package/dist/Chat/ChatToolCalls.d.ts.map +1 -1
- package/dist/Chat/ChatToolCalls.js +34 -17
- package/dist/ContextMenu/ContextMenu.d.ts +14 -3
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +149 -26
- package/dist/ContextMenu/index.d.ts +1 -0
- package/dist/ContextMenu/index.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.d.ts +24 -5
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +340 -26
- package/dist/DropdownMenu/DropdownMenuContext.d.ts +1 -1
- package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuItem.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuItem.js +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +6 -2
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +43 -14
- package/dist/DropdownMenu/MenuBottomSheet.d.ts +20 -0
- package/dist/DropdownMenu/MenuBottomSheet.d.ts.map +1 -0
- package/dist/DropdownMenu/MenuBottomSheet.js +36 -0
- package/dist/DropdownMenu/MenuBottomSheetActionList.d.ts +10 -0
- package/dist/DropdownMenu/MenuBottomSheetActionList.d.ts.map +1 -0
- package/dist/DropdownMenu/MenuBottomSheetActionList.js +111 -0
- package/dist/DropdownMenu/index.d.ts +2 -1
- package/dist/DropdownMenu/index.d.ts.map +1 -1
- package/dist/DropdownMenu/menuWidth.d.ts +14 -0
- package/dist/DropdownMenu/menuWidth.d.ts.map +1 -0
- package/dist/DropdownMenu/menuWidth.js +35 -0
- package/dist/DropdownMenu/useMenuOverflow.d.ts +10 -0
- package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -0
- package/dist/DropdownMenu/useMenuOverflow.js +60 -0
- package/dist/MoreMenu/MoreMenu.d.ts +8 -2
- package/dist/MoreMenu/MoreMenu.d.ts.map +1 -1
- package/dist/MoreMenu/MoreMenu.js +2 -0
- package/dist/MultiSelector/MultiSelector.d.ts +11 -1
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +97 -68
- package/dist/MultiSelector/index.d.ts +1 -1
- package/dist/MultiSelector/index.d.ts.map +1 -1
- package/dist/Popover/Popover.d.ts +10 -6
- package/dist/Popover/Popover.d.ts.map +1 -1
- package/dist/Popover/Popover.js +141 -16
- package/dist/Popover/usePopover.d.ts +4 -0
- package/dist/Popover/usePopover.d.ts.map +1 -1
- package/dist/Popover/usePopover.js +52 -5
- package/dist/Selector/Selector.d.ts +10 -0
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +106 -80
- package/dist/Selector/SelectorBottomSheet.d.ts +21 -0
- package/dist/Selector/SelectorBottomSheet.d.ts.map +1 -0
- package/dist/Selector/SelectorBottomSheet.js +83 -0
- package/dist/Selector/index.d.ts +1 -1
- package/dist/Selector/index.d.ts.map +1 -1
- package/dist/Selector/selectorPresentation.stylex.d.ts +16 -0
- package/dist/Selector/selectorPresentation.stylex.d.ts.map +1 -0
- package/dist/Selector/selectorPresentation.stylex.js +21 -0
- package/dist/Selector/useSelectorPresentation.d.ts +32 -0
- package/dist/Selector/useSelectorPresentation.d.ts.map +1 -0
- package/dist/Selector/useSelectorPresentation.js +90 -0
- package/dist/Toast/useToastGesture.d.ts.map +1 -1
- package/dist/Toast/useToastGesture.js +46 -9
- package/dist/astryx.css +19 -1
- package/dist/hooks/useAdaptivePresentation.d.ts +5 -0
- package/dist/hooks/useAdaptivePresentation.d.ts.map +1 -0
- package/dist/hooks/useAdaptivePresentation.js +19 -0
- package/dist/hooks/useFocusReturnVisibility.d.ts +7 -0
- package/dist/hooks/useFocusReturnVisibility.d.ts.map +1 -0
- package/dist/hooks/useFocusReturnVisibility.js +35 -0
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +12 -0
- package/locales/en.json +20 -0
- package/locales/pseudo.json +15 -0
- package/package.json +2 -2
- package/src/BottomSheet/BottomSheet.doc.mjs +6 -0
- package/src/BottomSheet/BottomSheet.test.tsx +29 -0
- package/src/BottomSheet/BottomSheet.tsx +7 -4
- package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +4 -6
- package/src/BottomSheet/BottomSheetEdgeTint.tsx +7 -10
- package/src/BottomSheet/BottomSheetSwitcher.tsx +1 -2
- package/src/Button/Button.doc.mjs +56 -0
- package/src/Button/Button.test.tsx +10 -0
- package/src/Button/Button.tsx +13 -5
- package/src/ButtonGroup/ButtonGroup.doc.mjs +47 -0
- package/src/Carousel/Carousel.test.tsx +83 -0
- package/src/Carousel/Carousel.tsx +8 -2
- package/src/Chat/ChatToolCalls.test.tsx +44 -0
- package/src/Chat/ChatToolCalls.tsx +36 -15
- package/src/ContextMenu/ContextMenu.doc.mjs +13 -1
- package/src/ContextMenu/ContextMenu.test.tsx +131 -1
- package/src/ContextMenu/ContextMenu.tsx +199 -32
- package/src/ContextMenu/index.ts +1 -0
- package/src/DropdownMenu/DropdownMenu.doc.mjs +201 -40
- package/src/DropdownMenu/DropdownMenu.test.tsx +518 -7
- package/src/DropdownMenu/DropdownMenu.tsx +459 -28
- package/src/DropdownMenu/DropdownMenuContext.tsx +1 -1
- package/src/DropdownMenu/DropdownMenuItem.tsx +2 -0
- package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +4 -2
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +124 -0
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +72 -17
- package/src/DropdownMenu/MenuBottomSheet.tsx +46 -0
- package/src/DropdownMenu/MenuBottomSheetActionList.tsx +141 -0
- package/src/DropdownMenu/index.ts +2 -0
- package/src/DropdownMenu/menuWidth.ts +51 -0
- package/src/DropdownMenu/useMenuOverflow.ts +77 -0
- package/src/IconButton/IconButton.doc.mjs +38 -0
- package/src/MoreMenu/MoreMenu.doc.mjs +17 -0
- package/src/MoreMenu/MoreMenu.test.tsx +47 -0
- package/src/MoreMenu/MoreMenu.tsx +13 -1
- package/src/MultiSelector/MultiSelector.doc.mjs +16 -0
- package/src/MultiSelector/MultiSelector.test.tsx +127 -0
- package/src/MultiSelector/MultiSelector.tsx +121 -73
- package/src/MultiSelector/index.ts +1 -0
- package/src/Popover/Popover.doc.mjs +11 -11
- package/src/Popover/Popover.test.tsx +397 -2
- package/src/Popover/Popover.tsx +228 -21
- package/src/Popover/usePopover.tsx +92 -15
- package/src/ProgressBar/ProgressBar.spec.md +208 -0
- package/src/SegmentedControl/SegmentedControl.doc.mjs +56 -0
- package/src/Selector/Selector.doc.mjs +12 -0
- package/src/Selector/Selector.test.tsx +135 -0
- package/src/Selector/Selector.tsx +141 -82
- package/src/Selector/SelectorBottomSheet.tsx +97 -0
- package/src/Selector/index.ts +1 -0
- package/src/Selector/selectorPresentation.stylex.ts +20 -0
- package/src/Selector/useSelectorPresentation.ts +147 -0
- package/src/Toast/ToastViewport.test.tsx +118 -4
- package/src/Toast/useToastGesture.ts +61 -8
- package/src/ToggleButton/ToggleButton.doc.mjs +56 -0
- package/src/hooks/useAdaptivePresentation.ts +35 -0
- package/src/hooks/useFocusReturnVisibility.ts +45 -0
- package/src/hooks/useFocusTrap.test.tsx +40 -0
- package/src/hooks/useFocusTrap.ts +13 -0
- package/src/theme/themingTargets.test.ts +91 -25
|
@@ -32,7 +32,7 @@ export const docs = {
|
|
|
32
32
|
{
|
|
33
33
|
name: 'items',
|
|
34
34
|
type: 'ContextMenuOption[]',
|
|
35
|
-
description: 'Array of menu entries. Each entry is one of: an action item `{label, onClick?, icon?, isDisabled?, variant?}` (variant `"destructive"` renders it in the error color), a divider `{type: "divider"}`, or a section `{type: "section", title?, items: [...action items]}`.',
|
|
35
|
+
description: 'Array of menu entries. Each entry is one of: an action item `{label, onClick?, icon?, isDisabled?, variant?, items?}` (nested `items` open a flyout in popover presentation and drill into a new view in bottom-sheet presentation; variant `"destructive"` renders it in the error color), a divider `{type: "divider"}`, or a section `{type: "section", title?, items: [...action items]}`.',
|
|
36
36
|
required: true,
|
|
37
37
|
},
|
|
38
38
|
{
|
|
@@ -63,6 +63,12 @@ export const docs = {
|
|
|
63
63
|
type: '(isOpen: boolean) => void',
|
|
64
64
|
description: 'Callback fired when the menu opens or closes.',
|
|
65
65
|
},
|
|
66
|
+
{
|
|
67
|
+
name: 'presentation',
|
|
68
|
+
type: "'popover' | 'bottom-sheet' | 'adaptive'",
|
|
69
|
+
description: 'Presentation policy. `popover` opens at the pointer position, `bottom-sheet` always uses an action sheet, and `adaptive` uses the BottomSheet at 768px and below when the primary pointer is coarse.',
|
|
70
|
+
default: "'popover'",
|
|
71
|
+
},
|
|
66
72
|
{
|
|
67
73
|
name: 'isDisabled',
|
|
68
74
|
type: 'boolean',
|
|
@@ -78,7 +84,9 @@ export const docs = {
|
|
|
78
84
|
bestPractices: [
|
|
79
85
|
{ guidance: true, description: 'Keep menu items concise and action-oriented; users expect quick access to contextual actions.' },
|
|
80
86
|
{ guidance: true, description: 'Use sections and dividers to group related actions when the menu has many items.' },
|
|
87
|
+
{ guidance: true, description: 'Use `presentation="adaptive"` when right-click should remain cursor-positioned on desktop while long-press opens a reachable BottomSheet on compact touch devices.' },
|
|
81
88
|
{ guidance: true, description: 'Ensure all context menu actions are also accessible via other UI elements for keyboard-only users.' },
|
|
89
|
+
{ guidance: true, description: 'Keep a visible MoreMenu or equivalent trigger for important mobile actions; long-press must not be the only route.' },
|
|
82
90
|
{ guidance: false, description: 'Use a ContextMenu as the only way to access important actions; not all users know to right-click.' },
|
|
83
91
|
{ guidance: false, description: 'Place more than 10–12 items in a single menu without grouping them into sections.' },
|
|
84
92
|
],
|
|
@@ -107,7 +115,9 @@ export const docsZh = {
|
|
|
107
115
|
bestPractices: [
|
|
108
116
|
{ guidance: true, description: '保持菜单项简洁和面向操作。' },
|
|
109
117
|
{ guidance: true, description: '有很多项时使用分组和分隔线。' },
|
|
118
|
+
{ guidance: true, description: '在桌面端需要光标定位、紧凑触控设备上需要 BottomSheet 时,使用 `presentation="adaptive"`。' },
|
|
110
119
|
{ guidance: true, description: '确保所有上下文菜单操作也可通过其他 UI 元素访问。' },
|
|
120
|
+
{ guidance: true, description: '重要的移动端操作还应提供可见的 MoreMenu 或同等入口,不要只依赖长按。' },
|
|
111
121
|
{ guidance: false, description: '将上下文菜单作为访问重要操作的唯一方式。' },
|
|
112
122
|
{ guidance: false, description: '在单个菜单中放置超过 10-12 个项而不分组。' },
|
|
113
123
|
],
|
|
@@ -122,7 +132,9 @@ export const docsDense = {
|
|
|
122
132
|
bestPractices: [
|
|
123
133
|
{ guidance: true, description: 'Keep items concise and action-oriented.' },
|
|
124
134
|
{ guidance: true, description: 'Group related actions with sections and dividers.' },
|
|
135
|
+
{ guidance: true, description: 'Use adaptive presentation for cursor popover on desktop and BottomSheet on compact touch.' },
|
|
125
136
|
{ guidance: true, description: 'Ensure actions are also accessible via other UI elements.' },
|
|
137
|
+
{ guidance: true, description: 'Keep a visible mobile entry point for important actions.' },
|
|
126
138
|
{ guidance: false, description: 'Use as the only way to access important actions.' },
|
|
127
139
|
{ guidance: false, description: 'Place more than 10–12 items without grouping.' },
|
|
128
140
|
],
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
12
|
-
import {render, screen, fireEvent, act} from '@testing-library/react';
|
|
12
|
+
import {render, screen, fireEvent, act, waitFor} from '@testing-library/react';
|
|
13
13
|
import userEvent from '@testing-library/user-event';
|
|
14
|
+
import {readFileSync} from 'node:fs';
|
|
14
15
|
import {ContextMenu} from './ContextMenu';
|
|
15
16
|
import {
|
|
16
17
|
ContextMenuItem,
|
|
@@ -23,6 +24,30 @@ import {DropdownMenuItem} from '../DropdownMenu/DropdownMenuItem';
|
|
|
23
24
|
import {Divider} from '../Divider';
|
|
24
25
|
|
|
25
26
|
beforeEach(() => {
|
|
27
|
+
HTMLDialogElement.prototype.showModal = vi.fn(function (
|
|
28
|
+
this: HTMLDialogElement,
|
|
29
|
+
) {
|
|
30
|
+
this.setAttribute('open', '');
|
|
31
|
+
});
|
|
32
|
+
HTMLDialogElement.prototype.show = vi.fn(function (this: HTMLDialogElement) {
|
|
33
|
+
this.setAttribute('open', '');
|
|
34
|
+
});
|
|
35
|
+
HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
|
|
36
|
+
this.removeAttribute('open');
|
|
37
|
+
});
|
|
38
|
+
vi.stubGlobal(
|
|
39
|
+
'matchMedia',
|
|
40
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
41
|
+
matches: false,
|
|
42
|
+
media: query,
|
|
43
|
+
onchange: null,
|
|
44
|
+
addEventListener: vi.fn(),
|
|
45
|
+
removeEventListener: vi.fn(),
|
|
46
|
+
addListener: vi.fn(),
|
|
47
|
+
removeListener: vi.fn(),
|
|
48
|
+
dispatchEvent: vi.fn(),
|
|
49
|
+
})),
|
|
50
|
+
);
|
|
26
51
|
HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) {
|
|
27
52
|
this.setAttribute('popover-open', '');
|
|
28
53
|
const event = new Event('toggle', {bubbles: false});
|
|
@@ -123,6 +148,100 @@ describe('ContextMenu', () => {
|
|
|
123
148
|
expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
|
|
124
149
|
});
|
|
125
150
|
|
|
151
|
+
it('opens the real menu content in a BottomSheet when requested', async () => {
|
|
152
|
+
render(
|
|
153
|
+
<ContextMenu
|
|
154
|
+
presentation="bottom-sheet"
|
|
155
|
+
label="Row actions"
|
|
156
|
+
items={[{label: 'Edit'}]}>
|
|
157
|
+
<div>Right-click me</div>
|
|
158
|
+
</ContextMenu>,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
fireEvent.contextMenu(screen.getByText('Right-click me'));
|
|
162
|
+
await waitFor(() =>
|
|
163
|
+
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledOnce(),
|
|
164
|
+
);
|
|
165
|
+
const dialog = screen.getByRole('dialog', {name: 'Row actions'});
|
|
166
|
+
const actionList = dialog.querySelector('[data-autofocus]');
|
|
167
|
+
expect(actionList).not.toBeNull();
|
|
168
|
+
await waitFor(() => expect(actionList).toHaveFocus());
|
|
169
|
+
expect(screen.getByRole('button', {name: 'Edit'})).not.toHaveFocus();
|
|
170
|
+
expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('drills into nested data items inside the BottomSheet', async () => {
|
|
174
|
+
const user = userEvent.setup();
|
|
175
|
+
const onMove = vi.fn();
|
|
176
|
+
|
|
177
|
+
render(
|
|
178
|
+
<ContextMenu
|
|
179
|
+
presentation="bottom-sheet"
|
|
180
|
+
label="Row actions"
|
|
181
|
+
items={[
|
|
182
|
+
{
|
|
183
|
+
label: 'Move to project',
|
|
184
|
+
items: [{label: 'Apollo launch', onClick: onMove}],
|
|
185
|
+
},
|
|
186
|
+
]}>
|
|
187
|
+
<div>Right-click me</div>
|
|
188
|
+
</ContextMenu>,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
fireEvent.contextMenu(screen.getByText('Right-click me'));
|
|
192
|
+
await user.click(screen.getByRole('button', {name: 'Move to project'}));
|
|
193
|
+
|
|
194
|
+
const submenuHeading = screen.getByRole('heading', {
|
|
195
|
+
name: 'Move to project',
|
|
196
|
+
});
|
|
197
|
+
expect(submenuHeading).toBeInTheDocument();
|
|
198
|
+
await waitFor(() => expect(submenuHeading).toHaveFocus());
|
|
199
|
+
expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled();
|
|
200
|
+
|
|
201
|
+
await user.click(screen.getByRole('button', {name: 'Apollo launch'}));
|
|
202
|
+
expect(onMove).toHaveBeenCalledOnce();
|
|
203
|
+
await waitFor(() =>
|
|
204
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('keeps only the top content padding in a menu BottomSheet', () => {
|
|
209
|
+
const source = readFileSync(
|
|
210
|
+
'packages/core/src/DropdownMenu/MenuBottomSheet.tsx',
|
|
211
|
+
'utf8',
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(source).toContain('paddingBlockStart={4}');
|
|
215
|
+
expect(source).toContain('paddingBlockEnd={0}');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('uses the BottomSheet for adaptive presentation on compact touch', async () => {
|
|
219
|
+
vi.stubGlobal(
|
|
220
|
+
'matchMedia',
|
|
221
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
222
|
+
matches: query === '(max-width: 768px) and (pointer: coarse)',
|
|
223
|
+
media: query,
|
|
224
|
+
onchange: null,
|
|
225
|
+
addEventListener: vi.fn(),
|
|
226
|
+
removeEventListener: vi.fn(),
|
|
227
|
+
addListener: vi.fn(),
|
|
228
|
+
removeListener: vi.fn(),
|
|
229
|
+
dispatchEvent: vi.fn(),
|
|
230
|
+
})),
|
|
231
|
+
);
|
|
232
|
+
render(
|
|
233
|
+
<ContextMenu presentation="adaptive" items={[{label: 'Edit'}]}>
|
|
234
|
+
<div>Right-click me</div>
|
|
235
|
+
</ContextMenu>,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
fireEvent.contextMenu(screen.getByText('Right-click me'));
|
|
239
|
+
await waitFor(() =>
|
|
240
|
+
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledOnce(),
|
|
241
|
+
);
|
|
242
|
+
expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled();
|
|
243
|
+
});
|
|
244
|
+
|
|
126
245
|
it('closes on Escape even when opened without auto-focus', () => {
|
|
127
246
|
render(
|
|
128
247
|
<ContextMenu items={[{label: 'Item 1'}]}>
|
|
@@ -273,6 +392,17 @@ describe('ContextMenu', () => {
|
|
|
273
392
|
}
|
|
274
393
|
});
|
|
275
394
|
|
|
395
|
+
it('suppresses native text selection on the long-press trigger', () => {
|
|
396
|
+
const source = readFileSync(
|
|
397
|
+
'packages/core/src/ContextMenu/ContextMenu.tsx',
|
|
398
|
+
'utf8',
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
expect(source).toMatch(
|
|
402
|
+
/trigger:[\s\S]*?WebkitTouchCallout: 'none',[\s\S]*?WebkitUserSelect: 'none',[\s\S]*?userSelect: 'none'/,
|
|
403
|
+
);
|
|
404
|
+
});
|
|
405
|
+
|
|
276
406
|
it('cancels the long-press when the finger moves past the threshold', () => {
|
|
277
407
|
vi.useFakeTimers();
|
|
278
408
|
try {
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* @file ContextMenu.tsx
|
|
6
|
-
* @input Uses React, StyleX, useLayer
|
|
7
|
-
*
|
|
6
|
+
* @input Uses React, StyleX, useLayer, BottomSheet, useListFocus, and the
|
|
7
|
+
* shared menu-presentation resolver
|
|
8
|
+
* @output Exports ContextMenu with cursor-popover and touch-sheet presentations
|
|
8
9
|
* @position Core implementation; consumed by index.ts
|
|
9
10
|
*
|
|
10
11
|
* Right-click context menu positioned at the cursor. The cursor point is
|
|
@@ -34,13 +35,19 @@ import React, {
|
|
|
34
35
|
useCallback,
|
|
35
36
|
useEffect,
|
|
36
37
|
useId,
|
|
38
|
+
lazy,
|
|
37
39
|
useMemo,
|
|
38
40
|
useRef,
|
|
39
41
|
useState,
|
|
42
|
+
Suspense,
|
|
40
43
|
} from 'react';
|
|
41
44
|
import type {ReactNode} from 'react';
|
|
42
45
|
import * as stylex from '@stylexjs/stylex';
|
|
46
|
+
import {Button} from '../Button';
|
|
47
|
+
import {Heading} from '../Heading';
|
|
48
|
+
import {Icon} from '../Icon';
|
|
43
49
|
import {useLayer} from '../Layer/useLayer';
|
|
50
|
+
import {MenuBottomSheetActionList} from '../DropdownMenu/MenuBottomSheetActionList';
|
|
44
51
|
import {renderDropdownItems} from '../DropdownMenu/renderDropdownItems';
|
|
45
52
|
import {
|
|
46
53
|
DropdownMenuContext,
|
|
@@ -63,7 +70,7 @@ import {
|
|
|
63
70
|
easeVars,
|
|
64
71
|
shadowVars,
|
|
65
72
|
} from '../theme/tokens.stylex';
|
|
66
|
-
import {mergeProps, isImeKeyEvent} from '../utils';
|
|
73
|
+
import {mergeProps, isImeKeyEvent, rtlStyles} from '../utils';
|
|
67
74
|
import type {BaseProps} from '../BaseProps';
|
|
68
75
|
import type {StyleXStyles} from '../theme/types';
|
|
69
76
|
import {themeProps} from '../utils/themeProps';
|
|
@@ -76,6 +83,16 @@ import type {
|
|
|
76
83
|
} from '../DropdownMenu/DropdownMenu';
|
|
77
84
|
|
|
78
85
|
import {useMergedRefs} from '../hooks/useMergedRefs';
|
|
86
|
+
import {
|
|
87
|
+
useAdaptivePresentation,
|
|
88
|
+
type AdaptivePresentation,
|
|
89
|
+
} from '../hooks/useAdaptivePresentation';
|
|
90
|
+
|
|
91
|
+
const LazyMenuBottomSheet = lazy(async () =>
|
|
92
|
+
import('../DropdownMenu/MenuBottomSheet').then(module => ({
|
|
93
|
+
default: module.MenuBottomSheet,
|
|
94
|
+
})),
|
|
95
|
+
);
|
|
79
96
|
const styles = stylex.create({
|
|
80
97
|
// Trigger wrapper: suppress the iOS long-press callout/selection so the
|
|
81
98
|
// long-press opens our context menu instead of the native text/callout UI.
|
|
@@ -85,6 +102,8 @@ const styles = stylex.create({
|
|
|
85
102
|
trigger: {
|
|
86
103
|
position: 'relative',
|
|
87
104
|
WebkitTouchCallout: 'none',
|
|
105
|
+
WebkitUserSelect: 'none',
|
|
106
|
+
userSelect: 'none',
|
|
88
107
|
},
|
|
89
108
|
// Zero-size anchor placed at the cursor point within the trigger. The menu
|
|
90
109
|
// is anchored to this element, so it sits under the cursor yet is positioned
|
|
@@ -121,6 +140,33 @@ const styles = stylex.create({
|
|
|
121
140
|
popoverCustomWidth: (width: string | number) => ({
|
|
122
141
|
minWidth: typeof width === 'number' ? `${width}px` : width,
|
|
123
142
|
}),
|
|
143
|
+
sheetMenu: {
|
|
144
|
+
boxSizing: 'border-box',
|
|
145
|
+
display: 'flex',
|
|
146
|
+
flexDirection: 'column',
|
|
147
|
+
gap: spacingVars['--spacing-0-5'],
|
|
148
|
+
width: '100%',
|
|
149
|
+
'--_dropdown-menu-radius': radiusVars['--radius-container'],
|
|
150
|
+
'--_dropdown-menu-padding': spacingVars['--spacing-1'],
|
|
151
|
+
padding: spacingVars['--spacing-1'],
|
|
152
|
+
borderRadius: 'var(--_dropdown-menu-radius)',
|
|
153
|
+
backgroundColor: colorVars['--color-background-surface'],
|
|
154
|
+
outline: 'none',
|
|
155
|
+
userSelect: 'none',
|
|
156
|
+
},
|
|
157
|
+
sheetHeader: {
|
|
158
|
+
display: 'flex',
|
|
159
|
+
flexDirection: 'row',
|
|
160
|
+
alignItems: 'center',
|
|
161
|
+
gap: spacingVars['--spacing-1'],
|
|
162
|
+
marginBottom: spacingVars['--spacing-2'],
|
|
163
|
+
},
|
|
164
|
+
sheetRootHeading: {
|
|
165
|
+
marginInlineStart: spacingVars['--spacing-3'],
|
|
166
|
+
},
|
|
167
|
+
sheetViewHeading: {
|
|
168
|
+
outline: 'none',
|
|
169
|
+
},
|
|
124
170
|
});
|
|
125
171
|
|
|
126
172
|
// =============================================================================
|
|
@@ -164,6 +210,15 @@ interface ContextMenuBaseProps extends BaseProps {
|
|
|
164
210
|
isDisabled?: boolean;
|
|
165
211
|
/** Called when the menu opens or closes. */
|
|
166
212
|
onOpenChange?: (isOpen: boolean) => void;
|
|
213
|
+
/**
|
|
214
|
+
* Presentation policy for the menu.
|
|
215
|
+
* - `popover`: open beside the pointer position.
|
|
216
|
+
* - `bottom-sheet`: open as an action sheet.
|
|
217
|
+
* - `adaptive`: use a BottomSheet on compact coarse-pointer viewports and
|
|
218
|
+
* the cursor-positioned popover elsewhere.
|
|
219
|
+
* @default 'popover'
|
|
220
|
+
*/
|
|
221
|
+
presentation?: AdaptivePresentation;
|
|
167
222
|
'data-testid'?: string;
|
|
168
223
|
}
|
|
169
224
|
|
|
@@ -215,6 +270,7 @@ export function ContextMenu({
|
|
|
215
270
|
label: labelFromProps,
|
|
216
271
|
isDisabled = false,
|
|
217
272
|
onOpenChange,
|
|
273
|
+
presentation = 'popover',
|
|
218
274
|
ref,
|
|
219
275
|
className,
|
|
220
276
|
style,
|
|
@@ -225,6 +281,9 @@ export function ContextMenu({
|
|
|
225
281
|
}: ContextMenuProps) {
|
|
226
282
|
const t = useTranslator();
|
|
227
283
|
const label = labelFromProps ?? t('@astryx.contextMenu.label');
|
|
284
|
+
const backLabel = t('@astryx.dropdownMenu.back');
|
|
285
|
+
const resolvedPresentation = useAdaptivePresentation(presentation);
|
|
286
|
+
const usesBottomSheet = resolvedPresentation === 'bottom-sheet';
|
|
228
287
|
// Separate content props (union discriminant) from DOM pass-through attrs.
|
|
229
288
|
// The union means exactly one of items/menuContent exists in rest; destructure
|
|
230
289
|
// both so triggerProps contains only DOM-safe attributes.
|
|
@@ -250,8 +309,25 @@ export function ContextMenu({
|
|
|
250
309
|
// Element focused before the menu opened, restored when it closes so focus
|
|
251
310
|
// does not fall to <body> after Escape or outside-click dismissal.
|
|
252
311
|
const triggerFocusRef = useRef<HTMLElement | null>(null);
|
|
312
|
+
const sheetHeadingRef = useRef<HTMLHeadingElement>(null);
|
|
253
313
|
|
|
254
314
|
const [isOpen, setIsOpen] = useState(false);
|
|
315
|
+
const [submenuPath, setSubmenuPath] = useState<ContextMenuItemData[]>([]);
|
|
316
|
+
const currentSubmenu = submenuPath.at(-1);
|
|
317
|
+
const currentItems = currentSubmenu?.items ?? items;
|
|
318
|
+
const currentTitle = currentSubmenu?.label ?? label;
|
|
319
|
+
const sheetLabel = typeof currentTitle === 'string' ? currentTitle : label;
|
|
320
|
+
|
|
321
|
+
const updateOpenState = useCallback(
|
|
322
|
+
(nextIsOpen: boolean) => {
|
|
323
|
+
if (!nextIsOpen) {
|
|
324
|
+
setSubmenuPath([]);
|
|
325
|
+
}
|
|
326
|
+
setIsOpen(nextIsOpen);
|
|
327
|
+
onOpenChange?.(nextIsOpen);
|
|
328
|
+
},
|
|
329
|
+
[onOpenChange],
|
|
330
|
+
);
|
|
255
331
|
|
|
256
332
|
const layer = useLayer({
|
|
257
333
|
mode: 'context',
|
|
@@ -273,8 +349,35 @@ export function ContextMenu({
|
|
|
273
349
|
});
|
|
274
350
|
|
|
275
351
|
const closeMenu = useCallback(() => {
|
|
276
|
-
|
|
277
|
-
|
|
352
|
+
if (usesBottomSheet) {
|
|
353
|
+
updateOpenState(false);
|
|
354
|
+
} else {
|
|
355
|
+
layer.hide();
|
|
356
|
+
}
|
|
357
|
+
}, [layer, updateOpenState, usesBottomSheet]);
|
|
358
|
+
|
|
359
|
+
const handleBottomSheetSelect = useCallback(
|
|
360
|
+
(item: ContextMenuItemData) => {
|
|
361
|
+
if (item.isDisabled) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
item.onClick?.();
|
|
365
|
+
if (item.hasCloseOnSelect !== false) {
|
|
366
|
+
closeMenu();
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
[closeMenu],
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
useEffect(() => {
|
|
373
|
+
if (!isOpen || submenuPath.length === 0) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const frame = requestAnimationFrame(() => {
|
|
377
|
+
sheetHeadingRef.current?.focus({preventScroll: true});
|
|
378
|
+
});
|
|
379
|
+
return () => cancelAnimationFrame(frame);
|
|
380
|
+
}, [isOpen, submenuPath.length]);
|
|
278
381
|
|
|
279
382
|
const {
|
|
280
383
|
listRef,
|
|
@@ -308,7 +411,7 @@ export function ContextMenu({
|
|
|
308
411
|
// opening right-click as a dismiss event. Handling it ourselves via
|
|
309
412
|
// mousedown avoids that race.
|
|
310
413
|
useEffect(() => {
|
|
311
|
-
if (!isOpen) {
|
|
414
|
+
if (!isOpen || usesBottomSheet) {
|
|
312
415
|
return;
|
|
313
416
|
}
|
|
314
417
|
const handleClickOutside = (e: MouseEvent) => {
|
|
@@ -321,14 +424,14 @@ export function ContextMenu({
|
|
|
321
424
|
return () => {
|
|
322
425
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
323
426
|
};
|
|
324
|
-
}, [isOpen, closeMenu, listRef]);
|
|
427
|
+
}, [isOpen, closeMenu, listRef, usesBottomSheet]);
|
|
325
428
|
|
|
326
429
|
// Dismiss on Escape from anywhere while open. The menu div's own onKeyDown
|
|
327
430
|
// only fires when focus is inside the menu; a document-level listener is
|
|
328
431
|
// kept as a reliable fallback Escape path (e.g. if focus has moved out of
|
|
329
432
|
// the menu). Guards against IME composition-cancel.
|
|
330
433
|
useEffect(() => {
|
|
331
|
-
if (!isOpen) {
|
|
434
|
+
if (!isOpen || usesBottomSheet) {
|
|
332
435
|
return;
|
|
333
436
|
}
|
|
334
437
|
const handleEscape = (e: KeyboardEvent) => {
|
|
@@ -347,7 +450,7 @@ export function ContextMenu({
|
|
|
347
450
|
return () => {
|
|
348
451
|
document.removeEventListener('keydown', handleEscape);
|
|
349
452
|
};
|
|
350
|
-
}, [isOpen, closeMenu]);
|
|
453
|
+
}, [isOpen, closeMenu, usesBottomSheet]);
|
|
351
454
|
|
|
352
455
|
const listKeyDown = useCallback(
|
|
353
456
|
(e: React.KeyboardEvent) => {
|
|
@@ -404,10 +507,14 @@ export function ContextMenu({
|
|
|
404
507
|
document.activeElement instanceof HTMLElement
|
|
405
508
|
? document.activeElement
|
|
406
509
|
: focusEl;
|
|
407
|
-
|
|
408
|
-
|
|
510
|
+
if (usesBottomSheet) {
|
|
511
|
+
updateOpenState(true);
|
|
512
|
+
} else {
|
|
513
|
+
layer.show();
|
|
514
|
+
requestAnimationFrame(() => focusFirst());
|
|
515
|
+
}
|
|
409
516
|
},
|
|
410
|
-
[layer, focusFirst],
|
|
517
|
+
[layer, focusFirst, updateOpenState, usesBottomSheet],
|
|
411
518
|
);
|
|
412
519
|
|
|
413
520
|
const handleContextMenu = useCallback(
|
|
@@ -465,6 +572,74 @@ export function ContextMenu({
|
|
|
465
572
|
const resolvedMenuContent =
|
|
466
573
|
itemsProp !== undefined ? renderDropdownItems(items) : menuContent;
|
|
467
574
|
|
|
575
|
+
const renderedMenu = (
|
|
576
|
+
<div
|
|
577
|
+
ref={listRef}
|
|
578
|
+
id={menuId}
|
|
579
|
+
role="menu"
|
|
580
|
+
data-autofocus={usesBottomSheet ? '' : undefined}
|
|
581
|
+
tabIndex={usesBottomSheet ? 0 : -1}
|
|
582
|
+
aria-label={label}
|
|
583
|
+
onKeyDown={listKeyDown}
|
|
584
|
+
onContextMenu={e => e.preventDefault()}
|
|
585
|
+
{...mergeProps(
|
|
586
|
+
themeProps('context-menu'),
|
|
587
|
+
stylex.props(usesBottomSheet ? styles.sheetMenu : styles.menu, xstyle),
|
|
588
|
+
className,
|
|
589
|
+
style,
|
|
590
|
+
)}>
|
|
591
|
+
<DropdownMenuContext value={contextValue}>
|
|
592
|
+
{resolvedMenuContent}
|
|
593
|
+
</DropdownMenuContext>
|
|
594
|
+
</div>
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
const renderedBottomSheetContent =
|
|
598
|
+
itemsProp !== undefined ? (
|
|
599
|
+
<div
|
|
600
|
+
ref={listRef}
|
|
601
|
+
data-autofocus=""
|
|
602
|
+
tabIndex={0}
|
|
603
|
+
{...mergeProps(
|
|
604
|
+
themeProps('context-menu'),
|
|
605
|
+
stylex.props(styles.sheetMenu, xstyle),
|
|
606
|
+
className,
|
|
607
|
+
style,
|
|
608
|
+
)}>
|
|
609
|
+
<div {...stylex.props(styles.sheetHeader)}>
|
|
610
|
+
{submenuPath.length > 0 && (
|
|
611
|
+
<Button
|
|
612
|
+
label={backLabel}
|
|
613
|
+
variant="ghost"
|
|
614
|
+
size="sm"
|
|
615
|
+
icon={
|
|
616
|
+
<Icon icon="chevronLeft" size="sm" xstyle={rtlStyles.mirror} />
|
|
617
|
+
}
|
|
618
|
+
isIconOnly
|
|
619
|
+
onClick={() => setSubmenuPath(path => path.slice(0, -1))}
|
|
620
|
+
/>
|
|
621
|
+
)}
|
|
622
|
+
<Heading
|
|
623
|
+
ref={sheetHeadingRef}
|
|
624
|
+
level={3}
|
|
625
|
+
tabIndex={-1}
|
|
626
|
+
xstyle={[
|
|
627
|
+
styles.sheetViewHeading,
|
|
628
|
+
submenuPath.length === 0 && styles.sheetRootHeading,
|
|
629
|
+
]}>
|
|
630
|
+
{currentTitle}
|
|
631
|
+
</Heading>
|
|
632
|
+
</div>
|
|
633
|
+
<MenuBottomSheetActionList
|
|
634
|
+
items={currentItems}
|
|
635
|
+
onSelect={handleBottomSheetSelect}
|
|
636
|
+
onOpenSubmenu={item => setSubmenuPath(path => [...path, item])}
|
|
637
|
+
/>
|
|
638
|
+
</div>
|
|
639
|
+
) : (
|
|
640
|
+
renderedMenu
|
|
641
|
+
);
|
|
642
|
+
|
|
468
643
|
return (
|
|
469
644
|
<>
|
|
470
645
|
<div
|
|
@@ -494,29 +669,21 @@ export function ContextMenu({
|
|
|
494
669
|
/>
|
|
495
670
|
</div>
|
|
496
671
|
|
|
497
|
-
{
|
|
498
|
-
<
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
className,
|
|
509
|
-
style,
|
|
510
|
-
)}>
|
|
511
|
-
<DropdownMenuContext value={contextValue}>
|
|
512
|
-
{resolvedMenuContent}
|
|
513
|
-
</DropdownMenuContext>
|
|
514
|
-
</div>,
|
|
515
|
-
{
|
|
672
|
+
{usesBottomSheet ? (
|
|
673
|
+
<Suspense fallback={null}>
|
|
674
|
+
<LazyMenuBottomSheet
|
|
675
|
+
isOpen={isOpen}
|
|
676
|
+
onOpenChange={updateOpenState}
|
|
677
|
+
label={sheetLabel}>
|
|
678
|
+
{renderedBottomSheetContent}
|
|
679
|
+
</LazyMenuBottomSheet>
|
|
680
|
+
</Suspense>
|
|
681
|
+
) : (
|
|
682
|
+
layer.render(renderedMenu, {
|
|
516
683
|
placement: 'below',
|
|
517
684
|
alignment: 'start',
|
|
518
685
|
xstyle: [popoverXstyle, layerAnimations.below],
|
|
519
|
-
}
|
|
686
|
+
})
|
|
520
687
|
)}
|
|
521
688
|
</>
|
|
522
689
|
);
|
package/src/ContextMenu/index.ts
CHANGED