From e4b62fe05ebc26ec76d00f43af9ddc0e341e20be Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Fri, 10 Oct 2025 08:14:03 +0200 Subject: [PATCH 1/6] First approach to new Sidenav implementation --- .../src/contextual-menu/ContextualMenu.tsx | 6 +- .../ContextualMenu.tsx | 62 ++++ .../ContextualMenuContext.tsx | 4 + .../src/general-contextual-menu/GroupItem.tsx | 38 ++ .../general-contextual-menu/ItemAction.tsx | 108 ++++++ .../src/general-contextual-menu/MenuItem.tsx | 27 ++ .../src/general-contextual-menu/Section.tsx | 41 +++ .../general-contextual-menu/SingleItem.tsx | 30 ++ .../src/general-contextual-menu/SubMenu.tsx | 24 ++ .../lib/src/general-contextual-menu/types.ts | 76 ++++ .../lib/src/general-contextual-menu/utils.ts | 38 ++ packages/lib/src/layout/ApplicationLayout.tsx | 4 +- .../sidenav/Sidenav.accessibility.test.tsx | 52 --- packages/lib/src/sidenav/Sidenav.stories.tsx | 342 ++++++------------ packages/lib/src/sidenav/Sidenav.tsx | 273 ++++---------- packages/lib/src/sidenav/types.ts | 95 ++++- 16 files changed, 722 insertions(+), 498 deletions(-) create mode 100644 packages/lib/src/general-contextual-menu/ContextualMenu.tsx create mode 100644 packages/lib/src/general-contextual-menu/ContextualMenuContext.tsx create mode 100644 packages/lib/src/general-contextual-menu/GroupItem.tsx create mode 100644 packages/lib/src/general-contextual-menu/ItemAction.tsx create mode 100644 packages/lib/src/general-contextual-menu/MenuItem.tsx create mode 100644 packages/lib/src/general-contextual-menu/Section.tsx create mode 100644 packages/lib/src/general-contextual-menu/SingleItem.tsx create mode 100644 packages/lib/src/general-contextual-menu/SubMenu.tsx create mode 100644 packages/lib/src/general-contextual-menu/types.ts create mode 100644 packages/lib/src/general-contextual-menu/utils.ts delete mode 100644 packages/lib/src/sidenav/Sidenav.accessibility.test.tsx diff --git a/packages/lib/src/contextual-menu/ContextualMenu.tsx b/packages/lib/src/contextual-menu/ContextualMenu.tsx index 13f58b4172..9fd9ded16c 100644 --- a/packages/lib/src/contextual-menu/ContextualMenu.tsx +++ b/packages/lib/src/contextual-menu/ContextualMenu.tsx @@ -8,7 +8,7 @@ import scrollbarStyles from "../styles/scroll"; import { addIdToItems, isSection } from "./utils"; import SubMenu from "./SubMenu"; -const ContextualMenu = styled.div` +const ContextualMenuContainer = styled.div` box-sizing: border-box; margin: 0; border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-lighter); @@ -45,7 +45,7 @@ export default function DxcContextualMenu({ items }: ContextualMenuPropsType) { }, [firstUpdate, selectedItemId]); return ( - + {itemsWithId[0] && isSection(itemsWithId[0]) ? ( (itemsWithId as SectionWithId[]).map((item, index) => ( @@ -59,6 +59,6 @@ export default function DxcContextualMenu({ items }: ContextualMenuPropsType) { )} - + ); } diff --git a/packages/lib/src/general-contextual-menu/ContextualMenu.tsx b/packages/lib/src/general-contextual-menu/ContextualMenu.tsx new file mode 100644 index 0000000000..ccfdff55ce --- /dev/null +++ b/packages/lib/src/general-contextual-menu/ContextualMenu.tsx @@ -0,0 +1,62 @@ +import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import styled from "@emotion/styled"; +import MenuItem from "./MenuItem"; +import ContextualMenuPropsType, { GroupItemWithId, ItemWithId, SectionWithId } from "./types"; +import Section from "./Section"; +import ContextualMenuContext from "./ContextualMenuContext"; +import scrollbarStyles from "../styles/scroll"; +import { addIdToItems, isSection } from "./utils"; +import SubMenu from "./SubMenu"; + +const ContextualMenuContainer = styled.div` + box-sizing: border-box; + margin: 0; + padding: var(--spacing-padding-m) var(--spacing-padding-xs); + display: grid; + gap: var(--spacing-gap-xs); + min-width: 248px; + max-height: 100%; + background-color: var(--color-bg-neutral-lightest); + overflow-y: auto; + overflow-x: hidden; + ${scrollbarStyles} +`; + +export default function DxcContextualMenu({ items }: ContextualMenuPropsType) { + const [firstUpdate, setFirstUpdate] = useState(true); + const [selectedItemId, setSelectedItemId] = useState(-1); + const contextualMenuRef = useRef(null); + const itemsWithId = useMemo(() => addIdToItems(items), [items]); + const contextValue = useMemo(() => ({ selectedItemId, setSelectedItemId }), [selectedItemId, setSelectedItemId]); + + useLayoutEffect(() => { + if (selectedItemId !== -1 && firstUpdate) { + const contextualMenuEl = contextualMenuRef.current; + const selectedItemEl = contextualMenuEl?.querySelector("[aria-pressed='true']"); + if (selectedItemEl instanceof HTMLButtonElement) { + contextualMenuEl?.scrollTo?.({ + top: (selectedItemEl?.offsetTop ?? 0) - (contextualMenuEl?.clientHeight ?? 0) / 2, + }); + } + setFirstUpdate(false); + } + }, [firstUpdate, selectedItemId]); + + return ( + + + {itemsWithId[0] && isSection(itemsWithId[0]) ? ( + (itemsWithId as SectionWithId[]).map((item, index) => ( +
+ )) + ) : ( + + {(itemsWithId as (GroupItemWithId | ItemWithId)[]).map((item, index) => ( + + ))} + + )} + + + ); +} diff --git a/packages/lib/src/general-contextual-menu/ContextualMenuContext.tsx b/packages/lib/src/general-contextual-menu/ContextualMenuContext.tsx new file mode 100644 index 0000000000..767f9f8513 --- /dev/null +++ b/packages/lib/src/general-contextual-menu/ContextualMenuContext.tsx @@ -0,0 +1,4 @@ +import { createContext } from "react"; +import { ContextualMenuContextProps } from "./types"; + +export default createContext(null); diff --git a/packages/lib/src/general-contextual-menu/GroupItem.tsx b/packages/lib/src/general-contextual-menu/GroupItem.tsx new file mode 100644 index 0000000000..d9a2474d7b --- /dev/null +++ b/packages/lib/src/general-contextual-menu/GroupItem.tsx @@ -0,0 +1,38 @@ +import { useContext, useMemo, useState, useId } from "react"; +import DxcIcon from "../icon/Icon"; +import SubMenu from "./SubMenu"; +import ItemAction from "./ItemAction"; +import MenuItem from "./MenuItem"; +import { GroupItemProps } from "./types"; +import ContextualMenuContext from "./ContextualMenuContext"; +import { isGroupSelected } from "./utils"; + +const GroupItem = ({ items, ...props }: GroupItemProps) => { + const groupMenuId = `group-menu-${useId()}`; + const { selectedItemId } = useContext(ContextualMenuContext) ?? {}; + const groupSelected = useMemo(() => isGroupSelected(items, selectedItemId), [items, selectedItemId]); + const [isOpen, setIsOpen] = useState(groupSelected && selectedItemId === -1); + + return ( + <> + : } + onClick={() => setIsOpen((isCurrentlyOpen) => !isCurrentlyOpen)} + selected={groupSelected && !isOpen} + {...props} + /> + {isOpen && ( + 0}> + {items.map((item, index) => ( + + ))} + + )} + + ); +}; + +export default GroupItem; diff --git a/packages/lib/src/general-contextual-menu/ItemAction.tsx b/packages/lib/src/general-contextual-menu/ItemAction.tsx new file mode 100644 index 0000000000..171bf8b5a4 --- /dev/null +++ b/packages/lib/src/general-contextual-menu/ItemAction.tsx @@ -0,0 +1,108 @@ +import { cloneElement, memo, MouseEvent, useState } from "react"; +import styled from "@emotion/styled"; +import { ItemActionProps } from "./types"; +import DxcIcon from "../icon/Icon"; +import { TooltipWrapper } from "../tooltip/Tooltip"; + +const Action = styled.button<{ + depthLevel: ItemActionProps["depthLevel"]; + selected: ItemActionProps["selected"]; +}>` + box-sizing: content-box; + border: none; + border-radius: var(--border-radius-s); + padding: var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xs); + margin-left: ${({ depthLevel }) => (depthLevel > 0 ? "var(--spacing-padding-xs)" : "var(--spacing-padding-none)")}; + /* ${({ depthLevel }) => `calc(var(--spacing-padding-xs) + ${depthLevel} * var(--spacing-padding-l))`}; */ + display: flex; + align-items: center; + justify-content: space-between; + background-color: ${({ selected }) => (selected ? "var(--color-bg-primary-lighter)" : "transparent")}; + height: var(--height-s); + cursor: pointer; + overflow: hidden; + + /* ${({ depthLevel }) => depthLevel > 0 && `border-left: 1px solid grey;`} */ + + &:hover { + background-color: ${({ selected }) => + selected ? "var(--color-bg-primary-medium)" : "var(--color-bg-neutral-light)"}; + } + &:active { + background-color: ${({ selected }) => + selected ? "var(--color-bg-primary-medium)" : "var(--color-bg-neutral-light)"}; + } + &:focus { + outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); + outline-offset: -2px; + } +`; + +const Label = styled.span` + display: flex; + align-items: center; + gap: var(--spacing-gap-s); + overflow: hidden; + padding-right: var(--spacing-padding-m); +`; + +const Icon = styled.span` + display: flex; + color: var(--color-fg-neutral-dark); + font-size: var(--height-xxs); + svg { + height: var(--height-xxs); + width: 16px; + } +`; + +const Text = styled.span<{ selected: ItemActionProps["selected"] }>` + color: var(--color-fg-neutral-dark); + font-family: var(--typography-font-family); + font-size: var(--typography-label-m); + font-weight: ${({ selected }) => (selected ? "var(--typography-label-semibold)" : "var(--typography-label-regular)")}; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +`; + +const Control = styled.span` + display: flex; + align-items: center; + padding: var(--spacing-padding-none); + justify-content: flex-end; + align-items: center; + gap: var(--spacing-gap-s); +`; + +const ItemAction = memo(({ badge, collapseIcon, depthLevel, icon, label, ...props }: ItemActionProps) => { + const [hasTooltip, setHasTooltip] = useState(false); + const modifiedBadge = badge && cloneElement(badge, { size: "small" }); + + return ( + + + + + {modifiedBadge} + {collapseIcon && {collapseIcon}} + + + + ); +}); + +ItemAction.displayName = "ItemAction"; + +export default ItemAction; diff --git a/packages/lib/src/general-contextual-menu/MenuItem.tsx b/packages/lib/src/general-contextual-menu/MenuItem.tsx new file mode 100644 index 0000000000..d9db1b7841 --- /dev/null +++ b/packages/lib/src/general-contextual-menu/MenuItem.tsx @@ -0,0 +1,27 @@ +import styled from "@emotion/styled"; +import GroupItem from "./GroupItem"; +import SingleItem from "./SingleItem"; +import { MenuItemProps } from "./types"; + +const MenuItemContainer = styled.li<{ depthLevel: number }>` + display: grid; + gap: var(--spacing-gap-xs); + ${({ depthLevel }) => + depthLevel > 0 && + ` + margin-left: var(--spacing-padding-m); + border-left: var(--border-width-s) solid var(--border-color-neutral-lighter); + `} +`; + +export default function MenuItem({ item, depthLevel = 0 }: MenuItemProps) { + return ( + + {"items" in item ? ( + + ) : ( + + )} + + ); +} diff --git a/packages/lib/src/general-contextual-menu/Section.tsx b/packages/lib/src/general-contextual-menu/Section.tsx new file mode 100644 index 0000000000..8cade2fba0 --- /dev/null +++ b/packages/lib/src/general-contextual-menu/Section.tsx @@ -0,0 +1,41 @@ +import { useId } from "react"; +import styled from "@emotion/styled"; +import { DxcInset } from ".."; +import DxcDivider from "../divider/Divider"; +import SubMenu from "./SubMenu"; +import MenuItem from "./MenuItem"; +import { SectionProps } from "./types"; + +const SectionContainer = styled.section` + display: grid; + gap: var(--spacing-gap-xs); +`; + +const Title = styled.h2` + all: unset; + color: var(--color-fg-neutral-dark); + font-family: var(--typography-font-family); + font-size: var(--typography-label-l); + font-weight: var(--typography-label-semibold); + padding: var(--spacing-padding-xxs); +`; + +export default function Section({ index, length, section }: SectionProps) { + const id = `section-${useId()}`; + + return ( + + {section.title && {section.title}} + + {section.items.map((item, i) => ( + + ))} + + {index !== length - 1 && ( + + + + )} + + ); +} diff --git a/packages/lib/src/general-contextual-menu/SingleItem.tsx b/packages/lib/src/general-contextual-menu/SingleItem.tsx new file mode 100644 index 0000000000..5fcd304d91 --- /dev/null +++ b/packages/lib/src/general-contextual-menu/SingleItem.tsx @@ -0,0 +1,30 @@ +import { useContext, useEffect } from "react"; +import ItemAction from "./ItemAction"; +import { SingleItemProps } from "./types"; +import ContextualMenuContext from "./ContextualMenuContext"; + +export default function SingleItem({ id, onSelect, selectedByDefault = false, ...props }: SingleItemProps) { + const { selectedItemId, setSelectedItemId } = useContext(ContextualMenuContext) ?? {}; + + const handleClick = () => { + setSelectedItemId?.(id); + onSelect?.(); + }; + + useEffect(() => { + if (selectedItemId === -1 && selectedByDefault) { + setSelectedItemId?.(id); + } + }, [selectedItemId, selectedByDefault, id]); + + return ( + + ); +} diff --git a/packages/lib/src/general-contextual-menu/SubMenu.tsx b/packages/lib/src/general-contextual-menu/SubMenu.tsx new file mode 100644 index 0000000000..4bf237475b --- /dev/null +++ b/packages/lib/src/general-contextual-menu/SubMenu.tsx @@ -0,0 +1,24 @@ +import styled from "@emotion/styled"; +import { SubMenuProps } from "./types"; + +const SubMenuContainer = styled.ul<{ outline?: boolean }>` + margin: 0; + padding: 0; + display: grid; + list-style: none; + /* + ${({ outline }) => + outline && + ` + margin-left: var(--spacing-padding-m); + border-left: var(--border-width-s) solid var(--border-color-neutral-lighter); + `} */ +`; + +export default function SubMenu({ children, id, outline }: SubMenuProps) { + return ( + + {children} + + ); +} diff --git a/packages/lib/src/general-contextual-menu/types.ts b/packages/lib/src/general-contextual-menu/types.ts new file mode 100644 index 0000000000..49f2f3af60 --- /dev/null +++ b/packages/lib/src/general-contextual-menu/types.ts @@ -0,0 +1,76 @@ +import { ButtonHTMLAttributes, Dispatch, ReactElement, ReactNode, SetStateAction } from "react"; +import { SVG } from "../common/utils"; + +type CommonItemProps = { + badge?: ReactElement; + icon?: string | SVG; + label: string; +}; +type Item = CommonItemProps & { + onSelect?: () => void; + selectedByDefault?: boolean; +}; +type GroupItem = CommonItemProps & { + items: (Item | GroupItem)[]; +}; +type Section = { items: (Item | GroupItem)[]; title?: string }; +type Props = { + /** + * Array of items to be displayed in the Contextual menu. + * Each item can be a single/simple item, a group item or a section. + */ + items: (Item | GroupItem)[] | Section[]; + /** + * If true, only the icons/initials of the items are displayed + */ + reduced: boolean; +}; + +type ItemWithId = Item & { id: number }; +type GroupItemWithId = { + badge?: ReactElement; + icon: string | SVG; + items: (ItemWithId | GroupItemWithId)[]; + label: string; +}; +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 = ButtonHTMLAttributes & { + badge?: Item["badge"]; + collapseIcon?: ReactNode; + depthLevel: number; + icon?: Item["icon"]; + label: Item["label"]; + selected: boolean; +}; +type SectionProps = { + section: SectionWithId; + index: number; + length: number; +}; +type SubMenuProps = { children: ReactNode; id?: string; outline?: boolean }; +type ContextualMenuContextProps = { + selectedItemId: number; + setSelectedItemId: Dispatch>; +}; + +export type { + ContextualMenuContextProps, + GroupItem, + GroupItemProps, + GroupItemWithId, + Item, + ItemActionProps, + ItemWithId, + SubMenuProps, + MenuItemProps, + Section, + SectionWithId, + SectionProps, + SingleItemProps, +}; + +export default Props; diff --git a/packages/lib/src/general-contextual-menu/utils.ts b/packages/lib/src/general-contextual-menu/utils.ts new file mode 100644 index 0000000000..3dfe2fb6d8 --- /dev/null +++ b/packages/lib/src/general-contextual-menu/utils.ts @@ -0,0 +1,38 @@ +import ContextualMenuPropsType, { + GroupItem, + GroupItemProps, + GroupItemWithId, + Item, + ItemWithId, + Section as SectionType, + SectionWithId, +} from "./types"; + +export const isGroupItem = (item: Item | GroupItem): item is GroupItem => "items" in item; + +export const isSection = (item: SectionType | Item | GroupItem): item is SectionType => + "items" in item && !("label" in item); + +export const addIdToItems = ( + items: ContextualMenuPropsType["items"] +): (ItemWithId | GroupItemWithId | SectionWithId)[] => { + let accId = 0; + const innerAddIdToItems = ( + items: ContextualMenuPropsType["items"] + ): (ItemWithId | GroupItemWithId | SectionWithId)[] => + items.map((item: Item | GroupItem | SectionType) => + isSection(item) + ? ({ ...item, items: innerAddIdToItems(item.items) } as SectionWithId) + : isGroupItem(item) + ? ({ ...item, items: innerAddIdToItems(item.items) } as GroupItemWithId) + : { ...item, id: accId++ } + ); + return innerAddIdToItems(items); +}; + +export const isGroupSelected = (items: GroupItemProps["items"], selectedItemId?: number): boolean => + items.some((item) => { + if ("items" in item) return isGroupSelected(item.items, selectedItemId); + else if (selectedItemId !== -1) return item.id === selectedItemId; + else return item.selectedByDefault; + }); diff --git a/packages/lib/src/layout/ApplicationLayout.tsx b/packages/lib/src/layout/ApplicationLayout.tsx index 59bceafefe..e77175ed60 100644 --- a/packages/lib/src/layout/ApplicationLayout.tsx +++ b/packages/lib/src/layout/ApplicationLayout.tsx @@ -4,7 +4,7 @@ import { responsiveSizes } from "../common/variables"; import DxcFooter from "../footer/Footer"; import DxcHeader from "../header/Header"; import DxcIcon from "../icon/Icon"; -import DxcSidenav from "../sidenav/Sidenav"; +// import DxcSidenav from "../sidenav/Sidenav"; import { SidenavContextProvider, useResponsiveSidenavVisibility } from "../sidenav/SidenavContext"; import { Tooltip } from "../tooltip/Tooltip"; import ApplicationLayoutPropsType, { AppLayoutMainPropsType } from "./types"; @@ -189,7 +189,7 @@ const DxcApplicationLayout = ({ DxcApplicationLayout.Footer = DxcFooter; DxcApplicationLayout.Header = DxcHeader; DxcApplicationLayout.Main = Main; -DxcApplicationLayout.SideNav = DxcSidenav; +// DxcApplicationLayout.SideNav = DxcSidenav; DxcApplicationLayout.useResponsiveSidenavVisibility = useResponsiveSidenavVisibility; export default DxcApplicationLayout; diff --git a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx deleted file mode 100644 index f8d93237ff..0000000000 --- a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { render } from "@testing-library/react"; -import { axe } from "../../test/accessibility/axe-helper"; -import DxcSidenav from "./Sidenav"; - -const iconSVG = ( - - - - - -); - -describe("Sidenav component accessibility tests", () => { - it("Should not have basic accessibility issues", async () => { - const { container } = render( - - -

