diff --git a/apps/website/pages/_app.tsx b/apps/website/pages/_app.tsx index fd97164cc..b0e62eec5 100644 --- a/apps/website/pages/_app.tsx +++ b/apps/website/pages/_app.tsx @@ -1,17 +1,20 @@ -import { ReactElement, ReactNode, useMemo, useState } 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, DxcTextInput, DxcToastsQueue } from "@dxc-technology/halstack-react"; -import SidenavLogo from "@/common/sidenav/SidenavLogo"; 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"; +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; @@ -26,73 +29,110 @@ 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(); + const router = useRouter(); + const pathname = usePathname(); 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 [isExpanded, setIsExpanded] = useState(true); + + 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; + }, []); - const matchPaths = (linkPath: string) => { - const desiredPaths = [linkPath, `${linkPath}/code`]; - const pathToBeMatched = currentPath?.split("#")[0]?.slice(0, -1); - return pathToBeMatched ? desiredPaths.includes(pathToBeMatched) : false; + return labelMatches || items.length ? { ...item, items } : null; + }; + + return sections.reduce((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); + return pathToBeMatched ? desiredPaths.includes(pathToBeMatched) : false; + }; + + return sections.map((section) => ({ + title: section.label, + items: section.links.map((link) => ({ + label: link.label, + href: link.path, + selected: matchPaths(link.path), + ...(link.status && { + badge: link.status !== "stable" ? : undefined, + }), + renderItem: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + })), + })); + }; + + 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 NEW CATEGORIZATION + + const filteredSections = useMemo(() => { + const sections = mapLinksToGroupItems(LinksSections); + return filterSections(sections, filter); + }, [filter]); + return ( }> - - { - setFilter(value); - }} - size="fillParent" - clearable - margin={{ - top: "large", - bottom: "large", - right: "medium", - left: "medium", - }} - /> - - {filteredLinks?.map(({ label, links }) => ( - - - {links.map(({ label, path, status }) => ( - - - {label} - {status && status !== "stable" && } - - - ))} - - - ))} - - - GitHub - - - + } + topContent={ + isExpanded && ( + { + setFilter(value); + }} + size="fillParent" + clearable + /> + ) + } + expanded={isExpanded} + onExpandedChange={() => { + setIsExpanded((currentlyExpanded) => !currentlyExpanded); + }} + /> } > diff --git a/apps/website/pages/components/sidenav/code.tsx b/apps/website/pages/components/sidenav/code.tsx deleted file mode 100644 index 9bb8d2993..000000000 --- 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; diff --git a/apps/website/pages/components/sidenav/index.tsx b/apps/website/pages/components/sidenav/index.tsx index 50ec17a20..8e099c450 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/common/StatusBadge.tsx b/apps/website/screens/common/StatusBadge.tsx index 6a1e30a64..1702b1831 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) => ( - -); +// 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 ( + + ); +}; export default StatusBadge; diff --git a/apps/website/screens/common/sidenav/SidenavLogo.tsx b/apps/website/screens/common/sidenav/SidenavLogo.tsx index 8be159388..286f78e9b 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 ? ( @@ -47,6 +47,14 @@ const SidenavLogo = ({ subtitle = "Design System" }: { subtitle?: string }) => { size="small" /> + ) : ( + Halstack logo ); }; diff --git a/apps/website/screens/components/application-layout/code/ApplicationLayoutCodePage.tsx b/apps/website/screens/components/application-layout/code/ApplicationLayoutCodePage.tsx index c115d03a5..c06008ad6 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 = () => ( - - - visibilityToggleLabel - - string - - Text to be placed next to the hamburger button that toggles the visibility of the sidenav. - - - ); @@ -100,16 +92,6 @@ const sections = [ ), }, - { - title: "DxcApplicationLayout.useResponsiveSidenavVisibility", - content: ( - - 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). - - ), - }, { 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 f6e796569..53e783da3 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 = `{ @@ -80,6 +80,7 @@ const sections = [ title: "Action menu", content: , }, + // TODO: We should remove this example as it is not the intended usage right? (Navigation is handled inside ApplicationLayout) { title: "Navigation menu", content: , diff --git a/apps/website/screens/components/sidenav/SidenavPageLayout.tsx b/apps/website/screens/components/sidenav/SidenavPageLayout.tsx index 10fc9973b..fb5616270 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 WHEN OVERVIEW IS ADDED return ( diff --git a/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx b/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx index 1bd4af40e..1cb2135c1 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 cd856a969..7e28f0a8f 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/base-menu/BaseMenuContext.tsx b/packages/lib/src/base-menu/BaseMenuContext.tsx new file mode 100644 index 000000000..cbeb62d6f --- /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 000000000..db9c6b328 --- /dev/null +++ b/packages/lib/src/base-menu/GroupItem.tsx @@ -0,0 +1,86 @@ +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); + + // TODO: SET A FIXED WIDTH TO PREVENT MOVING CONTENT WHEN EXPANDING/COLLAPSING IN RESPONSIVEVIEW + 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/base-menu/ItemAction.tsx b/packages/lib/src/base-menu/ItemAction.tsx new file mode 100644 index 000000000..681e24bbf --- /dev/null +++ b/packages/lib/src/base-menu/ItemAction.tsx @@ -0,0 +1,141 @@ +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 { useItemAction } from "./useItemAction"; + +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((props, ref) => { + const { + hasTooltip, + modifiedBadge, + displayControlsAfter, + responsiveView, + displayGroupLines, + handleTextMouseEnter, + getWrapper, + } = useItemAction(props); + const { depthLevel, selected, href, label, icon, collapseIcon, "aria-pressed": ariaPressed, ...rest } = props; + + return getWrapper( + + + + {!responsiveView && (modifiedBadge || (displayControlsAfter && collapseIcon)) && ( + + {modifiedBadge} + {displayControlsAfter && collapseIcon && {collapseIcon}} + + )} + + + ); + }) +); + +ItemAction.displayName = "ItemAction"; + +export default ItemAction; diff --git a/packages/lib/src/contextual-menu/MenuItem.tsx b/packages/lib/src/base-menu/MenuItem.tsx similarity index 88% rename from packages/lib/src/contextual-menu/MenuItem.tsx rename to packages/lib/src/base-menu/MenuItem.tsx index 65aadf7f1..b70663a48 100644 --- a/packages/lib/src/contextual-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 ( - {"items" in item ? ( + {isGroupItem(item) ? ( ) : ( diff --git a/packages/lib/src/contextual-menu/Section.tsx b/packages/lib/src/base-menu/Section.tsx similarity index 77% rename from packages/lib/src/contextual-menu/Section.tsx rename to packages/lib/src/base-menu/Section.tsx index 8cade2fba..5981b3490 100644 --- a/packages/lib/src/contextual-menu/Section.tsx +++ b/packages/lib/src/base-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 BaseMenuContext from "./BaseMenuContext"; +import DxcInset from "../inset/Inset"; +import DxcDivider from "../divider/Divider"; const SectionContainer = styled.section` display: grid; @@ -22,11 +23,11 @@ const Title = styled.h2` export default function Section({ index, length, section }: SectionProps) { const id = `section-${useId()}`; - + const { responsiveView } = useContext(BaseMenuContext) ?? {}; return ( - {section.title && {section.title}} - + {!responsiveView && section.title && {section.title}} + {section.items.map((item, i) => ( ))} diff --git a/packages/lib/src/base-menu/SingleItem.tsx b/packages/lib/src/base-menu/SingleItem.tsx new file mode 100644 index 000000000..f6271af76 --- /dev/null +++ b/packages/lib/src/base-menu/SingleItem.tsx @@ -0,0 +1,28 @@ +import { useContext, useEffect } from "react"; +import ItemAction from "./ItemAction"; +import { SingleItemProps } from "./types"; +import BaseMenuContext from "./BaseMenuContext"; + +export default function SingleItem({ id, onSelect, selected = false, ...props }: SingleItemProps) { + const { selectedItemId, setSelectedItemId } = useContext(BaseMenuContext) ?? {}; + + const handleClick = () => { + setSelectedItemId?.(id); + onSelect?.(); + }; + + useEffect(() => { + if (selectedItemId === -1 && selected) { + setSelectedItemId?.(id); + } + }, [selectedItemId, selected, id]); + + return ( + + ); +} diff --git a/packages/lib/src/base-menu/SubMenu.tsx b/packages/lib/src/base-menu/SubMenu.tsx new file mode 100644 index 000000000..a0414a3d2 --- /dev/null +++ b/packages/lib/src/base-menu/SubMenu.tsx @@ -0,0 +1,29 @@ +import styled from "@emotion/styled"; +import { SubMenuProps } from "./types"; +import BaseMenuContext from "./BaseMenuContext"; +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(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 000000000..1ef9fc25b --- /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 000000000..c8997ec3f --- /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 000000000..dda97fa10 --- /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 97% rename from packages/lib/src/contextual-menu/utils.ts rename to packages/lib/src/base-menu/utils.ts index 3dfe2fb6d..77db32b03 100644 --- a/packages/lib/src/contextual-menu/utils.ts +++ b/packages/lib/src/base-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/contextual-menu/ContextualMenu.stories.tsx b/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx index f174fbabe..54ec2c90e 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"; @@ -42,7 +42,7 @@ const groupItems = [ icon: "bookmark", badge: , }, - { 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 59af5b6fd..06958825d 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(); @@ -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(); diff --git a/packages/lib/src/contextual-menu/ContextualMenu.tsx b/packages/lib/src/contextual-menu/ContextualMenu.tsx index 13f58b417..42405bea4 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 ba794fd61..000000000 --- a/packages/lib/src/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 && ( - - {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 747681996..000000000 --- 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/SingleItem.tsx b/packages/lib/src/contextual-menu/SingleItem.tsx deleted file mode 100644 index 5fcd304d9..000000000 --- a/packages/lib/src/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/contextual-menu/SubMenu.tsx b/packages/lib/src/contextual-menu/SubMenu.tsx deleted file mode 100644 index 70c003006..000000000 --- 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 e9599a7f8..83d6b0a53 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; - 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[]; -}; - -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, @@ -67,6 +33,5 @@ export type { SectionWithId, SectionProps, SingleItemProps, + Props as default, }; - -export default Props; diff --git a/packages/lib/src/layout/ApplicationLayout.stories.tsx b/packages/lib/src/layout/ApplicationLayout.stories.tsx index bfd20f7f0..446b2f508 100644 --- a/packages/lib/src/layout/ApplicationLayout.stories.tsx +++ b/packages/lib/src/layout/ApplicationLayout.stories.tsx @@ -22,25 +22,37 @@ 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 = () => ( <> - Application layout with push sidenav - - } - > - -

