@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
|
@@ -10,9 +10,11 @@ import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
|
10
10
|
import {useState} from 'react';
|
|
11
11
|
import {render, screen, waitFor, fireEvent, act} from '@testing-library/react';
|
|
12
12
|
import userEvent from '@testing-library/user-event';
|
|
13
|
+
import * as stylex from '@stylexjs/stylex';
|
|
13
14
|
import {DropdownMenu} from './DropdownMenu';
|
|
14
15
|
import {DropdownMenuItem} from './DropdownMenuItem';
|
|
15
16
|
import {DropdownMenuSubMenu} from './DropdownMenuSubMenu';
|
|
17
|
+
import {rtlStyles} from '../utils';
|
|
16
18
|
|
|
17
19
|
beforeEach(() => {
|
|
18
20
|
HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) {
|
|
@@ -81,6 +83,82 @@ describe('DropdownMenuSubMenu', () => {
|
|
|
81
83
|
).toBeInTheDocument();
|
|
82
84
|
});
|
|
83
85
|
|
|
86
|
+
it('caps the flyout width and height to the available viewport', async () => {
|
|
87
|
+
const user = userEvent.setup();
|
|
88
|
+
render(
|
|
89
|
+
<DropdownMenu button={{label: 'Actions'}}>
|
|
90
|
+
<DropdownMenuSubMenu label="Move to" menuWidth={640}>
|
|
91
|
+
<DropdownMenuItem label="Folder A" onClick={() => {}} />
|
|
92
|
+
</DropdownMenuSubMenu>
|
|
93
|
+
</DropdownMenu>,
|
|
94
|
+
);
|
|
95
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
96
|
+
const trigger = screen.getByRole('menuitem', {
|
|
97
|
+
name: /Move to/,
|
|
98
|
+
hidden: true,
|
|
99
|
+
});
|
|
100
|
+
await user.click(trigger);
|
|
101
|
+
|
|
102
|
+
const flyout = screen.getByRole('menu', {name: /Move to/, hidden: true});
|
|
103
|
+
const popover = flyout.closest('[popover]');
|
|
104
|
+
expect(popover?.className).toContain(
|
|
105
|
+
'DropdownMenuSubMenu__flyoutStyles.popoverViewport',
|
|
106
|
+
);
|
|
107
|
+
expect(popover).not.toHaveStyle(
|
|
108
|
+
'margin-inline-start: max(var(--spacing-2),env(safe-area-inset-left,0px))',
|
|
109
|
+
);
|
|
110
|
+
expect(popover).not.toHaveStyle(
|
|
111
|
+
'margin-inline-end: max(var(--spacing-2),env(safe-area-inset-right,0px))',
|
|
112
|
+
);
|
|
113
|
+
expect(popover).toHaveStyle({minWidth: 'var(--x-minWidth)'});
|
|
114
|
+
expect(popover?.getAttribute('style')).toContain('min(640px, calc(100vw');
|
|
115
|
+
expect(flyout).toHaveStyle(
|
|
116
|
+
'max-height: min(300px,calc(100dvb - max(var(--spacing-4),env(safe-area-inset-top,0px)) - max(var(--spacing-4),env(safe-area-inset-bottom,0px))))',
|
|
117
|
+
);
|
|
118
|
+
expect(flyout).not.toHaveStyle({overflowY: 'auto'});
|
|
119
|
+
expect(flyout).toHaveAttribute('tabindex', '-1');
|
|
120
|
+
|
|
121
|
+
Object.defineProperties(flyout, {
|
|
122
|
+
clientHeight: {configurable: true, value: 300},
|
|
123
|
+
scrollHeight: {configurable: true, value: 480},
|
|
124
|
+
});
|
|
125
|
+
fireEvent(window, new Event('resize'));
|
|
126
|
+
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
expect(flyout).toHaveStyle({overflowY: 'auto'});
|
|
129
|
+
expect(flyout).toHaveAttribute('tabindex', '0');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it.each(['max-content', 'fit-content', 'auto'])(
|
|
134
|
+
'preserves the valid %s flyout width while retaining the viewport cap',
|
|
135
|
+
async menuWidth => {
|
|
136
|
+
const user = userEvent.setup();
|
|
137
|
+
render(
|
|
138
|
+
<DropdownMenu button={{label: 'Actions'}}>
|
|
139
|
+
<DropdownMenuSubMenu label="Move to" menuWidth={menuWidth}>
|
|
140
|
+
<DropdownMenuItem label="Folder A" onClick={() => {}} />
|
|
141
|
+
</DropdownMenuSubMenu>
|
|
142
|
+
</DropdownMenu>,
|
|
143
|
+
);
|
|
144
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
145
|
+
await user.click(
|
|
146
|
+
screen.getByRole('menuitem', {name: /Move to/, hidden: true}),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const flyout = screen.getByRole('menu', {name: /Move to/, hidden: true});
|
|
150
|
+
const popover = flyout.closest('[popover]');
|
|
151
|
+
expect(popover?.className).toContain(
|
|
152
|
+
'DropdownMenuSubMenu__flyoutStyles.popoverCustomIntrinsicWidth',
|
|
153
|
+
);
|
|
154
|
+
expect(popover?.getAttribute('style')).toContain(menuWidth);
|
|
155
|
+
expect(popover?.className).toContain(
|
|
156
|
+
'DropdownMenuSubMenu__flyoutStyles.popoverViewport',
|
|
157
|
+
);
|
|
158
|
+
expect(popover?.getAttribute('style')).not.toContain(`min(${menuWidth},`);
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
|
|
84
162
|
it('opens on ArrowRight and returns focus to the trigger on ArrowLeft', async () => {
|
|
85
163
|
const user = userEvent.setup();
|
|
86
164
|
render(<MoveMenu />);
|
|
@@ -115,6 +193,52 @@ describe('DropdownMenuSubMenu', () => {
|
|
|
115
193
|
expect(trigger).toHaveFocus();
|
|
116
194
|
});
|
|
117
195
|
|
|
196
|
+
it('mirrors its indicator and keyboard directions under RTL', async () => {
|
|
197
|
+
const user = userEvent.setup();
|
|
198
|
+
const {className: mirrorClassName} = stylex.props(rtlStyles.mirror);
|
|
199
|
+
const mirrorClasses = mirrorClassName?.split(' ') ?? [];
|
|
200
|
+
expect(mirrorClasses.length).toBeGreaterThan(0);
|
|
201
|
+
render(
|
|
202
|
+
<div dir="rtl" style={{direction: 'rtl'}}>
|
|
203
|
+
<MoveMenu />
|
|
204
|
+
</div>,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
208
|
+
const trigger = screen.getByRole('menuitem', {
|
|
209
|
+
name: /Move to/,
|
|
210
|
+
hidden: true,
|
|
211
|
+
});
|
|
212
|
+
const indicator = trigger.querySelector('.astryx-icon');
|
|
213
|
+
expect(indicator).not.toBeNull();
|
|
214
|
+
for (const className of mirrorClasses) {
|
|
215
|
+
expect(indicator).toHaveClass(className);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await waitFor(() => {
|
|
219
|
+
expect(
|
|
220
|
+
screen.getByRole('menu', {name: 'Actions', hidden: true}),
|
|
221
|
+
).toHaveFocus();
|
|
222
|
+
});
|
|
223
|
+
// jsdom does not propagate the ancestor's computed direction, so set the
|
|
224
|
+
// same RTL direction directly on each keyboard event owner.
|
|
225
|
+
trigger.style.direction = 'rtl';
|
|
226
|
+
trigger.focus();
|
|
227
|
+
await user.keyboard('{ArrowLeft}');
|
|
228
|
+
const flyout = screen.getByRole('menu', {name: /Move to/, hidden: true});
|
|
229
|
+
flyout.style.direction = 'rtl';
|
|
230
|
+
await waitFor(() => {
|
|
231
|
+
expect(
|
|
232
|
+
screen.getByRole('menuitem', {name: 'Folder A', hidden: true}),
|
|
233
|
+
).toHaveFocus();
|
|
234
|
+
});
|
|
235
|
+
await user.keyboard('{ArrowRight}');
|
|
236
|
+
await waitFor(() => {
|
|
237
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
238
|
+
});
|
|
239
|
+
expect(trigger).toHaveFocus();
|
|
240
|
+
});
|
|
241
|
+
|
|
118
242
|
it('names the flyout from its trigger via aria-labelledby', async () => {
|
|
119
243
|
const user = userEvent.setup();
|
|
120
244
|
render(<MoveMenu />);
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @file DropdownMenuSubMenu.tsx
|
|
7
7
|
* @input React, stylex, useLayer (context mode), useListFocus, useMenuHover,
|
|
8
|
-
* useTypeahead,
|
|
8
|
+
* useTypeahead, the shared viewport-safe menu-width resolver, Item, Icon,
|
|
9
|
+
* Spinner, and DropdownMenu context + item roles
|
|
9
10
|
* @output Exports DropdownMenuSubMenu — a single menu row that reveals a nested
|
|
10
11
|
* flyout menu of its own children/items.
|
|
11
12
|
* @position Sub-component; place inside a DropdownMenu (or ContextMenu)
|
|
@@ -56,6 +57,8 @@ import {layerAnimations} from '../Layer/layerAnimations.stylex';
|
|
|
56
57
|
import {useListFocus} from '../hooks/useListFocus';
|
|
57
58
|
import {useMenuHover} from '../hooks/useMenuHover';
|
|
58
59
|
import {useTypeahead} from '../hooks/useTypeahead';
|
|
60
|
+
import {useMenuOverflow} from './useMenuOverflow';
|
|
61
|
+
import {resolveMenuWidth} from './menuWidth';
|
|
59
62
|
import {
|
|
60
63
|
colorVars,
|
|
61
64
|
spacingVars,
|
|
@@ -66,7 +69,7 @@ import {
|
|
|
66
69
|
typographyVars,
|
|
67
70
|
typeScaleVars,
|
|
68
71
|
} from '../theme/tokens.stylex';
|
|
69
|
-
import {mergeProps} from '../utils';
|
|
72
|
+
import {mergeProps, rtlStyles} from '../utils';
|
|
70
73
|
import {themeProps} from '../utils/themeProps';
|
|
71
74
|
import type {BaseProps} from '../BaseProps';
|
|
72
75
|
import {
|
|
@@ -81,6 +84,14 @@ import {
|
|
|
81
84
|
} from './DropdownMenuContext';
|
|
82
85
|
import {focusMenuItemOnHover} from './menuItemHover';
|
|
83
86
|
|
|
87
|
+
const MENU_VIEWPORT_GUTTER = spacingVars['--spacing-4'];
|
|
88
|
+
// `useLayer` adds 4px of anchor clearance. An 8px collision margin resolves
|
|
89
|
+
// to a 4px visible gap after the browser flips the flyout.
|
|
90
|
+
const MENU_MAX_INLINE_SIZE = `calc(100vi - max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-left, 0px)) - max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-right, 0px)))`;
|
|
91
|
+
const MENU_MAX_INLINE_SIZE_FALLBACK = `calc(100vw - ${MENU_VIEWPORT_GUTTER} - ${MENU_VIEWPORT_GUTTER})`;
|
|
92
|
+
const MENU_MAX_BLOCK_SIZE = `min(300px, calc(100dvb - max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-top, 0px)) - max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-bottom, 0px))))`;
|
|
93
|
+
const MENU_MAX_BLOCK_SIZE_FALLBACK = `min(300px, calc(100vh - ${MENU_VIEWPORT_GUTTER} - ${MENU_VIEWPORT_GUTTER}))`;
|
|
94
|
+
|
|
84
95
|
const triggerStyles = stylex.create({
|
|
85
96
|
root: {
|
|
86
97
|
boxSizing: 'border-box',
|
|
@@ -135,8 +146,14 @@ const flyoutStyles = stylex.create({
|
|
|
135
146
|
display: 'flex',
|
|
136
147
|
flexDirection: 'column',
|
|
137
148
|
gap: spacingVars['--spacing-0-5'],
|
|
138
|
-
|
|
139
|
-
|
|
149
|
+
maxInlineSize: stylex.firstThatWorks(
|
|
150
|
+
MENU_MAX_INLINE_SIZE,
|
|
151
|
+
MENU_MAX_INLINE_SIZE_FALLBACK,
|
|
152
|
+
),
|
|
153
|
+
maxHeight: stylex.firstThatWorks(
|
|
154
|
+
MENU_MAX_BLOCK_SIZE,
|
|
155
|
+
MENU_MAX_BLOCK_SIZE_FALLBACK,
|
|
156
|
+
),
|
|
140
157
|
'--_dropdown-menu-radius': radiusVars['--radius-container'],
|
|
141
158
|
'--_dropdown-menu-padding': spacingVars['--spacing-1'],
|
|
142
159
|
padding: spacingVars['--spacing-1'],
|
|
@@ -148,11 +165,33 @@ const flyoutStyles = stylex.create({
|
|
|
148
165
|
transitionDuration: durationVars['--duration-fast'],
|
|
149
166
|
transitionTimingFunction: easeVars['--ease-standard'],
|
|
150
167
|
},
|
|
168
|
+
scrollable: {
|
|
169
|
+
overflowY: 'auto',
|
|
170
|
+
overflowX: 'hidden',
|
|
171
|
+
overscrollBehavior: 'contain',
|
|
172
|
+
},
|
|
173
|
+
popoverViewport: {
|
|
174
|
+
boxSizing: 'border-box',
|
|
175
|
+
// Keep the inline viewport cap on the menu surface above. Applying it to
|
|
176
|
+
// the anchor-positioned popover itself prevents Chromium from selecting
|
|
177
|
+
// `flip-inline` and instead shifts the flyout back across its parent.
|
|
178
|
+
maxBlockSize: stylex.firstThatWorks(
|
|
179
|
+
MENU_MAX_BLOCK_SIZE,
|
|
180
|
+
MENU_MAX_BLOCK_SIZE_FALLBACK,
|
|
181
|
+
),
|
|
182
|
+
},
|
|
151
183
|
popover: {
|
|
152
|
-
minWidth:
|
|
184
|
+
minWidth: stylex.firstThatWorks(
|
|
185
|
+
`min(160px, ${MENU_MAX_INLINE_SIZE})`,
|
|
186
|
+
`min(160px, ${MENU_MAX_INLINE_SIZE_FALLBACK})`,
|
|
187
|
+
'160px',
|
|
188
|
+
),
|
|
153
189
|
},
|
|
154
|
-
popoverCustomWidth: (width: string
|
|
155
|
-
minWidth:
|
|
190
|
+
popoverCustomWidth: (width: string) => ({
|
|
191
|
+
minWidth: width,
|
|
192
|
+
}),
|
|
193
|
+
popoverCustomIntrinsicWidth: (width: string) => ({
|
|
194
|
+
inlineSize: width,
|
|
156
195
|
}),
|
|
157
196
|
});
|
|
158
197
|
|
|
@@ -178,7 +217,10 @@ interface DropdownMenuSubMenuBaseProps extends Pick<
|
|
|
178
217
|
* @default false
|
|
179
218
|
*/
|
|
180
219
|
hasSpinner?: boolean;
|
|
181
|
-
/**
|
|
220
|
+
/**
|
|
221
|
+
* Minimum flyout width. The flyout may grow for its content but is capped to
|
|
222
|
+
* the available viewport space. Defaults to intrinsic sizing (min 160px).
|
|
223
|
+
*/
|
|
182
224
|
menuWidth?: number | string;
|
|
183
225
|
/** Called when the flyout opens or closes. */
|
|
184
226
|
onOpenChange?: (isOpen: boolean) => void;
|
|
@@ -289,6 +331,7 @@ export function DropdownMenuSubMenu(
|
|
|
289
331
|
wrap: false,
|
|
290
332
|
onEscape: () => close({focusTrigger: true}),
|
|
291
333
|
});
|
|
334
|
+
const hasOverflow = useMenuOverflow(menuRef, children, isOpen);
|
|
292
335
|
|
|
293
336
|
const typeahead = useTypeahead({
|
|
294
337
|
getItemLabels: () => getItems().map(el => el.textContent),
|
|
@@ -485,13 +528,19 @@ export function DropdownMenuSubMenu(
|
|
|
485
528
|
icon="chevronRight"
|
|
486
529
|
size="sm"
|
|
487
530
|
color="secondary"
|
|
531
|
+
xstyle={rtlStyles.mirror}
|
|
488
532
|
{...themeProps('dropdown-menu-indicator-icon')}
|
|
489
533
|
/>
|
|
490
534
|
</span>
|
|
491
535
|
);
|
|
492
536
|
|
|
493
|
-
const
|
|
494
|
-
?
|
|
537
|
+
const resolvedMenuWidth = menuWidth
|
|
538
|
+
? resolveMenuWidth(menuWidth, MENU_MAX_INLINE_SIZE_FALLBACK)
|
|
539
|
+
: null;
|
|
540
|
+
const popoverXstyle = resolvedMenuWidth
|
|
541
|
+
? resolvedMenuWidth.property === 'inlineSize'
|
|
542
|
+
? flyoutStyles.popoverCustomIntrinsicWidth(resolvedMenuWidth.value)
|
|
543
|
+
: flyoutStyles.popoverCustomWidth(resolvedMenuWidth.value)
|
|
495
544
|
: flyoutStyles.popover;
|
|
496
545
|
|
|
497
546
|
return (
|
|
@@ -537,11 +586,10 @@ export function DropdownMenuSubMenu(
|
|
|
537
586
|
ref={menuRef}
|
|
538
587
|
id={contentId}
|
|
539
588
|
role="menu"
|
|
540
|
-
// Focusable as a fallback target so an empty/loading flyout
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
|
|
544
|
-
tabIndex={-1}
|
|
589
|
+
// Focusable as a fallback target so an empty/loading flyout can own
|
|
590
|
+
// arrow/Escape keys. An overflowing flyout joins the Tab order so its
|
|
591
|
+
// scrollable region is keyboard-accessible.
|
|
592
|
+
tabIndex={hasOverflow ? 0 : -1}
|
|
545
593
|
aria-labelledby={triggerId}
|
|
546
594
|
onKeyDown={handleContentKeyDown}
|
|
547
595
|
onMouseEnter={contentProps.onMouseEnter}
|
|
@@ -549,7 +597,10 @@ export function DropdownMenuSubMenu(
|
|
|
549
597
|
data-testid={menuDataTestId}
|
|
550
598
|
{...mergeProps(
|
|
551
599
|
themeProps('dropdown-menu'),
|
|
552
|
-
stylex.props(
|
|
600
|
+
stylex.props(
|
|
601
|
+
flyoutStyles.menu,
|
|
602
|
+
hasOverflow && flyoutStyles.scrollable,
|
|
603
|
+
),
|
|
553
604
|
)}>
|
|
554
605
|
<DropdownMenuContext value={nestedMenuContext}>
|
|
555
606
|
{children}
|
|
@@ -559,7 +610,11 @@ export function DropdownMenuSubMenu(
|
|
|
559
610
|
placement: 'end',
|
|
560
611
|
alignment: 'start',
|
|
561
612
|
offset: spacingVars['--spacing-1'],
|
|
562
|
-
xstyle: [
|
|
613
|
+
xstyle: [
|
|
614
|
+
flyoutStyles.popoverViewport,
|
|
615
|
+
popoverXstyle,
|
|
616
|
+
layerAnimations.end,
|
|
617
|
+
],
|
|
563
618
|
},
|
|
564
619
|
)}
|
|
565
620
|
</>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file MenuBottomSheet.tsx
|
|
7
|
+
* @input Uses BottomSheet and Section
|
|
8
|
+
* @output Internal action-sheet host for menu content
|
|
9
|
+
* @position Shared by DropdownMenu and ContextMenu adaptive presentations
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {ReactNode, RefObject} from 'react';
|
|
13
|
+
import {BottomSheet} from '../BottomSheet/BottomSheet';
|
|
14
|
+
import {Section} from '../Section/Section';
|
|
15
|
+
|
|
16
|
+
interface MenuBottomSheetProps {
|
|
17
|
+
finalFocusRef?: RefObject<HTMLElement | null>;
|
|
18
|
+
isOpen: boolean;
|
|
19
|
+
onOpenChange: (isOpen: boolean) => void;
|
|
20
|
+
label: string;
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function MenuBottomSheet({
|
|
25
|
+
finalFocusRef,
|
|
26
|
+
isOpen,
|
|
27
|
+
onOpenChange,
|
|
28
|
+
label,
|
|
29
|
+
children,
|
|
30
|
+
}: MenuBottomSheetProps) {
|
|
31
|
+
return (
|
|
32
|
+
<BottomSheet
|
|
33
|
+
finalFocusRef={finalFocusRef}
|
|
34
|
+
isOpen={isOpen}
|
|
35
|
+
onOpenChange={onOpenChange}
|
|
36
|
+
label={label}
|
|
37
|
+
height="hug"
|
|
38
|
+
purpose="info">
|
|
39
|
+
<Section paddingBlockStart={4} paddingBlockEnd={0} paddingInline={1}>
|
|
40
|
+
{children}
|
|
41
|
+
</Section>
|
|
42
|
+
</BottomSheet>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
MenuBottomSheet.displayName = 'MenuBottomSheet';
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file MenuBottomSheetActionList.tsx
|
|
7
|
+
* @input Uses menu data entries and callbacks for selection and drill-in
|
|
8
|
+
* @output Internal touch-friendly action list shared by DropdownMenu and ContextMenu
|
|
9
|
+
* @position Shared implementation for menu bottom-sheet presentations
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {ReactElement} from 'react';
|
|
13
|
+
import * as stylex from '@stylexjs/stylex';
|
|
14
|
+
import {Divider} from '../Divider';
|
|
15
|
+
import {Heading} from '../Heading';
|
|
16
|
+
import {Icon, renderIconSlot} from '../Icon';
|
|
17
|
+
import {List, ListItem} from '../List';
|
|
18
|
+
import {colorVars, spacingVars} from '../theme/tokens.stylex';
|
|
19
|
+
import {rtlStyles} from '../utils';
|
|
20
|
+
import {getInteractionModality} from '../utils/interactionModality';
|
|
21
|
+
import type {DropdownMenuItemData, DropdownMenuOption} from './DropdownMenu';
|
|
22
|
+
|
|
23
|
+
const styles = stylex.create({
|
|
24
|
+
destructiveAction: {
|
|
25
|
+
'--_item-label-color': colorVars['--color-error'],
|
|
26
|
+
'--_item-description-color': colorVars['--color-error'],
|
|
27
|
+
color: colorVars['--color-error'],
|
|
28
|
+
},
|
|
29
|
+
structuralItem: {
|
|
30
|
+
listStyleType: 'none',
|
|
31
|
+
},
|
|
32
|
+
divider: {
|
|
33
|
+
marginBlock: spacingVars['--spacing-1'],
|
|
34
|
+
},
|
|
35
|
+
section: {
|
|
36
|
+
display: 'flex',
|
|
37
|
+
flexDirection: 'column',
|
|
38
|
+
gap: spacingVars['--spacing-1'],
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function getItemKey(item: DropdownMenuItemData, index: number): string {
|
|
43
|
+
return `item-${item.id ?? index}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function MenuBottomSheetActionList({
|
|
47
|
+
items,
|
|
48
|
+
onSelect,
|
|
49
|
+
onOpenSubmenu,
|
|
50
|
+
}: {
|
|
51
|
+
items: DropdownMenuOption[];
|
|
52
|
+
onSelect: (item: DropdownMenuItemData) => void;
|
|
53
|
+
onOpenSubmenu: (item: DropdownMenuItemData) => void;
|
|
54
|
+
}) {
|
|
55
|
+
const renderItem = (
|
|
56
|
+
item: DropdownMenuItemData,
|
|
57
|
+
index: number,
|
|
58
|
+
): ReactElement => {
|
|
59
|
+
const isSubmenu = item.items != null && item.items.length > 0;
|
|
60
|
+
const isDestructive = item.variant === 'destructive';
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<ListItem
|
|
64
|
+
key={getItemKey(item, index)}
|
|
65
|
+
label={item.label}
|
|
66
|
+
description={item.description}
|
|
67
|
+
startContent={
|
|
68
|
+
item.icon
|
|
69
|
+
? renderIconSlot(item.icon, {
|
|
70
|
+
size: 'sm',
|
|
71
|
+
color: isDestructive ? 'error' : 'secondary',
|
|
72
|
+
})
|
|
73
|
+
: undefined
|
|
74
|
+
}
|
|
75
|
+
endContent={
|
|
76
|
+
isSubmenu ? (
|
|
77
|
+
<Icon
|
|
78
|
+
icon="chevronRight"
|
|
79
|
+
size="sm"
|
|
80
|
+
color="secondary"
|
|
81
|
+
xstyle={rtlStyles.mirror}
|
|
82
|
+
/>
|
|
83
|
+
) : (
|
|
84
|
+
item.endContent
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
isDisabled={item.isDisabled}
|
|
88
|
+
onClick={event => {
|
|
89
|
+
if (getInteractionModality() === 'pointer') {
|
|
90
|
+
(event.currentTarget as HTMLElement).blur();
|
|
91
|
+
}
|
|
92
|
+
if (isSubmenu) {
|
|
93
|
+
onOpenSubmenu(item);
|
|
94
|
+
} else {
|
|
95
|
+
onSelect(item);
|
|
96
|
+
}
|
|
97
|
+
}}
|
|
98
|
+
xstyle={isDestructive && styles.destructiveAction}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<List density="spacious">
|
|
105
|
+
{items.map((option, index) => {
|
|
106
|
+
if ('type' in option && option.type === 'divider') {
|
|
107
|
+
return (
|
|
108
|
+
<li
|
|
109
|
+
// eslint-disable-next-line @eslint-react/no-array-index-key
|
|
110
|
+
key={`divider-${index}`}
|
|
111
|
+
role="presentation"
|
|
112
|
+
{...stylex.props(styles.structuralItem)}>
|
|
113
|
+
<Divider xstyle={styles.divider} />
|
|
114
|
+
</li>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if ('type' in option && option.type === 'section') {
|
|
119
|
+
return (
|
|
120
|
+
<li
|
|
121
|
+
key={`section-${option.id ?? index}`}
|
|
122
|
+
role="presentation"
|
|
123
|
+
{...stylex.props(styles.structuralItem)}>
|
|
124
|
+
<div
|
|
125
|
+
role="group"
|
|
126
|
+
aria-label={option.title}
|
|
127
|
+
{...stylex.props(styles.section)}>
|
|
128
|
+
{option.title && <Heading level={4}>{option.title}</Heading>}
|
|
129
|
+
<List density="spacious">{option.items.map(renderItem)}</List>
|
|
130
|
+
</div>
|
|
131
|
+
</li>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return renderItem(option, index);
|
|
136
|
+
})}
|
|
137
|
+
</List>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
MenuBottomSheetActionList.displayName = 'MenuBottomSheetActionList';
|
|
@@ -20,12 +20,14 @@
|
|
|
20
20
|
export {
|
|
21
21
|
DropdownMenu,
|
|
22
22
|
type DropdownMenuProps,
|
|
23
|
+
type DropdownMenuPresentation,
|
|
23
24
|
type DropdownMenuButtonProps,
|
|
24
25
|
type DropdownMenuItemData,
|
|
25
26
|
type DropdownMenuDividerData,
|
|
26
27
|
type DropdownMenuSection,
|
|
27
28
|
type DropdownMenuOption,
|
|
28
29
|
} from './DropdownMenu';
|
|
30
|
+
export type {AdaptivePresentation as MenuPresentation} from '../hooks/useAdaptivePresentation';
|
|
29
31
|
|
|
30
32
|
export {DropdownMenuItem, type DropdownMenuItemProps} from './DropdownMenuItem';
|
|
31
33
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file menuWidth.ts
|
|
5
|
+
* @input Accepts the public DropdownMenu menuWidth value and a viewport limit
|
|
6
|
+
* @output Resolves whether the value belongs on min-width or inline-size
|
|
7
|
+
* @position Shared width compatibility logic for root menus and submenu flyouts
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const INTRINSIC_AND_CSS_WIDE_WIDTHS = new Set([
|
|
11
|
+
'auto',
|
|
12
|
+
'contain',
|
|
13
|
+
'fit-content',
|
|
14
|
+
'inherit',
|
|
15
|
+
'initial',
|
|
16
|
+
'max-content',
|
|
17
|
+
'min-content',
|
|
18
|
+
'revert',
|
|
19
|
+
'revert-layer',
|
|
20
|
+
'stretch',
|
|
21
|
+
'unset',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export type ResolvedMenuWidth =
|
|
25
|
+
| {property: 'inlineSize'; value: string}
|
|
26
|
+
| {property: 'minWidth'; value: string};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* CSS intrinsic and CSS-wide keywords cannot be arguments to `min()`. Apply
|
|
30
|
+
* those values as the preferred inline size so max-inline-size can still cap
|
|
31
|
+
* the menu. Lengths retain the existing minimum-width growth contract.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveMenuWidth(
|
|
34
|
+
menuWidth: number | string,
|
|
35
|
+
maximum: string,
|
|
36
|
+
): ResolvedMenuWidth {
|
|
37
|
+
if (typeof menuWidth === 'number') {
|
|
38
|
+
return {property: 'minWidth', value: `min(${menuWidth}px, ${maximum})`};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const width = menuWidth.trim();
|
|
42
|
+
const normalizedWidth = width.toLowerCase();
|
|
43
|
+
if (
|
|
44
|
+
INTRINSIC_AND_CSS_WIDE_WIDTHS.has(normalizedWidth) ||
|
|
45
|
+
normalizedWidth.startsWith('fit-content(')
|
|
46
|
+
) {
|
|
47
|
+
return {property: 'inlineSize', value: width};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {property: 'minWidth', value: `min(${width}, ${maximum})`};
|
|
51
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file useMenuOverflow.ts
|
|
7
|
+
* @input Menu element ref, rendered content, and open state
|
|
8
|
+
* @output Returns whether the open menu overflows its block-size constraint
|
|
9
|
+
* @position Shared responsive helper for DropdownMenu and DropdownMenuSubMenu
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {useCallback, useState, type ReactNode, type RefObject} from 'react';
|
|
13
|
+
import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
|
|
14
|
+
|
|
15
|
+
/** Detect block-axis overflow without making a fitting menu a scroll container. */
|
|
16
|
+
export function useMenuOverflow(
|
|
17
|
+
menuRef: RefObject<HTMLElement | null>,
|
|
18
|
+
content: ReactNode,
|
|
19
|
+
isOpen: boolean,
|
|
20
|
+
): boolean {
|
|
21
|
+
const [hasOverflow, setHasOverflow] = useState(false);
|
|
22
|
+
|
|
23
|
+
const measureOverflow = useCallback(() => {
|
|
24
|
+
const menu = menuRef.current;
|
|
25
|
+
if (!menu) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const nextHasOverflow = menu.scrollHeight > menu.clientHeight + 1;
|
|
29
|
+
// eslint-disable-next-line @eslint-react/set-state-in-effect -- DOM overflow measurement controls whether this menu becomes a scroll container
|
|
30
|
+
setHasOverflow(current =>
|
|
31
|
+
current === nextHasOverflow ? current : nextHasOverflow,
|
|
32
|
+
);
|
|
33
|
+
}, [menuRef]);
|
|
34
|
+
|
|
35
|
+
useIsomorphicLayoutEffect(() => {
|
|
36
|
+
if (!isOpen) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const menu = menuRef.current;
|
|
40
|
+
if (!menu) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
measureOverflow();
|
|
44
|
+
const resizeObserver =
|
|
45
|
+
typeof ResizeObserver === 'undefined'
|
|
46
|
+
? null
|
|
47
|
+
: new ResizeObserver(measureOverflow);
|
|
48
|
+
const mutationObserver =
|
|
49
|
+
typeof MutationObserver === 'undefined'
|
|
50
|
+
? null
|
|
51
|
+
: new MutationObserver(measureOverflow);
|
|
52
|
+
resizeObserver?.observe(menu);
|
|
53
|
+
mutationObserver?.observe(menu, {
|
|
54
|
+
childList: true,
|
|
55
|
+
characterData: true,
|
|
56
|
+
subtree: true,
|
|
57
|
+
});
|
|
58
|
+
menu.addEventListener('load', measureOverflow, true);
|
|
59
|
+
window.addEventListener('resize', measureOverflow);
|
|
60
|
+
window.visualViewport?.addEventListener('resize', measureOverflow);
|
|
61
|
+
return () => {
|
|
62
|
+
resizeObserver?.disconnect();
|
|
63
|
+
mutationObserver?.disconnect();
|
|
64
|
+
menu.removeEventListener('load', measureOverflow, true);
|
|
65
|
+
window.removeEventListener('resize', measureOverflow);
|
|
66
|
+
window.visualViewport?.removeEventListener('resize', measureOverflow);
|
|
67
|
+
};
|
|
68
|
+
}, [isOpen, measureOverflow, menuRef]);
|
|
69
|
+
|
|
70
|
+
useIsomorphicLayoutEffect(() => {
|
|
71
|
+
if (isOpen) {
|
|
72
|
+
measureOverflow();
|
|
73
|
+
}
|
|
74
|
+
}, [content, isOpen, measureOverflow]);
|
|
75
|
+
|
|
76
|
+
return hasOverflow;
|
|
77
|
+
}
|