Skip to content

Commit

Permalink
Merge pull request #1966 from dxc-technology/gomezivann/contextualMen…
Browse files Browse the repository at this point in the history
…u-update

New attribute for Contextual Menu items
  • Loading branch information
Mil4n0r committed Apr 30, 2024
2 parents 2c43bcf + 5a559b4 commit 74dd241
Show file tree
Hide file tree
Showing 7 changed files with 96 additions and 60 deletions.
19 changes: 5 additions & 14 deletions lib/src/contextual-menu/ContextualMenu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import DxcContextualMenu, { ContextualMenuContext } from "./ContextualMenu";
import DxcContainer from "../container/Container";
import SingleItem from "./SingleItem";
import ExampleContainer from "../../.storybook/components/ExampleContainer";
import { userEvent, within } from "@storybook/test";
import DxcBadge from "../badge/Badge";
import { disabledRules } from "../../test/accessibility/rules/specific/contextual-menu/disabledRules";
import preview from "../../.storybook/preview";
Expand All @@ -29,7 +28,7 @@ const items = [{ label: "Item 1" }, { label: "Item 2" }, { label: "Item 3" }, {

const sections = [
{
title: "Team repositories",
title: "Section title",
items: [{ label: "Approved locations" }, { label: "Approved locations" }, { label: "Approved locations" }],
},
{
Expand All @@ -54,7 +53,7 @@ const groupItems = [
icon: "bookmark",
badge: <DxcBadge color="purple" label="Experimental" />,
},
{ label: "Selected Item 3" },
{ label: "Selected Item 3", selectedByDefault: true },
],
},
],
Expand Down Expand Up @@ -114,7 +113,7 @@ const sectionsWithScroll = [
{ label: "Approved locations" },
{ label: "Approved locations" },
{ label: "Approved locations" },
{ label: "Approved locations" },
{ label: "Approved locations", selectedByDefault: true },
],
},
];
Expand All @@ -135,7 +134,7 @@ const itemsWithTruncatedText = [
},
];

