diff --git a/apps/website/pages/_app.tsx b/apps/website/pages/_app.tsx index ed382f285..8bdffef33 100644 --- a/apps/website/pages/_app.tsx +++ b/apps/website/pages/_app.tsx @@ -107,7 +107,7 @@ export default function App({ Component, pageProps, emotionCache = clientSideEmo sidenav={ } + appTitle={} searchBar={{ placeholder: "Search docs", onChange: (value) => setFilter(value) }} expanded={isExpanded} onExpandedChange={() => { diff --git a/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx b/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx index 137e967e3..551981c7e 100644 --- a/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx +++ b/apps/website/screens/components/sidenav/code/SidenavCodePage.tsx @@ -95,7 +95,9 @@ const sections = [ boolean If true the nav menu will have lines marking the groups. - - + + false + diff --git a/packages/lib/src/base-menu/GroupItem.tsx b/packages/lib/src/base-menu/GroupItem.tsx index d5a742288..ea328b439 100644 --- a/packages/lib/src/base-menu/GroupItem.tsx +++ b/packages/lib/src/base-menu/GroupItem.tsx @@ -15,7 +15,6 @@ const GroupItem = ({ items, ...props }: GroupItemProps) => { const contextValue = useContext(BaseMenuContext) ?? {}; const { groupSelected, isOpen, toggleOpen, hasPopOver, isHorizontal } = useGroupItem(items, contextValue); - // TODO: SET A FIXED WIDTH TO PREVENT MOVING CONTENT WHEN EXPANDING/COLLAPSING IN RESPONSIVEVIEW return hasPopOver ? ( <> @@ -47,10 +46,35 @@ const GroupItem = ({ items, ...props }: GroupItemProps) => { }} align="start" side={isHorizontal ? "bottom" : "right"} - style={{ zIndex: "var(--z-contextualmenu)" }} - sideOffset={isHorizontal ? 16 : 0} + style={{ + zIndex: "var(--z-contextualmenu)", + padding: "var(--spacing-padding-xs)", + boxShadow: "var(--shadow-100)", + backgroundColor: "var(--color-bg-neutral-lightest)", + borderRadius: "var(--border-radius-m)", + ...(isHorizontal + ? {} + : { + display: "flex", + flexDirection: "column", + gap: "var(--spacing-gap-xxs)", + }), + }} + sideOffset={16} onInteractOutside={isHorizontal ? () => toggleOpen() : undefined} > + {!isHorizontal && props.depthLevel === 0 && ( + : } + onClick={() => toggleOpen()} + selected={groupSelected && !isOpen} + {...props} + icon={undefined} + /> + )} {items.map((item, index) => ( (isHorizontal ? "row" : "column")}; gap: ${({ isHorizontal }) => (isHorizontal ? "var(--spacing-gap-s)" : "var(--spacing-gap-xs)")}; list-style: none; - ${({ isPopOver }) => - isPopOver && - ` + ${({ depthLevel, displayGroupLines, isPopOver }) => + isPopOver + ? ` min-width: 200px; max-width: 320px; - padding: var(--spacing-padding-xs); - background-color: var(--color-bg-neutral-lightest); - border-radius: var(--border-radius-m); - box-shadow: var(--shadow-100); - `} - ${({ depthLevel, displayGroupLines }) => - displayGroupLines && - depthLevel >= 0 && ` + : 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({ diff --git a/packages/lib/src/layout/ApplicationLayout.stories.tsx b/packages/lib/src/layout/ApplicationLayout.stories.tsx index 4f1fa5b29..68ed3448c 100644 --- a/packages/lib/src/layout/ApplicationLayout.stories.tsx +++ b/packages/lib/src/layout/ApplicationLayout.stories.tsx @@ -144,7 +144,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 16b55ab42..0537e9e4b 100644 --- a/packages/lib/src/layout/ApplicationLayout.tsx +++ b/packages/lib/src/layout/ApplicationLayout.tsx @@ -6,6 +6,7 @@ import DxcSidenav from "../sidenav/Sidenav"; import ApplicationLayoutPropsType, { AppLayoutMainPropsType } from "./types"; import { bottomLinks, findChildType, socialLinks, year } from "./utils"; import ApplicationLayoutContext from "./ApplicationLayoutContext"; +import { responsiveSizes } from "../common/variables"; const ApplicationLayoutContainer = styled.div<{ header?: React.ReactNode }>` top: 0; @@ -25,19 +26,29 @@ const HeaderContainer = styled.div` z-index: var(--z-app-layout-header); `; -const BodyContainer = styled.div<{ hasSidenav?: boolean }>` +const BodyContainer = styled.div<{ hasSidenav: boolean }>` display: grid; grid-template-columns: ${({ hasSidenav }) => (hasSidenav ? "auto 1fr" : "1fr")}; grid-template-rows: 1fr; overflow: hidden; + + @media (max-width: ${responsiveSizes.medium}rem) { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + overflow-y: auto; + } `; const SidenavContainer = styled.div` width: fit-content; - height: 100%; - z-index: var(--z-app-layout-sidenav); position: sticky; overflow: auto; + z-index: var(--z-app-layout-sidenav); + height: 100%; + + @media (max-width: ${responsiveSizes.medium}rem) { + width: 100%; + } `; const MainContainer = styled.div` diff --git a/packages/lib/src/navigation-tree/NavigationTree.tsx b/packages/lib/src/navigation-tree/NavigationTree.tsx index a139d8225..f47568d38 100644 --- a/packages/lib/src/navigation-tree/NavigationTree.tsx +++ b/packages/lib/src/navigation-tree/NavigationTree.tsx @@ -13,7 +13,6 @@ const NavigationTreeContainer = styled.div<{ displayBorder: boolean }>` 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; diff --git a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx index 7b2ac21e1..963bec30b 100644 --- a/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx +++ b/packages/lib/src/sidenav/Sidenav.accessibility.test.tsx @@ -11,6 +11,16 @@ global.ResizeObserver = vi.fn().mockImplementation(() => ({ })); describe("Sidenav component accessibility tests", () => { + beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + }); + }); it("Should not have basic accessibility issues", async () => { const groupItems = [ { diff --git a/packages/lib/src/sidenav/Sidenav.stories.tsx b/packages/lib/src/sidenav/Sidenav.stories.tsx index e0a68423d..0a1c7c879 100644 --- a/packages/lib/src/sidenav/Sidenav.stories.tsx +++ b/packages/lib/src/sidenav/Sidenav.stories.tsx @@ -11,6 +11,8 @@ 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"; +import DxcApplicationLayout from "../layout/ApplicationLayout"; +import DxcParagraph from "../paragraph/Paragraph"; export default { title: "Sidenav", @@ -34,7 +36,7 @@ const DetailedAvatar = () => { { Michael Ramirez + DXC Logo + + + + + + + +); + +const dxcBrandedLogo = { + src: dxcLogo, + alt: "DXC Logo", +}; + const Sidenav = () => ( <> @@ -437,6 +464,89 @@ const SelectedGroup = () => ( /> ); + +const SidenavInLayout = () => ( + + } + > + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ultrices fermentum ante et pharetra. Integer + ullamcorper ante non laoreet suscipit. Integer pharetra viverra nunc, quis fermentum urna eleifend eget. + Maecenas dolor justo, ullamcorper ac posuere tincidunt, dictum id urna. Suspendisse est metus, euismod et felis + eget, condimentum elementum eros. Curabitur ut lorem ut odio volutpat lacinia. Interdum et malesuada fames ac + ante ipsum primis in faucibus. Sed leo quam, lobortis in ultricies ac, interdum in sem. Suspendisse magna enim, + rhoncus eget lectus vitae, rutrum interdum ligula. Nunc efficitur neque ac orci pretium lacinia. Proin sagittis + condimentum mi, eu dapibus quam faucibus eget. Aenean fermentum nisl ut mauris convallis, in imperdiet neque + porttitor. Aliquam erat volutpat. Fusce tincidunt arcu id arcu dignissim viverra. Sed imperdiet vitae odio eget + consequat. Vivamus eu dictum orci. + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ultrices fermentum ante et pharetra. Integer + ullamcorper ante non laoreet suscipit. Integer pharetra viverra nunc, quis fermentum urna eleifend eget. + Maecenas dolor justo, ullamcorper ac posuere tincidunt, dictum id urna. Suspendisse est metus, euismod et felis + eget, condimentum elementum eros. Curabitur ut lorem ut odio volutpat lacinia. Interdum et malesuada fames ac + ante ipsum primis in faucibus. Sed leo quam, lobortis in ultricies ac, interdum in sem. Suspendisse magna enim, + rhoncus eget lectus vitae, rutrum interdum ligula. Nunc efficitur neque ac orci pretium lacinia. Proin sagittis + condimentum mi, eu dapibus quam faucibus eget. Aenean fermentum nisl ut mauris convallis, in imperdiet neque + porttitor. Aliquam erat volutpat. Fusce tincidunt arcu id arcu dignissim viverra. Sed imperdiet vitae odio eget + consequat. Vivamus eu dictum orci. + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ultrices fermentum ante et pharetra. Integer + ullamcorper ante non laoreet suscipit. Integer pharetra viverra nunc, quis fermentum urna eleifend eget. + Maecenas dolor justo, ullamcorper ac posuere tincidunt, dictum id urna. Suspendisse est metus, euismod et felis + eget, condimentum elementum eros. Curabitur ut lorem ut odio volutpat lacinia. Interdum et malesuada fames ac + ante ipsum primis in faucibus. Sed leo quam, lobortis in ultricies ac, interdum in sem. Suspendisse magna enim, + rhoncus eget lectus vitae, rutrum interdum ligula. Nunc efficitur neque ac orci pretium lacinia. Proin sagittis + condimentum mi, eu dapibus quam faucibus eget. Aenean fermentum nisl ut mauris convallis, in imperdiet neque + porttitor. Aliquam erat volutpat. Fusce tincidunt arcu id arcu dignissim viverra. Sed imperdiet vitae odio eget + consequat. Vivamus eu dictum orci. + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ultrices fermentum ante et pharetra. Integer + ullamcorper ante non laoreet suscipit. Integer pharetra viverra nunc, quis fermentum urna eleifend eget. + Maecenas dolor justo, ullamcorper ac posuere tincidunt, dictum id urna. Suspendisse est metus, euismod et felis + eget, condimentum elementum eros. Curabitur ut lorem ut odio volutpat lacinia. Interdum et malesuada fames ac + ante ipsum primis in faucibus. Sed leo quam, lobortis in ultricies ac, interdum in sem. Suspendisse magna enim, + rhoncus eget lectus vitae, rutrum interdum ligula. Nunc efficitur neque ac orci pretium lacinia. Proin sagittis + condimentum mi, eu dapibus quam faucibus eget. Aenean fermentum nisl ut mauris convallis, in imperdiet neque + porttitor. Aliquam erat volutpat. Fusce tincidunt arcu id arcu dignissim viverra. Sed imperdiet vitae odio eget + consequat. Vivamus eu dictum orci. + + + +); + +export const InLayout: Story = { + render: SidenavInLayout, +}; + +export const Responsive: Story = { + render: SidenavInLayout, + parameters: { + chromatic: { viewports: [375] }, + }, + globals: { + viewport: { value: "iphonex", isRotated: false }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await new Promise((resolve) => setTimeout(resolve, 100)); + await canvas.findByLabelText("Expand"); + await userEvent.tab(); + await userEvent.keyboard("{Enter}"); + await canvas.findByLabelText("Collapse"); + }, +}; + type Story = StoryObj; export const Chromatic: Story = { diff --git a/packages/lib/src/sidenav/Sidenav.test.tsx b/packages/lib/src/sidenav/Sidenav.test.tsx index cbbc8c717..0808a3016 100644 --- a/packages/lib/src/sidenav/Sidenav.test.tsx +++ b/packages/lib/src/sidenav/Sidenav.test.tsx @@ -10,8 +10,21 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({ })); describe("DxcSidenav component", () => { + const mockMatchMedia = jest.fn(); + + beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: mockMatchMedia, + }); + }); + beforeEach(() => { - jest.clearAllMocks(); + mockMatchMedia.mockImplementation(() => ({ + matches: false, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })); }); test("Sidenav renders title and children correctly", () => { diff --git a/packages/lib/src/sidenav/Sidenav.tsx b/packages/lib/src/sidenav/Sidenav.tsx index 7481e199e..22b3fbad9 100644 --- a/packages/lib/src/sidenav/Sidenav.tsx +++ b/packages/lib/src/sidenav/Sidenav.tsx @@ -1,33 +1,61 @@ import styled from "@emotion/styled"; -import { responsiveSizes } from "../common/variables"; import DxcFlex from "../flex/Flex"; import SidenavPropsType from "./types"; import DxcDivider from "../divider/Divider"; import DxcButton from "../button/Button"; import DxcImage from "../image/Image"; -import { useContext, useRef, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import DxcNavigationTree from "../navigation-tree/NavigationTree"; import DxcInset from "../inset/Inset"; import ApplicationLayoutContext from "../layout/ApplicationLayoutContext"; import DxcSearchBar from "../search-bar/SearchBar"; import DxcSearchBarTrigger from "../search-bar/SearchBarTrigger"; +import useResize from "../utils/useResize"; +import { useBreakpoint } from "../utils/useBreakpoint"; +import { responsiveSizes } from "../common/variables"; -const SidenavContainer = styled.div<{ expanded: boolean }>` +const COLLAPSED_WIDTH = 56; +const MIN_WIDTH = 0; +const MAX_WIDTH = 320; +const DEFAULT_WIDTH = 240; + +const SidenavContainer = styled.div<{ + expanded: boolean; + width: number; + showBorder?: boolean; + side?: "left" | "right"; + hasHeader?: boolean; +}>` + position: relative; box-sizing: border-box; display: flex; - flex-direction: column; - /* TODO: IMPLEMENT RESIZABLE SIDENAV */ + flex-direction: ${({ side }) => (side === "right" ? "row-reverse" : "row")}; + ${({ showBorder }) => + showBorder && + `border-right: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-lighter);`} + width: ${({ expanded, width }) => (expanded ? `${width}px` : `${COLLAPSED_WIDTH}px`)}; min-width: ${({ expanded }) => (expanded ? "240px" : "56px")}; - max-width: ${({ expanded }) => (expanded ? "320px" : "56px")}; height: 100%; - @media (max-width: ${responsiveSizes.large}rem) { - width: 100vw; + background-color: var(--color-bg-neutral-lightest); + @media (max-width: ${responsiveSizes.medium}rem) { + width: 100%; + ${({ expanded, hasHeader }) => + expanded && (hasHeader ? "height: calc(100vh - var(--height-xxxl));" : "height: 100vh;")} } +`; + +const SidenavContent = styled.div` + position: relative; + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 100%; + width: 100%; padding-top: var(--spacing-padding-m); padding-bottom: var(--spacing-padding-m); gap: var(--spacing-gap-l); background-color: var(--color-bg-neutral-lightest); - border-right: var(--border-width-s) var(--border-style-default) var(--border-color-neutral-lighter); + & > div { box-sizing: border-box; padding-left: var(--spacing-padding-xs); @@ -35,6 +63,13 @@ const SidenavContainer = styled.div<{ expanded: boolean }>` } `; +const ResizeHandle = styled.span<{ active: boolean }>` + padding: var(--spacing-padding-none) var(--spacing-padding-xxxs); + height: 100%; + cursor: col-resize; + background-color: transparent; +`; + const SidenavTitle = styled.div` display: flex; align-items: center; @@ -67,13 +102,28 @@ const DxcSidenav = ({ appTitle, displayGroupLines = false, expanded, - defaultExpanded = true, + defaultExpanded, onExpandedChange, searchBar, }: SidenavPropsType): JSX.Element => { - const [internalExpanded, setInternalExpanded] = useState(defaultExpanded); + const isBelowLarge = useBreakpoint("large"); + const isBelowMedium = useBreakpoint("medium"); const { logo, headerExists } = useContext(ApplicationLayoutContext); const isControlled = expanded !== undefined; + const { width, sidenavRef, isResizing, startResize } = useResize({ + minWidth: MIN_WIDTH, + maxWidth: MAX_WIDTH, + defaultWidth: DEFAULT_WIDTH, + }); + + const [internalExpanded, setInternalExpanded] = useState(defaultExpanded ?? !isBelowLarge); + + useEffect(() => { + if (defaultExpanded === undefined) { + setInternalExpanded(!isBelowLarge && width > COLLAPSED_WIDTH); + } + }, [isBelowLarge, width]); + const isExpanded = isControlled ? !!expanded : internalExpanded; const shouldFocusSearchBar = useRef(false); @@ -92,70 +142,90 @@ const DxcSidenav = ({ }; return ( - - - - - - {logo && !headerExists && ( - - {typeof logo.src === "string" ? ( - - ) : ( - logo.src - )} - - )} - {appTitle} - - - {(topContent || searchBar) && ( - - {searchBar && - (isExpanded ? ( - - ) : ( - - ))} - {topContent} - - )} - {navItems && ( - - )} - {bottomContent && ( - <> - - - - - {bottomContent} + + + + + + + {logo && !headerExists && ( + + {typeof logo.src === "string" ? ( + + ) : ( + logo.src + )} + + )} + {isExpanded && {appTitle}} - - )} + + {!(isBelowMedium && !isExpanded) && ( + <> + {(topContent || searchBar) && ( + + {searchBar && + (isExpanded ? ( + + ) : ( + + ))} + {topContent} + + )} + {navItems && ( + + )} + {bottomContent && ( + <> + + + + + {bottomContent} + + + )} + + )} + + {isExpanded && } ); }; diff --git a/packages/lib/src/utils/useBreakpoint.ts b/packages/lib/src/utils/useBreakpoint.ts new file mode 100644 index 000000000..da169e86f --- /dev/null +++ b/packages/lib/src/utils/useBreakpoint.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react"; +import { responsiveSizes } from "../common/variables"; + +export const useBreakpoint = (breakpoint: keyof typeof responsiveSizes): boolean | undefined => { + const query = `(max-width: ${responsiveSizes[breakpoint]}rem)`; + + const [matches, setMatches] = useState(() => { + if (typeof window !== "undefined") { + return window.matchMedia(query).matches; + } + return undefined; + }); + + useEffect(() => { + if (typeof window === "undefined") return; + + const media = window.matchMedia(query); + const handler = () => setMatches(media.matches); + + setMatches(media.matches); + + media.addEventListener("change", handler); + return () => media.removeEventListener("change", handler); + }, [query]); + + return matches; +}; diff --git a/packages/lib/src/utils/useResize.ts b/packages/lib/src/utils/useResize.ts new file mode 100644 index 000000000..c33895cbe --- /dev/null +++ b/packages/lib/src/utils/useResize.ts @@ -0,0 +1,53 @@ +import { useEffect, useRef, useState } from "react"; + +type UseResizeProps = { + minWidth: number; + maxWidth: number; + defaultWidth: number; +}; + +const useResize = ({ minWidth, maxWidth, defaultWidth }: UseResizeProps) => { + const [width, setWidth] = useState(defaultWidth); + const sidenavRef = useRef(null); + const [isResizing, setIsResizing] = useState(false); + + useEffect(() => { + const onMove = (e: MouseEvent) => { + if (!isResizing || !sidenavRef.current) return; + + const rect = sidenavRef.current.getBoundingClientRect(); + const nextWidth = e.clientX - rect.left; + + setWidth(Math.min(Math.max(nextWidth, minWidth), maxWidth)); + }; + + const stop = () => { + setIsResizing(false); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", stop); + + return () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", stop); + }; + }, [minWidth, maxWidth, isResizing]); + + const startResize = () => { + setIsResizing(true); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }; + + return { + width, + sidenavRef, + isResizing, + startResize, + }; +}; + +export default useResize;