diff --git a/packages/lib/src/contextual-menu/ContextualMenu.tsx b/packages/lib/src/contextual-menu/ContextualMenu.tsx index 13f58b417..023a75e72 100644 --- a/packages/lib/src/contextual-menu/ContextualMenu.tsx +++ b/packages/lib/src/contextual-menu/ContextualMenu.tsx @@ -8,28 +8,41 @@ import scrollbarStyles from "../styles/scroll"; import { addIdToItems, isSection } from "./utils"; import SubMenu from "./SubMenu"; -const ContextualMenu = 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); - min-width: 248px; + /* min-width: 248px; */ max-height: 100%; 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); + padding: var(--spacing-padding-m) var(--spacing-padding-xs); + `} `; -export default function DxcContextualMenu({ items }: ContextualMenuPropsType) { +export default function DxcContextualMenu({ + items, + displayBorder = true, + displayGroupLines = 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 }), [selectedItemId, setSelectedItemId]); + const contextValue = useMemo( + () => ({ selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView }), + [selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView] + ); useLayoutEffect(() => { if (selectedItemId !== -1 && firstUpdate) { @@ -45,7 +58,7 @@ export default function DxcContextualMenu({ items }: ContextualMenuPropsType) { }, [firstUpdate, selectedItemId]); return ( - + {itemsWithId[0] && isSection(itemsWithId[0]) ? ( (itemsWithId as SectionWithId[]).map((item, index) => ( @@ -59,6 +72,6 @@ export default function DxcContextualMenu({ items }: ContextualMenuPropsType) { )} - + ); } diff --git a/packages/lib/src/contextual-menu/GroupItem.tsx b/packages/lib/src/contextual-menu/GroupItem.tsx index ba794fd61..d8074f312 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) => ( + + ))} + + + + + +
+ + ) : ( <> { {...props} /> {isOpen && ( - + {items.map((item, index) => ( ))} diff --git a/packages/lib/src/contextual-menu/ItemAction.tsx b/packages/lib/src/contextual-menu/ItemAction.tsx index 747681996..75b8976dd 100644 --- a/packages/lib/src/contextual-menu/ItemAction.tsx +++ b/packages/lib/src/contextual-menu/ItemAction.tsx @@ -1,22 +1,27 @@ -import { cloneElement, memo, MouseEvent, useState } from "react"; +import { cloneElement, forwardRef, 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"]; + displayGroupLines: boolean; + responsiveView?: 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))`}; + ${({ 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; 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; @@ -63,31 +68,58 @@ const Text = styled.span<{ selected: ItemActionProps["selected"] }>` overflow: hidden; `; -const ItemAction = memo(({ badge, collapseIcon, depthLevel, icon, label, ...props }: ItemActionProps) => { - const [hasTooltip, setHasTooltip] = useState(false); - const modifiedBadge = badge && cloneElement(badge, { size: "small" }); +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( + forwardRef(({ badge, collapseIcon, depthLevel, icon, label, ...props }, ref) => { + const [hasTooltip, setHasTooltip] = useState(false); + const modifiedBadge = badge && cloneElement(badge, { size: "small" }); + const { displayControlsAfter, responsiveView, displayGroupLines } = useContext(ContextualMenuContext) ?? {}; - return ( - - - - {modifiedBadge} - - - ); -}); + 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 8cade2fba..7fd823b8e 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,11 +23,11 @@ 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}} - + {section.items.map((item, i) => ( ))} @@ -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/SubMenu.tsx b/packages/lib/src/contextual-menu/SubMenu.tsx index 70c003006..0d29a7e2c 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; displayGroupLines?: boolean }>` margin: 0; padding: 0; display: grid; gap: var(--spacing-gap-xs); list-style: none; + + ${({ depthLevel, displayGroupLines }) => + displayGroupLines && + 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 { displayGroupLines } = useContext(ContextualMenuContext) ?? {}; return ( - + {children} ); diff --git a/packages/lib/src/contextual-menu/types.ts b/packages/lib/src/contextual-menu/types.ts index e9599a7f8..ff2224ec7 100644 --- a/packages/lib/src/contextual-menu/types.ts +++ b/packages/lib/src/contextual-menu/types.ts @@ -20,6 +20,26 @@ 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 + */ + displayGroupLines?: boolean; + /** + * If true the contextual menu will have controls at the end. + * @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 }; @@ -31,9 +51,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 +74,13 @@ 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>; + selectedItemId?: number; + setSelectedItemId?: Dispatch>; + displayGroupLines?: boolean; + displayControlsAfter?: boolean; + responsiveView?: boolean; }; export type { diff --git a/packages/lib/src/layout/ApplicationLayout.tsx b/packages/lib/src/layout/ApplicationLayout.tsx index 59bceafef..e77175ed6 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 index 6d5b46eec..824daa05b 100644 --- a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx +++ b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx @@ -1,50 +1,54 @@ import { render } from "@testing-library/react"; import { axe } from "../../test/accessibility/axe-helper"; import DxcSidenav from "./Sidenav"; - -const iconSVG = ( - - - - - -); +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( - - -

nav-content-test

- - Link - -
- - - Lorem ipsum - Lorem ipsum - Lorem ipsum - Lorem ipsum - Lorem ipsum - - -
+ ); 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 121db6ef4..8cc60a45d 100644 --- a/packages/lib/src/sidenav/Sidenav.stories.tsx +++ b/packages/lib/src/sidenav/Sidenav.stories.tsx @@ -1,9 +1,12 @@ +import { Meta, StoryObj } from "@storybook/react-vite"; 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 { Meta, StoryObj } from "@storybook/react-vite"; +import DxcBadge from "../badge/Badge"; +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 { @@ -11,235 +14,449 @@ export default { component: DxcSidenav, } satisfies Meta; -const iconSVG = ( - - - { + return ( + + + + + + Michael Ramirez + + + m.ramirez@insurance.com + + + + - - -); + + ); +}; + +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" }, + ], + }, + ], + 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 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: , + }, + { 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 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 + 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 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> + <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 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 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 HoveredGroupSidenav = () => ( +const Hovered = () => ( <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> + <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 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> +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, -}; - -export const CollapsableGroup: Story = { - render: CollapsedGroupSidenav, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const collapsableGroups = await canvas.findAllByText("Collapsed Group"); - for (const group of collapsableGroups) { - await userEvent.click(group); + 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 CollapsedHoverGroup: Story = { - render: HoveredGroupSidenav, +export const CollapsedSideNav: Story = { + render: Collapsed, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const collapsableGroups = await canvas.findAllByText("Collapsed Group"); - for (const group of collapsableGroups) { - await userEvent.click(group); + 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); } - await new Promise((resolve) => { - setTimeout(resolve, 1000); - }); }, }; -export const CollapsedActiveGroup: Story = { - render: ActiveGroupSidenav, +export const HoveredSideNav: Story = { + render: Hovered, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const collapsableGroups = await canvas.findAllByText("Collapsed Group"); - if (collapsableGroups[0]) { - await userEvent.click(collapsableGroups[0]); + 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 f124b938a..c0fab0886 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 814dd5b57..4df5a28fa 100644 --- a/packages/lib/src/sidenav/Sidenav.tsx +++ b/packages/lib/src/sidenav/Sidenav.tsx @@ -1,246 +1,109 @@ -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 "../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; - } + font-weight: var(--typography-title-bold); `; -const SidenavGroupTitleButton = styled.button<{ selectedGroup: boolean }>` - all: unset; - box-sizing: border-box; +const LogoContainer = styled.div<{ + hasAction?: boolean; + href?: Logo["href"]; +}>` + position: relative; display: flex; + justify-content: center; 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; - } -`; - -const SidenavLink = styled.a<{ selected: SidenavLinkPropsType["selected"] }>` - display: flex; - 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 => { - return ( - <SidenavContainer> - {title} - <DxcFlex direction="column" gap="var(--spacing-gap-ml)"> - {children} - </DxcFlex> - </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 DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }: SidenavPropsType): JSX.Element => { + const [isExpanded, setIsExpanded] = useState(true); -const Group = ({ title, collapsable = false, icon, children }: SidenavGroupPropsType): JSX.Element => { - const [collapsed, setCollapsed] = useState(false); - const [isSelected, changeIsSelected] = useState(false); + const renderedChildren = typeof children === "function" ? children(isExpanded) : children; 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> - ) + <SidenavContainer expanded={isExpanded}> + <DxcFlex justifyContent={isExpanded ? "space-between" : "center"}> + {/* TODO: HANDLE TITLE */} + <DxcButton + icon={`left_panel_${isExpanded ? "close" : "open"}`} + size={{ height: "medium" }} + 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" : logo?.href ? "link" : "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> )} - {!collapsed && children} - </SidenavGroup> - </GroupContextProvider> + </DxcFlex> + {/* TODO: SEARCHBAR */} + {items && ( + <DxcContextualMenu + items={items} + displayGroupLines={displayGroupLines} + displayBorder={false} + responsiveView={!isExpanded} + displayControlsAfter + /> + )} + <DxcDivider color="lightGrey" /> + {renderedChildren} + </SidenavContainer> ); }; -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 ed577b49d..74fcf285d 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, ReactElement } from "react"; import { SVG } from "../common/utils"; export type SidenavTitlePropsType = { @@ -68,15 +68,63 @@ 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 Section = { items: (Item | GroupItem)[]; title?: 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. + * 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. + */ + items?: (Item | GroupItem)[] | Section[]; + /** + * Object with the properties of the logo placed at the top of the sidenav. + */ + logo?: Logo; + /** + * If true the nav menu will have lines marking the groups. + */ + displayGroupLines?: boolean; +}; + +type CommonItemProps = { + badge?: ReactElement; + icon?: string | SVG; + label: string; +}; +type Item = CommonItemProps & { + onSelect?: () => void; + selectedByDefault?: boolean; +}; +type GroupItem = CommonItemProps & { + items: (Item | GroupItem)[]; }; export default Props; diff --git a/packages/lib/src/styles/variables.css b/packages/lib/src/styles/variables.css index 253df46a4..53b0b52dc 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; diff --git a/packages/lib/vitest.shims.d.ts b/packages/lib/vitest.shims.d.ts index f923d47d4..a1d31e5a7 100644 --- a/packages/lib/vitest.shims.d.ts +++ b/packages/lib/vitest.shims.d.ts @@ -1 +1 @@ -/// <reference types="@vitest/browser/providers/playwright" /> \ No newline at end of file +/// <reference types="@vitest/browser/providers/playwright" />