Skip to content

Commit d95d1a7

Browse files
reidbarberdevongovettLFDanLu
authored
RAC: Submenu support (#5648)
* initialize RAC submenu * use cached children * cleanup * use one useRenderProps * types * cleanup * fix trigger ref * typescript * add chevron style to submenu trigger * types * cleanup * pass submenu ref * add submenu story to menu story file * fix nested case * cleanup * add nested story * fix trigger attribute * add tests * update jsdoc * add docs * update storybook styles * fix docs types * cleanup tests * move imports in docs closer to example * typescript * add example to homepage * close all submenus if underlay is clicked * revert autoformatting from headwind * add to small example, fix imports and labels * render via portal * add render props and data attributes * add || undefined * add many items example * add keys for storybook * imrove styles on docs page * improve homepage styles * move chevron into item wrapper * ts lint * lint * story style update * style isSubmenuOpen states * add dynamic example to dcs * don't allow submenu trigger to be a link * clarify submenutrigger children order in types * use proper hover state from useHover * update tests * update docs to clarify children * remove top -5 offset * add disabled submenu example * fix iOS VO submenu closing issue * add Select story with many items * move docs chevron styles from inline to CSS snippet * export SubmenuTrigger directly without forwardRef * update render props * fix indentation * add has-submenu to tailwind plugin * remove offset * fix homepage example styles * fix docs example dark mode style * focus trigger on escape * add test for focus menu trigger after submenu closes via escape * add alpha tag to docs * add JSDoc * fix restoring focus to menu trigger after nested submenu closed via Esc key * lint * Render popover outside menu item div fixes hover event handling (events bubble through portals) * support a custom delay prop in hook and RAC * remove from homepage and tailwind starter for now * revert auto-formatting --------- Co-authored-by: Devon Govett <[email protected]> Co-authored-by: Daniel Lu <[email protected]>
1 parent d766af6 commit d95d1a7

File tree

16 files changed

+980
-47
lines changed

16 files changed

+980
-47
lines changed

packages/@react-aria/menu/src/useSubmenuTrigger.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ export interface AriaSubmenuTriggerProps {
3030
/** Ref of the menu that contains the submenu trigger. */
3131
parentMenuRef: RefObject<HTMLElement>,
3232
/** Ref of the submenu opened by the submenu trigger. */
33-
submenuRef: RefObject<HTMLElement>
33+
submenuRef: RefObject<HTMLElement>,
34+
/**
35+
* The delay time in milliseconds for the submenu to appear after hovering over the trigger.
36+
* @default 200
37+
*/
38+
delay?: number
3439
}
3540

3641
interface SubmenuTriggerProps extends AriaMenuItemProps {
@@ -59,7 +64,7 @@ export interface SubmenuTriggerAria<T> {
5964
* @param ref - Ref to the submenu trigger element.
6065
*/
6166
export function UNSTABLE_useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: SubmenuTriggerState, ref: RefObject<FocusableElement>): SubmenuTriggerAria<T> {
62-
let {parentMenuRef, submenuRef, type = 'menu', isDisabled, node} = props;
67+
let {parentMenuRef, submenuRef, type = 'menu', isDisabled, node, delay = 200} = props;
6368
let submenuTriggerId = useId();
6469
let overlayId = useId();
6570
let {direction} = useLocale();
@@ -188,7 +193,7 @@ export function UNSTABLE_useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, st
188193
if (!openTimeout.current) {
189194
openTimeout.current = setTimeout(() => {
190195
onSubmenuOpen();
191-
}, 200);
196+
}, delay);
192197
}
193198
} else if (!isHovered) {
194199
cancelOpenTimeout();

packages/@react-aria/selection/src/useSelectableCollection.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,10 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
409409
if (!isVirtualized) {
410410
scrollIntoView(scrollRef.current, element);
411411
}
412-
scrollIntoViewport(element, {containingElement: ref.current});
412+
// Avoid scroll in iOS VO, since it may cause overlay to close (i.e. RAC submenu)
413+
if (modality !== 'virtual') {
414+
scrollIntoViewport(element, {containingElement: ref.current});
415+
}
413416
}
414417
}
415418

packages/react-aria-components/docs/Menu.mdx

Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {Layout} from '@react-spectrum/docs';
1111
export default Layout;
1212