const ContextualMenu = () => (
export const Chromatic = () => (
<>
<Title title="Default" theme="light" level={3} />
<ExampleContainer>
Expand Down Expand Up @@ -171,7 +170,7 @@ const ContextualMenu = () => (
<DxcContextualMenu items={itemsWithTruncatedText} />
</DxcContainer>
</ExampleContainer>
<Title title="With scroll" theme="light" level={3} />
<Title title="With auto-scroll" theme="light" level={3} />
<ExampleContainer>
<DxcContainer height="300px" width="300px">
<DxcContextualMenu items={sectionsWithScroll} />
Expand All @@ -186,14 +185,6 @@ const ContextualMenu = () => (
</>
);

export const Chromatic = ContextualMenu.bind({});
Chromatic.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText("Grouped Item 1"));
await userEvent.click(canvas.getByText("Grouped Item 2"));
await userEvent.click(canvas.getByText("Selected Item 3"));
};

export const SingleItemStates = () => (
<DxcContainer width="300px">
<ContextualMenuContext.Provider value={{ selectedItemId: -1, setSelectedItemId: () => {} }}>
Expand Down
43 changes: 34 additions & 9 deletions lib/src/contextual-menu/ContextualMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { render, fireEvent, getByRole } from "@testing-library/react";
import { render, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import DxcContextualMenu from "./ContextualMenu";

Expand Down Expand Up @@ -32,16 +32,27 @@ const groups = [
];

describe("Contextual menu component tests", () => {
test("Default - Renders with correct aria attributes", () => {
test("Single - Renders with correct aria attributes", async () => {
const { getAllByRole, getByRole } = render(<DxcContextualMenu items={items} />);
expect(getAllByRole("menuitem").length).toBe(4);
const actions = getAllByRole("button");
await userEvent.click(actions[0]);
expect(actions[0].getAttribute("aria-selected")).toBeTruthy();
expect(getByRole("menu")).toBeTruthy();
});
test("Single - An item can appear as selected by default by using the attribute selectedByDefault", () => {
const test = [
{
label: "Tested item",
selectedByDefault: true,
},
];
const { getByRole } = render(<DxcContextualMenu items={test} />);
const item = getByRole("button");
expect(item.getAttribute("aria-selected")).toBeTruthy();
});
test("Group - Group items collapse when clicked", async () => {
const { queryByText, getByText, getAllByRole } = render(<DxcContextualMenu items={groups} />);
const group1 = getAllByRole("button")[0];
const { queryByText, getByText } = render(<DxcContextualMenu items={groups} />);
await userEvent.click(getByText("Grouped Item 1"));
expect(getByText("Item 1")).toBeTruthy();
expect(getByText("Grouped Item 2")).toBeTruthy();
Expand All @@ -54,16 +65,28 @@ describe("Contextual menu component tests", () => {
expect(queryByText("Item 3")).toBeFalsy();
});
test("Group - Renders with correct aria attributes", async () => {
const { getByText, getAllByRole } = render(<DxcContextualMenu items={groups} />);
const { getAllByRole } = render(<DxcContextualMenu items={groups} />);
const group1 = getAllByRole("button")[0];
await userEvent.click(group1);
expect(group1.getAttribute("aria-expanded")).toBeTruthy();
expect(group1.getAttribute("aria-controls")).toBe(getAllByRole("list")[0].id);
await userEvent.click(getByText("Grouped Item 2"));
await userEvent.click(getByText("Grouped Item 3"));
await userEvent.click(getAllByRole("button")[2]);
await userEvent.click(getAllByRole("button")[6]);
expect(getAllByRole("menuitem").length).toBe(10);
const actions = getAllByRole("button");
expect(actions[4].getAttribute("aria-selected")).toBeTruthy();
const optionToBeClicked = getAllByRole("button")[4];
await userEvent.click(optionToBeClicked);
expect(optionToBeClicked.getAttribute("aria-selected")).toBeTruthy();
});
test("Group - A grouped item, selected by default, must be visible (expanded group) in the first render of the component", () => {
const test = [
{
label: "Grouped item",
items: [{ label: "Tested item", selectedByDefault: true }],
},
];
const { getByText, getAllByRole } = render(<DxcContextualMenu items={test} />);
expect(getByText("Tested item")).toBeTruthy();
expect(getAllByRole("button")[1].getAttribute("aria-selected")).toBeTruthy();
});
test("Group - Collapsed groups render as selected when containing a selected item", async () => {
const { getAllByRole } = render(<DxcContextualMenu items={groups} />);
Expand All @@ -88,6 +111,8 @@ describe("Contextual menu component tests", () => {
await userEvent.click(actions[0]);
expect(actions[0].getAttribute("aria-selected")).toBeTruthy();
expect(getAllByRole("group").length).toBe(2);
const section = getAllByRole("group")[0];
expect(section.getAttribute("aria-labelledby")).toBe("Team repositories");
});
test("The onSelect event from each item is called correctly", () => {
const test = [
Expand Down
21 changes: 16 additions & 5 deletions lib/src/contextual-menu/ContextualMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { Fragment, createContext, useMemo, useState } from "react";
import React, { Fragment, createContext, useLayoutEffect, useMemo, useRef, useState } from "react";
import styled from "styled-components";
import CoreTokens from "../common/coreTokens";
import ContextualMenuPropsType, {
Expand Down Expand Up @@ -35,12 +35,13 @@ const addIdToItems = (items: ContextualMenuPropsType["items"]): (ItemWithId | Gr

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

const renderSection = (section: SectionWithId, currentSectionIndex: number, length: number) => (
<Fragment key={`section-${currentSectionIndex}`}>
<li role="group">
{section.title != null && <Title>{section.title}</Title>}
<li role="group" aria-labelledby={section.title}>
{section.title != null && <Title id={section.title}>{section.title}</Title>}
<SectionList>
{section.items.map((item, index) => (
<MenuItem item={item} key={`${item.label}-${index}`} />
Expand All @@ -55,8 +56,18 @@ const DxcContextualMenu = ({ items }: ContextualMenuPropsType) => {
</Fragment>
);

const [firstUpdate, setFirstUpdate] = useState(true);
useLayoutEffect(() => {
if (selectedItemId !== -1 && firstUpdate) {
const contextualMenuEl = contextualMenuRef?.current;
const selectedItemEl = contextualMenuEl?.querySelector("[aria-selected='true']");
contextualMenuEl?.scrollTo?.({ top: selectedItemEl?.offsetTop - contextualMenuEl?.clientHeight / 2 });
setFirstUpdate(false);
}
}, [firstUpdate, selectedItemId]);

return (
<ContextualMenu role="menu">
<ContextualMenu role="menu" ref={contextualMenuRef}>
<ContextualMenuContext.Provider value={{ selectedItemId, setSelectedItemId }}>
{itemsWithId.map((item: GroupItemWithId | ItemWithId | SectionWithId, index: number) =>
"items" in item && !("label" in item) ? (
Expand Down Expand Up @@ -104,7 +115,7 @@ const SectionList = styled.ul`
gap: ${CoreTokens.spacing_4};
`;

const Title = styled.span`
const Title = styled.h2`
margin: 0 0 ${CoreTokens.spacing_4} 0;
padding: ${CoreTokens.spacing_4};
color: ${CoreTokens.color_grey_900};
Expand Down
16 changes: 10 additions & 6 deletions lib/src/contextual-menu/GroupItem.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,36 @@
import React, { useContext, useMemo, useState } from "react";
import styled from "styled-components";
import CoreTokens from "../common/coreTokens";
import { GroupItemProps } from "./types";
import { GroupItemProps, ItemWithId } from "./types";
import MenuItem from "./MenuItem";
import ItemAction from "./ItemAction";
import { ContextualMenuContext } from "./ContextualMenu";
import DxcIcon from "../icon/Icon";

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

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

return (
<>
<ItemAction
aria-controls={groupMenuId}
aria-expanded={isOpen ? true : undefined}
aria-selected={selected}
aria-selected={groupSelected && !isOpen}
collapseIcon={isOpen ? <DxcIcon icon="filled_expand_less" /> : <DxcIcon icon="filled_expand_more" />}
onClick={() => {
setIsOpen((isOpen) => !isOpen);
}}
selected={selected}
selected={groupSelected && !isOpen}
{...props}
/>
{isOpen && (
Expand Down
16 changes: 9 additions & 7 deletions lib/src/contextual-menu/ItemAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import CoreTokens from "../common/coreTokens";
import { ItemActionProps } from "./types";
import DxcIcon from "../icon/Icon";

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

return (
<Action depthLevel={depthLevel} selected={selected} {...props}>
<Action depthLevel={depthLevel} {...props}>
<Label>
{collapseIcon}
{icon && depthLevel === 0 && (typeof icon === "string" ? <DxcIcon icon={icon} /> : icon)}
{icon && depthLevel === 0 && <Icon>{typeof icon === "string" ? <DxcIcon icon={icon} /> : icon}</Icon>}
<Text
onMouseEnter={(event: React.MouseEvent<HTMLSpanElement>) => {
const text = event.currentTarget;
Expand Down Expand Up @@ -62,10 +62,12 @@ const Action = styled.button<{ depthLevel: ItemActionProps["depthLevel"]; select
outline: 2px solid ${CoreTokens.color_blue_600};
outline-offset: -1px;
}
span::before {
display: flex;
font-size: 16px;
}
`;

const Icon = styled.span`
display: flex;
font-size: 16px;
svg {
height: 16px;
width: 16px;
Expand Down
17 changes: 9 additions & 8 deletions lib/src/contextual-menu/SingleItem.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
import React, { useContext } from "react";
import React, { useContext, useEffect } from "react";
import { ContextualMenuContext } from "./ContextualMenu";
import ItemAction from "./ItemAction";
import { SingleItemProps } from "./types";

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

const handleClick = () => {
setSelectedItemId(id);
onSelect?.();
};

useEffect(() => {
if (selectedItemId === -1 && selectedByDefault) setSelectedItemId(id);
}, [selectedItemId, selectedByDefault, id]);

return (
<ItemAction
aria-selected={selectedItemId === id}
badge={badge}
icon={icon}
label={label}
depthLevel={depthLevel}
aria-selected={selectedItemId === -1 ? selectedByDefault : selectedItemId === id}
onClick={handleClick}
selected={selectedItemId === id}
selected={selectedItemId === -1 ? selectedByDefault : selectedItemId === id}
{...props}
/>
);
};
Expand Down
24 changes: 13 additions & 11 deletions lib/src/contextual-menu/types.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import React from "react";

type SVG = React.ReactNode & React.SVGProps<SVGSVGElement>;
type Item = {
type CommonItemProps = {
badge?: React.ReactElement;
icon?: string | SVG;
label: string;
};
type Item = CommonItemProps & {
onSelect?: () => void;
selectedByDefault?: boolean;
};
type GroupItem = {
badge?: React.ReactElement;
icon?: string | SVG;
type GroupItem = CommonItemProps & {
items: (Item | GroupItem)[];
label: string;
};
type Section = { items: (Item | GroupItem)[]; title?: string };
type Props = {
Expand All @@ -32,12 +32,14 @@ type SectionWithId = { items: (ItemWithId | GroupItemWithId)[]; title?: string }
type SingleItemProps = ItemWithId & { depthLevel: number };
type GroupItemProps = GroupItemWithId & { depthLevel: number };
type MenuItemProps = { item: ItemWithId | GroupItemWithId; depthLevel?: number };
type ItemActionProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
Item & {
collapseIcon?: React.ReactNode;
depthLevel: number;
selected: boolean;
};
type ItemActionProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
badge?: Item["badge"];
collapseIcon?: React.ReactNode;
depthLevel: number;
icon?: Item["icon"];
label: Item["label"];
selected: boolean;
};
type ContextualMenuContextProps = {
selectedItemId: number;
setSelectedItemId: React.Dispatch<React.SetStateAction<number>>;
Expand Down

0 comments on commit 74dd241

Please sign in to comment.