From e4b62fe05ebc26ec76d00f43af9ddc0e341e20be Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Fri, 10 Oct 2025 08:14:03 +0200
Subject: [PATCH 01/36] First approach to new Sidenav implementation
---
.../src/contextual-menu/ContextualMenu.tsx | 6 +-
.../ContextualMenu.tsx | 62 ++++
.../ContextualMenuContext.tsx | 4 +
.../src/general-contextual-menu/GroupItem.tsx | 38 ++
.../general-contextual-menu/ItemAction.tsx | 108 ++++++
.../src/general-contextual-menu/MenuItem.tsx | 27 ++
.../src/general-contextual-menu/Section.tsx | 41 +++
.../general-contextual-menu/SingleItem.tsx | 30 ++
.../src/general-contextual-menu/SubMenu.tsx | 24 ++
.../lib/src/general-contextual-menu/types.ts | 76 ++++
.../lib/src/general-contextual-menu/utils.ts | 38 ++
packages/lib/src/layout/ApplicationLayout.tsx | 4 +-
.../sidenav/Sidenav.accessibility.test.tsx | 52 ---
packages/lib/src/sidenav/Sidenav.stories.tsx | 342 ++++++------------
packages/lib/src/sidenav/Sidenav.tsx | 273 ++++----------
packages/lib/src/sidenav/types.ts | 95 ++++-
16 files changed, 722 insertions(+), 498 deletions(-)
create mode 100644 packages/lib/src/general-contextual-menu/ContextualMenu.tsx
create mode 100644 packages/lib/src/general-contextual-menu/ContextualMenuContext.tsx
create mode 100644 packages/lib/src/general-contextual-menu/GroupItem.tsx
create mode 100644 packages/lib/src/general-contextual-menu/ItemAction.tsx
create mode 100644 packages/lib/src/general-contextual-menu/MenuItem.tsx
create mode 100644 packages/lib/src/general-contextual-menu/Section.tsx
create mode 100644 packages/lib/src/general-contextual-menu/SingleItem.tsx
create mode 100644 packages/lib/src/general-contextual-menu/SubMenu.tsx
create mode 100644 packages/lib/src/general-contextual-menu/types.ts
create mode 100644 packages/lib/src/general-contextual-menu/utils.ts
delete mode 100644 packages/lib/src/sidenav/Sidenav.accessibility.test.tsx
diff --git a/packages/lib/src/contextual-menu/ContextualMenu.tsx b/packages/lib/src/contextual-menu/ContextualMenu.tsx
index 13f58b4172..9fd9ded16c 100644
--- a/packages/lib/src/contextual-menu/ContextualMenu.tsx
+++ b/packages/lib/src/contextual-menu/ContextualMenu.tsx
@@ -8,7 +8,7 @@ import scrollbarStyles from "../styles/scroll";
import { addIdToItems, isSection } from "./utils";
import SubMenu from "./SubMenu";
-const ContextualMenu = styled.div`
+const ContextualMenuContainer = styled.div`
box-sizing: border-box;
margin: 0;
border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-lighter);
@@ -45,7 +45,7 @@ export default function DxcContextualMenu({ items }: ContextualMenuPropsType) {
}, [firstUpdate, selectedItemId]);
return (
-
+
{itemsWithId[0] && isSection(itemsWithId[0]) ? (
(itemsWithId as SectionWithId[]).map((item, index) => (
@@ -59,6 +59,6 @@ export default function DxcContextualMenu({ items }: ContextualMenuPropsType) {
)}
-
+
);
}
diff --git a/packages/lib/src/general-contextual-menu/ContextualMenu.tsx b/packages/lib/src/general-contextual-menu/ContextualMenu.tsx
new file mode 100644
index 0000000000..ccfdff55ce
--- /dev/null
+++ b/packages/lib/src/general-contextual-menu/ContextualMenu.tsx
@@ -0,0 +1,62 @@
+import { useLayoutEffect, useMemo, useRef, useState } from "react";
+import styled from "@emotion/styled";
+import MenuItem from "./MenuItem";
+import ContextualMenuPropsType, { GroupItemWithId, ItemWithId, SectionWithId } from "./types";
+import Section from "./Section";
+import ContextualMenuContext from "./ContextualMenuContext";
+import scrollbarStyles from "../styles/scroll";
+import { addIdToItems, isSection } from "./utils";
+import SubMenu from "./SubMenu";
+
+const ContextualMenuContainer = styled.div`
+ box-sizing: border-box;
+ margin: 0;
+ padding: var(--spacing-padding-m) var(--spacing-padding-xs);
+ display: grid;
+ gap: var(--spacing-gap-xs);
+ min-width: 248px;
+ max-height: 100%;
+ background-color: var(--color-bg-neutral-lightest);
+ overflow-y: auto;
+ overflow-x: hidden;
+ ${scrollbarStyles}
+`;
+
+export default function DxcContextualMenu({ items }: ContextualMenuPropsType) {
+ const [firstUpdate, setFirstUpdate] = useState(true);
+ const [selectedItemId, setSelectedItemId] = useState(-1);
+ const contextualMenuRef = useRef(null);
+ const itemsWithId = useMemo(() => addIdToItems(items), [items]);
+ const contextValue = useMemo(() => ({ selectedItemId, setSelectedItemId }), [selectedItemId, setSelectedItemId]);
+
+ useLayoutEffect(() => {
+ if (selectedItemId !== -1 && firstUpdate) {
+ const contextualMenuEl = contextualMenuRef.current;
+ const selectedItemEl = contextualMenuEl?.querySelector("[aria-pressed='true']");
+ if (selectedItemEl instanceof HTMLButtonElement) {
+ contextualMenuEl?.scrollTo?.({
+ top: (selectedItemEl?.offsetTop ?? 0) - (contextualMenuEl?.clientHeight ?? 0) / 2,
+ });
+ }
+ setFirstUpdate(false);
+ }
+ }, [firstUpdate, selectedItemId]);
+
+ return (
+
+
+ {itemsWithId[0] && isSection(itemsWithId[0]) ? (
+ (itemsWithId as SectionWithId[]).map((item, index) => (
+
+ ))
+ ) : (
+
+ {(itemsWithId as (GroupItemWithId | ItemWithId)[]).map((item, index) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/packages/lib/src/general-contextual-menu/ContextualMenuContext.tsx b/packages/lib/src/general-contextual-menu/ContextualMenuContext.tsx
new file mode 100644
index 0000000000..767f9f8513
--- /dev/null
+++ b/packages/lib/src/general-contextual-menu/ContextualMenuContext.tsx
@@ -0,0 +1,4 @@
+import { createContext } from "react";
+import { ContextualMenuContextProps } from "./types";
+
+export default createContext(null);
diff --git a/packages/lib/src/general-contextual-menu/GroupItem.tsx b/packages/lib/src/general-contextual-menu/GroupItem.tsx
new file mode 100644
index 0000000000..d9a2474d7b
--- /dev/null
+++ b/packages/lib/src/general-contextual-menu/GroupItem.tsx
@@ -0,0 +1,38 @@
+import { useContext, useMemo, useState, useId } from "react";
+import DxcIcon from "../icon/Icon";
+import SubMenu from "./SubMenu";
+import ItemAction from "./ItemAction";
+import MenuItem from "./MenuItem";
+import { GroupItemProps } from "./types";
+import ContextualMenuContext from "./ContextualMenuContext";
+import { isGroupSelected } from "./utils";
+
+const GroupItem = ({ items, ...props }: GroupItemProps) => {
+ const groupMenuId = `group-menu-${useId()}`;
+ const { selectedItemId } = useContext(ContextualMenuContext) ?? {};
+ const groupSelected = useMemo(() => isGroupSelected(items, selectedItemId), [items, selectedItemId]);
+ const [isOpen, setIsOpen] = useState(groupSelected && selectedItemId === -1);
+
+ return (
+ <>
+ : }
+ onClick={() => setIsOpen((isCurrentlyOpen) => !isCurrentlyOpen)}
+ selected={groupSelected && !isOpen}
+ {...props}
+ />
+ {isOpen && (
+
+ )}
+ >
+ );
+};
+
+export default GroupItem;
diff --git a/packages/lib/src/general-contextual-menu/ItemAction.tsx b/packages/lib/src/general-contextual-menu/ItemAction.tsx
new file mode 100644
index 0000000000..171bf8b5a4
--- /dev/null
+++ b/packages/lib/src/general-contextual-menu/ItemAction.tsx
@@ -0,0 +1,108 @@
+import { cloneElement, memo, MouseEvent, useState } from "react";
+import styled from "@emotion/styled";
+import { ItemActionProps } from "./types";
+import DxcIcon from "../icon/Icon";
+import { TooltipWrapper } from "../tooltip/Tooltip";
+
+const Action = styled.button<{
+ depthLevel: ItemActionProps["depthLevel"];
+ selected: ItemActionProps["selected"];
+}>`
+ box-sizing: content-box;
+ border: none;
+ border-radius: var(--border-radius-s);
+ padding: var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xs);
+ margin-left: ${({ depthLevel }) => (depthLevel > 0 ? "var(--spacing-padding-xs)" : "var(--spacing-padding-none)")};
+ /* ${({ depthLevel }) => `calc(var(--spacing-padding-xs) + ${depthLevel} * var(--spacing-padding-l))`}; */
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background-color: ${({ selected }) => (selected ? "var(--color-bg-primary-lighter)" : "transparent")};
+ height: var(--height-s);
+ cursor: pointer;
+ overflow: hidden;
+
+ /* ${({ depthLevel }) => depthLevel > 0 && `border-left: 1px solid grey;`} */
+
+ &:hover {
+ background-color: ${({ selected }) =>
+ selected ? "var(--color-bg-primary-medium)" : "var(--color-bg-neutral-light)"};
+ }
+ &:active {
+ background-color: ${({ selected }) =>
+ selected ? "var(--color-bg-primary-medium)" : "var(--color-bg-neutral-light)"};
+ }
+ &:focus {
+ outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium);
+ outline-offset: -2px;
+ }
+`;
+
+const Label = styled.span`
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-gap-s);
+ overflow: hidden;
+ padding-right: var(--spacing-padding-m);
+`;
+
+const Icon = styled.span`
+ display: flex;
+ color: var(--color-fg-neutral-dark);
+ font-size: var(--height-xxs);
+ svg {
+ height: var(--height-xxs);
+ width: 16px;
+ }
+`;
+
+const Text = styled.span<{ selected: ItemActionProps["selected"] }>`
+ color: var(--color-fg-neutral-dark);
+ font-family: var(--typography-font-family);
+ font-size: var(--typography-label-m);
+ font-weight: ${({ selected }) => (selected ? "var(--typography-label-semibold)" : "var(--typography-label-regular)")};
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+`;
+
+const Control = styled.span`
+ display: flex;
+ align-items: center;
+ padding: var(--spacing-padding-none);
+ justify-content: flex-end;
+ align-items: center;
+ gap: var(--spacing-gap-s);
+`;
+
+const ItemAction = memo(({ badge, collapseIcon, depthLevel, icon, label, ...props }: ItemActionProps) => {
+ const [hasTooltip, setHasTooltip] = useState(false);
+ const modifiedBadge = badge && cloneElement(badge, { size: "small" });
+
+ return (
+
+
+
+ {icon && depthLevel === 0 && {typeof icon === "string" ? : icon} }
+ ) => {
+ const text = event.currentTarget;
+ setHasTooltip(text.scrollWidth > text.clientWidth);
+ }}
+ >
+ {label}
+
+
+
+ {modifiedBadge}
+ {collapseIcon && {collapseIcon} }
+
+
+
+ );
+});
+
+ItemAction.displayName = "ItemAction";
+
+export default ItemAction;
diff --git a/packages/lib/src/general-contextual-menu/MenuItem.tsx b/packages/lib/src/general-contextual-menu/MenuItem.tsx
new file mode 100644
index 0000000000..d9db1b7841
--- /dev/null
+++ b/packages/lib/src/general-contextual-menu/MenuItem.tsx
@@ -0,0 +1,27 @@
+import styled from "@emotion/styled";
+import GroupItem from "./GroupItem";
+import SingleItem from "./SingleItem";
+import { MenuItemProps } from "./types";
+
+const MenuItemContainer = styled.li<{ depthLevel: number }>`
+ display: grid;
+ gap: var(--spacing-gap-xs);
+ ${({ depthLevel }) =>
+ depthLevel > 0 &&
+ `
+ margin-left: var(--spacing-padding-m);
+ border-left: var(--border-width-s) solid var(--border-color-neutral-lighter);
+ `}
+`;
+
+export default function MenuItem({ item, depthLevel = 0 }: MenuItemProps) {
+ return (
+
+ {"items" in item ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/packages/lib/src/general-contextual-menu/Section.tsx b/packages/lib/src/general-contextual-menu/Section.tsx
new file mode 100644
index 0000000000..8cade2fba0
--- /dev/null
+++ b/packages/lib/src/general-contextual-menu/Section.tsx
@@ -0,0 +1,41 @@
+import { useId } from "react";
+import styled from "@emotion/styled";
+import { DxcInset } from "..";
+import DxcDivider from "../divider/Divider";
+import SubMenu from "./SubMenu";
+import MenuItem from "./MenuItem";
+import { SectionProps } from "./types";
+
+const SectionContainer = styled.section`
+ display: grid;
+ gap: var(--spacing-gap-xs);
+`;
+
+const Title = styled.h2`
+ all: unset;
+ color: var(--color-fg-neutral-dark);
+ font-family: var(--typography-font-family);
+ font-size: var(--typography-label-l);
+ font-weight: var(--typography-label-semibold);
+ padding: var(--spacing-padding-xxs);
+`;
+
+export default function Section({ index, length, section }: SectionProps) {
+ const id = `section-${useId()}`;
+
+ return (
+
+ {section.title && {section.title} }
+
+ {section.items.map((item, i) => (
+
+ ))}
+
+ {index !== length - 1 && (
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/lib/src/general-contextual-menu/SingleItem.tsx b/packages/lib/src/general-contextual-menu/SingleItem.tsx
new file mode 100644
index 0000000000..5fcd304d91
--- /dev/null
+++ b/packages/lib/src/general-contextual-menu/SingleItem.tsx
@@ -0,0 +1,30 @@
+import { useContext, useEffect } from "react";
+import ItemAction from "./ItemAction";
+import { SingleItemProps } from "./types";
+import ContextualMenuContext from "./ContextualMenuContext";
+
+export default function SingleItem({ id, onSelect, selectedByDefault = false, ...props }: SingleItemProps) {
+ const { selectedItemId, setSelectedItemId } = useContext(ContextualMenuContext) ?? {};
+
+ const handleClick = () => {
+ setSelectedItemId?.(id);
+ onSelect?.();
+ };
+
+ useEffect(() => {
+ if (selectedItemId === -1 && selectedByDefault) {
+ setSelectedItemId?.(id);
+ }
+ }, [selectedItemId, selectedByDefault, id]);
+
+ return (
+
+ );
+}
diff --git a/packages/lib/src/general-contextual-menu/SubMenu.tsx b/packages/lib/src/general-contextual-menu/SubMenu.tsx
new file mode 100644
index 0000000000..4bf237475b
--- /dev/null
+++ b/packages/lib/src/general-contextual-menu/SubMenu.tsx
@@ -0,0 +1,24 @@
+import styled from "@emotion/styled";
+import { SubMenuProps } from "./types";
+
+const SubMenuContainer = styled.ul<{ outline?: boolean }>`
+ margin: 0;
+ padding: 0;
+ display: grid;
+ list-style: none;
+ /*
+ ${({ outline }) =>
+ outline &&
+ `
+ margin-left: var(--spacing-padding-m);
+ border-left: var(--border-width-s) solid var(--border-color-neutral-lighter);
+ `} */
+`;
+
+export default function SubMenu({ children, id, outline }: SubMenuProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/lib/src/general-contextual-menu/types.ts b/packages/lib/src/general-contextual-menu/types.ts
new file mode 100644
index 0000000000..49f2f3af60
--- /dev/null
+++ b/packages/lib/src/general-contextual-menu/types.ts
@@ -0,0 +1,76 @@
+import { ButtonHTMLAttributes, Dispatch, ReactElement, ReactNode, SetStateAction } from "react";
+import { SVG } from "../common/utils";
+
+type CommonItemProps = {
+ badge?: ReactElement;
+ icon?: string | SVG;
+ label: string;
+};
+type Item = CommonItemProps & {
+ onSelect?: () => void;
+ selectedByDefault?: boolean;
+};
+type GroupItem = CommonItemProps & {
+ items: (Item | GroupItem)[];
+};
+type Section = { items: (Item | GroupItem)[]; title?: string };
+type Props = {
+ /**
+ * Array of items to be displayed in the Contextual menu.
+ * Each item can be a single/simple item, a group item or a section.
+ */
+ items: (Item | GroupItem)[] | Section[];
+ /**
+ * If true, only the icons/initials of the items are displayed
+ */
+ reduced: boolean;
+};
+
+type ItemWithId = Item & { id: number };
+type GroupItemWithId = {
+ badge?: ReactElement;
+ icon: string | SVG;
+ items: (ItemWithId | GroupItemWithId)[];
+ label: string;
+};
+type SectionWithId = { items: (ItemWithId | GroupItemWithId)[]; title?: string };
+
+type SingleItemProps = ItemWithId & { depthLevel: number };
+type GroupItemProps = GroupItemWithId & { depthLevel: number };
+type MenuItemProps = { item: ItemWithId | GroupItemWithId; depthLevel?: number };
+type ItemActionProps = ButtonHTMLAttributes & {
+ badge?: Item["badge"];
+ collapseIcon?: ReactNode;
+ depthLevel: number;
+ icon?: Item["icon"];
+ label: Item["label"];
+ selected: boolean;
+};
+type SectionProps = {
+ section: SectionWithId;
+ index: number;
+ length: number;
+};
+type SubMenuProps = { children: ReactNode; id?: string; outline?: boolean };
+type ContextualMenuContextProps = {
+ selectedItemId: number;
+ setSelectedItemId: Dispatch>;
+};
+
+export type {
+ ContextualMenuContextProps,
+ GroupItem,
+ GroupItemProps,
+ GroupItemWithId,
+ Item,
+ ItemActionProps,
+ ItemWithId,
+ SubMenuProps,
+ MenuItemProps,
+ Section,
+ SectionWithId,
+ SectionProps,
+ SingleItemProps,
+};
+
+export default Props;
diff --git a/packages/lib/src/general-contextual-menu/utils.ts b/packages/lib/src/general-contextual-menu/utils.ts
new file mode 100644
index 0000000000..3dfe2fb6d8
--- /dev/null
+++ b/packages/lib/src/general-contextual-menu/utils.ts
@@ -0,0 +1,38 @@
+import ContextualMenuPropsType, {
+ GroupItem,
+ GroupItemProps,
+ GroupItemWithId,
+ Item,
+ ItemWithId,
+ Section as SectionType,
+ SectionWithId,
+} from "./types";
+
+export const isGroupItem = (item: Item | GroupItem): item is GroupItem => "items" in item;
+
+export const isSection = (item: SectionType | Item | GroupItem): item is SectionType =>
+ "items" in item && !("label" in item);
+
+export const addIdToItems = (
+ items: ContextualMenuPropsType["items"]
+): (ItemWithId | GroupItemWithId | SectionWithId)[] => {
+ let accId = 0;
+ const innerAddIdToItems = (
+ items: ContextualMenuPropsType["items"]
+ ): (ItemWithId | GroupItemWithId | SectionWithId)[] =>
+ items.map((item: Item | GroupItem | SectionType) =>
+ isSection(item)
+ ? ({ ...item, items: innerAddIdToItems(item.items) } as SectionWithId)
+ : isGroupItem(item)
+ ? ({ ...item, items: innerAddIdToItems(item.items) } as GroupItemWithId)
+ : { ...item, id: accId++ }
+ );
+ return innerAddIdToItems(items);
+};
+
+export const isGroupSelected = (items: GroupItemProps["items"], selectedItemId?: number): boolean =>
+ items.some((item) => {
+ if ("items" in item) return isGroupSelected(item.items, selectedItemId);
+ else if (selectedItemId !== -1) return item.id === selectedItemId;
+ else return item.selectedByDefault;
+ });
diff --git a/packages/lib/src/layout/ApplicationLayout.tsx b/packages/lib/src/layout/ApplicationLayout.tsx
index 59bceafefe..e77175ed60 100644
--- a/packages/lib/src/layout/ApplicationLayout.tsx
+++ b/packages/lib/src/layout/ApplicationLayout.tsx
@@ -4,7 +4,7 @@ import { responsiveSizes } from "../common/variables";
import DxcFooter from "../footer/Footer";
import DxcHeader from "../header/Header";
import DxcIcon from "../icon/Icon";
-import DxcSidenav from "../sidenav/Sidenav";
+// import DxcSidenav from "../sidenav/Sidenav";
import { SidenavContextProvider, useResponsiveSidenavVisibility } from "../sidenav/SidenavContext";
import { Tooltip } from "../tooltip/Tooltip";
import ApplicationLayoutPropsType, { AppLayoutMainPropsType } from "./types";
@@ -189,7 +189,7 @@ const DxcApplicationLayout = ({
DxcApplicationLayout.Footer = DxcFooter;
DxcApplicationLayout.Header = DxcHeader;
DxcApplicationLayout.Main = Main;
-DxcApplicationLayout.SideNav = DxcSidenav;
+// DxcApplicationLayout.SideNav = DxcSidenav;
DxcApplicationLayout.useResponsiveSidenavVisibility = useResponsiveSidenavVisibility;
export default DxcApplicationLayout;
diff --git a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx
deleted file mode 100644
index f8d93237ff..0000000000
--- a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { render } from "@testing-library/react";
-import { axe } from "../../test/accessibility/axe-helper";
-import DxcSidenav from "./Sidenav";
-
-const iconSVG = (
-
-
-
-
-
-);
-
-describe("Sidenav component accessibility tests", () => {
- it("Should not have basic accessibility issues", async () => {
- const { container } = render(
-
-
- nav-content-test
-
- Link
-
-
-
-
- Lorem ipsum
- Lorem ipsum
- Lorem ipsum
- Lorem ipsum
- Lorem ipsum
-
-
-
- );
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-});
diff --git a/packages/lib/src/sidenav/Sidenav.stories.tsx b/packages/lib/src/sidenav/Sidenav.stories.tsx
index 2902a38129..13a1db2ba3 100644
--- a/packages/lib/src/sidenav/Sidenav.stories.tsx
+++ b/packages/lib/src/sidenav/Sidenav.stories.tsx
@@ -1,245 +1,133 @@
import { Meta, StoryObj } from "@storybook/react";
-import { userEvent, within } from "@storybook/test";
import Title from "../../.storybook/components/Title";
import ExampleContainer from "../../.storybook/components/ExampleContainer";
-import DxcInset from "../inset/Inset";
-import DxcSelect from "../select/Select";
import DxcSidenav from "./Sidenav";
+import DxcBadge from "../badge/Badge";
+import DxcFlex from "../flex/Flex";
+import DxcTypography from "../typography/Typography";
+import DxcContainer from "../container/Container";
+import DxcButton from "../button/Button";
export default {
title: "Sidenav",
component: DxcSidenav,
} as Meta;
-const iconSVG = (
-
-
- {
+ return (
+
+
+ {/* TODO: METER AVATAR */}
+
+
+
+ Michael Ramirez
+
+
+ m.ramirez@insurance.com
+
+
+
+ {/* TODO: DISCUSS WITH DESIGNERS ACTIONICON OR BUTTON? */}
+
-
-
-);
-
-const SideNav = () => (
- <>
-
-
- 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
-
-
-
-
- >
-);
-
-const CollapsedGroupSidenav = () => (
-
-
- Dxc technology}>
-
-
- Group Link
- Group Link
- Group Link
- Group Link
-
-
-
-
- Group Link
- Group Link
-
-
- Group Link
- Group Link
- Group Link
-
-
-
-
-);
-
-const HoveredGroupSidenav = () => (
-
-
- 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 SideNav = () => {
+ const groupItems = [
+ {
+ title: "Section 1",
+ items: [
+ {
+ label: "Grouped Item 1",
+ icon: "favorite",
+ items: [
+ { label: "Item 1" },
+ {
+ label: "Grouped Item 2",
+ items: [
+ {
+ label: "Item 2",
+ icon: "bookmark",
+ badge: ,
+ },
+ { label: "Selected Item 3", selectedByDefault: 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" },
+ ],
+ },
+ ];
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
type Story = StoryObj;
export const Chromatic: Story = {
render: SideNav,
};
-
-export const CollapsableGroup: Story = {
- render: CollapsedGroupSidenav,
- play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
- const collapsableGroups = canvas.getAllByText("Collapsed Group");
- for (const group of collapsableGroups) {
- await userEvent.click(group);
- }
- },
-};
-
-export const CollapsedHoverGroup: Story = {
- render: HoveredGroupSidenav,
- play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
- const collapsableGroups = canvas.getAllByText("Collapsed Group");
- for (const group of collapsableGroups) {
- await userEvent.click(group);
- }
- await new Promise((resolve) => {
- setTimeout(resolve, 1000);
- });
- },
-};
-
-export const CollapsedActiveGroup: Story = {
- render: ActiveGroupSidenav,
- play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
- const collapsableGroups = canvas.getAllByText("Collapsed Group");
- if (collapsableGroups[0]) {
- await userEvent.click(collapsableGroups[0]);
- }
- },
-};
diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx
index 814dd5b576..4aa94fbb8f 100644
--- a/packages/lib/src/sidenav/Sidenav.tsx
+++ b/packages/lib/src/sidenav/Sidenav.tsx
@@ -1,246 +1,97 @@
-import { forwardRef, MouseEvent, useContext, useEffect, useState } from "react";
import styled from "@emotion/styled";
import { responsiveSizes } from "../common/variables";
import DxcFlex from "../flex/Flex";
-import DxcIcon from "../icon/Icon";
-import { GroupContext, GroupContextProvider, useResponsiveSidenavVisibility } from "./SidenavContext";
-import SidenavPropsType, {
- SidenavGroupPropsType,
- SidenavLinkPropsType,
- SidenavSectionPropsType,
- SidenavTitlePropsType,
-} from "./types";
+import SidenavPropsType, { Logo } from "./types";
import scrollbarStyles from "../styles/scroll";
import DxcDivider from "../divider/Divider";
-import DxcInset from "../inset/Inset";
+import DxcButton from "../button/Button";
+import DxcContextualMenu from "../general-contextual-menu/ContextualMenu";
+import DxcImage from "../image/Image";
+import { useState } from "react";
-const SidenavContainer = styled.div`
+const SidenavContainer = styled.div<{ expanded: boolean }>`
box-sizing: border-box;
display: flex;
flex-direction: column;
- width: 280px;
+ /* TODO: ASK FINAL SIZES AND IMPLEMENT RESIZABLE SIDENAV */
+ min-width: ${({ expanded }) => (expanded ? "240px" : "56px")};
+ max-width: ${({ expanded }) => (expanded ? "320px" : "56px")};
height: 100%;
@media (max-width: ${responsiveSizes.large}rem) {
width: 100vw;
}
- padding: var(--spacing-padding-xl) var(--spacing-padding-none);
- background-color: var(--color-bg-neutral-light);
-
- overflow-y: auto;
+ padding: var(--spacing-padding-m) var(--spacing-padding-xs);
+ gap: var(--spacing-gap-l);
+ background-color: var(--color-bg-neutral-lightest);
+ /* overflow-y: auto;
overflow-x: hidden;
- ${scrollbarStyles}
+ ${scrollbarStyles} */
`;
const SidenavTitle = styled.div`
display: flex;
align-items: center;
- padding: var(--spacing-padding-xs) var(--spacing-padding-m);
font-family: var(--typography-font-family);
- font-size: var(--typography-label-xl);
- color: var(--color-fg-neutral-stronger);
- font-weight: var(--typography-label-semibold);
-`;
-
-const SidenavGroup = styled.div`
- a {
- padding: var(--spacing-padding-xs) var(--spacing-padding-xxl);
- }
-`;
-
-const SectionContainer = styled.div`
- display: flex;
- flex-direction: column;
- gap: var(--spacing-gap-ml);
- &:last-child {
- hr {
- display: none;
- }
- }
-`;
-
-const SidenavGroupTitle = styled.span`
- box-sizing: border-box;
- display: flex;
- align-items: center;
- gap: var(--spacing-gap-s);
- padding: var(--spacing-padding-xs) var(--spacing-padding-ml);
- font-family: var(--typography-font-family);
- font-size: var(--typography-label-m);
- font-weight: var(--typography-label-semibold);
+ font-size: var(--typography-ttle-m);
color: var(--color-fg-neutral-dark);
- span::before {
- font-size: var(--height-xxs);
- }
- svg {
- height: var(--height-xxs);
- width: 16px;
- }
-`;
-
-const SidenavGroupTitleButton = styled.button<{ selectedGroup: boolean }>`
- all: unset;
- box-sizing: border-box;
- display: flex;
- align-items: center;
- justify-content: space-between;
- width: 100%;
- padding: var(--spacing-padding-xs) var(--spacing-padding-ml);
- font-family: var(--typography-font-family);
- font-size: var(--typography-label-m);
- font-weight: var(--typography-label-semibold);
- cursor: pointer;
-
- ${(props) =>
- props.selectedGroup
- ? `color: var(--color-fg-neutral-bright); background-color: var(--color-bg-neutral-stronger);`
- : `color: var(--color-fg-neutral-stronger); background-color: transparent;`}
-
- &:focus, &:focus-visible {
- outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium);
- outline-offset: -2px;
- }
- &:hover,
- &:active {
- background-color: ${(props) =>
- props.selectedGroup ? "var(--color-bg-neutral-strongest)" : "var(--color-bg-neutral-medium)"};
- }
- span::before {
- font-size: var(--height-xxs);
- }
- svg {
- height: var(--height-xxs);
- width: 16px;
- }
+ font-weight: var(--typography-title-bold);
`;
-const SidenavLink = styled.a<{ selected: SidenavLinkPropsType["selected"] }>`
+const LogoContainer = styled.div<{
+ hasAction?: boolean;
+ href?: Logo["href"];
+}>`
+ position: relative;
display: flex;
+ justify-content: center;
align-items: center;
- justify-content: space-between;
- padding: var(--spacing-padding-xs) var(--spacing-padding-ml);
- font-family: var(--typography-font-family);
- font-size: var(--typography-label-m);
- font-weight: var(--typography-label-regular);
text-decoration: none;
- cursor: pointer;
-
- ${(props) =>
- props.selected
- ? `color: var(--color-fg-neutral-bright); background-color: var(--color-bg-neutral-stronger);`
- : `color: var(--color-fg-neutral-stronger); background-color: transparent;`}
-
- &:focus, &:focus-visible {
- outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium);
- outline-offset: -2px;
- }
- &:hover,
- &:active {
- background-color: ${(props) =>
- props.selected ? "var(--color-bg-neutral-strongest)" : "var(--color-bg-neutral-medium)"};
- }
- span::before {
- font-size: var(--height-xxs);
- }
- svg {
- height: var(--height-xxs);
- width: 16px;
- }
`;
-const DxcSidenav = ({ title, children }: SidenavPropsType): JSX.Element => {
+const DxcSidenav = ({ title, children, items, logo }: SidenavPropsType): JSX.Element => {
+ const [isExpanded, setIsExpanded] = useState(true);
return (
-
- {title}
-
- {children}
+
+
+ {/* TODO: HANDLE TITLE */}
+ {
+ setIsExpanded((previousExpanded) => !previousExpanded);
+ }}
+ />
+ {isExpanded && (
+
+ {logo && (
+
+
+
+ )}
+ {title}
+
+ )}
+ {/* TODO: SEARCHBAR */}
+
+
+ {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);
-
- return (
-
-
- {collapsable && title ? (
- setCollapsed(!collapsed)}
- selectedGroup={collapsed && isSelected}
- >
-
- {typeof icon === "string" ? : icon}
- {title}
-
-
-
- ) : (
- title && (
-
- {typeof icon === "string" ? : icon}
- {title}
-
- )
- )}
- {!collapsed && children}
-
-
- );
-};
-
-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;
+// DxcSidenav.Section = Section;
+// DxcSidenav.Group = Group;
+// DxcSidenav.Link = Link;
+// DxcSidenav.Title = Title;
export default DxcSidenav;
diff --git a/packages/lib/src/sidenav/types.ts b/packages/lib/src/sidenav/types.ts
index ed577b49d5..699be3a59f 100644
--- a/packages/lib/src/sidenav/types.ts
+++ b/packages/lib/src/sidenav/types.ts
@@ -1,4 +1,4 @@
-import { MouseEvent, ReactNode } from "react";
+import { MouseEvent, ReactNode, ButtonHTMLAttributes, Dispatch, ReactElement, SetStateAction } from "react";
import { SVG } from "../common/utils";
export type SidenavTitlePropsType = {
@@ -68,15 +68,104 @@ export type SidenavLinkPropsType = {
tabIndex?: number;
};
+export type Logo = {
+ /**
+ * URL of the image that will be placed in the logo.
+ */
+ src: string;
+ /**
+ * Alternative text for the logo image.
+ */
+ alt: string;
+ /**
+ * URL to navigate to when the logo is clicked. If not provided, the logo will not be clickable.
+ */
+ onClick?: (event: MouseEvent) => void;
+ /**
+ * URL to navigate when the logo is clicked.
+ */
+ href?: string;
+};
+
type Props = {
/**
- * The area assigned to render the sidenav title. It is highly recommended to use the sidenav title.
+ * The title of the sidenav that will be placed under the logo.
*/
- title?: ReactNode;
+ title?: string;
/**
* The area inside the sidenav. This area can be used to render the content inside the sidenav.
*/
children: ReactNode;
+ /**
+ * Array of items to be displayed in the Nav menu.
+ * Each item can be a single/simple item, a group item or a section.
+ */
+ items: (Item | GroupItem)[] | Section[];
+ /**
+ * Object with the properties of the logo placed at the top of the sidenav.
+ */
+ logo: Logo;
+};
+
+type CommonItemProps = {
+ badge?: ReactElement;
+ icon?: string | SVG;
+ label: string;
+};
+type Item = CommonItemProps & {
+ onSelect?: () => void;
+ selectedByDefault?: boolean;
+};
+type GroupItem = CommonItemProps & {
+ items: (Item | GroupItem)[];
+};
+type Section = { items: (Item | GroupItem)[]; title?: string };
+
+type ItemWithId = Item & { id: number };
+type GroupItemWithId = {
+ badge?: ReactElement;
+ icon: string | SVG;
+ items: (ItemWithId | GroupItemWithId)[];
+ label: string;
+};
+type SectionWithId = { items: (ItemWithId | GroupItemWithId)[]; title?: string };
+
+type SingleItemProps = ItemWithId & { depthLevel: number };
+type GroupItemProps = GroupItemWithId & { depthLevel: number };
+type MenuItemProps = { item: ItemWithId | GroupItemWithId; depthLevel?: number };
+type ItemActionProps = ButtonHTMLAttributes & {
+ badge?: Item["badge"];
+ collapseIcon?: ReactNode;
+ depthLevel: number;
+ icon?: Item["icon"];
+ label: Item["label"];
+ selected: boolean;
+};
+type SectionProps = {
+ section: SectionWithId;
+ index: number;
+ length: number;
+};
+type SubMenuProps = { children: ReactNode; id?: string };
+type ContextualMenuContextProps = {
+ selectedItemId: number;
+ setSelectedItemId: Dispatch>;
+};
+
+export type {
+ ContextualMenuContextProps,
+ GroupItem,
+ GroupItemProps,
+ GroupItemWithId,
+ Item,
+ ItemActionProps,
+ ItemWithId,
+ SubMenuProps,
+ MenuItemProps,
+ Section,
+ SectionWithId,
+ SectionProps,
+ SingleItemProps,
};
export default Props;
From e4b7043ab4e3bb274f90adcdba42d97a4a0b364b Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Thu, 16 Oct 2025 16:15:17 +0200
Subject: [PATCH 02/36] First version of the new SideNav
---
.../src/contextual-menu/ContextualMenu.tsx | 27 +++--
.../lib/src/contextual-menu/GroupItem.tsx | 2 +-
.../lib/src/contextual-menu/ItemAction.tsx | 31 +++--
packages/lib/src/contextual-menu/Section.tsx | 2 +-
packages/lib/src/contextual-menu/SubMenu.tsx | 17 ++-
packages/lib/src/contextual-menu/types.ts | 32 +++++-
.../ContextualMenu.tsx | 62 ----------
.../ContextualMenuContext.tsx | 4 -
.../src/general-contextual-menu/GroupItem.tsx | 38 ------
.../general-contextual-menu/ItemAction.tsx | 108 ------------------
.../src/general-contextual-menu/MenuItem.tsx | 27 -----
.../src/general-contextual-menu/Section.tsx | 41 -------
.../general-contextual-menu/SingleItem.tsx | 30 -----
.../src/general-contextual-menu/SubMenu.tsx | 24 ----
.../lib/src/general-contextual-menu/types.ts | 76 ------------
.../lib/src/general-contextual-menu/utils.ts | 38 ------
packages/lib/src/sidenav/Sidenav.stories.tsx | 6 +-
packages/lib/src/sidenav/Sidenav.tsx | 4 +-
18 files changed, 93 insertions(+), 476 deletions(-)
delete mode 100644 packages/lib/src/general-contextual-menu/ContextualMenu.tsx
delete mode 100644 packages/lib/src/general-contextual-menu/ContextualMenuContext.tsx
delete mode 100644 packages/lib/src/general-contextual-menu/GroupItem.tsx
delete mode 100644 packages/lib/src/general-contextual-menu/ItemAction.tsx
delete mode 100644 packages/lib/src/general-contextual-menu/MenuItem.tsx
delete mode 100644 packages/lib/src/general-contextual-menu/Section.tsx
delete mode 100644 packages/lib/src/general-contextual-menu/SingleItem.tsx
delete mode 100644 packages/lib/src/general-contextual-menu/SubMenu.tsx
delete mode 100644 packages/lib/src/general-contextual-menu/types.ts
delete mode 100644 packages/lib/src/general-contextual-menu/utils.ts
diff --git a/packages/lib/src/contextual-menu/ContextualMenu.tsx b/packages/lib/src/contextual-menu/ContextualMenu.tsx
index 9fd9ded16c..d28ae6c311 100644
--- a/packages/lib/src/contextual-menu/ContextualMenu.tsx
+++ b/packages/lib/src/contextual-menu/ContextualMenu.tsx
@@ -8,11 +8,9 @@ import scrollbarStyles from "../styles/scroll";
import { addIdToItems, isSection } from "./utils";
import SubMenu from "./SubMenu";
-const ContextualMenuContainer = styled.div`
+const ContextualMenuContainer = styled.div<{ displayBorder: boolean }>`
box-sizing: border-box;
margin: 0;
- border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-lighter);
- border-radius: var(--border-radius-s);
padding: var(--spacing-padding-m) var(--spacing-padding-xs);
display: grid;
gap: var(--spacing-gap-xs);
@@ -21,15 +19,30 @@ const ContextualMenuContainer = styled.div`
background-color: var(--color-bg-neutral-lightest);
overflow-y: auto;
overflow-x: hidden;
- ${scrollbarStyles}
+ ${scrollbarStyles};
+
+ ${({ displayBorder }) =>
+ displayBorder &&
+ `
+ border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-lighter);
+ border-radius: var(--border-radius-s);
+ `}
`;
-export default function DxcContextualMenu({ items }: ContextualMenuPropsType) {
+export default function DxcContextualMenu({
+ items,
+ displayBorder = true,
+ displayGroupsLine = false,
+ displayControlsAfter = false,
+}: ContextualMenuPropsType) {
const [firstUpdate, setFirstUpdate] = useState(true);
const [selectedItemId, setSelectedItemId] = useState(-1);
const contextualMenuRef = useRef(null);
const itemsWithId = useMemo(() => addIdToItems(items), [items]);
- const contextValue = useMemo(() => ({ selectedItemId, setSelectedItemId }), [selectedItemId, setSelectedItemId]);
+ const contextValue = useMemo(
+ () => ({ selectedItemId, setSelectedItemId, displayGroupsLine, displayControlsAfter }),
+ [selectedItemId, setSelectedItemId, displayGroupsLine, displayControlsAfter]
+ );
useLayoutEffect(() => {
if (selectedItemId !== -1 && firstUpdate) {
@@ -45,7 +58,7 @@ export default function DxcContextualMenu({ items }: ContextualMenuPropsType) {
}, [firstUpdate, selectedItemId]);
return (
-
+
{itemsWithId[0] && isSection(itemsWithId[0]) ? (
(itemsWithId as SectionWithId[]).map((item, index) => (
diff --git a/packages/lib/src/contextual-menu/GroupItem.tsx b/packages/lib/src/contextual-menu/GroupItem.tsx
index ba794fd617..9112216fbe 100644
--- a/packages/lib/src/contextual-menu/GroupItem.tsx
+++ b/packages/lib/src/contextual-menu/GroupItem.tsx
@@ -25,7 +25,7 @@ const GroupItem = ({ items, ...props }: GroupItemProps) => {
{...props}
/>
{isOpen && (
-
{/* TODO: SEARCHBAR */}
-
+
{children}
From 9a887b359107402ec1fa55c7b99bb92dfadba92f Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Fri, 17 Oct 2025 14:55:03 +0200
Subject: [PATCH 03/36] Added responsiveness behavior
---
.../src/contextual-menu/ContextualMenu.tsx | 10 +--
.../lib/src/contextual-menu/GroupItem.tsx | 54 ++++++++++++-
.../lib/src/contextual-menu/ItemAction.tsx | 79 +++++++++++--------
packages/lib/src/contextual-menu/Section.tsx | 13 ++-
packages/lib/src/contextual-menu/types.ts | 6 ++
packages/lib/src/sidenav/Sidenav.stories.tsx | 69 ++++++++++------
packages/lib/src/sidenav/Sidenav.tsx | 18 ++++-
packages/lib/src/sidenav/types.ts | 5 +-
packages/lib/src/styles/variables.css | 1 +
9 files changed, 182 insertions(+), 73 deletions(-)
diff --git a/packages/lib/src/contextual-menu/ContextualMenu.tsx b/packages/lib/src/contextual-menu/ContextualMenu.tsx
index d28ae6c311..827cbe7f89 100644
--- a/packages/lib/src/contextual-menu/ContextualMenu.tsx
+++ b/packages/lib/src/contextual-menu/ContextualMenu.tsx
@@ -11,21 +11,20 @@ import SubMenu from "./SubMenu";
const ContextualMenuContainer = styled.div<{ displayBorder: boolean }>`
box-sizing: border-box;
margin: 0;
- padding: var(--spacing-padding-m) var(--spacing-padding-xs);
display: grid;
gap: var(--spacing-gap-xs);
- min-width: 248px;
+ /* min-width: 248px; */
max-height: 100%;
background-color: var(--color-bg-neutral-lightest);
overflow-y: auto;
overflow-x: hidden;
${scrollbarStyles};
-
${({ displayBorder }) =>
displayBorder &&
`
border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-lighter);
border-radius: var(--border-radius-s);
+ padding: var(--spacing-padding-m) var(--spacing-padding-xs);
`}
`;
@@ -34,14 +33,15 @@ export default function DxcContextualMenu({
displayBorder = true,
displayGroupsLine = false,
displayControlsAfter = false,
+ responsiveView = false,
}: ContextualMenuPropsType) {
const [firstUpdate, setFirstUpdate] = useState(true);
const [selectedItemId, setSelectedItemId] = useState(-1);
const contextualMenuRef = useRef(null);
const itemsWithId = useMemo(() => addIdToItems(items), [items]);
const contextValue = useMemo(
- () => ({ selectedItemId, setSelectedItemId, displayGroupsLine, displayControlsAfter }),
- [selectedItemId, setSelectedItemId, displayGroupsLine, displayControlsAfter]
+ () => ({ selectedItemId, setSelectedItemId, displayGroupsLine, displayControlsAfter, responsiveView }),
+ [selectedItemId, setSelectedItemId, displayGroupsLine, displayControlsAfter, responsiveView]
);
useLayoutEffect(() => {
diff --git a/packages/lib/src/contextual-menu/GroupItem.tsx b/packages/lib/src/contextual-menu/GroupItem.tsx
index 9112216fbe..8cf95d3186 100644
--- a/packages/lib/src/contextual-menu/GroupItem.tsx
+++ b/packages/lib/src/contextual-menu/GroupItem.tsx
@@ -6,14 +6,64 @@ import MenuItem from "./MenuItem";
import { GroupItemProps } from "./types";
import ContextualMenuContext from "./ContextualMenuContext";
import { isGroupSelected } from "./utils";
+import * as Popover from "@radix-ui/react-popover";
const GroupItem = ({ items, ...props }: GroupItemProps) => {
const groupMenuId = `group-menu-${useId()}`;
- const { selectedItemId } = useContext(ContextualMenuContext) ?? {};
+ const { selectedItemId, responsiveView } = useContext(ContextualMenuContext) ?? {};
const groupSelected = useMemo(() => isGroupSelected(items, selectedItemId), [items, selectedItemId]);
const [isOpen, setIsOpen] = useState(groupSelected && selectedItemId === -1);
- return (
+ const contextualMenuId = `sidenav-${useId()}`;
+
+ const contextValue = useContext(ContextualMenuContext) ?? {};
+
+ return responsiveView ? (
+ <>
+
+
+ : }
+ onClick={() => setIsOpen((isCurrentlyOpen) => !isCurrentlyOpen)}
+ selected={groupSelected && !isOpen}
+ {...props}
+ />
+
+
+
+ {
+ event.preventDefault();
+ }}
+ onOpenAutoFocus={(event) => {
+ event.preventDefault();
+ }}
+ align="start"
+ side="right"
+ style={{ zIndex: "var(--z-contextualmenu)" }}
+ >
+
+
+
+
+
+
+ >
+ ) : (
<>
`
box-sizing: content-box;
border: none;
border-radius: var(--border-radius-s);
- ${({ displayGroupsLine, depthLevel }) => `
- padding: var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xxs) calc(var(--spacing-padding-xs) + ${!displayGroupsLine ? depthLevel : 0} * var(--spacing-padding-l));
+ ${({ displayGroupsLine, depthLevel, responsiveView }) => `
+ ${!responsiveView ? `padding: var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xxs) calc(var(--spacing-padding-xs) + ${!displayGroupsLine ? depthLevel : 0} * var(--spacing-padding-l))` : "padding: var(--spacing-padding-xxs) var(--spacing-padding-none)"};
${displayGroupsLine && depthLevel > 0 ? "margin-left: var(--spacing-padding-xs);" : ""}
`}
display: flex;
align-items: center;
gap: var(--spacing-gap-m);
- justify-content: space-between;
+ justify-content: ${({ responsiveView }) => (responsiveView ? "center" : "space-between")};
background-color: ${({ selected }) => (selected ? "var(--color-bg-primary-lighter)" : "transparent")};
height: var(--height-s);
cursor: pointer;
@@ -76,35 +77,49 @@ const Control = styled.span`
gap: var(--spacing-gap-s);
`;
-const ItemAction = memo(({ badge, collapseIcon, depthLevel, icon, label, ...props }: ItemActionProps) => {
- const [hasTooltip, setHasTooltip] = useState(false);
- const modifiedBadge = badge && cloneElement(badge, { size: "small" });
- const { displayControlsAfter } = useContext(ContextualMenuContext) ?? {};
+const ItemAction = memo(
+ forwardRef(({ badge, collapseIcon, depthLevel, icon, label, ...props }, ref) => {
+ const [hasTooltip, setHasTooltip] = useState(false);
+ const modifiedBadge = badge && cloneElement(badge, { size: "small" });
+ const { displayControlsAfter, responsiveView, displayGroupsLine } = useContext(ContextualMenuContext) ?? {};
- return (
-
-
-
- {!displayControlsAfter && {collapseIcon && {collapseIcon} } }
- {icon && {typeof icon === "string" ? : icon} }
- ) => {
- const text = event.currentTarget;
- setHasTooltip(text.scrollWidth > text.clientWidth);
- }}
- >
- {label}
-
-
-
- {modifiedBadge}
- {displayControlsAfter && collapseIcon && {collapseIcon} }
-
-
-
- );
-});
+ return (
+
+
+
+ {!displayControlsAfter && {collapseIcon && {collapseIcon} } }
+
+ {typeof icon === "string" ? : icon}
+
+ {!responsiveView && (
+ ) => {
+ const text = event.currentTarget;
+ setHasTooltip(text.scrollWidth > text.clientWidth);
+ }}
+ >
+ {label}
+
+ )}
+
+ {!responsiveView && (
+
+ {modifiedBadge}
+ {displayControlsAfter && collapseIcon && {collapseIcon} }
+
+ )}
+
+
+ );
+ })
+);
ItemAction.displayName = "ItemAction";
diff --git a/packages/lib/src/contextual-menu/Section.tsx b/packages/lib/src/contextual-menu/Section.tsx
index df788ea07d..7fd823b8ed 100644
--- a/packages/lib/src/contextual-menu/Section.tsx
+++ b/packages/lib/src/contextual-menu/Section.tsx
@@ -1,10 +1,11 @@
-import { useId } from "react";
+import { useContext, useId } from "react";
import styled from "@emotion/styled";
import { DxcInset } from "..";
import DxcDivider from "../divider/Divider";
import SubMenu from "./SubMenu";
import MenuItem from "./MenuItem";
import { SectionProps } from "./types";
+import ContextualMenuContext from "./ContextualMenuContext";
const SectionContainer = styled.section`
display: grid;
@@ -22,8 +23,8 @@ const Title = styled.h2`
export default function Section({ index, length, section }: SectionProps) {
const id = `section-${useId()}`;
-
- return (
+ const { responsiveView } = useContext(ContextualMenuContext) ?? {};
+ return !responsiveView ? (
{section.title && {section.title} }
@@ -37,5 +38,11 @@ export default function Section({ index, length, section }: SectionProps) {
)}
+ ) : (
+
+ {section.items.map((item, i) => (
+
+ ))}
+
);
}
diff --git a/packages/lib/src/contextual-menu/types.ts b/packages/lib/src/contextual-menu/types.ts
index c2624262de..6b8aa00116 100644
--- a/packages/lib/src/contextual-menu/types.ts
+++ b/packages/lib/src/contextual-menu/types.ts
@@ -35,6 +35,11 @@ type Props = {
* @private
*/
displayControlsAfter?: boolean;
+ /**
+ * If true the contextual menu will be icons only and display a popover on click.
+ * @private
+ */
+ responsiveView?: boolean;
};
type ItemWithId = Item & { id: number };
@@ -75,6 +80,7 @@ type ContextualMenuContextProps = {
setSelectedItemId: Dispatch>;
displayGroupsLine: boolean;
displayControlsAfter: boolean;
+ responsiveView: boolean;
};
export type {
diff --git a/packages/lib/src/sidenav/Sidenav.stories.tsx b/packages/lib/src/sidenav/Sidenav.stories.tsx
index 3b44d37b69..b01e1ba318 100644
--- a/packages/lib/src/sidenav/Sidenav.stories.tsx
+++ b/packages/lib/src/sidenav/Sidenav.stories.tsx
@@ -5,8 +5,8 @@ import DxcSidenav from "./Sidenav";
import DxcBadge from "../badge/Badge";
import DxcFlex from "../flex/Flex";
import DxcTypography from "../typography/Typography";
-import DxcContainer from "../container/Container";
import DxcButton from "../button/Button";
+import DxcAvatar from "../avatar/Avatar";
export default {
title: "Sidenav",
@@ -18,12 +18,7 @@ const DetailedAvatar = () => {
{/* TODO: METER AVATAR */}
-
+
{
alt: "TEST",
}}
>
-
-
-
-
-
+ {(expanded: boolean) =>
+ expanded ? (
+ <>
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+ >
+ )
+ }
>
diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx
index bc5f9991a9..34258676f7 100644
--- a/packages/lib/src/sidenav/Sidenav.tsx
+++ b/packages/lib/src/sidenav/Sidenav.tsx
@@ -50,13 +50,16 @@ const LogoContainer = styled.div<{
const DxcSidenav = ({ title, children, items, logo }: SidenavPropsType): JSX.Element => {
const [isExpanded, setIsExpanded] = useState(true);
+
+ const renderedChildren = typeof children === "function" ? children(isExpanded) : children;
+
return (
-
+
{/* TODO: HANDLE TITLE */}
{
@@ -83,12 +86,19 @@ const DxcSidenav = ({ title, children, items, logo }: SidenavPropsType): JSX.Ele
)}
{/* TODO: SEARCHBAR */}
-
+
- {children}
+ {renderedChildren}
);
};
+
// DxcSidenav.Section = Section;
// DxcSidenav.Group = Group;
// DxcSidenav.Link = Link;
diff --git a/packages/lib/src/sidenav/types.ts b/packages/lib/src/sidenav/types.ts
index 699be3a59f..fbc7bc5e9f 100644
--- a/packages/lib/src/sidenav/types.ts
+++ b/packages/lib/src/sidenav/types.ts
@@ -93,9 +93,10 @@ type Props = {
*/
title?: string;
/**
- * The area inside the sidenav. This area can be used to render the content inside the sidenav.
+ * The additional content rendered inside the sidenav.
+ * It can also be a function that receives the expansion state to render different content based on it.
*/
- children: ReactNode;
+ children?: React.ReactNode | ((expanded: boolean) => React.ReactNode);
/**
* Array of items to be displayed in the Nav menu.
* Each item can be a single/simple item, a group item or a section.
diff --git a/packages/lib/src/styles/variables.css b/packages/lib/src/styles/variables.css
index 253df46a42..53b0b52dc2 100644
--- a/packages/lib/src/styles/variables.css
+++ b/packages/lib/src/styles/variables.css
@@ -16,6 +16,7 @@
--z-dropdown: 310;
--z-textinput: 320;
--z-select: 330;
+ --z-contextualmenu: 340;
/* Modals and overlays */
--z-dialog: 400;
From 83f634b83326798858459b7d59caae26513bd921 Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Mon, 20 Oct 2025 11:43:40 +0200
Subject: [PATCH 04/36] Made context props optional to prevent typing warnings
---
packages/lib/src/contextual-menu/types.ts | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/packages/lib/src/contextual-menu/types.ts b/packages/lib/src/contextual-menu/types.ts
index 6b8aa00116..7bcf59b1e5 100644
--- a/packages/lib/src/contextual-menu/types.ts
+++ b/packages/lib/src/contextual-menu/types.ts
@@ -76,11 +76,11 @@ type SectionProps = {
};
type SubMenuProps = { children: ReactNode; id?: string; depthLevel?: number };
type ContextualMenuContextProps = {
- selectedItemId: number;
- setSelectedItemId: Dispatch>;
- displayGroupsLine: boolean;
- displayControlsAfter: boolean;
- responsiveView: boolean;
+ selectedItemId?: number;
+ setSelectedItemId?: Dispatch>;
+ displayGroupsLine?: boolean;
+ displayControlsAfter?: boolean;
+ responsiveView?: boolean;
};
export type {
From 323a29be991f84ee19a2ed2bf689aac627f2051a Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Mon, 20 Oct 2025 11:49:18 +0200
Subject: [PATCH 05/36] Cleaned types definition file
---
packages/lib/src/sidenav/types.ts | 52 ++-----------------------------
1 file changed, 3 insertions(+), 49 deletions(-)
diff --git a/packages/lib/src/sidenav/types.ts b/packages/lib/src/sidenav/types.ts
index fbc7bc5e9f..e9f1a9eb50 100644
--- a/packages/lib/src/sidenav/types.ts
+++ b/packages/lib/src/sidenav/types.ts
@@ -1,4 +1,4 @@
-import { MouseEvent, ReactNode, ButtonHTMLAttributes, Dispatch, ReactElement, SetStateAction } from "react";
+import { MouseEvent, ReactNode, ReactElement } from "react";
import { SVG } from "../common/utils";
export type SidenavTitlePropsType = {
@@ -87,6 +87,8 @@ export type Logo = {
href?: string;
};
+type Section = { items: (Item | GroupItem)[]; title?: string };
+
type Props = {
/**
* The title of the sidenav that will be placed under the logo.
@@ -120,53 +122,5 @@ type Item = CommonItemProps & {
type GroupItem = CommonItemProps & {
items: (Item | GroupItem)[];
};
-type Section = { items: (Item | GroupItem)[]; title?: string };
-
-type ItemWithId = Item & { id: number };
-type GroupItemWithId = {
- badge?: ReactElement;
- icon: string | SVG;
- items: (ItemWithId | GroupItemWithId)[];
- label: string;
-};
-type SectionWithId = { items: (ItemWithId | GroupItemWithId)[]; title?: string };
-
-type SingleItemProps = ItemWithId & { depthLevel: number };
-type GroupItemProps = GroupItemWithId & { depthLevel: number };
-type MenuItemProps = { item: ItemWithId | GroupItemWithId; depthLevel?: number };
-type ItemActionProps = ButtonHTMLAttributes & {
- badge?: Item["badge"];
- collapseIcon?: ReactNode;
- depthLevel: number;
- icon?: Item["icon"];
- label: Item["label"];
- selected: boolean;
-};
-type SectionProps = {
- section: SectionWithId;
- index: number;
- length: number;
-};
-type SubMenuProps = { children: ReactNode; id?: string };
-type ContextualMenuContextProps = {
- selectedItemId: number;
- setSelectedItemId: Dispatch>;
-};
-
-export type {
- ContextualMenuContextProps,
- GroupItem,
- GroupItemProps,
- GroupItemWithId,
- Item,
- ItemActionProps,
- ItemWithId,
- SubMenuProps,
- MenuItemProps,
- Section,
- SectionWithId,
- SectionProps,
- SingleItemProps,
-};
export default Props;
From d90b88187ce298ad499b0450c21c397bb73a68a0 Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Mon, 20 Oct 2025 16:50:37 +0200
Subject: [PATCH 06/36] Added tests and stories and fixed types
---
.../src/contextual-menu/ContextualMenu.tsx | 6 +-
.../lib/src/contextual-menu/GroupItem.tsx | 2 +-
.../lib/src/contextual-menu/ItemAction.tsx | 12 +-
packages/lib/src/contextual-menu/SubMenu.tsx | 10 +-
packages/lib/src/contextual-menu/types.ts | 4 +-
.../sidenav/Sidenav.accessibility.test.tsx | 56 ++
packages/lib/src/sidenav/Sidenav.stories.tsx | 506 ++++++++++++++----
packages/lib/src/sidenav/Sidenav.test.tsx | 94 ++--
packages/lib/src/sidenav/Sidenav.tsx | 20 +-
packages/lib/src/sidenav/types.ts | 8 +-
10 files changed, 561 insertions(+), 157 deletions(-)
create mode 100644 packages/lib/src/sidenav/Sidenav.accessibility.test.tsx
diff --git a/packages/lib/src/contextual-menu/ContextualMenu.tsx b/packages/lib/src/contextual-menu/ContextualMenu.tsx
index 827cbe7f89..023a75e72e 100644
--- a/packages/lib/src/contextual-menu/ContextualMenu.tsx
+++ b/packages/lib/src/contextual-menu/ContextualMenu.tsx
@@ -31,7 +31,7 @@ const ContextualMenuContainer = styled.div<{ displayBorder: boolean }>`
export default function DxcContextualMenu({
items,
displayBorder = true,
- displayGroupsLine = false,
+ displayGroupLines = false,
displayControlsAfter = false,
responsiveView = false,
}: ContextualMenuPropsType) {
@@ -40,8 +40,8 @@ export default function DxcContextualMenu({
const contextualMenuRef = useRef(null);
const itemsWithId = useMemo(() => addIdToItems(items), [items]);
const contextValue = useMemo(
- () => ({ selectedItemId, setSelectedItemId, displayGroupsLine, displayControlsAfter, responsiveView }),
- [selectedItemId, setSelectedItemId, displayGroupsLine, displayControlsAfter, responsiveView]
+ () => ({ selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView }),
+ [selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView]
);
useLayoutEffect(() => {
diff --git a/packages/lib/src/contextual-menu/GroupItem.tsx b/packages/lib/src/contextual-menu/GroupItem.tsx
index 8cf95d3186..d8074f3122 100644
--- a/packages/lib/src/contextual-menu/GroupItem.tsx
+++ b/packages/lib/src/contextual-menu/GroupItem.tsx
@@ -39,7 +39,7 @@ const GroupItem = ({ items, ...props }: GroupItemProps) => {
/>
-
+
{
diff --git a/packages/lib/src/contextual-menu/ItemAction.tsx b/packages/lib/src/contextual-menu/ItemAction.tsx
index 45554c8ece..75b8976dd3 100644
--- a/packages/lib/src/contextual-menu/ItemAction.tsx
+++ b/packages/lib/src/contextual-menu/ItemAction.tsx
@@ -8,15 +8,15 @@ import ContextualMenuContext from "./ContextualMenuContext";
const Action = styled.button<{
depthLevel: ItemActionProps["depthLevel"];
selected: ItemActionProps["selected"];
- displayGroupsLine: boolean;
+ displayGroupLines: boolean;
responsiveView?: boolean;
}>`
box-sizing: content-box;
border: none;
border-radius: var(--border-radius-s);
- ${({ displayGroupsLine, depthLevel, responsiveView }) => `
- ${!responsiveView ? `padding: var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xxs) calc(var(--spacing-padding-xs) + ${!displayGroupsLine ? depthLevel : 0} * var(--spacing-padding-l))` : "padding: var(--spacing-padding-xxs) var(--spacing-padding-none)"};
- ${displayGroupsLine && depthLevel > 0 ? "margin-left: var(--spacing-padding-xs);" : ""}
+ ${({ displayGroupLines, depthLevel, responsiveView }) => `
+ ${!responsiveView ? `padding: var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xxs) calc(var(--spacing-padding-xs) + ${!displayGroupLines ? depthLevel : 0} * var(--spacing-padding-l))` : "padding: var(--spacing-padding-xxs) var(--spacing-padding-none)"};
+ ${displayGroupLines && depthLevel > 0 ? "margin-left: var(--spacing-padding-xs);" : ""}
`}
display: flex;
align-items: center;
@@ -81,14 +81,14 @@ const ItemAction = memo(
forwardRef(({ badge, collapseIcon, depthLevel, icon, label, ...props }, ref) => {
const [hasTooltip, setHasTooltip] = useState(false);
const modifiedBadge = badge && cloneElement(badge, { size: "small" });
- const { displayControlsAfter, responsiveView, displayGroupsLine } = useContext(ContextualMenuContext) ?? {};
+ const { displayControlsAfter, responsiveView, displayGroupLines } = useContext(ContextualMenuContext) ?? {};
return (
diff --git a/packages/lib/src/contextual-menu/SubMenu.tsx b/packages/lib/src/contextual-menu/SubMenu.tsx
index aa040515b5..0d29a7e2c1 100644
--- a/packages/lib/src/contextual-menu/SubMenu.tsx
+++ b/packages/lib/src/contextual-menu/SubMenu.tsx
@@ -3,15 +3,15 @@ import { SubMenuProps } from "./types";
import ContextualMenuContext from "./ContextualMenuContext";
import { useContext } from "react";
-const SubMenuContainer = styled.ul<{ depthLevel: number; displayGroupsLine?: boolean }>`
+const SubMenuContainer = styled.ul<{ depthLevel: number; displayGroupLines?: boolean }>`
margin: 0;
padding: 0;
display: grid;
gap: var(--spacing-gap-xs);
list-style: none;
- ${({ depthLevel, displayGroupsLine }) =>
- displayGroupsLine &&
+ ${({ depthLevel, displayGroupLines }) =>
+ displayGroupLines &&
depthLevel >= 0 &&
`
margin-left: calc(var(--spacing-padding-m) + ${depthLevel} * var(--spacing-padding-xs));
@@ -20,9 +20,9 @@ const SubMenuContainer = styled.ul<{ depthLevel: number; displayGroupsLine?: boo
`;
export default function SubMenu({ children, id, depthLevel = 0 }: SubMenuProps) {
- const { displayGroupsLine } = useContext(ContextualMenuContext) ?? {};
+ const { displayGroupLines } = useContext(ContextualMenuContext) ?? {};
return (
-
+
{children}
);
diff --git a/packages/lib/src/contextual-menu/types.ts b/packages/lib/src/contextual-menu/types.ts
index 7bcf59b1e5..ff2224ec72 100644
--- a/packages/lib/src/contextual-menu/types.ts
+++ b/packages/lib/src/contextual-menu/types.ts
@@ -29,7 +29,7 @@ type Props = {
* If true the contextual menu will have lines marking the groups.
* @private
*/
- displayGroupsLine?: boolean;
+ displayGroupLines?: boolean;
/**
* If true the contextual menu will have controls at the end.
* @private
@@ -78,7 +78,7 @@ type SubMenuProps = { children: ReactNode; id?: string; depthLevel?: number };
type ContextualMenuContextProps = {
selectedItemId?: number;
setSelectedItemId?: Dispatch>;
- displayGroupsLine?: boolean;
+ displayGroupLines?: boolean;
displayControlsAfter?: boolean;
responsiveView?: boolean;
};
diff --git a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx
new file mode 100644
index 0000000000..824daa05bc
--- /dev/null
+++ b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx
@@ -0,0 +1,56 @@
+import { render } from "@testing-library/react";
+import { axe } from "../../test/accessibility/axe-helper";
+import DxcSidenav from "./Sidenav";
+import DxcBadge from "../badge/Badge";
+
+describe("Sidenav component accessibility tests", () => {
+ it("Should not have basic accessibility issues", async () => {
+ const groupItems = [
+ {
+ title: "Section 1",
+ items: [
+ {
+ label: "Grouped Item 1",
+ icon: "favorite",
+ items: [
+ { label: "Item 1", icon: "person" },
+ {
+ label: "Grouped Item 2",
+ items: [
+ {
+ label: "Item 2",
+ icon: "bookmark",
+ badge: ,
+ },
+ { label: "Selected Item 3", selectedByDefault: true },
+ ],
+ },
+ ],
+ badge: ,
+ },
+ { label: "Item 4", icon: "key" },
+ ],
+ },
+ {
+ title: "Section 2",
+ items: [
+ { label: "Item 5", icon: "person" },
+ { label: "Grouped Item 6", items: [{ label: "Item 7", icon: "person" }, { label: "Item 8" }] },
+ { label: "Item 9" },
+ ],
+ },
+ ];
+ const { container } = render(
+
+ );
+ const results = await axe(container);
+ expect(results.violations).toHaveLength(0);
+ });
+});
diff --git a/packages/lib/src/sidenav/Sidenav.stories.tsx b/packages/lib/src/sidenav/Sidenav.stories.tsx
index b01e1ba318..8cc60a45d9 100644
--- a/packages/lib/src/sidenav/Sidenav.stories.tsx
+++ b/packages/lib/src/sidenav/Sidenav.stories.tsx
@@ -7,6 +7,7 @@ import DxcFlex from "../flex/Flex";
import DxcTypography from "../typography/Typography";
import DxcButton from "../button/Button";
import DxcAvatar from "../avatar/Avatar";
+import { userEvent, within } from "storybook/internal/test";
export default {
title: "Sidenav",
@@ -17,8 +18,7 @@ const DetailedAvatar = () => {
return (
- {/* TODO: METER AVATAR */}
-
+
{
- {/* TODO: DISCUSS WITH DESIGNERS ACTIONICON OR BUTTON? */}
{
);
};
-const SideNav = () => {
- const groupItems = [
- {
- title: "Section 1",
- items: [
- {
- label: "Grouped Item 1",
- icon: "favorite",
- items: [
- { label: "Item 1", icon: "person" },
- {
- label: "Grouped Item 2",
- items: [
- {
- label: "Item 2",
- icon: "bookmark",
- badge: ,
- },
- { label: "Selected Item 3", selectedByDefault: true },
- ],
- },
- ],
- badge: ,
- },
- { label: "Item 4", icon: "key" },
- ],
- },
- {
- title: "Section 2",
- items: [
- { label: "Item 5", icon: "person" },
- { label: "Grouped Item 6", items: [{ label: "Item 7", icon: "person" }, { label: "Item 8" }] },
- { label: "Item 9" },
- ],
- },
- ];
- return (
- <>
-
-
-
- {(expanded: boolean) =>
- expanded ? (
- <>
-
-
-
-
-
- >
- ) : (
- <>
-
-
-
-
-
- >
- )
- }
-
-
- >
- );
-};
+const groupItems = [
+ {
+ title: "Section 1",
+ items: [
+ {
+ label: "Grouped Item 1",
+ icon: "favorite",
+ items: [
+ { label: "Item 1", icon: "person" },
+ {
+ label: "Grouped Item 2",
+ items: [
+ {
+ label: "Item 2",
+ icon: "bookmark",
+ badge: ,
+ },
+ { label: "Selected Item 3" },
+ ],
+ },
+ ],
+ badge: ,
+ },
+ { label: "Item 4", icon: "key" },
+ ],
+ },
+ {
+ title: "Section 2",
+ items: [
+ { label: "Item 5", icon: "person" },
+ { label: "Grouped Item 6", items: [{ label: "Item 7", icon: "person" }, { label: "Item 8" }] },
+ { label: "Item 9" },
+ ],
+ },
+];
+const selectedGroupItems = [
+ {
+ title: "Section 1",
+ items: [
+ {
+ label: "Grouped Item 1",
+ icon: "favorite",
+ items: [
+ { label: "Item 1", icon: "person" },
+ {
+ label: "Grouped Item 2",
+ items: [
+ {
+ label: "Item 2",
+ icon: "bookmark",
+ badge: ,
+ },
+ { label: "Selected Item 3", selectedByDefault: true },
+ ],
+ },
+ ],
+ badge: ,
+ },
+ { label: "Item 4", icon: "key" },
+ ],
+ },
+ {
+ title: "Section 2",
+ items: [
+ { label: "Item 5", icon: "person" },
+ { label: "Grouped Item 6", items: [{ label: "Item 7", icon: "person" }, { label: "Item 8" }] },
+ { label: "Item 9" },
+ ],
+ },
+];
+
+const SideNav = () => (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+);
+
+const Collapsed = () => (
+ <>
+
+
+
+ {(expanded: boolean) =>
+ expanded ? (
+ <>
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+ >
+ )
+ }
+
+
+
+
+
+ {(expanded: boolean) =>
+ expanded ? (
+ <>
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+ >
+ )
+ }
+
+
+
+
+
+ {(expanded: boolean) =>
+ expanded ? (
+ <>
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+ >
+ )
+ }
+
+
+ >
+);
+
+const Hovered = () => (
+
+
+
+
+
+
+
+
+
+
+);
+
+const SelectedGroup = () => (
+
+
+
+
+
+
+
+
+
+
+);
type Story = StoryObj;
export const Chromatic: Story = {
render: SideNav,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const menuItem1 = (await canvas.findAllByRole("button"))[10];
+ if (menuItem1) {
+ await userEvent.click(menuItem1);
+ }
+ const menuItem2 = (await canvas.findAllByRole("button"))[12];
+ if (menuItem2) {
+ await userEvent.click(menuItem2);
+ }
+ },
+};
+
+export const CollapsedSideNav: Story = {
+ render: Collapsed,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const collapseButtons = await canvas.findAllByRole("button", { name: "Collapse" });
+ for (const button of collapseButtons) {
+ await userEvent.click(button);
+ }
+ const menuItem1 = (await canvas.findAllByRole("button"))[9];
+ if (menuItem1) {
+ await userEvent.click(menuItem1);
+ }
+ const menuItem2 = (await canvas.findAllByRole("button"))[11];
+ if (menuItem2) {
+ await userEvent.click(menuItem2);
+ }
+ const menuItem3 = (await canvas.findAllByRole("button"))[21];
+ if (menuItem3) {
+ await userEvent.click(menuItem3);
+ }
+ const menuItem4 = (await canvas.findAllByRole("button"))[23];
+ if (menuItem4) {
+ await userEvent.click(menuItem4);
+ }
+ },
+};
+
+export const HoveredSideNav: Story = {
+ render: Hovered,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ console.log(await canvas.findAllByRole("button"));
+ const menuItem1 = (await canvas.findAllByRole("button"))[1];
+ if (menuItem1) {
+ await userEvent.click(menuItem1);
+ }
+ const menuItem2 = (await canvas.findAllByRole("button"))[3];
+ if (menuItem2) {
+ await userEvent.click(menuItem2);
+ }
+ },
+};
+
+export const SelectedGroupSideNav: Story = {
+ render: SelectedGroup,
};
diff --git a/packages/lib/src/sidenav/Sidenav.test.tsx b/packages/lib/src/sidenav/Sidenav.test.tsx
index f124b938aa..c0fab0886b 100644
--- a/packages/lib/src/sidenav/Sidenav.test.tsx
+++ b/packages/lib/src/sidenav/Sidenav.test.tsx
@@ -1,40 +1,72 @@
-import { fireEvent, render } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { render, fireEvent } from "@testing-library/react";
import DxcSidenav from "./Sidenav";
+import DxcContextualMenu from "../contextual-menu/ContextualMenu";
-describe("Sidenav component tests", () => {
- test("Sidenav renders anchors and Section correctly", () => {
- const { getByText } = render(
-
-
- nav-content-test
- Link
-
+jest.mock("../contextual-menu/ContextualMenu", () => jest.fn(() =>
));
+
+describe("DxcSidenav component", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test("Sidenav renders title and children correctly", () => {
+ const { getByText, getByRole } = render(
+
+ Custom child content
);
- expect(getByText("nav-content-test")).toBeTruthy();
- const link = getByText("Link");
- expect(link.closest("a")?.getAttribute("href")).toBe("#");
+
+ expect(getByText("Main Menu")).toBeTruthy();
+ expect(getByText("Custom child content")).toBeTruthy();
+ const collapseButton = getByRole("button", { name: "Collapse" });
+ expect(collapseButton).toBeTruthy();
});
- test("Sidenav renders groups correctly", () => {
- const sidenav = render(
-
-
-
- Lorem ipsum
- Lorem ipsum
- Lorem ipsum
- Lorem ipsum
- Lorem ipsum
-
-
-
+ 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("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("renders contextual menu with items", () => {
+ const items = [{ label: "Dashboard" }, { label: "Settings" }];
+ const { getByTestId } = render( );
+ expect(getByTestId("mock-menu")).toBeTruthy();
+ expect(DxcContextualMenu).toHaveBeenCalledWith(
+ expect.objectContaining({
+ items,
+ displayGroupLines: false,
+ displayControlsAfter: true,
+ displayBorder: false,
+ }),
+ {}
);
- expect(sidenav.getByText("Collapsable")).toBeTruthy();
- let buttons = sidenav.getAllByRole("button");
- expect(buttons[0]?.getAttribute("aria-expanded")).toBe("true");
- fireEvent.click(sidenav.getByText("Collapsable"));
- buttons = sidenav.getAllByRole("button");
- expect(buttons[0]?.getAttribute("aria-expanded")).toBe("false");
+ });
+
+ test("renders children using function pattern", () => {
+ const childFn = jest.fn((expanded) => {expanded ? "Expanded content" : "Collapsed content"}
);
+
+ const { getByText, getByRole } = render({childFn} );
+ expect(getByText("Expanded content")).toBeTruthy();
+ expect(childFn).toHaveBeenCalledWith(true);
+ const collapseButton = getByRole("button", { name: "Collapse" });
+ expect(collapseButton).toBeTruthy();
+ fireEvent.click(collapseButton);
+ expect(childFn).toHaveBeenCalledWith(false);
});
});
diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx
index 34258676f7..4df5a28fa5 100644
--- a/packages/lib/src/sidenav/Sidenav.tsx
+++ b/packages/lib/src/sidenav/Sidenav.tsx
@@ -48,7 +48,7 @@ const LogoContainer = styled.div<{
text-decoration: none;
`;
-const DxcSidenav = ({ title, children, items, logo }: SidenavPropsType): JSX.Element => {
+const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }: SidenavPropsType): JSX.Element => {
const [isExpanded, setIsExpanded] = useState(true);
const renderedChildren = typeof children === "function" ? children(isExpanded) : children;
@@ -72,7 +72,7 @@ const DxcSidenav = ({ title, children, items, logo }: SidenavPropsType): JSX.Ele
{/* TODO: SEARCHBAR */}
-
+ {items && (
+
+ )}
{renderedChildren}
diff --git a/packages/lib/src/sidenav/types.ts b/packages/lib/src/sidenav/types.ts
index e9f1a9eb50..74fcf285d8 100644
--- a/packages/lib/src/sidenav/types.ts
+++ b/packages/lib/src/sidenav/types.ts
@@ -103,11 +103,15 @@ type Props = {
* Array of items to be displayed in the Nav menu.
* Each item can be a single/simple item, a group item or a section.
*/
- items: (Item | GroupItem)[] | Section[];
+ items?: (Item | GroupItem)[] | Section[];
/**
* Object with the properties of the logo placed at the top of the sidenav.
*/
- logo: Logo;
+ logo?: Logo;
+ /**
+ * If true the nav menu will have lines marking the groups.
+ */
+ displayGroupLines?: boolean;
};
type CommonItemProps = {
From 9713d2c9d850d3b53650f03c01e6c551c032dc66 Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Thu, 23 Oct 2025 08:31:39 +0200
Subject: [PATCH 07/36] Made changes in sidenav API to fit our documentation
website
---
apps/website/pages/_app.tsx | 100 ++++++++-------
.../code/ApplicationLayoutCodePage.tsx | 18 ---
.../code/ContextualMenuCodePage.tsx | 2 +-
.../ContextualMenu.stories.tsx | 4 +-
.../contextual-menu/ContextualMenu.test.tsx | 6 +-
.../src/contextual-menu/ContextualMenu.tsx | 12 +-
.../lib/src/contextual-menu/ItemAction.tsx | 83 +++++++------
.../lib/src/contextual-menu/SingleItem.tsx | 12 +-
packages/lib/src/contextual-menu/types.ts | 10 +-
packages/lib/src/contextual-menu/utils.ts | 2 +-
.../src/layout/ApplicationLayout.stories.tsx | 115 ++++++-----------
packages/lib/src/layout/ApplicationLayout.tsx | 116 ++----------------
packages/lib/src/layout/types.ts | 5 -
.../sidenav/Sidenav.accessibility.test.tsx | 2 +-
packages/lib/src/sidenav/Sidenav.stories.tsx | 7 +-
packages/lib/src/sidenav/Sidenav.tsx | 29 +++--
packages/lib/src/sidenav/SidenavContext.tsx | 7 +-
packages/lib/src/sidenav/types.ts | 2 +-
18 files changed, 208 insertions(+), 324 deletions(-)
diff --git a/apps/website/pages/_app.tsx b/apps/website/pages/_app.tsx
index fd97164ccd..959f5f74a1 100644
--- a/apps/website/pages/_app.tsx
+++ b/apps/website/pages/_app.tsx
@@ -1,17 +1,16 @@
-import { ReactElement, ReactNode, useMemo, useState } from "react";
+import { ReactElement, ReactNode, useEffect } from "react";
import type { NextPage } from "next";
import type { AppProps } from "next/app";
import Head from "next/head";
-import { DxcApplicationLayout, DxcTextInput, DxcToastsQueue } from "@dxc-technology/halstack-react";
-import SidenavLogo from "@/common/sidenav/SidenavLogo";
+import { DxcApplicationLayout, DxcToastsQueue } from "@dxc-technology/halstack-react";
import MainContent from "@/common/MainContent";
import { useRouter } from "next/router";
import { LinksSectionDetails, LinksSections } from "@/common/pagesList";
-import Link from "next/link";
import StatusBadge from "@/common/StatusBadge";
import "../global-styles.css";
import createCache, { EmotionCache } from "@emotion/cache";
import { CacheProvider } from "@emotion/react";
+import { usePathname } from "next/navigation";
type NextPageWithLayout = NextPage & {
getLayout?: (_page: ReactElement) => ReactNode;
@@ -26,54 +25,69 @@ const clientSideEmotionCache = createCache({ key: "css", prepend: true });
export default function App({ Component, pageProps, emotionCache = clientSideEmotionCache }: AppPropsWithLayout) {
const getLayout = Component.getLayout || ((page) => page);
const componentWithLayout = getLayout( );
- const [filter, setFilter] = useState("");
- const { asPath: currentPath } = useRouter();
- const filteredLinks = useMemo(() => {
- const filtered: LinksSectionDetails[] = [];
- LinksSections.map((section) => {
- const sectionFilteredLinks = section?.links.filter((link) =>
- link.label.toLowerCase().includes(filter.toLowerCase())
- );
- if (sectionFilteredLinks.length) {
- filtered.push({ label: section.label, links: sectionFilteredLinks });
- }
- });
- return filtered;
- }, [filter]);
+ const router = useRouter();
+ const pathname = usePathname();
+ // const [filter, setFilter] = useState("");
+ // const filteredLinks = useMemo(() => {
+ // const filtered: LinksSectionDetails[] = [];
+ // LinksSections.map((section) => {
+ // const sectionFilteredLinks = section?.links.filter((link) =>
+ // link.label.toLowerCase().includes(filter.toLowerCase())
+ // );
+ // if (sectionFilteredLinks.length) {
+ // filtered.push({ label: section.label, links: sectionFilteredLinks });
+ // }
+ // });
+ // return filtered;
+ // }, [filter]);
+
+ const mapLinksToGroupItems = (sections: LinksSectionDetails[]) => {
+ const matchPaths = (linkPath: string) => {
+ const desiredPaths = [linkPath, `${linkPath}/code`];
+ const pathToBeMatched = pathname?.split("#")[0]?.slice(0, -1);
+ return pathToBeMatched ? desiredPaths.includes(pathToBeMatched) : false;
+ };
- const matchPaths = (linkPath: string) => {
- const desiredPaths = [linkPath, `${linkPath}/code`];
- const pathToBeMatched = currentPath?.split("#")[0]?.slice(0, -1);
- return pathToBeMatched ? desiredPaths.includes(pathToBeMatched) : false;
+ return sections.map((section) => ({
+ title: section.label,
+ items: section.links.map((link) => ({
+ label: link.label,
+ onSelect: () => router.push(link.path),
+ selected: matchPaths(link.path),
+ ...(link.status && {
+ badge: link.status !== "stable" ? : undefined,
+ }),
+ })),
+ }));
};
+ useEffect(() => {
+ const paths = [...new Set(LinksSections.flatMap((s) => s.links.map((l) => l.path)))];
+ const prefetchPaths = async () => {
+ for (const path of paths) {
+ await router.prefetch(path);
+ }
+ };
+ void prefetchPaths();
+ }, []);
+
+ // TODO: ADD FILTERING
+ // TODO: ADD CATEGORIZATION
+
+ const sections = mapLinksToGroupItems(LinksSections);
+
return (
}>
-
- {
- setFilter(value);
- }}
- size="fillParent"
- clearable
- margin={{
- top: "large",
- bottom: "large",
- right: "medium",
- left: "medium",
- }}
- />
-
- {filteredLinks?.map(({ label, links }) => (
+ }
+ >
+ {/* {filteredLinks?.map(({ label, links }) => (
{links.map(({ label, path, status }) => (
@@ -91,7 +105,7 @@ export default function App({ Component, pageProps, emotionCache = clientSideEmo
GitHub
-
+ */}
}
>
diff --git a/apps/website/screens/components/application-layout/code/ApplicationLayoutCodePage.tsx b/apps/website/screens/components/application-layout/code/ApplicationLayoutCodePage.tsx
index c115d03a5b..c06008ad6e 100644
--- a/apps/website/screens/components/application-layout/code/ApplicationLayoutCodePage.tsx
+++ b/apps/website/screens/components/application-layout/code/ApplicationLayoutCodePage.tsx
@@ -75,14 +75,6 @@ const ApplicationLayoutPropsTable = () => (
-
-
- 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 f6e796569a..e35303c443 100644
--- a/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx
+++ b/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx
@@ -12,7 +12,7 @@ const itemTypeString = `{
icon?: string | SVG;
label: string;
onSelect?: () => void;
- selectedByDefault?: boolean;
+ selected?: boolean;
}`;
const groupItemTypeString = `{
diff --git a/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx b/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx
index f174fbabe8..a83b217fee 100644
--- a/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx
+++ b/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx
@@ -42,7 +42,7 @@ const groupItems = [
icon: "bookmark",
badge: ,
},
- { label: "Selected Item 3", selectedByDefault: true },
+ { label: "Selected Item 3", selected: true },
],
},
],
@@ -102,7 +102,7 @@ const sectionsWithScroll = [
{ label: "Approved locations" },
{ label: "Approved locations" },
{ label: "Approved locations" },
- { label: "Approved locations", selectedByDefault: true },
+ { label: "Approved locations", selected: true },
],
},
];
diff --git a/packages/lib/src/contextual-menu/ContextualMenu.test.tsx b/packages/lib/src/contextual-menu/ContextualMenu.test.tsx
index 59af5b6fd8..06958825d0 100644
--- a/packages/lib/src/contextual-menu/ContextualMenu.test.tsx
+++ b/packages/lib/src/contextual-menu/ContextualMenu.test.tsx
@@ -41,11 +41,11 @@ describe("Contextual menu component tests", () => {
expect(actions[0]?.getAttribute("aria-pressed")).toBeTruthy();
expect(getByRole("menu")).toBeTruthy();
});
- test("Single — An item can appear as selected by default by using the attribute selectedByDefault", () => {
+ test("Single — An item can appear as selected by default by using the attribute selected", () => {
const test = [
{
label: "Tested item",
- selectedByDefault: true,
+ selected: true,
},
];
const { getByRole } = render( );
@@ -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 023a75e72e..d50ed86c75 100644
--- a/packages/lib/src/contextual-menu/ContextualMenu.tsx
+++ b/packages/lib/src/contextual-menu/ContextualMenu.tsx
@@ -34,14 +34,22 @@ export default function DxcContextualMenu({
displayGroupLines = false,
displayControlsAfter = false,
responsiveView = false,
+ allowNavigation = false,
}: ContextualMenuPropsType) {
const [firstUpdate, setFirstUpdate] = useState(true);
const [selectedItemId, setSelectedItemId] = useState(-1);
const contextualMenuRef = useRef(null);
const itemsWithId = useMemo(() => addIdToItems(items), [items]);
const contextValue = useMemo(
- () => ({ selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView }),
- [selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView]
+ () => ({
+ selectedItemId,
+ setSelectedItemId,
+ displayGroupLines,
+ displayControlsAfter,
+ responsiveView,
+ allowNavigation,
+ }),
+ [selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView, allowNavigation]
);
useLayoutEffect(() => {
diff --git a/packages/lib/src/contextual-menu/ItemAction.tsx b/packages/lib/src/contextual-menu/ItemAction.tsx
index 75b8976dd3..aeb2ae3e95 100644
--- a/packages/lib/src/contextual-menu/ItemAction.tsx
+++ b/packages/lib/src/contextual-menu/ItemAction.tsx
@@ -26,6 +26,7 @@ const Action = styled.button<{
height: var(--height-s);
cursor: pointer;
overflow: hidden;
+ text-decoration: none;
&:hover {
background-color: ${({ selected }) =>
@@ -78,47 +79,53 @@ const Control = styled.span`
`;
const ItemAction = memo(
- forwardRef(({ badge, collapseIcon, depthLevel, icon, label, ...props }, ref) => {
- const [hasTooltip, setHasTooltip] = useState(false);
- const modifiedBadge = badge && cloneElement(badge, { size: "small" });
- const { displayControlsAfter, responsiveView, displayGroupLines } = useContext(ContextualMenuContext) ?? {};
+ forwardRef(
+ ({ badge, collapseIcon, depthLevel, icon, label, href, ...props }, ref) => {
+ const [hasTooltip, setHasTooltip] = useState(false);
+ const modifiedBadge = badge && cloneElement(badge, { size: "small" });
+ const { displayControlsAfter, responsiveView, displayGroupLines, allowNavigation } =
+ useContext(ContextualMenuContext) ?? {};
- return (
-
-
-
- {!displayControlsAfter && {collapseIcon && {collapseIcon} } }
-
- {typeof icon === "string" ? : icon}
-
+ return (
+
+
+
+ {!displayControlsAfter && {collapseIcon && {collapseIcon} } }
+
+ {typeof icon === "string" ? : icon}
+
+ {!responsiveView && (
+ ) => {
+ const text = event.currentTarget;
+ setHasTooltip(text.scrollWidth > text.clientWidth);
+ }}
+ >
+ {label}
+
+ )}
+
{!responsiveView && (
- ) => {
- const text = event.currentTarget;
- setHasTooltip(text.scrollWidth > text.clientWidth);
- }}
- >
- {label}
-
+
+ {modifiedBadge}
+ {displayControlsAfter && collapseIcon && {collapseIcon} }
+
)}
-
- {!responsiveView && (
-
- {modifiedBadge}
- {displayControlsAfter && collapseIcon && {collapseIcon} }
-
- )}
-
-
- );
- })
+
+
+ );
+ }
+ )
);
ItemAction.displayName = "ItemAction";
diff --git a/packages/lib/src/contextual-menu/SingleItem.tsx b/packages/lib/src/contextual-menu/SingleItem.tsx
index 5fcd304d91..ab7152282a 100644
--- a/packages/lib/src/contextual-menu/SingleItem.tsx
+++ b/packages/lib/src/contextual-menu/SingleItem.tsx
@@ -3,7 +3,7 @@ import ItemAction from "./ItemAction";
import { SingleItemProps } from "./types";
import ContextualMenuContext from "./ContextualMenuContext";
-export default function SingleItem({ id, onSelect, selectedByDefault = false, ...props }: SingleItemProps) {
+export default function SingleItem({ id, onSelect, selected = false, ...props }: SingleItemProps) {
const { selectedItemId, setSelectedItemId } = useContext(ContextualMenuContext) ?? {};
const handleClick = () => {
@@ -12,18 +12,16 @@ export default function SingleItem({ id, onSelect, selectedByDefault = false, ..
};
useEffect(() => {
- if (selectedItemId === -1 && selectedByDefault) {
+ if (selectedItemId === -1 && selected) {
setSelectedItemId?.(id);
}
- }, [selectedItemId, selectedByDefault, id]);
+ }, [selectedItemId, selected, id]);
return (
);
diff --git a/packages/lib/src/contextual-menu/types.ts b/packages/lib/src/contextual-menu/types.ts
index ff2224ec72..6d54167486 100644
--- a/packages/lib/src/contextual-menu/types.ts
+++ b/packages/lib/src/contextual-menu/types.ts
@@ -8,7 +8,8 @@ type CommonItemProps = {
};
type Item = CommonItemProps & {
onSelect?: () => void;
- selectedByDefault?: boolean;
+ selected?: boolean;
+ href?: string;
};
type GroupItem = CommonItemProps & {
items: (Item | GroupItem)[];
@@ -40,6 +41,11 @@ type Props = {
* @private
*/
responsiveView?: boolean;
+ /**
+ * If true the leaf nodes will be rendered as anchor elements when href is provided.
+ * @private
+ */
+ allowNavigation?: boolean;
};
type ItemWithId = Item & { id: number };
@@ -68,6 +74,7 @@ type ItemActionProps = ButtonHTMLAttributes & {
icon?: Item["icon"];
label: Item["label"];
selected: boolean;
+ href?: Item["href"];
};
type SectionProps = {
section: SectionWithId;
@@ -81,6 +88,7 @@ type ContextualMenuContextProps = {
displayGroupLines?: boolean;
displayControlsAfter?: boolean;
responsiveView?: boolean;
+ allowNavigation?: boolean;
};
export type {
diff --git a/packages/lib/src/contextual-menu/utils.ts b/packages/lib/src/contextual-menu/utils.ts
index 3dfe2fb6d8..77db32b032 100644
--- a/packages/lib/src/contextual-menu/utils.ts
+++ b/packages/lib/src/contextual-menu/utils.ts
@@ -34,5 +34,5 @@ export const isGroupSelected = (items: GroupItemProps["items"], selectedItemId?:
items.some((item) => {
if ("items" in item) return isGroupSelected(item.items, selectedItemId);
else if (selectedItemId !== -1) return item.id === selectedItemId;
- else return item.selectedByDefault;
+ else return item.selected;
});
diff --git a/packages/lib/src/layout/ApplicationLayout.stories.tsx b/packages/lib/src/layout/ApplicationLayout.stories.tsx
index bfd20f7f0b..71aab7b79e 100644
--- a/packages/lib/src/layout/ApplicationLayout.stories.tsx
+++ b/packages/lib/src/layout/ApplicationLayout.stories.tsx
@@ -22,26 +22,33 @@ const ApplicationLayout = () => (
>
);
+const items = [
+ {
+ label: "SideNav Content",
+ icon: "tab",
+ },
+ {
+ label: "SideNav Content",
+ icon: "tab",
+ },
+ {
+ label: "SideNav Content",
+ icon: "tab",
+ },
+ {
+ label: "SideNav Content",
+ icon: "tab",
+ },
+ {
+ label: "SideNav Content",
+ icon: "tab",
+ },
+];
+
const ApplicationLayoutDefaultSidenav = () => (
<>
- Application layout with push sidenav
-
- }
- >
-
- SideNav Content
- SideNav Content
- SideNav Content
- SideNav Content
- SideNav Content
-
-
- }
+ sidenav={ }
>
Main Content
@@ -56,22 +63,9 @@ const ApplicationLayoutDefaultSidenav = () => (
const ApplicationLayoutResponsiveSidenav = () => (
<>
- Application layout with push sidenav
-
- }
- >
-
- SideNav Content
- SideNav Content
- SideNav Content
- SideNav Content
- SideNav Content
-
+
+ {(expanded: boolean) => (!expanded ? Responsive Content
: <>>)}
}
>
@@ -89,23 +83,7 @@ const ApplicationLayoutCustomHeader = () => (
<>
Custom Header
}
- sidenav={
-
- Application layout with push sidenav
-
- }
- >
-
- SideNav Content
- SideNav Content
- SideNav Content
- SideNav Content
- SideNav Content
-
-
- }
+ sidenav={ }
>
Main Content
@@ -121,23 +99,7 @@ const ApplicationLayoutCustomFooter = () => (
<>
Custom Footer}
- sidenav={
-
- Application layout with push sidenav
-
- }
- >
-
- SideNav Content
- SideNav Content
- SideNav Content
- SideNav Content
- SideNav Content
-
-
- }
+ sidenav={ }
>
Main Content
@@ -151,13 +113,7 @@ const ApplicationLayoutCustomFooter = () => (
const Tooltip = () => (
-
- SideNav Content
-
-
- }
+ sidenav={ }
>
Main Content
@@ -181,6 +137,13 @@ export const ApplicationLayoutWithResponsiveSidenav: Story = {
globals: {
viewport: { value: "pixel", isRotated: false },
},
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const collapseButton = (await canvas.findAllByRole("button"))[0];
+ if (collapseButton) {
+ await userEvent.click(collapseButton);
+ }
+ },
};
export const ApplicationLayoutWithCustomHeader: Story = {
@@ -201,7 +164,9 @@ export const ApplicationLayoutTooltip: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- const toggleVisibility = await canvas.findByRole("button");
- await userEvent.hover(toggleVisibility);
+ const collapseButton = (await canvas.findAllByRole("button"))[0];
+ if (collapseButton) {
+ await userEvent.hover(collapseButton);
+ }
},
};
diff --git a/packages/lib/src/layout/ApplicationLayout.tsx b/packages/lib/src/layout/ApplicationLayout.tsx
index e77175ed60..810aca93cb 100644
--- a/packages/lib/src/layout/ApplicationLayout.tsx
+++ b/packages/lib/src/layout/ApplicationLayout.tsx
@@ -1,20 +1,12 @@
-import { useContext, useEffect, useRef, useState } from "react";
+import { useRef } from "react";
import styled from "@emotion/styled";
-import { responsiveSizes } from "../common/variables";
import DxcFooter from "../footer/Footer";
import DxcHeader from "../header/Header";
-import DxcIcon from "../icon/Icon";
-// import DxcSidenav from "../sidenav/Sidenav";
-import { SidenavContextProvider, useResponsiveSidenavVisibility } from "../sidenav/SidenavContext";
-import { Tooltip } from "../tooltip/Tooltip";
+import DxcSidenav from "../sidenav/Sidenav";
import ApplicationLayoutPropsType, { AppLayoutMainPropsType } from "./types";
-import { bottomLinks, findChildType, socialLinks, useResponsive, year } from "./utils";
-import { HalstackLanguageContext } from "../HalstackContext";
+import { bottomLinks, findChildType, socialLinks, year } from "./utils";
-const ApplicationLayoutContainer = styled.div<{
- isSidenavVisible: boolean;
- hasSidenav: boolean;
-}>`
+const ApplicationLayoutContainer = styled.div`
top: 0;
left: 0;
display: grid;
@@ -23,10 +15,6 @@ const ApplicationLayoutContainer = styled.div<{
width: 100vw;
position: absolute;
overflow: hidden;
-
- @media (max-width: ${responsiveSizes.large}rem) {
- ${(props) => props.isSidenavVisible && "overflow: hidden;"}
- }
`;
const HeaderContainer = styled.div`
@@ -35,44 +23,6 @@ const HeaderContainer = styled.div`
z-index: var(--z-app-layout-header);
`;
-const VisibilityToggle = styled.div`
- box-sizing: border-box;
- display: flex;
- align-items: center;
- padding: var(--spacing-padding-xxs) var(--spacing-padding-m);
- width: 100%;
- background-color: var(--color-bg-neutral-light);
- user-select: none;
- z-index: 1;
-`;
-
-const HamburgerTrigger = styled.button`
- display: flex;
- flex-wrap: wrap;
- gap: var(--spacing-gap-s);
- border: 0px solid transparent;
- border-radius: var(--border-radius-xs);
- padding: var(--spacing-gap-none) var(--spacing-gap-none);
- background-color: transparent;
- font-family: var(--typography-font-family);
- font-weight: var(--typography-label-semibold);
- font-size: var(--typography-label-m);
- color: var(--color-fg-neutral-dark);
- cursor: pointer;
-
- :active {
- background-color: var(--color-bg-neutral-lightest);
- }
- :focus,
- :focus-visible {
- outline: none;
- outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium);
- }
- span::before {
- font-size: var(--height-xs);
- }
-`;
-
const BodyContainer = styled.div`
display: flex;
width: 100%;
@@ -87,12 +37,6 @@ const SidenavContainer = styled.div`
z-index: var(--z-app-layout-sidenav);
position: sticky;
overflow: auto;
-
- @media (max-width: ${responsiveSizes.large}rem) {
- position: absolute;
- top: 0px;
- height: 100%;
- }
`;
const MainContainer = styled.div`
@@ -118,55 +62,14 @@ const MainContentContainer = styled.main`
const Main = ({ children }: AppLayoutMainPropsType): JSX.Element => {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 45d151c95d..a00cddee67 100644
--- a/packages/lib/src/layout/types.ts
+++ b/packages/lib/src/layout/types.ts
@@ -19,11 +19,6 @@ export type AppLayoutSidenavPropsType = {
};
type ApplicationLayoutPropsType = {
- /**
- * Text to be placed next to the hamburger button that toggles the
- * visibility of the sidenav.
- */
- visibilityToggleLabel?: string;
/**
* Header content.
*/
diff --git a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx
index 824daa05bc..d7c31a7830 100644
--- a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx
+++ b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx
@@ -22,7 +22,7 @@ describe("Sidenav component accessibility tests", () => {
icon: "bookmark",
badge: ,
},
- { label: "Selected Item 3", selectedByDefault: true },
+ { label: "Selected Item 3", selected: true },
],
},
],
diff --git a/packages/lib/src/sidenav/Sidenav.stories.tsx b/packages/lib/src/sidenav/Sidenav.stories.tsx
index 8cc60a45d9..82feda81f0 100644
--- a/packages/lib/src/sidenav/Sidenav.stories.tsx
+++ b/packages/lib/src/sidenav/Sidenav.stories.tsx
@@ -56,7 +56,7 @@ const groupItems = [
label: "Grouped Item 1",
icon: "favorite",
items: [
- { label: "Item 1", icon: "person" },
+ { label: "Item 1", icon: "person", href: "#1" },
{
label: "Grouped Item 2",
items: [
@@ -67,11 +67,12 @@ const groupItems = [
},
{ label: "Selected Item 3" },
],
+ href: "#2",
},
],
badge: ,
},
- { label: "Item 4", icon: "key" },
+ { label: "Item 4", icon: "key", href: "#3" },
],
},
{
@@ -101,7 +102,7 @@ const selectedGroupItems = [
icon: "bookmark",
badge: ,
},
- { label: "Selected Item 3", selectedByDefault: true },
+ { label: "Selected Item 3", selected: true },
],
},
],
diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx
index 4df5a28fa5..62421cebbb 100644
--- a/packages/lib/src/sidenav/Sidenav.tsx
+++ b/packages/lib/src/sidenav/Sidenav.tsx
@@ -2,12 +2,12 @@ import styled from "@emotion/styled";
import { responsiveSizes } from "../common/variables";
import DxcFlex from "../flex/Flex";
import SidenavPropsType, { Logo } from "./types";
-import scrollbarStyles from "../styles/scroll";
import DxcDivider from "../divider/Divider";
import DxcButton from "../button/Button";
import DxcContextualMenu from "../contextual-menu/ContextualMenu";
import DxcImage from "../image/Image";
import { useState } from "react";
+import DxcTextInput from "../text-input/TextInput";
const SidenavContainer = styled.div<{ expanded: boolean }>`
box-sizing: border-box;
@@ -23,9 +23,6 @@ const SidenavContainer = styled.div<{ expanded: boolean }>`
padding: var(--spacing-padding-m) var(--spacing-padding-xs);
gap: var(--spacing-gap-l);
background-color: var(--color-bg-neutral-lightest);
- /* overflow-y: auto;
- overflow-x: hidden;
- ${scrollbarStyles} */
`;
const SidenavTitle = styled.div`
@@ -56,7 +53,6 @@ const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }:
return (
- {/* TODO: HANDLE TITLE */}
{isExpanded && (
+ {/* TODO: ADD GORGORITO TO COVER CASES WITH NO ICON? */}
{logo && (
{/* TODO: SEARCHBAR */}
+
+ {/* {
+ setFilter(value);
+ }}
+ size="fillParent"
+ clearable
+ margin={{
+ top: "large",
+ bottom: "large",
+ right: "medium",
+ left: "medium",
+ }}
+ /> */}
{items && (
)}
@@ -101,9 +115,4 @@ const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }:
);
};
-// DxcSidenav.Section = Section;
-// DxcSidenav.Group = Group;
-// DxcSidenav.Link = Link;
-// DxcSidenav.Title = Title;
-
export default DxcSidenav;
diff --git a/packages/lib/src/sidenav/SidenavContext.tsx b/packages/lib/src/sidenav/SidenavContext.tsx
index fc8f9d2b3c..7d16fa2018 100644
--- a/packages/lib/src/sidenav/SidenavContext.tsx
+++ b/packages/lib/src/sidenav/SidenavContext.tsx
@@ -1,4 +1,4 @@
-import { createContext, Dispatch, SetStateAction, useContext } from "react";
+import { createContext, Dispatch, SetStateAction } from "react";
type SidenavContextType = (_isSidenavVisible: boolean) => void;
@@ -9,8 +9,3 @@ export const GroupContext = createContext> | nu
export const SidenavContextProvider = SidenavContext.Provider;
export const GroupContextProvider = GroupContext.Provider;
-
-export const useResponsiveSidenavVisibility = () => {
- const changeResponsiveSidenavVisibility = useContext(SidenavContext);
- return changeResponsiveSidenavVisibility;
-};
diff --git a/packages/lib/src/sidenav/types.ts b/packages/lib/src/sidenav/types.ts
index 74fcf285d8..3d0b0c433c 100644
--- a/packages/lib/src/sidenav/types.ts
+++ b/packages/lib/src/sidenav/types.ts
@@ -121,7 +121,7 @@ type CommonItemProps = {
};
type Item = CommonItemProps & {
onSelect?: () => void;
- selectedByDefault?: boolean;
+ selected?: boolean;
};
type GroupItem = CommonItemProps & {
items: (Item | GroupItem)[];
From 16eba2e585971d53be580737c52cf2bf80eaf8b4 Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Thu, 23 Oct 2025 12:10:41 +0200
Subject: [PATCH 08/36] Created new TreeNavigation
---
packages/lib/src/sidenav/Sidenav.tsx | 37 ++-
packages/lib/src/sidenav/types.ts | 4 +-
.../lib/src/tree-navigation/GroupItem.tsx | 88 +++++++
.../lib/src/tree-navigation/ItemAction.tsx | 133 ++++++++++
packages/lib/src/tree-navigation/MenuItem.tsx | 21 ++
.../NavigationTree.accessibility.test.tsx | 100 ++++++++
.../NavigationTree.stories.tsx | 240 ++++++++++++++++++
.../tree-navigation/NavigationTree.test.tsx | 153 +++++++++++
.../src/tree-navigation/NavigationTree.tsx | 85 +++++++
.../tree-navigation/NavigationTreeContext.tsx | 4 +
packages/lib/src/tree-navigation/Section.tsx | 48 ++++
.../lib/src/tree-navigation/SingleItem.tsx | 28 ++
packages/lib/src/tree-navigation/SubMenu.tsx | 29 +++
packages/lib/src/tree-navigation/types.ts | 105 ++++++++
packages/lib/src/tree-navigation/utils.ts | 38 +++
15 files changed, 1097 insertions(+), 16 deletions(-)
create mode 100644 packages/lib/src/tree-navigation/GroupItem.tsx
create mode 100644 packages/lib/src/tree-navigation/ItemAction.tsx
create mode 100644 packages/lib/src/tree-navigation/MenuItem.tsx
create mode 100644 packages/lib/src/tree-navigation/NavigationTree.accessibility.test.tsx
create mode 100644 packages/lib/src/tree-navigation/NavigationTree.stories.tsx
create mode 100644 packages/lib/src/tree-navigation/NavigationTree.test.tsx
create mode 100644 packages/lib/src/tree-navigation/NavigationTree.tsx
create mode 100644 packages/lib/src/tree-navigation/NavigationTreeContext.tsx
create mode 100644 packages/lib/src/tree-navigation/Section.tsx
create mode 100644 packages/lib/src/tree-navigation/SingleItem.tsx
create mode 100644 packages/lib/src/tree-navigation/SubMenu.tsx
create mode 100644 packages/lib/src/tree-navigation/types.ts
create mode 100644 packages/lib/src/tree-navigation/utils.ts
diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx
index 62421cebbb..fe70f0f4ec 100644
--- a/packages/lib/src/sidenav/Sidenav.tsx
+++ b/packages/lib/src/sidenav/Sidenav.tsx
@@ -4,10 +4,10 @@ import DxcFlex from "../flex/Flex";
import SidenavPropsType, { Logo } from "./types";
import DxcDivider from "../divider/Divider";
import DxcButton from "../button/Button";
-import DxcContextualMenu from "../contextual-menu/ContextualMenu";
import DxcImage from "../image/Image";
-import { useState } from "react";
+import { ReactElement, useState } from "react";
import DxcTextInput from "../text-input/TextInput";
+import DxcNavigationTree from "../tree-navigation/NavigationTree";
const SidenavContainer = styled.div<{ expanded: boolean }>`
box-sizing: border-box;
@@ -50,6 +50,10 @@ const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }:
const renderedChildren = typeof children === "function" ? children(isExpanded) : children;
+ function isLogoObject(logo: Logo | ReactElement): logo is Logo {
+ return (logo as Logo).src !== undefined;
+ }
+
return (
@@ -66,17 +70,22 @@ const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }:
{/* TODO: ADD GORGORITO TO COVER CASES WITH NO ICON? */}
{logo && (
-
-
-
+ <>
+ {isLogoObject(logo) ? (
+
+
+
+ ) : (
+ logo
+ )}
+ >
)}
{title}
@@ -100,7 +109,7 @@ const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }:
}}
/> */}
{items && (
- {
+ const groupMenuId = `group-menu-${useId()}`;
+ const { selectedItemId, responsiveView } = useContext(NavigationTreeContext) ?? {};
+ const groupSelected = useMemo(() => isGroupSelected(items, selectedItemId), [items, selectedItemId]);
+ const [isOpen, setIsOpen] = useState(groupSelected && selectedItemId === -1);
+
+ const NavigationTreeId = `sidenav-${useId()}`;
+
+ const contextValue = useContext(NavigationTreeContext) ?? {};
+
+ return responsiveView ? (
+ <>
+
+
+ : }
+ onClick={() => setIsOpen((isCurrentlyOpen) => !isCurrentlyOpen)}
+ selected={groupSelected && !isOpen}
+ {...props}
+ />
+
+
+
+ {
+ event.preventDefault();
+ }}
+ onOpenAutoFocus={(event) => {
+ event.preventDefault();
+ }}
+ align="start"
+ side="right"
+ style={{ zIndex: "var(--z-contextualmenu)" }}
+ >
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+ : }
+ onClick={() => setIsOpen((isCurrentlyOpen) => !isCurrentlyOpen)}
+ selected={groupSelected && !isOpen}
+ {...props}
+ />
+ {isOpen && (
+
+ )}
+ >
+ );
+};
+
+export default GroupItem;
diff --git a/packages/lib/src/tree-navigation/ItemAction.tsx b/packages/lib/src/tree-navigation/ItemAction.tsx
new file mode 100644
index 0000000000..c0e0209616
--- /dev/null
+++ b/packages/lib/src/tree-navigation/ItemAction.tsx
@@ -0,0 +1,133 @@
+import { cloneElement, forwardRef, memo, MouseEvent, useContext, useState } from "react";
+import styled from "@emotion/styled";
+import { ItemActionProps } from "./types";
+import DxcIcon from "../icon/Icon";
+import { TooltipWrapper } from "../tooltip/Tooltip";
+import NavigationTreeContext from "./NavigationTreeContext";
+
+const Action = styled.button<{
+ depthLevel: ItemActionProps["depthLevel"];
+ selected: ItemActionProps["selected"];
+ displayGroupLines: boolean;
+ responsiveView?: boolean;
+}>`
+ box-sizing: content-box;
+ border: none;
+ border-radius: var(--border-radius-s);
+ ${({ displayGroupLines, depthLevel, responsiveView }) => `
+ ${!responsiveView ? `padding: var(--spacing-padding-xxs) var(--spacing-padding-xxs) var(--spacing-padding-xxs) calc(var(--spacing-padding-xs) + ${!displayGroupLines ? depthLevel : 0} * var(--spacing-padding-l))` : "padding: var(--spacing-padding-xxs) var(--spacing-padding-none)"};
+ ${displayGroupLines && depthLevel > 0 ? "margin-left: var(--spacing-padding-xs);" : ""}
+ `}
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-gap-m);
+ justify-content: ${({ responsiveView }) => (responsiveView ? "center" : "space-between")};
+ background-color: ${({ selected }) => (selected ? "var(--color-bg-primary-lighter)" : "transparent")};
+ height: var(--height-s);
+ cursor: pointer;
+ overflow: hidden;
+ text-decoration: none;
+
+ &:hover {
+ background-color: ${({ selected }) =>
+ selected ? "var(--color-bg-primary-medium)" : "var(--color-bg-neutral-light)"};
+ }
+ &:active {
+ background-color: ${({ selected }) =>
+ selected ? "var(--color-bg-primary-medium)" : "var(--color-bg-neutral-light)"};
+ }
+ &:focus {
+ outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium);
+ outline-offset: -2px;
+ }
+`;
+
+const Label = styled.span`
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-gap-s);
+ overflow: hidden;
+`;
+
+const Icon = styled.span`
+ display: flex;
+ color: var(--color-fg-neutral-dark);
+ font-size: var(--height-xxs);
+ svg {
+ height: var(--height-xxs);
+ width: 16px;
+ }
+`;
+
+const Text = styled.span<{ selected: ItemActionProps["selected"] }>`
+ color: var(--color-fg-neutral-dark);
+ font-family: var(--typography-font-family);
+ font-size: var(--typography-label-m);
+ font-weight: ${({ selected }) => (selected ? "var(--typography-label-semibold)" : "var(--typography-label-regular)")};
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+`;
+
+const Control = styled.span`
+ display: flex;
+ align-items: center;
+ padding: var(--spacing-padding-none);
+ justify-content: flex-end;
+ align-items: center;
+ gap: var(--spacing-gap-s);
+`;
+
+const ItemAction = memo(
+ forwardRef(
+ ({ badge, collapseIcon, depthLevel, icon, label, href, ...props }, ref) => {
+ const [hasTooltip, setHasTooltip] = useState(false);
+ const modifiedBadge = badge && cloneElement(badge, { size: "small" });
+ const { displayControlsAfter, responsiveView, displayGroupLines, allowNavigation } =
+ useContext(NavigationTreeContext) ?? {};
+
+ return (
+
+
+
+ {!displayControlsAfter && {collapseIcon && {collapseIcon} } }
+
+ {typeof icon === "string" ? : icon}
+
+ {!responsiveView && (
+ ) => {
+ const text = event.currentTarget;
+ setHasTooltip(text.scrollWidth > text.clientWidth);
+ }}
+ >
+ {label}
+
+ )}
+
+ {!responsiveView && (
+
+ {modifiedBadge}
+ {displayControlsAfter && collapseIcon && {collapseIcon} }
+
+ )}
+
+
+ );
+ }
+ )
+);
+
+ItemAction.displayName = "ItemAction";
+
+export default ItemAction;
diff --git a/packages/lib/src/tree-navigation/MenuItem.tsx b/packages/lib/src/tree-navigation/MenuItem.tsx
new file mode 100644
index 0000000000..65aadf7f17
--- /dev/null
+++ b/packages/lib/src/tree-navigation/MenuItem.tsx
@@ -0,0 +1,21 @@
+import styled from "@emotion/styled";
+import GroupItem from "./GroupItem";
+import SingleItem from "./SingleItem";
+import { MenuItemProps } from "./types";
+
+const MenuItemContainer = styled.li`
+ display: grid;
+ gap: var(--spacing-gap-xs);
+`;
+
+export default function MenuItem({ item, depthLevel = 0 }: MenuItemProps) {
+ return (
+
+ {"items" in item ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/packages/lib/src/tree-navigation/NavigationTree.accessibility.test.tsx b/packages/lib/src/tree-navigation/NavigationTree.accessibility.test.tsx
new file mode 100644
index 0000000000..ca6231b0f4
--- /dev/null
+++ b/packages/lib/src/tree-navigation/NavigationTree.accessibility.test.tsx
@@ -0,0 +1,100 @@
+import { render } from "@testing-library/react";
+import { axe } from "../../test/accessibility/axe-helper";
+import DxcBadge from "../badge/Badge";
+import DxcNavigationTree from "./NavigationTree";
+
+const badgeIcon = (
+
+
+
+
+
+);
+
+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/tree-navigation/NavigationTree.stories.tsx b/packages/lib/src/tree-navigation/NavigationTree.stories.tsx
new file mode 100644
index 0000000000..4fc831a429
--- /dev/null
+++ b/packages/lib/src/tree-navigation/NavigationTree.stories.tsx
@@ -0,0 +1,240 @@
+import ExampleContainer from "../../.storybook/components/ExampleContainer";
+import Title from "../../.storybook/components/Title";
+import DxcBadge from "../badge/Badge";
+import DxcContainer from "../container/Container";
+import DxcNavigationTree from "./NavigationTree";
+import SingleItem from "./SingleItem";
+import NavigationTreeContext from "./NavigationTreeContext";
+import { Meta, StoryObj } from "@storybook/react-vite";
+import { userEvent, within } from "storybook/internal/test";
+
+export default {
+ title: "Navigation Tree",
+ component: DxcNavigationTree,
+} satisfies Meta;
+
+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/tree-navigation/NavigationTree.test.tsx b/packages/lib/src/tree-navigation/NavigationTree.test.tsx
new file mode 100644
index 0000000000..643172d3cb
--- /dev/null
+++ b/packages/lib/src/tree-navigation/NavigationTree.test.tsx
@@ -0,0 +1,153 @@
+import { fireEvent, render } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import DxcNavigationTree from "./NavigationTree";
+
+const items = [{ label: "Item 1" }, { label: "Item 2" }, { label: "Item 3" }, { label: "Item 4" }];
+
+const sections = [
+ {
+ title: "Section title",
+ items: [{ label: "Approved locations" }, { label: "Approved locations" }, { label: "Approved locations" }],
+ },
+ {
+ items: [{ label: "Approved locations" }, { label: "Approved locations" }, { label: "Approved locations" }],
+ },
+];
+
+const groups = [
+ {
+ label: "Grouped Item 1",
+ items: [
+ { label: "Item 1" },
+ {
+ label: "Grouped Item 2",
+ items: [{ label: "Item 2" }, { label: "Item 3" }],
+ },
+ ],
+ },
+ { label: "Item 4", icon: "key" },
+ { label: "Grouped Item 3", items: [{ label: "Item 6" }, { label: "Item 7" }] },
+ { label: "Item 8" },
+];
+
+describe("Navigation tree component tests", () => {
+ test("Single — Renders with correct aria attributes", () => {
+ const { getAllByRole, getByRole } = render( );
+ 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/tree-navigation/NavigationTree.tsx b/packages/lib/src/tree-navigation/NavigationTree.tsx
new file mode 100644
index 0000000000..c1e3126637
--- /dev/null
+++ b/packages/lib/src/tree-navigation/NavigationTree.tsx
@@ -0,0 +1,85 @@
+import { useLayoutEffect, useMemo, useRef, useState } from "react";
+import styled from "@emotion/styled";
+import MenuItem from "./MenuItem";
+import NavigationTreePropsType, { GroupItemWithId, ItemWithId, SectionWithId } from "./types";
+import Section from "./Section";
+import NavigationTreeContext from "./NavigationTreeContext";
+import scrollbarStyles from "../styles/scroll";
+import { addIdToItems, isSection } from "./utils";
+import SubMenu from "./SubMenu";
+
+const NavigationTreeContainer = styled.div<{ displayBorder: boolean }>`
+ box-sizing: border-box;
+ margin: 0;
+ display: grid;
+ gap: var(--spacing-gap-xs);
+ /* min-width: 248px; */
+ max-height: 100%;
+ background-color: var(--color-bg-neutral-lightest);
+ overflow-y: auto;
+ overflow-x: hidden;
+ ${scrollbarStyles};
+ ${({ displayBorder }) =>
+ displayBorder &&
+ `
+ border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-lighter);
+ border-radius: var(--border-radius-s);
+ padding: var(--spacing-padding-m) var(--spacing-padding-xs);
+ `}
+`;
+
+export default function DxcNavigationTree({
+ items,
+ displayBorder = true,
+ displayGroupLines = false,
+ displayControlsAfter = false,
+ responsiveView = false,
+ allowNavigation = false,
+}: NavigationTreePropsType) {
+ const [firstUpdate, setFirstUpdate] = useState(true);
+ const [selectedItemId, setSelectedItemId] = useState(-1);
+ const NavigationTreeRef = useRef(null);
+ const itemsWithId = useMemo(() => addIdToItems(items), [items]);
+ const contextValue = useMemo(
+ () => ({
+ selectedItemId,
+ setSelectedItemId,
+ displayGroupLines,
+ displayControlsAfter,
+ responsiveView,
+ allowNavigation,
+ }),
+ [selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView, allowNavigation]
+ );
+
+ useLayoutEffect(() => {
+ if (selectedItemId !== -1 && firstUpdate) {
+ const NavigationTreeEl = NavigationTreeRef.current;
+ const selectedItemEl = NavigationTreeEl?.querySelector("[aria-pressed='true']");
+ if (selectedItemEl instanceof HTMLButtonElement) {
+ NavigationTreeEl?.scrollTo?.({
+ top: (selectedItemEl?.offsetTop ?? 0) - (NavigationTreeEl?.clientHeight ?? 0) / 2,
+ });
+ }
+ setFirstUpdate(false);
+ }
+ }, [firstUpdate, selectedItemId]);
+
+ return (
+
+
+ {itemsWithId[0] && isSection(itemsWithId[0]) ? (
+ (itemsWithId as SectionWithId[]).map((item, index) => (
+
+ ))
+ ) : (
+
+ {(itemsWithId as (GroupItemWithId | ItemWithId)[]).map((item, index) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/packages/lib/src/tree-navigation/NavigationTreeContext.tsx b/packages/lib/src/tree-navigation/NavigationTreeContext.tsx
new file mode 100644
index 0000000000..99fc7b12e2
--- /dev/null
+++ b/packages/lib/src/tree-navigation/NavigationTreeContext.tsx
@@ -0,0 +1,4 @@
+import { createContext } from "react";
+import { NavigationTreeContextProps } from "./types";
+
+export default createContext(null);
diff --git a/packages/lib/src/tree-navigation/Section.tsx b/packages/lib/src/tree-navigation/Section.tsx
new file mode 100644
index 0000000000..2b25e7d4c2
--- /dev/null
+++ b/packages/lib/src/tree-navigation/Section.tsx
@@ -0,0 +1,48 @@
+import { useContext, useId } from "react";
+import styled from "@emotion/styled";
+import { DxcInset } from "..";
+import DxcDivider from "../divider/Divider";
+import SubMenu from "./SubMenu";
+import MenuItem from "./MenuItem";
+import { SectionProps } from "./types";
+import NavigationTreeContext from "./NavigationTreeContext";
+
+const SectionContainer = styled.section`
+ display: grid;
+ gap: var(--spacing-gap-xs);
+`;
+
+const Title = styled.h2`
+ all: unset;
+ color: var(--color-fg-neutral-dark);
+ font-family: var(--typography-font-family);
+ font-size: var(--typography-label-l);
+ font-weight: var(--typography-label-semibold);
+ padding: var(--spacing-padding-xxs);
+`;
+
+export default function Section({ index, length, section }: SectionProps) {
+ const id = `section-${useId()}`;
+ const { responsiveView } = useContext(NavigationTreeContext) ?? {};
+ return !responsiveView ? (
+
+ {section.title && {section.title} }
+
+ {section.items.map((item, i) => (
+
+ ))}
+
+ {index !== length - 1 && (
+
+
+
+ )}
+
+ ) : (
+
+ {section.items.map((item, i) => (
+
+ ))}
+
+ );
+}
diff --git a/packages/lib/src/tree-navigation/SingleItem.tsx b/packages/lib/src/tree-navigation/SingleItem.tsx
new file mode 100644
index 0000000000..07e02373e8
--- /dev/null
+++ b/packages/lib/src/tree-navigation/SingleItem.tsx
@@ -0,0 +1,28 @@
+import { useContext, useEffect } from "react";
+import ItemAction from "./ItemAction";
+import { SingleItemProps } from "./types";
+import NavigationTreeContext from "./NavigationTreeContext";
+
+export default function SingleItem({ id, onSelect, selected = false, ...props }: SingleItemProps) {
+ const { selectedItemId, setSelectedItemId } = useContext(NavigationTreeContext) ?? {};
+
+ const handleClick = () => {
+ setSelectedItemId?.(id);
+ onSelect?.();
+ };
+
+ useEffect(() => {
+ if (selectedItemId === -1 && selected) {
+ setSelectedItemId?.(id);
+ }
+ }, [selectedItemId, selected, id]);
+
+ return (
+
+ );
+}
diff --git a/packages/lib/src/tree-navigation/SubMenu.tsx b/packages/lib/src/tree-navigation/SubMenu.tsx
new file mode 100644
index 0000000000..40414e071f
--- /dev/null
+++ b/packages/lib/src/tree-navigation/SubMenu.tsx
@@ -0,0 +1,29 @@
+import styled from "@emotion/styled";
+import { SubMenuProps } from "./types";
+import NavigationTreeContext from "./NavigationTreeContext";
+import { useContext } from "react";
+
+const SubMenuContainer = styled.ul<{ depthLevel: number; displayGroupLines?: boolean }>`
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: var(--spacing-gap-xs);
+ list-style: none;
+
+ ${({ depthLevel, displayGroupLines }) =>
+ displayGroupLines &&
+ depthLevel >= 0 &&
+ `
+ margin-left: calc(var(--spacing-padding-m) + ${depthLevel} * var(--spacing-padding-xs));
+ border-left: var(--border-width-s) solid var(--border-color-neutral-lighter);
+ `}
+`;
+
+export default function SubMenu({ children, id, depthLevel = 0 }: SubMenuProps) {
+ const { displayGroupLines } = useContext(NavigationTreeContext) ?? {};
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/lib/src/tree-navigation/types.ts b/packages/lib/src/tree-navigation/types.ts
new file mode 100644
index 0000000000..45f4409cde
--- /dev/null
+++ b/packages/lib/src/tree-navigation/types.ts
@@ -0,0 +1,105 @@
+import { ButtonHTMLAttributes, Dispatch, ReactElement, ReactNode, SetStateAction } from "react";
+import { SVG } from "../common/utils";
+
+type CommonItemProps = {
+ badge?: ReactElement;
+ icon?: string | SVG;
+ label: string;
+};
+type Item = CommonItemProps & {
+ onSelect?: () => void;
+ selected?: boolean;
+ href?: string;
+};
+type GroupItem = CommonItemProps & {
+ items: (Item | GroupItem)[];
+};
+type Section = { items: (Item | GroupItem)[]; title?: string };
+type Props = {
+ /**
+ * Array of items to be displayed in the navigation tree.
+ * Each item can be a single/simple item, a group item or a section.
+ */
+ items: (Item | GroupItem)[] | Section[];
+ /**
+ * If true the navigation tree will be displayed with a border.
+ */
+ displayBorder?: boolean;
+ /**
+ * If true the navigation tree will have lines marking the groups.
+ */
+ displayGroupLines?: boolean;
+ /**
+ * If true the navigation tree will have controls at the end.
+ */
+ displayControlsAfter?: boolean;
+ /**
+ * If true the navigation tree will be icons only and display a popover on click.
+ */
+ responsiveView?: boolean;
+ /**
+ * If true the leaf nodes will be rendered as anchor elements when href is provided.
+ */
+ allowNavigation?: boolean;
+};
+
+type ItemWithId = Item & { id: number };
+type GroupItemWithId = {
+ badge?: ReactElement;
+ icon: string | SVG;
+ items: (ItemWithId | GroupItemWithId)[];
+ label: string;
+};
+type SectionWithId = { items: (ItemWithId | GroupItemWithId)[]; title?: string };
+
+type SingleItemProps = ItemWithId & {
+ depthLevel: number;
+};
+type GroupItemProps = GroupItemWithId & {
+ depthLevel: number;
+};
+type MenuItemProps = {
+ item: ItemWithId | GroupItemWithId;
+ depthLevel?: number;
+};
+type ItemActionProps = ButtonHTMLAttributes & {
+ badge?: Item["badge"];
+ collapseIcon?: ReactNode;
+ depthLevel: number;
+ icon?: Item["icon"];
+ label: Item["label"];
+ selected: boolean;
+ href?: Item["href"];
+};
+type SectionProps = {
+ section: SectionWithId;
+ index: number;
+ length: number;
+};
+type SubMenuProps = { children: ReactNode; id?: string; depthLevel?: number };
+type NavigationTreeContextProps = {
+ selectedItemId?: number;
+ setSelectedItemId?: Dispatch>;
+ displayGroupLines?: boolean;
+ displayControlsAfter?: boolean;
+ responsiveView?: boolean;
+ allowNavigation?: boolean;
+};
+
+export type {
+ NavigationTreeContextProps,
+ GroupItem,
+ GroupItemProps,
+ GroupItemWithId,
+ Item,
+ ItemActionProps,
+ ItemWithId,
+ SubMenuProps,
+ MenuItemProps,
+ Section,
+ SectionWithId,
+ SectionProps,
+ SingleItemProps,
+};
+
+export default Props;
diff --git a/packages/lib/src/tree-navigation/utils.ts b/packages/lib/src/tree-navigation/utils.ts
new file mode 100644
index 0000000000..77db32b032
--- /dev/null
+++ b/packages/lib/src/tree-navigation/utils.ts
@@ -0,0 +1,38 @@
+import ContextualMenuPropsType, {
+ GroupItem,
+ GroupItemProps,
+ GroupItemWithId,
+ Item,
+ ItemWithId,
+ Section as SectionType,
+ SectionWithId,
+} from "./types";
+
+export const isGroupItem = (item: Item | GroupItem): item is GroupItem => "items" in item;
+
+export const isSection = (item: SectionType | Item | GroupItem): item is SectionType =>
+ "items" in item && !("label" in item);
+
+export const addIdToItems = (
+ items: ContextualMenuPropsType["items"]
+): (ItemWithId | GroupItemWithId | SectionWithId)[] => {
+ let accId = 0;
+ const innerAddIdToItems = (
+ items: ContextualMenuPropsType["items"]
+ ): (ItemWithId | GroupItemWithId | SectionWithId)[] =>
+ items.map((item: Item | GroupItem | SectionType) =>
+ isSection(item)
+ ? ({ ...item, items: innerAddIdToItems(item.items) } as SectionWithId)
+ : isGroupItem(item)
+ ? ({ ...item, items: innerAddIdToItems(item.items) } as GroupItemWithId)
+ : { ...item, id: accId++ }
+ );
+ return innerAddIdToItems(items);
+};
+
+export const isGroupSelected = (items: GroupItemProps["items"], selectedItemId?: number): boolean =>
+ items.some((item) => {
+ if ("items" in item) return isGroupSelected(item.items, selectedItemId);
+ else if (selectedItemId !== -1) return item.id === selectedItemId;
+ else return item.selected;
+ });
From 57645fa4434892a271285358114e177cce7f2cf8 Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Thu, 23 Oct 2025 12:17:34 +0200
Subject: [PATCH 09/36] Restored contextualmenu
---
.../ContextualMenu.stories.tsx | 4 +-
.../contextual-menu/ContextualMenu.test.tsx | 6 +-
.../src/contextual-menu/ContextualMenu.tsx | 41 ++------
.../lib/src/contextual-menu/GroupItem.tsx | 56 +----------
.../lib/src/contextual-menu/ItemAction.tsx | 93 ++++++-------------
packages/lib/src/contextual-menu/Section.tsx | 15 +--
.../lib/src/contextual-menu/SingleItem.tsx | 12 ++-
packages/lib/src/contextual-menu/SubMenu.tsx | 17 +---
packages/lib/src/contextual-menu/types.ts | 52 ++---------
packages/lib/src/contextual-menu/utils.ts | 2 +-
10 files changed, 67 insertions(+), 231 deletions(-)
diff --git a/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx b/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx
index a83b217fee..f174fbabe8 100644
--- a/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx
+++ b/packages/lib/src/contextual-menu/ContextualMenu.stories.tsx
@@ -42,7 +42,7 @@ const groupItems = [
icon: "bookmark",
badge: ,
},
- { label: "Selected Item 3", selected: true },
+ { label: "Selected Item 3", selectedByDefault: true },
],
},
],
@@ -102,7 +102,7 @@ const sectionsWithScroll = [
{ label: "Approved locations" },
{ label: "Approved locations" },
{ label: "Approved locations" },
- { label: "Approved locations", selected: true },
+ { label: "Approved locations", selectedByDefault: true },
],
},
];
diff --git a/packages/lib/src/contextual-menu/ContextualMenu.test.tsx b/packages/lib/src/contextual-menu/ContextualMenu.test.tsx
index 06958825d0..59af5b6fd8 100644
--- a/packages/lib/src/contextual-menu/ContextualMenu.test.tsx
+++ b/packages/lib/src/contextual-menu/ContextualMenu.test.tsx
@@ -41,11 +41,11 @@ describe("Contextual menu component tests", () => {
expect(actions[0]?.getAttribute("aria-pressed")).toBeTruthy();
expect(getByRole("menu")).toBeTruthy();
});
- test("Single — An item can appear as selected by default by using the attribute selected", () => {
+ test("Single — An item can appear as selected by default by using the attribute selectedByDefault", () => {
const test = [
{
label: "Tested item",
- selected: true,
+ selectedByDefault: true,
},
];
const { getByRole } = render( );
@@ -92,7 +92,7 @@ describe("Contextual menu component tests", () => {
const test = [
{
label: "Grouped item",
- items: [{ label: "Tested item", selected: true }],
+ items: [{ label: "Tested item", selectedByDefault: true }],
},
];
const { getByText, getAllByRole } = render( );
diff --git a/packages/lib/src/contextual-menu/ContextualMenu.tsx b/packages/lib/src/contextual-menu/ContextualMenu.tsx
index d50ed86c75..13f58b4172 100644
--- a/packages/lib/src/contextual-menu/ContextualMenu.tsx
+++ b/packages/lib/src/contextual-menu/ContextualMenu.tsx
@@ -8,49 +8,28 @@ import scrollbarStyles from "../styles/scroll";
import { addIdToItems, isSection } from "./utils";
import SubMenu from "./SubMenu";
-const ContextualMenuContainer = styled.div<{ displayBorder: boolean }>`
+const ContextualMenu = styled.div`
box-sizing: border-box;
margin: 0;
+ border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-lighter);
+ border-radius: var(--border-radius-s);
+ padding: var(--spacing-padding-m) var(--spacing-padding-xs);
display: grid;
gap: var(--spacing-gap-xs);
- /* min-width: 248px; */
+ min-width: 248px;
max-height: 100%;
background-color: var(--color-bg-neutral-lightest);
overflow-y: auto;
overflow-x: hidden;
- ${scrollbarStyles};
- ${({ displayBorder }) =>
- displayBorder &&
- `
- border: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-lighter);
- border-radius: var(--border-radius-s);
- padding: var(--spacing-padding-m) var(--spacing-padding-xs);
- `}
+ ${scrollbarStyles}
`;
-export default function DxcContextualMenu({
- items,
- displayBorder = true,
- displayGroupLines = false,
- displayControlsAfter = false,
- responsiveView = false,
- allowNavigation = false,
-}: ContextualMenuPropsType) {
+export default function DxcContextualMenu({ items }: ContextualMenuPropsType) {
const [firstUpdate, setFirstUpdate] = useState(true);
const [selectedItemId, setSelectedItemId] = useState(-1);
const contextualMenuRef = useRef(null);
const itemsWithId = useMemo(() => addIdToItems(items), [items]);
- const contextValue = useMemo(
- () => ({
- selectedItemId,
- setSelectedItemId,
- displayGroupLines,
- displayControlsAfter,
- responsiveView,
- allowNavigation,
- }),
- [selectedItemId, setSelectedItemId, displayGroupLines, displayControlsAfter, responsiveView, allowNavigation]
- );
+ const contextValue = useMemo(() => ({ selectedItemId, setSelectedItemId }), [selectedItemId, setSelectedItemId]);
useLayoutEffect(() => {
if (selectedItemId !== -1 && firstUpdate) {
@@ -66,7 +45,7 @@ export default function DxcContextualMenu({
}, [firstUpdate, selectedItemId]);
return (
-
+
{itemsWithId[0] && isSection(itemsWithId[0]) ? (
(itemsWithId as SectionWithId[]).map((item, index) => (
@@ -80,6 +59,6 @@ export default function DxcContextualMenu({
)}
-
+
);
}
diff --git a/packages/lib/src/contextual-menu/GroupItem.tsx b/packages/lib/src/contextual-menu/GroupItem.tsx
index d8074f3122..ba794fd617 100644
--- a/packages/lib/src/contextual-menu/GroupItem.tsx
+++ b/packages/lib/src/contextual-menu/GroupItem.tsx
@@ -6,64 +6,14 @@ import MenuItem from "./MenuItem";
import { GroupItemProps } from "./types";
import ContextualMenuContext from "./ContextualMenuContext";
import { isGroupSelected } from "./utils";
-import * as Popover from "@radix-ui/react-popover";
const GroupItem = ({ items, ...props }: GroupItemProps) => {
const groupMenuId = `group-menu-${useId()}`;
- const { selectedItemId, responsiveView } = useContext(ContextualMenuContext) ?? {};
+ const { selectedItemId } = useContext(ContextualMenuContext) ?? {};
const groupSelected = useMemo(() => isGroupSelected(items, selectedItemId), [items, selectedItemId]);
const [isOpen, setIsOpen] = useState(groupSelected && selectedItemId === -1);
- const contextualMenuId = `sidenav-${useId()}`;
-
- const contextValue = useContext(ContextualMenuContext) ?? {};
-
- return responsiveView ? (
- <>
-
-
- : }
- onClick={() => setIsOpen((isCurrentlyOpen) => !isCurrentlyOpen)}
- selected={groupSelected && !isOpen}
- {...props}
- />
-
-
-
- {
- event.preventDefault();
- }}
- onOpenAutoFocus={(event) => {
- event.preventDefault();
- }}
- align="start"
- side="right"
- style={{ zIndex: "var(--z-contextualmenu)" }}
- >
-
-
-
-
-
-
- >
- ) : (
+ return (
<>
{
{...props}
/>
{isOpen && (
-
)}
- {/* TODO: SEARCHBAR */}
+ {/* TODO: REPLACE WITH THE ACTUAL SEARCHBAR */}
{/*
Date: Fri, 24 Oct 2025 14:11:54 +0200
Subject: [PATCH 21/36] Fixed types export
---
packages/lib/src/contextual-menu/types.ts | 3 +--
packages/lib/src/navigation-tree/types.ts | 3 +--
2 files changed, 2 insertions(+), 4 deletions(-)
diff --git a/packages/lib/src/contextual-menu/types.ts b/packages/lib/src/contextual-menu/types.ts
index 55d82c46af..83d6b0a53f 100644
--- a/packages/lib/src/contextual-menu/types.ts
+++ b/packages/lib/src/contextual-menu/types.ts
@@ -33,6 +33,5 @@ export type {
SectionWithId,
SectionProps,
SingleItemProps,
+ Props as default,
};
-
-export default Props;
diff --git a/packages/lib/src/navigation-tree/types.ts b/packages/lib/src/navigation-tree/types.ts
index a0ce75e0c2..9afb437531 100644
--- a/packages/lib/src/navigation-tree/types.ts
+++ b/packages/lib/src/navigation-tree/types.ts
@@ -28,6 +28,5 @@ export type {
SectionWithId,
SectionProps,
SingleItemProps,
+ Props as default,
};
-
-export default Props;
From 525005459fa8877293d9edfc6b1de0c82b8623ea Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Fri, 24 Oct 2025 14:29:47 +0200
Subject: [PATCH 22/36] Fixed some styles problems in ItemAction for
ContextualMenu
---
packages/lib/src/base-menu/ItemAction.tsx | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/packages/lib/src/base-menu/ItemAction.tsx b/packages/lib/src/base-menu/ItemAction.tsx
index d675f006cf..5667a26f1c 100644
--- a/packages/lib/src/base-menu/ItemAction.tsx
+++ b/packages/lib/src/base-menu/ItemAction.tsx
@@ -44,7 +44,6 @@ const Action = styled.button<{
const Label = styled.span`
display: flex;
- align-items: center;
gap: var(--spacing-gap-s);
overflow: hidden;
`;
@@ -106,10 +105,16 @@ const ItemAction = memo(
aria-pressed={!href ? ariaPressed : undefined}
>
- {!displayControlsAfter && {collapseIcon && {collapseIcon} } }
-
- {typeof icon === "string" ? : icon}
-
+ {!displayControlsAfter && collapseIcon && (
+
+ {collapseIcon}
+
+ )}
+ {icon && (
+
+ {typeof icon === "string" ? : icon}
+
+ )}
{!responsiveView && (
{label}
From e214f0cc5bc9973ba896629d1e40ef941cecdbf0 Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Fri, 24 Oct 2025 14:38:01 +0200
Subject: [PATCH 23/36] Restored label centering
---
packages/lib/src/base-menu/ItemAction.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/lib/src/base-menu/ItemAction.tsx b/packages/lib/src/base-menu/ItemAction.tsx
index 5667a26f1c..5352fd461d 100644
--- a/packages/lib/src/base-menu/ItemAction.tsx
+++ b/packages/lib/src/base-menu/ItemAction.tsx
@@ -44,6 +44,7 @@ const Action = styled.button<{
const Label = styled.span`
display: flex;
+ align-items: center;
gap: var(--spacing-gap-s);
overflow: hidden;
`;
From 76ac354c4eb6a27ca52aedc94db4324c006fbc4c Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Fri, 24 Oct 2025 14:51:52 +0200
Subject: [PATCH 24/36] Fixed uncentered text with no badge/icon
---
packages/lib/src/base-menu/ItemAction.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/lib/src/base-menu/ItemAction.tsx b/packages/lib/src/base-menu/ItemAction.tsx
index 5352fd461d..a1cf18ebee 100644
--- a/packages/lib/src/base-menu/ItemAction.tsx
+++ b/packages/lib/src/base-menu/ItemAction.tsx
@@ -122,7 +122,7 @@ const ItemAction = memo(
)}
- {!responsiveView && (
+ {!responsiveView && (modifiedBadge || (displayControlsAfter && collapseIcon)) && (
{modifiedBadge}
{displayControlsAfter && collapseIcon && {collapseIcon} }
From a263be461e9a76335e3162fb11e0ed999bab9b75 Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Tue, 4 Nov 2025 13:37:29 +0100
Subject: [PATCH 25/36] Used navItems instead of items for the API
---
.../lib/src/sidenav/Sidenav.accessibility.test.tsx | 2 +-
packages/lib/src/sidenav/Sidenav.stories.tsx | 14 +++++++-------
packages/lib/src/sidenav/Sidenav.test.tsx | 2 +-
packages/lib/src/sidenav/Sidenav.tsx | 6 +++---
packages/lib/src/sidenav/types.ts | 2 +-
5 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx
index 7a05186670..77f142f843 100644
--- a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx
+++ b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx
@@ -49,7 +49,7 @@ describe("Sidenav component accessibility tests", () => {
];
const { container } = render(
(
(
(
<>
-
+
{(expanded: boolean) =>
expanded ? (
<>
@@ -249,7 +249,7 @@ const Collapsed = () => (
-
+
{(expanded: boolean) =>
expanded ? (
<>
@@ -296,7 +296,7 @@ const Collapsed = () => (
-
+
{(expanded: boolean) =>
expanded ? (
<>
@@ -348,7 +348,7 @@ const Hovered = () => (
(
{
test("renders contextual menu with items", () => {
const items = [{ label: "Dashboard" }, { label: "Settings" }];
- const { getByTestId } = render( );
+ const { getByTestId } = render( );
expect(getByTestId("mock-menu")).toBeTruthy();
expect(DxcNavigationTree).toHaveBeenCalledWith(
expect.objectContaining({
diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx
index 5a93037c2a..c3d51101aa 100644
--- a/packages/lib/src/sidenav/Sidenav.tsx
+++ b/packages/lib/src/sidenav/Sidenav.tsx
@@ -45,7 +45,7 @@ const LogoContainer = styled.div<{
text-decoration: none;
`;
-const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }: SidenavPropsType): JSX.Element => {
+const DxcSidenav = ({ title, children, navItems, logo, displayGroupLines = false }: SidenavPropsType): JSX.Element => {
const [isExpanded, setIsExpanded] = useState(true);
const renderedChildren = typeof children === "function" ? children(isExpanded) : children;
@@ -108,9 +108,9 @@ const DxcSidenav = ({ title, children, items, logo, displayGroupLines = false }:
left: "medium",
}}
/> */}
- {items && (
+ {navItems && (
Date: Tue, 4 Nov 2025 15:13:31 +0100
Subject: [PATCH 26/36] Replaced items in test
---
packages/lib/src/layout/ApplicationLayout.stories.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/packages/lib/src/layout/ApplicationLayout.stories.tsx b/packages/lib/src/layout/ApplicationLayout.stories.tsx
index 71aab7b79e..b1476ea7d3 100644
--- a/packages/lib/src/layout/ApplicationLayout.stories.tsx
+++ b/packages/lib/src/layout/ApplicationLayout.stories.tsx
@@ -48,7 +48,7 @@ const items = [
const ApplicationLayoutDefaultSidenav = () => (
<>
}
+ sidenav={ }
>
Main Content
@@ -64,7 +64,7 @@ const ApplicationLayoutResponsiveSidenav = () => (
<>
+
{(expanded: boolean) => (!expanded ? Responsive Content
: <>>)}
}
@@ -83,7 +83,7 @@ const ApplicationLayoutCustomHeader = () => (
<>
Custom Header}
- sidenav={ }
+ sidenav={ }
>
Main Content
@@ -99,7 +99,7 @@ const ApplicationLayoutCustomFooter = () => (
<>
Custom Footer}
- sidenav={ }
+ sidenav={ }
>
Main Content
@@ -113,7 +113,7 @@ const ApplicationLayoutCustomFooter = () => (
const Tooltip = () => (
}
+ sidenav={ }
>
Main Content
From 95c892f154c1f75677b9d9aa7253a7a404f1dc70 Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Tue, 4 Nov 2025 15:52:07 +0100
Subject: [PATCH 27/36] Fixed app according to new API
---
apps/website/pages/_app.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/website/pages/_app.tsx b/apps/website/pages/_app.tsx
index f914a64bf3..52d02e578e 100644
--- a/apps/website/pages/_app.tsx
+++ b/apps/website/pages/_app.tsx
@@ -90,7 +90,7 @@ export default function App({ Component, pageProps, emotionCache = clientSideEmo
}
>
{/* {filteredLinks?.map(({ label, links }) => (
From 083634764d61350ec777e863ab3b470bff286ec5 Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Thu, 6 Nov 2025 16:54:03 +0100
Subject: [PATCH 28/36] Added tests, docsite sidenav and new API improvements
---
apps/website/pages/_app.tsx | 109 ++--
.../screens/common/sidenav/SidenavLogo.tsx | 12 +-
.../code/ContextualMenuCodePage.tsx | 2 +-
packages/lib/src/base-menu/GroupItem.tsx | 1 +
packages/lib/src/base-menu/ItemAction.tsx | 6 +-
packages/lib/src/base-menu/MenuItem.tsx | 3 +-
.../src/layout/ApplicationLayout.stories.tsx | 43 +-
packages/lib/src/layout/ApplicationLayout.tsx | 2 +-
.../sidenav/Sidenav.accessibility.test.tsx | 10 +-
packages/lib/src/sidenav/Sidenav.stories.tsx | 539 ++++++++++--------
packages/lib/src/sidenav/Sidenav.test.tsx | 95 ++-
packages/lib/src/sidenav/Sidenav.tsx | 101 ++--
packages/lib/src/sidenav/types.ts | 97 +---
13 files changed, 552 insertions(+), 468 deletions(-)
diff --git a/apps/website/pages/_app.tsx b/apps/website/pages/_app.tsx
index 52d02e578e..d3f47ba687 100644
--- a/apps/website/pages/_app.tsx
+++ b/apps/website/pages/_app.tsx
@@ -1,8 +1,8 @@
-import { ReactElement, ReactNode, useEffect } from "react";
+import { ReactElement, ReactNode, useEffect, useMemo, useState } from "react";
import type { NextPage } from "next";
import type { AppProps } from "next/app";
import Head from "next/head";
-import { DxcApplicationLayout, DxcToastsQueue } from "@dxc-technology/halstack-react";
+import { DxcApplicationLayout, DxcTextInput, DxcToastsQueue } from "@dxc-technology/halstack-react";
import MainContent from "@/common/MainContent";
import { useRouter } from "next/router";
import { LinksSectionDetails, LinksSections } from "@/common/pagesList";
@@ -12,6 +12,9 @@ import createCache, { EmotionCache } from "@emotion/cache";
import { CacheProvider } from "@emotion/react";
import { usePathname } from "next/navigation";
import Link from "next/link";
+import { GroupItem, Item, Section } from "../../../packages/lib/src/base-menu/types";
+import { isGroupItem } from "../../../packages/lib/src/base-menu/utils";
+import SidenavLogo from "@/common/sidenav/SidenavLogo";
type NextPageWithLayout = NextPage & {
getLayout?: (_page: ReactElement) => ReactNode;
@@ -28,21 +31,39 @@ export default function App({ Component, pageProps, emotionCache = clientSideEmo
const componentWithLayout = getLayout( );
const router = useRouter();
const pathname = usePathname();
- // const [filter, setFilter] = useState("");
- // const filteredLinks = useMemo(() => {
- // const filtered: LinksSectionDetails[] = [];
- // LinksSections.map((section) => {
- // const sectionFilteredLinks = section?.links.filter((link) =>
- // link.label.toLowerCase().includes(filter.toLowerCase())
- // );
- // if (sectionFilteredLinks.length) {
- // filtered.push({ label: section.label, links: sectionFilteredLinks });
- // }
- // });
- // return filtered;
- // }, [filter]);
+ const [filter, setFilter] = useState("");
+ const [isExpanded, setIsExpanded] = useState(true);
- const mapLinksToGroupItems = (sections: LinksSectionDetails[]) => {
+ const filterSections = (sections: Section[], query: string): Section[] => {
+ const q = query.trim().toLowerCase();
+ if (!q) return sections;
+
+ const filterItem = (item: Item | GroupItem): Item | GroupItem | null => {
+ const labelMatches = item.label.toLowerCase().includes(q);
+
+ if (!isGroupItem(item)) return labelMatches ? item : null;
+
+ const items = item.items.reduce<(Item | GroupItem)[]>((acc, child) => {
+ const filtered = filterItem(child);
+ if (filtered) acc.push(filtered);
+ return acc;
+ }, []);
+
+ return labelMatches || items.length ? { ...item, items } : null;
+ };
+
+ return sections.reduce((acc, section) => {
+ const items = section.items.reduce<(Item | GroupItem)[]>((acc, item) => {
+ const filtered = filterItem(item);
+ if (filtered) acc.push(filtered);
+ return acc;
+ }, []);
+ if (items.length) acc.push({ ...section, items });
+ return acc;
+ }, []);
+ };
+
+ const mapLinksToGroupItems = (sections: LinksSectionDetails[]): Section[] => {
const matchPaths = (linkPath: string) => {
const desiredPaths = [linkPath, `${linkPath}/code`];
const pathToBeMatched = pathname?.split("#")[0]?.slice(0, -1);
@@ -77,10 +98,13 @@ export default function App({ Component, pageProps, emotionCache = clientSideEmo
void prefetchPaths();
}, []);
- // TODO: ADD FILTERING
- // TODO: ADD CATEGORIZATION
+ // TODO: ADD NEW CATEGORIZATION
- const sections = mapLinksToGroupItems(LinksSections);
+ const filteredSections = useMemo(() => {
+ const sections = mapLinksToGroupItems(LinksSections);
+ console.log("SECTIONS", sections);
+ return filterSections(sections, filter);
+ }, [filter]);
return (
@@ -89,30 +113,29 @@ export default function App({ Component, pageProps, emotionCache = clientSideEmo
}
- >
- {/* {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/screens/common/sidenav/SidenavLogo.tsx b/apps/website/screens/common/sidenav/SidenavLogo.tsx
index 8be159388f..286f78e9b6 100644
--- a/apps/website/screens/common/sidenav/SidenavLogo.tsx
+++ b/apps/website/screens/common/sidenav/SidenavLogo.tsx
@@ -22,11 +22,11 @@ const Subtitle = styled.div`
font-family: var(--typography-font-family);
`;
-const SidenavLogo = ({ subtitle = "Design System" }: { subtitle?: string }) => {
+const SidenavLogo = ({ subtitle = "Design System", expanded }: { subtitle?: string; expanded: boolean }) => {
const pathVersion = process.env.NEXT_PUBLIC_SITE_VERSION;
const isDev = process.env.NODE_ENV === "development";
- return (
+ return expanded ? (
@@ -47,6 +47,14 @@ const SidenavLogo = ({ subtitle = "Design System" }: { subtitle?: string }) => {
size="small"
/>
+ ) : (
+
);
};
diff --git a/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx b/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx
index c6f4242e7f..53e783da3c 100644
--- a/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx
+++ b/apps/website/screens/components/contextual-menu/code/ContextualMenuCodePage.tsx
@@ -80,7 +80,7 @@ const sections = [
title: "Action menu",
content: ,
},
- // TODO: We should remove this example as it is not the intended usage right?
+ // TODO: We should remove this example as it is not the intended usage right? (Navigation is handled inside ApplicationLayout)
{
title: "Navigation menu",
content: ,
diff --git a/packages/lib/src/base-menu/GroupItem.tsx b/packages/lib/src/base-menu/GroupItem.tsx
index 63f4bfbb84..db9c6b328b 100644
--- a/packages/lib/src/base-menu/GroupItem.tsx
+++ b/packages/lib/src/base-menu/GroupItem.tsx
@@ -15,6 +15,7 @@ const GroupItem = ({ items, ...props }: GroupItemProps) => {
const contextValue = useContext(BaseMenuContext) ?? {};
const { groupSelected, isOpen, toggleOpen, responsiveView } = useGroupItem(items, contextValue);
+ // TODO: SET A FIXED WIDTH TO PREVENT MOVING CONTENT WHEN EXPANDING/COLLAPSING IN RESPONSIVEVIEW
return responsiveView ? (
<>
diff --git a/packages/lib/src/base-menu/ItemAction.tsx b/packages/lib/src/base-menu/ItemAction.tsx
index a1cf18ebee..681e24bbfe 100644
--- a/packages/lib/src/base-menu/ItemAction.tsx
+++ b/packages/lib/src/base-menu/ItemAction.tsx
@@ -111,9 +111,11 @@ const ItemAction = memo(
{collapseIcon}
)}
- {icon && (
+ {(icon || responsiveView) && (
- {typeof icon === "string" ? : icon}
+
+ {typeof icon === "string" ? : icon ? icon : }
+
)}
{!responsiveView && (
diff --git a/packages/lib/src/base-menu/MenuItem.tsx b/packages/lib/src/base-menu/MenuItem.tsx
index 65aadf7f17..b70663a489 100644
--- a/packages/lib/src/base-menu/MenuItem.tsx
+++ b/packages/lib/src/base-menu/MenuItem.tsx
@@ -2,6 +2,7 @@ import styled from "@emotion/styled";
import GroupItem from "./GroupItem";
import SingleItem from "./SingleItem";
import { MenuItemProps } from "./types";
+import { isGroupItem } from "./utils";
const MenuItemContainer = styled.li`
display: grid;
@@ -11,7 +12,7 @@ const MenuItemContainer = styled.li`
export default function MenuItem({ item, depthLevel = 0 }: MenuItemProps) {
return (
- {"items" in item ? (
+ {isGroupItem(item) ? (
) : (
diff --git a/packages/lib/src/layout/ApplicationLayout.stories.tsx b/packages/lib/src/layout/ApplicationLayout.stories.tsx
index b1476ea7d3..4e4048fc41 100644
--- a/packages/lib/src/layout/ApplicationLayout.stories.tsx
+++ b/packages/lib/src/layout/ApplicationLayout.stories.tsx
@@ -24,23 +24,23 @@ const ApplicationLayout = () => (
const items = [
{
- label: "SideNav Content",
+ label: "Sidenav Content",
icon: "tab",
},
{
- label: "SideNav Content",
+ label: "Sidenav Content",
icon: "tab",
},
{
- label: "SideNav Content",
+ label: "Sidenav Content",
icon: "tab",
},
{
- label: "SideNav Content",
+ label: "Sidenav Content",
icon: "tab",
},
{
- label: "SideNav Content",
+ label: "Sidenav Content",
icon: "tab",
},
];
@@ -48,7 +48,12 @@ const items = [
const ApplicationLayoutDefaultSidenav = () => (
<>
}
+ sidenav={
+
+ }
>
Main Content
@@ -64,9 +69,11 @@ const ApplicationLayoutResponsiveSidenav = () => (
<>
- {(expanded: boolean) => (!expanded ? Responsive Content
: <>>)}
-
+ (!expanded ? Responsive Content
: <>>)}
+ />
}
>
@@ -83,7 +90,12 @@ const ApplicationLayoutCustomHeader = () => (
<>
Custom Header}
- sidenav={ }
+ sidenav={
+
+ }
>
Main Content
@@ -99,7 +111,12 @@ const ApplicationLayoutCustomFooter = () => (
<>
Custom Footer}
- sidenav={ }
+ sidenav={
+
+ }
>
Main Content
@@ -113,7 +130,9 @@ const ApplicationLayoutCustomFooter = () => (
const Tooltip = () => (
}
+ sidenav={
+
+ }
>
Main Content
diff --git a/packages/lib/src/layout/ApplicationLayout.tsx b/packages/lib/src/layout/ApplicationLayout.tsx
index 810aca93cb..72808a6085 100644
--- a/packages/lib/src/layout/ApplicationLayout.tsx
+++ b/packages/lib/src/layout/ApplicationLayout.tsx
@@ -92,6 +92,6 @@ const DxcApplicationLayout = ({ header, sidenav, footer, children }: Application
DxcApplicationLayout.Footer = DxcFooter;
DxcApplicationLayout.Header = DxcHeader;
DxcApplicationLayout.Main = Main;
-DxcApplicationLayout.SideNav = DxcSidenav;
+DxcApplicationLayout.Sidenav = DxcSidenav;
export default DxcApplicationLayout;
diff --git a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx
index 77f142f843..a12a6dc692 100644
--- a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx
+++ b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx
@@ -50,10 +50,12 @@ describe("Sidenav component accessibility tests", () => {
const { container } = render(
);
diff --git a/packages/lib/src/sidenav/Sidenav.stories.tsx b/packages/lib/src/sidenav/Sidenav.stories.tsx
index 34c4976e88..6efff792e6 100644
--- a/packages/lib/src/sidenav/Sidenav.stories.tsx
+++ b/packages/lib/src/sidenav/Sidenav.stories.tsx
@@ -10,6 +10,7 @@ import DxcAvatar from "../avatar/Avatar";
import { userEvent, within } from "storybook/internal/test";
import disabledRules from "../../test/accessibility/rules/specific/sidenav/disabledRules";
import preview from "../../.storybook/preview";
+import { useState } from "react";
export default {
title: "Sidenav",
@@ -132,248 +133,287 @@ const selectedGroupItems = [
},
];
-const SideNav = () => (
+const Sidenav = () => (
<>
-
-
-
-
-
-
+ bottomContent={
+ <>
+
+
+
+
+
+ >
+ }
+ />
+
+
+
+
+
+ >
+ }
displayGroupLines
- >
-
-
-
-
-
-
+ />
>
);
-const Collapsed = () => (
- <>
-
-
-
- {(expanded: boolean) =>
- expanded ? (
- <>
-
-
-
-
-
- >
- ) : (
- <>
-
-
-
-
-
- >
- )
- }
-
-
-
-
-
- {(expanded: boolean) =>
- expanded ? (
- <>
-
-
-
-
-
- >
- ) : (
- <>
-
-
-
-
-
- >
- )
- }
-
-
-
-
-
- {(expanded: boolean) =>
- expanded ? (
- <>
-
-
-
-
-
- >
- ) : (
- <>
-
-
-
-
-
- >
- )
- }
-
-
- >
-);
+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 Hovered = () => (
-
-
-
-
-
-
+ bottomContent={
+ <>
+
+
+
+
+
+ >
+ }
+ />
);
@@ -382,39 +422,42 @@ const SelectedGroup = () => (
-
-
-
-
-
-
+ bottomContent={
+ <>
+
+
+
+
+
+ >
+ }
+ />
);
type Story = StoryObj;
-// TODO: ADD TEST AND STORIES FOR LINK/RENDERITEM PROPS
-
export const Chromatic: Story = {
- render: SideNav,
+ render: Sidenav,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const menuItem1 = (await canvas.findAllByRole("button"))[10];
@@ -428,7 +471,7 @@ export const Chromatic: Story = {
},
};
-export const CollapsedSideNav: Story = {
+export const CollapsedSidenav: Story = {
render: Collapsed,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -455,7 +498,7 @@ export const CollapsedSideNav: Story = {
},
};
-export const HoveredSideNav: Story = {
+export const HoveredSidenav: Story = {
render: Hovered,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -471,6 +514,6 @@ export const HoveredSideNav: Story = {
},
};
-export const SelectedGroupSideNav: Story = {
+export const SelectedGroupSidenav: Story = {
render: SelectedGroup,
};
diff --git a/packages/lib/src/sidenav/Sidenav.test.tsx b/packages/lib/src/sidenav/Sidenav.test.tsx
index df25cd6fa3..4bf1b2e2ea 100644
--- a/packages/lib/src/sidenav/Sidenav.test.tsx
+++ b/packages/lib/src/sidenav/Sidenav.test.tsx
@@ -1,7 +1,7 @@
import "@testing-library/jest-dom";
import { render, fireEvent } from "@testing-library/react";
import DxcSidenav from "./Sidenav";
-import DxcNavigationTree from "../navigation-tree/NavigationTree";
+import { ReactNode } from "react";
global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
@@ -9,8 +9,6 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
}));
-jest.mock("../navigation-tree/NavigationTree", () => jest.fn(() =>
));
-
describe("DxcSidenav component", () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -18,19 +16,22 @@ describe("DxcSidenav component", () => {
test("Sidenav renders title and children correctly", () => {
const { getByText, getByRole } = render(
-
- Custom child content
-
+ Custom top content}
+ bottomContent={Custom bottom content
}
+ />
);
expect(getByText("Main Menu")).toBeTruthy();
- expect(getByText("Custom child content")).toBeTruthy();
+ expect(getByText("Custom top content")).toBeTruthy();
+ expect(getByText("Custom bottom content")).toBeTruthy();
const collapseButton = getByRole("button", { name: "Collapse" });
expect(collapseButton).toBeTruthy();
});
test("Sidenav collapses and expands correctly on button click", () => {
- const { getByRole } = render( );
+ const { getByRole } = render( );
const collapseButton = getByRole("button", { name: "Collapse" });
expect(collapseButton).toBeTruthy();
@@ -40,39 +41,77 @@ describe("DxcSidenav component", () => {
fireEvent.click(expandButton);
});
- test("renders logo correctly when provided", () => {
+ test("Sidenav renders logo correctly when provided", () => {
const logo = { src: "logo.png", alt: "Company Logo", href: "https://example.com" };
- const { getByRole, getByAltText } = render( );
+ const { getByRole, getByAltText } = render( );
const link = getByRole("link");
expect(link).toHaveAttribute("href", "https://example.com");
expect(getByAltText("Company Logo")).toBeTruthy();
});
- test("renders contextual menu with items", () => {
+ test("Sidenav renders contextual menu with items", () => {
const items = [{ label: "Dashboard" }, { label: "Settings" }];
- const { getByTestId } = render( );
- expect(getByTestId("mock-menu")).toBeTruthy();
- expect(DxcNavigationTree).toHaveBeenCalledWith(
- expect.objectContaining({
- items,
- displayGroupLines: false,
- displayControlsAfter: true,
- displayBorder: false,
- }),
- {}
+ 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("renders children using function pattern", () => {
- const childFn = jest.fn((expanded) => {expanded ? "Expanded content" : "Collapsed content"}
);
+ test("Sidenav uses controlled expanded prop instead of internal state", () => {
+ const onExpandedChange = jest.fn();
+ const { getByRole, rerender } = render(
+
+ );
+
+ const expandButton = getByRole("button", { name: "Expand" });
+ expect(expandButton).toBeTruthy();
+
+ fireEvent.click(expandButton);
+ expect(onExpandedChange).toHaveBeenCalledWith(true);
+
+ rerender(
+
+ );
- const { getByText, getByRole } = render({childFn} );
- expect(getByText("Expanded content")).toBeTruthy();
- expect(childFn).toHaveBeenCalledWith(true);
const collapseButton = getByRole("button", { name: "Collapse" });
expect(collapseButton).toBeTruthy();
- fireEvent.click(collapseButton);
- expect(childFn).toHaveBeenCalledWith(false);
+ });
+
+ test("Sidenav toggles internal state correctly", () => {
+ const { getByRole } = render( );
+
+ const expandButton = getByRole("button", { name: "Expand" });
+ expect(expandButton).toBeTruthy();
+
+ fireEvent.click(expandButton);
+ const collapseButton = getByRole("button", { name: "Collapse" });
+ expect(collapseButton).toBeTruthy();
});
});
diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx
index c3d51101aa..92b4d1cecb 100644
--- a/packages/lib/src/sidenav/Sidenav.tsx
+++ b/packages/lib/src/sidenav/Sidenav.tsx
@@ -5,15 +5,14 @@ import SidenavPropsType, { Logo } from "./types";
import DxcDivider from "../divider/Divider";
import DxcButton from "../button/Button";
import DxcImage from "../image/Image";
-import { ReactElement, useState } from "react";
-import DxcTextInput from "../text-input/TextInput";
+import { useState } from "react";
import DxcNavigationTree from "../navigation-tree/NavigationTree";
const SidenavContainer = styled.div<{ expanded: boolean }>`
box-sizing: border-box;
display: flex;
flex-direction: column;
- /* TODO: ASK FINAL SIZES AND IMPLEMENT RESIZABLE SIDENAV */
+ /* TODO: IMPLEMENT RESIZABLE SIDENAV */
min-width: ${({ expanded }) => (expanded ? "240px" : "56px")};
max-width: ${({ expanded }) => (expanded ? "320px" : "56px")};
height: 100%;
@@ -45,69 +44,69 @@ const LogoContainer = styled.div<{
text-decoration: none;
`;
-const DxcSidenav = ({ title, children, navItems, logo, displayGroupLines = false }: SidenavPropsType): JSX.Element => {
- const [isExpanded, setIsExpanded] = useState(true);
+const DxcSidenav = ({
+ topContent,
+ bottomContent,
+ navItems,
+ branding,
+ displayGroupLines = false,
+ expanded,
+ defaultExpanded = true,
+ onExpandedChange,
+}: SidenavPropsType): JSX.Element => {
+ const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
+ const isControlled = expanded !== undefined;
+ const isExpanded = isControlled ? !!expanded : internalExpanded;
- const renderedChildren = typeof children === "function" ? children(isExpanded) : children;
+ const handleToggle = () => {
+ const nextState = !isExpanded;
+ if (!isControlled) setInternalExpanded(nextState);
+ onExpandedChange?.(nextState);
+ };
- function isLogoObject(logo: Logo | ReactElement): logo is Logo {
- return (logo as Logo).src !== undefined;
- }
+ const isBrandingObject = (branding: SidenavPropsType["branding"]): branding is { logo?: Logo; appTitle?: string } => {
+ return (
+ (typeof branding === "object" && branding !== null && "logo" in branding) ||
+ (!!branding && "appTitle" in branding)
+ );
+ };
return (
-
+
{
- setIsExpanded((previousExpanded) => !previousExpanded);
- }}
+ onClick={handleToggle}
/>
- {isExpanded && (
+ {isBrandingObject(branding) ? (
- {/* TODO: ADD GORGORITO TO COVER CASES WITH NO ICON? */}
- {logo && (
- <>
- {isLogoObject(logo) ? (
-
-
-
- ) : (
- logo
- )}
- >
+ {branding.logo && (
+
+
+
)}
- {title}
+ {branding.appTitle}
+ ) : (
+ branding
)}
- {/* TODO: REPLACE WITH THE ACTUAL SEARCHBAR */}
-
- {/* {
- setFilter(value);
- }}
- size="fillParent"
- clearable
- margin={{
- top: "large",
- bottom: "large",
- right: "medium",
- left: "medium",
- }}
- /> */}
+ {topContent}
{navItems && (
)}
- {renderedChildren}
+ {bottomContent}
);
};
diff --git a/packages/lib/src/sidenav/types.ts b/packages/lib/src/sidenav/types.ts
index 79564f3363..a09161d8bf 100644
--- a/packages/lib/src/sidenav/types.ts
+++ b/packages/lib/src/sidenav/types.ts
@@ -1,73 +1,6 @@
-import { MouseEvent, ReactNode, ReactElement } from "react";
+import { MouseEvent, ReactElement } from "react";
import { SVG } from "../common/utils";
-export type SidenavTitlePropsType = {
- /**
- * The area inside the sidenav title. This area can be used to render custom content.
- */
- children: ReactNode;
-};
-
-export type SidenavSectionPropsType = {
- /**
- * The area inside the sidenav section. This area can be used to render sidenav groups, links and custom content.
- */
- children: ReactNode;
-};
-
-export type SidenavGroupPropsType = {
- /**
- * The title of the sidenav group.
- */
- title?: string;
- /**
- * If true, the sidenav group will be a button that will allow you to collapse the links contained within it.
- * In addition, if it's collapsed and contains the currently selected link, the group title will also be marked as selected.
- */
- collapsable?: boolean;
- /**
- * Material Symbol name or SVG icon to be displayed next to the title of the group.
- */
- icon?: string | SVG;
- /**
- * The area inside the sidenav group. This area can be used to render sidenav links.
- */
- children: ReactNode;
-};
-
-export type SidenavLinkPropsType = {
- /**
- * Page to be opened when the user clicks on the link.
- */
- href?: string;
- /**
- * If true, the page is opened in a new browser tab.
- */
- newWindow?: boolean;
- /**
- * The Material symbol or SVG element used as the icon that will be placed to the left of the link text.
- */
- icon?: string | SVG;
- /**
- * If true, the link will be marked as selected. Moreover, in that same case,
- * if it is contained within a collapsed group, and consequently, the currently selected link is not visible,
- * the group title will appear as selected too.
- */
- selected?: boolean;
- /**
- * This function will be called when the user clicks the link and the event will be passed to this function.
- */
- onClick?: (event: MouseEvent) => void;
- /**
- * The area inside the sidenav link.
- */
- children: ReactNode;
- /**
- * Value of the tabindex.
- */
- tabIndex?: number;
-};
-
export type Logo = {
/**
* URL of the image that will be placed in the logo.
@@ -76,7 +9,7 @@ export type Logo = {
/**
* Alternative text for the logo image.
*/
- alt?: string;
+ alt: string;
/**
* URL to navigate to when the logo is clicked. If not provided, the logo will not be clickable.
*/
@@ -91,23 +24,37 @@ type Section = { items: (Item | GroupItem)[]; title?: string };
type Props = {
/**
- * The title of the sidenav that will be placed under the logo.
+ * If true, the sidenav is expanded.
+ * If undefined the component will be uncontrolled and the value will be managed internally by the component.
+ */
+ expanded?: boolean;
+ /**
+ * Initial state of the expansion of the sidenav, only when it is uncontrolled.
+ */
+ defaultExpanded?: boolean;
+ /**
+ * Function called when the expansion state of the sidenav changes.
+ */
+ onExpandedChange?: (value: boolean) => void;
+ /**
+ * The additional content rendered in the upper part of the sidenav, under the branding.
+ * It can also be a function that receives the expansion state to render different content based on it.
*/
- title?: string;
+ topContent?: ReactElement;
/**
- * The additional content rendered inside the sidenav.
+ * The content rendered in the bottom part of the sidenav, under the navigation menu.
* It can also be a function that receives the expansion state to render different content based on it.
*/
- children?: React.ReactNode | ((expanded: boolean) => React.ReactNode);
+ bottomContent?: ReactElement;
/**
- * Array of items to be displayed in the Nav menu.
+ * Array of items to be displayed in the navigation menu.
* Each item can be a single/simple item, a group item or a section.
*/
navItems?: (Item | GroupItem)[] | Section[];
/**
* Object with the properties of the logo placed at the top of the sidenav.
*/
- logo?: Logo | ReactElement;
+ branding?: { logo?: Logo; appTitle?: string } | ReactElement;
/**
* If true the nav menu will have lines marking the groups.
*/
From e133d79a0091169d7c0f27cb0184e45eeaf330dd Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Thu, 6 Nov 2025 17:06:02 +0100
Subject: [PATCH 29/36] Fixed problem with build
---
apps/website/pages/_app.tsx | 1 -
1 file changed, 1 deletion(-)
diff --git a/apps/website/pages/_app.tsx b/apps/website/pages/_app.tsx
index d3f47ba687..e1526d55ab 100644
--- a/apps/website/pages/_app.tsx
+++ b/apps/website/pages/_app.tsx
@@ -102,7 +102,6 @@ export default function App({ Component, pageProps, emotionCache = clientSideEmo
const filteredSections = useMemo(() => {
const sections = mapLinksToGroupItems(LinksSections);
- console.log("SECTIONS", sections);
return filterSections(sections, filter);
}, [filter]);
From 39ab15fe392c63f7127cf12b2291f770a782220f Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Fri, 7 Nov 2025 10:33:19 +0100
Subject: [PATCH 30/36] Added section support for responsive mode
---
apps/website/screens/common/StatusBadge.tsx | 40 ++++++++++++++++-----
packages/lib/src/base-menu/Section.tsx | 10 ++----
2 files changed, 34 insertions(+), 16 deletions(-)
diff --git a/apps/website/screens/common/StatusBadge.tsx b/apps/website/screens/common/StatusBadge.tsx
index 6a1e30a646..1702b18310 100644
--- a/apps/website/screens/common/StatusBadge.tsx
+++ b/apps/website/screens/common/StatusBadge.tsx
@@ -4,6 +4,7 @@ import { ComponentStatus } from "./pagesList";
type StatusBadgeProps = {
hasTitle?: boolean;
status: ComponentStatus | "required";
+ // reduced?: boolean;
};
const getBadgeColor = (status: StatusBadgeProps["status"]) => {
@@ -40,13 +41,36 @@ const getBadgeTitle = (status: StatusBadgeProps["status"]) => {
}
};
-const StatusBadge = ({ hasTitle = false, status }: StatusBadgeProps) => (
-
-);
+// 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/packages/lib/src/base-menu/Section.tsx b/packages/lib/src/base-menu/Section.tsx
index 68708811db..5981b34906 100644
--- a/packages/lib/src/base-menu/Section.tsx
+++ b/packages/lib/src/base-menu/Section.tsx
@@ -24,9 +24,9 @@ const Title = styled.h2`
export default function Section({ index, length, section }: SectionProps) {
const id = `section-${useId()}`;
const { responsiveView } = useContext(BaseMenuContext) ?? {};
- return !responsiveView ? (
+ return (
- {section.title && {section.title} }
+ {!responsiveView && section.title && {section.title} }
{section.items.map((item, i) => (
@@ -38,11 +38,5 @@ export default function Section({ index, length, section }: SectionProps) {
)}
- ) : (
-
- {section.items.map((item, i) => (
-
- ))}
-
);
}
From 04b1de31f5df50a4fdb334f4942b4f48bb8c88cc Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Fri, 7 Nov 2025 14:39:26 +0100
Subject: [PATCH 31/36] Added documentation and sorted props
---
apps/website/pages/_app.tsx | 4 +-
.../components/sidenav/SidenavPageLayout.tsx | 2 +-
.../sidenav/code/SidenavCodePage.tsx | 399 +++++-------------
.../sidenav/overview/SidenavOverviewPage.tsx | 1 +
packages/lib/src/sidenav/types.ts | 46 +-
5 files changed, 139 insertions(+), 313 deletions(-)
diff --git a/apps/website/pages/_app.tsx b/apps/website/pages/_app.tsx
index e1526d55ab..b0e62eec5c 100644
--- a/apps/website/pages/_app.tsx
+++ b/apps/website/pages/_app.tsx
@@ -116,7 +116,7 @@ export default function App({ Component, pageProps, emotionCache = clientSideEmo
navItems={filteredSections}
branding={ }
topContent={
- isExpanded ? (
+ isExpanded && (
- ) : (
- <>>
)
}
expanded={isExpanded}
diff --git a/apps/website/screens/components/sidenav/SidenavPageLayout.tsx b/apps/website/screens/components/sidenav/SidenavPageLayout.tsx
index 10fc9973b4..0ce9cf71ab 100644
--- a/apps/website/screens/components/sidenav/SidenavPageLayout.tsx
+++ b/apps/website/screens/components/sidenav/SidenavPageLayout.tsx
@@ -10,7 +10,7 @@ const SidenavPageHeading = ({ children }: { children: ReactNode }) => {
{ label: "Overview", path: "/components/sidenav" },
{ label: "Code", path: "/components/sidenav/code" },
];
-
+ // TODO: UPDATE DESCRIPTION
return (
diff --git a/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx b/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx
index 1bd4af40e8..1cb2135c1f 100644
--- a/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx
+++ b/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx
@@ -1,9 +1,41 @@
import DocFooter from "@/common/DocFooter";
import QuickNavContainer from "@/common/QuickNavContainer";
-import StatusBadge from "@/common/StatusBadge";
-import Code, { TableCode } from "@/common/Code";
-import { DxcLink, DxcFlex, DxcTable, DxcParagraph } from "@dxc-technology/halstack-react";
-import Link from "next/link";
+import Code, { ExtendedTableCode, TableCode } from "@/common/Code";
+import { DxcFlex, DxcTable } from "@dxc-technology/halstack-react";
+
+const brandingTypeString = `{
+ logo?: Logo;
+ appTitle?: string;
+}`;
+
+const logoTypeString = `{
+ alt: string;
+ href?: string;
+ onClick?: (event: MouseEvent) => void;
+ src: string;
+}`;
+
+const commonItemTypeString = `{
+ badge?: ReactElement;
+ icon?: string | SVG;
+ label: string;
+}`;
+
+const itemTypeString = `{
+ ${commonItemTypeString}
+ onSelect?: () => void;
+ selected?: boolean;
+}`;
+
+const groupItemTypeString = `{
+ ${commonItemTypeString}
+ items: (Item | GroupItem)[];
+}`;
+
+const sectionTypeString = `{
+ items: (Item | GroupItem)[];
+ title?: string };
+}`;
const sections = [
{
@@ -20,305 +52,102 @@ const sections = [
+ bottomContent
+
+ React.ReactNode
+
+ The content rendered in the bottom part of the sidenav, under the navigation menu.
+ -
+
+
+ branding
-
-
- children
-
+ {"Logo | ReactNode"}
+
+ being Message an object with the following properties:
+
+ {brandingTypeString}
+
+ and Logo an object with the following properties:
+
+ {logoTypeString}
+ Object with the properties of the branding placed at the top of the sidenav.
+ -
+
+
+ defaultExpanded
- React.ReactNode
+ boolean
+
+ Initial state of the expansion of the sidenav, only when it is uncontrolled.
+ -
+
+
+ displayGroupLines
+
+ boolean
+
+ If true the nav menu will have lines marking the groups.
+ -
+
+
+ expanded
+
+ boolean
+
+
+ If true, the sidenav is expanded. If undefined the component will be uncontrolled and the value will be
+ managed internally by the component.
+
+ -
+
+
+ navItems
+
+ {"(Item | GroupItem)[] | Section[]"}
+
+ being Item an object with the following properties:
+
+ {itemTypeString}
+
+ , GroupItem an object with the following properties:
+
+ {groupItemTypeString}
+
+ and Section an object with the following properties:
+
+ {sectionTypeString}
+
+
+ Array of items to be displayed in the navigation menu. Each item can be a single/simple item, a group item
+ or a section.
+
+ -
+
+
+ onExpandedChange
+
+ {"(value: boolean) => void"}
- The area inside the sidenav.
+ Function called when the expansion state of the sidenav changes.
-
- title
+ topContent
React.ReactNode
- The area assigned to render the title. It is highly recommended to use the sidenav title.
+ The content rendered in the upper part of the sidenav, under the branding.
-
),
},
- {
- title: "DxcSidenav.Title",
- content: (
-
- This compound component should only be used inside the title prop.
-
- ),
- subSections: [
- {
- title: "Props",
- content: (
-
-
-
- Name
- Type
- Description
- Default
-
-
-
-
-
-
-
- children
-
-
-
- React.ReactNode
-
- The area inside the sidenav title. This area can be used to render custom content.
- -
-
-
-
- ),
- },
- ],
- },
- {
- title: "DxcSidenav.Section",
- content: (
-
- Sections must be defined as direct children of the DxcSidenav and serve to group links, groups
- and/or custom content into different and distinguishable parts of the component. Consecutive sections are
- separated by a divider.
-
- ),
- subSections: [
- {
- title: "Props",
- content: (
-
-
-
- Name
- Type
- Description
- Default
-
-
-
-
-
-
-
- children
-
-
-
- React.ReactNode
-
- The area inside the sidenav section. Child items will be stacked inside a flex container.
- -
-
-
-
- ),
- },
- ],
- },
- {
- title: "DxcSidenav.Group",
- content: (
-
- Even though any children are accepted in a group, we recommend using only the DxcSidenav.Link or
- any React-based router, complemented with this one, as links to the different pages.
-
- ),
- subSections: [
- {
- title: "Props",
- content: (
-
-
-
- Name
- Type
- Description
- Default
-
-
-
-
-
-
-
- children
-
-
-
- React.ReactNode
-
- The area inside the sidenav group. This area can be used to render sidenav links.
- -
-
-
- collapsable
-
- boolean
-
-
- If true, the sidenav group will be a button that will allow you to collapse the links contained within
- it. In addition, if it's collapsed and contains the currently selected link, the group title will also
- be marked as selected.
-
-
- false
-
-
-
- icon
-
- string | {"(React.ReactNode & React.SVGProps )"}
-
-
- A{" "}
-
- Material Symbol
- {" "}
- or a SVG element to be displayed next to the title of the group as an icon.
-
- -
-
-
- title
-
- string
-
- The title of the sidenav group.
- -
-
-
-
- ),
- },
- ],
- },
- {
- title: "DxcSidenav.Link",
- content: (
-
- As with the DxcLink component, we decided to make our Sidenav link component a styled HTML anchor
- that can be used with any React-based router. You can check the{" "}
-
- Link
- {" "}
- for more information regarding this.
-
- ),
- subSections: [
- {
- title: "Props",
- content: (
-
-
-
- Name
- Type
- Description
- Default
-
-
-
-
-
-
-
- children
-
-
-
- React.ReactNode
-
- The area inside the sidenav link.
- -
-
-
- href
-
- string
-
- Page to be opened when the user clicks on the link.
- -
-
-
- icon
-
- string | {"(React.ReactNode & React.SVGProps )"}
-
-
- A{" "}
-
- Material Symbol
- {" "}
- or a SVG element to be displayed left to the link as an icon.
-
- -
-
-
- newWindow
-
- boolean
-
- If true, the page is opened in a new browser tab.
-
- false
-
-
-
- onClick
-
- {"(event: React.MouseEvent ) => void"}
-
-
- This function will be called when the user clicks the link and the event will be passed to this
- function.
-
- -
-
-
- selected
-
- boolean
-
-
- If true, the link will be marked as selected. Moreover, in that same case, if it is contained within a
- collapsed group, and consequently, the currently selected link is not visible, the group title will
- appear as selected too.
-
-
- false
-
-
-
- tabIndex
-
- number
-
-
- Value of the tabindex attribute.
-
-
- 0
-
-
-
-
- ),
- },
- ],
- },
{
title: "Examples",
+ // TODO: Update the sandbox link
subSections: [
{
title: "Application layout with sidenav",
diff --git a/apps/website/screens/components/sidenav/overview/SidenavOverviewPage.tsx b/apps/website/screens/components/sidenav/overview/SidenavOverviewPage.tsx
index cd856a9694..7e28f0a8fb 100644
--- a/apps/website/screens/components/sidenav/overview/SidenavOverviewPage.tsx
+++ b/apps/website/screens/components/sidenav/overview/SidenavOverviewPage.tsx
@@ -5,6 +5,7 @@ import Image from "@/common/Image";
import anatomy from "./images/sidenav_anatomy.png";
import responsive from "./images/sidenav_responsive.png";
+// TODO: UPDATE WHEN DOC IS READY
const sections = [
{
title: "Introduction",
diff --git a/packages/lib/src/sidenav/types.ts b/packages/lib/src/sidenav/types.ts
index a09161d8bf..4cdfc4b959 100644
--- a/packages/lib/src/sidenav/types.ts
+++ b/packages/lib/src/sidenav/types.ts
@@ -1,64 +1,62 @@
-import { MouseEvent, ReactElement } from "react";
+import { MouseEvent, ReactElement, ReactNode } from "react";
import { SVG } from "../common/utils";
export type Logo = {
- /**
- * URL of the image that will be placed in the logo.
- */
- src: string;
/**
* Alternative text for the logo image.
*/
alt: string;
+ /**
+ * URL to navigate when the logo is clicked.
+ */
+ href?: string;
/**
* URL to navigate to when the logo is clicked. If not provided, the logo will not be clickable.
*/
onClick?: (event: MouseEvent) => void;
/**
- * URL to navigate when the logo is clicked.
+ * URL of the image that will be placed in the logo.
*/
- href?: string;
+ src: string;
};
type Section = { items: (Item | GroupItem)[]; title?: string };
type Props = {
/**
- * If true, the sidenav is expanded.
- * If undefined the component will be uncontrolled and the value will be managed internally by the component.
+ * The content rendered in the bottom part of the sidenav, under the navigation menu.
*/
- expanded?: boolean;
+ bottomContent?: ReactNode;
/**
- * Initial state of the expansion of the sidenav, only when it is uncontrolled.
+ * Object with the properties of the branding placed at the top of the sidenav.
*/
- defaultExpanded?: boolean;
+ branding?: { logo?: Logo; appTitle?: string } | ReactNode;
/**
- * Function called when the expansion state of the sidenav changes.
+ * Initial state of the expansion of the sidenav, only when it is uncontrolled.
*/
- onExpandedChange?: (value: boolean) => void;
+ defaultExpanded?: boolean;
/**
- * The additional content rendered in the upper part of the sidenav, under the branding.
- * It can also be a function that receives the expansion state to render different content based on it.
+ * If true the nav menu will have lines marking the groups.
*/
- topContent?: ReactElement;
+ displayGroupLines?: boolean;
/**
- * The content rendered in the bottom part of the sidenav, under the navigation menu.
- * It can also be a function that receives the expansion state to render different content based on it.
+ * If true, the sidenav is expanded.
+ * If undefined the component will be uncontrolled and the value will be managed internally by the component.
*/
- bottomContent?: ReactElement;
+ expanded?: boolean;
/**
* Array of items to be displayed in the navigation menu.
* Each item can be a single/simple item, a group item or a section.
*/
navItems?: (Item | GroupItem)[] | Section[];
/**
- * Object with the properties of the logo placed at the top of the sidenav.
+ * Function called when the expansion state of the sidenav changes.
*/
- branding?: { logo?: Logo; appTitle?: string } | ReactElement;
+ onExpandedChange?: (value: boolean) => void;
/**
- * If true the nav menu will have lines marking the groups.
+ * The additional content rendered in the upper part of the sidenav, under the branding.
*/
- displayGroupLines?: boolean;
+ topContent?: ReactNode;
};
type CommonItemProps = {
From a59350f1358351bc2c9fad7d52c5906aac644a49 Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Mon, 10 Nov 2025 10:53:28 +0100
Subject: [PATCH 32/36] Fixed typing problem with new branding
---
packages/lib/src/sidenav/Sidenav.tsx | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx
index 92b4d1cecb..8581d5c862 100644
--- a/packages/lib/src/sidenav/Sidenav.tsx
+++ b/packages/lib/src/sidenav/Sidenav.tsx
@@ -65,10 +65,7 @@ const DxcSidenav = ({
};
const isBrandingObject = (branding: SidenavPropsType["branding"]): branding is { logo?: Logo; appTitle?: string } => {
- return (
- (typeof branding === "object" && branding !== null && "logo" in branding) ||
- (!!branding && "appTitle" in branding)
- );
+ return typeof branding === "object" && branding !== null && ("logo" in branding || "appTitle" in branding);
};
return (
From 113220895e68d0a7b4803641a040f5df337dd3fc Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Tue, 11 Nov 2025 16:29:09 +0100
Subject: [PATCH 33/36] Temporally removed doc overview from sidenav
---
.../website/pages/components/sidenav/code.tsx | 28 ++++++++---------
.../pages/components/sidenav/index.tsx | 31 +++++++++++++++----
.../components/sidenav/SidenavPageLayout.tsx | 7 +++--
3 files changed, 43 insertions(+), 23 deletions(-)
diff --git a/apps/website/pages/components/sidenav/code.tsx b/apps/website/pages/components/sidenav/code.tsx
index 9bb8d2993a..fbab950b0c 100644
--- a/apps/website/pages/components/sidenav/code.tsx
+++ b/apps/website/pages/components/sidenav/code.tsx
@@ -1,17 +1,17 @@
-import Head from "next/head";
-import type { ReactElement } from "react";
-import SidenavPageLayout from "screens/components/sidenav/SidenavPageLayout";
-import SidenavCodePage from "screens/components/sidenav/code/SidenavCodePage";
+// import Head from "next/head";
+// import type { ReactElement } from "react";
+// import SidenavPageLayout from "screens/components/sidenav/SidenavPageLayout";
+// import SidenavCodePage from "screens/components/sidenav/code/SidenavCodePage";
-const Code = () => (
- <>
-
- Sidenav code — Halstack Design System
-
-
- >
-);
+// const Code = () => (
+// <>
+//
+// Sidenav code — Halstack Design System
+//
+//
+// >
+// );
-Code.getLayout = (page: ReactElement) => {page} ;
+// Code.getLayout = (page: ReactElement) => {page} ;
-export default Code;
+// export default Code;
diff --git a/apps/website/pages/components/sidenav/index.tsx b/apps/website/pages/components/sidenav/index.tsx
index 50ec17a206..8e099c450b 100644
--- a/apps/website/pages/components/sidenav/index.tsx
+++ b/apps/website/pages/components/sidenav/index.tsx
@@ -1,17 +1,36 @@
+// import Head from "next/head";
+// import type { ReactElement } from "react";
+// import SidenavPageLayout from "screens/components/sidenav/SidenavPageLayout";
+// import SidenavOverviewPage from "screens/components/sidenav/overview/SidenavOverviewPage";
+
+// const Index = () => (
+// <>
+//
+// Sidenav — Halstack Design System
+//
+// {/* */}
+//
+// >
+// );
+
+// Index.getLayout = (page: ReactElement) => {page} ;
+
+// export default Index;
+
import Head from "next/head";
import type { ReactElement } from "react";
import SidenavPageLayout from "screens/components/sidenav/SidenavPageLayout";
-import SidenavOverviewPage from "screens/components/sidenav/overview/SidenavOverviewPage";
+import SidenavCodePage from "screens/components/sidenav/code/SidenavCodePage";
-const Index = () => (
+const Code = () => (
<>
- Sidenav — Halstack Design System
+ Sidenav code — Halstack Design System
-
+
>
);
-Index.getLayout = (page: ReactElement) => {page} ;
+Code.getLayout = (page: ReactElement) => {page} ;
-export default Index;
+export default Code;
diff --git a/apps/website/screens/components/sidenav/SidenavPageLayout.tsx b/apps/website/screens/components/sidenav/SidenavPageLayout.tsx
index 0ce9cf71ab..fb56162709 100644
--- a/apps/website/screens/components/sidenav/SidenavPageLayout.tsx
+++ b/apps/website/screens/components/sidenav/SidenavPageLayout.tsx
@@ -7,10 +7,11 @@ import { ReactNode } from "react";
const SidenavPageHeading = ({ children }: { children: ReactNode }) => {
const tabs = [
- { label: "Overview", path: "/components/sidenav" },
- { label: "Code", path: "/components/sidenav/code" },
+ // { label: "Overview", path: "/components/sidenav" },
+ // { label: "Code", path: "/components/sidenav/code" },
+ { label: "Code", path: "/components/sidenav" },
];
- // TODO: UPDATE DESCRIPTION
+ // TODO: UPDATE DESCRIPTION WHEN OVERVIEW IS ADDED
return (
From d4d4cbb9c346e000d90dcef0f5e187e9ba410152 Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Tue, 11 Nov 2025 16:49:36 +0100
Subject: [PATCH 34/36] Removed redundant GroupItem
---
.../lib/src/navigation-tree/GroupItem.tsx | 85 -------------------
1 file changed, 85 deletions(-)
delete mode 100644 packages/lib/src/navigation-tree/GroupItem.tsx
diff --git a/packages/lib/src/navigation-tree/GroupItem.tsx b/packages/lib/src/navigation-tree/GroupItem.tsx
deleted file mode 100644
index f8c87e19cf..0000000000
--- a/packages/lib/src/navigation-tree/GroupItem.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { useContext, useId } from "react";
-import DxcIcon from "../icon/Icon";
-import { GroupItemProps } from "./types";
-import * as Popover from "@radix-ui/react-popover";
-import { useGroupItem } from "../base-menu/useGroupItem";
-import NavigationTreeContext from "./NavigationTreeContext";
-import ItemAction from "../base-menu/ItemAction";
-import SubMenu from "../base-menu/SubMenu";
-import MenuItem from "../base-menu/MenuItem";
-
-const GroupItem = ({ items, ...props }: GroupItemProps) => {
- const groupMenuId = `group-menu-${useId()}`;
-
- const NavigationTreeId = `sidenav-${useId()}`;
- const contextValue = useContext(NavigationTreeContext) ?? {};
- const { groupSelected, isOpen, toggleOpen, responsiveView } = useGroupItem(items, contextValue);
-
- return responsiveView ? (
- <>
-
-
- : }
- onClick={() => toggleOpen()}
- selected={groupSelected && !isOpen}
- {...props}
- />
-
-
-
- {
- event.preventDefault();
- }}
- onOpenAutoFocus={(event) => {
- event.preventDefault();
- }}
- align="start"
- side="right"
- style={{ zIndex: "var(--z-contextualmenu)" }}
- >
-
-
-
-
-
-
- >
- ) : (
- <>
- : }
- onClick={() => toggleOpen()}
- selected={groupSelected && !isOpen}
- {...props}
- />
- {isOpen && (
-
- )}
- >
- );
-};
-
-export default GroupItem;
From eb921cae761b158ad6a5e0228b7d242ea0833e9e Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Tue, 11 Nov 2025 17:07:33 +0100
Subject: [PATCH 35/36] Removed original code page
---
apps/website/pages/components/sidenav/code.tsx | 17 -----------------
1 file changed, 17 deletions(-)
delete mode 100644 apps/website/pages/components/sidenav/code.tsx
diff --git a/apps/website/pages/components/sidenav/code.tsx b/apps/website/pages/components/sidenav/code.tsx
deleted file mode 100644
index fbab950b0c..0000000000
--- a/apps/website/pages/components/sidenav/code.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-// import Head from "next/head";
-// import type { ReactElement } from "react";
-// import SidenavPageLayout from "screens/components/sidenav/SidenavPageLayout";
-// import SidenavCodePage from "screens/components/sidenav/code/SidenavCodePage";
-
-// const Code = () => (
-// <>
-//
-// Sidenav code — Halstack Design System
-//
-//
-// >
-// );
-
-// Code.getLayout = (page: ReactElement) => {page} ;
-
-// export default Code;
From 6e4919977fe6a09d57ea59e7fecd3499f3702dba Mon Sep 17 00:00:00 2001
From: Mil4n0r
Date: Tue, 11 Nov 2025 17:21:52 +0100
Subject: [PATCH 36/36] Fixed example from ApplicationLayout stories
---
packages/lib/src/layout/ApplicationLayout.stories.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/lib/src/layout/ApplicationLayout.stories.tsx b/packages/lib/src/layout/ApplicationLayout.stories.tsx
index 4e4048fc41..446b2f508d 100644
--- a/packages/lib/src/layout/ApplicationLayout.stories.tsx
+++ b/packages/lib/src/layout/ApplicationLayout.stories.tsx
@@ -72,7 +72,7 @@ const ApplicationLayoutResponsiveSidenav = () => (
(!expanded ? Responsive Content
: <>>)}
+ defaultExpanded={false}
/>
}
>