1313
import docs from 'docs:react-aria-components';
14-
import {PropTable, HeaderInfo, TypeLink, PageDescription, StateTable, ContextTable} from '@react-spectrum/docs';
14+
import {PropTable, HeaderInfo, TypeLink, PageDescription, StateTable, ContextTable, VersionBadge} from '@react-spectrum/docs';
1515
import styles from '@react-spectrum/docs/src/docs.css';
1616
import packageData from 'react-aria-components/package.json';
1717
import Anatomy from './MenuAnatomy.svg';
@@ -38,7 +38,7 @@ type: component
3838

3939
<HeaderInfo
4040
packageData={packageData}
41-
componentNames={['MenuTrigger', 'Menu']}
41+
componentNames={['MenuTrigger', 'Menu', 'SubmenuTrigger']}
4242
sourceData={[
4343
{type: 'W3C', url: 'https://www.w3.org/WAI/ARIA/apg/patterns/menu/'}
4444
]} />
@@ -226,7 +226,18 @@ function MyMenuButton<T extends object>({label, children, ...props}: MyMenuButto
226226
}
227227

228228
function MyItem(props: MenuItemProps) {
229-
return <MenuItem {...props} className={({isFocused, isSelected}) => `my-item ${isFocused ? 'focused' : ''}`} />
229+
return (
230+
<MenuItem {...props} className={({isFocused, isSelected, isOpen}) => `my-item ${isFocused ? 'focused' : ''} ${isOpen ? 'open' : ''}`}>
231+
{({hasSubmenu}) => (
232+
<>
233+
{props.children}
234+
{hasSubmenu && (
235+
<svg className="chevron" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></svg>
236+
)}
237+
</>
238+
)}
239+
</MenuItem>
240+
);
230241
}
231242

232243
<MyMenuButton label="Edit">
@@ -254,6 +265,23 @@ function MyItem(props: MenuItemProps) {
254265
background: #e70073;
255266
color: white;
256267
}
268+
&.open:not(.focused) {
269+
background: rgba(192, 192, 192, 0.3);
270+
color: var(--text-color);
271+
}
272+
.chevron {
273+
width: 20;
274+
height: 20;
275+
fill: none;
276+
stroke: currentColor;
277+
stroke-linecap: round;
278+
stroke-linejoin: round;
279+
stroke-width: 2;
280+
position: absolute;
281+
right: 0;
282+
top: 0;
283+
height: 100%;
284+
}
257285
}
258286

