Skip to content

Commit 09976b9

Browse files
committed
fork Menu and MenuItem WIP from #675
1 parent 0b4621b commit 09976b9

16 files changed

Lines changed: 1366 additions & 10 deletions

File tree

packages/react-components/src/App.tsx

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
import { Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
2-
31
import "./App.css";
42
import "@bcgov/bc-sans/css/BC_Sans.css";
53

6-
import { Button, Footer, FooterLinks, Header } from "@/components";
4+
import {
5+
Button,
6+
Footer,
7+
FooterLinks,
8+
Header,
9+
Menu,
10+
MenuItem,
11+
MenuTrigger,
12+
} from "@/components";
713
import useWindowDimensions from "@/hooks/useWindowDimensions";
814
import {
915
AccordionGroupPage,
@@ -95,13 +101,11 @@ function App() {
95101
<Button size="small" variant="secondary">
96102
Menu <SvgMenuIcon />
97103
</Button>
98-
<Popover>
99-
<Menu>
100-
<MenuItem className="menu-item">Link</MenuItem>
101-
<MenuItem className="menu-item">Link</MenuItem>
102-
<MenuItem className="menu-item">Link</MenuItem>
103-
</Menu>
104-
</Popover>
104+
<Menu>
105+
<MenuItem>Link</MenuItem>
106+
<MenuItem>Link</MenuItem>
107+
<MenuItem>Link</MenuItem>
108+
</Menu>
105109
</MenuTrigger>
106110
)}
107111
</div>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
.bcds-react-aria-Menu {
2+
display: flex;
3+
flex-direction: column;
4+
padding: var(--layout-padding-none);
5+
}
6+
7+
/* Sizing */
8+
.bcds-react-aria-Menu.small {
9+
font: var(--typography-regular-small-body);
10+
}
11+
12+
.bcds-react-aria-Menu.medium {
13+
font: var(--typography-regular-body);
14+
}
15+
16+
/* Sections */
17+
.bcds-react-aria-Menu .react-aria-Header {
18+
padding: var(--layout-padding-small);
19+
font: var(--typography-bold-small-body);
20+
}
21+
.bcds-react-aria-Menu .react-aria-MenuSection {
22+
display: flex;
23+
flex-direction: column;
24+
gap: var(--layout-margin-small);
25+
}
26+
27+
/* States */
28+
.bcds-react-aria-Menu[data-focused] {
29+
outline: none;
30+
}
31+
.bcds-react-aria-Popover:focus {
32+
outline: none;
33+
}
34+
35+
/* Popover */
36+
.bcds-react-aria-Popover {
37+
background-color: var(--surface-color-forms-default);
38+
border: var(--layout-border-width-small) solid
39+
var(--surface-color-border-default);
40+
border-radius: var(--layout-border-radius-medium);
41+
box-shadow: var(--surface-shadow-medium);
42+
box-sizing: border-box;
43+
padding: var(--layout-padding-xsmall) var(--layout-padding-xsmall);
44+
min-width: var(--trigger-width);
45+
overflow-y: scroll;
46+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expect, it } from "vitest";
2+
import "@testing-library/jest-dom";
3+
import { fireEvent, render, screen } from "@testing-library/react";
4+
5+
import Menu, { MenuSectionProps, MenuTrigger } from "./Menu";
6+
import MenuItem from "../MenuItem";
7+
import Button from "../Button";
8+
9+
describe("Menu", () => {
10+
it("renders menu items from children when opened", async () => {
11+
render(
12+
<MenuTrigger>
13+
<Button>Open</Button>
14+
<Menu aria-label="Actions">
15+
<MenuItem id="edit">Edit</MenuItem>
16+
<MenuItem id="delete">Delete</MenuItem>
17+
</Menu>
18+
</MenuTrigger>
19+
);
20+
21+
fireEvent.click(screen.getByRole("button", { name: /open/i }));
22+
23+
expect(await screen.findByRole("menu")).toBeInTheDocument();
24+
expect(
25+
await screen.findByRole("menuitem", { name: /edit/i })
26+
).toBeInTheDocument();
27+
expect(
28+
await screen.findByRole("menuitem", { name: /delete/i })
29+
).toBeInTheDocument();
30+
});
31+
32+
it("applies the size class", async () => {
33+
render(
34+
<MenuTrigger>
35+
<Button>Open</Button>
36+
<Menu aria-label="Actions" size="small">
37+
<MenuItem id="edit">Edit</MenuItem>
38+
</Menu>
39+
</MenuTrigger>
40+
);
41+
42+
fireEvent.click(screen.getByRole("button", { name: /open/i }));
43+
44+
expect(await screen.findByRole("menu")).toHaveClass(
45+
"bcds-react-aria-Menu small"
46+
);
47+
});
48+
49+
it("renders section headers and separator between sections", async () => {
50+
const sections: MenuSectionProps[] = [
51+
{
52+
id: "file",
53+
header: "File",
54+
items: [{ id: "new", children: "New" }],
55+
},
56+
{
57+
id: "edit",
58+
header: "Edit",
59+
items: [{ id: "copy", children: "Copy" }],
60+
},
61+
];
62+
63+
render(
64+
<MenuTrigger>
65+
<Button>Open</Button>
66+
<Menu aria-label="Sectioned actions" sections={sections} />
67+
</MenuTrigger>
68+
);
69+
70+
fireEvent.click(screen.getByRole("button", { name: /open/i }));
71+
72+
expect(await screen.findByText("File")).toBeInTheDocument();
73+
expect(await screen.findByText("Edit")).toBeInTheDocument();
74+
expect(
75+
await screen.findByRole("menuitem", { name: /new/i })
76+
).toBeInTheDocument();
77+
expect(
78+
await screen.findByRole("menuitem", { name: /copy/i })
79+
).toBeInTheDocument();
80+
expect(screen.getAllByRole("separator")).toHaveLength(1);
81+
});
82+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import {
2+
MenuTrigger,
3+
Menu as ReactAriaMenu,
4+
MenuProps as ReactAriaMenuProps,
5+
MenuSection,
6+
Header as MenuSectionHeader,
7+
SubmenuTrigger,
8+
Popover,
9+
PopoverProps,
10+
Key,
11+
Collection,
12+
} from "react-aria-components";
13+
14+
import MenuItem, { MenuItemProps } from "../MenuItem/MenuItem";
15+
import Separator from "../Separator";
16+
17+
import "./Menu.css";
18+
19+
export interface MenuSectionProps {
20+
/* Unique identifier for the section */
21+
id: Key;
22+
/* Text label for the section */
23+
header?: string;
24+
/* Array of items in the section */
25+
items: MenuItemProps[];
26+
}
27+
28+
export interface MenuProps<
29+
T extends MenuItemProps,
30+
> extends ReactAriaMenuProps<T> {
31+
/* Set size of menu button and items */
32+
size?: "small" | "medium";
33+
/* Use for a simple list menu */
34+
items?: T[];
35+
/* Use for a sectioned list with `items` in each section */
36+
sections?: MenuSectionProps[];
37+
/* Popover position */
38+
placement?: PopoverProps["placement"];
39+
}
40+
41+
export default function Menu<T extends MenuItemProps>({
42+
size = "medium",
43+
children,
44+
items,
45+
sections,
46+
placement,
47+
...props
48+
}: MenuProps<T>) {
49+
/* Manual composition via children */
50+
if (children) {
51+
return (
52+
<Popover className="bcds-react-aria-Popover" placement={placement}>
53+
<ReactAriaMenu
54+
className={`bcds-react-aria-Menu ${size}`}
55+
items={items}
56+
{...props}
57+
>
58+
{children}
59+
</ReactAriaMenu>
60+
</Popover>
61+
);
62+
}
63+
/* Dynamic collection via items/sections props */
64+
const sectionsArray = sections?.length
65+
? sections
66+
: [{ id: "section", header: "", items: items ? [...items] : [] }];
67+
68+
const lastSectionId =
69+
sectionsArray.length > 0
70+
? sectionsArray[sectionsArray.length - 1].id
71+
: undefined;
72+
73+
return (
74+
<Popover className="bcds-react-aria-Popover" placement={placement}>
75+
<ReactAriaMenu
76+
className={`bcds-react-aria-Menu ${size}`}
77+
{...props}
78+
items={sectionsArray}
79+
>
80+
{(section: MenuSectionProps) => (
81+
<MenuSection key={section.id} id={section.id}>
82+
{section.header && (
83+
<MenuSectionHeader>{section.header}</MenuSectionHeader>
84+
)}
85+
<Collection items={section.items}>
86+
{(item: MenuItemProps) => (
87+
<MenuItem {...item} size={size}>
88+
{item.children}
89+
</MenuItem>
90+
)}
91+
</Collection>
92+
{section.id !== lastSectionId && <Separator size="small" />}
93+
</MenuSection>
94+
)}
95+
</ReactAriaMenu>
96+
</Popover>
97+
);
98+
}
99+
100+
export { MenuTrigger, SubmenuTrigger, MenuSection, MenuSectionHeader };
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export {
2+
default,
3+
MenuTrigger,
4+
SubmenuTrigger,
5+
MenuSection,
6+
MenuSectionHeader,
7+
} from "./Menu";
8+
9+
export type { MenuProps, MenuSectionProps } from "./Menu";
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
.bcds-react-aria-MenuItem {
2+
display: flex;
3+
gap: var(--layout-margin-xsmall);
4+
padding: var(--layout-padding-small);
5+
cursor: pointer;
6+
color: var(--typography-color-secondary);
7+
}
8+
.bcds-react-aria-MenuItem--Icon {
9+
display: flex;
10+
align-self: flex-start;
11+
padding-top: var(--layout-padding-xsmall);
12+
}
13+
.bcds-react-aria-MenuItem--Content {
14+
display: flex;
15+
flex-direction: column;
16+
gap: var(--layout-padding-hair);
17+
}
18+
19+
/* Sizing */
20+
.bcds-react-aria-MenuItem.small {
21+
font: var(--typography-regular-small-body);
22+
}
23+
.bcds-react-aria-MenuItem.small
24+
> .bcds-react-aria-MenuItem--Content
25+
> *[slot="description"] {
26+
font: var(--typography-regular-label);
27+
}
28+
.bcds-react-aria-MenuItem.medium {
29+
font: var(--typography-regular-body);
30+
}
31+
.bcds-react-aria-MenuItem.medium
32+
> .bcds-react-aria-MenuItem--Content
33+
> *[slot="description"] {
34+
font: var(--typography-regular-small-body);
35+
}
36+
37+
/* Link styling */
38+
a.bcds-react-aria-MenuItem {
39+
text-decoration: none;
40+
}
41+
a.bcds-react-aria-MenuItem
42+
> .bcds-react-aria-MenuItem--Content
43+
> *[slot="label"] {
44+
color: var(--typography-color-link);
45+
text-decoration: underline var(--typography-color-link);
46+
text-underline-offset: var(--layout-margin-hair);
47+
}
48+
a.bcds-react-aria-MenuItem
49+
> .bcds-react-aria-MenuItem--Content
50+
> *[slot="description"] {
51+
color: var(--typography-color-secondary);
52+
text-decoration: none;
53+
}
54+
a.bcds-react-aria-MenuItem[data-disabled]
55+
> .bcds-react-aria-MenuItem--Content
56+
> *[slot="label"] {
57+
color: var(--typography-color-disabled);
58+
text-decoration: none;
59+
}
60+
a.bcds-react-aria-MenuItem[data-disabled]
61+
> .bcds-react-aria-MenuItem--Content
62+
> *[slot="description"] {
63+
color: var(--typography-color-disabled);
64+
}
65+
66+
/* Icon displayed when an item has a submenu */
67+
.bcds-react-aria-MenuItem > svg {
68+
width: var(--icons-size-xsmall);
69+
height: var(--icons-size-xsmall);
70+
padding: var(--layout-padding-hair);
71+
align-self: center;
72+
}
73+
74+
/* States */
75+
.bcds-react-aria-MenuItem[data-focused],
76+
.bcds-react-aria-MenuItem[data-hovered],
77+
.bcds-react-aria-MenuItem[data-hovered][data-focused] {
78+
border-radius: var(--layout-border-radius-small);
79+
background-color: var(--surface-color-menus-hover);
80+
outline: none;
81+
}
82+
.bcds-react-aria-MenuItem[data-focus-visible] {
83+
outline: solid var(--layout-border-width-medium)
84+
var(--surface-color-border-active);
85+
outline-offset: var(--layout-margin-none);
86+
border-radius: var(--layout-border-radius-small);
87+
}
88+
.bcds-react-aria-MenuItem[data-disabled] {
89+
color: var(--typography-color-disabled);
90+
cursor: not-allowed;
91+
}

0 commit comments

Comments
 (0)