diff --git a/packages/odyssey-react-mui/src/labs/TopNav.tsx b/packages/odyssey-react-mui/src/labs/TopNav.tsx new file mode 100644 index 0000000000..02bc3b5c7e --- /dev/null +++ b/packages/odyssey-react-mui/src/labs/TopNav.tsx @@ -0,0 +1,394 @@ +/*! + * 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, + ReactElement, + useCallback, + MouseEventHandler, + KeyboardEventHandler, +} from "react"; + +import type { HtmlProps } from "../HtmlProps"; +import { QuestionCircleIcon, SettingsIcon } from "../icons.generated"; +import { Link } from "../Link"; +import { + DesignTokens, + useOdysseyDesignTokens, +} from "../OdysseyDesignTokensContext"; +import { Subordinate } from "../Typography"; + +export type TopNavLinkItem = { + id: string; + label: string; + /** + * link added to the nav item. if it is undefined, static text will be displayed. + * fires onClick event when it is passed + */ + href?: string; + /** + * determines whether the link item is diabled + */ + isDisabled?: boolean; + /** + * Event fired when the nav item is clicked + */ + onClick?: MouseEventHandler & + MouseEventHandler & + KeyboardEventHandler; + /** + * The link target prop. e.g., "_blank" + */ + target?: string; +}; + +export type UserProfileProps = { + /** + * Logged in user profile icon to be displayed in the top nav + */ + profileIcon?: ReactElement; + /** + * Logged in user info to be displayed in the top nav + */ + userName: string; + /** + * Org name of the logged in user + */ + orgName: string; +}; + +export type TopNavProps = { + /** + * Pass in a SearchField component with the variant="filled" prop set + */ + SearchFieldComponent?: ReactElement; + /** + * Nav links in the top nav + */ + topNavLinkItems: TopNavLinkItem[]; + /** + * Pass in an additional component like `Button` that will be displayed after the nav link items + */ + AdditionalNavItemComponent?: ReactElement; + /** + * URL to settings page. + */ + settingsPageHref?: string; + /** + * URL to the help page. + */ + helpPageHref?: string; + /** + * Displays user account info + */ + userProfile?: UserProfileProps; +} & Pick; + +const UserProfileContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ + display: "flex", + alignItems: "center", + paddingRight: odysseyDesignTokens.Spacing4, +})); + +const UserProfileIconContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})(({ odysseyDesignTokens }: { odysseyDesignTokens: DesignTokens }) => ({ + display: "flex", + paddingRight: odysseyDesignTokens.Spacing2, +})); + +const UserProfileInfoContainer = styled("div")(() => ({ + display: "flex", + flexDirection: "column", +})); + +const UserProfile = ({ profileIcon, userName, orgName }: UserProfileProps) => { + const odysseyDesignTokens = useOdysseyDesignTokens(); + + return ( + + {profileIcon && ( + + {profileIcon} + + )} + + {userName} + {orgName} + + + ); +}; + +const TopNavListContainer = styled("ul")(() => ({ + padding: 0, + listStyle: "none", + listStyleType: "none", + display: "flex", + alignItems: "center", +})); + +const TopNavItemLabelContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})<{ + odysseyDesignTokens: DesignTokens; +}>(({ odysseyDesignTokens }) => ({ + display: "flex", + flexWrap: "wrap", + alignItems: "center", + fontSize: odysseyDesignTokens.TypographyScale0, + fontWeight: odysseyDesignTokens.TypographyWeightHeading, +})); + +const TopNavListItemContainer = styled("li", { + shouldForwardProp: (prop) => + prop !== "odysseyDesignTokens" && prop !== "isDisabled", +})<{ + odysseyDesignTokens: DesignTokens; + isDisabled?: boolean; +}>(({ odysseyDesignTokens, isDisabled }) => ({ + display: "flex", + alignItems: "center", + cursor: isDisabled ? "default" : "pointer", + pointerEvents: isDisabled ? "none" : "auto", + color: `${isDisabled ? odysseyDesignTokens.TypographyColorDisabled : odysseyDesignTokens.TypographyColorHeading} !important`, + "& a": { + display: "flex", + alignItems: "center", + padding: `${odysseyDesignTokens.Spacing2} ${odysseyDesignTokens.Spacing4}`, + color: `${odysseyDesignTokens.TypographyColorHeading} !important`, + }, + "& a:hover": { + textDecoration: "none", + backgroundColor: !isDisabled ? odysseyDesignTokens.HueNeutral50 : "inherit", + }, + "& div[role='button']:hover": { + backgroundColor: !isDisabled ? odysseyDesignTokens.HueNeutral50 : "inherit", + }, + "& a:focus-visible": { + outlineOffset: 0, + borderRadius: 0, + outlineWidth: odysseyDesignTokens.FocusOutlineWidthMain, + backgroundColor: !isDisabled ? odysseyDesignTokens.HueNeutral50 : "inherit", + }, +})); + +const NavItemContentClickContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})<{ + odysseyDesignTokens: DesignTokens; +}>(({ odysseyDesignTokens }) => ({ + display: "flex", + alignItems: "center", + width: "100%", + padding: `${odysseyDesignTokens.Spacing2} ${odysseyDesignTokens.Spacing4}`, + "&:focus-visible": { + borderRadius: 0, + outlineColor: odysseyDesignTokens.FocusOutlineColorPrimary, + outlineStyle: odysseyDesignTokens.FocusOutlineStyle, + outlineWidth: odysseyDesignTokens.FocusOutlineWidthMain, + backgroundColor: odysseyDesignTokens.HueNeutral50, + textDecoration: "none", + }, +})); + +const TopNavItemContent = ({ + id, + label, + href, + target, + onClick, + isDisabled, +}: TopNavLinkItem) => { + const odysseyDesignTokens = useOdysseyDesignTokens(); + + const topNavItemContentKeyHandler = useCallback< + KeyboardEventHandler + >( + (event) => { + if (event?.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + onClick?.(event); + } + }, + [onClick], + ); + + return ( + + { + // Use Link for nav items with links and div for disabled or non-link items + isDisabled ? ( + + + {label} + + + ) : !href ? ( + + + {label} + + + ) : ( + + + {label} + + + ) + } + + ); +}; + +const LinkAndProfileWrapper = styled("div")(() => ({ + display: "flex", + alignItems: "center", + marginLeft: "auto", +})); + +const AdditionalLinkContainerWithBorder = styled("div", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})<{ + odysseyDesignTokens: DesignTokens; +}>(({ odysseyDesignTokens }) => ({ + display: "flex", + alignItems: "center", + marginRight: odysseyDesignTokens.Spacing3, + borderRight: `${odysseyDesignTokens.BorderWidthMain} solid ${odysseyDesignTokens.HueNeutral200}`, +})); + +const LinkContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})<{ + odysseyDesignTokens: DesignTokens; +}>(({ odysseyDesignTokens }) => ({ + paddingRight: odysseyDesignTokens.Spacing3, + "& a": { + color: `${odysseyDesignTokens.TypographyColorHeading} !important`, + }, +})); + +const TopNavContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})<{ + odysseyDesignTokens: DesignTokens; +}>(({ odysseyDesignTokens }) => ({ + display: "flex", + alignItems: "center", + backgroundColor: odysseyDesignTokens.HueNeutralWhite, + height: odysseyDesignTokens.Spacing9, +})); + +const SearchFieldContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})<{ + odysseyDesignTokens: DesignTokens; +}>(({ odysseyDesignTokens }) => ({ + width: "350px", + padding: `${odysseyDesignTokens.Spacing2} ${odysseyDesignTokens.Spacing3}`, +})); + +const AdditionalNavItemContainer = styled("div", { + shouldForwardProp: (prop) => prop !== "odysseyDesignTokens", +})<{ + odysseyDesignTokens: DesignTokens; +}>(({ odysseyDesignTokens }) => ({ + padding: `0 ${odysseyDesignTokens.Spacing3}`, +})); + +const TopNav = ({ + SearchFieldComponent, + topNavLinkItems, + AdditionalNavItemComponent, + settingsPageHref, + helpPageHref, + userProfile, +}: TopNavProps) => { + const odysseyDesignTokens = useOdysseyDesignTokens(); + + const processedNavItems = useMemo( + () => + topNavLinkItems.map((item) => ( + + )), + [topNavLinkItems], + ); + + return ( + + {SearchFieldComponent && ( + + {SearchFieldComponent} + + )} + + {processedNavItems?.map((item) => item)} + + + {(AdditionalNavItemComponent || settingsPageHref || helpPageHref) && ( + + {AdditionalNavItemComponent && ( + + {AdditionalNavItemComponent} + + )} + {settingsPageHref && ( + + + + + + )} + {helpPageHref && ( + + + + + + )} + + )} + {userProfile && } + + + ); +}; + +const MemoizedTopNav = memo(TopNav); +MemoizedTopNav.displayName = "TopNav"; + +export { MemoizedTopNav as TopNav }; diff --git a/packages/odyssey-react-mui/src/labs/index.ts b/packages/odyssey-react-mui/src/labs/index.ts index 5101a6c3d7..71f87667e6 100644 --- a/packages/odyssey-react-mui/src/labs/index.ts +++ b/packages/odyssey-react-mui/src/labs/index.ts @@ -37,3 +37,4 @@ export * from "./Switch"; export * from "./NavAccordion"; export * from "./SideNav"; +export * from "./TopNav"; diff --git a/packages/odyssey-storybook/src/components/odyssey-labs/TopNav/TopNav.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-labs/TopNav/TopNav.stories.tsx new file mode 100644 index 0000000000..2b2bff4772 --- /dev/null +++ b/packages/odyssey-storybook/src/components/odyssey-labs/TopNav/TopNav.stories.tsx @@ -0,0 +1,122 @@ +/*! + * Copyright (c) 2021-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 { TopNav, TopNavProps } from "@okta/odyssey-react-mui/labs"; +import { Meta, StoryObj } from "@storybook/react"; +import { MuiThemeDecorator } from "../../../../.storybook/components"; +import { Button, SearchField } from "@okta/odyssey-react-mui"; +import { UserIcon } from "@okta/odyssey-react-mui/icons"; + +const storybookMeta: Meta = { + title: "Labs Components/TopNav", + component: TopNav, + argTypes: { + SearchFieldComponent: { + control: "ReactElement", + description: "Display global search field", + table: { + type: { + summary: "ReactElement (SearchField)", + }, + }, + }, + topNavLinkItems: { + description: "Array of links to be displayed in the top nav", + table: { + type: { + summary: "Array", + }, + }, + }, + AdditionalNavItemComponent: { + description: + "Additional element to be displayed at the end of the top nav", + table: { + type: { + summary: "ReactElement (Button)", + }, + }, + }, + settingsPageHref: { + description: "Display the settings icon/link", + table: { + type: { + summary: "boolean", + }, + }, + }, + helpPageHref: { + description: "Display the help icon/link", + table: { + type: { + summary: "boolean", + }, + }, + }, + userProfile: { + description: "Shows the logged in user account info", + table: { + type: { + summary: "UserProfileProps", + }, + }, + }, + }, + args: { + SearchFieldComponent: ( + + ), + topNavLinkItems: [ + { + id: "link-01", + label: "Home", + href: "/", + }, + { + id: "link-02", + label: "Flows", + href: "/", + }, + { + id: "link-03", + label: "Connections", + href: "/", + isDisabled: true, + }, + { + id: "link-04", + label: "Template", + onClick: () => {}, + }, + ], + AdditionalNavItemComponent: ( +