Skip to content

Commit 74dd241

Browse files
authored
Merge pull request #1966 from dxc-technology/gomezivann/contextualMenu-update
New attribute for Contextual Menu items
2 parents 2c43bcf + 5a559b4 commit 74dd241

File tree

7 files changed

+96
-60
lines changed

7 files changed

+96
-60
lines changed

lib/src/contextual-menu/ContextualMenu.stories.tsx

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import DxcContextualMenu, { ContextualMenuContext } from "./ContextualMenu";
44
import DxcContainer from "../container/Container";
55
import SingleItem from "./SingleItem";
66
import ExampleContainer from "../../.storybook/components/ExampleContainer";
7-
import { userEvent, within } from "@storybook/test";
87
import DxcBadge from "../badge/Badge";
98
import { disabledRules } from "../../test/accessibility/rules/specific/contextual-menu/disabledRules";
109
import preview from "../../.storybook/preview";
@@ -29,7 +28,7 @@ const items = [{ label: "Item 1" }, { label: "Item 2" }, { label: "Item 3" }, {
2928

3029
const sections = [
3130
{
32-
title: "Team repositories",
31+
title: "Section title",
3332
items: [{ label: "Approved locations" }, { label: "Approved locations" }, { label: "Approved locations" }],
3433
},
3534
{
@@ -54,7 +53,7 @@ const groupItems = [
5453
icon: "bookmark",
5554
badge: <DxcBadge color="purple" label="Experimental" />,
5655
},
57-
{ label: "Selected Item 3" },
56+
{ label: "Selected Item 3", selectedByDefault: true },
5857
],
5958
},
6059
],
@@ -114,7 +113,7 @@ const sectionsWithScroll = [
114113
{ label: "Approved locations" },
115114
{ label: "Approved locations" },
116115
{ label: "Approved locations" },
117-
{ label: "Approved locations" },
116+
{ label: "Approved locations", selectedByDefault: true },
118117
],
119118
},
120119
];
@@ -135,7 +134,7 @@ const itemsWithTruncatedText = [
135134
},
136135
];
137136

