From e4b62fe05ebc26ec76d00f43af9ddc0e341e20be Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Fri, 10 Oct 2025 08:14:03 +0200 Subject: [PATCH 01/36] 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 02/36] 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 03/36] 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 04/36] 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 05/36] 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 06/36] 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 = { From 9713d2c9d850d3b53650f03c01e6c551c032dc66 Mon Sep 17 00:00:00 2001 From: Mil4n0r <morenocarmonaenrique@gmail.com> Date: Thu, 23 Oct 2025 08:31:39 +0200 Subject: [PATCH 07/36] Made changes in sidenav API to fit our documentation website --- apps/website/pages/_app.tsx | 100 ++++++++------- .../code/ApplicationLayoutCodePage.tsx | 18 --- .../code/ContextualMenuCodePage.tsx | 2 +- .../ContextualMenu.stories.tsx | 4 +- .../contextual-menu/ContextualMenu.test.tsx | 6 +- .../src/contextual-menu/ContextualMenu.tsx | 12 +- .../lib/src/contextual-menu/ItemAction.tsx | 83 +++++++------ .../lib/src/contextual-menu/SingleItem.tsx | 12 +- packages/lib/src/contextual-menu/types.ts | 10 +- packages/lib/src/contextual-menu/utils.ts | 2 +- .../src/layout/ApplicationLayout.stories.tsx | 115 ++++++----------- packages/lib/src/layout/ApplicationLayout.tsx | 116 ++---------------- packages/lib/src/layout/types.ts | 5 - .../sidenav/Sidenav.accessibility.test.tsx | 2 +- packages/lib/src/sidenav/Sidenav.stories.tsx | 7 +- packages/lib/src/sidenav/Sidenav.tsx | 29 +++-- packages/lib/src/sidenav/SidenavContext.tsx | 7 +- packages/lib/src/sidenav/types.ts | 2 +- 18 files changed, 208 insertions(+), 324 deletions(-) diff --git a/apps/website/pages/_app.tsx b/apps/website/pages/_app.tsx index fd97164ccd..959f5f74a1 100644 --- a/apps/website/pages/_app.tsx +++ b/apps/website/pages/_app.tsx @@ -1,17 +1,16 @@ -import { ReactElement, ReactNode, useMemo, useState } from "react"; +import { ReactElement, ReactNode, useEffect } from "react"; import type { NextPage } from "next"; import type { AppProps } from "next/app"; import Head from "next/head"; -import { DxcApplicationLayout, DxcTextInput, DxcToastsQueue } from "@dxc-technology/halstack-react"; -import SidenavLogo from "@/common/sidenav/SidenavLogo"; +import { DxcApplicationLayout, DxcToastsQueue } from "@dxc-technology/halstack-react"; import MainContent from "@/common/MainContent"; import { useRouter } from "next/router"; import { LinksSectionDetails, LinksSections } from "@/common/pagesList"; -import Link from "next/link"; import StatusBadge from "@/common/StatusBadge"; import "../global-styles.css"; import createCache, { EmotionCache } from "@emotion/cache"; import { CacheProvider } from "@emotion/react"; +import { usePathname } from "next/navigation"; type NextPageWithLayout = NextPage & { getLayout?: (_page: ReactElement) => ReactNode; @@ -26,54 +25,69 @@ const clientSideEmotionCache = createCache({ key: "css", prepend: true }); export default function App({ Component, pageProps, emotionCache = clientSideEmotionCache }: AppPropsWithLayout) { const getLayout = Component.getLayout || ((page) => page); const componentWithLayout = getLayout(<Component {...pageProps} />); - const [filter, setFilter] = useState(""); - const { asPath: currentPath } = useRouter(); - const filteredLinks = useMemo(() => { - const filtered: LinksSectionDetails[] = []; - LinksSections.map((section) => { - const sectionFilteredLinks = section?.links.filter((link) => - link.label.toLowerCase().includes(filter.toLowerCase()) - ); - if (sectionFilteredLinks.length) { - filtered.push({ label: section.label, links: sectionFilteredLinks }); - } - }); - return filtered; - }, [filter]); + const router = useRouter(); + const pathname = usePathname(); + // const [filter, setFilter] = useState(""); + // const filteredLinks = useMemo(() => { + // const filtered: LinksSectionDetails[] = []; + // LinksSections.map((section) => { + // const sectionFilteredLinks = section?.links.filter((link) => + // link.label.toLowerCase().includes(filter.toLowerCase()) + // ); + // if (sectionFilteredLinks.length) { + // filtered.push({ label: section.label, links: sectionFilteredLinks }); + // } + // }); + // return filtered; + // }, [filter]); + + const mapLinksToGroupItems = (sections: LinksSectionDetails[]) => { + const matchPaths = (linkPath: string) => { + const desiredPaths = [linkPath, `${linkPath}/code`]; + const pathToBeMatched = pathname?.split("#")[0]?.slice(0, -1); + return pathToBeMatched ? desiredPaths.includes(pathToBeMatched) : false; + }; - const matchPaths = (linkPath: string) => { - const desiredPaths = [linkPath, `${linkPath}/code`]; - const pathToBeMatched = currentPath?.split("#")[0]?.slice(0, -1); - return pathToBeMatched ? desiredPaths.includes(pathToBeMatched) : false; + return sections.map((section) => ({ + title: section.label, + items: section.links.map((link) => ({ + label: link.label, + onSelect: () => router.push(link.path), + selected: matchPaths(link.path), + ...(link.status && { + badge: link.status !== "stable" ? <StatusBadge hasTitle status={link.status} /> : undefined, + }), + })), + })); }; + useEffect(() => { + const paths = [...new Set(LinksSections.flatMap((s) => s.links.map((l) => l.path)))]; + const prefetchPaths = async () => { + for (const path of paths) { + await router.prefetch(path); + } + }; + void prefetchPaths(); + }, []); + + // TODO: ADD FILTERING + // TODO: ADD CATEGORIZATION + + const sections = mapLinksToGroupItems(LinksSections); + return ( <CacheProvider value={emotionCache}> <Head> <link rel="icon" type="image/png" sizes="32x32" href="/favicon.png" /> </Head> <DxcApplicationLayout - visibilityToggleLabel="Menu" sidenav={ - <DxcApplicationLayout.SideNav title={<SidenavLogo />}> - <DxcApplicationLayout.SideNav.Section> - <DxcTextInput - placeholder="Search docs" - value={filter} - onChange={({ value }: { value: string }) => { - setFilter(value); - }} - size="fillParent" - clearable - margin={{ - top: "large", - bottom: "large", - right: "medium", - left: "medium", - }} - /> - </DxcApplicationLayout.SideNav.Section> - {filteredLinks?.map(({ label, links }) => ( + <DxcApplicationLayout.SideNav + items={sections} + // title={<SidenavLogo />} + > + {/* {filteredLinks?.map(({ label, links }) => ( <DxcApplicationLayout.SideNav.Section key={label}> <DxcApplicationLayout.SideNav.Group title={label}> {links.map(({ label, path, status }) => ( @@ -91,7 +105,7 @@ export default function App({ Component, pageProps, emotionCache = clientSideEmo <DxcApplicationLayout.SideNav.Link href="https://github.com/dxc-technology/halstack-react" newWindow> GitHub </DxcApplicationLayout.SideNav.Link> - </DxcApplicationLayout.SideNav.Section> + </DxcApplicationLayout.SideNav.Section> */} </DxcApplicationLayout.SideNav> } > diff --git a/apps/website/screens/components/application-layout/code/ApplicationLayoutCodePage.tsx b/apps/website/screens/components/application-layout/code/ApplicationLayoutCodePage.tsx index c115d03a5b..c06008ad6e 100644 --- a/apps/website/screens/components/application-layout/code/ApplicationLayoutCodePage.tsx +++ b/apps/website/screens/components/application-layout/code/ApplicationLayoutCodePage.tsx @@ -75,14 +75,6 @@ const ApplicationLayoutPropsTable = () => ( </td> <td>-</td> </tr> - <tr> - <td>visibilityToggleLabel</td> - <td> - <TableCode>string</TableCode> - </td> - <td>Text to be placed next to the hamburger button that toggles the visibility of the sidenav.</td> - <td>-</td> - </tr> </tbody> </DxcTable> ); @@ -100,16 +92,6 @@ const sections = [ </DxcParagraph> ), }, - { - title: "DxcApplicationLayout.useResponsiveSidenavVisibility", - content: ( - <DxcParagraph> - Custom hook that returns a function to manually change the visibility of the sidenav in responsive mode. This - can be very useful for cases where a custom sidenav is being used and some of its inner elements can close it - (for example, a navigation link). - </DxcParagraph> - ), - }, { title: "Examples", subSections: [ diff --git a/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx b/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx index f6e796569a..e35303c443 100644 --- a/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx +++ b/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx @@ -12,7 +12,7 @@ const itemTypeString = `{ icon?: string | SVG; label: string; onSelect?: () => void; - selectedByDefault?: boolean; + selected?: boolean; }`; const groupItemTypeString = `{ diff --git a/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx b/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx index f174fbabe8..a83b217fee 100644 --- a/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx +++ b/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx @@ -42,7 +42,7 @@ const groupItems = [ icon: "bookmark", badge: <DxcBadge color="primary" label="Experimental" />, }, - { label: "Selected Item 3", selectedByDefault: true }, + { label: "Selected Item 3", selected: true }, ], }, ], @@ -102,7 +102,7 @@ const sectionsWithScroll = [ { label: "Approved locations" }, { label: "Approved locations" }, { label: "Approved locations" }, - { label: "Approved locations", selectedByDefault: true }, + { label: "Approved locations", selected: true }, ], }, ]; diff --git a/packages/lib/src/contextual-menu/ContextualMenu.test.tsx b/packages/lib/src/contextual-menu/ContextualMenu.test.tsx index 59af5b6fd8..06958825d0 100644 --- a/packages/lib/src/contextual-menu/ContextualMenu.test.tsx +++ b/packages/lib/src/contextual-menu/ContextualMenu.test.tsx @@ -41,11 +41,11 @@ describe("Contextual menu component tests", () => { expect(actions[0]?.getAttribute("aria-pressed")).toBeTruthy(); expect(getByRole("menu")).toBeTruthy(); }); - test("Single — An item can appear as selected by default by using the attribute selectedByDefault", () => { + test("Single — An item can appear as selected by default by using the attribute selected", () => { const test = [ { label: "Tested item", - selectedByDefault: true, + selected: true, }, ]; const { getByRole } = render(<DxcContextualMenu items={test} />); @@ -92,7 +92,7 @@ describe("Contextual menu component tests", () => { const test = [ { label: "Grouped item", - items: [{ label: "Tested item", selectedByDefault: true }], + items: [{ label: "Tested item", selected: true }], }, ]; const { getByText, getAllByRole } = render(<DxcContextualMenu items={test} />); diff --git a/packages/lib/src/contextual-menu/ContextualMenu.tsx b/packages/lib/src/contextual-menu/ContextualMenu.tsx index 023a75e72e..d50ed86c75 100644 --- a/packages/lib/src/contextual-menu/ContextualMenu.tsx +++ b/packages/lib/src/contextual-menu/ContextualMenu.tsx @@ -34,14 +34,22 @@ export default function DxcContextualMenu({ displayGroupLines = false, displayControlsAfter = false, responsiveView = false, + allowNavigation = 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, displayGroupLines, displayControlsAfter, responsiveView }), - [selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView] + () => ({ + selectedItemId, + setSelectedItemId, + displayGroupLines, + displayControlsAfter, + responsiveView, + allowNavigation, + }), + [selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView, allowNavigation] ); useLayoutEffect(() => { diff --git a/packages/lib/src/contextual-menu/ItemAction.tsx b/packages/lib/src/contextual-menu/ItemAction.tsx index 75b8976dd3..aeb2ae3e95 100644 --- a/packages/lib/src/contextual-menu/ItemAction.tsx +++ b/packages/lib/src/contextual-menu/ItemAction.tsx @@ -26,6 +26,7 @@ const Action = styled.button<{ height: var(--height-s); cursor: pointer; overflow: hidden; + text-decoration: none; &:hover { background-color: ${({ selected }) => @@ -78,47 +79,53 @@ const Control = styled.span` `; const ItemAction = memo( - forwardRef<HTMLButtonElement, ItemActionProps>(({ 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) ?? {}; + forwardRef<HTMLButtonElement, ItemActionProps>( + ({ badge, collapseIcon, depthLevel, icon, label, href, ...props }, ref) => { + const [hasTooltip, setHasTooltip] = useState(false); + const modifiedBadge = badge && cloneElement(badge, { size: "small" }); + const { displayControlsAfter, responsiveView, displayGroupLines, allowNavigation } = + useContext(ContextualMenuContext) ?? {}; - return ( - <TooltipWrapper condition={hasTooltip} label={label}> - <Action - ref={ref} - depthLevel={depthLevel} - displayGroupLines={!!displayGroupLines} - responsiveView={responsiveView} - {...props} - > - <Label> - {!displayControlsAfter && <Control>{collapseIcon && <Icon>{collapseIcon}</Icon>}</Control>} - <TooltipWrapper condition={responsiveView} label={label}> - <Icon>{typeof icon === "string" ? <DxcIcon icon={icon} /> : icon}</Icon> - </TooltipWrapper> + return ( + <TooltipWrapper condition={hasTooltip} label={label}> + <Action + as={allowNavigation && href ? "a" : "button"} + role={allowNavigation && href ? "link" : "button"} + ref={ref} + depthLevel={depthLevel} + displayGroupLines={!!displayGroupLines} + responsiveView={responsiveView} + {...(allowNavigation && href && { href })} + {...props} + > + <Label> + {!displayControlsAfter && <Control>{collapseIcon && <Icon>{collapseIcon}</Icon>}</Control>} + <TooltipWrapper condition={responsiveView} label={label}> + <Icon>{typeof icon === "string" ? <DxcIcon icon={icon} /> : icon}</Icon> + </TooltipWrapper> + {!responsiveView && ( + <Text + selected={props.selected} + onMouseEnter={(event: MouseEvent<HTMLSpanElement>) => { + const text = event.currentTarget; + setHasTooltip(text.scrollWidth > text.clientWidth); + }} + > + {label} + </Text> + )} + </Label> {!responsiveView && ( - <Text - selected={props.selected} - onMouseEnter={(event: MouseEvent<HTMLSpanElement>) => { - const text = event.currentTarget; - setHasTooltip(text.scrollWidth > text.clientWidth); - }} - > - {label} - </Text> + <Control> + {modifiedBadge} + {displayControlsAfter && collapseIcon && <Icon>{collapseIcon}</Icon>} + </Control> )} - </Label> - {!responsiveView && ( - <Control> - {modifiedBadge} - {displayControlsAfter && collapseIcon && <Icon>{collapseIcon}</Icon>} - </Control> - )} - </Action> - </TooltipWrapper> - ); - }) + </Action> + </TooltipWrapper> + ); + } + ) ); ItemAction.displayName = "ItemAction"; diff --git a/packages/lib/src/contextual-menu/SingleItem.tsx b/packages/lib/src/contextual-menu/SingleItem.tsx index 5fcd304d91..ab7152282a 100644 --- a/packages/lib/src/contextual-menu/SingleItem.tsx +++ b/packages/lib/src/contextual-menu/SingleItem.tsx @@ -3,7 +3,7 @@ import ItemAction from "./ItemAction"; import { SingleItemProps } from "./types"; import ContextualMenuContext from "./ContextualMenuContext"; -export default function SingleItem({ id, onSelect, selectedByDefault = false, ...props }: SingleItemProps) { +export default function SingleItem({ id, onSelect, selected = false, ...props }: SingleItemProps) { const { selectedItemId, setSelectedItemId } = useContext(ContextualMenuContext) ?? {}; const handleClick = () => { @@ -12,18 +12,16 @@ export default function SingleItem({ id, onSelect, selectedByDefault = false, .. }; useEffect(() => { - if (selectedItemId === -1 && selectedByDefault) { + if (selectedItemId === -1 && selected) { setSelectedItemId?.(id); } - }, [selectedItemId, selectedByDefault, id]); + }, [selectedItemId, selected, id]); return ( <ItemAction - aria-pressed={selectedItemId === -1 ? selectedByDefault : selectedItemId === id} + aria-pressed={selectedItemId === -1 ? selected : selectedItemId === id} onClick={handleClick} - selected={ - selectedItemId != null && (selectedItemId === -1 ? (selectedByDefault ?? false) : selectedItemId === id) - } + selected={selectedItemId != null && (selectedItemId === -1 ? (selected ?? false) : selectedItemId === id)} {...props} /> ); diff --git a/packages/lib/src/contextual-menu/types.ts b/packages/lib/src/contextual-menu/types.ts index ff2224ec72..6d54167486 100644 --- a/packages/lib/src/contextual-menu/types.ts +++ b/packages/lib/src/contextual-menu/types.ts @@ -8,7 +8,8 @@ type CommonItemProps = { }; type Item = CommonItemProps & { onSelect?: () => void; - selectedByDefault?: boolean; + selected?: boolean; + href?: string; }; type GroupItem = CommonItemProps & { items: (Item | GroupItem)[]; @@ -40,6 +41,11 @@ type Props = { * @private */ responsiveView?: boolean; + /** + * If true the leaf nodes will be rendered as anchor elements when href is provided. + * @private + */ + allowNavigation?: boolean; }; type ItemWithId = Item & { id: number }; @@ -68,6 +74,7 @@ type ItemActionProps = ButtonHTMLAttributes<HTMLButtonElement> & { icon?: Item["icon"]; label: Item["label"]; selected: boolean; + href?: Item["href"]; }; type SectionProps = { section: SectionWithId; @@ -81,6 +88,7 @@ type ContextualMenuContextProps = { displayGroupLines?: boolean; displayControlsAfter?: boolean; responsiveView?: boolean; + allowNavigation?: boolean; }; export type { diff --git a/packages/lib/src/contextual-menu/utils.ts b/packages/lib/src/contextual-menu/utils.ts index 3dfe2fb6d8..77db32b032 100644 --- a/packages/lib/src/contextual-menu/utils.ts +++ b/packages/lib/src/contextual-menu/utils.ts @@ -34,5 +34,5 @@ export const isGroupSelected = (items: GroupItemProps["items"], selectedItemId?: items.some((item) => { if ("items" in item) return isGroupSelected(item.items, selectedItemId); else if (selectedItemId !== -1) return item.id === selectedItemId; - else return item.selectedByDefault; + else return item.selected; }); diff --git a/packages/lib/src/layout/ApplicationLayout.stories.tsx b/packages/lib/src/layout/ApplicationLayout.stories.tsx index bfd20f7f0b..71aab7b79e 100644 --- a/packages/lib/src/layout/ApplicationLayout.stories.tsx +++ b/packages/lib/src/layout/ApplicationLayout.stories.tsx @@ -22,26 +22,33 @@ const ApplicationLayout = () => ( </> ); +const items = [ + { + label: "SideNav Content", + icon: "tab", + }, + { + label: "SideNav Content", + icon: "tab", + }, + { + label: "SideNav Content", + icon: "tab", + }, + { + label: "SideNav Content", + icon: "tab", + }, + { + label: "SideNav Content", + icon: "tab", + }, +]; + const ApplicationLayoutDefaultSidenav = () => ( <> <DxcApplicationLayout - sidenav={ - <DxcApplicationLayout.SideNav - title={ - <DxcApplicationLayout.SideNav.Title> - Application layout with push sidenav - </DxcApplicationLayout.SideNav.Title> - } - > - <DxcApplicationLayout.SideNav.Section> - <p>SideNav Content</p> - <p>SideNav Content</p> - <p>SideNav Content</p> - <p>SideNav Content</p> - <p>SideNav Content</p> - </DxcApplicationLayout.SideNav.Section> - </DxcApplicationLayout.SideNav> - } + sidenav={<DxcApplicationLayout.SideNav title="Application layout with push sidenav" items={items} />} > <DxcApplicationLayout.Main> <p>Main Content</p> @@ -56,22 +63,9 @@ const ApplicationLayoutDefaultSidenav = () => ( const ApplicationLayoutResponsiveSidenav = () => ( <> <DxcApplicationLayout - visibilityToggleLabel="Example" sidenav={ - <DxcApplicationLayout.SideNav - title={ - <DxcApplicationLayout.SideNav.Title> - Application layout with push sidenav - </DxcApplicationLayout.SideNav.Title> - } - > - <DxcApplicationLayout.SideNav.Section> - <p>SideNav Content</p> - <p>SideNav Content</p> - <p>SideNav Content</p> - <p>SideNav Content</p> - <p>SideNav Content</p> - </DxcApplicationLayout.SideNav.Section> + <DxcApplicationLayout.SideNav title="Application layout with push sidenav" items={items}> + {(expanded: boolean) => (!expanded ? <p>Responsive Content</p> : <></>)} </DxcApplicationLayout.SideNav> } > @@ -89,23 +83,7 @@ const ApplicationLayoutCustomHeader = () => ( <> <DxcApplicationLayout header={<p>Custom Header</p>} - sidenav={ - <DxcApplicationLayout.SideNav - title={ - <DxcApplicationLayout.SideNav.Title> - Application layout with push sidenav - </DxcApplicationLayout.SideNav.Title> - } - > - <DxcApplicationLayout.SideNav.Section> - <p>SideNav Content</p> - <p>SideNav Content</p> - <p>SideNav Content</p> - <p>SideNav Content</p> - <p>SideNav Content</p> - </DxcApplicationLayout.SideNav.Section> - </DxcApplicationLayout.SideNav> - } + sidenav={<DxcApplicationLayout.SideNav title="Application layout with push sidenav" items={items} />} > <DxcApplicationLayout.Main> <p>Main Content</p> @@ -121,23 +99,7 @@ const ApplicationLayoutCustomFooter = () => ( <> <DxcApplicationLayout footer={<p>Custom Footer</p>} - sidenav={ - <DxcApplicationLayout.SideNav - title={ - <DxcApplicationLayout.SideNav.Title> - Application layout with push sidenav - </DxcApplicationLayout.SideNav.Title> - } - > - <DxcApplicationLayout.SideNav.Section> - <p>SideNav Content</p> - <p>SideNav Content</p> - <p>SideNav Content</p> - <p>SideNav Content</p> - <p>SideNav Content</p> - </DxcApplicationLayout.SideNav.Section> - </DxcApplicationLayout.SideNav> - } + sidenav={<DxcApplicationLayout.SideNav title="Application layout with push sidenav" items={items} />} > <DxcApplicationLayout.Main> <p>Main Content</p> @@ -151,13 +113,7 @@ const ApplicationLayoutCustomFooter = () => ( const Tooltip = () => ( <DxcApplicationLayout - sidenav={ - <DxcApplicationLayout.SideNav> - <DxcApplicationLayout.SideNav.Section> - <p>SideNav Content</p> - </DxcApplicationLayout.SideNav.Section> - </DxcApplicationLayout.SideNav> - } + sidenav={<DxcApplicationLayout.SideNav title="Application layout with push sidenav" items={items} />} > <DxcApplicationLayout.Main> <p>Main Content</p> @@ -181,6 +137,13 @@ export const ApplicationLayoutWithResponsiveSidenav: Story = { globals: { viewport: { value: "pixel", isRotated: false }, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const collapseButton = (await canvas.findAllByRole("button"))[0]; + if (collapseButton) { + await userEvent.click(collapseButton); + } + }, }; export const ApplicationLayoutWithCustomHeader: Story = { @@ -201,7 +164,9 @@ export const ApplicationLayoutTooltip: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const toggleVisibility = await canvas.findByRole("button"); - await userEvent.hover(toggleVisibility); + const collapseButton = (await canvas.findAllByRole("button"))[0]; + if (collapseButton) { + await userEvent.hover(collapseButton); + } }, }; diff --git a/packages/lib/src/layout/ApplicationLayout.tsx b/packages/lib/src/layout/ApplicationLayout.tsx index e77175ed60..810aca93cb 100644 --- a/packages/lib/src/layout/ApplicationLayout.tsx +++ b/packages/lib/src/layout/ApplicationLayout.tsx @@ -1,20 +1,12 @@ -import { useContext, useEffect, useRef, useState } from "react"; +import { useRef } from "react"; import styled from "@emotion/styled"; -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 { SidenavContextProvider, useResponsiveSidenavVisibility } from "../sidenav/SidenavContext"; -import { Tooltip } from "../tooltip/Tooltip"; +import DxcSidenav from "../sidenav/Sidenav"; import ApplicationLayoutPropsType, { AppLayoutMainPropsType } from "./types"; -import { bottomLinks, findChildType, socialLinks, useResponsive, year } from "./utils"; -import { HalstackLanguageContext } from "../HalstackContext"; +import { bottomLinks, findChildType, socialLinks, year } from "./utils"; -const ApplicationLayoutContainer = styled.div<{ - isSidenavVisible: boolean; - hasSidenav: boolean; -}>` +const ApplicationLayoutContainer = styled.div` top: 0; left: 0; display: grid; @@ -23,10 +15,6 @@ const ApplicationLayoutContainer = styled.div<{ width: 100vw; position: absolute; overflow: hidden; - - @media (max-width: ${responsiveSizes.large}rem) { - ${(props) => props.isSidenavVisible && "overflow: hidden;"} - } `; const HeaderContainer = styled.div` @@ -35,44 +23,6 @@ const HeaderContainer = styled.div` z-index: var(--z-app-layout-header); `; -const VisibilityToggle = styled.div` - box-sizing: border-box; - display: flex; - align-items: center; - padding: var(--spacing-padding-xxs) var(--spacing-padding-m); - width: 100%; - background-color: var(--color-bg-neutral-light); - user-select: none; - z-index: 1; -`; - -const HamburgerTrigger = styled.button` - display: flex; - flex-wrap: wrap; - gap: var(--spacing-gap-s); - border: 0px solid transparent; - border-radius: var(--border-radius-xs); - padding: var(--spacing-gap-none) var(--spacing-gap-none); - background-color: transparent; - font-family: var(--typography-font-family); - font-weight: var(--typography-label-semibold); - font-size: var(--typography-label-m); - color: var(--color-fg-neutral-dark); - cursor: pointer; - - :active { - background-color: var(--color-bg-neutral-lightest); - } - :focus, - :focus-visible { - outline: none; - outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); - } - span::before { - font-size: var(--height-xs); - } -`; - const BodyContainer = styled.div` display: flex; width: 100%; @@ -87,12 +37,6 @@ const SidenavContainer = styled.div` z-index: var(--z-app-layout-sidenav); position: sticky; overflow: auto; - - @media (max-width: ${responsiveSizes.large}rem) { - position: absolute; - top: 0px; - height: 100%; - } `; const MainContainer = styled.div` @@ -118,55 +62,14 @@ const MainContentContainer = styled.main` const Main = ({ children }: AppLayoutMainPropsType): JSX.Element => <div>{children}</div>; -const DxcApplicationLayout = ({ - visibilityToggleLabel = "", - header, - sidenav, - footer, - children, -}: ApplicationLayoutPropsType): JSX.Element => { - const [isSidenavVisibleResponsive, setIsSidenavVisibleResponsive] = useState(false); - const isResponsive = useResponsive(responsiveSizes.large); +const DxcApplicationLayout = ({ header, sidenav, footer, children }: ApplicationLayoutPropsType): JSX.Element => { const ref = useRef(null); - const translatedLabels = useContext(HalstackLanguageContext); - - const handleSidenavVisibility = () => { - setIsSidenavVisibleResponsive((currentIsSidenavVisibleResponsive) => !currentIsSidenavVisibleResponsive); - }; - - useEffect(() => { - if (!isResponsive) { - setIsSidenavVisibleResponsive(false); - } - }, [isResponsive]); return ( - <ApplicationLayoutContainer hasSidenav={!!sidenav} isSidenavVisible={isSidenavVisibleResponsive} ref={ref}> - <HeaderContainer> - {header ?? <DxcHeader underlined />} - {sidenav && isResponsive && ( - <VisibilityToggle> - <Tooltip label={translatedLabels.applicationLayout.visibilityToggleTitle}> - <HamburgerTrigger - onClick={handleSidenavVisibility} - aria-label={ - visibilityToggleLabel ? undefined : translatedLabels.applicationLayout.visibilityToggleTitle - } - > - <DxcIcon icon="Menu" /> - {visibilityToggleLabel} - </HamburgerTrigger> - </Tooltip> - </VisibilityToggle> - )} - </HeaderContainer> - + <ApplicationLayoutContainer ref={ref}> + <HeaderContainer>{header ?? <DxcHeader underlined />}</HeaderContainer> <BodyContainer> - <SidenavContextProvider value={setIsSidenavVisibleResponsive}> - {sidenav && (isResponsive ? isSidenavVisibleResponsive : true) && ( - <SidenavContainer>{sidenav}</SidenavContainer> - )} - </SidenavContextProvider> + {sidenav && <SidenavContainer>{sidenav}</SidenavContainer>} <MainContainer> <MainContentContainer> {findChildType(children, Main)} @@ -189,7 +92,6 @@ const DxcApplicationLayout = ({ DxcApplicationLayout.Footer = DxcFooter; DxcApplicationLayout.Header = DxcHeader; DxcApplicationLayout.Main = Main; -// DxcApplicationLayout.SideNav = DxcSidenav; -DxcApplicationLayout.useResponsiveSidenavVisibility = useResponsiveSidenavVisibility; +DxcApplicationLayout.SideNav = DxcSidenav; export default DxcApplicationLayout; diff --git a/packages/lib/src/layout/types.ts b/packages/lib/src/layout/types.ts index 45d151c95d..a00cddee67 100644 --- a/packages/lib/src/layout/types.ts +++ b/packages/lib/src/layout/types.ts @@ -19,11 +19,6 @@ export type AppLayoutSidenavPropsType = { }; type ApplicationLayoutPropsType = { - /** - * Text to be placed next to the hamburger button that toggles the - * visibility of the sidenav. - */ - visibilityToggleLabel?: string; /** * Header content. */ diff --git a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx index 824daa05bc..d7c31a7830 100644 --- a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx +++ b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx @@ -22,7 +22,7 @@ describe("Sidenav component accessibility tests", () => { icon: "bookmark", badge: <DxcBadge color="primary" label="Experimental" />, }, - { label: "Selected Item 3", selectedByDefault: true }, + { label: "Selected Item 3", selected: true }, ], }, ], diff --git a/packages/lib/src/sidenav/Sidenav.stories.tsx b/packages/lib/src/sidenav/Sidenav.stories.tsx index 8cc60a45d9..82feda81f0 100644 --- a/packages/lib/src/sidenav/Sidenav.stories.tsx +++ b/packages/lib/src/sidenav/Sidenav.stories.tsx @@ -56,7 +56,7 @@ const groupItems = [ label: "Grouped Item 1", icon: "favorite", items: [ - { label: "Item 1", icon: "person" }, + { label: "Item 1", icon: "person", href: "#1" }, { label: "Grouped Item 2", items: [ @@ -67,11 +67,12 @@ const groupItems = [ }, { label: "Selected Item 3" }, ], + href: "#2", }, ], badge: <DxcBadge color="success" label="New" />, }, - { label: "Item 4", icon: "key" }, + { label: "Item 4", icon: "key", href: "#3" }, ], }, { @@ -101,7 +102,7 @@ const selectedGroupItems = [ icon: "bookmark", badge: <DxcBadge color="primary" label="Experimental" />, }, - { label: "Selected Item 3", selectedByDefault: true }, + { label: "Selected Item 3", selected: true }, ], }, ], diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx index 4df5a28fa5..62421cebbb 100644 --- a/packages/lib/src/sidenav/Sidenav.tsx +++ b/packages/lib/src/sidenav/Sidenav.tsx @@ -2,12 +2,12 @@ import styled from "@emotion/styled"; import { responsiveSizes } from "../common/variables"; import DxcFlex from "../flex/Flex"; import SidenavPropsType, { Logo } from "./types"; -import scrollbarStyles from "../styles/scroll"; import DxcDivider from "../divider/Divider"; import DxcButton from "../button/Button"; import DxcContextualMenu from "../contextual-menu/ContextualMenu"; import DxcImage from "../image/Image"; import { useState } from "react"; +import DxcTextInput from "../text-input/TextInput"; const SidenavContainer = styled.div<{ expanded: boolean }>` box-sizing: border-box; @@ -23,9 +23,6 @@ const SidenavContainer = styled.div<{ expanded: boolean }>` 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} */ `; const SidenavTitle = styled.div` @@ -56,7 +53,6 @@ const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }: return ( <SidenavContainer expanded={isExpanded}> <DxcFlex justifyContent={isExpanded ? "space-between" : "center"}> - {/* TODO: HANDLE TITLE */} <DxcButton icon={`left_panel_${isExpanded ? "close" : "open"}`} size={{ height: "medium" }} @@ -68,6 +64,7 @@ const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }: /> {isExpanded && ( <DxcFlex direction="column" gap="var(--spacing-gap-m)" justifyContent="center" alignItems="flex-start"> + {/* TODO: ADD GORGORITO TO COVER CASES WITH NO ICON? */} {logo && ( <LogoContainer onClick={logo?.onClick} @@ -86,6 +83,22 @@ const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }: )} </DxcFlex> {/* TODO: SEARCHBAR */} + <DxcTextInput placeholder="Search docs" size="small" clearable /> + {/* <DxcTextInput + placeholder="Search docs" + value={filter} + onChange={({ value }: { value: string }) => { + setFilter(value); + }} + size="fillParent" + clearable + margin={{ + top: "large", + bottom: "large", + right: "medium", + left: "medium", + }} + /> */} {items && ( <DxcContextualMenu items={items} @@ -93,6 +106,7 @@ const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }: displayBorder={false} responsiveView={!isExpanded} displayControlsAfter + allowNavigation /> )} <DxcDivider color="lightGrey" /> @@ -101,9 +115,4 @@ const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }: ); }; -// DxcSidenav.Section = Section; -// DxcSidenav.Group = Group; -// DxcSidenav.Link = Link; -// DxcSidenav.Title = Title; - export default DxcSidenav; diff --git a/packages/lib/src/sidenav/SidenavContext.tsx b/packages/lib/src/sidenav/SidenavContext.tsx index fc8f9d2b3c..7d16fa2018 100644 --- a/packages/lib/src/sidenav/SidenavContext.tsx +++ b/packages/lib/src/sidenav/SidenavContext.tsx @@ -1,4 +1,4 @@ -import { createContext, Dispatch, SetStateAction, useContext } from "react"; +import { createContext, Dispatch, SetStateAction } from "react"; type SidenavContextType = (_isSidenavVisible: boolean) => void; @@ -9,8 +9,3 @@ export const GroupContext = createContext<Dispatch<SetStateAction<boolean>> | nu export const SidenavContextProvider = SidenavContext.Provider; export const GroupContextProvider = GroupContext.Provider; - -export const useResponsiveSidenavVisibility = () => { - const changeResponsiveSidenavVisibility = useContext(SidenavContext); - return changeResponsiveSidenavVisibility; -}; diff --git a/packages/lib/src/sidenav/types.ts b/packages/lib/src/sidenav/types.ts index 74fcf285d8..3d0b0c433c 100644 --- a/packages/lib/src/sidenav/types.ts +++ b/packages/lib/src/sidenav/types.ts @@ -121,7 +121,7 @@ type CommonItemProps = { }; type Item = CommonItemProps & { onSelect?: () => void; - selectedByDefault?: boolean; + selected?: boolean; }; type GroupItem = CommonItemProps & { items: (Item | GroupItem)[]; From 16eba2e585971d53be580737c52cf2bf80eaf8b4 Mon Sep 17 00:00:00 2001 From: Mil4n0r <morenocarmonaenrique@gmail.com> Date: Thu, 23 Oct 2025 12:10:41 +0200 Subject: [PATCH 08/36] Created new TreeNavigation --- packages/lib/src/sidenav/Sidenav.tsx | 37 ++- packages/lib/src/sidenav/types.ts | 4 +- .../lib/src/tree-navigation/GroupItem.tsx | 88 +++++++ .../lib/src/tree-navigation/ItemAction.tsx | 133 ++++++++++ packages/lib/src/tree-navigation/MenuItem.tsx | 21 ++ .../NavigationTree.accessibility.test.tsx | 100 ++++++++ .../NavigationTree.stories.tsx | 240 ++++++++++++++++++ .../tree-navigation/NavigationTree.test.tsx | 153 +++++++++++ .../src/tree-navigation/NavigationTree.tsx | 85 +++++++ .../tree-navigation/NavigationTreeContext.tsx | 4 + packages/lib/src/tree-navigation/Section.tsx | 48 ++++ .../lib/src/tree-navigation/SingleItem.tsx | 28 ++ packages/lib/src/tree-navigation/SubMenu.tsx | 29 +++ packages/lib/src/tree-navigation/types.ts | 105 ++++++++ packages/lib/src/tree-navigation/utils.ts | 38 +++ 15 files changed, 1097 insertions(+), 16 deletions(-) create mode 100644 packages/lib/src/tree-navigation/GroupItem.tsx create mode 100644 packages/lib/src/tree-navigation/ItemAction.tsx create mode 100644 packages/lib/src/tree-navigation/MenuItem.tsx create mode 100644 packages/lib/src/tree-navigation/NavigationTree.accessibility.test.tsx create mode 100644 packages/lib/src/tree-navigation/NavigationTree.stories.tsx create mode 100644 packages/lib/src/tree-navigation/NavigationTree.test.tsx create mode 100644 packages/lib/src/tree-navigation/NavigationTree.tsx create mode 100644 packages/lib/src/tree-navigation/NavigationTreeContext.tsx create mode 100644 packages/lib/src/tree-navigation/Section.tsx create mode 100644 packages/lib/src/tree-navigation/SingleItem.tsx create mode 100644 packages/lib/src/tree-navigation/SubMenu.tsx create mode 100644 packages/lib/src/tree-navigation/types.ts create mode 100644 packages/lib/src/tree-navigation/utils.ts diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx index 62421cebbb..fe70f0f4ec 100644 --- a/packages/lib/src/sidenav/Sidenav.tsx +++ b/packages/lib/src/sidenav/Sidenav.tsx @@ -4,10 +4,10 @@ import DxcFlex from "../flex/Flex"; import SidenavPropsType, { Logo } from "./types"; import DxcDivider from "../divider/Divider"; import DxcButton from "../button/Button"; -import DxcContextualMenu from "../contextual-menu/ContextualMenu"; import DxcImage from "../image/Image"; -import { useState } from "react"; +import { ReactElement, useState } from "react"; import DxcTextInput from "../text-input/TextInput"; +import DxcNavigationTree from "../tree-navigation/NavigationTree"; const SidenavContainer = styled.div<{ expanded: boolean }>` box-sizing: border-box; @@ -50,6 +50,10 @@ const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }: const renderedChildren = typeof children === "function" ? children(isExpanded) : children; + function isLogoObject(logo: Logo | ReactElement): logo is Logo { + return (logo as Logo).src !== undefined; + } + return ( <SidenavContainer expanded={isExpanded}> <DxcFlex justifyContent={isExpanded ? "space-between" : "center"}> @@ -66,17 +70,22 @@ const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }: <DxcFlex direction="column" gap="var(--spacing-gap-m)" justifyContent="center" alignItems="flex-start"> {/* TODO: ADD GORGORITO TO COVER CASES WITH NO ICON? */} {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> + <> + {isLogoObject(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")} + > + <DxcImage alt={logo.alt ?? ""} src={logo.src} height="100%" width="100%" /> + </LogoContainer> + ) : ( + logo + )} + </> )} <SidenavTitle>{title}</SidenavTitle> </DxcFlex> @@ -100,7 +109,7 @@ const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }: }} /> */} {items && ( - <DxcContextualMenu + <DxcNavigationTree items={items} displayGroupLines={displayGroupLines} displayBorder={false} diff --git a/packages/lib/src/sidenav/types.ts b/packages/lib/src/sidenav/types.ts index 3d0b0c433c..b87de890c3 100644 --- a/packages/lib/src/sidenav/types.ts +++ b/packages/lib/src/sidenav/types.ts @@ -76,7 +76,7 @@ export type Logo = { /** * Alternative text for the logo image. */ - alt: string; + alt?: string; /** * URL to navigate to when the logo is clicked. If not provided, the logo will not be clickable. */ @@ -107,7 +107,7 @@ type Props = { /** * Object with the properties of the logo placed at the top of the sidenav. */ - logo?: Logo; + logo?: Logo | ReactElement; /** * If true the nav menu will have lines marking the groups. */ diff --git a/packages/lib/src/tree-navigation/GroupItem.tsx b/packages/lib/src/tree-navigation/GroupItem.tsx new file mode 100644 index 0000000000..5c9b34e823 --- /dev/null +++ b/packages/lib/src/tree-navigation/GroupItem.tsx @@ -0,0 +1,88 @@ +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 NavigationTreeContext from "./NavigationTreeContext"; +import { isGroupSelected } from "./utils"; +import * as Popover from "@radix-ui/react-popover"; + +const GroupItem = ({ items, ...props }: GroupItemProps) => { + const groupMenuId = `group-menu-${useId()}`; + const { selectedItemId, responsiveView } = useContext(NavigationTreeContext) ?? {}; + const groupSelected = useMemo(() => isGroupSelected(items, selectedItemId), [items, selectedItemId]); + const [isOpen, setIsOpen] = useState(groupSelected && selectedItemId === -1); + + const NavigationTreeId = `sidenav-${useId()}`; + + const contextValue = useContext(NavigationTreeContext) ?? {}; + + return responsiveView ? ( + <> + <Popover.Root open={isOpen}> + <Popover.Trigger + aria-controls={undefined} + aria-expanded={undefined} + aria-haspopup={undefined} + asChild + type={undefined} + > + <ItemAction + aria-controls={isOpen ? groupMenuId : undefined} + aria-expanded={isOpen ? true : undefined} + aria-pressed={groupSelected && !isOpen} + collapseIcon={isOpen ? <DxcIcon icon="filled_expand_less" /> : <DxcIcon icon="filled_expand_more" />} + onClick={() => setIsOpen((isCurrentlyOpen) => !isCurrentlyOpen)} + selected={groupSelected && !isOpen} + {...props} + /> + </Popover.Trigger> + <Popover.Portal container={document.getElementById(`${NavigationTreeId}-portal`)}> + <NavigationTreeContext.Provider value={{ ...contextValue, displayGroupLines: false, responsiveView: false }}> + <Popover.Content + aria-label="Group details" + onCloseAutoFocus={(event) => { + event.preventDefault(); + }} + onOpenAutoFocus={(event) => { + event.preventDefault(); + }} + align="start" + side="right" + style={{ zIndex: "var(--z-contextualmenu)" }} + > + <SubMenu id={groupMenuId} depthLevel={props.depthLevel}> + {items.map((item, index) => ( + <MenuItem item={item} depthLevel={props.depthLevel + 1} key={`${item.label}-${index}`} /> + ))} + </SubMenu> + </Popover.Content> + </NavigationTreeContext.Provider> + </Popover.Portal> + </Popover.Root> + <div id={`${NavigationTreeId}-portal`} style={{ position: "absolute" }} /> + </> + ) : ( + <> + <ItemAction + aria-controls={isOpen ? groupMenuId : undefined} + aria-expanded={isOpen ? true : undefined} + aria-pressed={groupSelected && !isOpen} + collapseIcon={isOpen ? <DxcIcon icon="filled_expand_less" /> : <DxcIcon icon="filled_expand_more" />} + onClick={() => setIsOpen((isCurrentlyOpen) => !isCurrentlyOpen)} + selected={groupSelected && !isOpen} + {...props} + /> + {isOpen && ( + <SubMenu id={groupMenuId} depthLevel={props.depthLevel}> + {items.map((item, index) => ( + <MenuItem item={item} depthLevel={props.depthLevel + 1} key={`${item.label}-${index}`} /> + ))} + </SubMenu> + )} + </> + ); +}; + +export default GroupItem; diff --git a/packages/lib/src/tree-navigation/ItemAction.tsx b/packages/lib/src/tree-navigation/ItemAction.tsx new file mode 100644 index 0000000000..c0e0209616 --- /dev/null +++ b/packages/lib/src/tree-navigation/ItemAction.tsx @@ -0,0 +1,133 @@ +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 NavigationTreeContext from "./NavigationTreeContext"; + +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); + ${({ 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: ${({ responsiveView }) => (responsiveView ? "center" : "space-between")}; + background-color: ${({ selected }) => (selected ? "var(--color-bg-primary-lighter)" : "transparent")}; + height: var(--height-s); + cursor: pointer; + overflow: hidden; + text-decoration: none; + + &: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; +`; + +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( + forwardRef<HTMLButtonElement, ItemActionProps>( + ({ badge, collapseIcon, depthLevel, icon, label, href, ...props }, ref) => { + const [hasTooltip, setHasTooltip] = useState(false); + const modifiedBadge = badge && cloneElement(badge, { size: "small" }); + const { displayControlsAfter, responsiveView, displayGroupLines, allowNavigation } = + useContext(NavigationTreeContext) ?? {}; + + return ( + <TooltipWrapper condition={hasTooltip} label={label}> + <Action + as={allowNavigation && href ? "a" : "button"} + role={allowNavigation && href ? "link" : "button"} + ref={ref} + depthLevel={depthLevel} + displayGroupLines={!!displayGroupLines} + responsiveView={responsiveView} + {...(allowNavigation && href && { href })} + {...props} + > + <Label> + {!displayControlsAfter && <Control>{collapseIcon && <Icon>{collapseIcon}</Icon>}</Control>} + <TooltipWrapper condition={responsiveView} label={label}> + <Icon>{typeof icon === "string" ? <DxcIcon icon={icon} /> : icon}</Icon> + </TooltipWrapper> + {!responsiveView && ( + <Text + selected={props.selected} + onMouseEnter={(event: MouseEvent<HTMLSpanElement>) => { + const text = event.currentTarget; + setHasTooltip(text.scrollWidth > text.clientWidth); + }} + > + {label} + </Text> + )} + </Label> + {!responsiveView && ( + <Control> + {modifiedBadge} + {displayControlsAfter && collapseIcon && <Icon>{collapseIcon}</Icon>} + </Control> + )} + </Action> + </TooltipWrapper> + ); + } + ) +); + +ItemAction.displayName = "ItemAction"; + +export default ItemAction; diff --git a/packages/lib/src/tree-navigation/MenuItem.tsx b/packages/lib/src/tree-navigation/MenuItem.tsx new file mode 100644 index 0000000000..65aadf7f17 --- /dev/null +++ b/packages/lib/src/tree-navigation/MenuItem.tsx @@ -0,0 +1,21 @@ +import styled from "@emotion/styled"; +import GroupItem from "./GroupItem"; +import SingleItem from "./SingleItem"; +import { MenuItemProps } from "./types"; + +const MenuItemContainer = styled.li` + display: grid; + gap: var(--spacing-gap-xs); +`; + +export default function MenuItem({ item, depthLevel = 0 }: MenuItemProps) { + return ( + <MenuItemContainer role="menuitem"> + {"items" in item ? ( + <GroupItem {...item} depthLevel={depthLevel} /> + ) : ( + <SingleItem {...item} depthLevel={depthLevel} /> + )} + </MenuItemContainer> + ); +} diff --git a/packages/lib/src/tree-navigation/NavigationTree.accessibility.test.tsx b/packages/lib/src/tree-navigation/NavigationTree.accessibility.test.tsx new file mode 100644 index 0000000000..ca6231b0f4 --- /dev/null +++ b/packages/lib/src/tree-navigation/NavigationTree.accessibility.test.tsx @@ -0,0 +1,100 @@ +import { render } from "@testing-library/react"; +import { axe } from "../../test/accessibility/axe-helper"; +import DxcBadge from "../badge/Badge"; +import DxcNavigationTree from "./NavigationTree"; + +const badgeIcon = ( + <svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor"> + <path d="M11 17H13V11H11V17ZM12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM12 20C7.59 20 4 16.41 4 12C4 7.59 7.59 4 12 4C16.41 4 20 7.59 20 12C20 16.41 16.41 20 12 20ZM11 9H13V7H11V9Z" /> + <path d="M11 7H13V9H11V7ZM11 11H13V17H11V11Z" /> + <path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM12 20C7.59 20 4 16.41 4 12C4 7.59 7.59 4 12 4C16.41 4 20 7.59 20 12C20 16.41 16.41 20 12 20Z" /> + </svg> +); + +const keyIcon = ( + <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="currentColor"> + <path d="M280-400q-33 0-56.5-23.5T200-480q0-33 23.5-56.5T280-560q33 0 56.5 23.5T360-480q0 33-23.5 56.5T280-400Zm0 160q-100 0-170-70T40-480q0-100 70-170t170-70q67 0 121.5 33t86.5 87h352l120 120-180 180-80-60-80 60-85-60h-47q-32 54-86.5 87T280-240Zm0-80q56 0 98.5-34t56.5-86h125l58 41 82-61 71 55 75-75-40-40H435q-14-52-56.5-86T280-640q-66 0-113 47t-47 113q0 66 47 113t113 47Z" /> + </svg> +); + +const favIcon = ( + <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="currentColor"> + <path d="m480-120-58-52q-101-91-167-157T150-447.5Q111-500 95.5-544T80-634q0-94 63-157t157-63q52 0 99 22t81 62q34-40 81-62t99-22q94 0 157 63t63 157q0 46-15.5 90T810-447.5Q771-395 705-329T538-172l-58 52Zm0-108q96-86 158-147.5t98-107q36-45.5 50-81t14-70.5q0-60-40-100t-100-40q-47 0-87 26.5T518-680h-76q-15-41-55-67.5T300-774q-60 0-100 40t-40 100q0 35 14 70.5t50 81q36 45.5 98 107T480-228Zm0-273Z" /> + </svg> +); + +const itemsWithTruncatedText = [ + { + label: "Item with a very long label that should be truncated", + slot: <DxcBadge color="secondary" mode="contextual" label="Label" size="small" icon={badgeIcon} title="Badge" />, + icon: keyIcon, + }, + { + label: "Item 2", + slot: ( + <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path + d="M10.6667 10.6667H1.33333V1.33333H6V0H1.33333C0.593333 0 0 0.6 0 1.33333V10.6667C0 11.4 0.593333 12 1.33333 12H10.6667C11.4 12 12 11.4 12 10.6667V6H10.6667V10.6667ZM7.33333 0V1.33333H9.72667L3.17333 7.88667L4.11333 8.82667L10.6667 2.27333V4.66667H12V0H7.33333Z" + fill="#323232" + /> + </svg> + ), + icon: favIcon, + }, +]; + +const items = [ + { + title: "Business services", + items: [ + { + label: "Home", + icon: "home", + items: [ + { label: "Data & statistics" }, + { + label: "Apps", + items: [ + { + label: "Sales data module", + badge: <DxcBadge color="primary" label="Experimental" />, + }, + { label: "Central platform" }, + ], + }, + ], + }, + { + label: "Data warehouse", + icon: "database", + items: [ + { + label: "Data & statistics", + }, + { + label: "Sales performance", + }, + { + label: "Key metrics", + }, + ], + }, + ], + }, + { + items: [{ label: "Support", icon: "support_agent" }], + }, +]; + +describe("Navigation tree accessibility tests", () => { + it("Should not have basic accessibility issues", async () => { + const { container } = render(<DxcNavigationTree items={itemsWithTruncatedText} />); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + it("A complex navigation tree should not have basic accessibility issues", async () => { + const { container } = render(<DxcNavigationTree items={items} />); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); +}); diff --git a/packages/lib/src/tree-navigation/NavigationTree.stories.tsx b/packages/lib/src/tree-navigation/NavigationTree.stories.tsx new file mode 100644 index 0000000000..4fc831a429 --- /dev/null +++ b/packages/lib/src/tree-navigation/NavigationTree.stories.tsx @@ -0,0 +1,240 @@ +import ExampleContainer from "../../.storybook/components/ExampleContainer"; +import Title from "../../.storybook/components/Title"; +import DxcBadge from "../badge/Badge"; +import DxcContainer from "../container/Container"; +import DxcNavigationTree from "./NavigationTree"; +import SingleItem from "./SingleItem"; +import NavigationTreeContext from "./NavigationTreeContext"; +import { Meta, StoryObj } from "@storybook/react-vite"; +import { userEvent, within } from "storybook/internal/test"; + +export default { + title: "Navigation Tree", + component: DxcNavigationTree, +} satisfies Meta<typeof DxcNavigationTree>; + +const items = [{ label: "Item 1" }, { label: "Item 2" }, { label: "Item 3" }, { label: "Item 4" }]; + +const sections = [ + { + title: "Section title", + items: [{ label: "Approved locations" }, { label: "Approved locations" }, { label: "Approved locations" }], + }, + { + items: [{ label: "Approved locations" }, { label: "Approved locations" }, { label: "Approved locations" }], + }, +]; + +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", selected: 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" }, + ], + }, +]; + +const itemsWithIcon = [ + { + label: "Item 1", + icon: ( + <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="currentColor"> + <path d="M200-120v-640q0-33 23.5-56.5T280-840h400q33 0 56.5 23.5T760-760v640L480-240 200-120Zm80-122 200-86 200 86v-518H280v518Zm0-518h400-400Z" /> + </svg> + ), + }, + { + label: "Item 2", + icon: "star", + }, +]; + +const itemsWithBadge = [ + { + label: "Item 1", + badge: <DxcBadge color="success" label="New" />, + }, + { + label: "Item 2", + badge: <DxcBadge color="primary" label="Experimental" />, + }, +]; + +const sectionsWithScroll = [ + { + title: "Team repositories", + items: [{ label: "Approved locations" }, { label: "Approved locations" }, { label: "Approved locations" }], + }, + { + items: [ + { label: "Approved locations" }, + { label: "Approved locations" }, + { label: "Approved locations" }, + { label: "Approved locations" }, + { label: "Approved locations" }, + { label: "Approved locations" }, + { label: "Approved locations" }, + { label: "Approved locations" }, + { label: "Approved locations", selected: true }, + ], + }, +]; + +const itemsWithTruncatedText = [ + { + label: "Item with a very long label that should be truncated", + badge: <DxcBadge color="success" label="New" />, + icon: ( + <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="currentColor"> + <path d="M200-120v-640q0-33 23.5-56.5T280-840h400q33 0 56.5 23.5T760-760v640L480-240 200-120Zm80-122 200-86 200 86v-518H280v518Zm0-518h400-400Z" /> + </svg> + ), + }, + { + label: "Item 2", + icon: "favorite", + }, +]; + +const NavigationTree = () => ( + <> + <Title title="Default" theme="light" level={3} /> + <ExampleContainer> + <DxcNavigationTree items={items} /> + </ExampleContainer> + <Title title="With sections" theme="light" level={3} /> + <ExampleContainer> + <DxcContainer width="300px"> + <DxcNavigationTree items={sections} /> + </DxcContainer> + </ExampleContainer> + <Title title="With group items" theme="light" level={3} /> + <ExampleContainer> + <DxcContainer width="300px"> + <DxcNavigationTree items={groupItems} /> + </DxcContainer> + </ExampleContainer> + <Title title="With icons" theme="light" level={3} /> + <ExampleContainer> + <DxcContainer width="300px"> + <DxcNavigationTree items={itemsWithIcon} /> + </DxcContainer> + </ExampleContainer> + <Title title="With badge" theme="light" level={3} /> + <ExampleContainer> + <DxcContainer width="300px"> + <DxcNavigationTree items={itemsWithBadge} /> + </DxcContainer> + </ExampleContainer> + <Title title="With label truncated" theme="light" level={3} /> + <ExampleContainer> + <DxcContainer width="300px"> + <DxcNavigationTree items={itemsWithTruncatedText} /> + </DxcContainer> + </ExampleContainer> + <Title title="With auto-scroll" theme="light" level={3} /> + <ExampleContainer> + <DxcContainer height="300px" width="300px"> + <DxcNavigationTree items={sectionsWithScroll} /> + </DxcContainer> + </ExampleContainer> + <Title title="Width doesn't go below 248px" theme="light" level={3} /> + <ExampleContainer> + <DxcContainer width="200px"> + <DxcNavigationTree items={items} /> + </DxcContainer> + </ExampleContainer> + </> +); + +const Single = () => ( + <DxcContainer width="300px"> + <NavigationTreeContext.Provider value={{ selectedItemId: -1, setSelectedItemId: () => {} }}> + <Title title="Default" theme="light" level={3} /> + <ExampleContainer> + <SingleItem {...items[0]!} id={0} depthLevel={0} /> + </ExampleContainer> + <Title title="Focus" theme="light" level={3} /> + <ExampleContainer pseudoState="pseudo-focus"> + <SingleItem {...items[0]!} id={0} depthLevel={0} /> + </ExampleContainer> + <Title title="Hover" theme="light" level={3} /> + <ExampleContainer pseudoState="pseudo-hover"> + <SingleItem {...items[0]!} id={0} depthLevel={0} /> + </ExampleContainer> + <Title title="Active" theme="light" level={3} /> + <ExampleContainer pseudoState="pseudo-active"> + <SingleItem {...items[0]!} id={0} depthLevel={0} /> + </ExampleContainer> + </NavigationTreeContext.Provider> + <NavigationTreeContext.Provider value={{ selectedItemId: 0, setSelectedItemId: () => {} }}> + <Title title="Selected" theme="light" level={3} /> + <ExampleContainer> + <SingleItem {...items[0]!} id={0} depthLevel={0} /> + </ExampleContainer> + <Title title="Selected hover" theme="light" level={3} /> + <ExampleContainer pseudoState="pseudo-hover"> + <SingleItem {...items[0]!} id={0} depthLevel={0} /> + </ExampleContainer> + <Title title="Selected active" theme="light" level={3} /> + <ExampleContainer pseudoState="pseudo-active"> + <SingleItem {...items[0]!} id={0} depthLevel={0} /> + </ExampleContainer> + </NavigationTreeContext.Provider> + </DxcContainer> +); + +const ItemWithEllipsis = () => ( + <ExampleContainer expanded> + <Title title="Tooltip in items with ellipsis" theme="light" level={3} /> + <DxcContainer width="300px"> + <DxcNavigationTree items={itemsWithTruncatedText} /> + </DxcContainer> + </ExampleContainer> +); + +type Story = StoryObj<typeof DxcNavigationTree>; + +export const Chromatic: Story = { + render: NavigationTree, +}; + +export const SingleItemStates: Story = { + render: Single, +}; + +export const NavigationTreeTooltip: Story = { + render: ItemWithEllipsis, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.hover(await canvas.findByText("Item with a very long label that should be truncated")); + await userEvent.hover(await canvas.findByText("Item with a very long label that should be truncated")); + }, +}; diff --git a/packages/lib/src/tree-navigation/NavigationTree.test.tsx b/packages/lib/src/tree-navigation/NavigationTree.test.tsx new file mode 100644 index 0000000000..643172d3cb --- /dev/null +++ b/packages/lib/src/tree-navigation/NavigationTree.test.tsx @@ -0,0 +1,153 @@ +import { fireEvent, render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import DxcNavigationTree from "./NavigationTree"; + +const items = [{ label: "Item 1" }, { label: "Item 2" }, { label: "Item 3" }, { label: "Item 4" }]; + +const sections = [ + { + title: "Section title", + items: [{ label: "Approved locations" }, { label: "Approved locations" }, { label: "Approved locations" }], + }, + { + items: [{ label: "Approved locations" }, { label: "Approved locations" }, { label: "Approved locations" }], + }, +]; + +const groups = [ + { + label: "Grouped Item 1", + items: [ + { label: "Item 1" }, + { + label: "Grouped Item 2", + items: [{ label: "Item 2" }, { label: "Item 3" }], + }, + ], + }, + { label: "Item 4", icon: "key" }, + { label: "Grouped Item 3", items: [{ label: "Item 6" }, { label: "Item 7" }] }, + { label: "Item 8" }, +]; + +describe("Navigation tree component tests", () => { + test("Single — Renders with correct aria attributes", () => { + const { getAllByRole, getByRole } = render(<DxcNavigationTree items={items} />); + expect(getAllByRole("menuitem").length).toBe(4); + const actions = getAllByRole("button"); + if (actions[0] != null) { + userEvent.click(actions[0]); + } + expect(actions[0]?.getAttribute("aria-pressed")).toBeTruthy(); + expect(getByRole("menu")).toBeTruthy(); + }); + test("Single — An item can appear as selected by default by using the attribute selected", () => { + const test = [ + { + label: "Tested item", + selected: true, + }, + ]; + const { getByRole } = render(<DxcNavigationTree items={test} />); + const item = getByRole("button"); + expect(item.getAttribute("aria-pressed")).toBeTruthy(); + }); + test("Group — Group items collapse when clicked", () => { + const { queryByText, getByText } = render(<DxcNavigationTree items={groups} />); + userEvent.click(getByText("Grouped Item 1")); + expect(getByText("Item 1")).toBeTruthy(); + expect(getByText("Grouped Item 2")).toBeTruthy(); + userEvent.click(getByText("Grouped Item 2")); + expect(getByText("Item 2")).toBeTruthy(); + expect(getByText("Item 3")).toBeTruthy(); + userEvent.click(getByText("Grouped Item 1")); + expect(queryByText("Item 1")).toBeFalsy(); + expect(queryByText("Item 2")).toBeFalsy(); + expect(queryByText("Item 3")).toBeFalsy(); + }); + test("Group — Renders with correct aria attributes", () => { + const { getAllByRole } = render(<DxcNavigationTree items={groups} />); + const group1 = getAllByRole("button")[0]; + if (group1 != null) { + userEvent.click(group1); + } + expect(group1?.getAttribute("aria-expanded")).toBeTruthy(); + expect(group1?.getAttribute("aria-controls")).toBe(group1?.nextElementSibling?.id); + const expandedGroupItem1 = getAllByRole("button")[2]; + if (expandedGroupItem1 != null) { + userEvent.click(expandedGroupItem1); + } + const expandedGroupedItem2 = getAllByRole("button")[6]; + if (expandedGroupedItem2 != null) { + userEvent.click(expandedGroupedItem2); + } + expect(getAllByRole("menuitem").length).toBe(10); + const optionToBeClicked = getAllByRole("button")[4]; + if (optionToBeClicked != null) { + userEvent.click(optionToBeClicked); + } + expect(optionToBeClicked?.getAttribute("aria-pressed")).toBeTruthy(); + }); + test("Group — A grouped item, selected by default, must be visible (expanded group) in the first render of the component", () => { + const test = [ + { + label: "Grouped item", + items: [{ label: "Tested item", selected: true }], + }, + ]; + const { getByText, getAllByRole } = render(<DxcNavigationTree items={test} />); + expect(getByText("Tested item")).toBeTruthy(); + expect(getAllByRole("button")[1]?.getAttribute("aria-pressed")).toBeTruthy(); + }); + test("Group — Collapsed groups render as selected when containing a selected item", () => { + const { getAllByRole } = render(<DxcNavigationTree items={groups} />); + const group1 = getAllByRole("button")[0]; + if (group1 != null) { + userEvent.click(group1); + } + const group2 = getAllByRole("button")[2]; + if (group2 != null) { + userEvent.click(group2); + } + const item = getAllByRole("button")[3]; + if (item != null) { + userEvent.click(item); + } + expect(item?.getAttribute("aria-pressed")).toBeTruthy(); + expect(group1?.getAttribute("aria-pressed")).toBe("false"); + expect(group2?.getAttribute("aria-pressed")).toBe("false"); + if (group2 != null) { + userEvent.click(group2); + } + expect(group2?.getAttribute("aria-pressed")).toBe("true"); + if (group1 != null) { + userEvent.click(group1); + } + expect(group1?.getAttribute("aria-pressed")).toBe("true"); + }); + test("Sections — Renders with correct aria attributes", () => { + const { getAllByRole, getByText } = render(<DxcNavigationTree items={sections} />); + expect(getAllByRole("region").length).toBe(2); + expect(getAllByRole("menuitem").length).toBe(6); + const actions = getAllByRole("button"); + if (actions[0] != null) { + userEvent.click(actions[0]); + } + expect(actions[0]?.getAttribute("aria-pressed")).toBeTruthy(); + expect(getAllByRole("menu").length).toBe(2); + expect(getAllByRole("region")[0]?.getAttribute("aria-labelledby")).toBe(getByText("Section title").id); + expect(getAllByRole("region")[1]?.getAttribute("aria-label")).toBeTruthy(); + }); + test("The onSelect event from each item is called correctly", () => { + const test = [ + { + label: "Tested item", + onSelect: jest.fn(), + }, + ]; + const { getByRole } = render(<DxcNavigationTree items={test} />); + const item = getByRole("button"); + fireEvent.click(item); + expect(test[0]?.onSelect).toHaveBeenCalled(); + }); +}); diff --git a/packages/lib/src/tree-navigation/NavigationTree.tsx b/packages/lib/src/tree-navigation/NavigationTree.tsx new file mode 100644 index 0000000000..c1e3126637 --- /dev/null +++ b/packages/lib/src/tree-navigation/NavigationTree.tsx @@ -0,0 +1,85 @@ +import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import styled from "@emotion/styled"; +import MenuItem from "./MenuItem"; +import NavigationTreePropsType, { GroupItemWithId, ItemWithId, SectionWithId } from "./types"; +import Section from "./Section"; +import NavigationTreeContext from "./NavigationTreeContext"; +import scrollbarStyles from "../styles/scroll"; +import { addIdToItems, isSection } from "./utils"; +import SubMenu from "./SubMenu"; + +const NavigationTreeContainer = styled.div<{ displayBorder: boolean }>` + box-sizing: border-box; + margin: 0; + 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}; + ${({ 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 DxcNavigationTree({ + items, + displayBorder = true, + displayGroupLines = false, + displayControlsAfter = false, + responsiveView = false, + allowNavigation = false, +}: NavigationTreePropsType) { + const [firstUpdate, setFirstUpdate] = useState(true); + const [selectedItemId, setSelectedItemId] = useState(-1); + const NavigationTreeRef = useRef<HTMLDivElement | null>(null); + const itemsWithId = useMemo(() => addIdToItems(items), [items]); + const contextValue = useMemo( + () => ({ + selectedItemId, + setSelectedItemId, + displayGroupLines, + displayControlsAfter, + responsiveView, + allowNavigation, + }), + [selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView, allowNavigation] + ); + + useLayoutEffect(() => { + if (selectedItemId !== -1 && firstUpdate) { + const NavigationTreeEl = NavigationTreeRef.current; + const selectedItemEl = NavigationTreeEl?.querySelector("[aria-pressed='true']"); + if (selectedItemEl instanceof HTMLButtonElement) { + NavigationTreeEl?.scrollTo?.({ + top: (selectedItemEl?.offsetTop ?? 0) - (NavigationTreeEl?.clientHeight ?? 0) / 2, + }); + } + setFirstUpdate(false); + } + }, [firstUpdate, selectedItemId]); + + return ( + <NavigationTreeContainer displayBorder={displayBorder} ref={NavigationTreeRef}> + <NavigationTreeContext.Provider value={contextValue}> + {itemsWithId[0] && isSection(itemsWithId[0]) ? ( + (itemsWithId as SectionWithId[]).map((item, index) => ( + <Section key={`section-${index}`} section={item} index={index} length={itemsWithId.length} /> + )) + ) : ( + <SubMenu> + {(itemsWithId as (GroupItemWithId | ItemWithId)[]).map((item, index) => ( + <MenuItem item={item} key={`${item.label}-${index}`} /> + ))} + </SubMenu> + )} + </NavigationTreeContext.Provider> + </NavigationTreeContainer> + ); +} diff --git a/packages/lib/src/tree-navigation/NavigationTreeContext.tsx b/packages/lib/src/tree-navigation/NavigationTreeContext.tsx new file mode 100644 index 0000000000..99fc7b12e2 --- /dev/null +++ b/packages/lib/src/tree-navigation/NavigationTreeContext.tsx @@ -0,0 +1,4 @@ +import { createContext } from "react"; +import { NavigationTreeContextProps } from "./types"; + +export default createContext<NavigationTreeContextProps | null>(null); diff --git a/packages/lib/src/tree-navigation/Section.tsx b/packages/lib/src/tree-navigation/Section.tsx new file mode 100644 index 0000000000..2b25e7d4c2 --- /dev/null +++ b/packages/lib/src/tree-navigation/Section.tsx @@ -0,0 +1,48 @@ +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 NavigationTreeContext from "./NavigationTreeContext"; + +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()}`; + const { responsiveView } = useContext(NavigationTreeContext) ?? {}; + return !responsiveView ? ( + <SectionContainer aria-label={section.title ?? id} aria-labelledby={id}> + {section.title && <Title id={id}>{section.title}} + + {section.items.map((item, i) => ( + + ))} + + {index !== length - 1 && ( + + + + )} + + ) : ( + + {section.items.map((item, i) => ( + + ))} + + ); +} diff --git a/packages/lib/src/tree-navigation/SingleItem.tsx b/packages/lib/src/tree-navigation/SingleItem.tsx new file mode 100644 index 0000000000..07e02373e8 --- /dev/null +++ b/packages/lib/src/tree-navigation/SingleItem.tsx @@ -0,0 +1,28 @@ +import { useContext, useEffect } from "react"; +import ItemAction from "./ItemAction"; +import { SingleItemProps } from "./types"; +import NavigationTreeContext from "./NavigationTreeContext"; + +export default function SingleItem({ id, onSelect, selected = false, ...props }: SingleItemProps) { + const { selectedItemId, setSelectedItemId } = useContext(NavigationTreeContext) ?? {}; + + const handleClick = () => { + setSelectedItemId?.(id); + onSelect?.(); + }; + + useEffect(() => { + if (selectedItemId === -1 && selected) { + setSelectedItemId?.(id); + } + }, [selectedItemId, selected, id]); + + return ( + + ); +} diff --git a/packages/lib/src/tree-navigation/SubMenu.tsx b/packages/lib/src/tree-navigation/SubMenu.tsx new file mode 100644 index 0000000000..40414e071f --- /dev/null +++ b/packages/lib/src/tree-navigation/SubMenu.tsx @@ -0,0 +1,29 @@ +import styled from "@emotion/styled"; +import { SubMenuProps } from "./types"; +import NavigationTreeContext from "./NavigationTreeContext"; +import { useContext } from "react"; + +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, depthLevel = 0 }: SubMenuProps) { + const { displayGroupLines } = useContext(NavigationTreeContext) ?? {}; + return ( + + {children} + + ); +} diff --git a/packages/lib/src/tree-navigation/types.ts b/packages/lib/src/tree-navigation/types.ts new file mode 100644 index 0000000000..45f4409cde --- /dev/null +++ b/packages/lib/src/tree-navigation/types.ts @@ -0,0 +1,105 @@ +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; + selected?: boolean; + href?: string; +}; +type GroupItem = CommonItemProps & { + items: (Item | GroupItem)[]; +}; +type Section = { items: (Item | GroupItem)[]; title?: string }; +type Props = { + /** + * Array of items to be displayed in the navigation tree. + * Each item can be a single/simple item, a group item or a section. + */ + items: (Item | GroupItem)[] | Section[]; + /** + * If true the navigation tree will be displayed with a border. + */ + displayBorder?: boolean; + /** + * If true the navigation tree will have lines marking the groups. + */ + displayGroupLines?: boolean; + /** + * If true the navigation tree will have controls at the end. + */ + displayControlsAfter?: boolean; + /** + * If true the navigation tree will be icons only and display a popover on click. + */ + responsiveView?: boolean; + /** + * If true the leaf nodes will be rendered as anchor elements when href is provided. + */ + allowNavigation?: 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; + href?: Item["href"]; +}; +type SectionProps = { + section: SectionWithId; + index: number; + length: number; +}; +type SubMenuProps = { children: ReactNode; id?: string; depthLevel?: number }; +type NavigationTreeContextProps = { + selectedItemId?: number; + setSelectedItemId?: Dispatch>; + displayGroupLines?: boolean; + displayControlsAfter?: boolean; + responsiveView?: boolean; + allowNavigation?: boolean; +}; + +export type { + NavigationTreeContextProps, + GroupItem, + GroupItemProps, + GroupItemWithId, + Item, + ItemActionProps, + ItemWithId, + SubMenuProps, + MenuItemProps, + Section, + SectionWithId, + SectionProps, + SingleItemProps, +}; + +export default Props; diff --git a/packages/lib/src/tree-navigation/utils.ts b/packages/lib/src/tree-navigation/utils.ts new file mode 100644 index 0000000000..77db32b032 --- /dev/null +++ b/packages/lib/src/tree-navigation/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.selected; + }); From 57645fa4434892a271285358114e177cce7f2cf8 Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Thu, 23 Oct 2025 12:17:34 +0200 Subject: [PATCH 09/36] Restored contextualmenu --- .../ContextualMenu.stories.tsx | 4 +- .../contextual-menu/ContextualMenu.test.tsx | 6 +- .../src/contextual-menu/ContextualMenu.tsx | 41 ++------ .../lib/src/contextual-menu/GroupItem.tsx | 56 +---------- .../lib/src/contextual-menu/ItemAction.tsx | 93 ++++++------------- packages/lib/src/contextual-menu/Section.tsx | 15 +-- .../lib/src/contextual-menu/SingleItem.tsx | 12 ++- packages/lib/src/contextual-menu/SubMenu.tsx | 17 +--- packages/lib/src/contextual-menu/types.ts | 52 ++--------- packages/lib/src/contextual-menu/utils.ts | 2 +- 10 files changed, 67 insertions(+), 231 deletions(-) diff --git a/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx b/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx index a83b217fee..f174fbabe8 100644 --- a/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx +++ b/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx @@ -42,7 +42,7 @@ const groupItems = [ icon: "bookmark", badge: , }, - { label: "Selected Item 3", selected: true }, + { label: "Selected Item 3", selectedByDefault: true }, ], }, ], @@ -102,7 +102,7 @@ const sectionsWithScroll = [ { label: "Approved locations" }, { label: "Approved locations" }, { label: "Approved locations" }, - { label: "Approved locations", selected: true }, + { label: "Approved locations", selectedByDefault: true }, ], }, ]; diff --git a/packages/lib/src/contextual-menu/ContextualMenu.test.tsx b/packages/lib/src/contextual-menu/ContextualMenu.test.tsx index 06958825d0..59af5b6fd8 100644 --- a/packages/lib/src/contextual-menu/ContextualMenu.test.tsx +++ b/packages/lib/src/contextual-menu/ContextualMenu.test.tsx @@ -41,11 +41,11 @@ describe("Contextual menu component tests", () => { expect(actions[0]?.getAttribute("aria-pressed")).toBeTruthy(); expect(getByRole("menu")).toBeTruthy(); }); - test("Single — An item can appear as selected by default by using the attribute selected", () => { + test("Single — An item can appear as selected by default by using the attribute selectedByDefault", () => { const test = [ { label: "Tested item", - selected: true, + selectedByDefault: true, }, ]; const { getByRole } = render(); @@ -92,7 +92,7 @@ describe("Contextual menu component tests", () => { const test = [ { label: "Grouped item", - items: [{ label: "Tested item", selected: true }], + items: [{ label: "Tested item", selectedByDefault: true }], }, ]; const { getByText, getAllByRole } = render(); diff --git a/packages/lib/src/contextual-menu/ContextualMenu.tsx b/packages/lib/src/contextual-menu/ContextualMenu.tsx index d50ed86c75..13f58b4172 100644 --- a/packages/lib/src/contextual-menu/ContextualMenu.tsx +++ b/packages/lib/src/contextual-menu/ContextualMenu.tsx @@ -8,49 +8,28 @@ import scrollbarStyles from "../styles/scroll"; import { addIdToItems, isSection } from "./utils"; import SubMenu from "./SubMenu"; -const ContextualMenuContainer = styled.div<{ displayBorder: boolean }>` +const ContextualMenu = styled.div` 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}; - ${({ 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); - `} + ${scrollbarStyles} `; -export default function DxcContextualMenu({ - items, - displayBorder = true, - displayGroupLines = false, - displayControlsAfter = false, - responsiveView = false, - allowNavigation = false, -}: ContextualMenuPropsType) { +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, - displayGroupLines, - displayControlsAfter, - responsiveView, - allowNavigation, - }), - [selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView, allowNavigation] - ); + const contextValue = useMemo(() => ({ selectedItemId, setSelectedItemId }), [selectedItemId, setSelectedItemId]); useLayoutEffect(() => { if (selectedItemId !== -1 && firstUpdate) { @@ -66,7 +45,7 @@ export default function DxcContextualMenu({ }, [firstUpdate, selectedItemId]); return ( - + {itemsWithId[0] && isSection(itemsWithId[0]) ? ( (itemsWithId as SectionWithId[]).map((item, index) => ( @@ -80,6 +59,6 @@ export default function DxcContextualMenu({ )} - + ); } diff --git a/packages/lib/src/contextual-menu/GroupItem.tsx b/packages/lib/src/contextual-menu/GroupItem.tsx index d8074f3122..ba794fd617 100644 --- a/packages/lib/src/contextual-menu/GroupItem.tsx +++ b/packages/lib/src/contextual-menu/GroupItem.tsx @@ -6,64 +6,14 @@ 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, responsiveView } = useContext(ContextualMenuContext) ?? {}; + const { selectedItemId } = useContext(ContextualMenuContext) ?? {}; const groupSelected = useMemo(() => isGroupSelected(items, selectedItemId), [items, selectedItemId]); const [isOpen, setIsOpen] = useState(groupSelected && selectedItemId === -1); - 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) => ( - - ))} - - - - - -
- - ) : ( + return ( <> { {...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 aeb2ae3e95..7476819964 100644 --- a/packages/lib/src/contextual-menu/ItemAction.tsx +++ b/packages/lib/src/contextual-menu/ItemAction.tsx @@ -1,32 +1,26 @@ -import { cloneElement, forwardRef, memo, MouseEvent, useContext, useState } from "react"; +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"; -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); - ${({ 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);" : ""} - `} + padding: var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xxs) + ${({ depthLevel }) => `calc(var(--spacing-padding-xs) + ${depthLevel} * var(--spacing-padding-l))`}; display: flex; align-items: center; gap: var(--spacing-gap-m); - justify-content: ${({ responsiveView }) => (responsiveView ? "center" : "space-between")}; + justify-content: space-between; background-color: ${({ selected }) => (selected ? "var(--color-bg-primary-lighter)" : "transparent")}; height: var(--height-s); cursor: pointer; overflow: hidden; - text-decoration: none; &:hover { background-color: ${({ selected }) => @@ -69,64 +63,31 @@ 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( - forwardRef( - ({ badge, collapseIcon, depthLevel, icon, label, href, ...props }, ref) => { - const [hasTooltip, setHasTooltip] = useState(false); - const modifiedBadge = badge && cloneElement(badge, { size: "small" }); - const { displayControlsAfter, responsiveView, displayGroupLines, allowNavigation } = - useContext(ContextualMenuContext) ?? {}; +const ItemAction = memo(({ badge, collapseIcon, depthLevel, icon, label, ...props }: ItemActionProps) => { + const [hasTooltip, setHasTooltip] = useState(false); + const modifiedBadge = badge && cloneElement(badge, { size: "small" }); - return ( - - + + - - ); - } - ) -); + {label} + + + {modifiedBadge} + + + ); +}); ItemAction.displayName = "ItemAction"; diff --git a/packages/lib/src/contextual-menu/Section.tsx b/packages/lib/src/contextual-menu/Section.tsx index 7fd823b8ed..8cade2fba0 100644 --- a/packages/lib/src/contextual-menu/Section.tsx +++ b/packages/lib/src/contextual-menu/Section.tsx @@ -1,11 +1,10 @@ -import { useContext, useId } from "react"; +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"; -import ContextualMenuContext from "./ContextualMenuContext"; const SectionContainer = styled.section` display: grid; @@ -23,11 +22,11 @@ const Title = styled.h2` export default function Section({ index, length, section }: SectionProps) { const id = `section-${useId()}`; - const { responsiveView } = useContext(ContextualMenuContext) ?? {}; - return !responsiveView ? ( + + return ( {section.title && {section.title}} - + {section.items.map((item, i) => ( ))} @@ -38,11 +37,5 @@ export default function Section({ index, length, section }: SectionProps) { )} - ) : ( - - {section.items.map((item, i) => ( - - ))} - ); } diff --git a/packages/lib/src/contextual-menu/SingleItem.tsx b/packages/lib/src/contextual-menu/SingleItem.tsx index ab7152282a..5fcd304d91 100644 --- a/packages/lib/src/contextual-menu/SingleItem.tsx +++ b/packages/lib/src/contextual-menu/SingleItem.tsx @@ -3,7 +3,7 @@ import ItemAction from "./ItemAction"; import { SingleItemProps } from "./types"; import ContextualMenuContext from "./ContextualMenuContext"; -export default function SingleItem({ id, onSelect, selected = false, ...props }: SingleItemProps) { +export default function SingleItem({ id, onSelect, selectedByDefault = false, ...props }: SingleItemProps) { const { selectedItemId, setSelectedItemId } = useContext(ContextualMenuContext) ?? {}; const handleClick = () => { @@ -12,16 +12,18 @@ export default function SingleItem({ id, onSelect, selected = false, ...props }: }; useEffect(() => { - if (selectedItemId === -1 && selected) { + if (selectedItemId === -1 && selectedByDefault) { setSelectedItemId?.(id); } - }, [selectedItemId, selected, id]); + }, [selectedItemId, selectedByDefault, id]); return ( ); diff --git a/packages/lib/src/contextual-menu/SubMenu.tsx b/packages/lib/src/contextual-menu/SubMenu.tsx index 0d29a7e2c1..70c003006b 100644 --- a/packages/lib/src/contextual-menu/SubMenu.tsx +++ b/packages/lib/src/contextual-menu/SubMenu.tsx @@ -1,28 +1,17 @@ import styled from "@emotion/styled"; import { SubMenuProps } from "./types"; -import ContextualMenuContext from "./ContextualMenuContext"; -import { useContext } from "react"; -const SubMenuContainer = styled.ul<{ depthLevel: number; displayGroupLines?: boolean }>` +const SubMenuContainer = styled.ul` 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, depthLevel = 0 }: SubMenuProps) { - const { displayGroupLines } = useContext(ContextualMenuContext) ?? {}; +export default function SubMenu({ children, id }: SubMenuProps) { return ( - + {children} ); diff --git a/packages/lib/src/contextual-menu/types.ts b/packages/lib/src/contextual-menu/types.ts index 6d54167486..e9599a7f89 100644 --- a/packages/lib/src/contextual-menu/types.ts +++ b/packages/lib/src/contextual-menu/types.ts @@ -8,8 +8,7 @@ type CommonItemProps = { }; type Item = CommonItemProps & { onSelect?: () => void; - selected?: boolean; - href?: string; + selectedByDefault?: boolean; }; type GroupItem = CommonItemProps & { items: (Item | GroupItem)[]; @@ -21,31 +20,6 @@ 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; - /** - * If true the leaf nodes will be rendered as anchor elements when href is provided. - * @private - */ - allowNavigation?: boolean; }; type ItemWithId = Item & { id: number }; @@ -57,16 +31,9 @@ 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; @@ -74,21 +41,16 @@ type ItemActionProps = ButtonHTMLAttributes & { icon?: Item["icon"]; label: Item["label"]; selected: boolean; - href?: Item["href"]; }; type SectionProps = { section: SectionWithId; index: number; length: number; }; -type SubMenuProps = { children: ReactNode; id?: string; depthLevel?: number }; +type SubMenuProps = { children: ReactNode; id?: string }; type ContextualMenuContextProps = { - selectedItemId?: number; - setSelectedItemId?: Dispatch>; - displayGroupLines?: boolean; - displayControlsAfter?: boolean; - responsiveView?: boolean; - allowNavigation?: boolean; + selectedItemId: number; + setSelectedItemId: Dispatch>; }; export type { diff --git a/packages/lib/src/contextual-menu/utils.ts b/packages/lib/src/contextual-menu/utils.ts index 77db32b032..3dfe2fb6d8 100644 --- a/packages/lib/src/contextual-menu/utils.ts +++ b/packages/lib/src/contextual-menu/utils.ts @@ -34,5 +34,5 @@ export const isGroupSelected = (items: GroupItemProps["items"], selectedItemId?: items.some((item) => { if ("items" in item) return isGroupSelected(item.items, selectedItemId); else if (selectedItemId !== -1) return item.id === selectedItemId; - else return item.selected; + else return item.selectedByDefault; }); From b5819f4b9c5f24da3d8f96a601c66d914e4a19c2 Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Thu, 23 Oct 2025 12:45:14 +0200 Subject: [PATCH 10/36] Fixed problem in test --- .../GroupItem.tsx | 0 .../ItemAction.tsx | 0 .../MenuItem.tsx | 0 .../NavigationTree.accessibility.test.tsx | 0 .../NavigationTree.stories.tsx | 0 .../NavigationTree.test.tsx | 0 .../NavigationTree.tsx | 0 .../NavigationTreeContext.tsx | 0 .../{tree-navigation => navigation-tree}/Section.tsx | 0 .../SingleItem.tsx | 0 .../{tree-navigation => navigation-tree}/SubMenu.tsx | 0 .../{tree-navigation => navigation-tree}/types.ts | 0 .../{tree-navigation => navigation-tree}/utils.ts | 0 packages/lib/src/sidenav/Sidenav.test.tsx | 12 +++++++++--- packages/lib/src/sidenav/Sidenav.tsx | 2 +- 15 files changed, 10 insertions(+), 4 deletions(-) rename packages/lib/src/{tree-navigation => navigation-tree}/GroupItem.tsx (100%) rename packages/lib/src/{tree-navigation => navigation-tree}/ItemAction.tsx (100%) rename packages/lib/src/{tree-navigation => navigation-tree}/MenuItem.tsx (100%) rename packages/lib/src/{tree-navigation => navigation-tree}/NavigationTree.accessibility.test.tsx (100%) rename packages/lib/src/{tree-navigation => navigation-tree}/NavigationTree.stories.tsx (100%) rename packages/lib/src/{tree-navigation => navigation-tree}/NavigationTree.test.tsx (100%) rename packages/lib/src/{tree-navigation => navigation-tree}/NavigationTree.tsx (100%) rename packages/lib/src/{tree-navigation => navigation-tree}/NavigationTreeContext.tsx (100%) rename packages/lib/src/{tree-navigation => navigation-tree}/Section.tsx (100%) rename packages/lib/src/{tree-navigation => navigation-tree}/SingleItem.tsx (100%) rename packages/lib/src/{tree-navigation => navigation-tree}/SubMenu.tsx (100%) rename packages/lib/src/{tree-navigation => navigation-tree}/types.ts (100%) rename packages/lib/src/{tree-navigation => navigation-tree}/utils.ts (100%) diff --git a/packages/lib/src/tree-navigation/GroupItem.tsx b/packages/lib/src/navigation-tree/GroupItem.tsx similarity index 100% rename from packages/lib/src/tree-navigation/GroupItem.tsx rename to packages/lib/src/navigation-tree/GroupItem.tsx diff --git a/packages/lib/src/tree-navigation/ItemAction.tsx b/packages/lib/src/navigation-tree/ItemAction.tsx similarity index 100% rename from packages/lib/src/tree-navigation/ItemAction.tsx rename to packages/lib/src/navigation-tree/ItemAction.tsx diff --git a/packages/lib/src/tree-navigation/MenuItem.tsx b/packages/lib/src/navigation-tree/MenuItem.tsx similarity index 100% rename from packages/lib/src/tree-navigation/MenuItem.tsx rename to packages/lib/src/navigation-tree/MenuItem.tsx diff --git a/packages/lib/src/tree-navigation/NavigationTree.accessibility.test.tsx b/packages/lib/src/navigation-tree/NavigationTree.accessibility.test.tsx similarity index 100% rename from packages/lib/src/tree-navigation/NavigationTree.accessibility.test.tsx rename to packages/lib/src/navigation-tree/NavigationTree.accessibility.test.tsx diff --git a/packages/lib/src/tree-navigation/NavigationTree.stories.tsx b/packages/lib/src/navigation-tree/NavigationTree.stories.tsx similarity index 100% rename from packages/lib/src/tree-navigation/NavigationTree.stories.tsx rename to packages/lib/src/navigation-tree/NavigationTree.stories.tsx diff --git a/packages/lib/src/tree-navigation/NavigationTree.test.tsx b/packages/lib/src/navigation-tree/NavigationTree.test.tsx similarity index 100% rename from packages/lib/src/tree-navigation/NavigationTree.test.tsx rename to packages/lib/src/navigation-tree/NavigationTree.test.tsx diff --git a/packages/lib/src/tree-navigation/NavigationTree.tsx b/packages/lib/src/navigation-tree/NavigationTree.tsx similarity index 100% rename from packages/lib/src/tree-navigation/NavigationTree.tsx rename to packages/lib/src/navigation-tree/NavigationTree.tsx diff --git a/packages/lib/src/tree-navigation/NavigationTreeContext.tsx b/packages/lib/src/navigation-tree/NavigationTreeContext.tsx similarity index 100% rename from packages/lib/src/tree-navigation/NavigationTreeContext.tsx rename to packages/lib/src/navigation-tree/NavigationTreeContext.tsx diff --git a/packages/lib/src/tree-navigation/Section.tsx b/packages/lib/src/navigation-tree/Section.tsx similarity index 100% rename from packages/lib/src/tree-navigation/Section.tsx rename to packages/lib/src/navigation-tree/Section.tsx diff --git a/packages/lib/src/tree-navigation/SingleItem.tsx b/packages/lib/src/navigation-tree/SingleItem.tsx similarity index 100% rename from packages/lib/src/tree-navigation/SingleItem.tsx rename to packages/lib/src/navigation-tree/SingleItem.tsx diff --git a/packages/lib/src/tree-navigation/SubMenu.tsx b/packages/lib/src/navigation-tree/SubMenu.tsx similarity index 100% rename from packages/lib/src/tree-navigation/SubMenu.tsx rename to packages/lib/src/navigation-tree/SubMenu.tsx diff --git a/packages/lib/src/tree-navigation/types.ts b/packages/lib/src/navigation-tree/types.ts similarity index 100% rename from packages/lib/src/tree-navigation/types.ts rename to packages/lib/src/navigation-tree/types.ts diff --git a/packages/lib/src/tree-navigation/utils.ts b/packages/lib/src/navigation-tree/utils.ts similarity index 100% rename from packages/lib/src/tree-navigation/utils.ts rename to packages/lib/src/navigation-tree/utils.ts diff --git a/packages/lib/src/sidenav/Sidenav.test.tsx b/packages/lib/src/sidenav/Sidenav.test.tsx index c0fab0886b..22a7590a92 100644 --- a/packages/lib/src/sidenav/Sidenav.test.tsx +++ b/packages/lib/src/sidenav/Sidenav.test.tsx @@ -1,9 +1,15 @@ import "@testing-library/jest-dom"; import { render, fireEvent } from "@testing-library/react"; import DxcSidenav from "./Sidenav"; -import DxcContextualMenu from "../contextual-menu/ContextualMenu"; +import DxcNavigationTree from "../navigation-tree/NavigationTree"; -jest.mock("../contextual-menu/ContextualMenu", () => jest.fn(() =>
)); +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +jest.mock("../navigation-tree/NavigationTree", () => jest.fn(() =>
)); describe("DxcSidenav component", () => { beforeEach(() => { @@ -47,7 +53,7 @@ describe("DxcSidenav component", () => { const items = [{ label: "Dashboard" }, { label: "Settings" }]; const { getByTestId } = render(); expect(getByTestId("mock-menu")).toBeTruthy(); - expect(DxcContextualMenu).toHaveBeenCalledWith( + expect(DxcNavigationTree).toHaveBeenCalledWith( expect.objectContaining({ items, displayGroupLines: false, diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx index fe70f0f4ec..b5a47af9af 100644 --- a/packages/lib/src/sidenav/Sidenav.tsx +++ b/packages/lib/src/sidenav/Sidenav.tsx @@ -7,7 +7,7 @@ import DxcButton from "../button/Button"; import DxcImage from "../image/Image"; import { ReactElement, useState } from "react"; import DxcTextInput from "../text-input/TextInput"; -import DxcNavigationTree from "../tree-navigation/NavigationTree"; +import DxcNavigationTree from "../navigation-tree/NavigationTree"; const SidenavContainer = styled.div<{ expanded: boolean }>` box-sizing: border-box; From 9e49a3864eb062f613fe09d2f9a291626599f3d5 Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Thu, 23 Oct 2025 12:50:52 +0200 Subject: [PATCH 11/36] Fixed problem in test --- packages/lib/src/sidenav/Sidenav.accessibility.test.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx index d7c31a7830..7a05186670 100644 --- a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx +++ b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx @@ -2,6 +2,13 @@ import { render } from "@testing-library/react"; import { axe } from "../../test/accessibility/axe-helper"; import DxcSidenav from "./Sidenav"; import DxcBadge from "../badge/Badge"; +import { vi } from "vitest"; + +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); describe("Sidenav component accessibility tests", () => { it("Should not have basic accessibility issues", async () => { From d5153b851367368aa7c04921f600c02dd4fddf47 Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Thu, 23 Oct 2025 13:41:46 +0200 Subject: [PATCH 12/36] Fixed circular dependency --- packages/lib/src/navigation-tree/Section.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lib/src/navigation-tree/Section.tsx b/packages/lib/src/navigation-tree/Section.tsx index 2b25e7d4c2..16ee0e8eaf 100644 --- a/packages/lib/src/navigation-tree/Section.tsx +++ b/packages/lib/src/navigation-tree/Section.tsx @@ -1,11 +1,11 @@ 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 NavigationTreeContext from "./NavigationTreeContext"; +import DxcInset from "../inset/Inset"; +import DxcDivider from "../divider/Divider"; const SectionContainer = styled.section` display: grid; From 5bacd9cad7cdba8c8603bb4a4374ec1dc3afac9a Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Thu, 23 Oct 2025 14:02:13 +0200 Subject: [PATCH 13/36] Fixed accessibility issues --- packages/lib/src/sidenav/Sidenav.stories.tsx | 12 ++++++++++++ .../rules/specific/sidenav/disabledRules.ts | 10 ++++++++++ 2 files changed, 22 insertions(+) create mode 100644 packages/lib/test/accessibility/rules/specific/sidenav/disabledRules.ts diff --git a/packages/lib/src/sidenav/Sidenav.stories.tsx b/packages/lib/src/sidenav/Sidenav.stories.tsx index 82feda81f0..d32319f1aa 100644 --- a/packages/lib/src/sidenav/Sidenav.stories.tsx +++ b/packages/lib/src/sidenav/Sidenav.stories.tsx @@ -8,10 +8,22 @@ import DxcTypography from "../typography/Typography"; import DxcButton from "../button/Button"; import DxcAvatar from "../avatar/Avatar"; import { userEvent, within } from "storybook/internal/test"; +import disabledRules from "../../test/accessibility/rules/specific/sidenav/disabledRules"; +import preview from "../../.storybook/preview"; export default { title: "Sidenav", component: DxcSidenav, + parameters: { + a11y: { + config: { + rules: [ + ...(preview?.parameters?.a11y?.config?.rules || []), + ...disabledRules.map((ruleId) => ({ id: ruleId, reviewOnFail: true })), + ], + }, + }, + }, } satisfies Meta; const DetailedAvatar = () => { diff --git a/packages/lib/test/accessibility/rules/specific/sidenav/disabledRules.ts b/packages/lib/test/accessibility/rules/specific/sidenav/disabledRules.ts new file mode 100644 index 0000000000..8e7726c7fb --- /dev/null +++ b/packages/lib/test/accessibility/rules/specific/sidenav/disabledRules.ts @@ -0,0 +1,10 @@ +/** + * Array of accessibility rule IDs to be disabled in both Jest and Storybook for the sidenav component. + * + */ +const disabledRules = [ + // Disable landmark unique rule to allow multiple sidenavs in the same page without having to set different ids + "landmark-unique", +]; + +export default disabledRules; From adafdbddc4e1d0b293c7f54eeaa46af1a48d4044 Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Thu, 23 Oct 2025 14:48:15 +0200 Subject: [PATCH 14/36] Fixed accessibility problems and cleared code --- packages/lib/src/navigation-tree/ItemAction.tsx | 10 +++++----- packages/lib/src/navigation-tree/NavigationTree.tsx | 4 +--- packages/lib/src/navigation-tree/types.ts | 5 ----- packages/lib/src/sidenav/Sidenav.tsx | 1 - 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/lib/src/navigation-tree/ItemAction.tsx b/packages/lib/src/navigation-tree/ItemAction.tsx index c0e0209616..a50fce49b7 100644 --- a/packages/lib/src/navigation-tree/ItemAction.tsx +++ b/packages/lib/src/navigation-tree/ItemAction.tsx @@ -83,20 +83,20 @@ const ItemAction = memo( ({ badge, collapseIcon, depthLevel, icon, label, href, ...props }, ref) => { const [hasTooltip, setHasTooltip] = useState(false); const modifiedBadge = badge && cloneElement(badge, { size: "small" }); - const { displayControlsAfter, responsiveView, displayGroupLines, allowNavigation } = - useContext(NavigationTreeContext) ?? {}; + const { displayControlsAfter, responsiveView, displayGroupLines } = useContext(NavigationTreeContext) ?? {}; return ( + + ); + if (renderItem) { + return <>{renderItem({ children: content })}; } - ) + return content; + } ); ItemAction.displayName = "ItemAction"; diff --git a/packages/lib/src/navigation-tree/types.ts b/packages/lib/src/navigation-tree/types.ts index a6effb6bb9..78822a8b49 100644 --- a/packages/lib/src/navigation-tree/types.ts +++ b/packages/lib/src/navigation-tree/types.ts @@ -6,10 +6,19 @@ type CommonItemProps = { icon?: string | SVG; label: string; }; + +type RenderItemProps = { + /** Whether the item is selected (for styling) */ + // selected: boolean; + /** The default rendered content (label, icon, badge, etc.) */ + children: React.ReactNode; +}; + type Item = CommonItemProps & { onSelect?: () => void; selected?: boolean; href?: string; + renderItem?: (props: RenderItemProps) => React.ReactNode; }; type GroupItem = CommonItemProps & { items: (Item | GroupItem)[]; @@ -64,8 +73,9 @@ type ItemActionProps = ButtonHTMLAttributes & { depthLevel: number; icon?: Item["icon"]; label: Item["label"]; - selected: boolean; + selected: Item["selected"]; href?: Item["href"]; + renderItem?: Item["renderItem"]; }; type SectionProps = { section: SectionWithId; diff --git a/packages/lib/src/utils/useGroupItem.ts b/packages/lib/src/utils/useGroupItem.ts new file mode 100644 index 0000000000..6880bf844e --- /dev/null +++ b/packages/lib/src/utils/useGroupItem.ts @@ -0,0 +1,37 @@ +import { useId, useMemo, useState } from "react"; +import { ContextualMenuContextProps, GroupItemProps as ContextItemProps } from "../contextual-menu/types"; +import { NavigationTreeContextProps, GroupItemProps as NavGroupItemProps } from "../navigation-tree/types"; + +const isGroupSelected = ( + items: ContextItemProps["items"] | NavGroupItemProps["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.selected; + }); + +const hasResponsiveView = ( + ctx: ContextualMenuContextProps | NavigationTreeContextProps +): ctx is NavigationTreeContextProps => "responsiveView" in ctx; + +export const useGroupItem = ( + items: ContextItemProps["items"] | NavGroupItemProps["items"], + context: ContextualMenuContextProps | NavigationTreeContextProps +) => { + const groupMenuId = `group-menu-${useId()}`; + const { selectedItemId } = context ?? {}; + const groupSelected = useMemo(() => isGroupSelected(items, selectedItemId), [items, selectedItemId]); + const [isOpen, setIsOpen] = useState(groupSelected && selectedItemId === -1); + + const toggleOpen = () => setIsOpen((prev) => !prev); + + return { + groupMenuId, + groupSelected, + isOpen, + toggleOpen, + responsiveView: hasResponsiveView(context) ? context.responsiveView : undefined, + }; +}; From 5a4811f942aa73dbe2a8ee88a3237fab3dffb71e Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Fri, 24 Oct 2025 13:56:43 +0200 Subject: [PATCH 18/36] Simplified all the common logic for ContextualMenu and NavigationMenu --- .../lib/src/base-menu/BaseMenuContext.tsx | 4 + packages/lib/src/base-menu/GroupItem.tsx | 85 ++++++++++++++ .../ItemAction.tsx | 41 ++++--- .../MenuItem.tsx | 0 .../Section.tsx | 4 +- .../SingleItem.tsx | 4 +- .../SubMenu.tsx | 4 +- packages/lib/src/base-menu/types.ts | 104 +++++++++++++++++ packages/lib/src/base-menu/useGroupItem.ts | 26 +++++ packages/lib/src/base-menu/useItemAction.ts | 25 ++++ .../{contextual-menu => base-menu}/utils.ts | 0 .../ContextualMenu.stories.tsx | 4 +- .../src/contextual-menu/ContextualMenu.tsx | 10 +- .../lib/src/contextual-menu/GroupItem.tsx | 37 ------ .../lib/src/contextual-menu/ItemAction.tsx | 94 --------------- packages/lib/src/contextual-menu/Section.tsx | 41 ------- .../lib/src/contextual-menu/SingleItem.tsx | 28 ----- packages/lib/src/contextual-menu/SubMenu.tsx | 18 --- packages/lib/src/contextual-menu/types.ts | 72 +++--------- .../lib/src/navigation-tree/GroupItem.tsx | 10 +- packages/lib/src/navigation-tree/MenuItem.tsx | 21 ---- .../NavigationTree.stories.tsx | 4 +- .../src/navigation-tree/NavigationTree.tsx | 10 +- packages/lib/src/navigation-tree/types.ts | 109 +++--------------- packages/lib/src/navigation-tree/utils.ts | 38 ------ packages/lib/src/sidenav/Sidenav.stories.tsx | 7 +- packages/lib/src/utils/useGroupItem.ts | 37 ------ 27 files changed, 328 insertions(+), 509 deletions(-) create mode 100644 packages/lib/src/base-menu/BaseMenuContext.tsx create mode 100644 packages/lib/src/base-menu/GroupItem.tsx rename packages/lib/src/{navigation-tree => base-menu}/ItemAction.tsx (78%) rename packages/lib/src/{contextual-menu => base-menu}/MenuItem.tsx (100%) rename packages/lib/src/{navigation-tree => base-menu}/Section.tsx (91%) rename packages/lib/src/{navigation-tree => base-menu}/SingleItem.tsx (82%) rename packages/lib/src/{navigation-tree => base-menu}/SubMenu.tsx (86%) create mode 100644 packages/lib/src/base-menu/types.ts create mode 100644 packages/lib/src/base-menu/useGroupItem.ts create mode 100644 packages/lib/src/base-menu/useItemAction.ts rename packages/lib/src/{contextual-menu => base-menu}/utils.ts (100%) delete mode 100644 packages/lib/src/contextual-menu/GroupItem.tsx delete mode 100644 packages/lib/src/contextual-menu/ItemAction.tsx delete mode 100644 packages/lib/src/contextual-menu/Section.tsx delete mode 100644 packages/lib/src/contextual-menu/SingleItem.tsx delete mode 100644 packages/lib/src/contextual-menu/SubMenu.tsx delete mode 100644 packages/lib/src/navigation-tree/MenuItem.tsx delete mode 100644 packages/lib/src/navigation-tree/utils.ts delete mode 100644 packages/lib/src/utils/useGroupItem.ts diff --git a/packages/lib/src/base-menu/BaseMenuContext.tsx b/packages/lib/src/base-menu/BaseMenuContext.tsx new file mode 100644 index 0000000000..cbeb62d6ff --- /dev/null +++ b/packages/lib/src/base-menu/BaseMenuContext.tsx @@ -0,0 +1,4 @@ +import { createContext } from "react"; +import { BaseMenuContextProps } from "./types"; + +export default createContext(null); diff --git a/packages/lib/src/base-menu/GroupItem.tsx b/packages/lib/src/base-menu/GroupItem.tsx new file mode 100644 index 0000000000..63f4bfbb84 --- /dev/null +++ b/packages/lib/src/base-menu/GroupItem.tsx @@ -0,0 +1,85 @@ +import { useContext, 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 * as Popover from "@radix-ui/react-popover"; +import { useGroupItem } from "./useGroupItem"; +import BaseMenuContext from "./BaseMenuContext"; + +const GroupItem = ({ items, ...props }: GroupItemProps) => { + const groupMenuId = `group-menu-${useId()}`; + + const NavigationTreeId = `sidenav-${useId()}`; + const contextValue = useContext(BaseMenuContext) ?? {}; + const { groupSelected, isOpen, toggleOpen, responsiveView } = useGroupItem(items, contextValue); + + return responsiveView ? ( + <> + + + : } + onClick={() => toggleOpen()} + selected={groupSelected && !isOpen} + {...props} + /> + + + + { + event.preventDefault(); + }} + onOpenAutoFocus={(event) => { + event.preventDefault(); + }} + align="start" + side="right" + style={{ zIndex: "var(--z-contextualmenu)" }} + > + + {items.map((item, index) => ( + + ))} + + + + + +
+ + ) : ( + <> + : } + onClick={() => toggleOpen()} + selected={groupSelected && !isOpen} + {...props} + /> + {isOpen && ( + + {items.map((item, index) => ( + + ))} + + )} + + ); +}; + +export default GroupItem; diff --git a/packages/lib/src/navigation-tree/ItemAction.tsx b/packages/lib/src/base-menu/ItemAction.tsx similarity index 78% rename from packages/lib/src/navigation-tree/ItemAction.tsx rename to packages/lib/src/base-menu/ItemAction.tsx index 7e3faf1d35..d675f006cf 100644 --- a/packages/lib/src/navigation-tree/ItemAction.tsx +++ b/packages/lib/src/base-menu/ItemAction.tsx @@ -1,9 +1,9 @@ -import { cloneElement, memo, MouseEvent, useContext, useState } from "react"; +import { forwardRef, memo } from "react"; import styled from "@emotion/styled"; import { ItemActionProps } from "./types"; import DxcIcon from "../icon/Icon"; import { TooltipWrapper } from "../tooltip/Tooltip"; -import NavigationTreeContext from "./NavigationTreeContext"; +import { useItemAction } from "./useItemAction"; const Action = styled.button<{ depthLevel: ItemActionProps["depthLevel"]; @@ -79,22 +79,31 @@ const Control = styled.span` `; const ItemAction = memo( - ({ badge, collapseIcon, depthLevel, icon, label, href, renderItem, ...props }: ItemActionProps) => { - const [hasTooltip, setHasTooltip] = useState(false); - const modifiedBadge = badge && cloneElement(badge, { size: "small" }); - const { displayControlsAfter, responsiveView, displayGroupLines } = useContext(NavigationTreeContext) ?? {}; + forwardRef((props, ref) => { + const { + hasTooltip, + modifiedBadge, + displayControlsAfter, + responsiveView, + displayGroupLines, + handleTextMouseEnter, + getWrapper, + } = useItemAction(props); + const { depthLevel, selected, href, label, icon, collapseIcon, "aria-pressed": ariaPressed, ...rest } = props; - const content = ( + return getWrapper( {!responsiveView && ( - ) => { - const text = event.currentTarget; - setHasTooltip(text.scrollWidth > text.clientWidth); - }} - > + {label} )} @@ -122,11 +125,7 @@ const ItemAction = memo( ); - if (renderItem) { - return <>{renderItem({ children: content })}; - } - return content; - } + }) ); ItemAction.displayName = "ItemAction"; diff --git a/packages/lib/src/contextual-menu/MenuItem.tsx b/packages/lib/src/base-menu/MenuItem.tsx similarity index 100% rename from packages/lib/src/contextual-menu/MenuItem.tsx rename to packages/lib/src/base-menu/MenuItem.tsx diff --git a/packages/lib/src/navigation-tree/Section.tsx b/packages/lib/src/base-menu/Section.tsx similarity index 91% rename from packages/lib/src/navigation-tree/Section.tsx rename to packages/lib/src/base-menu/Section.tsx index 16ee0e8eaf..68708811db 100644 --- a/packages/lib/src/navigation-tree/Section.tsx +++ b/packages/lib/src/base-menu/Section.tsx @@ -3,7 +3,7 @@ import styled from "@emotion/styled"; import SubMenu from "./SubMenu"; import MenuItem from "./MenuItem"; import { SectionProps } from "./types"; -import NavigationTreeContext from "./NavigationTreeContext"; +import BaseMenuContext from "./BaseMenuContext"; import DxcInset from "../inset/Inset"; import DxcDivider from "../divider/Divider"; @@ -23,7 +23,7 @@ const Title = styled.h2` export default function Section({ index, length, section }: SectionProps) { const id = `section-${useId()}`; - const { responsiveView } = useContext(NavigationTreeContext) ?? {}; + const { responsiveView } = useContext(BaseMenuContext) ?? {}; return !responsiveView ? ( {section.title && {section.title}} diff --git a/packages/lib/src/navigation-tree/SingleItem.tsx b/packages/lib/src/base-menu/SingleItem.tsx similarity index 82% rename from packages/lib/src/navigation-tree/SingleItem.tsx rename to packages/lib/src/base-menu/SingleItem.tsx index 07e02373e8..f6271af764 100644 --- a/packages/lib/src/navigation-tree/SingleItem.tsx +++ b/packages/lib/src/base-menu/SingleItem.tsx @@ -1,10 +1,10 @@ import { useContext, useEffect } from "react"; import ItemAction from "./ItemAction"; import { SingleItemProps } from "./types"; -import NavigationTreeContext from "./NavigationTreeContext"; +import BaseMenuContext from "./BaseMenuContext"; export default function SingleItem({ id, onSelect, selected = false, ...props }: SingleItemProps) { - const { selectedItemId, setSelectedItemId } = useContext(NavigationTreeContext) ?? {}; + const { selectedItemId, setSelectedItemId } = useContext(BaseMenuContext) ?? {}; const handleClick = () => { setSelectedItemId?.(id); diff --git a/packages/lib/src/navigation-tree/SubMenu.tsx b/packages/lib/src/base-menu/SubMenu.tsx similarity index 86% rename from packages/lib/src/navigation-tree/SubMenu.tsx rename to packages/lib/src/base-menu/SubMenu.tsx index 40414e071f..a0414a3d22 100644 --- a/packages/lib/src/navigation-tree/SubMenu.tsx +++ b/packages/lib/src/base-menu/SubMenu.tsx @@ -1,6 +1,6 @@ import styled from "@emotion/styled"; import { SubMenuProps } from "./types"; -import NavigationTreeContext from "./NavigationTreeContext"; +import BaseMenuContext from "./BaseMenuContext"; import { useContext } from "react"; const SubMenuContainer = styled.ul<{ depthLevel: number; displayGroupLines?: boolean }>` @@ -20,7 +20,7 @@ const SubMenuContainer = styled.ul<{ depthLevel: number; displayGroupLines?: boo `; export default function SubMenu({ children, id, depthLevel = 0 }: SubMenuProps) { - const { displayGroupLines } = useContext(NavigationTreeContext) ?? {}; + const { displayGroupLines } = useContext(BaseMenuContext) ?? {}; return ( {children} diff --git a/packages/lib/src/base-menu/types.ts b/packages/lib/src/base-menu/types.ts new file mode 100644 index 0000000000..1ef9fc25bb --- /dev/null +++ b/packages/lib/src/base-menu/types.ts @@ -0,0 +1,104 @@ +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; + selected?: boolean; + href?: string; + renderItem?: (props: { children: ReactNode }) => ReactNode; +}; + +type GroupItem = CommonItemProps & { + items: (Item | GroupItem)[]; +}; +type Section = { items: (Item | GroupItem)[]; title?: string }; +type Props = { + /** + * Array of items to be displayed in the menu. + * Each item can be a single/simple item, a group item or a section. + */ + items: (Item | GroupItem)[] | Section[]; + /** + * If true the menu will be displayed with a border. + */ + displayBorder?: boolean; + /** + * If true the menu will have lines marking the groups. + */ + displayGroupLines?: boolean; + /** + * If true the menu will have controls at the end. + */ + displayControlsAfter?: boolean; + /** + * If true the menu will be icons only and display a popover on click. + */ + responsiveView?: 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: Item["selected"]; + href?: Item["href"]; + renderItem?: Item["renderItem"]; +}; +type SectionProps = { + section: SectionWithId; + index: number; + length: number; +}; +type SubMenuProps = { children: ReactNode; id?: string; depthLevel?: number }; +type BaseMenuContextProps = { + selectedItemId?: number; + setSelectedItemId?: Dispatch>; + displayGroupLines?: boolean; + displayControlsAfter?: boolean; + responsiveView?: boolean; +}; + +export type { + BaseMenuContextProps, + GroupItem, + GroupItemProps, + GroupItemWithId, + Item, + ItemActionProps, + ItemWithId, + SubMenuProps, + MenuItemProps, + Section, + SectionWithId, + SectionProps, + SingleItemProps, +}; + +export default Props; diff --git a/packages/lib/src/base-menu/useGroupItem.ts b/packages/lib/src/base-menu/useGroupItem.ts new file mode 100644 index 0000000000..c8997ec3f4 --- /dev/null +++ b/packages/lib/src/base-menu/useGroupItem.ts @@ -0,0 +1,26 @@ +import { useId, useMemo, useState } from "react"; +import { BaseMenuContextProps, GroupItemProps } from "./types"; + +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.selected; + }); + +export const useGroupItem = (items: GroupItemProps["items"], context: BaseMenuContextProps) => { + const groupMenuId = `group-menu-${useId()}`; + const { selectedItemId } = context ?? {}; + const groupSelected = useMemo(() => isGroupSelected(items, selectedItemId), [items, selectedItemId]); + const [isOpen, setIsOpen] = useState(groupSelected && selectedItemId === -1); + + const toggleOpen = () => setIsOpen((prev) => !prev); + + return { + groupMenuId, + groupSelected, + isOpen, + toggleOpen, + responsiveView: context.responsiveView, + }; +}; diff --git a/packages/lib/src/base-menu/useItemAction.ts b/packages/lib/src/base-menu/useItemAction.ts new file mode 100644 index 0000000000..dda97fa109 --- /dev/null +++ b/packages/lib/src/base-menu/useItemAction.ts @@ -0,0 +1,25 @@ +import { useState, useContext, cloneElement, ReactNode } from "react"; +import BaseMenuContext from "./BaseMenuContext"; +import { ItemActionProps } from "./types"; + +export function useItemAction({ badge, renderItem }: ItemActionProps) { + const [hasTooltip, setHasTooltip] = useState(false); + const modifiedBadge = badge && cloneElement(badge, { size: "small" }); + const { displayControlsAfter, responsiveView, displayGroupLines } = useContext(BaseMenuContext) ?? {}; + + const handleTextMouseEnter = (event: React.MouseEvent) => { + const text = event.currentTarget; + setHasTooltip(text.scrollWidth > text.clientWidth); + }; + const getWrapper = (children: ReactNode) => (renderItem ? renderItem({ children }) : children); + + return { + hasTooltip, + modifiedBadge, + displayControlsAfter, + responsiveView, + displayGroupLines, + handleTextMouseEnter, + getWrapper, + }; +} diff --git a/packages/lib/src/contextual-menu/utils.ts b/packages/lib/src/base-menu/utils.ts similarity index 100% rename from packages/lib/src/contextual-menu/utils.ts rename to packages/lib/src/base-menu/utils.ts diff --git a/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx b/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx index a83b217fee..54ec2c90e1 100644 --- a/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx +++ b/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx @@ -3,8 +3,8 @@ import Title from "../../.storybook/components/Title"; import DxcBadge from "../badge/Badge"; import DxcContainer from "../container/Container"; import DxcContextualMenu from "./ContextualMenu"; -import SingleItem from "./SingleItem"; -import ContextualMenuContext from "./ContextualMenuContext"; +import SingleItem from "../base-menu/SingleItem"; +import ContextualMenuContext from "../base-menu/BaseMenuContext"; import { Meta, StoryObj } from "@storybook/react-vite"; import { userEvent, within } from "storybook/internal/test"; diff --git a/packages/lib/src/contextual-menu/ContextualMenu.tsx b/packages/lib/src/contextual-menu/ContextualMenu.tsx index 13f58b4172..42405bea46 100644 --- a/packages/lib/src/contextual-menu/ContextualMenu.tsx +++ b/packages/lib/src/contextual-menu/ContextualMenu.tsx @@ -1,12 +1,12 @@ import { useLayoutEffect, useMemo, useRef, useState } from "react"; import styled from "@emotion/styled"; -import MenuItem from "./MenuItem"; +import MenuItem from "../base-menu/MenuItem"; +import Section from "../base-menu/Section"; +import SubMenu from "../base-menu/SubMenu"; +import ContextualMenuContext from "../base-menu/BaseMenuContext"; 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"; +import { addIdToItems, isSection } from "../base-menu/utils"; const ContextualMenu = styled.div` box-sizing: border-box; diff --git a/packages/lib/src/contextual-menu/GroupItem.tsx b/packages/lib/src/contextual-menu/GroupItem.tsx deleted file mode 100644 index 00eda21d5a..0000000000 --- a/packages/lib/src/contextual-menu/GroupItem.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useContext, 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 { useGroupItem } from "../utils/useGroupItem"; - -const GroupItem = ({ items, ...props }: GroupItemProps) => { - const groupMenuId = `group-menu-${useId()}`; - - const contextValue = useContext(ContextualMenuContext) ?? {}; - const { groupSelected, isOpen, toggleOpen } = useGroupItem(items, contextValue); - return ( - <> - : } - onClick={() => toggleOpen()} - selected={groupSelected && !isOpen} - {...props} - /> - {isOpen && ( - - {items.map((item, index) => ( - - ))} - - )} - - ); -}; - -export default GroupItem; diff --git a/packages/lib/src/contextual-menu/ItemAction.tsx b/packages/lib/src/contextual-menu/ItemAction.tsx deleted file mode 100644 index 7476819964..0000000000 --- a/packages/lib/src/contextual-menu/ItemAction.tsx +++ /dev/null @@ -1,94 +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) - ${({ depthLevel }) => `calc(var(--spacing-padding-xs) + ${depthLevel} * var(--spacing-padding-l))`}; - display: flex; - align-items: center; - gap: var(--spacing-gap-m); - justify-content: space-between; - background-color: ${({ selected }) => (selected ? "var(--color-bg-primary-lighter)" : "transparent")}; - height: var(--height-s); - cursor: pointer; - overflow: hidden; - - &: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; -`; - -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 ItemAction = memo(({ badge, collapseIcon, depthLevel, icon, label, ...props }: ItemActionProps) => { - const [hasTooltip, setHasTooltip] = useState(false); - const modifiedBadge = badge && cloneElement(badge, { size: "small" }); - - return ( - - - - {modifiedBadge} - - - ); -}); - -ItemAction.displayName = "ItemAction"; - -export default ItemAction; diff --git a/packages/lib/src/contextual-menu/Section.tsx b/packages/lib/src/contextual-menu/Section.tsx deleted file mode 100644 index 8cade2fba0..0000000000 --- a/packages/lib/src/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/contextual-menu/SingleItem.tsx b/packages/lib/src/contextual-menu/SingleItem.tsx deleted file mode 100644 index ab7152282a..0000000000 --- a/packages/lib/src/contextual-menu/SingleItem.tsx +++ /dev/null @@ -1,28 +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, selected = false, ...props }: SingleItemProps) { - const { selectedItemId, setSelectedItemId } = useContext(ContextualMenuContext) ?? {}; - - const handleClick = () => { - setSelectedItemId?.(id); - onSelect?.(); - }; - - useEffect(() => { - if (selectedItemId === -1 && selected) { - setSelectedItemId?.(id); - } - }, [selectedItemId, selected, id]); - - return ( - - ); -} diff --git a/packages/lib/src/contextual-menu/SubMenu.tsx b/packages/lib/src/contextual-menu/SubMenu.tsx deleted file mode 100644 index 70c003006b..0000000000 --- a/packages/lib/src/contextual-menu/SubMenu.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import styled from "@emotion/styled"; -import { SubMenuProps } from "./types"; - -const SubMenuContainer = styled.ul` - margin: 0; - padding: 0; - display: grid; - gap: var(--spacing-gap-xs); - list-style: none; -`; - -export default function SubMenu({ children, id }: SubMenuProps) { - return ( - - {children} - - ); -} diff --git a/packages/lib/src/contextual-menu/types.ts b/packages/lib/src/contextual-menu/types.ts index 188f39862d..55d82c46af 100644 --- a/packages/lib/src/contextual-menu/types.ts +++ b/packages/lib/src/contextual-menu/types.ts @@ -1,57 +1,23 @@ -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; - selected?: 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[]; -}; - -type ItemWithId = Item & { id: number }; -type GroupItemWithId = { - badge?: ReactElement; - icon: string | SVG; - items: (ItemWithId | GroupItemWithId)[]; - label: string; -}; -type SectionWithId = { items: (ItemWithId | GroupItemWithId)[]; title?: string }; +import BaseProps, { + BaseMenuContextProps, + GroupItem, + GroupItemProps, + GroupItemWithId, + Item as BaseItem, + ItemActionProps as BaseItemActionProps, + ItemWithId, + SubMenuProps, + MenuItemProps, + Section, + SectionWithId, + SectionProps, + SingleItemProps, +} from "../base-menu/types"; -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>; -}; +type Item = Omit; +type Props = Omit; +type ItemActionProps = Omit; +type ContextualMenuContextProps = Omit; export type { ContextualMenuContextProps, diff --git a/packages/lib/src/navigation-tree/GroupItem.tsx b/packages/lib/src/navigation-tree/GroupItem.tsx index 107484d441..f8c87e19cf 100644 --- a/packages/lib/src/navigation-tree/GroupItem.tsx +++ b/packages/lib/src/navigation-tree/GroupItem.tsx @@ -1,12 +1,12 @@ import { useContext, 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 NavigationTreeContext from "./NavigationTreeContext"; import * as Popover from "@radix-ui/react-popover"; -import { useGroupItem } from "../utils/useGroupItem"; +import { useGroupItem } from "../base-menu/useGroupItem"; +import NavigationTreeContext from "./NavigationTreeContext"; +import ItemAction from "../base-menu/ItemAction"; +import SubMenu from "../base-menu/SubMenu"; +import MenuItem from "../base-menu/MenuItem"; const GroupItem = ({ items, ...props }: GroupItemProps) => { const groupMenuId = `group-menu-${useId()}`; diff --git a/packages/lib/src/navigation-tree/MenuItem.tsx b/packages/lib/src/navigation-tree/MenuItem.tsx deleted file mode 100644 index 65aadf7f17..0000000000 --- a/packages/lib/src/navigation-tree/MenuItem.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import styled from "@emotion/styled"; -import GroupItem from "./GroupItem"; -import SingleItem from "./SingleItem"; -import { MenuItemProps } from "./types"; - -const MenuItemContainer = styled.li` - display: grid; - gap: var(--spacing-gap-xs); -`; - -export default function MenuItem({ item, depthLevel = 0 }: MenuItemProps) { - return ( - - {"items" in item ? ( - - ) : ( - - )} - - ); -} diff --git a/packages/lib/src/navigation-tree/NavigationTree.stories.tsx b/packages/lib/src/navigation-tree/NavigationTree.stories.tsx index 4fc831a429..3744fe8232 100644 --- a/packages/lib/src/navigation-tree/NavigationTree.stories.tsx +++ b/packages/lib/src/navigation-tree/NavigationTree.stories.tsx @@ -3,10 +3,10 @@ import Title from "../../.storybook/components/Title"; import DxcBadge from "../badge/Badge"; import DxcContainer from "../container/Container"; import DxcNavigationTree from "./NavigationTree"; -import SingleItem from "./SingleItem"; -import NavigationTreeContext from "./NavigationTreeContext"; import { Meta, StoryObj } from "@storybook/react-vite"; import { userEvent, within } from "storybook/internal/test"; +import NavigationTreeContext from "./NavigationTreeContext"; +import SingleItem from "../base-menu/SingleItem"; export default { title: "Navigation Tree", diff --git a/packages/lib/src/navigation-tree/NavigationTree.tsx b/packages/lib/src/navigation-tree/NavigationTree.tsx index a6f49440be..49c248dd05 100644 --- a/packages/lib/src/navigation-tree/NavigationTree.tsx +++ b/packages/lib/src/navigation-tree/NavigationTree.tsx @@ -1,12 +1,12 @@ import { useLayoutEffect, useMemo, useRef, useState } from "react"; import styled from "@emotion/styled"; -import MenuItem from "./MenuItem"; +import MenuItem from "../base-menu/MenuItem"; import NavigationTreePropsType, { GroupItemWithId, ItemWithId, SectionWithId } from "./types"; -import Section from "./Section"; -import NavigationTreeContext from "./NavigationTreeContext"; +import Section from "../base-menu/Section"; +import NavigationTreeContext from "../base-menu/BaseMenuContext"; import scrollbarStyles from "../styles/scroll"; -import { addIdToItems, isSection } from "./utils"; -import SubMenu from "./SubMenu"; +import { addIdToItems, isSection } from "../base-menu/utils"; +import SubMenu from "../base-menu/SubMenu"; const NavigationTreeContainer = styled.div<{ displayBorder: boolean }>` box-sizing: border-box; diff --git a/packages/lib/src/navigation-tree/types.ts b/packages/lib/src/navigation-tree/types.ts index 78822a8b49..a0ce75e0c2 100644 --- a/packages/lib/src/navigation-tree/types.ts +++ b/packages/lib/src/navigation-tree/types.ts @@ -1,98 +1,20 @@ -import { ButtonHTMLAttributes, Dispatch, ReactElement, ReactNode, SetStateAction } from "react"; -import { SVG } from "../common/utils"; - -type CommonItemProps = { - badge?: ReactElement; - icon?: string | SVG; - label: string; -}; - -type RenderItemProps = { - /** Whether the item is selected (for styling) */ - // selected: boolean; - /** The default rendered content (label, icon, badge, etc.) */ - children: React.ReactNode; -}; - -type Item = CommonItemProps & { - onSelect?: () => void; - selected?: boolean; - href?: string; - renderItem?: (props: RenderItemProps) => React.ReactNode; -}; -type GroupItem = CommonItemProps & { - items: (Item | GroupItem)[]; -}; -type Section = { items: (Item | GroupItem)[]; title?: string }; -type Props = { - /** - * Array of items to be displayed in the navigation tree. - * Each item can be a single/simple item, a group item or a section. - */ - items: (Item | GroupItem)[] | Section[]; - /** - * If true the navigation tree will be displayed with a border. - */ - displayBorder?: boolean; - /** - * If true the navigation tree will have lines marking the groups. - */ - displayGroupLines?: boolean; - /** - * If true the navigation tree will have controls at the end. - */ - displayControlsAfter?: boolean; - /** - * If true the navigation tree will be icons only and display a popover on click. - */ - responsiveView?: 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: Item["selected"]; - href?: Item["href"]; - renderItem?: Item["renderItem"]; -}; -type SectionProps = { - section: SectionWithId; - index: number; - length: number; -}; -type SubMenuProps = { children: ReactNode; id?: string; depthLevel?: number }; -type NavigationTreeContextProps = { - selectedItemId?: number; - setSelectedItemId?: Dispatch>; - displayGroupLines?: boolean; - displayControlsAfter?: boolean; - responsiveView?: boolean; -}; +import Props, { + BaseMenuContextProps as NavigationTreeContextProps, + GroupItem, + GroupItemProps, + GroupItemWithId, + Item, + ItemActionProps, + ItemWithId, + SubMenuProps, + MenuItemProps, + Section, + SectionWithId, + SectionProps, + SingleItemProps, +} from "../base-menu/types"; export type { - NavigationTreeContextProps, GroupItem, GroupItemProps, GroupItemWithId, @@ -101,6 +23,7 @@ export type { ItemWithId, SubMenuProps, MenuItemProps, + NavigationTreeContextProps, Section, SectionWithId, SectionProps, diff --git a/packages/lib/src/navigation-tree/utils.ts b/packages/lib/src/navigation-tree/utils.ts deleted file mode 100644 index 77db32b032..0000000000 --- a/packages/lib/src/navigation-tree/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.selected; - }); diff --git a/packages/lib/src/sidenav/Sidenav.stories.tsx b/packages/lib/src/sidenav/Sidenav.stories.tsx index d32319f1aa..6fef56d2a4 100644 --- a/packages/lib/src/sidenav/Sidenav.stories.tsx +++ b/packages/lib/src/sidenav/Sidenav.stories.tsx @@ -68,7 +68,7 @@ const groupItems = [ label: "Grouped Item 1", icon: "favorite", items: [ - { label: "Item 1", icon: "person", href: "#1" }, + { label: "Item 1", icon: "person" }, { label: "Grouped Item 2", items: [ @@ -79,12 +79,11 @@ const groupItems = [ }, { label: "Selected Item 3" }, ], - href: "#2", }, ], badge: , }, - { label: "Item 4", icon: "key", href: "#3" }, + { label: "Item 4", icon: "key" }, ], }, { @@ -412,6 +411,8 @@ const SelectedGroup = () => ( ); type Story = StoryObj; +// TODO: ADD TEST AND STORIES FOR LINK/RENDERITEM PROPS + export const Chromatic: Story = { render: SideNav, play: async ({ canvasElement }) => { diff --git a/packages/lib/src/utils/useGroupItem.ts b/packages/lib/src/utils/useGroupItem.ts deleted file mode 100644 index 6880bf844e..0000000000 --- a/packages/lib/src/utils/useGroupItem.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useId, useMemo, useState } from "react"; -import { ContextualMenuContextProps, GroupItemProps as ContextItemProps } from "../contextual-menu/types"; -import { NavigationTreeContextProps, GroupItemProps as NavGroupItemProps } from "../navigation-tree/types"; - -const isGroupSelected = ( - items: ContextItemProps["items"] | NavGroupItemProps["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.selected; - }); - -const hasResponsiveView = ( - ctx: ContextualMenuContextProps | NavigationTreeContextProps -): ctx is NavigationTreeContextProps => "responsiveView" in ctx; - -export const useGroupItem = ( - items: ContextItemProps["items"] | NavGroupItemProps["items"], - context: ContextualMenuContextProps | NavigationTreeContextProps -) => { - const groupMenuId = `group-menu-${useId()}`; - const { selectedItemId } = context ?? {}; - const groupSelected = useMemo(() => isGroupSelected(items, selectedItemId), [items, selectedItemId]); - const [isOpen, setIsOpen] = useState(groupSelected && selectedItemId === -1); - - const toggleOpen = () => setIsOpen((prev) => !prev); - - return { - groupMenuId, - groupSelected, - isOpen, - toggleOpen, - responsiveView: hasResponsiveView(context) ? context.responsiveView : undefined, - }; -}; From 69eb0b9444049e3eeeddbe2905ab9d1dd357783c Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Fri, 24 Oct 2025 13:57:44 +0200 Subject: [PATCH 19/36] Added TODO --- .../components/contextual-menu/code/ContextualMenuCodePage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx b/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx index e35303c443..c6f4242e7f 100644 --- a/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx +++ b/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx @@ -80,6 +80,7 @@ const sections = [ title: "Action menu", content: , }, + // TODO: We should remove this example as it is not the intended usage right? { title: "Navigation menu", content: , From 8f618fce0e680d6459cfaf1fa87f43c2221fad04 Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Fri, 24 Oct 2025 14:03:40 +0200 Subject: [PATCH 20/36] Fixed typings --- apps/website/pages/_app.tsx | 2 +- packages/lib/src/sidenav/Sidenav.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/website/pages/_app.tsx b/apps/website/pages/_app.tsx index 2932e175a1..f914a64bf3 100644 --- a/apps/website/pages/_app.tsx +++ b/apps/website/pages/_app.tsx @@ -58,7 +58,7 @@ export default function App({ Component, pageProps, emotionCache = clientSideEmo ...(link.status && { badge: link.status !== "stable" ? : undefined, }), - renderItem: ({ children }) => ( + renderItem: ({ children }: { children: ReactNode }) => ( {children} diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx index 0f9ed5eac2..5a93037c2a 100644 --- a/packages/lib/src/sidenav/Sidenav.tsx +++ b/packages/lib/src/sidenav/Sidenav.tsx @@ -91,7 +91,7 @@ const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }: )} - {/* TODO: SEARCHBAR */} + {/* TODO: REPLACE WITH THE ACTUAL SEARCHBAR */} {/* Date: Fri, 24 Oct 2025 14:11:54 +0200 Subject: [PATCH 21/36] Fixed types export --- packages/lib/src/contextual-menu/types.ts | 3 +-- packages/lib/src/navigation-tree/types.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/lib/src/contextual-menu/types.ts b/packages/lib/src/contextual-menu/types.ts index 55d82c46af..83d6b0a53f 100644 --- a/packages/lib/src/contextual-menu/types.ts +++ b/packages/lib/src/contextual-menu/types.ts @@ -33,6 +33,5 @@ export type { SectionWithId, SectionProps, SingleItemProps, + Props as default, }; - -export default Props; diff --git a/packages/lib/src/navigation-tree/types.ts b/packages/lib/src/navigation-tree/types.ts index a0ce75e0c2..9afb437531 100644 --- a/packages/lib/src/navigation-tree/types.ts +++ b/packages/lib/src/navigation-tree/types.ts @@ -28,6 +28,5 @@ export type { SectionWithId, SectionProps, SingleItemProps, + Props as default, }; - -export default Props; From 525005459fa8877293d9edfc6b1de0c82b8623ea Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Fri, 24 Oct 2025 14:29:47 +0200 Subject: [PATCH 22/36] Fixed some styles problems in ItemAction for ContextualMenu --- packages/lib/src/base-menu/ItemAction.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/lib/src/base-menu/ItemAction.tsx b/packages/lib/src/base-menu/ItemAction.tsx index d675f006cf..5667a26f1c 100644 --- a/packages/lib/src/base-menu/ItemAction.tsx +++ b/packages/lib/src/base-menu/ItemAction.tsx @@ -44,7 +44,6 @@ const Action = styled.button<{ const Label = styled.span` display: flex; - align-items: center; gap: var(--spacing-gap-s); overflow: hidden; `; @@ -106,10 +105,16 @@ const ItemAction = memo( aria-pressed={!href ? ariaPressed : undefined} > - {!responsiveView && ( + {!responsiveView && (modifiedBadge || (displayControlsAfter && collapseIcon)) && ( {modifiedBadge} {displayControlsAfter && collapseIcon && {collapseIcon}} From a263be461e9a76335e3162fb11e0ed999bab9b75 Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Tue, 4 Nov 2025 13:37:29 +0100 Subject: [PATCH 25/36] Used navItems instead of items for the API --- .../lib/src/sidenav/Sidenav.accessibility.test.tsx | 2 +- packages/lib/src/sidenav/Sidenav.stories.tsx | 14 +++++++------- packages/lib/src/sidenav/Sidenav.test.tsx | 2 +- packages/lib/src/sidenav/Sidenav.tsx | 6 +++--- packages/lib/src/sidenav/types.ts | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx index 7a05186670..77f142f843 100644 --- a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx +++ b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx @@ -49,7 +49,7 @@ describe("Sidenav component accessibility tests", () => { ]; const { container } = render( ( <DxcSidenav - items={groupItems} + navItems={groupItems} title="Application Name" logo={{ src: "https://images.ctfassets.net/hrltx12pl8hq/5596z2BCR9KmT1KeRBrOQa/4070fd4e2f1a13f71c2c46afeb18e41c/shutterstock_451077043-hero1.jpg", @@ -167,7 +167,7 @@ const SideNav = () => ( <ExampleContainer> <Title title="Sidenav with group lines" theme="light" level={4} /> <DxcSidenav - items={groupItems} + navItems={groupItems} title="Application Name" logo={{ src: "https://images.ctfassets.net/hrltx12pl8hq/5596z2BCR9KmT1KeRBrOQa/4070fd4e2f1a13f71c2c46afeb18e41c/shutterstock_451077043-hero1.jpg", @@ -202,7 +202,7 @@ const Collapsed = () => ( <> <ExampleContainer> <Title title="Collapsed sidenav" theme="light" level={4} /> - <DxcSidenav items={groupItems} title="App Name"> + <DxcSidenav navItems={groupItems} title="App Name"> {(expanded: boolean) => expanded ? ( <> @@ -249,7 +249,7 @@ const Collapsed = () => ( </ExampleContainer> <ExampleContainer> <Title title="Collapsed sidenav with groups expanded (no lines)" theme="light" level={4} /> - <DxcSidenav items={groupItems} title="App Name"> + <DxcSidenav navItems={groupItems} title="App Name"> {(expanded: boolean) => expanded ? ( <> @@ -296,7 +296,7 @@ const Collapsed = () => ( </ExampleContainer> <ExampleContainer> <Title title="Collapsed sidenav with groups expanded (lines)" theme="light" level={4} /> - <DxcSidenav items={groupItems} title="App Name" displayGroupLines> + <DxcSidenav navItems={groupItems} title="App Name" displayGroupLines> {(expanded: boolean) => expanded ? ( <> @@ -348,7 +348,7 @@ const Hovered = () => ( <ExampleContainer pseudoState="pseudo-hover"> <Title title="Hover state for groups" theme="light" level={4} /> <DxcSidenav - items={groupItems} + navItems={groupItems} title="Application Name" logo={{ src: "https://images.ctfassets.net/hrltx12pl8hq/5596z2BCR9KmT1KeRBrOQa/4070fd4e2f1a13f71c2c46afeb18e41c/shutterstock_451077043-hero1.jpg", @@ -381,7 +381,7 @@ const SelectedGroup = () => ( <ExampleContainer> <Title title="Default sidenav" theme="light" level={4} /> <DxcSidenav - items={selectedGroupItems} + navItems={selectedGroupItems} title="Application Name" logo={{ src: "https://images.ctfassets.net/hrltx12pl8hq/5596z2BCR9KmT1KeRBrOQa/4070fd4e2f1a13f71c2c46afeb18e41c/shutterstock_451077043-hero1.jpg", diff --git a/packages/lib/src/sidenav/Sidenav.test.tsx b/packages/lib/src/sidenav/Sidenav.test.tsx index 22a7590a92..df25cd6fa3 100644 --- a/packages/lib/src/sidenav/Sidenav.test.tsx +++ b/packages/lib/src/sidenav/Sidenav.test.tsx @@ -51,7 +51,7 @@ describe("DxcSidenav component", () => { test("renders contextual menu with items", () => { const items = [{ label: "Dashboard" }, { label: "Settings" }]; - const { getByTestId } = render(<DxcSidenav items={items} />); + const { getByTestId } = render(<DxcSidenav navItems={items} />); expect(getByTestId("mock-menu")).toBeTruthy(); expect(DxcNavigationTree).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx index 5a93037c2a..c3d51101aa 100644 --- a/packages/lib/src/sidenav/Sidenav.tsx +++ b/packages/lib/src/sidenav/Sidenav.tsx @@ -45,7 +45,7 @@ const LogoContainer = styled.div<{ text-decoration: none; `; -const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }: SidenavPropsType): JSX.Element => { +const DxcSidenav = ({ title, children, navItems, logo, displayGroupLines = false }: SidenavPropsType): JSX.Element => { const [isExpanded, setIsExpanded] = useState(true); const renderedChildren = typeof children === "function" ? children(isExpanded) : children; @@ -108,9 +108,9 @@ const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }: left: "medium", }} /> */} - {items && ( + {navItems && ( <DxcNavigationTree - items={items} + items={navItems} displayGroupLines={displayGroupLines} displayBorder={false} responsiveView={!isExpanded} diff --git a/packages/lib/src/sidenav/types.ts b/packages/lib/src/sidenav/types.ts index b87de890c3..79564f3363 100644 --- a/packages/lib/src/sidenav/types.ts +++ b/packages/lib/src/sidenav/types.ts @@ -103,7 +103,7 @@ 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[]; + navItems?: (Item | GroupItem)[] | Section[]; /** * Object with the properties of the logo placed at the top of the sidenav. */ From 27907ddf0082782fe8d78060d6b1f6a048bc1469 Mon Sep 17 00:00:00 2001 From: Mil4n0r <morenocarmonaenrique@gmail.com> Date: Tue, 4 Nov 2025 15:13:31 +0100 Subject: [PATCH 26/36] Replaced items in test --- packages/lib/src/layout/ApplicationLayout.stories.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/lib/src/layout/ApplicationLayout.stories.tsx b/packages/lib/src/layout/ApplicationLayout.stories.tsx index 71aab7b79e..b1476ea7d3 100644 --- a/packages/lib/src/layout/ApplicationLayout.stories.tsx +++ b/packages/lib/src/layout/ApplicationLayout.stories.tsx @@ -48,7 +48,7 @@ const items = [ const ApplicationLayoutDefaultSidenav = () => ( <> <DxcApplicationLayout - sidenav={<DxcApplicationLayout.SideNav title="Application layout with push sidenav" items={items} />} + sidenav={<DxcApplicationLayout.SideNav title="Application layout with push sidenav" navItems={items} />} > <DxcApplicationLayout.Main> <p>Main Content</p> @@ -64,7 +64,7 @@ const ApplicationLayoutResponsiveSidenav = () => ( <> <DxcApplicationLayout sidenav={ - <DxcApplicationLayout.SideNav title="Application layout with push sidenav" items={items}> + <DxcApplicationLayout.SideNav title="Application layout with push sidenav" navItems={items}> {(expanded: boolean) => (!expanded ? <p>Responsive Content</p> : <></>)} </DxcApplicationLayout.SideNav> } @@ -83,7 +83,7 @@ const ApplicationLayoutCustomHeader = () => ( <> <DxcApplicationLayout header={<p>Custom Header</p>} - sidenav={<DxcApplicationLayout.SideNav title="Application layout with push sidenav" items={items} />} + sidenav={<DxcApplicationLayout.SideNav title="Application layout with push sidenav" navItems={items} />} > <DxcApplicationLayout.Main> <p>Main Content</p> @@ -99,7 +99,7 @@ const ApplicationLayoutCustomFooter = () => ( <> <DxcApplicationLayout footer={<p>Custom Footer</p>} - sidenav={<DxcApplicationLayout.SideNav title="Application layout with push sidenav" items={items} />} + sidenav={<DxcApplicationLayout.SideNav title="Application layout with push sidenav" navItems={items} />} > <DxcApplicationLayout.Main> <p>Main Content</p> @@ -113,7 +113,7 @@ const ApplicationLayoutCustomFooter = () => ( const Tooltip = () => ( <DxcApplicationLayout - sidenav={<DxcApplicationLayout.SideNav title="Application layout with push sidenav" items={items} />} + sidenav={<DxcApplicationLayout.SideNav title="Application layout with push sidenav" navItems={items} />} > <DxcApplicationLayout.Main> <p>Main Content</p> From 95c892f154c1f75677b9d9aa7253a7a404f1dc70 Mon Sep 17 00:00:00 2001 From: Mil4n0r <morenocarmonaenrique@gmail.com> Date: Tue, 4 Nov 2025 15:52:07 +0100 Subject: [PATCH 27/36] Fixed app according to new API --- apps/website/pages/_app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/website/pages/_app.tsx b/apps/website/pages/_app.tsx index f914a64bf3..52d02e578e 100644 --- a/apps/website/pages/_app.tsx +++ b/apps/website/pages/_app.tsx @@ -90,7 +90,7 @@ export default function App({ Component, pageProps, emotionCache = clientSideEmo <DxcApplicationLayout sidenav={ <DxcApplicationLayout.SideNav - items={sections} + navItems={sections} // title={<SidenavLogo />} > {/* {filteredLinks?.map(({ label, links }) => ( From 083634764d61350ec777e863ab3b470bff286ec5 Mon Sep 17 00:00:00 2001 From: Mil4n0r <morenocarmonaenrique@gmail.com> Date: Thu, 6 Nov 2025 16:54:03 +0100 Subject: [PATCH 28/36] Added tests, docsite sidenav and new API improvements --- apps/website/pages/_app.tsx | 109 ++-- .../screens/common/sidenav/SidenavLogo.tsx | 12 +- .../code/ContextualMenuCodePage.tsx | 2 +- packages/lib/src/base-menu/GroupItem.tsx | 1 + packages/lib/src/base-menu/ItemAction.tsx | 6 +- packages/lib/src/base-menu/MenuItem.tsx | 3 +- .../src/layout/ApplicationLayout.stories.tsx | 43 +- packages/lib/src/layout/ApplicationLayout.tsx | 2 +- .../sidenav/Sidenav.accessibility.test.tsx | 10 +- packages/lib/src/sidenav/Sidenav.stories.tsx | 539 ++++++++++-------- packages/lib/src/sidenav/Sidenav.test.tsx | 95 ++- packages/lib/src/sidenav/Sidenav.tsx | 101 ++-- packages/lib/src/sidenav/types.ts | 97 +--- 13 files changed, 552 insertions(+), 468 deletions(-) diff --git a/apps/website/pages/_app.tsx b/apps/website/pages/_app.tsx index 52d02e578e..d3f47ba687 100644 --- a/apps/website/pages/_app.tsx +++ b/apps/website/pages/_app.tsx @@ -1,8 +1,8 @@ -import { ReactElement, ReactNode, useEffect } from "react"; +import { ReactElement, ReactNode, useEffect, useMemo, useState } from "react"; import type { NextPage } from "next"; import type { AppProps } from "next/app"; import Head from "next/head"; -import { DxcApplicationLayout, DxcToastsQueue } from "@dxc-technology/halstack-react"; +import { DxcApplicationLayout, DxcTextInput, DxcToastsQueue } from "@dxc-technology/halstack-react"; import MainContent from "@/common/MainContent"; import { useRouter } from "next/router"; import { LinksSectionDetails, LinksSections } from "@/common/pagesList"; @@ -12,6 +12,9 @@ import createCache, { EmotionCache } from "@emotion/cache"; import { CacheProvider } from "@emotion/react"; import { usePathname } from "next/navigation"; import Link from "next/link"; +import { GroupItem, Item, Section } from "../../../packages/lib/src/base-menu/types"; +import { isGroupItem } from "../../../packages/lib/src/base-menu/utils"; +import SidenavLogo from "@/common/sidenav/SidenavLogo"; type NextPageWithLayout = NextPage & { getLayout?: (_page: ReactElement) => ReactNode; @@ -28,21 +31,39 @@ export default function App({ Component, pageProps, emotionCache = clientSideEmo const componentWithLayout = getLayout(<Component {...pageProps} />); const router = useRouter(); const pathname = usePathname(); - // const [filter, setFilter] = useState(""); - // const filteredLinks = useMemo(() => { - // const filtered: LinksSectionDetails[] = []; - // LinksSections.map((section) => { - // const sectionFilteredLinks = section?.links.filter((link) => - // link.label.toLowerCase().includes(filter.toLowerCase()) - // ); - // if (sectionFilteredLinks.length) { - // filtered.push({ label: section.label, links: sectionFilteredLinks }); - // } - // }); - // return filtered; - // }, [filter]); + const [filter, setFilter] = useState(""); + const [isExpanded, setIsExpanded] = useState(true); - const mapLinksToGroupItems = (sections: LinksSectionDetails[]) => { + const filterSections = (sections: Section[], query: string): Section[] => { + const q = query.trim().toLowerCase(); + if (!q) return sections; + + const filterItem = (item: Item | GroupItem): Item | GroupItem | null => { + const labelMatches = item.label.toLowerCase().includes(q); + + if (!isGroupItem(item)) return labelMatches ? item : null; + + const items = item.items.reduce<(Item | GroupItem)[]>((acc, child) => { + const filtered = filterItem(child); + if (filtered) acc.push(filtered); + return acc; + }, []); + + return labelMatches || items.length ? { ...item, items } : null; + }; + + return sections.reduce<Section[]>((acc, section) => { + const items = section.items.reduce<(Item | GroupItem)[]>((acc, item) => { + const filtered = filterItem(item); + if (filtered) acc.push(filtered); + return acc; + }, []); + if (items.length) acc.push({ ...section, items }); + return acc; + }, []); + }; + + const mapLinksToGroupItems = (sections: LinksSectionDetails[]): Section[] => { const matchPaths = (linkPath: string) => { const desiredPaths = [linkPath, `${linkPath}/code`]; const pathToBeMatched = pathname?.split("#")[0]?.slice(0, -1); @@ -77,10 +98,13 @@ export default function App({ Component, pageProps, emotionCache = clientSideEmo void prefetchPaths(); }, []); - // TODO: ADD FILTERING - // TODO: ADD CATEGORIZATION + // TODO: ADD NEW CATEGORIZATION - const sections = mapLinksToGroupItems(LinksSections); + const filteredSections = useMemo(() => { + const sections = mapLinksToGroupItems(LinksSections); + console.log("SECTIONS", sections); + return filterSections(sections, filter); + }, [filter]); return ( <CacheProvider value={emotionCache}> @@ -89,30 +113,29 @@ export default function App({ Component, pageProps, emotionCache = clientSideEmo </Head> <DxcApplicationLayout sidenav={ - <DxcApplicationLayout.SideNav - navItems={sections} - // title={<SidenavLogo />} - > - {/* {filteredLinks?.map(({ label, links }) => ( - <DxcApplicationLayout.SideNav.Section key={label}> - <DxcApplicationLayout.SideNav.Group title={label}> - {links.map(({ label, path, status }) => ( - <Link key={`${label}-${path}`} href={path} passHref legacyBehavior> - <DxcApplicationLayout.SideNav.Link selected={matchPaths(path)}> - {label} - {status && status !== "stable" && <StatusBadge hasTitle status={status} />} - </DxcApplicationLayout.SideNav.Link> - </Link> - ))} - </DxcApplicationLayout.SideNav.Group> - </DxcApplicationLayout.SideNav.Section> - ))} - <DxcApplicationLayout.SideNav.Section> - <DxcApplicationLayout.SideNav.Link href="https://github.com/dxc-technology/halstack-react" newWindow> - GitHub - </DxcApplicationLayout.SideNav.Link> - </DxcApplicationLayout.SideNav.Section> */} - </DxcApplicationLayout.SideNav> + <DxcApplicationLayout.Sidenav + navItems={filteredSections} + branding={<SidenavLogo expanded={isExpanded} />} + topContent={ + isExpanded ? ( + <DxcTextInput + placeholder="Search docs" + value={filter} + onChange={({ value }: { value: string }) => { + setFilter(value); + }} + size="fillParent" + clearable + /> + ) : ( + <></> + ) + } + expanded={isExpanded} + onExpandedChange={() => { + setIsExpanded((currentlyExpanded) => !currentlyExpanded); + }} + /> } > <DxcApplicationLayout.Main> diff --git a/apps/website/screens/common/sidenav/SidenavLogo.tsx b/apps/website/screens/common/sidenav/SidenavLogo.tsx index 8be159388f..286f78e9b6 100644 --- a/apps/website/screens/common/sidenav/SidenavLogo.tsx +++ b/apps/website/screens/common/sidenav/SidenavLogo.tsx @@ -22,11 +22,11 @@ const Subtitle = styled.div` font-family: var(--typography-font-family); `; -const SidenavLogo = ({ subtitle = "Design System" }: { subtitle?: string }) => { +const SidenavLogo = ({ subtitle = "Design System", expanded }: { subtitle?: string; expanded: boolean }) => { const pathVersion = process.env.NEXT_PUBLIC_SITE_VERSION; const isDev = process.env.NODE_ENV === "development"; - return ( + return expanded ? ( <DxcFlex alignItems="center"> <LogoContainer> <DxcFlex alignItems="center" gap="var(--spacing-gap-s)"> @@ -47,6 +47,14 @@ const SidenavLogo = ({ subtitle = "Design System" }: { subtitle?: string }) => { size="small" /> </DxcFlex> + ) : ( + <Image + alt="Halstack logo" + height={32} + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + src={halstackLogo} + width={32} + /> ); }; diff --git a/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx b/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx index c6f4242e7f..53e783da3c 100644 --- a/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx +++ b/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx @@ -80,7 +80,7 @@ const sections = [ title: "Action menu", content: <Example example={actionMenu} defaultIsVisible />, }, - // TODO: We should remove this example as it is not the intended usage right? + // TODO: We should remove this example as it is not the intended usage right? (Navigation is handled inside ApplicationLayout) { title: "Navigation menu", content: <Example example={navigationMenu} defaultIsVisible />, diff --git a/packages/lib/src/base-menu/GroupItem.tsx b/packages/lib/src/base-menu/GroupItem.tsx index 63f4bfbb84..db9c6b328b 100644 --- a/packages/lib/src/base-menu/GroupItem.tsx +++ b/packages/lib/src/base-menu/GroupItem.tsx @@ -15,6 +15,7 @@ const GroupItem = ({ items, ...props }: GroupItemProps) => { const contextValue = useContext(BaseMenuContext) ?? {}; const { groupSelected, isOpen, toggleOpen, responsiveView } = useGroupItem(items, contextValue); + // TODO: SET A FIXED WIDTH TO PREVENT MOVING CONTENT WHEN EXPANDING/COLLAPSING IN RESPONSIVEVIEW return responsiveView ? ( <> <Popover.Root open={isOpen}> diff --git a/packages/lib/src/base-menu/ItemAction.tsx b/packages/lib/src/base-menu/ItemAction.tsx index a1cf18ebee..681e24bbfe 100644 --- a/packages/lib/src/base-menu/ItemAction.tsx +++ b/packages/lib/src/base-menu/ItemAction.tsx @@ -111,9 +111,11 @@ const ItemAction = memo( <Icon>{collapseIcon}</Icon> </Control> )} - {icon && ( + {(icon || responsiveView) && ( <TooltipWrapper condition={responsiveView} label={label}> - <Icon>{typeof icon === "string" ? <DxcIcon icon={icon} /> : icon}</Icon> + <Icon> + {typeof icon === "string" ? <DxcIcon icon={icon} /> : icon ? icon : <DxcIcon icon="topic" />} + </Icon> </TooltipWrapper> )} {!responsiveView && ( diff --git a/packages/lib/src/base-menu/MenuItem.tsx b/packages/lib/src/base-menu/MenuItem.tsx index 65aadf7f17..b70663a489 100644 --- a/packages/lib/src/base-menu/MenuItem.tsx +++ b/packages/lib/src/base-menu/MenuItem.tsx @@ -2,6 +2,7 @@ import styled from "@emotion/styled"; import GroupItem from "./GroupItem"; import SingleItem from "./SingleItem"; import { MenuItemProps } from "./types"; +import { isGroupItem } from "./utils"; const MenuItemContainer = styled.li` display: grid; @@ -11,7 +12,7 @@ const MenuItemContainer = styled.li` export default function MenuItem({ item, depthLevel = 0 }: MenuItemProps) { return ( <MenuItemContainer role="menuitem"> - {"items" in item ? ( + {isGroupItem(item) ? ( <GroupItem {...item} depthLevel={depthLevel} /> ) : ( <SingleItem {...item} depthLevel={depthLevel} /> diff --git a/packages/lib/src/layout/ApplicationLayout.stories.tsx b/packages/lib/src/layout/ApplicationLayout.stories.tsx index b1476ea7d3..4e4048fc41 100644 --- a/packages/lib/src/layout/ApplicationLayout.stories.tsx +++ b/packages/lib/src/layout/ApplicationLayout.stories.tsx @@ -24,23 +24,23 @@ const ApplicationLayout = () => ( const items = [ { - label: "SideNav Content", + label: "Sidenav Content", icon: "tab", }, { - label: "SideNav Content", + label: "Sidenav Content", icon: "tab", }, { - label: "SideNav Content", + label: "Sidenav Content", icon: "tab", }, { - label: "SideNav Content", + label: "Sidenav Content", icon: "tab", }, { - label: "SideNav Content", + label: "Sidenav Content", icon: "tab", }, ]; @@ -48,7 +48,12 @@ const items = [ const ApplicationLayoutDefaultSidenav = () => ( <> <DxcApplicationLayout - sidenav={<DxcApplicationLayout.SideNav title="Application layout with push sidenav" navItems={items} />} + sidenav={ + <DxcApplicationLayout.Sidenav + branding={{ appTitle: "Application layout with push sidenav" }} + navItems={items} + /> + } > <DxcApplicationLayout.Main> <p>Main Content</p> @@ -64,9 +69,11 @@ const ApplicationLayoutResponsiveSidenav = () => ( <> <DxcApplicationLayout sidenav={ - <DxcApplicationLayout.SideNav title="Application layout with push sidenav" navItems={items}> - {(expanded: boolean) => (!expanded ? <p>Responsive Content</p> : <></>)} - </DxcApplicationLayout.SideNav> + <DxcApplicationLayout.Sidenav + branding={{ appTitle: "Application layout with push sidenav" }} + navItems={items} + bottomContent={(expanded: boolean) => (!expanded ? <p>Responsive Content</p> : <></>)} + /> } > <DxcApplicationLayout.Main> @@ -83,7 +90,12 @@ const ApplicationLayoutCustomHeader = () => ( <> <DxcApplicationLayout header={<p>Custom Header</p>} - sidenav={<DxcApplicationLayout.SideNav title="Application layout with push sidenav" navItems={items} />} + sidenav={ + <DxcApplicationLayout.Sidenav + branding={{ appTitle: "Application layout with push sidenav" }} + navItems={items} + /> + } > <DxcApplicationLayout.Main> <p>Main Content</p> @@ -99,7 +111,12 @@ const ApplicationLayoutCustomFooter = () => ( <> <DxcApplicationLayout footer={<p>Custom Footer</p>} - sidenav={<DxcApplicationLayout.SideNav title="Application layout with push sidenav" navItems={items} />} + sidenav={ + <DxcApplicationLayout.Sidenav + branding={{ appTitle: "Application layout with push sidenav" }} + navItems={items} + /> + } > <DxcApplicationLayout.Main> <p>Main Content</p> @@ -113,7 +130,9 @@ const ApplicationLayoutCustomFooter = () => ( const Tooltip = () => ( <DxcApplicationLayout - sidenav={<DxcApplicationLayout.SideNav title="Application layout with push sidenav" navItems={items} />} + sidenav={ + <DxcApplicationLayout.Sidenav branding={{ appTitle: "Application layout with push sidenav" }} navItems={items} /> + } > <DxcApplicationLayout.Main> <p>Main Content</p> diff --git a/packages/lib/src/layout/ApplicationLayout.tsx b/packages/lib/src/layout/ApplicationLayout.tsx index 810aca93cb..72808a6085 100644 --- a/packages/lib/src/layout/ApplicationLayout.tsx +++ b/packages/lib/src/layout/ApplicationLayout.tsx @@ -92,6 +92,6 @@ const DxcApplicationLayout = ({ header, sidenav, footer, children }: Application DxcApplicationLayout.Footer = DxcFooter; DxcApplicationLayout.Header = DxcHeader; DxcApplicationLayout.Main = Main; -DxcApplicationLayout.SideNav = DxcSidenav; +DxcApplicationLayout.Sidenav = DxcSidenav; export default DxcApplicationLayout; diff --git a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx index 77f142f843..a12a6dc692 100644 --- a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx +++ b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx @@ -50,10 +50,12 @@ describe("Sidenav component accessibility tests", () => { const { container } = render( <DxcSidenav navItems={groupItems} - title="Application Name" - logo={{ - src: "https://picsum.photos/id/1022/200/300", - alt: "Alt text", + branding={{ + appTitle: "Application Name", + logo: { + src: "https://picsum.photos/id/1022/200/300", + alt: "Alt text", + }, }} /> ); diff --git a/packages/lib/src/sidenav/Sidenav.stories.tsx b/packages/lib/src/sidenav/Sidenav.stories.tsx index 34c4976e88..6efff792e6 100644 --- a/packages/lib/src/sidenav/Sidenav.stories.tsx +++ b/packages/lib/src/sidenav/Sidenav.stories.tsx @@ -10,6 +10,7 @@ import DxcAvatar from "../avatar/Avatar"; import { userEvent, within } from "storybook/internal/test"; import disabledRules from "../../test/accessibility/rules/specific/sidenav/disabledRules"; import preview from "../../.storybook/preview"; +import { useState } from "react"; export default { title: "Sidenav", @@ -132,248 +133,287 @@ const selectedGroupItems = [ }, ]; -const SideNav = () => ( +const Sidenav = () => ( <> <ExampleContainer> <Title title="Default sidenav" theme="light" level={4} /> <DxcSidenav navItems={groupItems} - title="Application Name" - logo={{ - src: "https://images.ctfassets.net/hrltx12pl8hq/5596z2BCR9KmT1KeRBrOQa/4070fd4e2f1a13f71c2c46afeb18e41c/shutterstock_451077043-hero1.jpg", - alt: "TEST", + branding={{ + appTitle: "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> + bottomContent={ + <> + <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> + </> + } + /> </ExampleContainer> <ExampleContainer> <Title title="Sidenav with group lines" theme="light" level={4} /> <DxcSidenav navItems={groupItems} - title="Application Name" - logo={{ - src: "https://images.ctfassets.net/hrltx12pl8hq/5596z2BCR9KmT1KeRBrOQa/4070fd4e2f1a13f71c2c46afeb18e41c/shutterstock_451077043-hero1.jpg", - alt: "TEST", + branding={{ + appTitle: "Application Name", + logo: { + src: "https://images.ctfassets.net/hrltx12pl8hq/5596z2BCR9KmT1KeRBrOQa/4070fd4e2f1a13f71c2c46afeb18e41c/shutterstock_451077043-hero1.jpg", + alt: "TEST", + }, }} + bottomContent={ + <> + <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> + </> + } 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 navItems={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 navItems={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 navItems={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 Collapsed = () => { + const [isExpanded, setIsExpanded] = useState(true); + const [isExpandedGroupsNoLines, setIsExpandedGroupsNoLines] = useState(true); + const [isExpandedGroups, setIsExpandedGroups] = useState(true); + return ( + <> + <ExampleContainer> + <Title title="Collapsed sidenav" theme="light" level={4} /> + <DxcSidenav + navItems={groupItems} + branding={{ appTitle: "App Name" }} + bottomContent={ + isExpanded ? ( + <> + <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> + </> + ) + } + expanded={isExpanded} + onExpandedChange={() => { + setIsExpanded((previouslyExpanded) => !previouslyExpanded); + }} + /> + </ExampleContainer> + <ExampleContainer> + <Title title="Collapsed sidenav with groups expanded (no lines)" theme="light" level={4} /> + <DxcSidenav + navItems={groupItems} + branding={{ appTitle: "App Name" }} + bottomContent={ + isExpandedGroupsNoLines ? ( + <> + <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> + </> + ) + } + expanded={isExpandedGroupsNoLines} + onExpandedChange={() => { + setIsExpandedGroupsNoLines((previouslyExpanded) => !previouslyExpanded); + }} + /> + </ExampleContainer> + <ExampleContainer> + <Title title="Collapsed sidenav with groups expanded (lines)" theme="light" level={4} /> + <DxcSidenav + navItems={groupItems} + branding={{ appTitle: "App Name" }} + bottomContent={ + isExpandedGroups ? ( + <> + <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> + </> + ) + } + expanded={isExpandedGroups} + onExpandedChange={() => { + setIsExpandedGroups((previouslyExpanded) => !previouslyExpanded); + }} + displayGroupLines + /> + </ExampleContainer> + </> + ); +}; const Hovered = () => ( <ExampleContainer pseudoState="pseudo-hover"> <Title title="Hover state for groups" theme="light" level={4} /> <DxcSidenav navItems={groupItems} - title="Application Name" - logo={{ - src: "https://images.ctfassets.net/hrltx12pl8hq/5596z2BCR9KmT1KeRBrOQa/4070fd4e2f1a13f71c2c46afeb18e41c/shutterstock_451077043-hero1.jpg", - alt: "TEST", + branding={{ + appTitle: "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> + bottomContent={ + <> + <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> + </> + } + /> </ExampleContainer> ); @@ -382,39 +422,42 @@ const SelectedGroup = () => ( <Title title="Default sidenav" theme="light" level={4} /> <DxcSidenav navItems={selectedGroupItems} - title="Application Name" - logo={{ - src: "https://images.ctfassets.net/hrltx12pl8hq/5596z2BCR9KmT1KeRBrOQa/4070fd4e2f1a13f71c2c46afeb18e41c/shutterstock_451077043-hero1.jpg", - alt: "TEST", + branding={{ + appTitle: "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> + bottomContent={ + <> + <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> + </> + } + /> </ExampleContainer> ); type Story = StoryObj<typeof DxcSidenav>; -// TODO: ADD TEST AND STORIES FOR LINK/RENDERITEM PROPS - export const Chromatic: Story = { - render: SideNav, + render: Sidenav, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const menuItem1 = (await canvas.findAllByRole("button"))[10]; @@ -428,7 +471,7 @@ export const Chromatic: Story = { }, }; -export const CollapsedSideNav: Story = { +export const CollapsedSidenav: Story = { render: Collapsed, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -455,7 +498,7 @@ export const CollapsedSideNav: Story = { }, }; -export const HoveredSideNav: Story = { +export const HoveredSidenav: Story = { render: Hovered, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -471,6 +514,6 @@ export const HoveredSideNav: Story = { }, }; -export const SelectedGroupSideNav: Story = { +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 df25cd6fa3..4bf1b2e2ea 100644 --- a/packages/lib/src/sidenav/Sidenav.test.tsx +++ b/packages/lib/src/sidenav/Sidenav.test.tsx @@ -1,7 +1,7 @@ import "@testing-library/jest-dom"; import { render, fireEvent } from "@testing-library/react"; import DxcSidenav from "./Sidenav"; -import DxcNavigationTree from "../navigation-tree/NavigationTree"; +import { ReactNode } from "react"; global.ResizeObserver = jest.fn().mockImplementation(() => ({ observe: jest.fn(), @@ -9,8 +9,6 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({ disconnect: jest.fn(), })); -jest.mock("../navigation-tree/NavigationTree", () => jest.fn(() => <div data-testid="mock-menu" />)); - describe("DxcSidenav component", () => { beforeEach(() => { jest.clearAllMocks(); @@ -18,19 +16,22 @@ describe("DxcSidenav component", () => { test("Sidenav renders title and children correctly", () => { const { getByText, getByRole } = render( - <DxcSidenav title="Main Menu"> - <p>Custom child content</p> - </DxcSidenav> + <DxcSidenav + branding={{ appTitle: "Main Menu" }} + topContent={<p>Custom top content</p>} + bottomContent={<p>Custom bottom content</p>} + /> ); expect(getByText("Main Menu")).toBeTruthy(); - expect(getByText("Custom child content")).toBeTruthy(); + expect(getByText("Custom top content")).toBeTruthy(); + expect(getByText("Custom bottom content")).toBeTruthy(); const collapseButton = getByRole("button", { name: "Collapse" }); expect(collapseButton).toBeTruthy(); }); test("Sidenav collapses and expands correctly on button click", () => { - const { getByRole } = render(<DxcSidenav title="Main Menu" />); + const { getByRole } = render(<DxcSidenav branding={{ appTitle: "Main Menu" }} />); const collapseButton = getByRole("button", { name: "Collapse" }); expect(collapseButton).toBeTruthy(); @@ -40,39 +41,77 @@ describe("DxcSidenav component", () => { fireEvent.click(expandButton); }); - test("renders logo correctly when provided", () => { + test("Sidenav 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 { getByRole, getByAltText } = render(<DxcSidenav branding={{ appTitle: "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", () => { + test("Sidenav renders contextual menu with items", () => { const items = [{ label: "Dashboard" }, { label: "Settings" }]; - const { getByTestId } = render(<DxcSidenav navItems={items} />); - expect(getByTestId("mock-menu")).toBeTruthy(); - expect(DxcNavigationTree).toHaveBeenCalledWith( - expect.objectContaining({ - items, - displayGroupLines: false, - displayControlsAfter: true, - displayBorder: false, - }), - {} + const { getByText } = render(<DxcSidenav navItems={items} />); + expect(getByText("Dashboard")).toBeTruthy(); + expect(getByText("Settings")).toBeTruthy(); + }); + + test("Sidenav renders link items correctly", () => { + const navItems = [{ label: "Dashboard", href: "/dashboard" }]; + + const { getByRole } = render(<DxcSidenav navItems={navItems} />); + + const link = getByRole("link", { name: "Dashboard" }); + expect(link).toHaveAttribute("href", "/dashboard"); + }); + + test("Sidenav calls renderItem correctly", () => { + const CustomComponent = ({ children }: { children: ReactNode }) => ( + <div data-testid="custom-wrapper">{children}</div> ); + + const customGroupItems = [ + { + label: "Introduction", + href: "/overview/introduction", + selected: false, + renderItem: ({ children }: { children: ReactNode }) => <CustomComponent>{children}</CustomComponent>, + }, + ]; + + const { getByTestId } = render(<DxcSidenav navItems={customGroupItems} />); + expect(getByTestId("custom-wrapper")).toBeInTheDocument(); }); - test("renders children using function pattern", () => { - const childFn = jest.fn((expanded) => <div>{expanded ? "Expanded content" : "Collapsed content"}</div>); + test("Sidenav uses controlled expanded prop instead of internal state", () => { + const onExpandedChange = jest.fn(); + const { getByRole, rerender } = render( + <DxcSidenav branding={{ appTitle: "Controlled Menu" }} expanded={false} onExpandedChange={onExpandedChange} /> + ); + + const expandButton = getByRole("button", { name: "Expand" }); + expect(expandButton).toBeTruthy(); + + fireEvent.click(expandButton); + expect(onExpandedChange).toHaveBeenCalledWith(true); + + rerender( + <DxcSidenav branding={{ appTitle: "Controlled Menu" }} expanded={true} onExpandedChange={onExpandedChange} /> + ); - 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); + }); + + test("Sidenav toggles internal state correctly", () => { + const { getByRole } = render(<DxcSidenav branding={{ appTitle: "App" }} defaultExpanded={false} />); + + const expandButton = getByRole("button", { name: "Expand" }); + expect(expandButton).toBeTruthy(); + + fireEvent.click(expandButton); + const collapseButton = getByRole("button", { name: "Collapse" }); + expect(collapseButton).toBeTruthy(); }); }); diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx index c3d51101aa..92b4d1cecb 100644 --- a/packages/lib/src/sidenav/Sidenav.tsx +++ b/packages/lib/src/sidenav/Sidenav.tsx @@ -5,15 +5,14 @@ import SidenavPropsType, { Logo } from "./types"; import DxcDivider from "../divider/Divider"; import DxcButton from "../button/Button"; import DxcImage from "../image/Image"; -import { ReactElement, useState } from "react"; -import DxcTextInput from "../text-input/TextInput"; +import { useState } from "react"; import DxcNavigationTree from "../navigation-tree/NavigationTree"; const SidenavContainer = styled.div<{ expanded: boolean }>` box-sizing: border-box; display: flex; flex-direction: column; - /* TODO: ASK FINAL SIZES AND IMPLEMENT RESIZABLE SIDENAV */ + /* TODO: IMPLEMENT RESIZABLE SIDENAV */ min-width: ${({ expanded }) => (expanded ? "240px" : "56px")}; max-width: ${({ expanded }) => (expanded ? "320px" : "56px")}; height: 100%; @@ -45,69 +44,69 @@ const LogoContainer = styled.div<{ text-decoration: none; `; -const DxcSidenav = ({ title, children, navItems, logo, displayGroupLines = false }: SidenavPropsType): JSX.Element => { - const [isExpanded, setIsExpanded] = useState(true); +const DxcSidenav = ({ + topContent, + bottomContent, + navItems, + branding, + displayGroupLines = false, + expanded, + defaultExpanded = true, + onExpandedChange, +}: SidenavPropsType): JSX.Element => { + const [internalExpanded, setInternalExpanded] = useState(defaultExpanded); + const isControlled = expanded !== undefined; + const isExpanded = isControlled ? !!expanded : internalExpanded; - const renderedChildren = typeof children === "function" ? children(isExpanded) : children; + const handleToggle = () => { + const nextState = !isExpanded; + if (!isControlled) setInternalExpanded(nextState); + onExpandedChange?.(nextState); + }; - function isLogoObject(logo: Logo | ReactElement): logo is Logo { - return (logo as Logo).src !== undefined; - } + const isBrandingObject = (branding: SidenavPropsType["branding"]): branding is { logo?: Logo; appTitle?: string } => { + return ( + (typeof branding === "object" && branding !== null && "logo" in branding) || + (!!branding && "appTitle" in branding) + ); + }; return ( <SidenavContainer expanded={isExpanded}> - <DxcFlex justifyContent={isExpanded ? "space-between" : "center"}> + <DxcFlex + justifyContent={isExpanded ? "normal" : "center"} + gap={isExpanded ? "var(--spacing-gap-xs)" : "var(--spacing-gap-s)"} + direction={isExpanded ? "row" : "column-reverse"} + alignItems={isExpanded ? "normal" : "center"} + > <DxcButton icon={`left_panel_${isExpanded ? "close" : "open"}`} size={{ height: "medium" }} mode="tertiary" title={isExpanded ? "Collapse" : "Expand"} - onClick={() => { - setIsExpanded((previousExpanded) => !previousExpanded); - }} + onClick={handleToggle} /> - {isExpanded && ( + {isBrandingObject(branding) ? ( <DxcFlex direction="column" gap="var(--spacing-gap-m)" justifyContent="center" alignItems="flex-start"> - {/* TODO: ADD GORGORITO TO COVER CASES WITH NO ICON? */} - {logo && ( - <> - {isLogoObject(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")} - > - <DxcImage alt={logo.alt ?? ""} src={logo.src} height="100%" width="100%" /> - </LogoContainer> - ) : ( - logo - )} - </> + {branding.logo && ( + <LogoContainer + onClick={branding.logo.onClick} + hasAction={!!branding.logo.onClick || !!branding.logo.href} + role={branding.logo.onClick ? "button" : branding.logo.href ? "link" : "presentation"} + as={branding.logo.href ? "a" : undefined} + href={branding.logo.href} + aria-label={(branding.logo.onClick || branding.logo.href) && (branding.appTitle || "Avatar")} + > + <DxcImage alt={branding.logo.alt ?? ""} src={branding.logo.src} height="100%" width="100%" /> + </LogoContainer> )} - <SidenavTitle>{title}</SidenavTitle> + <SidenavTitle>{branding.appTitle}</SidenavTitle> </DxcFlex> + ) : ( + branding )} </DxcFlex> - {/* TODO: REPLACE WITH THE ACTUAL SEARCHBAR */} - <DxcTextInput placeholder="Search docs" size="small" clearable /> - {/* <DxcTextInput - placeholder="Search docs" - value={filter} - onChange={({ value }: { value: string }) => { - setFilter(value); - }} - size="fillParent" - clearable - margin={{ - top: "large", - bottom: "large", - right: "medium", - left: "medium", - }} - /> */} + {topContent} {navItems && ( <DxcNavigationTree items={navItems} @@ -118,7 +117,7 @@ const DxcSidenav = ({ title, children, navItems, logo, displayGroupLines = false /> )} <DxcDivider color="lightGrey" /> - {renderedChildren} + {bottomContent} </SidenavContainer> ); }; diff --git a/packages/lib/src/sidenav/types.ts b/packages/lib/src/sidenav/types.ts index 79564f3363..a09161d8bf 100644 --- a/packages/lib/src/sidenav/types.ts +++ b/packages/lib/src/sidenav/types.ts @@ -1,73 +1,6 @@ -import { MouseEvent, ReactNode, ReactElement } from "react"; +import { MouseEvent, ReactElement } from "react"; import { SVG } from "../common/utils"; -export type SidenavTitlePropsType = { - /** - * The area inside the sidenav title. This area can be used to render custom content. - */ - children: ReactNode; -}; - -export type SidenavSectionPropsType = { - /** - * The area inside the sidenav section. This area can be used to render sidenav groups, links and custom content. - */ - children: ReactNode; -}; - -export type SidenavGroupPropsType = { - /** - * The title of the sidenav group. - */ - title?: string; - /** - * If true, the sidenav group will be a button that will allow you to collapse the links contained within it. - * In addition, if it's collapsed and contains the currently selected link, the group title will also be marked as selected. - */ - collapsable?: boolean; - /** - * Material Symbol name or SVG icon to be displayed next to the title of the group. - */ - icon?: string | SVG; - /** - * The area inside the sidenav group. This area can be used to render sidenav links. - */ - children: ReactNode; -}; - -export type SidenavLinkPropsType = { - /** - * Page to be opened when the user clicks on the link. - */ - href?: string; - /** - * If true, the page is opened in a new browser tab. - */ - newWindow?: boolean; - /** - * The Material symbol or SVG element used as the icon that will be placed to the left of the link text. - */ - icon?: string | SVG; - /** - * If true, the link will be marked as selected. Moreover, in that same case, - * if it is contained within a collapsed group, and consequently, the currently selected link is not visible, - * the group title will appear as selected too. - */ - selected?: boolean; - /** - * This function will be called when the user clicks the link and the event will be passed to this function. - */ - onClick?: (event: MouseEvent<HTMLAnchorElement>) => void; - /** - * The area inside the sidenav link. - */ - children: ReactNode; - /** - * Value of the tabindex. - */ - tabIndex?: number; -}; - export type Logo = { /** * URL of the image that will be placed in the logo. @@ -76,7 +9,7 @@ export type Logo = { /** * Alternative text for the logo image. */ - alt?: string; + alt: string; /** * URL to navigate to when the logo is clicked. If not provided, the logo will not be clickable. */ @@ -91,23 +24,37 @@ type Section = { items: (Item | GroupItem)[]; title?: string }; type Props = { /** - * The title of the sidenav that will be placed under the logo. + * If true, the sidenav is expanded. + * If undefined the component will be uncontrolled and the value will be managed internally by the component. + */ + expanded?: boolean; + /** + * Initial state of the expansion of the sidenav, only when it is uncontrolled. + */ + defaultExpanded?: boolean; + /** + * Function called when the expansion state of the sidenav changes. + */ + onExpandedChange?: (value: boolean) => void; + /** + * The additional content rendered in the upper part of the sidenav, under the branding. + * It can also be a function that receives the expansion state to render different content based on it. */ - title?: string; + topContent?: ReactElement; /** - * The additional content rendered inside the sidenav. + * The content rendered in the bottom part of the sidenav, under the navigation menu. * It can also be a function that receives the expansion state to render different content based on it. */ - children?: React.ReactNode | ((expanded: boolean) => React.ReactNode); + bottomContent?: ReactElement; /** - * Array of items to be displayed in the Nav menu. + * Array of items to be displayed in the navigation menu. * Each item can be a single/simple item, a group item or a section. */ navItems?: (Item | GroupItem)[] | Section[]; /** * Object with the properties of the logo placed at the top of the sidenav. */ - logo?: Logo | ReactElement; + branding?: { logo?: Logo; appTitle?: string } | ReactElement; /** * If true the nav menu will have lines marking the groups. */ From e133d79a0091169d7c0f27cb0184e45eeaf330dd Mon Sep 17 00:00:00 2001 From: Mil4n0r <morenocarmonaenrique@gmail.com> Date: Thu, 6 Nov 2025 17:06:02 +0100 Subject: [PATCH 29/36] Fixed problem with build --- apps/website/pages/_app.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/website/pages/_app.tsx b/apps/website/pages/_app.tsx index d3f47ba687..e1526d55ab 100644 --- a/apps/website/pages/_app.tsx +++ b/apps/website/pages/_app.tsx @@ -102,7 +102,6 @@ export default function App({ Component, pageProps, emotionCache = clientSideEmo const filteredSections = useMemo(() => { const sections = mapLinksToGroupItems(LinksSections); - console.log("SECTIONS", sections); return filterSections(sections, filter); }, [filter]); From 39ab15fe392c63f7127cf12b2291f770a782220f Mon Sep 17 00:00:00 2001 From: Mil4n0r <morenocarmonaenrique@gmail.com> Date: Fri, 7 Nov 2025 10:33:19 +0100 Subject: [PATCH 30/36] Added section support for responsive mode --- apps/website/screens/common/StatusBadge.tsx | 40 ++++++++++++++++----- packages/lib/src/base-menu/Section.tsx | 10 ++---- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/apps/website/screens/common/StatusBadge.tsx b/apps/website/screens/common/StatusBadge.tsx index 6a1e30a646..1702b18310 100644 --- a/apps/website/screens/common/StatusBadge.tsx +++ b/apps/website/screens/common/StatusBadge.tsx @@ -4,6 +4,7 @@ import { ComponentStatus } from "./pagesList"; type StatusBadgeProps = { hasTitle?: boolean; status: ComponentStatus | "required"; + // reduced?: boolean; }; const getBadgeColor = (status: StatusBadgeProps["status"]) => { @@ -40,13 +41,36 @@ const getBadgeTitle = (status: StatusBadgeProps["status"]) => { } }; -const StatusBadge = ({ hasTitle = false, status }: StatusBadgeProps) => ( - <DxcBadge - label={status[0]?.toUpperCase() + status.slice(1)} - color={getBadgeColor(status)} - title={hasTitle ? getBadgeTitle(status) : undefined} - size="small" - /> -); +// TODO: enable icon when the status badge supports reduced version +// const getBadgeIcon = (status: StatusBadgeProps["status"]) => { +// switch (status) { +// case "required": +// return "warning_amber"; +// case "experimental": +// return "science"; +// case "new": +// return "new_releases"; +// case "stable": +// return "check_circle"; +// case "legacy": +// return "history"; +// case "deprecated": +// return "highlight_off"; +// default: +// return ""; +// } +// }; + +const StatusBadge = ({ hasTitle = false, status }: StatusBadgeProps) => { + return ( + <DxcBadge + label={status[0]?.toUpperCase() + status.slice(1)} + color={getBadgeColor(status)} + title={hasTitle ? getBadgeTitle(status) : undefined} + size="small" + // icon={reduced ? getBadgeIcon(status) : undefined} + /> + ); +}; export default StatusBadge; diff --git a/packages/lib/src/base-menu/Section.tsx b/packages/lib/src/base-menu/Section.tsx index 68708811db..5981b34906 100644 --- a/packages/lib/src/base-menu/Section.tsx +++ b/packages/lib/src/base-menu/Section.tsx @@ -24,9 +24,9 @@ const Title = styled.h2` export default function Section({ index, length, section }: SectionProps) { const id = `section-${useId()}`; const { responsiveView } = useContext(BaseMenuContext) ?? {}; - return !responsiveView ? ( + return ( <SectionContainer aria-label={section.title ?? id} aria-labelledby={id}> - {section.title && <Title id={id}>{section.title}} + {!responsiveView && section.title && {section.title}} {section.items.map((item, i) => ( @@ -38,11 +38,5 @@ export default function Section({ index, length, section }: SectionProps) { )} - ) : ( - - {section.items.map((item, i) => ( - - ))} - ); } From 04b1de31f5df50a4fdb334f4942b4f48bb8c88cc Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Fri, 7 Nov 2025 14:39:26 +0100 Subject: [PATCH 31/36] Added documentation and sorted props --- apps/website/pages/_app.tsx | 4 +- .../components/sidenav/SidenavPageLayout.tsx | 2 +- .../sidenav/code/SidenavCodePage.tsx | 399 +++++------------- .../sidenav/overview/SidenavOverviewPage.tsx | 1 + packages/lib/src/sidenav/types.ts | 46 +- 5 files changed, 139 insertions(+), 313 deletions(-) diff --git a/apps/website/pages/_app.tsx b/apps/website/pages/_app.tsx index e1526d55ab..b0e62eec5c 100644 --- a/apps/website/pages/_app.tsx +++ b/apps/website/pages/_app.tsx @@ -116,7 +116,7 @@ export default function App({ Component, pageProps, emotionCache = clientSideEmo navItems={filteredSections} branding={} topContent={ - isExpanded ? ( + isExpanded && ( - ) : ( - <> ) } expanded={isExpanded} diff --git a/apps/website/screens/components/sidenav/SidenavPageLayout.tsx b/apps/website/screens/components/sidenav/SidenavPageLayout.tsx index 10fc9973b4..0ce9cf71ab 100644 --- a/apps/website/screens/components/sidenav/SidenavPageLayout.tsx +++ b/apps/website/screens/components/sidenav/SidenavPageLayout.tsx @@ -10,7 +10,7 @@ const SidenavPageHeading = ({ children }: { children: ReactNode }) => { { label: "Overview", path: "/components/sidenav" }, { label: "Code", path: "/components/sidenav/code" }, ]; - + // TODO: UPDATE DESCRIPTION return ( diff --git a/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx b/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx index 1bd4af40e8..1cb2135c1f 100644 --- a/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx +++ b/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx @@ -1,9 +1,41 @@ import DocFooter from "@/common/DocFooter"; import QuickNavContainer from "@/common/QuickNavContainer"; -import StatusBadge from "@/common/StatusBadge"; -import Code, { TableCode } from "@/common/Code"; -import { DxcLink, DxcFlex, DxcTable, DxcParagraph } from "@dxc-technology/halstack-react"; -import Link from "next/link"; +import Code, { ExtendedTableCode, TableCode } from "@/common/Code"; +import { DxcFlex, DxcTable } from "@dxc-technology/halstack-react"; + +const brandingTypeString = `{ + logo?: Logo; + appTitle?: string; +}`; + +const logoTypeString = `{ + alt: string; + href?: string; + onClick?: (event: MouseEvent) => void; + src: string; +}`; + +const commonItemTypeString = `{ + badge?: ReactElement; + icon?: string | SVG; + label: string; +}`; + +const itemTypeString = `{ + ${commonItemTypeString} + onSelect?: () => void; + selected?: boolean; +}`; + +const groupItemTypeString = `{ + ${commonItemTypeString} + items: (Item | GroupItem)[]; +}`; + +const sectionTypeString = `{ + items: (Item | GroupItem)[]; + title?: string }; +}`; const sections = [ { @@ -20,305 +52,102 @@ const sections = [ + bottomContent + + React.ReactNode + + The content rendered in the bottom part of the sidenav, under the navigation menu. + - + + + branding - - - children - + {"Logo | ReactNode"} +

+ being Message an object with the following properties: +

+ {brandingTypeString} +

+ and Logo an object with the following properties: +

+ {logoTypeString} + Object with the properties of the branding placed at the top of the sidenav. + - + + + defaultExpanded - React.ReactNode + boolean + + Initial state of the expansion of the sidenav, only when it is uncontrolled. + - + + + displayGroupLines + + boolean + + If true the nav menu will have lines marking the groups. + - + + + expanded + + boolean + + + If true, the sidenav is expanded. If undefined the component will be uncontrolled and the value will be + managed internally by the component. + + - + + + navItems + + {"(Item | GroupItem)[] | Section[]"} +

+ being Item an object with the following properties: +

+ {itemTypeString} +

+ , GroupItem an object with the following properties: +

+ {groupItemTypeString} +

+ and Section an object with the following properties: +

+ {sectionTypeString} + + + Array of items to be displayed in the navigation menu. Each item can be a single/simple item, a group item + or a section. + + - + + + onExpandedChange + + {"(value: boolean) => void"} - The area inside the sidenav. + Function called when the expansion state of the sidenav changes. - - title + topContent React.ReactNode - The area assigned to render the title. It is highly recommended to use the sidenav title. + The content rendered in the upper part of the sidenav, under the branding. - ), }, - { - title: "DxcSidenav.Title", - content: ( - - This compound component should only be used inside the title prop. - - ), - subSections: [ - { - title: "Props", - content: ( - - - - Name - Type - Description - Default - - - - - - - - children - - - - React.ReactNode - - The area inside the sidenav title. This area can be used to render custom content. - - - - - - ), - }, - ], - }, - { - title: "DxcSidenav.Section", - content: ( - - Sections must be defined as direct children of the DxcSidenav and serve to group links, groups - and/or custom content into different and distinguishable parts of the component. Consecutive sections are - separated by a divider. - - ), - subSections: [ - { - title: "Props", - content: ( - - - - Name - Type - Description - Default - - - - - - - - children - - - - React.ReactNode - - The area inside the sidenav section. Child items will be stacked inside a flex container. - - - - - - ), - }, - ], - }, - { - title: "DxcSidenav.Group", - content: ( - - Even though any children are accepted in a group, we recommend using only the DxcSidenav.Link or - any React-based router, complemented with this one, as links to the different pages. - - ), - subSections: [ - { - title: "Props", - content: ( - - - - Name - Type - Description - Default - - - - - - - - children - - - - React.ReactNode - - The area inside the sidenav group. This area can be used to render sidenav links. - - - - - collapsable - - boolean - - - If true, the sidenav group will be a button that will allow you to collapse the links contained within - it. In addition, if it's collapsed and contains the currently selected link, the group title will also - be marked as selected. - - - false - - - - icon - - string | {"(React.ReactNode & React.SVGProps )"} - - - A{" "} - - Material Symbol - {" "} - or a SVG element to be displayed next to the title of the group as an icon. - - - - - - title - - string - - The title of the sidenav group. - - - - - - ), - }, - ], - }, - { - title: "DxcSidenav.Link", - content: ( - - As with the DxcLink component, we decided to make our Sidenav link component a styled HTML anchor - that can be used with any React-based router. You can check the{" "} - - Link - {" "} - for more information regarding this. - - ), - subSections: [ - { - title: "Props", - content: ( - - - - Name - Type - Description - Default - - - - - - - - children - - - - React.ReactNode - - The area inside the sidenav link. - - - - - href - - string - - Page to be opened when the user clicks on the link. - - - - - icon - - string | {"(React.ReactNode & React.SVGProps )"} - - - A{" "} - - Material Symbol - {" "} - or a SVG element to be displayed left to the link as an icon. - - - - - - newWindow - - boolean - - If true, the page is opened in a new browser tab. - - false - - - - onClick - - {"(event: React.MouseEvent ) => void"} - - - This function will be called when the user clicks the link and the event will be passed to this - function. - - - - - - selected - - boolean - - - If true, the link will be marked as selected. Moreover, in that same case, if it is contained within a - collapsed group, and consequently, the currently selected link is not visible, the group title will - appear as selected too. - - - false - - - - tabIndex - - number - - - Value of the tabindex attribute. - - - 0 - - - - - ), - }, - ], - }, { title: "Examples", + // TODO: Update the sandbox link subSections: [ { title: "Application layout with sidenav", diff --git a/apps/website/screens/components/sidenav/overview/SidenavOverviewPage.tsx b/apps/website/screens/components/sidenav/overview/SidenavOverviewPage.tsx index cd856a9694..7e28f0a8fb 100644 --- a/apps/website/screens/components/sidenav/overview/SidenavOverviewPage.tsx +++ b/apps/website/screens/components/sidenav/overview/SidenavOverviewPage.tsx @@ -5,6 +5,7 @@ import Image from "@/common/Image"; import anatomy from "./images/sidenav_anatomy.png"; import responsive from "./images/sidenav_responsive.png"; +// TODO: UPDATE WHEN DOC IS READY const sections = [ { title: "Introduction", diff --git a/packages/lib/src/sidenav/types.ts b/packages/lib/src/sidenav/types.ts index a09161d8bf..4cdfc4b959 100644 --- a/packages/lib/src/sidenav/types.ts +++ b/packages/lib/src/sidenav/types.ts @@ -1,64 +1,62 @@ -import { MouseEvent, ReactElement } from "react"; +import { MouseEvent, ReactElement, ReactNode } from "react"; import { SVG } from "../common/utils"; 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 when the logo is clicked. + */ + href?: string; /** * URL to navigate to when the logo is clicked. If not provided, the logo will not be clickable. */ onClick?: (event: MouseEvent) => void; /** - * URL to navigate when the logo is clicked. + * URL of the image that will be placed in the logo. */ - href?: string; + src: string; }; type Section = { items: (Item | GroupItem)[]; title?: string }; type Props = { /** - * If true, the sidenav is expanded. - * If undefined the component will be uncontrolled and the value will be managed internally by the component. + * The content rendered in the bottom part of the sidenav, under the navigation menu. */ - expanded?: boolean; + bottomContent?: ReactNode; /** - * Initial state of the expansion of the sidenav, only when it is uncontrolled. + * Object with the properties of the branding placed at the top of the sidenav. */ - defaultExpanded?: boolean; + branding?: { logo?: Logo; appTitle?: string } | ReactNode; /** - * Function called when the expansion state of the sidenav changes. + * Initial state of the expansion of the sidenav, only when it is uncontrolled. */ - onExpandedChange?: (value: boolean) => void; + defaultExpanded?: boolean; /** - * The additional content rendered in the upper part of the sidenav, under the branding. - * It can also be a function that receives the expansion state to render different content based on it. + * If true the nav menu will have lines marking the groups. */ - topContent?: ReactElement; + displayGroupLines?: boolean; /** - * The content rendered in the bottom part of the sidenav, under the navigation menu. - * It can also be a function that receives the expansion state to render different content based on it. + * If true, the sidenav is expanded. + * If undefined the component will be uncontrolled and the value will be managed internally by the component. */ - bottomContent?: ReactElement; + expanded?: boolean; /** * Array of items to be displayed in the navigation menu. * Each item can be a single/simple item, a group item or a section. */ navItems?: (Item | GroupItem)[] | Section[]; /** - * Object with the properties of the logo placed at the top of the sidenav. + * Function called when the expansion state of the sidenav changes. */ - branding?: { logo?: Logo; appTitle?: string } | ReactElement; + onExpandedChange?: (value: boolean) => void; /** - * If true the nav menu will have lines marking the groups. + * The additional content rendered in the upper part of the sidenav, under the branding. */ - displayGroupLines?: boolean; + topContent?: ReactNode; }; type CommonItemProps = { From a59350f1358351bc2c9fad7d52c5906aac644a49 Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Mon, 10 Nov 2025 10:53:28 +0100 Subject: [PATCH 32/36] Fixed typing problem with new branding --- packages/lib/src/sidenav/Sidenav.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx index 92b4d1cecb..8581d5c862 100644 --- a/packages/lib/src/sidenav/Sidenav.tsx +++ b/packages/lib/src/sidenav/Sidenav.tsx @@ -65,10 +65,7 @@ const DxcSidenav = ({ }; const isBrandingObject = (branding: SidenavPropsType["branding"]): branding is { logo?: Logo; appTitle?: string } => { - return ( - (typeof branding === "object" && branding !== null && "logo" in branding) || - (!!branding && "appTitle" in branding) - ); + return typeof branding === "object" && branding !== null && ("logo" in branding || "appTitle" in branding); }; return ( From 113220895e68d0a7b4803641a040f5df337dd3fc Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Tue, 11 Nov 2025 16:29:09 +0100 Subject: [PATCH 33/36] Temporally removed doc overview from sidenav --- .../website/pages/components/sidenav/code.tsx | 28 ++++++++--------- .../pages/components/sidenav/index.tsx | 31 +++++++++++++++---- .../components/sidenav/SidenavPageLayout.tsx | 7 +++-- 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/apps/website/pages/components/sidenav/code.tsx b/apps/website/pages/components/sidenav/code.tsx index 9bb8d2993a..fbab950b0c 100644 --- a/apps/website/pages/components/sidenav/code.tsx +++ b/apps/website/pages/components/sidenav/code.tsx @@ -1,17 +1,17 @@ -import Head from "next/head"; -import type { ReactElement } from "react"; -import SidenavPageLayout from "screens/components/sidenav/SidenavPageLayout"; -import SidenavCodePage from "screens/components/sidenav/code/SidenavCodePage"; +// import Head from "next/head"; +// import type { ReactElement } from "react"; +// import SidenavPageLayout from "screens/components/sidenav/SidenavPageLayout"; +// import SidenavCodePage from "screens/components/sidenav/code/SidenavCodePage"; -const Code = () => ( - <> - - Sidenav code — Halstack Design System - - - -); +// const Code = () => ( +// <> +// +// Sidenav code — Halstack Design System +// +// +// +// ); -Code.getLayout = (page: ReactElement) => {page}; +// Code.getLayout = (page: ReactElement) => {page}; -export default Code; +// export default Code; diff --git a/apps/website/pages/components/sidenav/index.tsx b/apps/website/pages/components/sidenav/index.tsx index 50ec17a206..8e099c450b 100644 --- a/apps/website/pages/components/sidenav/index.tsx +++ b/apps/website/pages/components/sidenav/index.tsx @@ -1,17 +1,36 @@ +// import Head from "next/head"; +// import type { ReactElement } from "react"; +// import SidenavPageLayout from "screens/components/sidenav/SidenavPageLayout"; +// import SidenavOverviewPage from "screens/components/sidenav/overview/SidenavOverviewPage"; + +// const Index = () => ( +// <> +// +// Sidenav — Halstack Design System +// +// {/* */} +// +// +// ); + +// Index.getLayout = (page: ReactElement) => {page}; + +// export default Index; + import Head from "next/head"; import type { ReactElement } from "react"; import SidenavPageLayout from "screens/components/sidenav/SidenavPageLayout"; -import SidenavOverviewPage from "screens/components/sidenav/overview/SidenavOverviewPage"; +import SidenavCodePage from "screens/components/sidenav/code/SidenavCodePage"; -const Index = () => ( +const Code = () => ( <> - Sidenav — Halstack Design System + Sidenav code — Halstack Design System - + ); -Index.getLayout = (page: ReactElement) => {page}; +Code.getLayout = (page: ReactElement) => {page}; -export default Index; +export default Code; diff --git a/apps/website/screens/components/sidenav/SidenavPageLayout.tsx b/apps/website/screens/components/sidenav/SidenavPageLayout.tsx index 0ce9cf71ab..fb56162709 100644 --- a/apps/website/screens/components/sidenav/SidenavPageLayout.tsx +++ b/apps/website/screens/components/sidenav/SidenavPageLayout.tsx @@ -7,10 +7,11 @@ import { ReactNode } from "react"; const SidenavPageHeading = ({ children }: { children: ReactNode }) => { const tabs = [ - { label: "Overview", path: "/components/sidenav" }, - { label: "Code", path: "/components/sidenav/code" }, + // { label: "Overview", path: "/components/sidenav" }, + // { label: "Code", path: "/components/sidenav/code" }, + { label: "Code", path: "/components/sidenav" }, ]; - // TODO: UPDATE DESCRIPTION + // TODO: UPDATE DESCRIPTION WHEN OVERVIEW IS ADDED return ( From d4d4cbb9c346e000d90dcef0f5e187e9ba410152 Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Tue, 11 Nov 2025 16:49:36 +0100 Subject: [PATCH 34/36] Removed redundant GroupItem --- .../lib/src/navigation-tree/GroupItem.tsx | 85 ------------------- 1 file changed, 85 deletions(-) delete mode 100644 packages/lib/src/navigation-tree/GroupItem.tsx diff --git a/packages/lib/src/navigation-tree/GroupItem.tsx b/packages/lib/src/navigation-tree/GroupItem.tsx deleted file mode 100644 index f8c87e19cf..0000000000 --- a/packages/lib/src/navigation-tree/GroupItem.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useContext, useId } from "react"; -import DxcIcon from "../icon/Icon"; -import { GroupItemProps } from "./types"; -import * as Popover from "@radix-ui/react-popover"; -import { useGroupItem } from "../base-menu/useGroupItem"; -import NavigationTreeContext from "./NavigationTreeContext"; -import ItemAction from "../base-menu/ItemAction"; -import SubMenu from "../base-menu/SubMenu"; -import MenuItem from "../base-menu/MenuItem"; - -const GroupItem = ({ items, ...props }: GroupItemProps) => { - const groupMenuId = `group-menu-${useId()}`; - - const NavigationTreeId = `sidenav-${useId()}`; - const contextValue = useContext(NavigationTreeContext) ?? {}; - const { groupSelected, isOpen, toggleOpen, responsiveView } = useGroupItem(items, contextValue); - - return responsiveView ? ( - <> - - - : } - onClick={() => toggleOpen()} - selected={groupSelected && !isOpen} - {...props} - /> - - - - { - event.preventDefault(); - }} - onOpenAutoFocus={(event) => { - event.preventDefault(); - }} - align="start" - side="right" - style={{ zIndex: "var(--z-contextualmenu)" }} - > - - {items.map((item, index) => ( - - ))} - - - - - -
- - ) : ( - <> - : } - onClick={() => toggleOpen()} - selected={groupSelected && !isOpen} - {...props} - /> - {isOpen && ( - - {items.map((item, index) => ( - - ))} - - )} - - ); -}; - -export default GroupItem; From eb921cae761b158ad6a5e0228b7d242ea0833e9e Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Tue, 11 Nov 2025 17:07:33 +0100 Subject: [PATCH 35/36] Removed original code page --- apps/website/pages/components/sidenav/code.tsx | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 apps/website/pages/components/sidenav/code.tsx diff --git a/apps/website/pages/components/sidenav/code.tsx b/apps/website/pages/components/sidenav/code.tsx deleted file mode 100644 index fbab950b0c..0000000000 --- a/apps/website/pages/components/sidenav/code.tsx +++ /dev/null @@ -1,17 +0,0 @@ -// import Head from "next/head"; -// import type { ReactElement } from "react"; -// import SidenavPageLayout from "screens/components/sidenav/SidenavPageLayout"; -// import SidenavCodePage from "screens/components/sidenav/code/SidenavCodePage"; - -// const Code = () => ( -// <> -// -// Sidenav code — Halstack Design System -// -// -// -// ); - -// Code.getLayout = (page: ReactElement) => {page}; - -// export default Code; From 6e4919977fe6a09d57ea59e7fecd3499f3702dba Mon Sep 17 00:00:00 2001 From: Mil4n0r Date: Tue, 11 Nov 2025 17:21:52 +0100 Subject: [PATCH 36/36] Fixed example from ApplicationLayout stories --- packages/lib/src/layout/ApplicationLayout.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lib/src/layout/ApplicationLayout.stories.tsx b/packages/lib/src/layout/ApplicationLayout.stories.tsx index 4e4048fc41..446b2f508d 100644 --- a/packages/lib/src/layout/ApplicationLayout.stories.tsx +++ b/packages/lib/src/layout/ApplicationLayout.stories.tsx @@ -72,7 +72,7 @@ const ApplicationLayoutResponsiveSidenav = () => ( (!expanded ?

Responsive Content

: <>)} + defaultExpanded={false} /> } >