diff --git a/packages/odyssey-react-mui/src/Box.tsx b/packages/odyssey-react-mui/src/Box.tsx index 39358e507b..2402b29f36 100644 --- a/packages/odyssey-react-mui/src/Box.tsx +++ b/packages/odyssey-react-mui/src/Box.tsx @@ -11,7 +11,7 @@ */ import { Box as MuiBox, BoxProps as MuiBoxProps } from "@mui/material"; -import { ReactNode, forwardRef, memo } from "react"; +import { AriaRole, ReactNode, forwardRef, memo } from "react"; import type { HtmlProps } from "./HtmlProps"; @@ -19,17 +19,19 @@ export type BoxProps = { children?: ReactNode; component?: MuiBoxProps["component"]; id?: MuiBoxProps["id"]; + role?: AriaRole; sx?: MuiBoxProps["sx"]; } & Pick; const Box = forwardRef( - ({ children, component, id, sx, testId, translate }, ref) => ( + ({ children, component, id, role, sx, testId, translate }, ref) => ( diff --git a/packages/odyssey-react-mui/src/labs/PageTemplate.tsx b/packages/odyssey-react-mui/src/labs/PageTemplate.tsx index 2d3e345f77..4ecb333f7d 100644 --- a/packages/odyssey-react-mui/src/labs/PageTemplate.tsx +++ b/packages/odyssey-react-mui/src/labs/PageTemplate.tsx @@ -20,6 +20,7 @@ import { import { DocumentationIcon } from "../icons.generated"; import { Heading4, Subordinate } from "../Typography"; import { Link } from "../Link"; +import { useHasUiShell } from "./UiShell"; export type PageTemplateProps = { /** @@ -72,16 +73,20 @@ type TemplateContentProps = { const TemplateContainer = styled("div", { shouldForwardProp: (prop) => - prop !== "odysseyDesignTokens" && prop !== "isFullWidth", + prop !== "odysseyDesignTokens" && + prop !== "hasUiShell" && + prop !== "isFullWidth", })<{ - odysseyDesignTokens: DesignTokens; + hasUiShell: boolean; isFullWidth: boolean; -}>(({ odysseyDesignTokens, isFullWidth }) => ({ + odysseyDesignTokens: DesignTokens; +}>(({ hasUiShell, isFullWidth, odysseyDesignTokens }) => ({ maxWidth: isFullWidth ? "100%" : `calc(1440px + ${odysseyDesignTokens.Spacing6} + ${odysseyDesignTokens.Spacing6})`, - marginInline: isFullWidth ? odysseyDesignTokens.Spacing6 : "auto", - padding: odysseyDesignTokens.Spacing6, + marginInline: + isFullWidth && !hasUiShell ? odysseyDesignTokens.Spacing6 : "auto", + padding: hasUiShell ? 0 : odysseyDesignTokens.Spacing6, })); const TemplateHeader = styled("div")(() => ({ @@ -163,24 +168,27 @@ const TemplateContent = styled("div", { ); const PageTemplate = ({ - title, + children, description, documentationLink, documentationText, + drawer, + isFullWidth = false, primaryCallToActionComponent, secondaryCallToActionComponent, tertiaryCallToActionComponent, - children, - drawer, - isFullWidth = false, + title, }: PageTemplateProps) => { const odysseyDesignTokens = useOdysseyDesignTokens(); const { isOpen: isDrawerOpen, variant: drawerVariant } = drawer?.props ?? {}; + const hasUiShell = useHasUiShell(); + return ( diff --git a/packages/odyssey-react-mui/src/labs/SideNav/NavAccordion.tsx b/packages/odyssey-react-mui/src/labs/SideNav/NavAccordion.tsx index a3fff71264..108991dec8 100644 --- a/packages/odyssey-react-mui/src/labs/SideNav/NavAccordion.tsx +++ b/packages/odyssey-react-mui/src/labs/SideNav/NavAccordion.tsx @@ -20,7 +20,7 @@ import { import { ReactNode, memo } from "react"; import type { HtmlProps } from "../../HtmlProps"; -import { ChevronRightIcon } from "../../icons.generated"; +import { ChevronDownIcon } from "../../icons.generated"; import { DesignTokens, useOdysseyDesignTokens, @@ -86,26 +86,32 @@ const AccordionSummaryContainer = styled(MuiAccordionSummary, { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens" && prop !== "isCompact" && - prop != "isDisabled", + prop !== "isDisabled", })<{ odysseyDesignTokens: DesignTokens; isCompact?: boolean; isDisabled?: boolean; }>(({ odysseyDesignTokens, isCompact, isDisabled }) => ({ - minHeight: isCompact - ? `${odysseyDesignTokens.Spacing6}` - : `${odysseyDesignTokens.Spacing7}`, - padding: isCompact - ? `${odysseyDesignTokens.Spacing0} ${odysseyDesignTokens.Spacing4}` - : `${odysseyDesignTokens.Spacing2} ${odysseyDesignTokens.Spacing4}`, - "&:hover": { - backgroundColor: !isDisabled ? odysseyDesignTokens.HueBlue50 : "inherit", - "& span": { - color: isDisabled - ? "default" - : `${odysseyDesignTokens.TypographyColorAction}`, - }, + borderRadius: odysseyDesignTokens.BorderRadiusMain, + paddingBlock: odysseyDesignTokens.Spacing3, + paddingInline: odysseyDesignTokens.Spacing4, + lineHeight: 1.5, + + "&:focus-visible": { + backgroundColor: "unset", + outline: "none", + boxShadow: `inset 0 0 0 3px ${odysseyDesignTokens.PalettePrimaryMain}`, }, + + ...(isCompact && { + paddingBlock: odysseyDesignTokens.Spacing2, + }), + + ...(!isDisabled && { + "&:hover": { + backgroundColor: odysseyDesignTokens.HueNeutral50, + }, + }), })); const NavAccordion = ({ @@ -135,7 +141,7 @@ const NavAccordion = ({ } + expandIcon={} id={headerId} odysseyDesignTokens={odysseyDesignTokens} isCompact={isCompact} diff --git a/packages/odyssey-react-mui/src/labs/SideNav/OktaLogo.tsx b/packages/odyssey-react-mui/src/labs/SideNav/OktaLogo.tsx index 2196b44fc1..fff50ebfc0 100644 --- a/packages/odyssey-react-mui/src/labs/SideNav/OktaLogo.tsx +++ b/packages/odyssey-react-mui/src/labs/SideNav/OktaLogo.tsx @@ -21,6 +21,7 @@ const OktaLogo = () => { fill="none" xmlns="http://www.w3.org/2000/svg" > + Okta { + test("can show the default Okta logo", async () => { + render( + + + , + ); + + expect(screen.getByTitle("Okta")).toBeInTheDocument(); + }); + + test("can show a custom logo", async () => { + render( + + + , + ); + + expect(screen.getByAltText("Custom logo")).toBeInTheDocument(); + }); + + test("can show header text", async () => { + const headerText = "Header text"; + + render( + + + , + ); + + expect( + screen.getByRole("heading", { name: headerText }), + ).toBeInTheDocument(); + }); + + test("is collapsible", async () => { + const menuItemText = "Users"; + + render( + + + , + ); + + expect(screen.getByText(menuItemText)).toBeVisible(); + + const collapseButton = screen.getByLabelText("Collapse side navigation"); + fireEvent.click(collapseButton); + + expect(screen.getByText(menuItemText)).not.toBeVisible; + + const expandButton = screen.getByLabelText("Expand side navigation"); + fireEvent.click(expandButton); + + expect(screen.getByText(menuItemText)).toBeVisible(); + }); + + test("can fire onCollapse event", async () => { + const menuItemText = "Users"; + const mockOnCollapse = jest.fn(); + + render( + + + , + ); + + const collapseButton = screen.getByLabelText("Collapse side navigation"); + fireEvent.click(collapseButton); + + expect(mockOnCollapse).toBeCalled(); + }); + + test("can fire onExpand event", async () => { + const menuItemText = "Users"; + const mockOnExpand = jest.fn(); + + render( + + + , + ); + + const collapseButton = screen.getByLabelText("Collapse side navigation"); + fireEvent.click(collapseButton); + + const expandButton = screen.getByLabelText("Expand side navigation"); + fireEvent.click(expandButton); + + expect(mockOnExpand).toBeCalled(); + }); + + test("shows loading skeleton state", async () => { + const menuItemText = "Menu item"; + + render( + + + , + ); + + expect(screen.queryByText(menuItemText)).not.toBeInTheDocument(); + }); + + test("shows footer links", async () => { + const footerItemLabel = "Footer item"; + render( + + + , + ); + + const footer = screen.getByRole("menubar"); + expect(within(footer).getByText(footerItemLabel)).toBeVisible(); + }); + + test("shows custom footer component", async () => { + const footerComponentText = "This is a custom footer component."; + const footerComponent =

{footerComponentText}

; + + render( + + + , + ); + + expect(screen.getByText(footerComponentText)).toBeVisible(); + }); + + test("displays sidenav link", async () => { + const accordionInner = "Accordion inside"; + const accordionOuter = "Accordion outside"; + const headingText = "Heading"; + const menuClickableText = "Clickable"; + const menuLinkText = "Link"; + + render( + + {}, + }, + { + id: "menuHeading", + label: headingText, + isSectionHeader: true, + }, + { + id: "menuLink", + href: "#", + label: menuLinkText, + }, + { + id: "accordionOuter", + label: accordionOuter, + children: [ + { + id: "accordionInner", + href: "#", + label: accordionInner, + }, + ], + }, + ]} + /> + , + ); + + expect(screen.getByRole("menuitem", { name: menuLinkText })).toBeVisible(); + expect( + screen.getByRole("button", { name: menuClickableText }), + ).toBeVisible(); + expect(screen.getByRole("heading", { name: headingText })).toBeVisible(); + + const accordion = screen.getByText(accordionOuter); + expect(screen.getByText(accordionInner)).not.toBeVisible(); + fireEvent.click(accordion); + expect(screen.getByText(accordionInner)).toBeVisible(); + }); + + test("can show notification badge", async () => { + const menuItemText = "Menu item text"; + const badgeCount = 9; + + render( + + + , + ); + + expect(screen.getByRole("menuitem")).toHaveTextContent(`${badgeCount}`); + }); +}); diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SideNav.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SideNav.tsx index ed5276421f..6345bde38a 100644 --- a/packages/odyssey-react-mui/src/labs/SideNav/SideNav.tsx +++ b/packages/odyssey-react-mui/src/labs/SideNav/SideNav.tsx @@ -10,33 +10,34 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import styled from "@emotion/styled"; +import styled, { CSSObject } from "@emotion/styled"; import { memo, useMemo, useState, useCallback, - KeyboardEvent, useRef, useEffect, + KeyboardEventHandler, } from "react"; +import { Skeleton } from "@mui/material"; +import { useTranslation } from "react-i18next"; import { NavAccordion } from "./NavAccordion"; import { DesignTokens, useOdysseyDesignTokens, } from "../../OdysseyDesignTokensContext"; +import { OdysseyThemeProvider } from "../../OdysseyThemeProvider"; import type { SideNavProps } from "./types"; -import { OktaLogo } from "./OktaLogo"; -import { HandleIcon } from "./HandleIcon"; -import { CollapseIcon } from "./CollapseIcon"; import { SideNavHeader } from "./SideNavHeader"; import { SideNavItemContent, - SideNavListItemContainer, + StyledSideNavListItem, } from "./SideNavItemContent"; import { SideNavFooterContent } from "./SideNavFooterContent"; import { SideNavItemContentContext } from "./SideNavItemContentContext"; +import { SideNavToggleButton } from "./SideNavToggleButton"; export const DEFAULT_SIDE_NAV_WIDTH = "300px"; @@ -44,13 +45,7 @@ export const DEFAULT_SIDE_NAV_WIDTH = "300px"; // to align it in the middle of the nav header text export const SIDENAV_COLLAPSE_ICON_POSITION = "77px"; -const SideNavContainer = styled("div")(() => ({ - display: "flex", - height: "100%", - overflow: "hidden", -})); - -const SideNavCollapsedContainer = styled("div", { +const StyledCollapsibleContent = styled("div", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens" && prop !== "isSideNavCollapsed", })( @@ -61,27 +56,20 @@ const SideNavCollapsedContainer = styled("div", { odysseyDesignTokens: DesignTokens; isSideNavCollapsed: boolean; }) => ({ - display: "flex", - "&:before": { - height: "100%", - width: 0, - content: '""', - }, - "&:has(svg:hover)": { - "&:before": { - width: isSideNavCollapsed ? "8px" : 0, - transitionProperty: "width, background-color", - transitionDuration: odysseyDesignTokens.TransitionDurationMain, - transitionTimingFunction: odysseyDesignTokens.TransitionTimingMain, - backgroundColor: isSideNavCollapsed - ? odysseyDesignTokens.HueNeutral200 - : "transparent", - }, - }, + position: "relative", + display: "inline-grid", + gridTemplateColumns: isSideNavCollapsed ? 0 : DEFAULT_SIDE_NAV_WIDTH, + gridTemplateRows: "max-content 1fr max-content", + minWidth: isSideNavCollapsed ? 0 : DEFAULT_SIDE_NAV_WIDTH, + height: "100%", + transition: `grid-template-columns ${odysseyDesignTokens.TransitionDurationMain}, opacity 300ms`, + transitionTimingFunction: odysseyDesignTokens.TransitionTimingMain, + overflow: "hidden", + opacity: isSideNavCollapsed ? 0 : 1, }), ); -const ToggleSideNavHandleContainer = styled("div", { +const StyledSideNav = styled("nav", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens" && prop !== "isSideNavCollapsed", })( @@ -92,60 +80,45 @@ const ToggleSideNavHandleContainer = styled("div", { odysseyDesignTokens: DesignTokens; isSideNavCollapsed: boolean; }) => ({ - height: 0, - cursor: "pointer", - marginTop: SIDENAV_COLLAPSE_ICON_POSITION, - "& svg:nth-of-type(2)": { + position: "relative", + display: "inline-block", + height: "100%", + backgroundColor: odysseyDesignTokens.HueNeutralWhite, + + "&::after": { + backgroundColor: odysseyDesignTokens.HueNeutral200, + content: "''", + height: "100%", opacity: 0, - width: 0, - transform: isSideNavCollapsed ? "rotate(180deg)" : "rotate(0deg)", + position: "absolute", + right: 0, + top: 0, + transform: `translateX(0)`, + transition: `opacity ${odysseyDesignTokens.TransitionDurationMain}, transform ${odysseyDesignTokens.TransitionDurationMain}`, + width: odysseyDesignTokens.Spacing2, }, - "&:hover, &:focus-visible": { - "& svg:nth-of-type(2)": { - opacity: 1, - width: "32px", - padding: odysseyDesignTokens.Spacing2, - transitionProperty: "opacity", - transitionDuration: odysseyDesignTokens.TransitionDurationMain, - transitionTimingFunction: odysseyDesignTokens.TransitionTimingMain, - }, - "& svg:nth-of-type(1)": { - opacity: 0, - width: 0, - transitionProperty: "opacity", - transitionDuration: odysseyDesignTokens.TransitionDurationMain, - transitionTimingFunction: odysseyDesignTokens.TransitionTimingMain, + + "&:has([data-sidenav-toggle='true']:hover), &:has([data-sidenav-toggle='true']:focus)": + { + ...(isSideNavCollapsed && { + "&::after": { + opacity: 1, + transform: `translateX(100%)`, + }, + + "[data-sidenav-toggle='true']": { + transform: `translate3d(calc(100% + ${odysseyDesignTokens.Spacing3}), 0, 0)`, + }, + }), }, - }, - }), -); -const SideNavExpandContainer = styled("nav", { - shouldForwardProp: (prop) => - prop !== "odysseyDesignTokens" && - prop !== "isSideNavCollapsed" && - prop !== "expandedWidth", -})( - ({ - odysseyDesignTokens, - isSideNavCollapsed, - expandedWidth, - }: { - odysseyDesignTokens: DesignTokens; - isSideNavCollapsed: boolean; - expandedWidth: string; - }) => ({ - backgroundColor: odysseyDesignTokens.HueNeutralWhite, - flexDirection: "column", - display: "flex", - opacity: isSideNavCollapsed ? 0 : 1, - visibility: isSideNavCollapsed ? "hidden" : "visible", - width: isSideNavCollapsed ? 0 : expandedWidth, - minWidth: isSideNavCollapsed ? 0 : expandedWidth, - transitionProperty: "opacity", - transitionDuration: odysseyDesignTokens.TransitionDurationMain, - transitionTimingFunction: odysseyDesignTokens.TransitionTimingMain, - borderRight: `${odysseyDesignTokens.BorderWidthMain} ${odysseyDesignTokens.BorderStyleMain} ${odysseyDesignTokens.HueNeutral50}`, + "[data-sidenav-toggle='true']": { + position: "absolute", + top: SIDENAV_COLLAPSE_ICON_POSITION, + right: 0, + transition: `transform ${odysseyDesignTokens.TransitionDurationMain}`, + transform: `translate3d(100%, 0, 0)`, + }, }), ); @@ -160,12 +133,12 @@ const SideNavHeaderContainer = styled("div", { hasContentScrolled: boolean; odysseyDesignTokens: DesignTokens; }) => ({ - position: "sticky", - top: 0, + flexShrink: 0, // The bottom border should appear only if the scrollable region has been scrolled - ...(hasContentScrolled && { - borderBottom: `${odysseyDesignTokens.BorderWidthMain} ${odysseyDesignTokens.BorderStyleMain} ${odysseyDesignTokens.HueNeutral50}`, - }), + ...(hasContentScrolled && + ({ + borderBottom: `${odysseyDesignTokens.BorderWidthMain} ${odysseyDesignTokens.BorderStyleMain} ${odysseyDesignTokens.HueNeutral50}`, + } satisfies CSSObject)), }), ); @@ -175,21 +148,28 @@ const SideNavListContainer = styled("ul")(() => ({ listStyleType: "none", })); -const SideNavScrollableContainer = styled("div")(() => ({ - flex: 1, +const SideNavScrollableContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ + flex: "1 1 100%", overflowY: "auto", + paddingInline: odysseyDesignTokens.Spacing2, +})); + +const SectionHeaderContainer = styled("li", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ + paddingBlock: odysseyDesignTokens.Spacing3, + paddingInline: odysseyDesignTokens.Spacing4, })); -const SectionHeader = styled("li", { +const SectionHeader = styled("h3", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", })(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ fontFamily: odysseyDesignTokens.TypographyFamilyHeading, fontSize: odysseyDesignTokens.TypographySizeOverline, fontWeight: odysseyDesignTokens.TypographyWeightHeadingBold, color: odysseyDesignTokens.HueNeutral600, - paddingTop: odysseyDesignTokens.Spacing3, - paddingBottom: odysseyDesignTokens.Spacing3, - paddingLeft: odysseyDesignTokens.Spacing4, textTransform: "uppercase", })); @@ -204,15 +184,14 @@ const SideNavFooter = styled("div", { isContentScrollable: boolean; odysseyDesignTokens: DesignTokens; }) => ({ - position: "sticky", - bottom: 0, - paddingTop: odysseyDesignTokens.Spacing2, + flexShrink: 0, transitionProperty: "box-shadow", transitionDuration: odysseyDesignTokens.TransitionDurationMain, transitionTiming: odysseyDesignTokens.TransitionTimingMain, + backgroundColor: odysseyDesignTokens.HueNeutralWhite, // The box shadow should appear above the footer only if the scrollable region has overflow ...(isContentScrollable && { - boxShadow: odysseyDesignTokens.DepthHigh, + boxShadow: "0px -8px 8px -8px rgba(39, 39, 39, 0.08)", }), }), ); @@ -220,37 +199,62 @@ const SideNavFooter = styled("div", { const SideNavFooterItemsContainer = styled("div", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", })(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ - paddingTop: odysseyDesignTokens.Spacing2, - paddingBottom: odysseyDesignTokens.Spacing2, + paddingBlock: odysseyDesignTokens.Spacing4, + // paddingBlockEnd: odysseyDesignTokens.Spacing4, + paddingInline: odysseyDesignTokens.Spacing5, display: "flex", - justifyContent: "center", flexWrap: "wrap", alignItems: "center", fontSize: odysseyDesignTokens.TypographySizeOverline, - "& a": { + + a: { color: `${odysseyDesignTokens.TypographyColorHeading} !important`, - }, - "& a:hover": { - textDecoration: "none", - }, - "& a:visited": { - color: odysseyDesignTokens.TypographyColorHeading, + + "&:hover": { + textDecoration: "none", + }, + "&:visited": { + color: odysseyDesignTokens.TypographyColorHeading, + }, }, })); +const LoadingItemContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ + alignItems: "center", + display: "flex", + gap: odysseyDesignTokens.Spacing2, + paddingBlock: odysseyDesignTokens.Spacing2, + paddingInline: odysseyDesignTokens.Spacing4, +})); + const getHasScrollableContent = (scrollableContainer: HTMLElement) => scrollableContainer.scrollHeight > scrollableContainer.clientHeight; +const LoadingItem = () => { + const odysseyDesignTokens: DesignTokens = useOdysseyDesignTokens(); + return ( + + + + + ); +}; + const SideNav = ({ - customCompanyLogo, - expandedWidth = DEFAULT_SIDE_NAV_WIDTH, + appName, footerComponent, footerItems, - hasCustomCompanyLogo, hasCustomFooter, isCollapsible, isCompact, - appName, + isLoading, + logoProps, onCollapse, onExpand, sideNavItems, @@ -262,6 +266,7 @@ const SideNav = ({ const resizeObserverRef = useRef(null); const intersectionObserverRef = useRef(null); const odysseyDesignTokens: DesignTokens = useOdysseyDesignTokens(); + const { t } = useTranslation(); useEffect(() => { const updateIsContentScrollable = () => { @@ -343,7 +348,7 @@ const SideNav = ({ } cancelAnimationFrame(resizeObserverDebounceTimer); // Ensure timer is cleared on component unmount }; - }, []); + }, [sideNavItems]); const scrollIntoViewRef = useRef(null); /** @@ -422,8 +427,10 @@ const SideNav = ({ setSideNavCollapsed(!isSideNavCollapsed); }, [isSideNavCollapsed, setSideNavCollapsed, onExpand, onCollapse]); - const sideNavExpandKeyHandler = useCallback( - (event: KeyboardEvent) => { + const sideNavExpandKeyHandler = useCallback< + KeyboardEventHandler + >( + (event) => { if (event?.key === "Enter" || event?.code === "Space") { event.preventDefault(); sideNavExpandClickHandler(); @@ -433,131 +440,130 @@ const SideNav = ({ ); return ( - - - + {isCollapsible && ( + + )} + + - - } - /> - - - - {processedSideNavItems?.map((item) => { - const { - id, - label, - isSectionHeader, - startIcon, - children, - isDefaultExpanded, - isDisabled, - isExpanded, - } = item; - - if (isSectionHeader) { - return ( - - {label} - - ); - } else if (children) { - return ( - - - - {children} - - - - ); - } else { - return ( - - - - ); - } - })} - - - {(footerItems || hasCustomFooter) && ( - - {hasCustomFooter - ? footerComponent - : footerItems && ( - - - - )} - - )} - - {isCollapsible && ( - - + + - - - - - )} - + + {isLoading + ? [...Array(6)].map((_, index) => ) + : processedSideNavItems?.map((item) => { + const { + id, + label, + isSectionHeader, + startIcon, + children, + isDefaultExpanded, + isDisabled, + isExpanded, + } = item; + + if (isSectionHeader) { + return ( + + + {label} + + + ); + } else if (children) { + return ( + + + + {children} + + + + ); + } else { + return ( + + + + ); + } + })} + + + {!isLoading && (footerItems || hasCustomFooter) && ( + + {hasCustomFooter + ? footerComponent + : footerItems && ( + + + + )} + + )} + + + ); }; diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SideNavFooterContent.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SideNavFooterContent.tsx index c05a4c650b..2fbef7d2f6 100644 --- a/packages/odyssey-react-mui/src/labs/SideNav/SideNavFooterContent.tsx +++ b/packages/odyssey-react-mui/src/labs/SideNav/SideNavFooterContent.tsx @@ -11,10 +11,30 @@ */ import { memo, useMemo } from "react"; -import { useOdysseyDesignTokens } from "../../OdysseyDesignTokensContext"; +import styled from "@emotion/styled"; + +import { + useOdysseyDesignTokens, + DesignTokens, +} from "../../OdysseyDesignTokensContext"; import type { SideNavFooterItem } from "./types"; import { Box } from "../../Box"; import { Link } from "../../Link"; +import { useTranslation } from "react-i18next"; + +const StyledFooterNav = styled("nav")({ + display: "flex", +}); + +const StyledFooterItemContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ + "& + &": { + marginInlineStart: odysseyDesignTokens.Spacing4, + paddingInlineStart: odysseyDesignTokens.Spacing4, + borderInlineStart: `1px solid ${odysseyDesignTokens.HueNeutral300}`, + }, +})); const SideNavFooterContent = ({ footerItems, @@ -22,41 +42,29 @@ const SideNavFooterContent = ({ footerItems: SideNavFooterItem[]; }) => { const odysseyDesignTokens = useOdysseyDesignTokens(); + const { t } = useTranslation(); - const footerContent = useMemo(() => { - return footerItems?.map((item, index) => ( - { + return footerItems?.map((item) => ( + {item.href ? ( - - {item.label} - + {item.label} ) : ( - - {item.label} - - )} - {index < footerItems.length - 1 && ( - - | - + {item.label} )} - + )); }, [footerItems, odysseyDesignTokens]); - return footerContent; + return ( + + {memoizedFooterContent} + + ); }; const MemoizedSideNavFooterContent = memo(SideNavFooterContent); MemoizedSideNavFooterContent.displayName = "SideNavFooterContent"; diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SideNavHeader.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SideNavHeader.tsx index 25f5dff705..7232e6d614 100644 --- a/packages/odyssey-react-mui/src/labs/SideNav/SideNavHeader.tsx +++ b/packages/odyssey-react-mui/src/labs/SideNav/SideNavHeader.tsx @@ -11,76 +11,91 @@ */ import styled from "@emotion/styled"; -import { memo, useMemo, type ReactElement } from "react"; +import { memo } from "react"; +import { Skeleton } from "@mui/material"; + import { type DesignTokens, useOdysseyDesignTokens, } from "../../OdysseyDesignTokensContext"; -import { Box } from "../../Box"; +import { SideNavLogo } from "./SideNavLogo"; +import { SideNavProps } from "./types"; import { Heading6 } from "../../Typography"; import { TOP_NAV_HEIGHT } from "../TopNav"; +const SideNavHeaderContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ + position: "relative", + display: "flex", + flexDirection: "column", + backgroundColor: odysseyDesignTokens.HueNeutralWhite, + zIndex: 1, +})); + const SideNavLogoContainer = styled("div", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", })(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ + display: "flex", + alignItems: "center", height: TOP_NAV_HEIGHT, - padding: odysseyDesignTokens.Spacing3, - borderColor: odysseyDesignTokens.HueNeutral50, - borderStyle: odysseyDesignTokens.BorderStyleMain, - borderWidth: 0, - borderBottomWidth: odysseyDesignTokens.BorderWidthMain, + padding: odysseyDesignTokens.Spacing4, + + "svg, img": { + maxHeight: "100%", + width: "auto", + maxWidth: "100%", + }, })); -const SideNavHeaderContainer = styled("div", { +const SideNavHeadingContainer = styled("div", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", })(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ display: "flex", justifyContent: "space-between", alignItems: "center", - paddingLeft: odysseyDesignTokens.Spacing4, - paddingRight: odysseyDesignTokens.Spacing4, - paddingTop: odysseyDesignTokens.Spacing3, - paddingBottom: odysseyDesignTokens.Spacing3, + padding: odysseyDesignTokens.Spacing4, + width: "100%", + + ["& .MuiTypography-root"]: { + margin: 0, + width: "100%", + }, })); -export type SideNavHeader = { +export type SideNavHeaderProps = { /** * The app's name. */ appName: string; /** - * Company logo that displays above the app name. + * If the side nav currently has no items, it will be loading. */ - companyLogo: ReactElement; -}; + isLoading?: boolean; +} & Pick; -const SideNavHeader = ({ appName, companyLogo }: SideNavHeader) => { +const SideNavHeader = ({ + appName, + isLoading, + logoProps, +}: SideNavHeaderProps) => { const odysseyDesignTokens = useOdysseyDesignTokens(); - const sideNavHeaderStyles = useMemo( - () => ({ - marginTop: odysseyDesignTokens.Spacing2, - }), - [odysseyDesignTokens], - ); - return ( - + - {companyLogo} + {isLoading ? ( + // The skeleton takes the hardcoded dimensions of the Okta logo + + ) : ( + + )} - - - {appName} - - - + + {isLoading ? : appName} + + ); }; diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemContent.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemContent.tsx index ff497a3c7d..b27c6101eb 100644 --- a/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemContent.tsx +++ b/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemContent.tsx @@ -32,7 +32,7 @@ import { } from "./SideNavItemContentContext"; import { ExternalLinkIcon } from "../../icons.generated"; -export const SideNavListItemContainer = styled("li", { +export const StyledSideNavListItem = styled("li", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens" && prop !== "isSelected", })<{ @@ -42,8 +42,16 @@ export const SideNavListItemContainer = styled("li", { }>(({ odysseyDesignTokens, isSelected }) => ({ display: "flex", alignItems: "center", - backgroundColor: isSelected ? odysseyDesignTokens.HueNeutral50 : "unset", - margin: `${odysseyDesignTokens.Spacing1} 0`, + backgroundColor: "unset", + borderRadius: odysseyDesignTokens.BorderRadiusMain, + lineHeight: 1.5, + transition: `backgroundColor ${odysseyDesignTokens.TransitionDurationMain}, color ${odysseyDesignTokens.TransitionDurationMain}`, + + ...(isSelected && { + color: `${odysseyDesignTokens.TypographyColorAction} !important`, + backgroundColor: odysseyDesignTokens.HueBlue50, + }), + "&:last-child": { marginBottom: odysseyDesignTokens.Spacing2, }, @@ -67,44 +75,61 @@ const GetNavItemContentStyles = ({ odysseyDesignTokens, contextValue, isDisabled, + isSelected, }: { odysseyDesignTokens: DesignTokens; contextValue: SideNavItemContentContextValue; isDisabled?: boolean; + isSelected?: boolean; }) => { return { display: "flex", alignItems: "center", width: "100%", textDecoration: "none", - color: `${isDisabled ? odysseyDesignTokens.TypographyColorDisabled : odysseyDesignTokens.TypographyColorHeading} !important`, - minHeight: contextValue.isCompact - ? odysseyDesignTokens.Spacing6 - : odysseyDesignTokens.Spacing7, - padding: contextValue.isCompact - ? `${odysseyDesignTokens.Spacing0} ${odysseyDesignTokens.Spacing4} ${odysseyDesignTokens.Spacing0} calc(${odysseyDesignTokens.Spacing4} * ${contextValue.depth})` - : `${odysseyDesignTokens.Spacing2} ${odysseyDesignTokens.Spacing4} ${odysseyDesignTokens.Spacing2} calc(${odysseyDesignTokens.Spacing4} * ${contextValue.depth})`, - "&:focus-visible": { - borderRadius: 0, - outlineColor: odysseyDesignTokens.FocusOutlineColorPrimary, - outlineStyle: odysseyDesignTokens.FocusOutlineStyle, - outlineWidth: odysseyDesignTokens.FocusOutlineWidthMain, + color: `${odysseyDesignTokens.TypographyColorHeading} !important`, + minHeight: odysseyDesignTokens.Spacing7, + paddingBlock: odysseyDesignTokens.Spacing4, + paddingInline: `calc(${odysseyDesignTokens.Spacing4} * ${contextValue.depth})`, + borderRadius: odysseyDesignTokens.BorderRadiusMain, + transition: `backgroundColor ${odysseyDesignTokens.TransitionDurationMain}, color ${odysseyDesignTokens.TransitionDurationMain}`, + + "&:hover": { textDecoration: "none", - outlineOffset: 0, - color: isDisabled - ? "default" - : `${odysseyDesignTokens.TypographyColorAction} !important`, + cursor: "pointer", backgroundColor: !isDisabled ? odysseyDesignTokens.HueNeutral50 : "inherit", + + ...(isDisabled && { + color: "inherit", + cursor: "default", + }), + + ...(isSelected && { + "&:hover": { + backgroundColor: odysseyDesignTokens.HueBlue50, + }, + }), }, - "&:hover": { - textDecoration: "none", - cursor: isDisabled ? "default" : "pointer", - color: isDisabled - ? "default" - : `${odysseyDesignTokens.TypographyColorAction} !important`, - backgroundColor: !isDisabled ? odysseyDesignTokens.HueBlue50 : "inherit", + + ...(isSelected && { + color: `${odysseyDesignTokens.TypographyColorAction} !important`, + fontWeight: odysseyDesignTokens.TypographyWeightBodyBold, + }), + + ...(isDisabled && { + color: `${odysseyDesignTokens.TypographyColorDisabled} !important`, + }), + + ...(contextValue.isCompact && { + paddingBlock: odysseyDesignTokens.Spacing1, + minHeight: odysseyDesignTokens.Spacing6, + }), + + "&:focus-visible": { + outline: "none", + boxShadow: `inset 0 0 0 3px ${odysseyDesignTokens.PalettePrimaryMain}`, }, }; }; @@ -113,17 +138,20 @@ const NavItemContentContainer = styled("div", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens" && prop != "contextValue" && - prop !== "isDisabled", + prop !== "isDisabled" && + prop !== "isSelected", })(GetNavItemContentStyles); const NavItemLinkContainer = styled(NavItemLink, { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens" && prop != "contextValue" && - prop !== "isDisabled", + prop !== "isDisabled" && + prop !== "isSelected", })(GetNavItemContentStyles); const SideNavItemContent = ({ + count, id, label, href, @@ -138,6 +166,7 @@ const SideNavItemContent = ({ scrollRef, }: Pick< SideNavItem, + | "count" | "id" | "label" | "href" @@ -186,13 +215,14 @@ const SideNavItemContent = ({ ); return ( - { @@ -202,8 +232,10 @@ const SideNavItemContent = ({ odysseyDesignTokens={odysseyDesignTokens} contextValue={contextValue} isDisabled={isDisabled} + isSelected={isSelected} > ) } - + ); }; const MemoizedSideNavItemContent = memo(SideNavItemContent); diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemLinkContent.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemLinkContent.tsx index bb0979a88b..e8d319a986 100644 --- a/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemLinkContent.tsx +++ b/packages/odyssey-react-mui/src/labs/SideNav/SideNavItemLinkContent.tsx @@ -19,6 +19,7 @@ import { import { Box } from "../../Box"; import { Status } from "../../Status"; import type { SideNavItem } from "./types"; +import { Badge } from "../../Badge"; const SideNavItemLabelContainer = styled("div", { shouldForwardProp: (prop) => @@ -32,11 +33,11 @@ const SideNavItemLabelContainer = styled("div", { flexWrap: "wrap", alignItems: "center", fontSize: odysseyDesignTokens.TypographyScale0, - fontWeight: odysseyDesignTokens.TypographyWeightHeading, - marginLeft: isIconVisible ? odysseyDesignTokens.Spacing2 : 0, + marginInlineStart: isIconVisible ? odysseyDesignTokens.Spacing2 : 0, })); const SideNavItemLinkContent = ({ + count, label, startIcon, endIcon, @@ -44,13 +45,16 @@ const SideNavItemLinkContent = ({ statusLabel, }: Pick< SideNavItem, - "label" | "startIcon" | "endIcon" | "severity" | "statusLabel" + "count" | "label" | "startIcon" | "endIcon" | "severity" | "statusLabel" >): ReactNode => { const odysseyDesignTokens = useOdysseyDesignTokens(); const sideNavItemContentStyles = useMemo( () => ({ - marginLeft: odysseyDesignTokens.Spacing2, + alignItems: "center", + display: "flex", + gap: odysseyDesignTokens.Spacing1, + marginInlineStart: odysseyDesignTokens.Spacing2, }), [odysseyDesignTokens], ); @@ -62,11 +66,17 @@ const SideNavItemLinkContent = ({ odysseyDesignTokens={odysseyDesignTokens} isIconVisible={Boolean(startIcon)} > - {Boolean(startIcon)} {label} - {severity && ( + {!count && severity && ( - + {severity && ( + + )} + + )} + {!severity && count && ( + + {count && } )} diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SideNavLogo.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SideNavLogo.tsx new file mode 100644 index 0000000000..f6983025df --- /dev/null +++ b/packages/odyssey-react-mui/src/labs/SideNav/SideNavLogo.tsx @@ -0,0 +1,41 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { memo, useMemo } from "react"; +import { OktaLogo } from "./OktaLogo"; +import { SideNavLogoProps } from "./types"; + +const SideNavLogo = ({ + imageAltText, + href, + logoComponent, + imageUrl, +}: SideNavLogoProps) => { + const logo = useMemo(() => { + if (logoComponent) { + return logoComponent; + } + + if (imageAltText && imageUrl) { + return {imageAltText}; + } + + return ; + }, [imageAltText, logoComponent, imageUrl]); + + return href ? {logo} : logo; +}; + +const MemoizedSideNavLogo = memo(SideNavLogo); +MemoizedSideNavLogo.displayName = "SideNavLogo"; + +export { MemoizedSideNavLogo as SideNavLogo }; diff --git a/packages/odyssey-react-mui/src/labs/SideNav/SideNavToggleButton.tsx b/packages/odyssey-react-mui/src/labs/SideNav/SideNavToggleButton.tsx new file mode 100644 index 0000000000..5d8c166f04 --- /dev/null +++ b/packages/odyssey-react-mui/src/labs/SideNav/SideNavToggleButton.tsx @@ -0,0 +1,249 @@ +/*! + * Copyright (c) 2022-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { Button as MuiButton } from "@mui/material"; +import type { ButtonProps as MuiButtonProps } from "@mui/material"; +import { + HTMLAttributes, + memo, + useCallback, + useImperativeHandle, + useMemo, + useRef, +} from "react"; +import styled from "@emotion/styled"; +import { useTranslation } from "react-i18next"; + +import { FocusHandle } from "../../inputUtils"; +import { MuiPropsContext, MuiPropsContextType } from "../../MuiPropsContext"; +import { + DesignTokens, + useOdysseyDesignTokens, +} from "../../OdysseyDesignTokensContext"; +import { Tooltip } from "../../Tooltip"; + +const StyledToggleButton = styled(MuiButton, { + shouldForwardProp: (prop) => + prop !== "odysseyDesignTokens" && prop !== "isSideNavCollapsed", +})( + ({ + isSideNavCollapsed, + odysseyDesignTokens, + }: { + isSideNavCollapsed: boolean; + odysseyDesignTokens: DesignTokens; + }) => ({ + backgroundColor: "transparent", + position: "relative", + width: odysseyDesignTokens.Spacing6, + height: odysseyDesignTokens.Spacing6, + border: 0, + zIndex: 2, + + "&:focus-visible": { + boxShadow: `inset 0 0 0 2px ${odysseyDesignTokens.PalettePrimaryMain}`, + outline: "none", + }, + + "&:hover, &:focus": { + backgroundColor: "transparent", + + "#lineOne": { + animation: + "lineOne-animate-to-collapse 250ms cubic-bezier(0, 0, 0.2, 1)", + animationFillMode: "forwards", + "@keyframes lineOne-animate-to-collapse": { + "0%": { + transform: "translate3d(-50%, -50%, 0)", + }, + "50%": { + transform: "translate3d(-50%, -50%, 0) rotate(-90deg) scaleY(.75)", + }, + "100%": { + transform: "translate3d(-50%, -27%, 0) rotate(-45deg) scaleY(.75)", + }, + }, + }, + + "#lineTwo": { + animation: + "lineTwo-animate-to-collapse 250ms cubic-bezier(0, 0, 0.2, 1)", + animationFillMode: "forwards", + "@keyframes lineTwo-animate-to-collapse": { + "0%": { + transform: "translate3d(-50%, -50%, 0)", + }, + "50%": { + transform: "translate3d(-50%, -50%, 0) rotate(-90deg) scaleY(.75)", + }, + "100%": { + transform: "translate3d(-50%, -73%, 0) rotate(-135deg) scaleY(.75)", + }, + }, + }, + + ...(isSideNavCollapsed && { + "#lineOne": { + animation: + "lineOne-animate-to-expand 250ms cubic-bezier(0, 0, 0.2, 1)", + animationFillMode: "forwards", + "@keyframes lineOne-animate-to-expand": { + "0%": { + transform: "translate3d(-50%, -50%, 0)", + }, + "50%": { + transform: "translate3d(-50%, -50%, 0) rotate(90deg) scaleY(.75)", + }, + "100%": { + transform: + "translate3d(-50%, -73%, 0) rotate(135deg) scaleY(.75)", + }, + }, + }, + + "#lineTwo": { + animation: + "lineTwo-animate-to-expand 250ms cubic-bezier(0, 0, 0.2, 1)", + animationFillMode: "forwards", + "@keyframes lineTwo-animate-to-expand": { + "0%": { + transform: "translate3d(-50%, -50%, 0)", + }, + "50%": { + transform: "translate3d(-50%, -50%, 0) rotate(90deg) scaleY(.75)", + }, + "100%": { + transform: "translate3d(-50%, -27%, 0) rotate(45deg) scaleY(.75)", + }, + }, + }, + }), + }, + + span: { + position: "absolute", + top: "50%", + left: "50%", + width: "2px", + height: odysseyDesignTokens.Spacing4, + backgroundColor: odysseyDesignTokens.HueNeutral500, + transform: "translate3d(-50%, -50%, 0)", + transition: `transform ${odysseyDesignTokens.TransitionDurationMain}`, + }, + }), +); + +export type SideNavToggleButtonProps = { + /** + * The ref forwarded to the Button + */ + buttonRef?: React.RefObject; + /** + * The `id` of the item this button controls + */ + ariaControls: string; + /** + * The ID of the Button + */ + id?: string; + isSideNavCollapsed: boolean; + tabIndex?: HTMLAttributes["tabIndex"]; + /** + * The click event handler for the Button + */ + onClick?: MuiButtonProps["onClick"]; + onKeyDown?: MuiButtonProps["onKeyDown"]; +}; + +const SideNavToggleButton = ({ + ariaControls, + buttonRef, + id, + isSideNavCollapsed, + onClick, + tabIndex, +}: SideNavToggleButtonProps) => { + const odysseyDesignTokens = useOdysseyDesignTokens(); + const { t } = useTranslation(); + + const localButtonRef = useRef(null); + + useImperativeHandle( + buttonRef, + () => ({ + focus: () => { + localButtonRef.current?.focus(); + }, + }), + [], + ); + + const toggleLabel = useMemo( + () => + isSideNavCollapsed + ? t("sidenav.toggle.expand") + : t("sidenav.toggle.collapse"), + [isSideNavCollapsed, t], + ); + + const renderButton = useCallback( + (muiProps: MuiPropsContextType) => { + return ( + { + if (element) { + ( + localButtonRef as React.MutableRefObject + ).current = element; + //@ts-expect-error ref is not an optional prop on the props context type + muiProps?.ref?.(element); + } + }} + tabIndex={tabIndex} + variant="floating" + > + + + + ); + }, + [ + ariaControls, + id, + isSideNavCollapsed, + odysseyDesignTokens, + onClick, + tabIndex, + toggleLabel, + ], + ); + + return ( + + {renderButton} + + ); +}; + +const MemoizedSideNavToggleButton = memo(SideNavToggleButton); +MemoizedSideNavToggleButton.displayName = "SideNavToggleButton"; + +export { MemoizedSideNavToggleButton as SideNavToggleButton }; diff --git a/packages/odyssey-react-mui/src/labs/SideNav/types.ts b/packages/odyssey-react-mui/src/labs/SideNav/types.ts index 8afede98ba..7b52d539b7 100644 --- a/packages/odyssey-react-mui/src/labs/SideNav/types.ts +++ b/packages/odyssey-react-mui/src/labs/SideNav/types.ts @@ -14,6 +14,41 @@ import type { ReactElement } from "react"; import type { HtmlProps } from "../../HtmlProps"; import type { statusSeverityValues } from "../../Status"; +export type SideNavLogoProps = { + href?: string; +} & ( + | { + /** + * a component to render as the logo + */ + logoComponent: ReactElement; + imageAltText?: never; + imageUrl?: never; + } + | { + /** + * The src url to render in an `img` tag + */ + imageUrl: string; + /** + * alt text for the img logo + */ + imageAltText: string; + logoComponent?: never; + } + | { + /** + * The src url to render in an `img` tag + */ + imageUrl?: never; + /** + * alt text for the img logo + */ + imageAltText?: never; + logoComponent?: never; + } +); + export type SideNavProps = { /** * Side Nav header text that is usually reserved to show the App name @@ -27,6 +62,14 @@ export type SideNavProps = { * Determines whether the side nav items use compact layout */ isCompact?: boolean; + /** + * Before the side nav has items, it will be in a loading state. + */ + isLoading?: boolean; + /** + * An optional logo component or src string for an img to display in the header. If not provided, will default to the Okta logo + */ + logoProps?: SideNavLogoProps; /** * Triggers when the side nav is collapsed */ @@ -39,54 +82,36 @@ export type SideNavProps = { * Nav items in the side nav */ sideNavItems: SideNavItem[]; - /** - * A CSS length string indicating the customizable expanded width of the SideNav container. - * (it will be smaller if isCollapsible and collapsed) - */ - expandedWidth?: string; } & ( | { /** - * An optional logo to display in the header. If not provided, will default to the Okta logo. - */ - customCompanyLogo: ReactElement; - /** - * Use the built-in Okta logo or a custom one. + * The component to display as the footer; if present the `footerItems` are ignored and not rendered. */ - hasCustomCompanyLogo: true; + footerComponent?: ReactElement; + footerItems?: never; + hasCustomFooter: true; } | { - customCompanyLogo?: never; - hasCustomCompanyLogo?: false; + footerComponent?: never; + /** + * Footer items in the side nav + */ + footerItems?: SideNavFooterItem[]; + hasCustomFooter?: false; } ) & - ( - | { - /** - * The component to display as the footer; if present the `footerItems` are ignored and not rendered. - */ - footerComponent?: ReactElement; - footerItems?: never; - hasCustomFooter: true; - } - | { - footerComponent?: never; - /** - * Footer items in the side nav - */ - footerItems?: SideNavFooterItem[]; - hasCustomFooter?: false; - } - ) & Pick; export type SideNavItem = { - id: string; - label: string; + /** + * The number to display as a count alongside the nav item + */ + count?: number; /** * The icon element to display at the end of the Nav Item */ endIcon?: ReactElement; + id: string; /** * Whether the item is disabled. When set to true the nav item is set to Disabled color, * the link/item is not clickable, and item with children is not expandable. @@ -96,6 +121,7 @@ export type SideNavItem = { * Whether the item is active/selected */ isSelected?: boolean; + label: string; /** * Event fired when the nav item is clicked */ @@ -118,25 +144,25 @@ export type SideNavItem = { target?: string; } & ( | { + children?: never; + href?: never; + isDefaultExpanded?: never; + isExpanded?: never; /** * Determines if the side nav item is a section header */ isSectionHeader: true; - href?: never; - children?: never; - isDefaultExpanded?: never; - isExpanded?: never; } | { + children?: never; /** * link added to the nav item. if it is undefined, static text will be displayed. * fires onClick event when it is passed */ href?: string; - children?: never; - isSectionHeader?: never; isDefaultExpanded?: never; isExpanded?: never; + isSectionHeader?: never; } | { /** @@ -144,6 +170,7 @@ export type SideNavItem = { */ children?: Array>; endIcon?: never; + href?: never; /** * Whether the accordion (nav item with children) is expanded by default */ @@ -154,7 +181,6 @@ export type SideNavItem = { */ isExpanded?: boolean; isSectionHeader?: never; - href?: never; } ); diff --git a/packages/odyssey-react-mui/src/labs/TopNav/TopNav.tsx b/packages/odyssey-react-mui/src/labs/TopNav/TopNav.tsx index cc7cde44dc..f43cf1d78e 100644 --- a/packages/odyssey-react-mui/src/labs/TopNav/TopNav.tsx +++ b/packages/odyssey-react-mui/src/labs/TopNav/TopNav.tsx @@ -21,40 +21,70 @@ import { export const TOP_NAV_HEIGHT = `${64 / 14}rem`; -export type TopNavProps = { - /** - * React components that render into the left side of the top nav. - */ - leftSideComponent?: ReactElement; - /** - * React components that render into the right side of the top nav. - */ - rightSideComponent?: ReactElement; -} & Pick; +const StyledLeftSideContainer = styled("div")(() => ({ + flexGrow: 1, +})); + +const StyledRightSideContainer = styled("div")(() => ({ + flexShrink: 0, +})); const StyledTopNavContainer = styled("div", { - shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", + shouldForwardProp: (prop) => + prop !== "odysseyDesignTokens" && prop !== "isScrolled", })<{ odysseyDesignTokens: DesignTokens; -}>(({ odysseyDesignTokens }) => ({ + isScrolled?: boolean; +}>(({ odysseyDesignTokens, isScrolled }) => ({ alignItems: "center", backgroundColor: odysseyDesignTokens.HueNeutral50, + boxShadow: isScrolled ? odysseyDesignTokens.DepthMedium : undefined, + clipPath: "inset(0 0 -100vh 0)", display: "flex", + gap: odysseyDesignTokens.Spacing4, height: "100%", justifyContent: "space-between", maxHeight: TOP_NAV_HEIGHT, minHeight: TOP_NAV_HEIGHT, paddingBlock: odysseyDesignTokens.Spacing2, paddingInline: odysseyDesignTokens.Spacing6, + transition: `box-shadow ${odysseyDesignTokens.TransitionDurationMain} ${odysseyDesignTokens.TransitionTimingMain}`, + zIndex: 1, })); -const TopNav = ({ leftSideComponent, rightSideComponent }: TopNavProps) => { +export type TopNavProps = { + /** + * Whether or not the underlying content has been scrolled + */ + isScrolled?: boolean; + /** + * React components that render into the left side of the top nav. + */ + leftSideComponent?: ReactElement; + /** + * React components that render into the right side of the top nav. + */ + rightSideComponent?: ReactElement; +} & Pick; + +const TopNav = ({ + isScrolled, + leftSideComponent, + rightSideComponent, +}: TopNavProps) => { const odysseyDesignTokens = useOdysseyDesignTokens(); return ( - - {leftSideComponent ??
} - {rightSideComponent ??
} + + + {leftSideComponent ??
} + + + {rightSideComponent ??
} + ); }; diff --git a/packages/odyssey-react-mui/src/labs/UiShell/UiShell.test.tsx b/packages/odyssey-react-mui/src/labs/UiShell/UiShell.test.tsx index 40cb6177ba..6d2a912e80 100644 --- a/packages/odyssey-react-mui/src/labs/UiShell/UiShell.test.tsx +++ b/packages/odyssey-react-mui/src/labs/UiShell/UiShell.test.tsx @@ -26,9 +26,8 @@ describe("UiShell", () => { appRootElement={appRootElement} onSubscriptionCreated={() => {}} optionalComponents={{ - topNavLeftSide:
, sideNavFooter:
, - companyLogo:
, + topNavLeftSide:
, topNavRightSide: ( { test("renders optionally-available `componentSlots`", async () => { const optionalComponentTestIds: Array< keyof Required["optionalComponents"] - > = ["companyLogo", "sideNavFooter"]; + > = ["sideNavFooter"]; // This is the subscription we give the component, and then once subscribed, we're going to immediately call it with new props. const subscribeToPropChanges: UiShellProps["subscribeToPropChanges"] = ( @@ -125,7 +124,6 @@ describe("UiShell", () => { ...defaultComponentProps, sideNavProps: { appName: "", - hasCustomCompanyLogo: true, hasCustomFooter: true, sideNavItems: [], }, diff --git a/packages/odyssey-react-mui/src/labs/UiShell/UiShellContent.tsx b/packages/odyssey-react-mui/src/labs/UiShell/UiShellContent.tsx index 6a799351d9..864eeed2f0 100644 --- a/packages/odyssey-react-mui/src/labs/UiShell/UiShellContent.tsx +++ b/packages/odyssey-react-mui/src/labs/UiShell/UiShellContent.tsx @@ -20,47 +20,56 @@ import { useOdysseyDesignTokens, type DesignTokens, } from "../../OdysseyDesignTokensContext"; +import { useScrollState } from "./useScrollState"; const StyledAppContainer = styled("div", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", })<{ odysseyDesignTokens: DesignTokens; }>(({ odysseyDesignTokens }) => ({ + gridArea: "app-content", overflowX: "hidden", - overflowY: "scroll", + overflowY: "auto", paddingBlock: odysseyDesignTokens.Spacing5, paddingInline: odysseyDesignTokens.Spacing6, })); -const StyledFlexibleContentContainer = styled("div", { +const StyledBannersContainer = styled("div")(() => ({ + gridArea: "banners", +})); + +const StyledSideNavContainer = styled("div")(() => ({ + gridArea: "side-nav", +})); + +const StyledShellContainer = styled("div", { shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", })<{ odysseyDesignTokens: DesignTokens; }>(({ odysseyDesignTokens }) => ({ backgroundColor: odysseyDesignTokens.HueNeutral50, - display: "flex", - flexBasis: "100%", - flexDirection: "column", - flexGrow: 1, -})); - -const StyledRigidContentContainer = styled("div")(() => ({ - flexShrink: 0, - height: "100%", -})); - -const StyledShellContainer = styled("div")(() => ({ - display: "flex", - flexWrap: "nowrap", + display: "grid", + gridGap: 0, + gridTemplateAreas: ` + "banners banners" + "side-nav top-nav" + "side-nav app-content" + `, + gridTemplateColumns: "auto 1fr", + gridTemplateRows: "auto auto 1fr", height: "100vh", width: "100vw", })); +const StyledTopNavContainer = styled("div")(() => ({ + gridArea: "top-nav", +})); + export type UiShellNavComponentProps = { /** * Object that gets pass directly to the side nav component. */ - sideNavProps?: Omit; + sideNavProps?: Omit; /** * Object that gets pass directly to the top nav component. */ @@ -81,7 +90,6 @@ export type UiShellContentProps = { */ optionalComponents?: { banners?: ReactElement; - companyLogo?: SideNavProps["customCompanyLogo"]; sideNavFooter?: SideNavProps["footerComponent"]; topNavLeftSide?: TopNavProps["leftSideComponent"]; topNavRightSide?: TopNavProps["rightSideComponent"]; @@ -103,25 +111,20 @@ const UiShellContent = ({ topNavProps, }: UiShellContentProps) => { const odysseyDesignTokens = useOdysseyDesignTokens(); + const { isContentScrolled, scrollableContentRef } = useScrollState(); return ( - - + + + {optionalComponents?.banners} + + + {sideNavProps && ( )} - + - + + - - {optionalComponents?.banners} - - {appComponent} - - + + {appComponent} + ); }; diff --git a/packages/odyssey-react-mui/src/labs/UiShell/index.ts b/packages/odyssey-react-mui/src/labs/UiShell/index.ts index 098411173c..12f80169f0 100644 --- a/packages/odyssey-react-mui/src/labs/UiShell/index.ts +++ b/packages/odyssey-react-mui/src/labs/UiShell/index.ts @@ -11,7 +11,7 @@ */ export * from "./renderUiShell"; +export * from "./useHasUiShell"; export { UiShell, type UiShellProps } from "./UiShell"; - export { type UiShellNavComponentProps } from "./UiShellContent"; diff --git a/packages/odyssey-react-mui/src/labs/UiShell/renderUiShell.test.tsx b/packages/odyssey-react-mui/src/labs/UiShell/renderUiShell.test.tsx index f11299fece..bb8bcf134d 100644 --- a/packages/odyssey-react-mui/src/labs/UiShell/renderUiShell.test.tsx +++ b/packages/odyssey-react-mui/src/labs/UiShell/renderUiShell.test.tsx @@ -54,7 +54,6 @@ describe("renderUiShell", () => { }); expect(slottedElements.banners).toBeInstanceOf(HTMLDivElement); - expect(slottedElements.companyLogo).toBeInstanceOf(HTMLDivElement); expect(slottedElements.sideNavFooter).toBeInstanceOf(HTMLDivElement); expect(slottedElements.topNavLeftSide).toBeInstanceOf(HTMLDivElement); expect(slottedElements.topNavRightSide).toBeInstanceOf(HTMLDivElement); diff --git a/packages/odyssey-react-mui/src/labs/UiShell/renderUiShell.tsx b/packages/odyssey-react-mui/src/labs/UiShell/renderUiShell.tsx index 3e0002e029..85add6f84b 100644 --- a/packages/odyssey-react-mui/src/labs/UiShell/renderUiShell.tsx +++ b/packages/odyssey-react-mui/src/labs/UiShell/renderUiShell.tsx @@ -19,12 +19,13 @@ import { UiShell, UiShellProps } from "./UiShell"; import { renderReactInWebComponent } from "../../web-component/renderReactInWebComponent"; import { type UiShellNavComponentProps } from "./UiShellContent"; +export const uiShellDataAttribute = "data-unified-ui-shell"; + export const optionalComponentSlotNames: Record< keyof Required["optionalComponents"], string > = { banners: "banners", - companyLogo: "company-logo", sideNavFooter: "side-nav-footer", topNavLeftSide: "top-nav-left-side", topNavRightSide: "top-nav-right-side", @@ -60,6 +61,9 @@ export const renderUiShell = ({ const appRootElement = explicitAppRootElement || document.createElement("div"); + // Add this attribute so `PageTemplate` and potentially other components will know if they're in UI Shell with special padding already available. + uiShellRootElement.setAttribute(uiShellDataAttribute, ""); + const { publish: publishPropChanges, subscribe: subscribeToPropChanges } = createMessageBus>(); diff --git a/packages/odyssey-react-mui/src/labs/UiShell/useHasUiShell.ts b/packages/odyssey-react-mui/src/labs/UiShell/useHasUiShell.ts new file mode 100644 index 0000000000..ae5e852101 --- /dev/null +++ b/packages/odyssey-react-mui/src/labs/UiShell/useHasUiShell.ts @@ -0,0 +1,25 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { useEffect, useState } from "react"; + +import { uiShellDataAttribute } from "./renderUiShell"; + +export const useHasUiShell = () => { + const [hasUiShell, setHasUiShell] = useState(false); + + useEffect(() => { + setHasUiShell(Boolean(document.querySelector(`[${uiShellDataAttribute}]`))); + }, []); + + return hasUiShell; +}; diff --git a/packages/odyssey-react-mui/src/labs/UiShell/useScrollState.ts b/packages/odyssey-react-mui/src/labs/UiShell/useScrollState.ts new file mode 100644 index 0000000000..c5c251ae76 --- /dev/null +++ b/packages/odyssey-react-mui/src/labs/UiShell/useScrollState.ts @@ -0,0 +1,56 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { useEffect, useRef, useState } from "react"; + +export const useScrollState = < + ScrollableContentElement extends HTMLElement = HTMLDivElement, +>() => { + const [isContentScrolled, setIsContentScrolled] = useState(false); + + const scrollableContentRef = useRef(null); + + useEffect(() => { + if (scrollableContentRef.current) { + let requestedAnimationFrameId: number; + const scrollableContentElement = scrollableContentRef.current; + + const updateScrollState = () => { + cancelAnimationFrame(requestedAnimationFrameId); + + requestedAnimationFrameId = requestAnimationFrame(() => { + setIsContentScrolled(scrollableContentElement.scrollTop > 0); + }); + }; + + scrollableContentElement.addEventListener("scroll", updateScrollState); + + updateScrollState(); + + return () => { + scrollableContentElement.removeEventListener( + "scroll", + updateScrollState, + ); + + cancelAnimationFrame(requestedAnimationFrameId); + }; + } + + return () => {}; + }, []); + + return { + isContentScrolled, + scrollableContentRef, + }; +}; diff --git a/packages/odyssey-react-mui/src/properties/odyssey-react-mui.properties b/packages/odyssey-react-mui/src/properties/odyssey-react-mui.properties index d5436bd8d3..d4f9e56125 100644 --- a/packages/odyssey-react-mui/src/properties/odyssey-react-mui.properties +++ b/packages/odyssey-react-mui/src/properties/odyssey-react-mui.properties @@ -1,3 +1,6 @@ +navigation.label = Main navigation +navigation.footer = Navigation secondary links + breadcrumbs.home.text = Home breadcrumbs.label.text = Breadcrumbs close.text = Close @@ -82,6 +85,8 @@ severity.error = error severity.info = info severity.success = success severity.warning = warning +sidenav.toggle.expand = Open navigation +sidenav.toggle.collapse = Close navigation switch.active = Active switch.inactive = Inactive table.columnvisibility.arialabel = Show/hide columns diff --git a/packages/odyssey-react-mui/src/theme/components.tsx b/packages/odyssey-react-mui/src/theme/components.tsx index 4c29202952..6cd9ed02e5 100644 --- a/packages/odyssey-react-mui/src/theme/components.tsx +++ b/packages/odyssey-react-mui/src/theme/components.tsx @@ -137,12 +137,6 @@ export const components = ({ verticalAlign: "middle", width: "1em", }, - "&.nav-accordion-summary": { - padding: `${odysseyTokens.Spacing2} ${odysseyTokens.Spacing4}`, - ".MuiAccordionSummary-expandIconWrapper.Mui-expanded": { - transform: "rotate(-90deg) !important", - }, - }, }), content: () => ({ marginBlock: 0, @@ -160,9 +154,7 @@ export const components = ({ paddingInline: odysseyTokens.Spacing3, paddingBlock: odysseyTokens.Spacing4, "&.nav-accordion-details": { - paddingTop: 0, - paddingBottom: 0, - paddingLeft: odysseyTokens.Spacing2, + padding: 0, }, }), }, diff --git a/packages/odyssey-storybook/src/components/odyssey-labs/SideNav/SideNav.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-labs/SideNav/SideNav.stories.tsx index 98c57003b3..784bc13b6d 100644 --- a/packages/odyssey-storybook/src/components/odyssey-labs/SideNav/SideNav.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-labs/SideNav/SideNav.stories.tsx @@ -26,6 +26,7 @@ import { DirectoryIcon, ServerIcon, FolderIcon, + NotificationIcon, } from "@okta/odyssey-react-mui/icons"; import { expect } from "@storybook/jest"; import { @@ -35,6 +36,7 @@ import { within, } from "@storybook/testing-library"; import { PlaywrightProps } from "../../odyssey-mui/storybookTypes"; +import PlaceholderLogo from "../PickerWithOptionAdornment/PlaceholderLogo"; const storybookMeta: Meta = { title: "Labs Components/SideNav", @@ -49,18 +51,6 @@ const storybookMeta: Meta = { }, }, }, - customCompanyLogo: { - description: "Logo to be displayed in the Nav Header.", - }, - expandedWidth: { - control: "text", - description: "Width of the side nav in px", - table: { - type: { - summary: "string", - }, - }, - }, footerComponent: { description: "Custom footer component to render in place of footer items.", @@ -73,41 +63,49 @@ const storybookMeta: Meta = { }, }, }, - hasCustomCompanyLogo: { + // hasCustomFooter: { + // control: "boolean", + // description: + // "Defines if a custom footer should be visible when available.", + // table: { + // type: { + // summary: "boolean", + // }, + // }, + // }, + isCollapsible: { control: "boolean", - description: - "Defines if a custom company logo should be visible when available.", + description: "Controls whether the side nav is collapsible", table: { type: { summary: "boolean", }, }, }, - hasCustomFooter: { + isCompact: { control: "boolean", - description: - "Defines if a custom footer should be visible when available.", + description: "Controls whether the side nav uses compact layout", table: { type: { summary: "boolean", }, }, }, - isCollapsible: { + isLoading: { control: "boolean", - description: "Controls whether the side nav is collapsible", + description: "Controls whether the side nav shows the skeleton loader.", table: { type: { summary: "boolean", }, }, }, - isCompact: { - control: "boolean", - description: "Controls whether the side nav uses compact layout", + logoProps: { + description: "Props passed in to render custom logo and/or link", table: { type: { - summary: "boolean", + summary: + "href?: string; logoSrcUrl?: string; altText?: string; logoComponent?: ReactElement", }, }, }, @@ -281,6 +279,13 @@ const storybookMeta: Meta = { label: "System Configuration", startIcon: , }, + { + id: "item6", + href: "/", + label: "Notifications", + startIcon: , + count: 1, + }, ], footerItems: [ { @@ -308,8 +313,8 @@ const storybookMeta: Meta = { export default storybookMeta; -export const Default: StoryObj = { - render: (props: SideNavProps) => { +export const Default: StoryObj = { + render: (props) => { return (
@@ -319,8 +324,14 @@ export const Default: StoryObj = { play: async ({ canvasElement, step }: PlaywrightProps) => { configure({ testIdAttribute: "data-se" }); const canvas = within(canvasElement); - const expandedRegion = canvas.getByTestId("expanded-region"); - const collapsedRegion = canvas.getByTestId("collapsed-region"); + + const toggleButton = canvas.getByRole("button", { + name: "Close navigation", + }); + const navElement = canvas.getByRole("navigation", { + name: "Main navigation", + }); + // const collapsedRegion = canvas.getByTestId("collapsed-region"); const scrollableRegion = canvas.getByTestId("scrollable-region"); /** @@ -347,22 +358,63 @@ export const Default: StoryObj = { } }); await step("Side Nav Collapse", async ({}) => { - const collapseButton = within(collapsedRegion).getByRole("button", { - name: "collapse side navigation", - }); - await userEvent.click(collapseButton); + await userEvent.click(toggleButton); + await waitFor(() => { - expect(expandedRegion).not.toBeVisible(); + expect(toggleButton.ariaExpanded).toEqual("false"); + expect(navElement).toHaveStyle({ width: 0 }); }); }); await step("Side Nav Expand", async ({}) => { - const expandeButton = within(collapsedRegion).getByRole("button", { - name: "expand side navigation", - }); - await userEvent.click(expandeButton); + await userEvent.click(toggleButton); await waitFor(() => { - expect(expandedRegion).toBeVisible(); + expect(toggleButton.ariaExpanded).toEqual("true"); + expect(navElement).toBeVisible(); }); }); }, }; + +export const Loading: StoryObj = { + args: { + isLoading: true, + }, + render: (props) => { + return ( +
+ +
+ ); + }, +}; + +export const CustomLogoElement: StoryObj = { + args: { + logoProps: { + logoComponent: , + }, + }, + render: (props) => { + return ( +
+ +
+ ); + }, +}; + +export const CustomLogoImage: StoryObj = { + args: { + logoProps: { + imageUrl: "https://placehold.co/600x60", + imageAltText: "My custom image logo", + }, + }, + render: (props) => { + return ( +
+ +
+ ); + }, +}; diff --git a/packages/odyssey-storybook/src/components/odyssey-labs/UiShell/UiShell.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-labs/UiShell/UiShell.stories.tsx index 78eb7be75a..4038ca2ed7 100644 --- a/packages/odyssey-storybook/src/components/odyssey-labs/UiShell/UiShell.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-labs/UiShell/UiShell.stories.tsx @@ -13,16 +13,20 @@ import { Meta, StoryObj } from "@storybook/react"; import { MuiThemeDecorator } from "../../../../.storybook/components"; import { + PageTemplate, UiShell, + uiShellDataAttribute, UserProfile, type UiShellNavComponentProps, type UiShellProps, } from "@okta/odyssey-react-mui/labs"; import { + Banner, Button, OdysseyProvider, Paragraph, SearchField, + Surface, } from "@okta/odyssey-react-mui"; import { AddCircleIcon, @@ -99,6 +103,7 @@ export default storybookMeta; const sharedSideNavProps: UiShellNavComponentProps["sideNavProps"] = { appName: "Enduser", + isCollapsible: true, sideNavItems: [ { id: "AddNewFolder", @@ -160,7 +165,11 @@ const sharedTopNavProps: UiShellNavComponentProps["topNavProps"] = { }; const sharedOptionalComponents: UiShellProps["optionalComponents"] = { - topNavLeftSide: , + topNavLeftSide: ( +
+ +
+ ), topNavRightSide: ( } @@ -185,6 +194,23 @@ export const TopNavOnly: StoryObj = { }, }; +export const LoadingData: StoryObj = { + args: { + optionalComponents: sharedOptionalComponents, + subscribeToPropChanges: (subscriber) => { + subscriber({ + sideNavProps: { + ...sharedSideNavProps, + isLoading: true, + }, + topNavProps: {}, + }); + + return () => {}; + }, + }, +}; + export const WithoutAppContent: StoryObj = { args: { optionalComponents: sharedOptionalComponents, @@ -199,7 +225,7 @@ export const WithoutAppContent: StoryObj = { }, }; -export const WithAppContent: StoryObj = { +export const WithTallAppContent: StoryObj = { args: { appComponent: (
@@ -350,20 +376,7 @@ export const WithAppContent: StoryObj = { Et…
), - optionalComponents: { - topNavLeftSide: ( -
- -
- ), - topNavRightSide: ( - } - orgName="ORG123" - userName="test.user@test.com" - /> - ), - }, + optionalComponents: sharedOptionalComponents, subscribeToPropChanges: (subscriber) => { subscriber({ sideNavProps: sharedSideNavProps, @@ -379,23 +392,41 @@ export const WithOdysseyAppContent: StoryObj = { args: { appComponent: ( - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris - lacinia leo quis sodales scelerisque. Maecenas tempor eget nunc sit - amet ultrices. Maecenas et varius ante. Nulla eu quam sit amet orci - fermentum dictum sit amet scelerisque libero. Proin luctus semper - elit, ut pretium massa tristique a. Mauris hendrerit ex eu commodo - egestas. Etiam a lacus aliquet, convallis metus et, sollicitudin odio. - Fusce vehicula purus sed orci elementum, ut cursus diam sollicitudin. - Pellentesque pulvinar nibh turpis, eu finibus dolor egestas eget. Duis - tellus mauris, pulvinar sit amet ante a, aliquet laoreet sapien. Ut - quis tempus massa. Fusce fringilla mattis lacinia. Cras at pharetra - quam, eu ultrices ipsum. - -
+ + ), - optionalComponents: sharedOptionalComponents, + optionalComponents: { + ...sharedOptionalComponents, + banners: , + }, subscribeToPropChanges: (subscriber) => { subscriber({ sideNavProps: sharedSideNavProps,