138-
const ContextualMenu = () => (
137+
export const Chromatic = () => (
139138
<>
140139
<Title title="Default" theme="light" level={3} />
141140
<ExampleContainer>
@@ -171,7 +170,7 @@ const ContextualMenu = () => (
171170
<DxcContextualMenu items={itemsWithTruncatedText} />
172171
</DxcContainer>
173172
</ExampleContainer>
174-
<Title title="With scroll" theme="light" level={3} />
173+
<Title title="With auto-scroll" theme="light" level={3} />
175174
<ExampleContainer>
176175
<DxcContainer height="300px" width="300px">
177176
<DxcContextualMenu items={sectionsWithScroll} />
@@ -186,14 +185,6 @@ const ContextualMenu = () => (
186185
</>
187186
);
188187

189-
export const Chromatic = ContextualMenu.bind({});
190-
Chromatic.play = async ({ canvasElement }) => {
191-
const canvas = within(canvasElement);
192-
await userEvent.click(canvas.getByText("Grouped Item 1"));
193-
await userEvent.click(canvas.getByText("Grouped Item 2"));
194-
await userEvent.click(canvas.getByText("Selected Item 3"));
195-
};
196-
197188
export const SingleItemStates = () => (
198189
<DxcContainer width="300px">
199190
<ContextualMenuContext.Provider value={{ selectedItemId: -1, setSelectedItemId: () => {} }}>

lib/src/contextual-menu/ContextualMenu.test.tsx

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react";
2-
import { render, fireEvent, getByRole } from "@testing-library/react";
2+
import { render, fireEvent } from "@testing-library/react";
33
import userEvent from "@testing-library/user-event";
44
import DxcContextualMenu from "./ContextualMenu";
55

@@ -32,16 +32,27 @@ const groups = [
3232
];
3333

3434
describe("Contextual menu component tests", () => {
35-
test("Default - Renders with correct aria attributes", () => {
35+
test("Single - Renders with correct aria attributes", async () => {
3636
const { getAllByRole, getByRole } = render(<DxcContextualMenu items={items} />);
3737
expect(getAllByRole("menuitem").length).toBe(4);
3838
const actions = getAllByRole("button");
39+
await userEvent.click(actions[0]);
3940
expect(actions[0].getAttribute("aria-selected")).toBeTruthy();
4041
expect(getByRole("menu")).toBeTruthy();
4142
});
43+
test("Single - An item can appear as selected by default by using the attribute selectedByDefault", () => {
44+
const test = [
45+
{
46+
label: "Tested item",
47+
selectedByDefault: true,
48+
},
49+
];
50+
const { getByRole } = render(<DxcContextualMenu items={test} />);
51+
const item = getByRole("button");
52+
expect(item.getAttribute("aria-selected")).toBeTruthy();
53+
});
4254
test("Group - Group items collapse when clicked", async () => {
43-
const { queryByText, getByText, getAllByRole } = render(<DxcContextualMenu items={groups} />);
44-
const group1 = getAllByRole("button")[0];
55+
const { queryByText, getByText } = render(<DxcContextualMenu items={groups} />);
4556
await userEvent.click(getByText("Grouped Item 1"));
4657
expect(getByText("Item 1")).toBeTruthy();
4758
expect(getByText("Grouped Item 2")).toBeTruthy();
@@ -54,16 +65,28 @@ describe("Contextual menu component tests", () => {
5465
expect(queryByText("Item 3")).toBeFalsy();
5566
});
5667
test("Group - Renders with correct aria attributes", async () => {
57-
const { getByText, getAllByRole } = render(<DxcContextualMenu items={groups} />);
68+
const { getAllByRole } = render(<DxcContextualMenu items={groups} />);
5869
const group1 = getAllByRole("button")[0];
5970
await userEvent.click(group1);
6071
expect(group1.getAttribute("aria-expanded")).toBeTruthy();
6172
expect(group1.getAttribute("aria-controls")).toBe(getAllByRole("list")[0].id);
62-
await userEvent.click(getByText("Grouped Item 2"));
63-
await userEvent.click(getByText("Grouped Item 3"));
73+
await userEvent.click(getAllByRole("button")[2]);
74+
await userEvent.click(getAllByRole("button")[6]);
6475
expect(getAllByRole("menuitem").length).toBe(10);
65-
const actions = getAllByRole("button");
66-
expect(actions[4].getAttribute("aria-selected")).toBeTruthy();
76+
const optionToBeClicked = getAllByRole("button")[4];
77+
await userEvent.click(optionToBeClicked);
78+
expect(optionToBeClicked.getAttribute("aria-selected")).toBeTruthy();
79+
});
80+
test("Group - A grouped item, selected by default, must be visible (expanded group) in the first render of the component", () => {
81+
const test = [
82+
{
83+
label: "Grouped item",
84+
items: [{ label: "Tested item", selectedByDefault: true }],
85+
},
86+
];
87+
const { getByText, getAllByRole } = render(<DxcContextualMenu items={test} />);
88+
expect(getByText("Tested item")).toBeTruthy();
89+
expect(getAllByRole("button")[1].getAttribute("aria-selected")).toBeTruthy();
6790
});
6891
test("Group - Collapsed groups render as selected when containing a selected item", async () => {
6992
const { getAllByRole } = render(<DxcContextualMenu items={groups} />);
@@ -88,6 +111,8 @@ describe("Contextual menu component tests", () => {
88111
await userEvent.click(actions[0]);
89112
expect(actions[0].getAttribute("aria-selected")).toBeTruthy();
90113
expect(getAllByRole("group").length).toBe(2);
114+
const section = getAllByRole("group")[0];
115+
expect(section.getAttribute("aria-labelledby")).toBe("Team repositories");
91116
});
92117
test("The onSelect event from each item is called correctly", () => {
93118
const test = [

lib/src/contextual-menu/ContextualMenu.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { Fragment, createContext, useMemo, useState } from "react";
1+
import React, { Fragment, createContext, useLayoutEffect, useMemo, useRef, useState } from "react";
22
import styled from "styled-components";
33
import CoreTokens from "../common/coreTokens";
44
import ContextualMenuPropsType, {
@@ -35,12 +35,13 @@ const addIdToItems = (items: ContextualMenuPropsType["items"]): (ItemWithId | Gr
3535

3636
const DxcContextualMenu = ({ items }: ContextualMenuPropsType) => {
3737
const [selectedItemId, setSelectedItemId] = useState(-1);
38+
const contextualMenuRef = useRef(null);
3839
const itemsWithId = useMemo(() => addIdToItems(items), [items]);
3940

4041
const renderSection = (section: SectionWithId, currentSectionIndex: number, length: number) => (
4142
<Fragment key={`section-${currentSectionIndex}`}>
42-
<li role="group">
43-
{section.title != null && <Title>{section.title}</Title>}
43+
<li role="group" aria-labelledby={section.title}>
44+
{section.title != null && <Title id={section.title}>{section.title}</Title>}
4445
<SectionList>
4546
{section.items.map((item, index) => (
4647
<MenuItem item={item} key={`${item.label}-${index}`} />
@@ -55,8 +56,18 @@ const DxcContextualMenu = ({ items }: ContextualMenuPropsType) => {
5556
</Fragment>
5657
);
5758

59+
const [firstUpdate, setFirstUpdate] = useState(true);
60+
useLayoutEffect(() => {
61+
if (selectedItemId !== -1 && firstUpdate) {
62+
const contextualMenuEl = contextualMenuRef?.current;
63+
const selectedItemEl = contextualMenuEl?.querySelector("[aria-selected='true']");
64+
contextualMenuEl?.scrollTo?.({ top: selectedItemEl?.offsetTop - contextualMenuEl?.clientHeight / 2 });
65+
setFirstUpdate(false);
66+
}
67+
}, [firstUpdate, selectedItemId]);
68+
5869
return (
59-
<ContextualMenu role="menu">
70+
<ContextualMenu role="menu" ref={contextualMenuRef}>
6071
<ContextualMenuContext.Provider value={{ selectedItemId, setSelectedItemId }}>
6172
{itemsWithId.map((item: GroupItemWithId | ItemWithId | SectionWithId, index: number) =>
6273
"items" in item && !("label" in item) ? (
@@ -104,7 +115,7 @@ const SectionList = styled.ul`
104115
gap: ${CoreTokens.spacing_4};
105116
`;
106117

107-
const Title = styled.span`
118+
const Title = styled.h2`
108119
margin: 0 0 ${CoreTokens.spacing_4} 0;
109120
padding: ${CoreTokens.spacing_4};
110121
color: ${CoreTokens.color_grey_900};

lib/src/contextual-menu/GroupItem.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,36 @@
11
import React, { useContext, useMemo, useState } from "react";
22
import styled from "styled-components";
33
import CoreTokens from "../common/coreTokens";
4-
import { GroupItemProps } from "./types";
4+
import { GroupItemProps, ItemWithId } from "./types";
55
import MenuItem from "./MenuItem";
66
import ItemAction from "./ItemAction";
77
import { ContextualMenuContext } from "./ContextualMenu";
88
import DxcIcon from "../icon/Icon";
99

1010
const isGroupSelected = (items: GroupItemProps["items"], selectedItemId: number): boolean =>
11-
items.some((item) => ("id" in item ? item.id === selectedItemId : isGroupSelected(item.items, selectedItemId)));
11+
items.some((item) => {
12+
if ("items" in item) return isGroupSelected(item.items, selectedItemId);
13+
else if (selectedItemId !== -1) return item.id === selectedItemId;
14+
else return (item as ItemWithId).selectedByDefault;
15+
});
1216

1317
const GroupItem = ({ items, ...props }: GroupItemProps) => {
1418
const groupMenuId = `group-menu-${props.label}`;
15-
const [isOpen, setIsOpen] = useState(false);
1619
const { selectedItemId } = useContext(ContextualMenuContext);
17-
const selected = useMemo(() => !isOpen && isGroupSelected(items, selectedItemId), [isOpen, items, selectedItemId]);
20+
const groupSelected = useMemo(() => isGroupSelected(items, selectedItemId), [items, selectedItemId]);
21+
const [isOpen, setIsOpen] = useState(groupSelected && selectedItemId === -1 ? true : false);
1822

1923
return (
2024
<>
2125
<ItemAction
2226
aria-controls={groupMenuId}
2327
aria-expanded={isOpen ? true : undefined}
24-
aria-selected={selected}
28+
aria-selected={groupSelected && !isOpen}
2529
collapseIcon={isOpen ? <DxcIcon icon="filled_expand_less" /> : <DxcIcon icon="filled_expand_more" />}
2630
onClick={() => {
2731
setIsOpen((isOpen) => !isOpen);
2832
}}
29-
selected={selected}
33+
selected={groupSelected && !isOpen}
3034
{...props}
3135
/>
3236
{isOpen && (

lib/src/contextual-menu/ItemAction.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import CoreTokens from "../common/coreTokens";
44
import { ItemActionProps } from "./types";
55
import DxcIcon from "../icon/Icon";
66

7-
const ItemAction = ({ badge, collapseIcon, icon, label, depthLevel, selected, ...props }: ItemActionProps) => {
7+
const ItemAction = ({ badge, collapseIcon, icon, label, depthLevel, ...props }: ItemActionProps) => {
88
const modifiedBadge = badge && React.cloneElement(badge, { size: "small" });
99

1010
return (
11-
<Action depthLevel={depthLevel} selected={selected} {...props}>
11+
<Action depthLevel={depthLevel} {...props}>
1212
<Label>
1313
{collapseIcon}
14-
{icon && depthLevel === 0 && (typeof icon === "string" ? <DxcIcon icon={icon} /> : icon)}
14+
{icon && depthLevel === 0 && <Icon>{typeof icon === "string" ? <DxcIcon icon={icon} /> : icon}</Icon>}
1515
<Text
1616
onMouseEnter={(event: React.MouseEvent<HTMLSpanElement>) => {
1717
const text = event.currentTarget;
@@ -62,10 +62,12 @@ const Action = styled.button<{ depthLevel: ItemActionProps["depthLevel"]; select
6262
outline: 2px solid ${CoreTokens.color_blue_600};
6363
outline-offset: -1px;
6464
}
65-
span::before {
66-
display: flex;
67-
font-size: 16px;
68-
}
65+
`;
66+
67+
const Icon = styled.span`
68+
display: flex;
69+
font-size: 16px;
70+
6971
svg {
7072
height: 16px;
7173
width: 16px;

lib/src/contextual-menu/SingleItem.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,26 @@
1-
import React, { useContext } from "react";
1+
import React, { useContext, useEffect } from "react";
22
import { ContextualMenuContext } from "./ContextualMenu";
33
import ItemAction from "./ItemAction";
44
import { SingleItemProps } from "./types";
55

6-
const SingleItem = ({ badge, icon, id, label, depthLevel, onSelect }: SingleItemProps) => {
6+
const SingleItem = ({ id, onSelect, selectedByDefault, ...props }: SingleItemProps) => {
77
const { selectedItemId, setSelectedItemId } = useContext(ContextualMenuContext);
88

99
const handleClick = () => {
1010
setSelectedItemId(id);
1111
onSelect?.();
1212
};
1313

14+
useEffect(() => {
15+
if (selectedItemId === -1 && selectedByDefault) setSelectedItemId(id);
16+
}, [selectedItemId, selectedByDefault, id]);
17+
1418
return (
1519
<ItemAction
16-
aria-selected={selectedItemId === id}
17-
badge={badge}
18-
icon={icon}
19-
label={label}
20-
depthLevel={depthLevel}
20+
aria-selected={selectedItemId === -1 ? selectedByDefault : selectedItemId === id}
2121
onClick={handleClick}
22-
selected={selectedItemId === id}
22+
selected={selectedItemId === -1 ? selectedByDefault : selectedItemId === id}
23+
{...props}
2324
/>
2425
);
2526
};

lib/src/contextual-menu/types.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import React from "react";
22

33
type SVG = React.ReactNode & React.SVGProps<SVGSVGElement>;
4-
type Item = {
4+
type CommonItemProps = {
55
badge?: React.ReactElement;
66
icon?: string | SVG;
77
label: string;
8+
};
9+
type Item = CommonItemProps & {
810
onSelect?: () => void;
11+
selectedByDefault?: boolean;
912
};
10-
type GroupItem = {
11-
badge?: React.ReactElement;
12-
icon?: string | SVG;
13+
type GroupItem = CommonItemProps & {
1314
items: (Item | GroupItem)[];
14-
label: string;
1515
};
1616
type Section = { items: (Item | GroupItem)[]; title?: string };
1717
type Props = {
@@ -32,12 +32,14 @@ type SectionWithId = { items: (ItemWithId | GroupItemWithId)[]; title?: string }
3232
type SingleItemProps = ItemWithId & { depthLevel: number };
3333
type GroupItemProps = GroupItemWithId & { depthLevel: number };
3434
type MenuItemProps = { item: ItemWithId | GroupItemWithId; depthLevel?: number };
35-
type ItemActionProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
36-
Item & {
37-
collapseIcon?: React.ReactNode;
38-
depthLevel: number;
39-
selected: boolean;
40-
};
35+
type ItemActionProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
36+
badge?: Item["badge"];
37+
collapseIcon?: React.ReactNode;
38+
depthLevel: number;
39+
icon?: Item["icon"];
40+
label: Item["label"];
41+
selected: boolean;
42+
};
4143
type ContextualMenuContextProps = {
4244
selectedItemId: number;
4345
setSelectedItemId: React.Dispatch<React.SetStateAction<number>>;

0 commit comments

Comments
 (0)