SideNav Content

-

SideNav Content

-

SideNav Content

-

SideNav Content

-

SideNav Content

-
- + } > @@ -56,23 +68,12 @@ const ApplicationLayoutDefaultSidenav = () => ( const ApplicationLayoutResponsiveSidenav = () => ( <> - Application layout with push sidenav - - } - > - -

SideNav Content

-

SideNav Content

-

SideNav Content

-

SideNav Content

-

SideNav Content

-
-
+ } > @@ -90,21 +91,10 @@ const ApplicationLayoutCustomHeader = () => ( Custom Header

} sidenav={ - - Application layout with push sidenav - - } - > - -

SideNav Content

-

SideNav Content

-

SideNav Content

-

SideNav Content

-

SideNav Content

-
-
+ } > @@ -122,21 +112,10 @@ const ApplicationLayoutCustomFooter = () => ( Custom Footer

} sidenav={ - - Application layout with push sidenav - - } - > - -

SideNav Content

-

SideNav Content

-

SideNav Content

-

SideNav Content

-

SideNav Content

-
-
+ } > @@ -152,11 +131,7 @@ const ApplicationLayoutCustomFooter = () => ( const Tooltip = () => ( - -

SideNav Content

-
-
+ } > @@ -181,6 +156,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 +183,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 59bceafef..72808a608 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 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 =>
{children}
; -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 ( - - - {header ?? } - {sidenav && isResponsive && ( - - - - - {visibilityToggleLabel} - - - - )} - - + + {header ?? } - - {sidenav && (isResponsive ? isSidenavVisibleResponsive : true) && ( - {sidenav} - )} - + {sidenav && {sidenav}} {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 45d151c95..a00cddee6 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/navigation-tree/NavigationTree.accessibility.test.tsx b/packages/lib/src/navigation-tree/NavigationTree.accessibility.test.tsx new file mode 100644 index 000000000..ca6231b0f --- /dev/null +++ b/packages/lib/src/navigation-tree/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 = ( + + + + + +); + +const keyIcon = ( + + + +); + +const favIcon = ( + + + +); + +const itemsWithTruncatedText = [ + { + label: "Item with a very long label that should be truncated", + slot: , + icon: keyIcon, + }, + { + label: "Item 2", + slot: ( + + + + ), + icon: favIcon, + }, +]; + +const items = [ + { + title: "Business services", + items: [ + { + label: "Home", + icon: "home", + items: [ + { label: "Data & statistics" }, + { + label: "Apps", + items: [ + { + label: "Sales data module", + badge: , + }, + { 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(); + 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(); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); +}); diff --git a/packages/lib/src/navigation-tree/NavigationTree.stories.tsx b/packages/lib/src/navigation-tree/NavigationTree.stories.tsx new file mode 100644 index 000000000..3744fe823 --- /dev/null +++ b/packages/lib/src/navigation-tree/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 { 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", + component: DxcNavigationTree, +} satisfies Meta; + +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: , + }, + { label: "Selected Item 3", selected: true }, + ], + }, + ], + badge: , + }, + { 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: ( + + + + ), + }, + { + label: "Item 2", + icon: "star", + }, +]; + +const itemsWithBadge = [ + { + label: "Item 1", + badge: , + }, + { + label: "Item 2", + badge: , + }, +]; + +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: , + icon: ( + + + + ), + }, + { + label: "Item 2", + icon: "favorite", + }, +]; + +const NavigationTree = () => ( + <> + + <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/navigation-tree/NavigationTree.test.tsx b/packages/lib/src/navigation-tree/NavigationTree.test.tsx new file mode 100644 index 000000000..643172d3c --- /dev/null +++ b/packages/lib/src/navigation-tree/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/navigation-tree/NavigationTree.tsx b/packages/lib/src/navigation-tree/NavigationTree.tsx new file mode 100644 index 000000000..49c248dd0 --- /dev/null +++ b/packages/lib/src/navigation-tree/NavigationTree.tsx @@ -0,0 +1,83 @@ +import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import styled from "@emotion/styled"; +import MenuItem from "../base-menu/MenuItem"; +import NavigationTreePropsType, { GroupItemWithId, ItemWithId, SectionWithId } from "./types"; +import Section from "../base-menu/Section"; +import NavigationTreeContext from "../base-menu/BaseMenuContext"; +import scrollbarStyles from "../styles/scroll"; +import { addIdToItems, isSection } from "../base-menu/utils"; +import SubMenu from "../base-menu/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, +}: 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, + }), + [selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView] + ); + + 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/navigation-tree/NavigationTreeContext.tsx b/packages/lib/src/navigation-tree/NavigationTreeContext.tsx new file mode 100644 index 000000000..99fc7b12e --- /dev/null +++ b/packages/lib/src/navigation-tree/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/navigation-tree/types.ts b/packages/lib/src/navigation-tree/types.ts new file mode 100644 index 000000000..9afb43753 --- /dev/null +++ b/packages/lib/src/navigation-tree/types.ts @@ -0,0 +1,32 @@ +import Props, { + BaseMenuContextProps as NavigationTreeContextProps, + GroupItem, + GroupItemProps, + GroupItemWithId, + Item, + ItemActionProps, + ItemWithId, + SubMenuProps, + MenuItemProps, + Section, + SectionWithId, + SectionProps, + SingleItemProps, +} from "../base-menu/types"; + +export type { + GroupItem, + GroupItemProps, + GroupItemWithId, + Item, + ItemActionProps, + ItemWithId, + SubMenuProps, + MenuItemProps, + NavigationTreeContextProps, + Section, + SectionWithId, + SectionProps, + SingleItemProps, + Props as default, +}; diff --git a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx index 6d5b46eec..a12a6dc69 100644 --- a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx +++ b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx @@ -1,50 +1,63 @@ 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"; -const iconSVG = ( - <svg - version="1.1" - x="0px" - y="0px" - width="438.536px" - height="438.536px" - viewBox="0 0 438.536 438.536" - fill="currentColor" - > - <g> - <path - d="M414.41,24.123C398.333,8.042,378.963,0,356.315,0H82.228C59.58,0,40.21,8.042,24.126,24.123 -C8.045,40.207,0.003,59.576,0.003,82.225v274.084c0,22.647,8.042,42.018,24.123,58.102c16.084,16.084,35.454,24.126,58.102,24.126 -h274.084c22.648,0,42.018-8.042,58.095-24.126c16.084-16.084,24.126-35.454,24.126-58.102V82.225 -C438.532,59.576,430.49,40.204,414.41,24.123z M373.155,225.548h-49.963V406.84h-74.802V225.548H210.99V163.02h37.401v-37.402 -c0-26.838,6.283-47.107,18.843-60.813c12.559-13.706,33.304-20.555,62.242-20.555h49.963v62.526h-31.401 -c-10.663,0-17.467,1.853-20.417,5.568c-2.949,3.711-4.428,10.23-4.428,19.558v31.119h56.534L373.155,225.548z" - /> - </g> - </svg> -); +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 () => { + 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", selected: 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 { container } = render( - <DxcSidenav title="Title"> - <DxcSidenav.Section> - <p>nav-content-test</p> - <DxcSidenav.Link href="#" icon={iconSVG} selected> - Link - </DxcSidenav.Link> - </DxcSidenav.Section> - <DxcSidenav.Section> - <DxcSidenav.Group title="Collapsable" icon={iconSVG} 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> + <DxcSidenav + navItems={groupItems} + branding={{ + appTitle: "Application Name", + logo: { + src: "https://picsum.photos/id/1022/200/300", + alt: "Alt text", + }, + }} + /> ); const results = await axe(container); expect(results.violations).toHaveLength(0); diff --git a/packages/lib/src/sidenav/Sidenav.stories.tsx b/packages/lib/src/sidenav/Sidenav.stories.tsx index 121db6ef4..6efff792e 100644 --- a/packages/lib/src/sidenav/Sidenav.stories.tsx +++ b/packages/lib/src/sidenav/Sidenav.stories.tsx @@ -1,245 +1,519 @@ +import { Meta, StoryObj } from "@storybook/react-vite"; import Title from "../../.storybook/components/Title"; import ExampleContainer from "../../.storybook/components/ExampleContainer"; -import DxcInset from "../inset/Inset"; -import DxcSelect from "../select/Select"; import DxcSidenav from "./Sidenav"; -import { Meta, StoryObj } from "@storybook/react-vite"; +import DxcBadge from "../badge/Badge"; +import DxcFlex from "../flex/Flex"; +import DxcTypography from "../typography/Typography"; +import DxcButton from "../button/Button"; +import DxcAvatar from "../avatar/Avatar"; import { userEvent, within } from "storybook/internal/test"; +import disabledRules from "../../test/accessibility/rules/specific/sidenav/disabledRules"; +import preview from "../../.storybook/preview"; +import { useState } from "react"; export default { title: "Sidenav", component: DxcSidenav, + parameters: { + a11y: { + config: { + rules: [ + ...(preview?.parameters?.a11y?.config?.rules || []), + ...disabledRules.map((ruleId) => ({ id: ruleId, reviewOnFail: true })), + ], + }, + }, + }, } satisfies Meta<typeof DxcSidenav>; -const iconSVG = ( - <svg - version="1.1" - x="0px" - y="0px" - width="438.536px" - height="438.536px" - viewBox="0 0 438.536 438.536" - fill="currentColor" - > - <g> - <path - d="M414.41,24.123C398.333,8.042,378.963,0,356.315,0H82.228C59.58,0,40.21,8.042,24.126,24.123 -C8.045,40.207,0.003,59.576,0.003,82.225v274.084c0,22.647,8.042,42.018,24.123,58.102c16.084,16.084,35.454,24.126,58.102,24.126 -h274.084c22.648,0,42.018-8.042,58.095-24.126c16.084-16.084,24.126-35.454,24.126-58.102V82.225 -C438.532,59.576,430.49,40.204,414.41,24.123z M373.155,225.548h-49.963V406.84h-74.802V225.548H210.99V163.02h37.401v-37.402 -c0-26.838,6.283-47.107,18.843-60.813c12.559-13.706,33.304-20.555,62.242-20.555h49.963v62.526h-31.401 -c-10.663,0-17.467,1.853-20.417,5.568c-2.949,3.711-4.428,10.23-4.428,19.558v31.119h56.534L373.155,225.548z" +const DetailedAvatar = () => { + return ( + <DxcFlex justifyContent="space-between" alignItems="center"> + <DxcFlex gap="var(--spacing-gap-s)"> + <DxcAvatar color="primary" status={{ mode: "error", position: "bottom" }} title="Michael Ramirez" /> + <DxcFlex direction="column"> + <DxcTypography + color="var(--color-fg-neutral-dark" + fontFamily="var(--typography-font-family)" + fontSize="var(--typography-label-l)" + fontWeight="var(--typography-label-regular)" + > + Michael Ramirez + </DxcTypography> + <DxcTypography + color="var(--color-fg-neutral-stronger" + fontFamily="var(--typography-font-family)" + fontSize="var(--typography-label-s)" + fontWeight="var(--typography-label-regular)" + > + m.ramirez@insurance.com + </DxcTypography> + </DxcFlex> + </DxcFlex> + <DxcButton + icon="keyboard_arrow_right" + size={{ height: "medium", width: "small" }} + mode="tertiary" + title="Show details" /> - </g> - </svg> -); + </DxcFlex> + ); +}; + +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 SideNav = () => ( +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", selected: 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 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> + <DxcSidenav + navItems={groupItems} + 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> + </> + } + /> </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> + <Title title="Sidenav with group lines" theme="light" level={4} /> + <DxcSidenav + navItems={groupItems} + 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 + /> </ExampleContainer> </> ); -const CollapsedGroupSidenav = () => ( - <ExampleContainer> - <Title title="Collapsed group with a selected link" theme="light" level={4} /> - <DxcSidenav title={<DxcSidenav.Title>Dxc technology</DxcSidenav.Title>}> - <DxcSidenav.Section> - <DxcSidenav.Group collapsable title="Collapsed Group" icon={iconSVG}> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - </DxcSidenav.Section> - <DxcSidenav.Section> - <DxcSidenav.Group collapsable title="Collapsed Group"> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link selected>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - <DxcSidenav.Group collapsable title="Section Group"> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - </DxcSidenav.Section> - </DxcSidenav> - </ExampleContainer> -); +const Collapsed = () => { + 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 HoveredGroupSidenav = () => ( +const Hovered = () => ( <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Hover state for groups (selected and not)" theme="light" level={4} /> - <DxcSidenav title={<DxcSidenav.Title>Dxc technology</DxcSidenav.Title>}> - <DxcSidenav.Section> - <DxcSidenav.Link>Single Link</DxcSidenav.Link> - <DxcSidenav.Link>Single Link</DxcSidenav.Link> - <DxcSidenav.Group collapsable title="Collapsed Group"> - <DxcSidenav.Link selected>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - </DxcSidenav.Section> - <DxcSidenav.Section> - <DxcSidenav.Group collapsable title="Not Collapsed Group"> - <DxcSidenav.Link selected>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - <DxcSidenav.Link>Single Link</DxcSidenav.Link> - <DxcSidenav.Link>Single Link</DxcSidenav.Link> - <DxcSidenav.Group collapsable title="Collapsed Group"> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - <DxcSidenav.Group collapsable title="Collapsed Group"> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - </DxcSidenav.Section> - </DxcSidenav> + <Title title="Hover state for groups" theme="light" level={4} /> + <DxcSidenav + navItems={groupItems} + 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> + </> + } + /> </ExampleContainer> ); -const ActiveGroupSidenav = () => ( - <ExampleContainer pseudoState="pseudo-active"> - <Title title="Active state for groups (selected and not)" theme="light" level={4} /> - <DxcSidenav title={<DxcSidenav.Title>Dxc technology</DxcSidenav.Title>}> - <DxcSidenav.Section> - <DxcInset space="var(--spacing-padding-m)"> - <DxcSelect - defaultValue="1" - options={[ - { label: "v1.0.0", value: "1" }, - { label: "v2.0.0", value: "2" }, - { label: "v3.0.0", value: "3" }, - { label: "v4.0.0", value: "4" }, - ]} - size="fillParent" - /> - </DxcInset> - </DxcSidenav.Section> - <DxcSidenav.Section> - <DxcSidenav.Group collapsable title="Collapsed Group"> - <DxcSidenav.Link selected>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - </DxcSidenav.Section> - <DxcSidenav.Section> - <DxcSidenav.Group collapsable title="Not Collapsed Group"> - <DxcSidenav.Link selected>Group Link</DxcSidenav.Link> - <DxcSidenav.Link>Group Link</DxcSidenav.Link> - </DxcSidenav.Group> - <DxcSidenav.Link>Single Link</DxcSidenav.Link> - <DxcSidenav.Link>Single Link</DxcSidenav.Link> - </DxcSidenav.Section> - </DxcSidenav> +const SelectedGroup = () => ( + <ExampleContainer> + <Title title="Default sidenav" theme="light" level={4} /> + <DxcSidenav + navItems={selectedGroupItems} + 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> + </> + } + /> </ExampleContainer> ); - type Story = StoryObj<typeof DxcSidenav>; export const Chromatic: Story = { - render: SideNav, -}; - -export const CollapsableGroup: Story = { - render: CollapsedGroupSidenav, + render: Sidenav, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const collapsableGroups = await canvas.findAllByText("Collapsed Group"); - for (const group of collapsableGroups) { - await userEvent.click(group); + const menuItem1 = (await canvas.findAllByRole("button"))[10]; + if (menuItem1) { + await userEvent.click(menuItem1); + } + const menuItem2 = (await canvas.findAllByRole("button"))[12]; + if (menuItem2) { + await userEvent.click(menuItem2); } }, }; -export const CollapsedHoverGroup: Story = { - render: HoveredGroupSidenav, +export const CollapsedSidenav: Story = { + render: Collapsed, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const collapsableGroups = await canvas.findAllByText("Collapsed Group"); - for (const group of collapsableGroups) { - await userEvent.click(group); + const collapseButtons = await canvas.findAllByRole("button", { name: "Collapse" }); + for (const button of collapseButtons) { + await userEvent.click(button); + } + const menuItem1 = (await canvas.findAllByRole("button"))[9]; + if (menuItem1) { + await userEvent.click(menuItem1); + } + const menuItem2 = (await canvas.findAllByRole("button"))[11]; + if (menuItem2) { + await userEvent.click(menuItem2); + } + const menuItem3 = (await canvas.findAllByRole("button"))[21]; + if (menuItem3) { + await userEvent.click(menuItem3); + } + const menuItem4 = (await canvas.findAllByRole("button"))[23]; + if (menuItem4) { + await userEvent.click(menuItem4); } - await new Promise((resolve) => { - setTimeout(resolve, 1000); - }); }, }; -export const CollapsedActiveGroup: Story = { - render: ActiveGroupSidenav, +export const HoveredSidenav: Story = { + render: Hovered, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const collapsableGroups = await canvas.findAllByText("Collapsed Group"); - if (collapsableGroups[0]) { - await userEvent.click(collapsableGroups[0]); + console.log(await canvas.findAllByRole("button")); + const menuItem1 = (await canvas.findAllByRole("button"))[1]; + if (menuItem1) { + await userEvent.click(menuItem1); + } + const menuItem2 = (await canvas.findAllByRole("button"))[3]; + if (menuItem2) { + await userEvent.click(menuItem2); } }, }; + +export const SelectedGroupSidenav: Story = { + render: SelectedGroup, +}; diff --git a/packages/lib/src/sidenav/Sidenav.test.tsx b/packages/lib/src/sidenav/Sidenav.test.tsx index f124b938a..4bf1b2e2e 100644 --- a/packages/lib/src/sidenav/Sidenav.test.tsx +++ b/packages/lib/src/sidenav/Sidenav.test.tsx @@ -1,40 +1,117 @@ -import { fireEvent, render } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { render, fireEvent } from "@testing-library/react"; import DxcSidenav from "./Sidenav"; +import { ReactNode } from "react"; -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> - </DxcSidenav> +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +describe("DxcSidenav component", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("Sidenav renders title and children correctly", () => { + const { getByText, getByRole } = render( + <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 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 branding={{ appTitle: "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("Sidenav renders logo correctly when provided", () => { + const logo = { src: "logo.png", alt: "Company Logo", href: "https://example.com" }; + 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("Sidenav renders contextual menu with items", () => { + const items = [{ label: "Dashboard" }, { label: "Settings" }]; + 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("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} /> ); - expect(getByText("nav-content-test")).toBeTruthy(); - const link = getByText("Link"); - expect(link.closest("a")?.getAttribute("href")).toBe("#"); - }); - - 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> + + 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} /> ); - 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"); + + const collapseButton = getByRole("button", { name: "Collapse" }); + expect(collapseButton).toBeTruthy(); + }); + + 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 814dd5b57..8581d5c86 100644 --- a/packages/lib/src/sidenav/Sidenav.tsx +++ b/packages/lib/src/sidenav/Sidenav.tsx @@ -1,246 +1,122 @@ -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 scrollbarStyles from "../styles/scroll"; +import SidenavPropsType, { Logo } from "./types"; import DxcDivider from "../divider/Divider"; -import DxcInset from "../inset/Inset"; +import DxcButton from "../button/Button"; +import DxcImage from "../image/Image"; +import { useState } from "react"; +import DxcNavigationTree from "../navigation-tree/NavigationTree"; -const SidenavContainer = styled.div` +const SidenavContainer = styled.div<{ expanded: boolean }>` box-sizing: border-box; display: flex; flex-direction: column; - width: 280px; + /* TODO: 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; - overflow-x: hidden; - ${scrollbarStyles} + padding: var(--spacing-padding-m) var(--spacing-padding-xs); + gap: var(--spacing-gap-l); + background-color: var(--color-bg-neutral-lightest); `; const SidenavTitle = styled.div` display: flex; align-items: center; - padding: var(--spacing-padding-xs) var(--spacing-padding-m); font-family: var(--typography-font-family); - font-size: var(--typography-label-xl); - color: var(--color-fg-neutral-stronger); - font-weight: var(--typography-label-semibold); -`; - -const SidenavGroup = styled.div` - a { - padding: var(--spacing-padding-xs) var(--spacing-padding-xxl); - } -`; - -const SectionContainer = styled.div` - display: flex; - flex-direction: column; - gap: var(--spacing-gap-ml); - &:last-child { - hr { - display: none; - } - } -`; - -const SidenavGroupTitle = styled.span` - box-sizing: border-box; - display: flex; - align-items: center; - gap: var(--spacing-gap-s); - padding: var(--spacing-padding-xs) var(--spacing-padding-ml); - font-family: var(--typography-font-family); - font-size: var(--typography-label-m); - font-weight: var(--typography-label-semibold); + font-size: var(--typography-ttle-m); color: var(--color-fg-neutral-dark); - span::before { - font-size: var(--height-xxs); - } - svg { - height: var(--height-xxs); - width: 16px; - } + font-weight: var(--typography-title-bold); `; -const SidenavGroupTitleButton = styled.button<{ selectedGroup: boolean }>` - all: unset; - box-sizing: border-box; +const LogoContainer = styled.div<{ + hasAction?: boolean; + href?: Logo["href"]; +}>` + position: relative; display: flex; + justify-content: center; align-items: center; - justify-content: space-between; - width: 100%; - padding: var(--spacing-padding-xs) var(--spacing-padding-ml); - font-family: var(--typography-font-family); - font-size: var(--typography-label-m); - font-weight: var(--typography-label-semibold); - cursor: pointer; - - ${(props) => - props.selectedGroup - ? `color: var(--color-fg-neutral-bright); background-color: var(--color-bg-neutral-stronger);` - : `color: var(--color-fg-neutral-stronger); background-color: transparent;`} - - &:focus, &:focus-visible { - outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); - outline-offset: -2px; - } - &:hover, - &:active { - background-color: ${(props) => - props.selectedGroup ? "var(--color-bg-neutral-strongest)" : "var(--color-bg-neutral-medium)"}; - } - span::before { - font-size: var(--height-xxs); - } - svg { - height: var(--height-xxs); - width: 16px; - } -`; - -const SidenavLink = styled.a<{ selected: SidenavLinkPropsType["selected"] }>` - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--spacing-padding-xs) var(--spacing-padding-ml); - font-family: var(--typography-font-family); - font-size: var(--typography-label-m); - font-weight: var(--typography-label-regular); text-decoration: none; - cursor: pointer; - - ${(props) => - props.selected - ? `color: var(--color-fg-neutral-bright); background-color: var(--color-bg-neutral-stronger);` - : `color: var(--color-fg-neutral-stronger); background-color: transparent;`} - - &:focus, &:focus-visible { - outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); - outline-offset: -2px; - } - &:hover, - &:active { - background-color: ${(props) => - props.selected ? "var(--color-bg-neutral-strongest)" : "var(--color-bg-neutral-medium)"}; - } - span::before { - font-size: var(--height-xxs); - } - svg { - height: var(--height-xxs); - width: 16px; - } `; -const DxcSidenav = ({ title, children }: SidenavPropsType): JSX.Element => { - return ( - <SidenavContainer> - {title} - <DxcFlex direction="column" gap="var(--spacing-gap-ml)"> - {children} - </DxcFlex> - </SidenavContainer> - ); -}; - -const Title = ({ children }: SidenavTitlePropsType): JSX.Element => <SidenavTitle>{children}</SidenavTitle>; - -const Section = ({ children }: SidenavSectionPropsType): JSX.Element => ( - <SectionContainer> - <DxcFlex direction="column">{children}</DxcFlex> - <DxcInset horizontal="var(--spacing-padding-ml)"> - <DxcDivider /> - </DxcInset> - </SectionContainer> -); - -const Group = ({ title, collapsable = false, icon, children }: SidenavGroupPropsType): JSX.Element => { - const [collapsed, setCollapsed] = useState(false); - const [isSelected, changeIsSelected] = useState(false); +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 handleToggle = () => { + const nextState = !isExpanded; + if (!isControlled) setInternalExpanded(nextState); + onExpandedChange?.(nextState); + }; + + const isBrandingObject = (branding: SidenavPropsType["branding"]): branding is { logo?: Logo; appTitle?: string } => { + return typeof branding === "object" && branding !== null && ("logo" in branding || "appTitle" in branding); + }; 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> + <SidenavContainer expanded={isExpanded}> + <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={handleToggle} + /> + {isBrandingObject(branding) ? ( + <DxcFlex direction="column" gap="var(--spacing-gap-m)" justifyContent="center" alignItems="flex-start"> + {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>{branding.appTitle}</SidenavTitle> + </DxcFlex> ) : ( - title && ( - <SidenavGroupTitle> - {typeof icon === "string" ? <DxcIcon icon={icon} /> : icon} - {title} - </SidenavGroupTitle> - ) + branding )} - {!collapsed && children} - </SidenavGroup> - </GroupContextProvider> + </DxcFlex> + {topContent} + {navItems && ( + <DxcNavigationTree + items={navItems} + displayGroupLines={displayGroupLines} + displayBorder={false} + responsiveView={!isExpanded} + displayControlsAfter + /> + )} + <DxcDivider color="lightGrey" /> + {bottomContent} + </SidenavContainer> ); }; -const Link = forwardRef<HTMLAnchorElement, SidenavLinkPropsType>( - ( - { href, newWindow = false, selected = false, icon, onClick, tabIndex = 0, children, ...otherProps }, - ref - ): JSX.Element => { - const changeIsGroupSelected = useContext(GroupContext); - const setIsSidenavVisibleResponsive = useResponsiveSidenavVisibility(); - const handleClick = ($event: MouseEvent<HTMLAnchorElement>) => { - onClick?.($event); - setIsSidenavVisibleResponsive?.(false); - }; - - useEffect(() => { - changeIsGroupSelected?.((isGroupSelected) => (!isGroupSelected ? selected : isGroupSelected)); - }, [selected, changeIsGroupSelected]); - - return ( - <SidenavLink - selected={selected} - href={href || undefined} - target={href ? (newWindow ? "_blank" : "_self") : undefined} - ref={ref} - tabIndex={tabIndex} - onClick={handleClick} - {...otherProps} - > - <DxcFlex alignItems="center" gap="var(--spacing-gap-s)"> - {typeof icon === "string" ? <DxcIcon icon={icon} /> : icon} - {children} - </DxcFlex> - {newWindow && <DxcIcon icon="open_in_new" />} - </SidenavLink> - ); - } -); - -DxcSidenav.Section = Section; -DxcSidenav.Group = Group; -DxcSidenav.Link = Link; -DxcSidenav.Title = Title; - export default DxcSidenav; diff --git a/packages/lib/src/sidenav/SidenavContext.tsx b/packages/lib/src/sidenav/SidenavContext.tsx index fc8f9d2b3..7d16fa201 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 ed577b49d..4cdfc4b95 100644 --- a/packages/lib/src/sidenav/types.ts +++ b/packages/lib/src/sidenav/types.ts @@ -1,82 +1,75 @@ -import { MouseEvent, ReactNode } from "react"; +import { MouseEvent, ReactElement, ReactNode } from "react"; import { SVG } from "../common/utils"; -export type SidenavTitlePropsType = { +export type Logo = { /** - * The area inside the sidenav title. This area can be used to render custom content. + * Alternative text for the logo image. */ - children: ReactNode; -}; - -export type SidenavSectionPropsType = { + alt: string; /** - * The area inside the sidenav section. This area can be used to render sidenav groups, links and custom content. + * URL to navigate when the logo is clicked. */ - 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; + href?: string; /** - * Material Symbol name or SVG icon to be displayed next to the title of the group. + * URL to navigate to when the logo is clicked. If not provided, the logo will not be clickable. */ - icon?: string | SVG; + onClick?: (event: MouseEvent<HTMLDivElement>) => void; /** - * The area inside the sidenav group. This area can be used to render sidenav links. + * URL of the image that will be placed in the logo. */ - children: ReactNode; + src: string; }; -export type SidenavLinkPropsType = { - /** - * Page to be opened when the user clicks on the link. - */ - href?: string; +type Section = { items: (Item | GroupItem)[]; title?: string }; + +type Props = { /** - * If true, the page is opened in a new browser tab. + * The content rendered in the bottom part of the sidenav, under the navigation menu. */ - newWindow?: boolean; + bottomContent?: ReactNode; /** - * The Material symbol or SVG element used as the icon that will be placed to the left of the link text. + * Object with the properties of the branding placed at the top of the sidenav. */ - icon?: string | SVG; + branding?: { logo?: Logo; appTitle?: string } | ReactNode; /** - * 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. + * Initial state of the expansion of the sidenav, only when it is uncontrolled. */ - selected?: boolean; + defaultExpanded?: boolean; /** - * This function will be called when the user clicks the link and the event will be passed to this function. + * If true the nav menu will have lines marking the groups. */ - onClick?: (event: MouseEvent<HTMLAnchorElement>) => void; + displayGroupLines?: boolean; /** - * The area inside the sidenav link. + * If true, the sidenav is expanded. + * If undefined the component will be uncontrolled and the value will be managed internally by the component. */ - children: ReactNode; + expanded?: boolean; /** - * Value of the tabindex. + * Array of items to be displayed in the navigation menu. + * Each item can be a single/simple item, a group item or a section. */ - tabIndex?: number; -}; - -type Props = { + navItems?: (Item | GroupItem)[] | Section[]; /** - * The area assigned to render the sidenav title. It is highly recommended to use the sidenav title. + * Function called when the expansion state of the sidenav changes. */ - title?: ReactNode; + onExpandedChange?: (value: boolean) => void; /** - * The area inside the sidenav. This area can be used to render the content inside the sidenav. + * The additional content rendered in the upper part of the sidenav, under the branding. */ - children: ReactNode; + topContent?: ReactNode; +}; + +type CommonItemProps = { + badge?: ReactElement; + icon?: string | SVG; + label: string; +}; +type Item = CommonItemProps & { + onSelect?: () => void; + selected?: boolean; +}; +type GroupItem = CommonItemProps & { + items: (Item | GroupItem)[]; }; export default Props; diff --git a/packages/lib/src/styles/variables.css b/packages/lib/src/styles/variables.css index 253df46a4..53b0b52dc 100644 --- a/packages/lib/src/styles/variables.css +++ b/packages/lib/src/styles/variables.css @@ -16,6 +16,7 @@ --z-dropdown: 310; --z-textinput: 320; --z-select: 330; + --z-contextualmenu: 340; /* Modals and overlays */ --z-dialog: 400; diff --git a/packages/lib/test/accessibility/rules/specific/sidenav/disabledRules.ts b/packages/lib/test/accessibility/rules/specific/sidenav/disabledRules.ts new file mode 100644 index 000000000..8e7726c7f --- /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; diff --git a/packages/lib/vitest.shims.d.ts b/packages/lib/vitest.shims.d.ts index f923d47d4..a1d31e5a7 100644 --- a/packages/lib/vitest.shims.d.ts +++ b/packages/lib/vitest.shims.d.ts @@ -1 +1 @@ -/// <reference types="@vitest/browser/providers/playwright" /> \ No newline at end of file +/// <reference types="@vitest/browser/providers/playwright" />