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"
/>
+ ) : (
+
);
};
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)" }}
+ >
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+ : }
+ onClick={() => toggleOpen()}
+ selected={groupSelected && !isOpen}
+ {...props}
+ />
+ {isOpen && (
+
+ )}
+ >
+ );
+};
+
+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(
+
+
+
+ {!displayControlsAfter && collapseIcon && (
+
+ {collapseIcon}
+
+ )}
+ {(icon || responsiveView) && (
+
+
+ {typeof icon === "string" ? : icon ? icon : }
+
+
+ )}
+ {!responsiveView && (
+
+ {label}
+
+ )}
+
+ {!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 && (
-
- )}
- >
- );
-};
-
-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 (
-
-
-
- {collapseIcon && {collapseIcon} }
- {icon && depthLevel === 0 && {typeof icon === "string" ? : icon} }
- ) => {
- const text = event.currentTarget;
- setHasTooltip(text.scrollWidth > text.clientWidth);
- }}
- >
- {label}
-
-
- {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 = () => (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+);
+
+const Single = () => (
+
+ {} }}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {} }}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+const ItemWithEllipsis = () => (
+
+
+
+
+
+
+);
+
+type Story = StoryObj;
+
+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( );
+ 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( );
+ const item = getByRole("button");
+ expect(item.getAttribute("aria-pressed")).toBeTruthy();
+ });
+ test("Group — Group items collapse when clicked", () => {
+ const { queryByText, getByText } = render( );
+ 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( );
+ 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( );
+ 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( );
+ 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( );
+ 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( );
+ 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(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 (
+
+
+ {itemsWithId[0] && isSection(itemsWithId[0]) ? (
+ (itemsWithId as SectionWithId[]).map((item, index) => (
+
+ ))
+ ) : (
+
+ {(itemsWithId as (GroupItemWithId | ItemWithId)[]).map((item, index) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
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(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 = (
-
-
-
-
-
-);
+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: ,
+ },
+ { label: "Selected Item 3", selected: true },
+ ],
+ },
+ ],
+ badge: ,
+ },
+ { label: "Item 4", icon: "key" },
+ ],
+ },
+ {
+ title: "Section 2",
+ items: [
+ { label: "Item 5", icon: "person" },
+ { label: "Grouped Item 6", items: [{ label: "Item 7", icon: "person" }, { label: "Item 8" }] },
+ { label: "Item 9" },
+ ],
+ },
+ ];
const { container } = render(
-
-
- nav-content-test
-
- Link
-
-
-
-
- Lorem ipsum
- Lorem ipsum
- Lorem ipsum
- Lorem ipsum
- Lorem ipsum
-
-
-
+
);
const results = await axe(container);
expect(results.violations).toHaveLength(0);
diff --git a/packages/lib/src/sidenav/Sidenav.stories.tsx b/packages/lib/src/sidenav/Sidenav.stories.tsx
index 121db6ef4..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;
-const iconSVG = (
-
-
- {
+ return (
+
+
+
+
+
+ Michael Ramirez
+
+
+ m.ramirez@insurance.com
+
+
+
+
-
-
-);
+
+ );
+};
+
+const groupItems = [
+ {
+ title: "Section 1",
+ items: [
+ {
+ label: "Grouped Item 1",
+ icon: "favorite",
+ items: [
+ { label: "Item 1", icon: "person" },
+ {
+ label: "Grouped Item 2",
+ items: [
+ {
+ label: "Item 2",
+ icon: "bookmark",
+ badge: ,
+ },
+ { label: "Selected Item 3" },
+ ],
+ },
+ ],
+ badge: ,
+ },
+ { label: "Item 4", icon: "key" },
+ ],
+ },
+ {
+ title: "Section 2",
+ items: [
+ { label: "Item 5", icon: "person" },
+ { label: "Grouped Item 6", items: [{ label: "Item 7", icon: "person" }, { label: "Item 8" }] },
+ { label: "Item 9" },
+ ],
+ },
+];
-const 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: ,
+ },
+ { label: "Selected Item 3", selected: true },
+ ],
+ },
+ ],
+ badge: ,
+ },
+ { label: "Item 4", icon: "key" },
+ ],
+ },
+ {
+ title: "Section 2",
+ items: [
+ { label: "Item 5", icon: "person" },
+ { label: "Grouped Item 6", items: [{ label: "Item 7", icon: "person" }, { label: "Item 8" }] },
+ { label: "Item 9" },
+ ],
+ },
+];
+
+const Sidenav = () => (
<>
- Dxc technology}>
-
-
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. In ullamcorper consectetur mollis. Suspendisse
- vitae lacinia libero.
-
-
-
- Single Link
-
- Group Link
- Group Link
- Group Link
- Group Link
-
-
-
-
- Group Link
- Group Link
-
-
- Single Link
-
- Single Link
-
- Group Link
- Group Link
- Group Link
-
-
-
+
+
+
+
+
+
+ >
+ }
+ />
-
-
- Dxc technology}>
-
-
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. In ullamcorper consectetur mollis. Suspendisse
- vitae lacinia libero.
-
-
-
- Single Link
-
- Group Link
-
-
-
-
-
- Group Link
-
-
-
- Group Link
-
-
-
+
+
+
+
+
+
+
+
+ >
+ }
+ displayGroupLines
+ />
>
);
-const CollapsedGroupSidenav = () => (
-
-
- Dxc technology}>
-
-
- Group Link
- Group Link
- Group Link
- Group Link
-
-
-
-
- Group Link
- Group Link
-
-
- Group Link
- Group Link
- Group Link
-
-
-
-
-);
+const Collapsed = () => {
+ const [isExpanded, setIsExpanded] = useState(true);
+ const [isExpandedGroupsNoLines, setIsExpandedGroupsNoLines] = useState(true);
+ const [isExpandedGroups, setIsExpandedGroups] = useState(true);
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+ >
+ )
+ }
+ expanded={isExpanded}
+ onExpandedChange={() => {
+ setIsExpanded((previouslyExpanded) => !previouslyExpanded);
+ }}
+ />
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+ >
+ )
+ }
+ expanded={isExpandedGroupsNoLines}
+ onExpandedChange={() => {
+ setIsExpandedGroupsNoLines((previouslyExpanded) => !previouslyExpanded);
+ }}
+ />
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+ >
+ )
+ }
+ expanded={isExpandedGroups}
+ onExpandedChange={() => {
+ setIsExpandedGroups((previouslyExpanded) => !previouslyExpanded);
+ }}
+ displayGroupLines
+ />
+
+ >
+ );
+};
-const HoveredGroupSidenav = () => (
+const Hovered = () => (
-
- Dxc technology}>
-
- Single Link
- Single Link
-
- Group Link
- Group Link
- Group Link
- Group Link
-
-
-
-
- Group Link
- Group Link
-
- Single Link
- Single Link
-
- Group Link
- Group Link
- Group Link
-
-
- Group Link
- Group Link
- Group Link
-
-
-
+
+
+
+
+
+
+
+ >
+ }
+ />
);
-const ActiveGroupSidenav = () => (
-
-
- Dxc technology}>
-
-
-
-
-
-
-
- Group Link
- Group Link
- Group Link
- Group Link
-
-
-
-
- Group Link
- Group Link
-
- Single Link
- Single Link
-
-
+const SelectedGroup = () => (
+
+
+
+
+
+
+
+
+ >
+ }
+ />
);
-
type Story = StoryObj;
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(
-
-
- nav-content-test
- Link
-
-
+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(
+ Custom top content}
+ bottomContent={Custom bottom content
}
+ />
+ );
+
+ 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( );
+
+ 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( );
+
+ 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( );
+ expect(getByText("Dashboard")).toBeTruthy();
+ expect(getByText("Settings")).toBeTruthy();
+ });
+
+ test("Sidenav renders link items correctly", () => {
+ const navItems = [{ label: "Dashboard", href: "/dashboard" }];
+
+ const { getByRole } = render( );
+
+ const link = getByRole("link", { name: "Dashboard" });
+ expect(link).toHaveAttribute("href", "/dashboard");
+ });
+
+ test("Sidenav calls renderItem correctly", () => {
+ const CustomComponent = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+
+ const customGroupItems = [
+ {
+ label: "Introduction",
+ href: "/overview/introduction",
+ selected: false,
+ renderItem: ({ children }: { children: ReactNode }) => {children} ,
+ },
+ ];
+
+ const { getByTestId } = render( );
+ expect(getByTestId("custom-wrapper")).toBeInTheDocument();
+ });
+
+ test("Sidenav uses controlled expanded prop instead of internal state", () => {
+ const onExpandedChange = jest.fn();
+ const { getByRole, rerender } = render(
+
);
- 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(
-
-
-
- Lorem ipsum
- Lorem ipsum
- Lorem ipsum
- Lorem ipsum
- Lorem ipsum
-
-
-
+
+ const expandButton = getByRole("button", { name: "Expand" });
+ expect(expandButton).toBeTruthy();
+
+ fireEvent.click(expandButton);
+ expect(onExpandedChange).toHaveBeenCalledWith(true);
+
+ rerender(
+
);
- 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( );
+
+ 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 (
-
- {title}
-
- {children}
-
-
- );
-};
-
-const Title = ({ children }: SidenavTitlePropsType): JSX.Element => {children} ;
-
-const Section = ({ children }: SidenavSectionPropsType): JSX.Element => (
-
- {children}
-
-
-
-
-);
-
-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 (
-
-
- {collapsable && title ? (
- setCollapsed(!collapsed)}
- selectedGroup={collapsed && isSelected}
- >
-
- {typeof icon === "string" ? : icon}
- {title}
-
-
-
+
+
+
+ {isBrandingObject(branding) ? (
+
+ {branding.logo && (
+
+
+
+ )}
+ {branding.appTitle}
+
) : (
- title && (
-
- {typeof icon === "string" ? : icon}
- {title}
-
- )
+ branding
)}
- {!collapsed && children}
-
-
+
+ {topContent}
+ {navItems && (
+
+ )}
+
+ {bottomContent}
+
);
};
-const Link = forwardRef(
- (
- { 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) => {
- onClick?.($event);
- setIsSidenavVisibleResponsive?.(false);
- };
-
- useEffect(() => {
- changeIsGroupSelected?.((isGroupSelected) => (!isGroupSelected ? selected : isGroupSelected));
- }, [selected, changeIsGroupSelected]);
-
- return (
-
-
- {typeof icon === "string" ? : icon}
- {children}
-
- {newWindow && }
-
- );
- }
-);
-
-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> | 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) => 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) => 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 @@
-///
\ No newline at end of file
+///