diff --git a/packages/odyssey-react-mui/src/labs/NavAccordion.tsx b/packages/odyssey-react-mui/src/labs/NavAccordion.tsx new file mode 100644 index 0000000000..78b24baad0 --- /dev/null +++ b/packages/odyssey-react-mui/src/labs/NavAccordion.tsx @@ -0,0 +1,133 @@ +/*! + * Copyright (c) 2023-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 styled from "@emotion/styled"; +import { + Accordion as MuiAccordion, + AccordionDetails as MuiAccordionDetails, + AccordionSummary as MuiAccordionSummary, + AccordionProps as MuiAccordionProps, +} from "@mui/material"; +import { ReactNode, memo } from "react"; + +import type { HtmlProps } from "../HtmlProps"; +import { ChevronRightIcon } from "../icons.generated"; +import { + DesignTokens, + useOdysseyDesignTokens, +} from "../OdysseyDesignTokensContext"; +import { Support } from "../Typography"; +import { useUniqueId } from "../useUniqueId"; + +export type NavAccordionProps = { + /** + * The content of the Accordion itself + */ + children: ReactNode; + /** + * The label text for the AccordionSummary + */ + label: string; + /** + * Defines IDs for the header and the content of the Accordion + */ + id?: string; + /** + * Whether the item is expanded by default + */ + isDefaultExpanded?: boolean; + /** + * Whether the item is disabled + */ + isDisabled?: boolean; + /** + * If true, expands the accordion, otherwise collapse it. + * Setting this prop enables control over the accordion. + */ + isExpanded?: boolean; + /** + * Event fired when the expansion state of the accordion is changed + */ + onChange?: MuiAccordionProps["onChange"]; + /** + * The icon element to display at the start of the Nav Item + */ + startIcon?: ReactNode; +} & Pick; + +const AccordionLabelContainer = styled("span", { + shouldForwardProp: (prop) => + prop !== "odysseyDesignTokens" && prop !== "isIconVisible", +})<{ + odysseyDesignTokens: DesignTokens; + isIconVisible: boolean; +}>(({ odysseyDesignTokens, isIconVisible }) => ({ + width: "100%", + marginLeft: isIconVisible ? odysseyDesignTokens.Spacing2 : 0, + fontSize: odysseyDesignTokens.TypographyScale0, + fontWeight: odysseyDesignTokens.TypographyWeightHeading, + color: odysseyDesignTokens.TypographyColorHeading, +})); + +const NavAccordion = ({ + children, + label, + id: idOverride, + isDefaultExpanded, + isDisabled, + isExpanded, + translate, + startIcon, +}: NavAccordionProps) => { + const id = useUniqueId(idOverride); + const headerId = `${id}-header`; + const contentId = `${id}-content`; + const odysseyDesignTokens = useOdysseyDesignTokens(); + + return ( + + } + id={headerId} + > + + {startIcon && startIcon} + + {label} + + + + + {children} + + + ); +}; + +const MemoizedNavAccordion = memo(NavAccordion); +MemoizedNavAccordion.displayName = "NavAccordion"; + +export { MemoizedNavAccordion as NavAccordion }; diff --git a/packages/odyssey-react-mui/src/labs/SideNav.tsx b/packages/odyssey-react-mui/src/labs/SideNav.tsx new file mode 100644 index 0000000000..bffd573679 --- /dev/null +++ b/packages/odyssey-react-mui/src/labs/SideNav.tsx @@ -0,0 +1,745 @@ +/*! + * 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 styled from "@emotion/styled"; +import { + memo, + useMemo, + useState, + ReactElement, + ReactNode, + useCallback, + KeyboardEvent, +} from "react"; + +import { Box } from "../Box"; +import { Button } from "../Button"; +import type { HtmlProps } from "../HtmlProps"; +import { CollapseLeftIcon, ExpandLeftIcon } from "../icons.generated"; +import { Link } from "../Link"; +import { NavAccordion } from "./NavAccordion"; +import { + DesignTokens, + useOdysseyDesignTokens, +} from "../OdysseyDesignTokensContext"; +import { Status, statusSeverityValues } from "../Status"; +import { Heading6 } from "../Typography"; + +export type SideNavItem = { + id: string; + label: string; + /** + * The icon element to display at the end of the Nav Item + */ + endIcon?: ReactElement; + /** + * 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. + */ + isDisabled?: boolean; + /** + * Whether the item is active/selected + */ + isSelected?: boolean; + /** + * Event fired when the nav item is clicked + */ + onClick?(): void; + /** + * The status element to display after the label + */ + severity?: (typeof statusSeverityValues)[number]; + /** + * The icon element to display at the start of the Nav Item + */ + startIcon?: ReactElement; + /** + * The label to display inside the status + */ + statusLabel?: string; + /** + * The link target prop. e.g., "_blank" + */ + target?: string; +} & ( + | { + /** + * Determines if the side nav item is a section header + */ + isSectionHeader: true; + href?: never; + children?: never; + isDefaultExpanded?: never; + isExpanded?: 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; + } + | { + /** + * An array of side nav items to be displayed as children within Accordion + */ + children?: SideNavItem[]; + /** + * Whether the accordion (nav item with children) is expanded by default + */ + isDefaultExpanded?: boolean; + /** + * If true, expands the accordion, otherwise collapse it. + * Setting this prop enables control over the accordion. + */ + isExpanded?: boolean; + isSectionHeader?: never; + href?: never; + } +); + +export type SideNavFooterItem = { + href?: string; + id: string; + label: string; +}; + +export type SideNavProps = { + /** + * Side Nav header text that is usually reserved to show the App name + */ + navHeaderText: string; + /** + * Determines whether the side nav is collapsible + */ + isCollapsible?: boolean; + /** + * Footer items in the side nav + */ + footerItems?: SideNavFooterItem[]; + /** + * Triggers when the side nav is collapsed + */ + onCollapse?(): void; + /** + * Triggers when the side nav is expanded + */ + onExpand?(): void; + /** + * Nav items in the side nav + */ + sideNavItems: SideNavItem[]; +} & Pick; + +const SideNavCollapsedContainer = styled("div", { + shouldForwardProp: (prop) => + prop !== "odysseyDesignTokens" && prop !== "isSideNavCollapsed", +})( + ({ + odysseyDesignTokens, + isSideNavCollapsed, + }: { + odysseyDesignTokens: DesignTokens; + isSideNavCollapsed: boolean; + }) => ({ + backgroundColor: odysseyDesignTokens.HueNeutral300, + paddingTop: odysseyDesignTokens.Spacing5, + cursor: "pointer", + width: isSideNavCollapsed ? "auto" : 0, + opacity: isSideNavCollapsed ? 1 : 0, + visibility: isSideNavCollapsed ? "visible" : "hidden", + transitionProperty: "opacity", + transitionDuration: odysseyDesignTokens.TransitionDurationMain, + transitionTimingFunction: odysseyDesignTokens.TransitionTimingMain, + }), +); + +const SideNavExpandContainer = styled("div", { + shouldForwardProp: (prop) => + prop !== "odysseyDesignTokens" && prop !== "isSideNavCollapsed", +})( + ({ + odysseyDesignTokens, + isSideNavCollapsed, + }: { + odysseyDesignTokens: DesignTokens; + isSideNavCollapsed: boolean; + }) => ({ + backgroundColor: odysseyDesignTokens.HueNeutralWhite, + flexDirection: "column", + display: "flex", + opacity: isSideNavCollapsed ? 0 : 1, + visibility: isSideNavCollapsed ? "hidden" : "visible", + width: isSideNavCollapsed ? "0" : "100%", + transitionProperty: "opacity, width", + transitionDuration: odysseyDesignTokens.TransitionDurationMain, + transitionTimingFunction: odysseyDesignTokens.TransitionTimingMain, + }), +); + +const SideNavHeaderContainer = 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, +})); + +const CollapseIcon = ({ onClick }: { onClick?(): void }): ReactElement => { + const odysseyDesignTokens = useOdysseyDesignTokens(); + + const collapseButtonStyles = useMemo( + () => ({ + "& > button": { + height: odysseyDesignTokens.Spacing6, + width: odysseyDesignTokens.Spacing6, + color: odysseyDesignTokens.HueNeutral400, + }, + }), + [odysseyDesignTokens], + ); + + return ( + +