nav-content-test

- - Link - -
- - - Lorem ipsum - Lorem ipsum - Lorem ipsum - Lorem ipsum - Lorem ipsum - - -
- ); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); -}); diff --git a/packages/lib/src/sidenav/Sidenav.stories.tsx b/packages/lib/src/sidenav/Sidenav.stories.tsx index 2902a38129..13a1db2ba3 100644 --- a/packages/lib/src/sidenav/Sidenav.stories.tsx +++ b/packages/lib/src/sidenav/Sidenav.stories.tsx @@ -1,245 +1,133 @@ import { Meta, StoryObj } from "@storybook/react"; -import { userEvent, within } from "@storybook/test"; import Title from "../../.storybook/components/Title"; import ExampleContainer from "../../.storybook/components/ExampleContainer"; -import DxcInset from "../inset/Inset"; -import DxcSelect from "../select/Select"; import DxcSidenav from "./Sidenav"; +import DxcBadge from "../badge/Badge"; +import DxcFlex from "../flex/Flex"; +import DxcTypography from "../typography/Typography"; +import DxcContainer from "../container/Container"; +import DxcButton from "../button/Button"; export default { title: "Sidenav", component: DxcSidenav, } as Meta; -const iconSVG = ( - - - { + return ( + + + {/* TODO: METER AVATAR */} + + + + Michael Ramirez + + + m.ramirez@insurance.com + + + + {/* TODO: DISCUSS WITH DESIGNERS ACTIONICON OR BUTTON? */} + - - -); - -const SideNav = () => ( - <> - - - <DxcSidenav title={<DxcSidenav.Title>Dxc technology</DxcSidenav.Title>}> - <DxcSidenav.Section> - <p> - Lorem ipsum dolor sit amet, consectetur adipiscing elit. In ullamcorper consectetur mollis. Suspendisse - vitae lacinia libero. - </p> - </DxcSidenav.Section> - <DxcSidenav.Section> - <DxcSidenav.Link>Single Link</DxcSidenav.Link> - <DxcSidenav.Group collapsable={false} title="Single Group" icon={iconSVG}> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - </DxcSidenav.Section> - <DxcSidenav.Section> - <DxcSidenav.Group collapsable title="Section Group" icon="filled_bottom_app_bar"> - <DxcSidenav.Link selected>Group Link</DxcSidenav.Link> - <DxcSidenav.Link icon={iconSVG}>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - <DxcSidenav.Link icon="filled_bottom_app_bar" newWindow> - Single Link - </DxcSidenav.Link> - <DxcSidenav.Link newWindow>Single Link</DxcSidenav.Link> - <DxcSidenav.Group collapsable={false} title="Section Group"> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - </DxcSidenav.Section> - </DxcSidenav> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-focus"> - <Title title="Focused options sidenav" theme="light" level={4} /> - <DxcSidenav title={<DxcSidenav.Title>Dxc technology</DxcSidenav.Title>}> - <DxcSidenav.Section> - <p> - Lorem ipsum dolor sit amet, consectetur adipiscing elit. In ullamcorper consectetur mollis. Suspendisse - vitae lacinia libero. - </p> - </DxcSidenav.Section> - <DxcSidenav.Section> - <DxcSidenav.Link>Single Link</DxcSidenav.Link> - <DxcSidenav.Group collapsable title="Collapsable Group"> - <DxcSidenav.Link icon="filled_bottom_app_bar">Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - </DxcSidenav.Section> - <DxcSidenav.Section> - <DxcSidenav.Group collapsable title="Collapsable Group"> - <DxcSidenav.Link selected icon={iconSVG}> - Group Link - </DxcSidenav.Link> - </DxcSidenav.Group> - <DxcSidenav.Group collapsable={false} title="Section Group"> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - </DxcSidenav.Section> - </DxcSidenav> - </ExampleContainer> - </> -); - -const CollapsedGroupSidenav = () => ( - <ExampleContainer> - <Title title="Collapsed group with a selected link" theme="light" level={4} /> - <DxcSidenav title={<DxcSidenav.Title>Dxc technology</DxcSidenav.Title>}> - <DxcSidenav.Section> - <DxcSidenav.Group collapsable title="Collapsed Group" icon={iconSVG}> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - </DxcSidenav.Section> - <DxcSidenav.Section> - <DxcSidenav.Group collapsable title="Collapsed Group"> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link selected>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - <DxcSidenav.Group collapsable title="Section Group"> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - </DxcSidenav.Section> - </DxcSidenav> - </ExampleContainer> -); - -const HoveredGroupSidenav = () => ( - <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Hover state for groups (selected and not)" theme="light" level={4} /> - <DxcSidenav title={<DxcSidenav.Title>Dxc technology</DxcSidenav.Title>}> - <DxcSidenav.Section> - <DxcSidenav.Link>Single Link</DxcSidenav.Link> - <DxcSidenav.Link>Single Link</DxcSidenav.Link> - <DxcSidenav.Group collapsable title="Collapsed Group"> - <DxcSidenav.Link selected>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - </DxcSidenav.Section> - <DxcSidenav.Section> - <DxcSidenav.Group collapsable title="Not Collapsed Group"> - <DxcSidenav.Link selected>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - <DxcSidenav.Link>Single Link</DxcSidenav.Link> - <DxcSidenav.Link>Single Link</DxcSidenav.Link> - <DxcSidenav.Group collapsable title="Collapsed Group"> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - <DxcSidenav.Group collapsable title="Collapsed Group"> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - </DxcSidenav.Section> - </DxcSidenav> - </ExampleContainer> -); + </DxcFlex> + ); +}; -const ActiveGroupSidenav = () => ( - <ExampleContainer pseudoState="pseudo-active"> - <Title title="Active state for groups (selected and not)" theme="light" level={4} /> - <DxcSidenav title={<DxcSidenav.Title>Dxc technology</DxcSidenav.Title>}> - <DxcSidenav.Section> - <DxcInset space="var(--spacing-padding-m)"> - <DxcSelect - defaultValue="1" - options={[ - { label: "v1.0.0", value: "1" }, - { label: "v2.0.0", value: "2" }, - { label: "v3.0.0", value: "3" }, - { label: "v4.0.0", value: "4" }, - ]} - size="fillParent" - /> - </DxcInset> - </DxcSidenav.Section> - <DxcSidenav.Section> - <DxcSidenav.Group collapsable title="Collapsed Group"> - <DxcSidenav.Link selected>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - </DxcSidenav.Section> - <DxcSidenav.Section> - <DxcSidenav.Group collapsable title="Not Collapsed Group"> - <DxcSidenav.Link selected>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - <DxcSidenav.Link>Single Link</DxcSidenav.Link> - <DxcSidenav.Link>Single Link</DxcSidenav.Link> - </DxcSidenav.Section> - </DxcSidenav> - </ExampleContainer> -); +const SideNav = () => { + const groupItems = [ + { + title: "Section 1", + items: [ + { + label: "Grouped Item 1", + icon: "favorite", + items: [ + { label: "Item 1" }, + { + label: "Grouped Item 2", + items: [ + { + label: "Item 2", + icon: "bookmark", + badge: <DxcBadge color="primary" label="Experimental" />, + }, + { label: "Selected Item 3", selectedByDefault: true }, + ], + }, + ], + badge: <DxcBadge color="success" label="New" />, + }, + { label: "Item 4", icon: "key" }, + ], + }, + { + title: "Section 2", + items: [ + { label: "Item 5" }, + { label: "Grouped Item 6", items: [{ label: "Item 7" }, { label: "Item 8" }] }, + { label: "Item 9" }, + ], + }, + ]; + return ( + <> + <ExampleContainer> + <Title title="Default sidenav" theme="light" level={4} /> + <DxcSidenav + items={groupItems} + title="Application Name" + logo={{ + src: "https://images.ctfassets.net/hrltx12pl8hq/5596z2BCR9KmT1KeRBrOQa/4070fd4e2f1a13f71c2c46afeb18e41c/shutterstock_451077043-hero1.jpg", + alt: "TEST", + }} + > + <DetailedAvatar /> + <DxcFlex direction="column" gap="var(--spacing-gap-m)"> + <DxcButton + icon="group" + iconPosition="after" + title="Manage clients" + label="Manage clients" + mode="secondary" + size={{ height: "medium", width: "fillParent" }} + /> + <DxcButton + icon="note_add" + iconPosition="after" + title="Start new application" + label="Start new application" + size={{ height: "medium", width: "fillParent" }} + /> + </DxcFlex> + </DxcSidenav> + </ExampleContainer> + </> + ); +}; type Story = StoryObj<typeof DxcSidenav>; export const Chromatic: Story = { render: SideNav, }; - -export const CollapsableGroup: Story = { - render: CollapsedGroupSidenav, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const collapsableGroups = canvas.getAllByText("Collapsed Group"); - for (const group of collapsableGroups) { - await userEvent.click(group); - } - }, -}; - -export const CollapsedHoverGroup: Story = { - render: HoveredGroupSidenav, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const collapsableGroups = canvas.getAllByText("Collapsed Group"); - for (const group of collapsableGroups) { - await userEvent.click(group); - } - await new Promise((resolve) => { - setTimeout(resolve, 1000); - }); - }, -}; - -export const CollapsedActiveGroup: Story = { - render: ActiveGroupSidenav, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const collapsableGroups = canvas.getAllByText("Collapsed Group"); - if (collapsableGroups[0]) { - await userEvent.click(collapsableGroups[0]); - } - }, -}; diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx index 814dd5b576..4aa94fbb8f 100644 --- a/packages/lib/src/sidenav/Sidenav.tsx +++ b/packages/lib/src/sidenav/Sidenav.tsx @@ -1,246 +1,97 @@ -import { forwardRef, MouseEvent, useContext, useEffect, useState } from "react"; import styled from "@emotion/styled"; import { responsiveSizes } from "../common/variables"; import DxcFlex from "../flex/Flex"; -import DxcIcon from "../icon/Icon"; -import { GroupContext, GroupContextProvider, useResponsiveSidenavVisibility } from "./SidenavContext"; -import SidenavPropsType, { - SidenavGroupPropsType, - SidenavLinkPropsType, - SidenavSectionPropsType, - SidenavTitlePropsType, -} from "./types"; +import SidenavPropsType, { Logo } from "./types"; import scrollbarStyles from "../styles/scroll"; import DxcDivider from "../divider/Divider"; -import DxcInset from "../inset/Inset"; +import DxcButton from "../button/Button"; +import DxcContextualMenu from "../general-contextual-menu/ContextualMenu"; +import DxcImage from "../image/Image"; +import { useState } from "react"; -const SidenavContainer = styled.div` +const SidenavContainer = styled.div<{ expanded: boolean }>` box-sizing: border-box; display: flex; flex-direction: column; - width: 280px; + /* TODO: ASK FINAL SIZES AND IMPLEMENT RESIZABLE SIDENAV */ + min-width: ${({ expanded }) => (expanded ? "240px" : "56px")}; + max-width: ${({ expanded }) => (expanded ? "320px" : "56px")}; height: 100%; @media (max-width: ${responsiveSizes.large}rem) { width: 100vw; } - padding: var(--spacing-padding-xl) var(--spacing-padding-none); - background-color: var(--color-bg-neutral-light); - - overflow-y: auto; + padding: var(--spacing-padding-m) var(--spacing-padding-xs); + gap: var(--spacing-gap-l); + background-color: var(--color-bg-neutral-lightest); + /* overflow-y: auto; overflow-x: hidden; - ${scrollbarStyles} + ${scrollbarStyles} */ `; const SidenavTitle = styled.div` display: flex; align-items: center; - padding: var(--spacing-padding-xs) var(--spacing-padding-m); font-family: var(--typography-font-family); - font-size: var(--typography-label-xl); - color: var(--color-fg-neutral-stronger); - font-weight: var(--typography-label-semibold); -`; - -const SidenavGroup = styled.div` - a { - padding: var(--spacing-padding-xs) var(--spacing-padding-xxl); - } -`; - -const SectionContainer = styled.div` - display: flex; - flex-direction: column; - gap: var(--spacing-gap-ml); - &:last-child { - hr { - display: none; - } - } -`; - -const SidenavGroupTitle = styled.span` - box-sizing: border-box; - display: flex; - align-items: center; - gap: var(--spacing-gap-s); - padding: var(--spacing-padding-xs) var(--spacing-padding-ml); - font-family: var(--typography-font-family); - font-size: var(--typography-label-m); - font-weight: var(--typography-label-semibold); + font-size: var(--typography-ttle-m); color: var(--color-fg-neutral-dark); - span::before { - font-size: var(--height-xxs); - } - svg { - height: var(--height-xxs); - width: 16px; - } -`; - -const SidenavGroupTitleButton = styled.button<{ selectedGroup: boolean }>` - all: unset; - box-sizing: border-box; - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: var(--spacing-padding-xs) var(--spacing-padding-ml); - font-family: var(--typography-font-family); - font-size: var(--typography-label-m); - font-weight: var(--typography-label-semibold); - cursor: pointer; - - ${(props) => - props.selectedGroup - ? `color: var(--color-fg-neutral-bright); background-color: var(--color-bg-neutral-stronger);` - : `color: var(--color-fg-neutral-stronger); background-color: transparent;`} - - &:focus, &:focus-visible { - outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); - outline-offset: -2px; - } - &:hover, - &:active { - background-color: ${(props) => - props.selectedGroup ? "var(--color-bg-neutral-strongest)" : "var(--color-bg-neutral-medium)"}; - } - span::before { - font-size: var(--height-xxs); - } - svg { - height: var(--height-xxs); - width: 16px; - } + font-weight: var(--typography-title-bold); `; -const SidenavLink = styled.a<{ selected: SidenavLinkPropsType["selected"] }>` +const LogoContainer = styled.div<{ + hasAction?: boolean; + href?: Logo["href"]; +}>` + position: relative; display: flex; + justify-content: center; align-items: center; - justify-content: space-between; - padding: var(--spacing-padding-xs) var(--spacing-padding-ml); - font-family: var(--typography-font-family); - font-size: var(--typography-label-m); - font-weight: var(--typography-label-regular); text-decoration: none; - cursor: pointer; - - ${(props) => - props.selected - ? `color: var(--color-fg-neutral-bright); background-color: var(--color-bg-neutral-stronger);` - : `color: var(--color-fg-neutral-stronger); background-color: transparent;`} - - &:focus, &:focus-visible { - outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); - outline-offset: -2px; - } - &:hover, - &:active { - background-color: ${(props) => - props.selected ? "var(--color-bg-neutral-strongest)" : "var(--color-bg-neutral-medium)"}; - } - span::before { - font-size: var(--height-xxs); - } - svg { - height: var(--height-xxs); - width: 16px; - } `; -const DxcSidenav = ({ title, children }: SidenavPropsType): JSX.Element => { +const DxcSidenav = ({ title, children, items, logo }: SidenavPropsType): JSX.Element => { + const [isExpanded, setIsExpanded] = useState(true); return ( - <SidenavContainer> - {title} - <DxcFlex direction="column" gap="var(--spacing-gap-ml)"> - {children} + <SidenavContainer expanded={isExpanded}> + <DxcFlex justifyContent="space-between"> + {/* TODO: HANDLE TITLE */} + <DxcButton + icon={`left_panel_${isExpanded ? "close" : "open"}`} + size={{ height: "medium", width: "small" }} + mode="tertiary" + title={isExpanded ? "Collapse" : "Expand"} + onClick={() => { + setIsExpanded((previousExpanded) => !previousExpanded); + }} + /> + {isExpanded && ( + <DxcFlex direction="column" gap="var(--spacing-gap-m)" justifyContent="center" alignItems="flex-start"> + {logo && ( + <LogoContainer + onClick={logo?.onClick} + hasAction={!!logo?.onClick || !!logo?.href} + role={logo?.onClick ? "button" : "presentation"} + as={logo?.href ? "a" : undefined} + href={logo?.href} + aria-label={(logo?.onClick || logo?.href) && (title || "Avatar")} + // tabIndex={logo?.onClick || logo?.href ? tabIndex : undefined} + > + <DxcImage alt={logo?.alt} src={logo?.src} height="100%" width="100%" /> + </LogoContainer> + )} + <SidenavTitle>{title}</SidenavTitle> + </DxcFlex> + )} </DxcFlex> + {/* TODO: SEARCHBAR */} + <DxcContextualMenu items={items} reduced={!isExpanded} /> + <DxcDivider color="lightGrey" /> + {children} </SidenavContainer> ); }; - -const Title = ({ children }: SidenavTitlePropsType): JSX.Element => <SidenavTitle>{children}</SidenavTitle>; - -const Section = ({ children }: SidenavSectionPropsType): JSX.Element => ( - <SectionContainer> - <DxcFlex direction="column">{children}</DxcFlex> - <DxcInset horizontal="var(--spacing-padding-ml)"> - <DxcDivider /> - </DxcInset> - </SectionContainer> -); - -const Group = ({ title, collapsable = false, icon, children }: SidenavGroupPropsType): JSX.Element => { - const [collapsed, setCollapsed] = useState(false); - const [isSelected, changeIsSelected] = useState(false); - - return ( - <GroupContextProvider value={changeIsSelected}> - <SidenavGroup> - {collapsable && title ? ( - <SidenavGroupTitleButton - aria-expanded={!collapsed} - onClick={() => setCollapsed(!collapsed)} - selectedGroup={collapsed && isSelected} - > - <DxcFlex alignItems="center" gap="var(--spacing-gap-s)"> - {typeof icon === "string" ? <DxcIcon icon={icon} /> : icon} - {title} - </DxcFlex> - <DxcIcon icon={collapsed ? "expand_more" : "expand_less"} /> - </SidenavGroupTitleButton> - ) : ( - title && ( - <SidenavGroupTitle> - {typeof icon === "string" ? <DxcIcon icon={icon} /> : icon} - {title} - </SidenavGroupTitle> - ) - )} - {!collapsed && children} - </SidenavGroup> - </GroupContextProvider> - ); -}; - -const Link = forwardRef<HTMLAnchorElement, SidenavLinkPropsType>( - ( - { href, newWindow = false, selected = false, icon, onClick, tabIndex = 0, children, ...otherProps }, - ref - ): JSX.Element => { - const changeIsGroupSelected = useContext(GroupContext); - const setIsSidenavVisibleResponsive = useResponsiveSidenavVisibility(); - const handleClick = ($event: MouseEvent<HTMLAnchorElement>) => { - onClick?.($event); - setIsSidenavVisibleResponsive?.(false); - }; - - useEffect(() => { - changeIsGroupSelected?.((isGroupSelected) => (!isGroupSelected ? selected : isGroupSelected)); - }, [selected, changeIsGroupSelected]); - - return ( - <SidenavLink - selected={selected} - href={href || undefined} - target={href ? (newWindow ? "_blank" : "_self") : undefined} - ref={ref} - tabIndex={tabIndex} - onClick={handleClick} - {...otherProps} - > - <DxcFlex alignItems="center" gap="var(--spacing-gap-s)"> - {typeof icon === "string" ? <DxcIcon icon={icon} /> : icon} - {children} - </DxcFlex> - {newWindow && <DxcIcon icon="open_in_new" />} - </SidenavLink> - ); - } -); - -DxcSidenav.Section = Section; -DxcSidenav.Group = Group; -DxcSidenav.Link = Link; -DxcSidenav.Title = Title; +// DxcSidenav.Section = Section; +// DxcSidenav.Group = Group; +// DxcSidenav.Link = Link; +// DxcSidenav.Title = Title; export default DxcSidenav; diff --git a/packages/lib/src/sidenav/types.ts b/packages/lib/src/sidenav/types.ts index ed577b49d5..699be3a59f 100644 --- a/packages/lib/src/sidenav/types.ts +++ b/packages/lib/src/sidenav/types.ts @@ -1,4 +1,4 @@ -import { MouseEvent, ReactNode } from "react"; +import { MouseEvent, ReactNode, ButtonHTMLAttributes, Dispatch, ReactElement, SetStateAction } from "react"; import { SVG } from "../common/utils"; export type SidenavTitlePropsType = { @@ -68,15 +68,104 @@ export type SidenavLinkPropsType = { tabIndex?: number; }; +export type Logo = { + /** + * URL of the image that will be placed in the logo. + */ + src: string; + /** + * Alternative text for the logo image. + */ + alt: string; + /** + * URL to navigate to when the logo is clicked. If not provided, the logo will not be clickable. + */ + onClick?: (event: MouseEvent<HTMLDivElement>) => void; + /** + * URL to navigate when the logo is clicked. + */ + href?: string; +}; + type Props = { /** - * The area assigned to render the sidenav title. It is highly recommended to use the sidenav title. + * The title of the sidenav that will be placed under the logo. */ - title?: ReactNode; + title?: string; /** * The area inside the sidenav. This area can be used to render the content inside the sidenav. */ children: ReactNode; + /** + * Array of items to be displayed in the Nav menu. + * Each item can be a single/simple item, a group item or a section. + */ + items: (Item | GroupItem)[] | Section[]; + /** + * Object with the properties of the logo placed at the top of the sidenav. + */ + logo: Logo; +}; + +type CommonItemProps = { + badge?: ReactElement; + icon?: string | SVG; + label: string; +}; +type Item = CommonItemProps & { + onSelect?: () => void; + selectedByDefault?: boolean; +}; +type GroupItem = CommonItemProps & { + items: (Item | GroupItem)[]; +}; +type Section = { items: (Item | GroupItem)[]; title?: string }; + +type ItemWithId = Item & { id: number }; +type GroupItemWithId = { + badge?: ReactElement; + icon: string | SVG; + items: (ItemWithId | GroupItemWithId)[]; + label: string; +}; +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 = ButtonHTMLAttributes<HTMLButtonElement> & { + badge?: Item["badge"]; + collapseIcon?: ReactNode; + depthLevel: number; + icon?: Item["icon"]; + label: Item["label"]; + selected: boolean; +}; +type SectionProps = { + section: SectionWithId; + index: number; + length: number; +}; +type SubMenuProps = { children: ReactNode; id?: string }; +type ContextualMenuContextProps = { + selectedItemId: number; + setSelectedItemId: Dispatch<SetStateAction<number>>; +}; + +export type { + ContextualMenuContextProps, + GroupItem, + GroupItemProps, + GroupItemWithId, + Item, + ItemActionProps, + ItemWithId, + SubMenuProps, + MenuItemProps, + Section, + SectionWithId, + SectionProps, + SingleItemProps, }; export default Props; From e4b7043ab4e3bb274f90adcdba42d97a4a0b364b Mon Sep 17 00:00:00 2001 From: Mil4n0r <morenocarmonaenrique@gmail.com> Date: Thu, 16 Oct 2025 16:15:17 +0200 Subject: [PATCH 2/6] First version of the new SideNav --- .../src/contextual-menu/ContextualMenu.tsx | 27 +++-- .../lib/src/contextual-menu/GroupItem.tsx | 2 +- .../lib/src/contextual-menu/ItemAction.tsx | 31 +++-- packages/lib/src/contextual-menu/Section.tsx | 2 +- packages/lib/src/contextual-menu/SubMenu.tsx | 17 ++- packages/lib/src/contextual-menu/types.ts | 32 +++++- .../ContextualMenu.tsx | 62 ---------- .../ContextualMenuContext.tsx | 4 - .../src/general-contextual-menu/GroupItem.tsx | 38 ------ .../general-contextual-menu/ItemAction.tsx | 108 ------------------ .../src/general-contextual-menu/MenuItem.tsx | 27 ----- .../src/general-contextual-menu/Section.tsx | 41 ------- .../general-contextual-menu/SingleItem.tsx | 30 ----- .../src/general-contextual-menu/SubMenu.tsx | 24 ---- .../lib/src/general-contextual-menu/types.ts | 76 ------------ .../lib/src/general-contextual-menu/utils.ts | 38 ------ packages/lib/src/sidenav/Sidenav.stories.tsx | 6 +- packages/lib/src/sidenav/Sidenav.tsx | 4 +- 18 files changed, 93 insertions(+), 476 deletions(-) delete mode 100644 packages/lib/src/general-contextual-menu/ContextualMenu.tsx delete mode 100644 packages/lib/src/general-contextual-menu/ContextualMenuContext.tsx delete mode 100644 packages/lib/src/general-contextual-menu/GroupItem.tsx delete mode 100644 packages/lib/src/general-contextual-menu/ItemAction.tsx delete mode 100644 packages/lib/src/general-contextual-menu/MenuItem.tsx delete mode 100644 packages/lib/src/general-contextual-menu/Section.tsx delete mode 100644 packages/lib/src/general-contextual-menu/SingleItem.tsx delete mode 100644 packages/lib/src/general-contextual-menu/SubMenu.tsx delete mode 100644 packages/lib/src/general-contextual-menu/types.ts delete mode 100644 packages/lib/src/general-contextual-menu/utils.ts diff --git a/packages/lib/src/contextual-menu/ContextualMenu.tsx b/packages/lib/src/contextual-menu/ContextualMenu.tsx index 9fd9ded16c..d28ae6c311 100644 --- a/packages/lib/src/contextual-menu/ContextualMenu.tsx +++ b/packages/lib/src/contextual-menu/ContextualMenu.tsx @@ -8,11 +8,9 @@ import scrollbarStyles from "../styles/scroll"; import { addIdToItems, isSection } from "./utils"; import SubMenu from "./SubMenu"; -const ContextualMenuContainer = styled.div` +const ContextualMenuContainer = styled.div<{ displayBorder: boolean }>` box-sizing: border-box; margin: 0; - border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-lighter); - border-radius: var(--border-radius-s); padding: var(--spacing-padding-m) var(--spacing-padding-xs); display: grid; gap: var(--spacing-gap-xs); @@ -21,15 +19,30 @@ const ContextualMenuContainer = styled.div` background-color: var(--color-bg-neutral-lightest); overflow-y: auto; overflow-x: hidden; - ${scrollbarStyles} + ${scrollbarStyles}; + + ${({ displayBorder }) => + displayBorder && + ` + border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-lighter); + border-radius: var(--border-radius-s); + `} `; -export default function DxcContextualMenu({ items }: ContextualMenuPropsType) { +export default function DxcContextualMenu({ + items, + displayBorder = true, + displayGroupsLine = false, + displayControlsAfter = false, +}: ContextualMenuPropsType) { const [firstUpdate, setFirstUpdate] = useState(true); const [selectedItemId, setSelectedItemId] = useState(-1); const contextualMenuRef = useRef<HTMLDivElement | null>(null); const itemsWithId = useMemo(() => addIdToItems(items), [items]); - const contextValue = useMemo(() => ({ selectedItemId, setSelectedItemId }), [selectedItemId, setSelectedItemId]); + const contextValue = useMemo( + () => ({ selectedItemId, setSelectedItemId, displayGroupsLine, displayControlsAfter }), + [selectedItemId, setSelectedItemId, displayGroupsLine, displayControlsAfter] + ); useLayoutEffect(() => { if (selectedItemId !== -1 && firstUpdate) { @@ -45,7 +58,7 @@ export default function DxcContextualMenu({ items }: ContextualMenuPropsType) { }, [firstUpdate, selectedItemId]); return ( - <ContextualMenuContainer ref={contextualMenuRef}> + <ContextualMenuContainer displayBorder={displayBorder} ref={contextualMenuRef}> <ContextualMenuContext.Provider value={contextValue}> {itemsWithId[0] && isSection(itemsWithId[0]) ? ( (itemsWithId as SectionWithId[]).map((item, index) => ( diff --git a/packages/lib/src/contextual-menu/GroupItem.tsx b/packages/lib/src/contextual-menu/GroupItem.tsx index ba794fd617..9112216fbe 100644 --- a/packages/lib/src/contextual-menu/GroupItem.tsx +++ b/packages/lib/src/contextual-menu/GroupItem.tsx @@ -25,7 +25,7 @@ const GroupItem = ({ items, ...props }: GroupItemProps) => { {...props} /> {isOpen && ( - <SubMenu id={groupMenuId}> + <SubMenu id={groupMenuId} depthLevel={props.depthLevel}> {items.map((item, index) => ( <MenuItem item={item} depthLevel={props.depthLevel + 1} key={`${item.label}-${index}`} /> ))} diff --git a/packages/lib/src/contextual-menu/ItemAction.tsx b/packages/lib/src/contextual-menu/ItemAction.tsx index 7476819964..642f56a12f 100644 --- a/packages/lib/src/contextual-menu/ItemAction.tsx +++ b/packages/lib/src/contextual-menu/ItemAction.tsx @@ -1,18 +1,22 @@ -import { cloneElement, memo, MouseEvent, useState } from "react"; +import { cloneElement, memo, MouseEvent, useContext, useState } from "react"; import styled from "@emotion/styled"; import { ItemActionProps } from "./types"; import DxcIcon from "../icon/Icon"; import { TooltipWrapper } from "../tooltip/Tooltip"; +import ContextualMenuContext from "./ContextualMenuContext"; const Action = styled.button<{ depthLevel: ItemActionProps["depthLevel"]; selected: ItemActionProps["selected"]; + displayGroupsLine: boolean; }>` box-sizing: content-box; border: none; border-radius: var(--border-radius-s); - padding: var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xxs) - ${({ depthLevel }) => `calc(var(--spacing-padding-xs) + ${depthLevel} * var(--spacing-padding-l))`}; + ${({ displayGroupsLine, depthLevel }) => ` + padding: var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xxs) calc(var(--spacing-padding-xs) + ${!displayGroupsLine ? depthLevel : 0} * var(--spacing-padding-l)); + ${displayGroupsLine && depthLevel > 0 ? "margin-left: var(--spacing-padding-xs);" : ""} + `} display: flex; align-items: center; gap: var(--spacing-gap-m); @@ -63,16 +67,26 @@ const Text = styled.span<{ selected: ItemActionProps["selected"] }>` overflow: hidden; `; +const Control = styled.span` + display: flex; + align-items: center; + padding: var(--spacing-padding-none); + justify-content: flex-end; + align-items: center; + gap: var(--spacing-gap-s); +`; + const ItemAction = memo(({ badge, collapseIcon, depthLevel, icon, label, ...props }: ItemActionProps) => { const [hasTooltip, setHasTooltip] = useState(false); const modifiedBadge = badge && cloneElement(badge, { size: "small" }); + const { displayControlsAfter } = useContext(ContextualMenuContext) ?? {}; return ( <TooltipWrapper condition={hasTooltip} label={label}> - <Action depthLevel={depthLevel} {...props}> + <Action depthLevel={depthLevel} displayGroupsLine={!!displayControlsAfter} {...props}> <Label> - {collapseIcon && <Icon>{collapseIcon}</Icon>} - {icon && depthLevel === 0 && <Icon>{typeof icon === "string" ? <DxcIcon icon={icon} /> : icon}</Icon>} + {!displayControlsAfter && <Control>{collapseIcon && <Icon>{collapseIcon}</Icon>}</Control>} + {icon && <Icon>{typeof icon === "string" ? <DxcIcon icon={icon} /> : icon}</Icon>} <Text selected={props.selected} onMouseEnter={(event: MouseEvent<HTMLSpanElement>) => { @@ -83,7 +97,10 @@ const ItemAction = memo(({ badge, collapseIcon, depthLevel, icon, label, ...prop {label} </Text> </Label> - {modifiedBadge} + <Control> + {modifiedBadge} + {displayControlsAfter && collapseIcon && <Icon>{collapseIcon}</Icon>} + </Control> </Action> </TooltipWrapper> ); diff --git a/packages/lib/src/contextual-menu/Section.tsx b/packages/lib/src/contextual-menu/Section.tsx index 8cade2fba0..df788ea07d 100644 --- a/packages/lib/src/contextual-menu/Section.tsx +++ b/packages/lib/src/contextual-menu/Section.tsx @@ -26,7 +26,7 @@ export default function Section({ index, length, section }: SectionProps) { return ( <SectionContainer aria-label={section.title ?? id} aria-labelledby={id}> {section.title && <Title id={id}>{section.title}} - + {section.items.map((item, i) => ( ))} diff --git a/packages/lib/src/contextual-menu/SubMenu.tsx b/packages/lib/src/contextual-menu/SubMenu.tsx index 70c003006b..aa040515b5 100644 --- a/packages/lib/src/contextual-menu/SubMenu.tsx +++ b/packages/lib/src/contextual-menu/SubMenu.tsx @@ -1,17 +1,28 @@ import styled from "@emotion/styled"; import { SubMenuProps } from "./types"; +import ContextualMenuContext from "./ContextualMenuContext"; +import { useContext } from "react"; -const SubMenuContainer = styled.ul` +const SubMenuContainer = styled.ul<{ depthLevel: number; displayGroupsLine?: boolean }>` margin: 0; padding: 0; display: grid; gap: var(--spacing-gap-xs); list-style: none; + + ${({ depthLevel, displayGroupsLine }) => + displayGroupsLine && + depthLevel >= 0 && + ` + margin-left: calc(var(--spacing-padding-m) + ${depthLevel} * var(--spacing-padding-xs)); + border-left: var(--border-width-s) solid var(--border-color-neutral-lighter); + `} `; -export default function SubMenu({ children, id }: SubMenuProps) { +export default function SubMenu({ children, id, depthLevel = 0 }: SubMenuProps) { + const { displayGroupsLine } = useContext(ContextualMenuContext) ?? {}; return ( - + {children} ); diff --git a/packages/lib/src/contextual-menu/types.ts b/packages/lib/src/contextual-menu/types.ts index e9599a7f89..c2624262de 100644 --- a/packages/lib/src/contextual-menu/types.ts +++ b/packages/lib/src/contextual-menu/types.ts @@ -20,6 +20,21 @@ type Props = { * Each item can be a single/simple item, a group item or a section. */ items: (Item | GroupItem)[] | Section[]; + /** + * If true the contextual menu will be displayed with a border. + * @private + */ + displayBorder?: boolean; + /** + * If true the contextual menu will have lines marking the groups. + * @private + */ + displayGroupsLine?: boolean; + /** + * If true the contextual menu will have controls at the end. + * @private + */ + displayControlsAfter?: boolean; }; type ItemWithId = Item & { id: number }; @@ -31,9 +46,16 @@ type GroupItemWithId = { }; 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 SingleItemProps = ItemWithId & { + depthLevel: number; +}; +type GroupItemProps = GroupItemWithId & { + depthLevel: number; +}; +type MenuItemProps = { + item: ItemWithId | GroupItemWithId; + depthLevel?: number; +}; type ItemActionProps = ButtonHTMLAttributes & { badge?: Item["badge"]; collapseIcon?: ReactNode; @@ -47,10 +69,12 @@ type SectionProps = { index: number; length: number; }; -type SubMenuProps = { children: ReactNode; id?: string }; +type SubMenuProps = { children: ReactNode; id?: string; depthLevel?: number }; type ContextualMenuContextProps = { selectedItemId: number; setSelectedItemId: Dispatch>; + displayGroupsLine: boolean; + displayControlsAfter: boolean; }; export type { diff --git a/packages/lib/src/general-contextual-menu/ContextualMenu.tsx b/packages/lib/src/general-contextual-menu/ContextualMenu.tsx deleted file mode 100644 index ccfdff55ce..0000000000 --- a/packages/lib/src/general-contextual-menu/ContextualMenu.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useLayoutEffect, useMemo, useRef, useState } from "react"; -import styled from "@emotion/styled"; -import MenuItem from "./MenuItem"; -import ContextualMenuPropsType, { GroupItemWithId, ItemWithId, SectionWithId } from "./types"; -import Section from "./Section"; -import ContextualMenuContext from "./ContextualMenuContext"; -import scrollbarStyles from "../styles/scroll"; -import { addIdToItems, isSection } from "./utils"; -import SubMenu from "./SubMenu"; - -const ContextualMenuContainer = styled.div` - box-sizing: border-box; - margin: 0; - padding: var(--spacing-padding-m) var(--spacing-padding-xs); - display: grid; - gap: var(--spacing-gap-xs); - min-width: 248px; - max-height: 100%; - background-color: var(--color-bg-neutral-lightest); - overflow-y: auto; - overflow-x: hidden; - ${scrollbarStyles} -`; - -export default function DxcContextualMenu({ items }: ContextualMenuPropsType) { - const [firstUpdate, setFirstUpdate] = useState(true); - const [selectedItemId, setSelectedItemId] = useState(-1); - const contextualMenuRef = useRef(null); - const itemsWithId = useMemo(() => addIdToItems(items), [items]); - const contextValue = useMemo(() => ({ selectedItemId, setSelectedItemId }), [selectedItemId, setSelectedItemId]); - - useLayoutEffect(() => { - if (selectedItemId !== -1 && firstUpdate) { - const contextualMenuEl = contextualMenuRef.current; - const selectedItemEl = contextualMenuEl?.querySelector("[aria-pressed='true']"); - if (selectedItemEl instanceof HTMLButtonElement) { - contextualMenuEl?.scrollTo?.({ - top: (selectedItemEl?.offsetTop ?? 0) - (contextualMenuEl?.clientHeight ?? 0) / 2, - }); - } - setFirstUpdate(false); - } - }, [firstUpdate, selectedItemId]); - - return ( - - - {itemsWithId[0] && isSection(itemsWithId[0]) ? ( - (itemsWithId as SectionWithId[]).map((item, index) => ( -
- )) - ) : ( - - {(itemsWithId as (GroupItemWithId | ItemWithId)[]).map((item, index) => ( - - ))} - - )} - - - ); -} diff --git a/packages/lib/src/general-contextual-menu/ContextualMenuContext.tsx b/packages/lib/src/general-contextual-menu/ContextualMenuContext.tsx deleted file mode 100644 index 767f9f8513..0000000000 --- a/packages/lib/src/general-contextual-menu/ContextualMenuContext.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { createContext } from "react"; -import { ContextualMenuContextProps } from "./types"; - -export default createContext(null); diff --git a/packages/lib/src/general-contextual-menu/GroupItem.tsx b/packages/lib/src/general-contextual-menu/GroupItem.tsx deleted file mode 100644 index d9a2474d7b..0000000000 --- a/packages/lib/src/general-contextual-menu/GroupItem.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useContext, useMemo, useState, useId } from "react"; -import DxcIcon from "../icon/Icon"; -import SubMenu from "./SubMenu"; -import ItemAction from "./ItemAction"; -import MenuItem from "./MenuItem"; -import { GroupItemProps } from "./types"; -import ContextualMenuContext from "./ContextualMenuContext"; -import { isGroupSelected } from "./utils"; - -const GroupItem = ({ items, ...props }: GroupItemProps) => { - const groupMenuId = `group-menu-${useId()}`; - const { selectedItemId } = useContext(ContextualMenuContext) ?? {}; - const groupSelected = useMemo(() => isGroupSelected(items, selectedItemId), [items, selectedItemId]); - const [isOpen, setIsOpen] = useState(groupSelected && selectedItemId === -1); - - return ( - <> - : } - onClick={() => setIsOpen((isCurrentlyOpen) => !isCurrentlyOpen)} - selected={groupSelected && !isOpen} - {...props} - /> - {isOpen && ( - 0}> - {items.map((item, index) => ( - - ))} - - )} - - ); -}; - -export default GroupItem; diff --git a/packages/lib/src/general-contextual-menu/ItemAction.tsx b/packages/lib/src/general-contextual-menu/ItemAction.tsx deleted file mode 100644 index 171bf8b5a4..0000000000 --- a/packages/lib/src/general-contextual-menu/ItemAction.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { cloneElement, memo, MouseEvent, useState } from "react"; -import styled from "@emotion/styled"; -import { ItemActionProps } from "./types"; -import DxcIcon from "../icon/Icon"; -import { TooltipWrapper } from "../tooltip/Tooltip"; - -const Action = styled.button<{ - depthLevel: ItemActionProps["depthLevel"]; - selected: ItemActionProps["selected"]; -}>` - box-sizing: content-box; - border: none; - border-radius: var(--border-radius-s); - padding: var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xs); - margin-left: ${({ depthLevel }) => (depthLevel > 0 ? "var(--spacing-padding-xs)" : "var(--spacing-padding-none)")}; - /* ${({ depthLevel }) => `calc(var(--spacing-padding-xs) + ${depthLevel} * var(--spacing-padding-l))`}; */ - display: flex; - align-items: center; - justify-content: space-between; - background-color: ${({ selected }) => (selected ? "var(--color-bg-primary-lighter)" : "transparent")}; - height: var(--height-s); - cursor: pointer; - overflow: hidden; - - /* ${({ depthLevel }) => depthLevel > 0 && `border-left: 1px solid grey;`} */ - - &:hover { - background-color: ${({ selected }) => - selected ? "var(--color-bg-primary-medium)" : "var(--color-bg-neutral-light)"}; - } - &:active { - background-color: ${({ selected }) => - selected ? "var(--color-bg-primary-medium)" : "var(--color-bg-neutral-light)"}; - } - &:focus { - outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); - outline-offset: -2px; - } -`; - -const Label = styled.span` - display: flex; - align-items: center; - gap: var(--spacing-gap-s); - overflow: hidden; - padding-right: var(--spacing-padding-m); -`; - -const Icon = styled.span` - display: flex; - color: var(--color-fg-neutral-dark); - font-size: var(--height-xxs); - svg { - height: var(--height-xxs); - width: 16px; - } -`; - -const Text = styled.span<{ selected: ItemActionProps["selected"] }>` - color: var(--color-fg-neutral-dark); - font-family: var(--typography-font-family); - font-size: var(--typography-label-m); - font-weight: ${({ selected }) => (selected ? "var(--typography-label-semibold)" : "var(--typography-label-regular)")}; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; -`; - -const Control = styled.span` - display: flex; - align-items: center; - padding: var(--spacing-padding-none); - justify-content: flex-end; - align-items: center; - gap: var(--spacing-gap-s); -`; - -const ItemAction = memo(({ badge, collapseIcon, depthLevel, icon, label, ...props }: ItemActionProps) => { - const [hasTooltip, setHasTooltip] = useState(false); - const modifiedBadge = badge && cloneElement(badge, { size: "small" }); - - return ( - - - - - {modifiedBadge} - {collapseIcon && {collapseIcon}} - - - - ); -}); - -ItemAction.displayName = "ItemAction"; - -export default ItemAction; diff --git a/packages/lib/src/general-contextual-menu/MenuItem.tsx b/packages/lib/src/general-contextual-menu/MenuItem.tsx deleted file mode 100644 index d9db1b7841..0000000000 --- a/packages/lib/src/general-contextual-menu/MenuItem.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import styled from "@emotion/styled"; -import GroupItem from "./GroupItem"; -import SingleItem from "./SingleItem"; -import { MenuItemProps } from "./types"; - -const MenuItemContainer = styled.li<{ depthLevel: number }>` - display: grid; - gap: var(--spacing-gap-xs); - ${({ depthLevel }) => - depthLevel > 0 && - ` - margin-left: var(--spacing-padding-m); - border-left: var(--border-width-s) solid var(--border-color-neutral-lighter); - `} -`; - -export default function MenuItem({ item, depthLevel = 0 }: MenuItemProps) { - return ( - - {"items" in item ? ( - - ) : ( - - )} - - ); -} diff --git a/packages/lib/src/general-contextual-menu/Section.tsx b/packages/lib/src/general-contextual-menu/Section.tsx deleted file mode 100644 index 8cade2fba0..0000000000 --- a/packages/lib/src/general-contextual-menu/Section.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useId } from "react"; -import styled from "@emotion/styled"; -import { DxcInset } from ".."; -import DxcDivider from "../divider/Divider"; -import SubMenu from "./SubMenu"; -import MenuItem from "./MenuItem"; -import { SectionProps } from "./types"; - -const SectionContainer = styled.section` - display: grid; - gap: var(--spacing-gap-xs); -`; - -const Title = styled.h2` - all: unset; - color: var(--color-fg-neutral-dark); - font-family: var(--typography-font-family); - font-size: var(--typography-label-l); - font-weight: var(--typography-label-semibold); - padding: var(--spacing-padding-xxs); -`; - -export default function Section({ index, length, section }: SectionProps) { - const id = `section-${useId()}`; - - return ( - - {section.title && {section.title}} - - {section.items.map((item, i) => ( - - ))} - - {index !== length - 1 && ( - - - - )} - - ); -} diff --git a/packages/lib/src/general-contextual-menu/SingleItem.tsx b/packages/lib/src/general-contextual-menu/SingleItem.tsx deleted file mode 100644 index 5fcd304d91..0000000000 --- a/packages/lib/src/general-contextual-menu/SingleItem.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useContext, useEffect } from "react"; -import ItemAction from "./ItemAction"; -import { SingleItemProps } from "./types"; -import ContextualMenuContext from "./ContextualMenuContext"; - -export default function SingleItem({ id, onSelect, selectedByDefault = false, ...props }: SingleItemProps) { - const { selectedItemId, setSelectedItemId } = useContext(ContextualMenuContext) ?? {}; - - const handleClick = () => { - setSelectedItemId?.(id); - onSelect?.(); - }; - - useEffect(() => { - if (selectedItemId === -1 && selectedByDefault) { - setSelectedItemId?.(id); - } - }, [selectedItemId, selectedByDefault, id]); - - return ( - - ); -} diff --git a/packages/lib/src/general-contextual-menu/SubMenu.tsx b/packages/lib/src/general-contextual-menu/SubMenu.tsx deleted file mode 100644 index 4bf237475b..0000000000 --- a/packages/lib/src/general-contextual-menu/SubMenu.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import styled from "@emotion/styled"; -import { SubMenuProps } from "./types"; - -const SubMenuContainer = styled.ul<{ outline?: boolean }>` - margin: 0; - padding: 0; - display: grid; - list-style: none; - /* - ${({ outline }) => - outline && - ` - margin-left: var(--spacing-padding-m); - border-left: var(--border-width-s) solid var(--border-color-neutral-lighter); - `} */ -`; - -export default function SubMenu({ children, id, outline }: SubMenuProps) { - return ( - - {children} - - ); -} diff --git a/packages/lib/src/general-contextual-menu/types.ts b/packages/lib/src/general-contextual-menu/types.ts deleted file mode 100644 index 49f2f3af60..0000000000 --- a/packages/lib/src/general-contextual-menu/types.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { ButtonHTMLAttributes, Dispatch, ReactElement, ReactNode, SetStateAction } from "react"; -import { SVG } from "../common/utils"; - -type CommonItemProps = { - badge?: ReactElement; - icon?: string | SVG; - label: string; -}; -type Item = CommonItemProps & { - onSelect?: () => void; - selectedByDefault?: boolean; -}; -type GroupItem = CommonItemProps & { - items: (Item | GroupItem)[]; -}; -type Section = { items: (Item | GroupItem)[]; title?: string }; -type Props = { - /** - * Array of items to be displayed in the Contextual menu. - * Each item can be a single/simple item, a group item or a section. - */ - items: (Item | GroupItem)[] | Section[]; - /** - * If true, only the icons/initials of the items are displayed - */ - reduced: boolean; -}; - -type ItemWithId = Item & { id: number }; -type GroupItemWithId = { - badge?: ReactElement; - icon: string | SVG; - items: (ItemWithId | GroupItemWithId)[]; - label: string; -}; -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 = ButtonHTMLAttributes & { - badge?: Item["badge"]; - collapseIcon?: ReactNode; - depthLevel: number; - icon?: Item["icon"]; - label: Item["label"]; - selected: boolean; -}; -type SectionProps = { - section: SectionWithId; - index: number; - length: number; -}; -type SubMenuProps = { children: ReactNode; id?: string; outline?: boolean }; -type ContextualMenuContextProps = { - selectedItemId: number; - setSelectedItemId: Dispatch>; -}; - -export type { - ContextualMenuContextProps, - GroupItem, - GroupItemProps, - GroupItemWithId, - Item, - ItemActionProps, - ItemWithId, - SubMenuProps, - MenuItemProps, - Section, - SectionWithId, - SectionProps, - SingleItemProps, -}; - -export default Props; diff --git a/packages/lib/src/general-contextual-menu/utils.ts b/packages/lib/src/general-contextual-menu/utils.ts deleted file mode 100644 index 3dfe2fb6d8..0000000000 --- a/packages/lib/src/general-contextual-menu/utils.ts +++ /dev/null @@ -1,38 +0,0 @@ -import ContextualMenuPropsType, { - GroupItem, - GroupItemProps, - GroupItemWithId, - Item, - ItemWithId, - Section as SectionType, - SectionWithId, -} from "./types"; - -export const isGroupItem = (item: Item | GroupItem): item is GroupItem => "items" in item; - -export const isSection = (item: SectionType | Item | GroupItem): item is SectionType => - "items" in item && !("label" in item); - -export const addIdToItems = ( - items: ContextualMenuPropsType["items"] -): (ItemWithId | GroupItemWithId | SectionWithId)[] => { - let accId = 0; - const innerAddIdToItems = ( - items: ContextualMenuPropsType["items"] - ): (ItemWithId | GroupItemWithId | SectionWithId)[] => - items.map((item: Item | GroupItem | SectionType) => - isSection(item) - ? ({ ...item, items: innerAddIdToItems(item.items) } as SectionWithId) - : isGroupItem(item) - ? ({ ...item, items: innerAddIdToItems(item.items) } as GroupItemWithId) - : { ...item, id: accId++ } - ); - return innerAddIdToItems(items); -}; - -export const isGroupSelected = (items: GroupItemProps["items"], selectedItemId?: number): boolean => - items.some((item) => { - if ("items" in item) return isGroupSelected(item.items, selectedItemId); - else if (selectedItemId !== -1) return item.id === selectedItemId; - else return item.selectedByDefault; - }); diff --git a/packages/lib/src/sidenav/Sidenav.stories.tsx b/packages/lib/src/sidenav/Sidenav.stories.tsx index 13a1db2ba3..705548e16e 100644 --- a/packages/lib/src/sidenav/Sidenav.stories.tsx +++ b/packages/lib/src/sidenav/Sidenav.stories.tsx @@ -63,7 +63,7 @@ const SideNav = () => { label: "Grouped Item 1", icon: "favorite", items: [ - { label: "Item 1" }, + { label: "Item 1", icon: "person" }, { label: "Grouped Item 2", items: [ @@ -84,8 +84,8 @@ const SideNav = () => { { title: "Section 2", items: [ - { label: "Item 5" }, - { label: "Grouped Item 6", items: [{ label: "Item 7" }, { label: "Item 8" }] }, + { label: "Item 5", icon: "person" }, + { label: "Grouped Item 6", items: [{ label: "Item 7", icon: "person" }, { label: "Item 8" }] }, { label: "Item 9" }, ], }, diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx index 4aa94fbb8f..bc5f9991a9 100644 --- a/packages/lib/src/sidenav/Sidenav.tsx +++ b/packages/lib/src/sidenav/Sidenav.tsx @@ -5,7 +5,7 @@ import SidenavPropsType, { Logo } from "./types"; import scrollbarStyles from "../styles/scroll"; import DxcDivider from "../divider/Divider"; import DxcButton from "../button/Button"; -import DxcContextualMenu from "../general-contextual-menu/ContextualMenu"; +import DxcContextualMenu from "../contextual-menu/ContextualMenu"; import DxcImage from "../image/Image"; import { useState } from "react"; @@ -83,7 +83,7 @@ const DxcSidenav = ({ title, children, items, logo }: SidenavPropsType): JSX.Ele )} {/* TODO: SEARCHBAR */} - + {children} From 9a887b359107402ec1fa55c7b99bb92dfadba92f Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Fri, 17 Oct 2025 14:55:03 +0200 Subject: [PATCH 3/6] Added responsiveness behavior --- .../src/contextual-menu/ContextualMenu.tsx | 10 +-- .../lib/src/contextual-menu/GroupItem.tsx | 54 ++++++++++++- .../lib/src/contextual-menu/ItemAction.tsx | 79 +++++++++++-------- packages/lib/src/contextual-menu/Section.tsx | 13 ++- packages/lib/src/contextual-menu/types.ts | 6 ++ packages/lib/src/sidenav/Sidenav.stories.tsx | 69 ++++++++++------ packages/lib/src/sidenav/Sidenav.tsx | 18 ++++- packages/lib/src/sidenav/types.ts | 5 +- packages/lib/src/styles/variables.css | 1 + 9 files changed, 182 insertions(+), 73 deletions(-) diff --git a/packages/lib/src/contextual-menu/ContextualMenu.tsx b/packages/lib/src/contextual-menu/ContextualMenu.tsx index d28ae6c311..827cbe7f89 100644 --- a/packages/lib/src/contextual-menu/ContextualMenu.tsx +++ b/packages/lib/src/contextual-menu/ContextualMenu.tsx @@ -11,21 +11,20 @@ import SubMenu from "./SubMenu"; const ContextualMenuContainer = styled.div<{ displayBorder: boolean }>` box-sizing: border-box; margin: 0; - padding: var(--spacing-padding-m) var(--spacing-padding-xs); display: grid; gap: var(--spacing-gap-xs); - min-width: 248px; + /* min-width: 248px; */ max-height: 100%; background-color: var(--color-bg-neutral-lightest); overflow-y: auto; overflow-x: hidden; ${scrollbarStyles}; - ${({ displayBorder }) => displayBorder && ` border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-lighter); border-radius: var(--border-radius-s); + padding: var(--spacing-padding-m) var(--spacing-padding-xs); `} `; @@ -34,14 +33,15 @@ export default function DxcContextualMenu({ displayBorder = true, displayGroupsLine = false, displayControlsAfter = false, + responsiveView = false, }: ContextualMenuPropsType) { const [firstUpdate, setFirstUpdate] = useState(true); const [selectedItemId, setSelectedItemId] = useState(-1); const contextualMenuRef = useRef(null); const itemsWithId = useMemo(() => addIdToItems(items), [items]); const contextValue = useMemo( - () => ({ selectedItemId, setSelectedItemId, displayGroupsLine, displayControlsAfter }), - [selectedItemId, setSelectedItemId, displayGroupsLine, displayControlsAfter] + () => ({ selectedItemId, setSelectedItemId, displayGroupsLine, displayControlsAfter, responsiveView }), + [selectedItemId, setSelectedItemId, displayGroupsLine, displayControlsAfter, responsiveView] ); useLayoutEffect(() => { diff --git a/packages/lib/src/contextual-menu/GroupItem.tsx b/packages/lib/src/contextual-menu/GroupItem.tsx index 9112216fbe..8cf95d3186 100644 --- a/packages/lib/src/contextual-menu/GroupItem.tsx +++ b/packages/lib/src/contextual-menu/GroupItem.tsx @@ -6,14 +6,64 @@ import MenuItem from "./MenuItem"; import { GroupItemProps } from "./types"; import ContextualMenuContext from "./ContextualMenuContext"; import { isGroupSelected } from "./utils"; +import * as Popover from "@radix-ui/react-popover"; const GroupItem = ({ items, ...props }: GroupItemProps) => { const groupMenuId = `group-menu-${useId()}`; - const { selectedItemId } = useContext(ContextualMenuContext) ?? {}; + const { selectedItemId, responsiveView } = useContext(ContextualMenuContext) ?? {}; const groupSelected = useMemo(() => isGroupSelected(items, selectedItemId), [items, selectedItemId]); const [isOpen, setIsOpen] = useState(groupSelected && selectedItemId === -1); - return ( + const contextualMenuId = `sidenav-${useId()}`; + + const contextValue = useContext(ContextualMenuContext) ?? {}; + + return responsiveView ? ( + <> + + + : } + onClick={() => setIsOpen((isCurrentlyOpen) => !isCurrentlyOpen)} + selected={groupSelected && !isOpen} + {...props} + /> + + + + { + event.preventDefault(); + }} + onOpenAutoFocus={(event) => { + event.preventDefault(); + }} + align="start" + side="right" + style={{ zIndex: "var(--z-contextualmenu)" }} + > + + {items.map((item, index) => ( + + ))} + + + + + +
+ + ) : ( <> ` box-sizing: content-box; border: none; border-radius: var(--border-radius-s); - ${({ displayGroupsLine, depthLevel }) => ` - padding: var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xxs) calc(var(--spacing-padding-xs) + ${!displayGroupsLine ? depthLevel : 0} * var(--spacing-padding-l)); + ${({ displayGroupsLine, depthLevel, responsiveView }) => ` + ${!responsiveView ? `padding: var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xxs) calc(var(--spacing-padding-xs) + ${!displayGroupsLine ? depthLevel : 0} * var(--spacing-padding-l))` : "padding: var(--spacing-padding-xxs) var(--spacing-padding-none)"}; ${displayGroupsLine && depthLevel > 0 ? "margin-left: var(--spacing-padding-xs);" : ""} `} display: flex; align-items: center; gap: var(--spacing-gap-m); - justify-content: space-between; + justify-content: ${({ responsiveView }) => (responsiveView ? "center" : "space-between")}; background-color: ${({ selected }) => (selected ? "var(--color-bg-primary-lighter)" : "transparent")}; height: var(--height-s); cursor: pointer; @@ -76,35 +77,49 @@ const Control = styled.span` gap: var(--spacing-gap-s); `; -const ItemAction = memo(({ badge, collapseIcon, depthLevel, icon, label, ...props }: ItemActionProps) => { - const [hasTooltip, setHasTooltip] = useState(false); - const modifiedBadge = badge && cloneElement(badge, { size: "small" }); - const { displayControlsAfter } = useContext(ContextualMenuContext) ?? {}; +const ItemAction = memo( + forwardRef(({ badge, collapseIcon, depthLevel, icon, label, ...props }, ref) => { + const [hasTooltip, setHasTooltip] = useState(false); + const modifiedBadge = badge && cloneElement(badge, { size: "small" }); + const { displayControlsAfter, responsiveView, displayGroupsLine } = useContext(ContextualMenuContext) ?? {}; - return ( - - - - - {modifiedBadge} - {displayControlsAfter && collapseIcon && {collapseIcon}} - - - - ); -}); + return ( + + + + {!responsiveView && ( + + {modifiedBadge} + {displayControlsAfter && collapseIcon && {collapseIcon}} + + )} + + + ); + }) +); ItemAction.displayName = "ItemAction"; diff --git a/packages/lib/src/contextual-menu/Section.tsx b/packages/lib/src/contextual-menu/Section.tsx index df788ea07d..7fd823b8ed 100644 --- a/packages/lib/src/contextual-menu/Section.tsx +++ b/packages/lib/src/contextual-menu/Section.tsx @@ -1,10 +1,11 @@ -import { useId } from "react"; +import { useContext, useId } from "react"; import styled from "@emotion/styled"; import { DxcInset } from ".."; import DxcDivider from "../divider/Divider"; import SubMenu from "./SubMenu"; import MenuItem from "./MenuItem"; import { SectionProps } from "./types"; +import ContextualMenuContext from "./ContextualMenuContext"; const SectionContainer = styled.section` display: grid; @@ -22,8 +23,8 @@ const Title = styled.h2` export default function Section({ index, length, section }: SectionProps) { const id = `section-${useId()}`; - - return ( + const { responsiveView } = useContext(ContextualMenuContext) ?? {}; + return !responsiveView ? ( {section.title && {section.title}} @@ -37,5 +38,11 @@ export default function Section({ index, length, section }: SectionProps) { )} + ) : ( + + {section.items.map((item, i) => ( + + ))} + ); } diff --git a/packages/lib/src/contextual-menu/types.ts b/packages/lib/src/contextual-menu/types.ts index c2624262de..6b8aa00116 100644 --- a/packages/lib/src/contextual-menu/types.ts +++ b/packages/lib/src/contextual-menu/types.ts @@ -35,6 +35,11 @@ type Props = { * @private */ displayControlsAfter?: boolean; + /** + * If true the contextual menu will be icons only and display a popover on click. + * @private + */ + responsiveView?: boolean; }; type ItemWithId = Item & { id: number }; @@ -75,6 +80,7 @@ type ContextualMenuContextProps = { setSelectedItemId: Dispatch>; displayGroupsLine: boolean; displayControlsAfter: boolean; + responsiveView: boolean; }; export type { diff --git a/packages/lib/src/sidenav/Sidenav.stories.tsx b/packages/lib/src/sidenav/Sidenav.stories.tsx index 3b44d37b69..b01e1ba318 100644 --- a/packages/lib/src/sidenav/Sidenav.stories.tsx +++ b/packages/lib/src/sidenav/Sidenav.stories.tsx @@ -5,8 +5,8 @@ import DxcSidenav from "./Sidenav"; import DxcBadge from "../badge/Badge"; import DxcFlex from "../flex/Flex"; import DxcTypography from "../typography/Typography"; -import DxcContainer from "../container/Container"; import DxcButton from "../button/Button"; +import DxcAvatar from "../avatar/Avatar"; export default { title: "Sidenav", @@ -18,12 +18,7 @@ const DetailedAvatar = () => { {/* TODO: METER AVATAR */} - + { alt: "TEST", }} > - - - - - + {(expanded: boolean) => + expanded ? ( + <> + + + + + + + ) : ( + <> + + + + + + + ) + } diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx index bc5f9991a9..34258676f7 100644 --- a/packages/lib/src/sidenav/Sidenav.tsx +++ b/packages/lib/src/sidenav/Sidenav.tsx @@ -50,13 +50,16 @@ const LogoContainer = styled.div<{ const DxcSidenav = ({ title, children, items, logo }: SidenavPropsType): JSX.Element => { const [isExpanded, setIsExpanded] = useState(true); + + const renderedChildren = typeof children === "function" ? children(isExpanded) : children; + return ( - + {/* TODO: HANDLE TITLE */} { @@ -83,12 +86,19 @@ const DxcSidenav = ({ title, children, items, logo }: SidenavPropsType): JSX.Ele )} {/* TODO: SEARCHBAR */} - + - {children} + {renderedChildren} ); }; + // DxcSidenav.Section = Section; // DxcSidenav.Group = Group; // DxcSidenav.Link = Link; diff --git a/packages/lib/src/sidenav/types.ts b/packages/lib/src/sidenav/types.ts index 699be3a59f..fbc7bc5e9f 100644 --- a/packages/lib/src/sidenav/types.ts +++ b/packages/lib/src/sidenav/types.ts @@ -93,9 +93,10 @@ type Props = { */ title?: string; /** - * The area inside the sidenav. This area can be used to render the content inside the sidenav. + * The additional content rendered inside the sidenav. + * It can also be a function that receives the expansion state to render different content based on it. */ - children: ReactNode; + children?: React.ReactNode | ((expanded: boolean) => React.ReactNode); /** * Array of items to be displayed in the Nav menu. * Each item can be a single/simple item, a group item or a section. diff --git a/packages/lib/src/styles/variables.css b/packages/lib/src/styles/variables.css index 253df46a42..53b0b52dc2 100644 --- a/packages/lib/src/styles/variables.css +++ b/packages/lib/src/styles/variables.css @@ -16,6 +16,7 @@ --z-dropdown: 310; --z-textinput: 320; --z-select: 330; + --z-contextualmenu: 340; /* Modals and overlays */ --z-dialog: 400; From 83f634b83326798858459b7d59caae26513bd921 Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Mon, 20 Oct 2025 11:43:40 +0200 Subject: [PATCH 4/6] Made context props optional to prevent typing warnings --- packages/lib/src/contextual-menu/types.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/lib/src/contextual-menu/types.ts b/packages/lib/src/contextual-menu/types.ts index 6b8aa00116..7bcf59b1e5 100644 --- a/packages/lib/src/contextual-menu/types.ts +++ b/packages/lib/src/contextual-menu/types.ts @@ -76,11 +76,11 @@ type SectionProps = { }; type SubMenuProps = { children: ReactNode; id?: string; depthLevel?: number }; type ContextualMenuContextProps = { - selectedItemId: number; - setSelectedItemId: Dispatch>; - displayGroupsLine: boolean; - displayControlsAfter: boolean; - responsiveView: boolean; + selectedItemId?: number; + setSelectedItemId?: Dispatch>; + displayGroupsLine?: boolean; + displayControlsAfter?: boolean; + responsiveView?: boolean; }; export type { From 323a29be991f84ee19a2ed2bf689aac627f2051a Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Mon, 20 Oct 2025 11:49:18 +0200 Subject: [PATCH 5/6] Cleaned types definition file --- packages/lib/src/sidenav/types.ts | 52 ++----------------------------- 1 file changed, 3 insertions(+), 49 deletions(-) diff --git a/packages/lib/src/sidenav/types.ts b/packages/lib/src/sidenav/types.ts index fbc7bc5e9f..e9f1a9eb50 100644 --- a/packages/lib/src/sidenav/types.ts +++ b/packages/lib/src/sidenav/types.ts @@ -1,4 +1,4 @@ -import { MouseEvent, ReactNode, ButtonHTMLAttributes, Dispatch, ReactElement, SetStateAction } from "react"; +import { MouseEvent, ReactNode, ReactElement } from "react"; import { SVG } from "../common/utils"; export type SidenavTitlePropsType = { @@ -87,6 +87,8 @@ export type Logo = { href?: string; }; +type Section = { items: (Item | GroupItem)[]; title?: string }; + type Props = { /** * The title of the sidenav that will be placed under the logo. @@ -120,53 +122,5 @@ type Item = CommonItemProps & { type GroupItem = CommonItemProps & { items: (Item | GroupItem)[]; }; -type Section = { items: (Item | GroupItem)[]; title?: string }; - -type ItemWithId = Item & { id: number }; -type GroupItemWithId = { - badge?: ReactElement; - icon: string | SVG; - items: (ItemWithId | GroupItemWithId)[]; - label: string; -}; -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 = ButtonHTMLAttributes & { - badge?: Item["badge"]; - collapseIcon?: ReactNode; - depthLevel: number; - icon?: Item["icon"]; - label: Item["label"]; - selected: boolean; -}; -type SectionProps = { - section: SectionWithId; - index: number; - length: number; -}; -type SubMenuProps = { children: ReactNode; id?: string }; -type ContextualMenuContextProps = { - selectedItemId: number; - setSelectedItemId: Dispatch>; -}; - -export type { - ContextualMenuContextProps, - GroupItem, - GroupItemProps, - GroupItemWithId, - Item, - ItemActionProps, - ItemWithId, - SubMenuProps, - MenuItemProps, - Section, - SectionWithId, - SectionProps, - SingleItemProps, -}; export default Props; From d90b88187ce298ad499b0450c21c397bb73a68a0 Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Mon, 20 Oct 2025 16:50:37 +0200 Subject: [PATCH 6/6] Added tests and stories and fixed types --- .../src/contextual-menu/ContextualMenu.tsx | 6 +- .../lib/src/contextual-menu/GroupItem.tsx | 2 +- .../lib/src/contextual-menu/ItemAction.tsx | 12 +- packages/lib/src/contextual-menu/SubMenu.tsx | 10 +- packages/lib/src/contextual-menu/types.ts | 4 +- .../sidenav/Sidenav.accessibility.test.tsx | 56 ++ packages/lib/src/sidenav/Sidenav.stories.tsx | 506 ++++++++++++++---- packages/lib/src/sidenav/Sidenav.test.tsx | 94 ++-- packages/lib/src/sidenav/Sidenav.tsx | 20 +- packages/lib/src/sidenav/types.ts | 8 +- 10 files changed, 561 insertions(+), 157 deletions(-) create mode 100644 packages/lib/src/sidenav/Sidenav.accessibility.test.tsx diff --git a/packages/lib/src/contextual-menu/ContextualMenu.tsx b/packages/lib/src/contextual-menu/ContextualMenu.tsx index 827cbe7f89..023a75e72e 100644 --- a/packages/lib/src/contextual-menu/ContextualMenu.tsx +++ b/packages/lib/src/contextual-menu/ContextualMenu.tsx @@ -31,7 +31,7 @@ const ContextualMenuContainer = styled.div<{ displayBorder: boolean }>` export default function DxcContextualMenu({ items, displayBorder = true, - displayGroupsLine = false, + displayGroupLines = false, displayControlsAfter = false, responsiveView = false, }: ContextualMenuPropsType) { @@ -40,8 +40,8 @@ export default function DxcContextualMenu({ const contextualMenuRef = useRef(null); const itemsWithId = useMemo(() => addIdToItems(items), [items]); const contextValue = useMemo( - () => ({ selectedItemId, setSelectedItemId, displayGroupsLine, displayControlsAfter, responsiveView }), - [selectedItemId, setSelectedItemId, displayGroupsLine, displayControlsAfter, responsiveView] + () => ({ selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView }), + [selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView] ); useLayoutEffect(() => { diff --git a/packages/lib/src/contextual-menu/GroupItem.tsx b/packages/lib/src/contextual-menu/GroupItem.tsx index 8cf95d3186..d8074f3122 100644 --- a/packages/lib/src/contextual-menu/GroupItem.tsx +++ b/packages/lib/src/contextual-menu/GroupItem.tsx @@ -39,7 +39,7 @@ const GroupItem = ({ items, ...props }: GroupItemProps) => { /> - + { diff --git a/packages/lib/src/contextual-menu/ItemAction.tsx b/packages/lib/src/contextual-menu/ItemAction.tsx index 45554c8ece..75b8976dd3 100644 --- a/packages/lib/src/contextual-menu/ItemAction.tsx +++ b/packages/lib/src/contextual-menu/ItemAction.tsx @@ -8,15 +8,15 @@ import ContextualMenuContext from "./ContextualMenuContext"; const Action = styled.button<{ depthLevel: ItemActionProps["depthLevel"]; selected: ItemActionProps["selected"]; - displayGroupsLine: boolean; + displayGroupLines: boolean; responsiveView?: boolean; }>` box-sizing: content-box; border: none; border-radius: var(--border-radius-s); - ${({ displayGroupsLine, depthLevel, responsiveView }) => ` - ${!responsiveView ? `padding: var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xxs) calc(var(--spacing-padding-xs) + ${!displayGroupsLine ? depthLevel : 0} * var(--spacing-padding-l))` : "padding: var(--spacing-padding-xxs) var(--spacing-padding-none)"}; - ${displayGroupsLine && depthLevel > 0 ? "margin-left: var(--spacing-padding-xs);" : ""} + ${({ displayGroupLines, depthLevel, responsiveView }) => ` + ${!responsiveView ? `padding: var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xxs) calc(var(--spacing-padding-xs) + ${!displayGroupLines ? depthLevel : 0} * var(--spacing-padding-l))` : "padding: var(--spacing-padding-xxs) var(--spacing-padding-none)"}; + ${displayGroupLines && depthLevel > 0 ? "margin-left: var(--spacing-padding-xs);" : ""} `} display: flex; align-items: center; @@ -81,14 +81,14 @@ const ItemAction = memo( forwardRef(({ badge, collapseIcon, depthLevel, icon, label, ...props }, ref) => { const [hasTooltip, setHasTooltip] = useState(false); const modifiedBadge = badge && cloneElement(badge, { size: "small" }); - const { displayControlsAfter, responsiveView, displayGroupsLine } = useContext(ContextualMenuContext) ?? {}; + const { displayControlsAfter, responsiveView, displayGroupLines } = useContext(ContextualMenuContext) ?? {}; return ( diff --git a/packages/lib/src/contextual-menu/SubMenu.tsx b/packages/lib/src/contextual-menu/SubMenu.tsx index aa040515b5..0d29a7e2c1 100644 --- a/packages/lib/src/contextual-menu/SubMenu.tsx +++ b/packages/lib/src/contextual-menu/SubMenu.tsx @@ -3,15 +3,15 @@ import { SubMenuProps } from "./types"; import ContextualMenuContext from "./ContextualMenuContext"; import { useContext } from "react"; -const SubMenuContainer = styled.ul<{ depthLevel: number; displayGroupsLine?: boolean }>` +const SubMenuContainer = styled.ul<{ depthLevel: number; displayGroupLines?: boolean }>` margin: 0; padding: 0; display: grid; gap: var(--spacing-gap-xs); list-style: none; - ${({ depthLevel, displayGroupsLine }) => - displayGroupsLine && + ${({ depthLevel, displayGroupLines }) => + displayGroupLines && depthLevel >= 0 && ` margin-left: calc(var(--spacing-padding-m) + ${depthLevel} * var(--spacing-padding-xs)); @@ -20,9 +20,9 @@ const SubMenuContainer = styled.ul<{ depthLevel: number; displayGroupsLine?: boo `; export default function SubMenu({ children, id, depthLevel = 0 }: SubMenuProps) { - const { displayGroupsLine } = useContext(ContextualMenuContext) ?? {}; + const { displayGroupLines } = useContext(ContextualMenuContext) ?? {}; return ( - + {children} ); diff --git a/packages/lib/src/contextual-menu/types.ts b/packages/lib/src/contextual-menu/types.ts index 7bcf59b1e5..ff2224ec72 100644 --- a/packages/lib/src/contextual-menu/types.ts +++ b/packages/lib/src/contextual-menu/types.ts @@ -29,7 +29,7 @@ type Props = { * If true the contextual menu will have lines marking the groups. * @private */ - displayGroupsLine?: boolean; + displayGroupLines?: boolean; /** * If true the contextual menu will have controls at the end. * @private @@ -78,7 +78,7 @@ type SubMenuProps = { children: ReactNode; id?: string; depthLevel?: number }; type ContextualMenuContextProps = { selectedItemId?: number; setSelectedItemId?: Dispatch>; - displayGroupsLine?: boolean; + displayGroupLines?: boolean; displayControlsAfter?: boolean; responsiveView?: boolean; }; diff --git a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx new file mode 100644 index 0000000000..824daa05bc --- /dev/null +++ b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx @@ -0,0 +1,56 @@ +import { render } from "@testing-library/react"; +import { axe } from "../../test/accessibility/axe-helper"; +import DxcSidenav from "./Sidenav"; +import DxcBadge from "../badge/Badge"; + +describe("Sidenav component accessibility tests", () => { + it("Should not have basic accessibility issues", async () => { + const groupItems = [ + { + title: "Section 1", + items: [ + { + label: "Grouped Item 1", + icon: "favorite", + items: [ + { label: "Item 1", icon: "person" }, + { + label: "Grouped Item 2", + items: [ + { + label: "Item 2", + icon: "bookmark", + badge: , + }, + { label: "Selected Item 3", selectedByDefault: true }, + ], + }, + ], + badge: , + }, + { label: "Item 4", icon: "key" }, + ], + }, + { + title: "Section 2", + items: [ + { label: "Item 5", icon: "person" }, + { label: "Grouped Item 6", items: [{ label: "Item 7", icon: "person" }, { label: "Item 8" }] }, + { label: "Item 9" }, + ], + }, + ]; + const { container } = render( + + ); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); +}); diff --git a/packages/lib/src/sidenav/Sidenav.stories.tsx b/packages/lib/src/sidenav/Sidenav.stories.tsx index b01e1ba318..8cc60a45d9 100644 --- a/packages/lib/src/sidenav/Sidenav.stories.tsx +++ b/packages/lib/src/sidenav/Sidenav.stories.tsx @@ -7,6 +7,7 @@ import DxcFlex from "../flex/Flex"; import DxcTypography from "../typography/Typography"; import DxcButton from "../button/Button"; import DxcAvatar from "../avatar/Avatar"; +import { userEvent, within } from "storybook/internal/test"; export default { title: "Sidenav", @@ -17,8 +18,7 @@ const DetailedAvatar = () => { return ( - {/* TODO: METER AVATAR */} - + { - {/* TODO: DISCUSS WITH DESIGNERS ACTIONICON OR BUTTON? */} { ); }; -const SideNav = () => { - const groupItems = [ - { - title: "Section 1", - items: [ - { - label: "Grouped Item 1", - icon: "favorite", - items: [ - { label: "Item 1", icon: "person" }, - { - label: "Grouped Item 2", - items: [ - { - label: "Item 2", - icon: "bookmark", - badge: , - }, - { label: "Selected Item 3", selectedByDefault: true }, - ], - }, - ], - badge: , - }, - { label: "Item 4", icon: "key" }, - ], - }, - { - title: "Section 2", - items: [ - { label: "Item 5", icon: "person" }, - { label: "Grouped Item 6", items: [{ label: "Item 7", icon: "person" }, { label: "Item 8" }] }, - { label: "Item 9" }, - ], - }, - ]; - return ( - <> - - - <DxcSidenav - items={groupItems} - title="Application Name" - logo={{ - src: "https://images.ctfassets.net/hrltx12pl8hq/5596z2BCR9KmT1KeRBrOQa/4070fd4e2f1a13f71c2c46afeb18e41c/shutterstock_451077043-hero1.jpg", - alt: "TEST", - }} - > - {(expanded: boolean) => - expanded ? ( - <> - <DetailedAvatar /> - <DxcFlex direction="column" gap="var(--spacing-gap-m)"> - <DxcButton - icon="group" - iconPosition="after" - title="Manage clients" - label="Manage clients" - mode="secondary" - size={{ height: "medium", width: "fillParent" }} - /> - <DxcButton - icon="note_add" - iconPosition="after" - title="Start new application" - label="Start new application" - size={{ height: "medium", width: "fillParent" }} - /> - </DxcFlex> - </> - ) : ( - <> - <DxcAvatar color="primary" status={{ mode: "error", position: "bottom" }} title="Michael Ramírez" /> - <DxcFlex direction="column" gap="var(--spacing-gap-m)"> - <DxcButton - icon="group" - iconPosition="after" - title="Manage clients" - mode="secondary" - size={{ height: "medium", width: "fillParent" }} - /> - <DxcButton - icon="note_add" - title="Start new application" - size={{ height: "medium", width: "fillParent" }} - /> - </DxcFlex> - </> - ) - } - </DxcSidenav> - </ExampleContainer> - </> - ); -}; +const groupItems = [ + { + title: "Section 1", + items: [ + { + label: "Grouped Item 1", + icon: "favorite", + items: [ + { label: "Item 1", icon: "person" }, + { + label: "Grouped Item 2", + items: [ + { + label: "Item 2", + icon: "bookmark", + badge: <DxcBadge color="primary" label="Experimental" />, + }, + { label: "Selected Item 3" }, + ], + }, + ], + badge: <DxcBadge color="success" label="New" />, + }, + { label: "Item 4", icon: "key" }, + ], + }, + { + title: "Section 2", + items: [ + { label: "Item 5", icon: "person" }, + { label: "Grouped Item 6", items: [{ label: "Item 7", icon: "person" }, { label: "Item 8" }] }, + { label: "Item 9" }, + ], + }, +]; +const selectedGroupItems = [ + { + title: "Section 1", + items: [ + { + label: "Grouped Item 1", + icon: "favorite", + items: [ + { label: "Item 1", icon: "person" }, + { + label: "Grouped Item 2", + items: [ + { + label: "Item 2", + icon: "bookmark", + badge: <DxcBadge color="primary" label="Experimental" />, + }, + { label: "Selected Item 3", selectedByDefault: true }, + ], + }, + ], + badge: <DxcBadge color="success" label="New" />, + }, + { label: "Item 4", icon: "key" }, + ], + }, + { + title: "Section 2", + items: [ + { label: "Item 5", icon: "person" }, + { label: "Grouped Item 6", items: [{ label: "Item 7", icon: "person" }, { label: "Item 8" }] }, + { label: "Item 9" }, + ], + }, +]; + +const SideNav = () => ( + <> + <ExampleContainer> + <Title title="Default sidenav" theme="light" level={4} /> + <DxcSidenav + items={groupItems} + title="Application Name" + logo={{ + src: "https://images.ctfassets.net/hrltx12pl8hq/5596z2BCR9KmT1KeRBrOQa/4070fd4e2f1a13f71c2c46afeb18e41c/shutterstock_451077043-hero1.jpg", + alt: "TEST", + }} + > + <DetailedAvatar /> + <DxcFlex direction="column" gap="var(--spacing-gap-m)"> + <DxcButton + icon="group" + iconPosition="after" + title="Manage clients" + label="Manage clients" + mode="secondary" + size={{ height: "medium", width: "fillParent" }} + /> + <DxcButton + icon="note_add" + iconPosition="after" + title="Start new application" + label="Start new application" + size={{ height: "medium", width: "fillParent" }} + /> + </DxcFlex> + </DxcSidenav> + </ExampleContainer> + <ExampleContainer> + <Title title="Sidenav with group lines" theme="light" level={4} /> + <DxcSidenav + items={groupItems} + title="Application Name" + logo={{ + src: "https://images.ctfassets.net/hrltx12pl8hq/5596z2BCR9KmT1KeRBrOQa/4070fd4e2f1a13f71c2c46afeb18e41c/shutterstock_451077043-hero1.jpg", + alt: "TEST", + }} + displayGroupLines + > + <DetailedAvatar /> + <DxcFlex direction="column" gap="var(--spacing-gap-m)"> + <DxcButton + icon="group" + iconPosition="after" + title="Manage clients" + label="Manage clients" + mode="secondary" + size={{ height: "medium", width: "fillParent" }} + /> + <DxcButton + icon="note_add" + iconPosition="after" + title="Start new application" + label="Start new application" + size={{ height: "medium", width: "fillParent" }} + /> + </DxcFlex> + </DxcSidenav> + </ExampleContainer> + </> +); + +const Collapsed = () => ( + <> + <ExampleContainer> + <Title title="Collapsed sidenav" theme="light" level={4} /> + <DxcSidenav items={groupItems} title="App Name"> + {(expanded: boolean) => + expanded ? ( + <> + <DetailedAvatar /> + <DxcFlex direction="column" gap="var(--spacing-gap-m)"> + <DxcButton + icon="group" + iconPosition="after" + title="Manage clients" + label="Manage clients" + mode="secondary" + size={{ height: "medium", width: "fillParent" }} + /> + <DxcButton + icon="note_add" + iconPosition="after" + title="Start new application" + label="Start new application" + size={{ height: "medium", width: "fillParent" }} + /> + </DxcFlex> + </> + ) : ( + <> + <DxcAvatar color="primary" status={{ mode: "error", position: "bottom" }} title="Michael Ramirez" /> + <DxcFlex direction="column" gap="var(--spacing-gap-m)"> + <DxcButton + icon="group" + iconPosition="after" + title="Manage clients" + mode="secondary" + size={{ height: "medium", width: "fillParent" }} + /> + <DxcButton + icon="note_add" + title="Start new application" + size={{ height: "medium", width: "fillParent" }} + /> + </DxcFlex> + </> + ) + } + </DxcSidenav> + </ExampleContainer> + <ExampleContainer> + <Title title="Collapsed sidenav with groups expanded (no lines)" theme="light" level={4} /> + <DxcSidenav items={groupItems} title="App Name"> + {(expanded: boolean) => + expanded ? ( + <> + <DetailedAvatar /> + <DxcFlex direction="column" gap="var(--spacing-gap-m)"> + <DxcButton + icon="group" + iconPosition="after" + title="Manage clients" + label="Manage clients" + mode="secondary" + size={{ height: "medium", width: "fillParent" }} + /> + <DxcButton + icon="note_add" + iconPosition="after" + title="Start new application" + label="Start new application" + size={{ height: "medium", width: "fillParent" }} + /> + </DxcFlex> + </> + ) : ( + <> + <DxcAvatar color="primary" status={{ mode: "error", position: "bottom" }} title="Michael Ramirez" /> + <DxcFlex direction="column" gap="var(--spacing-gap-m)"> + <DxcButton + icon="group" + iconPosition="after" + title="Manage clients" + mode="secondary" + size={{ height: "medium", width: "fillParent" }} + /> + <DxcButton + icon="note_add" + title="Start new application" + size={{ height: "medium", width: "fillParent" }} + /> + </DxcFlex> + </> + ) + } + </DxcSidenav> + </ExampleContainer> + <ExampleContainer> + <Title title="Collapsed sidenav with groups expanded (lines)" theme="light" level={4} /> + <DxcSidenav items={groupItems} title="App Name" displayGroupLines> + {(expanded: boolean) => + expanded ? ( + <> + <DetailedAvatar /> + <DxcFlex direction="column" gap="var(--spacing-gap-m)"> + <DxcButton + icon="group" + iconPosition="after" + title="Manage clients" + label="Manage clients" + mode="secondary" + size={{ height: "medium", width: "fillParent" }} + /> + <DxcButton + icon="note_add" + iconPosition="after" + title="Start new application" + label="Start new application" + size={{ height: "medium", width: "fillParent" }} + /> + </DxcFlex> + </> + ) : ( + <> + <DxcAvatar color="primary" status={{ mode: "error", position: "bottom" }} title="Michael Ramirez" /> + <DxcFlex direction="column" gap="var(--spacing-gap-m)"> + <DxcButton + icon="group" + iconPosition="after" + title="Manage clients" + mode="secondary" + size={{ height: "medium", width: "fillParent" }} + /> + <DxcButton + icon="note_add" + title="Start new application" + size={{ height: "medium", width: "fillParent" }} + /> + </DxcFlex> + </> + ) + } + </DxcSidenav> + </ExampleContainer> + </> +); + +const Hovered = () => ( + <ExampleContainer pseudoState="pseudo-hover"> + <Title title="Hover state for groups" theme="light" level={4} /> + <DxcSidenav + items={groupItems} + title="Application Name" + logo={{ + src: "https://images.ctfassets.net/hrltx12pl8hq/5596z2BCR9KmT1KeRBrOQa/4070fd4e2f1a13f71c2c46afeb18e41c/shutterstock_451077043-hero1.jpg", + alt: "TEST", + }} + > + <DetailedAvatar /> + <DxcFlex direction="column" gap="var(--spacing-gap-m)"> + <DxcButton + icon="group" + iconPosition="after" + title="Manage clients" + label="Manage clients" + mode="secondary" + size={{ height: "medium", width: "fillParent" }} + /> + <DxcButton + icon="note_add" + iconPosition="after" + title="Start new application" + label="Start new application" + size={{ height: "medium", width: "fillParent" }} + /> + </DxcFlex> + </DxcSidenav> + </ExampleContainer> +); + +const SelectedGroup = () => ( + <ExampleContainer> + <Title title="Default sidenav" theme="light" level={4} /> + <DxcSidenav + items={selectedGroupItems} + title="Application Name" + logo={{ + src: "https://images.ctfassets.net/hrltx12pl8hq/5596z2BCR9KmT1KeRBrOQa/4070fd4e2f1a13f71c2c46afeb18e41c/shutterstock_451077043-hero1.jpg", + alt: "TEST", + }} + > + <DetailedAvatar /> + <DxcFlex direction="column" gap="var(--spacing-gap-m)"> + <DxcButton + icon="group" + iconPosition="after" + title="Manage clients" + label="Manage clients" + mode="secondary" + size={{ height: "medium", width: "fillParent" }} + /> + <DxcButton + icon="note_add" + iconPosition="after" + title="Start new application" + label="Start new application" + size={{ height: "medium", width: "fillParent" }} + /> + </DxcFlex> + </DxcSidenav> + </ExampleContainer> +); type Story = StoryObj<typeof DxcSidenav>; export const Chromatic: Story = { render: SideNav, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const menuItem1 = (await canvas.findAllByRole("button"))[10]; + if (menuItem1) { + await userEvent.click(menuItem1); + } + const menuItem2 = (await canvas.findAllByRole("button"))[12]; + if (menuItem2) { + await userEvent.click(menuItem2); + } + }, +}; + +export const CollapsedSideNav: Story = { + render: Collapsed, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const collapseButtons = await canvas.findAllByRole("button", { name: "Collapse" }); + for (const button of collapseButtons) { + await userEvent.click(button); + } + const menuItem1 = (await canvas.findAllByRole("button"))[9]; + if (menuItem1) { + await userEvent.click(menuItem1); + } + const menuItem2 = (await canvas.findAllByRole("button"))[11]; + if (menuItem2) { + await userEvent.click(menuItem2); + } + const menuItem3 = (await canvas.findAllByRole("button"))[21]; + if (menuItem3) { + await userEvent.click(menuItem3); + } + const menuItem4 = (await canvas.findAllByRole("button"))[23]; + if (menuItem4) { + await userEvent.click(menuItem4); + } + }, +}; + +export const HoveredSideNav: Story = { + render: Hovered, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + console.log(await canvas.findAllByRole("button")); + const menuItem1 = (await canvas.findAllByRole("button"))[1]; + if (menuItem1) { + await userEvent.click(menuItem1); + } + const menuItem2 = (await canvas.findAllByRole("button"))[3]; + if (menuItem2) { + await userEvent.click(menuItem2); + } + }, +}; + +export const SelectedGroupSideNav: Story = { + render: SelectedGroup, }; diff --git a/packages/lib/src/sidenav/Sidenav.test.tsx b/packages/lib/src/sidenav/Sidenav.test.tsx index f124b938aa..c0fab0886b 100644 --- a/packages/lib/src/sidenav/Sidenav.test.tsx +++ b/packages/lib/src/sidenav/Sidenav.test.tsx @@ -1,40 +1,72 @@ -import { fireEvent, render } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { render, fireEvent } from "@testing-library/react"; import DxcSidenav from "./Sidenav"; +import DxcContextualMenu from "../contextual-menu/ContextualMenu"; -describe("Sidenav component tests", () => { - test("Sidenav renders anchors and Section correctly", () => { - const { getByText } = render( - <DxcSidenav> - <DxcSidenav.Section> - <p>nav-content-test</p> - <DxcSidenav.Link href="#">Link</DxcSidenav.Link> - </DxcSidenav.Section> +jest.mock("../contextual-menu/ContextualMenu", () => jest.fn(() => <div data-testid="mock-menu" />)); + +describe("DxcSidenav component", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("Sidenav renders title and children correctly", () => { + const { getByText, getByRole } = render( + <DxcSidenav title="Main Menu"> + <p>Custom child content</p> </DxcSidenav> ); - expect(getByText("nav-content-test")).toBeTruthy(); - const link = getByText("Link"); - expect(link.closest("a")?.getAttribute("href")).toBe("#"); + + expect(getByText("Main Menu")).toBeTruthy(); + expect(getByText("Custom child content")).toBeTruthy(); + const collapseButton = getByRole("button", { name: "Collapse" }); + expect(collapseButton).toBeTruthy(); }); - test("Sidenav renders groups correctly", () => { - const sidenav = render( - <DxcSidenav> - <DxcSidenav.Section> - <DxcSidenav.Group title="Collapsable" collapsable> - <DxcSidenav.Link href="#">Lorem ipsum</DxcSidenav.Link> - <DxcSidenav.Link href="#">Lorem ipsum</DxcSidenav.Link> - <DxcSidenav.Link href="#">Lorem ipsum</DxcSidenav.Link> - <DxcSidenav.Link href="#">Lorem ipsum</DxcSidenav.Link> - <DxcSidenav.Link href="#">Lorem ipsum</DxcSidenav.Link> - </DxcSidenav.Group> - </DxcSidenav.Section> - </DxcSidenav> + test("Sidenav collapses and expands correctly on button click", () => { + const { getByRole } = render(<DxcSidenav title="Main Menu" />); + + const collapseButton = getByRole("button", { name: "Collapse" }); + expect(collapseButton).toBeTruthy(); + fireEvent.click(collapseButton); + const expandButton = getByRole("button", { name: "Expand" }); + expect(expandButton).toBeTruthy(); + fireEvent.click(expandButton); + }); + + test("renders logo correctly when provided", () => { + const logo = { src: "logo.png", alt: "Company Logo", href: "https://example.com" }; + const { getByRole, getByAltText } = render(<DxcSidenav title="App" logo={logo} />); + + const link = getByRole("link"); + expect(link).toHaveAttribute("href", "https://example.com"); + expect(getByAltText("Company Logo")).toBeTruthy(); + }); + + test("renders contextual menu with items", () => { + const items = [{ label: "Dashboard" }, { label: "Settings" }]; + const { getByTestId } = render(<DxcSidenav items={items} />); + expect(getByTestId("mock-menu")).toBeTruthy(); + expect(DxcContextualMenu).toHaveBeenCalledWith( + expect.objectContaining({ + items, + displayGroupLines: false, + displayControlsAfter: true, + displayBorder: false, + }), + {} ); - expect(sidenav.getByText("Collapsable")).toBeTruthy(); - let buttons = sidenav.getAllByRole("button"); - expect(buttons[0]?.getAttribute("aria-expanded")).toBe("true"); - fireEvent.click(sidenav.getByText("Collapsable")); - buttons = sidenav.getAllByRole("button"); - expect(buttons[0]?.getAttribute("aria-expanded")).toBe("false"); + }); + + test("renders children using function pattern", () => { + const childFn = jest.fn((expanded) => <div>{expanded ? "Expanded content" : "Collapsed content"}</div>); + + const { getByText, getByRole } = render(<DxcSidenav>{childFn}</DxcSidenav>); + expect(getByText("Expanded content")).toBeTruthy(); + expect(childFn).toHaveBeenCalledWith(true); + const collapseButton = getByRole("button", { name: "Collapse" }); + expect(collapseButton).toBeTruthy(); + fireEvent.click(collapseButton); + expect(childFn).toHaveBeenCalledWith(false); }); }); diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx index 34258676f7..4df5a28fa5 100644 --- a/packages/lib/src/sidenav/Sidenav.tsx +++ b/packages/lib/src/sidenav/Sidenav.tsx @@ -48,7 +48,7 @@ const LogoContainer = styled.div<{ text-decoration: none; `; -const DxcSidenav = ({ title, children, items, logo }: SidenavPropsType): JSX.Element => { +const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }: SidenavPropsType): JSX.Element => { const [isExpanded, setIsExpanded] = useState(true); const renderedChildren = typeof children === "function" ? children(isExpanded) : children; @@ -72,7 +72,7 @@ const DxcSidenav = ({ title, children, items, logo }: SidenavPropsType): JSX.Ele <LogoContainer onClick={logo?.onClick} hasAction={!!logo?.onClick || !!logo?.href} - role={logo?.onClick ? "button" : "presentation"} + role={logo?.onClick ? "button" : logo?.href ? "link" : "presentation"} as={logo?.href ? "a" : undefined} href={logo?.href} aria-label={(logo?.onClick || logo?.href) && (title || "Avatar")} @@ -86,13 +86,15 @@ const DxcSidenav = ({ title, children, items, logo }: SidenavPropsType): JSX.Ele )} </DxcFlex> {/* TODO: SEARCHBAR */} - <DxcContextualMenu - items={items} - displayGroupsLine - displayControlsAfter - displayBorder={false} - responsiveView={!isExpanded} - /> + {items && ( + <DxcContextualMenu + items={items} + displayGroupLines={displayGroupLines} + displayBorder={false} + responsiveView={!isExpanded} + displayControlsAfter + /> + )} <DxcDivider color="lightGrey" /> {renderedChildren} </SidenavContainer> diff --git a/packages/lib/src/sidenav/types.ts b/packages/lib/src/sidenav/types.ts index e9f1a9eb50..74fcf285d8 100644 --- a/packages/lib/src/sidenav/types.ts +++ b/packages/lib/src/sidenav/types.ts @@ -103,11 +103,15 @@ type Props = { * Array of items to be displayed in the Nav menu. * Each item can be a single/simple item, a group item or a section. */ - items: (Item | GroupItem)[] | Section[]; + items?: (Item | GroupItem)[] | Section[]; /** * Object with the properties of the logo placed at the top of the sidenav. */ - logo: Logo; + logo?: Logo; + /** + * If true the nav menu will have lines marking the groups. + */ + displayGroupLines?: boolean; }; type CommonItemProps = {