259287
@media (forced-colors: active) {
@@ -658,12 +686,106 @@ function Example() {
658686
}
659687
```
660688

689+
## Submenus <VersionBadge version="alpha" style={{marginLeft: 4, verticalAlign: 'bottom'}} />
690+
691+
Submenus can be created by wrapping an item and a submenu in a `SubmenuTrigger`. The `SubmenuTrigger` accepts exactly two children: the first child should be the `MenuItem` which triggers opening of the submenu, and second child should be the `Popover` containing the submenu.
692+
693+
### Static
694+
695+
```tsx example
696+
import {Menu, Popover, SubmenuTrigger} from 'react-aria-components';
697+
698+
<MyMenuButton label="Actions">
699+
<MyItem>Cut</MyItem>
700+
<MyItem>Copy</MyItem>
701+
<MyItem>Delete</MyItem>
702+
<SubmenuTrigger>
703+
<MyItem aria-label="Share">Share</MyItem>
704+
<Popover>
705+
<Menu>
706+
<MyItem>SMS</MyItem>
707+
<MyItem>Twitter</MyItem>
708+
<SubmenuTrigger>
709+
<MyItem aria-label="Email">Email</MyItem>
710+
<Popover>
711+
<Menu>
712+
<MyItem>Work</MyItem>
713+
<MyItem>Personal</MyItem>
714+
</Menu>
715+
</Popover>
716+
</SubmenuTrigger>
717+
</Menu>
718+
</Popover>
719+
</SubmenuTrigger>
720+
</MyMenuButton>
721+
```
722+
723+
### Dynamic
724+
725+
You can define a recursive function to render the nested menu items dynamically.
726+
727+
```tsx example
728+
import {Menu, Popover, SubmenuTrigger} from 'react-aria-components';
729+
730+
let items = [
731+
{id: 'cut', name: 'Cut'},
732+
{id: 'copy', name: 'Copy'},
733+
{id: 'delete', name: 'Delete'},
734+
{id: 'share', name: 'Share', children: [
735+
{id: 'sms', name: 'SMS'},
736+
{id: 'twitter', name: 'Twitter'},
737+
{id: 'email', name: 'Email', children: [
738+
{id: 'work', name: 'Work'},
739+
{id: 'personal', name: 'Personal'},
740+
]}
741+
]}
742+
];
743+
744+
<MyMenuButton label="Actions" items={items}>
745+
{function renderSubmenu(item) {
746+
if (item.children) {
747+
return (
748+
<SubmenuTrigger>
749+
<MyItem key={item.name} aria-label={item.name}>{item.name}</MyItem>
750+
<Popover>
751+
<Menu items={item.children}>
752+
{(item) => renderSubmenu(item)}
753+
</Menu>
754+
</Popover>
755+
</SubmenuTrigger>
756+
);
757+
} else {
758+
return <MyItem key={item.name}>{item.name}</MyItem>;
759+
}
760+
}}
761+
</MyMenuButton>
762+
```
763+
764+
<details>
765+
<summary style={{fontWeight: 'bold'}}><ChevronRight size="S" /> Show CSS</summary>
766+
767+
```css
768+
.react-aria-Popover[data-trigger=SubmenuTrigger][data-placement="right"] {
769+
margin-left: -5px;
770+
}
771+
772+
.react-aria-Popover[data-trigger=SubmenuTrigger][data-placement="left"] {
773+
margin-right: -5px;
774+
}
775+
```
776+
777+
</details>
778+
661779
## Props
662780

663781
### MenuTrigger
664782

665783
<PropTable component={docs.exports.MenuTrigger} links={docs.links} />
666784

785+
### SubmenuTrigger
786+
787+
<PropTable component={docs.exports.SubmenuTrigger} links={docs.links} />
788+
667789
### Button
668790

669791
A `<Button>` accepts its contents as `children`. Other props such as `onPress` and `isDisabled` will be set by the `MenuTrigger`.
@@ -812,6 +934,18 @@ Within a MenuTrigger, the popover will have the `data-trigger="MenuTrigger"` att
812934
}
813935
```
814936

937+
Within a SubmenuTrigger, the popover will have the `data-trigger="SubmenuTrigger"` attribute, which can be used to define submenu-specific styles.
938+
939+
```css render=false
940+
.react-aria-Popover[data-trigger=SubmenuTrigger][data-placement="right"] {
941+
transform: translateX(-5px);
942+
}
943+
944+
.react-aria-Popover[data-trigger=SubmenuTrigger][data-placement="left"] {
945+
transform: translateX(5px);
946+
}
947+
```
948+
815949
### Menu
816950

817951
A `Menu` can be targeted with the `.react-aria-Menu` CSS selector, or by overriding with a custom `className`.

packages/react-aria-components/example/index.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ html {
1515
.menu, .group {
1616
padding: 0;
1717
list-style: none;
18+
overflow-y: auto;
19+
max-height: inherit;
1820
}
1921

2022
.item {
@@ -43,11 +45,20 @@ html {
4345
}
4446
}
4547

48+
.item[data-disabled] {
49+
opacity: 0.4;
50+
}
51+
4652
.item.focused {
4753
background: gray;
4854
color: white;
4955
}
5056

57+
.item.open:not(.focused) {
58+
background: lightslategray;
59+
color: white;
60+
}
61+
5162
.item.item.hovered {
5263
background: lightsalmon;
5364
color: white;
@@ -58,6 +69,20 @@ html {
5869
color: white;
5970
}
6071

72+
.item[data-has-submenu]::after {
73+
content: '›';
74+
content: '›' / '';
75+
justify-self: end;
76+
}
77+
78+
.popover[data-trigger=SubmenuTrigger][data-placement="right"] {
79+
margin-left: -8px;
80+
}
81+
82+
.popover[data-trigger=SubmenuTrigger][data-placement="left"] {
83+
margin-right: -8px;
84+
}
85+
6186
.wrapper {
6287
display: flex;
6388
flex-direction: column;

packages/react-aria-components/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@
4141
"@internationalized/string": "^3.2.0",
4242
"@react-aria/focus": "^3.16.0",
4343
"@react-aria/interactions": "^3.20.1",
44+
"@react-aria/menu": "^3.12.0",
4445
"@react-aria/toolbar": "3.0.0-beta.1",
4546
"@react-aria/utils": "^3.23.0",
47+
"@react-stately/menu": "^3.6.0",
4648
"@react-stately/table": "^3.11.4",
4749
"@react-stately/utils": "^3.9.0",
4850
"@react-types/form": "^3.7.1",

0 commit comments

Comments
 (0)