Skip to content

Commit

Permalink
feat: adds a generic top nav component (#2296)
Browse files Browse the repository at this point in the history
OKTA-744808 feat: adds a generic top nav component
fix: refactor, review comments fix
fix: refactor, review comments fix
fix: refactor to use odyssey Typography and address other review comments
fix: generate icons after adding the svg inside the odyssey-icons package
fix: use odyssey tokens instead of hard-coded values
fix: removes okta logo from top-nav
  • Loading branch information
ganeshsomasundaram-okta authored Aug 2, 2024
1 parent e0ba8f6 commit 6f3165f
Show file tree
Hide file tree
Showing 3 changed files with 517 additions and 0 deletions.
394 changes: 394 additions & 0 deletions packages/odyssey-react-mui/src/labs/TopNav.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLAnchorElement> &
MouseEventHandler<HTMLDivElement> &
KeyboardEventHandler<HTMLDivElement>;
/**
* 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<HtmlProps, "testId">;

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 (
<UserProfileContainer odysseyDesignTokens={odysseyDesignTokens}>
{profileIcon && (
<UserProfileIconContainer odysseyDesignTokens={odysseyDesignTokens}>
{profileIcon}
</UserProfileIconContainer>
)}
<UserProfileInfoContainer>
<Subordinate color="textPrimary">{userName}</Subordinate>
<Subordinate color="textSecondary">{orgName}</Subordinate>
</UserProfileInfoContainer>
</UserProfileContainer>
);
};

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<HTMLDivElement>
>(
(event) => {
if (event?.key === "Enter") {
event.preventDefault();
event.stopPropagation();
onClick?.(event);
}
},
[onClick],
);

return (
<TopNavListItemContainer
id={id}
key={id}
aria-disabled={isDisabled}
isDisabled={isDisabled}
odysseyDesignTokens={odysseyDesignTokens}
>
{
// Use Link for nav items with links and div for disabled or non-link items
isDisabled ? (
<NavItemContentClickContainer
odysseyDesignTokens={odysseyDesignTokens}
>
<TopNavItemLabelContainer odysseyDesignTokens={odysseyDesignTokens}>
{label}
</TopNavItemLabelContainer>
</NavItemContentClickContainer>
) : !href ? (
<NavItemContentClickContainer
odysseyDesignTokens={odysseyDesignTokens}
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={topNavItemContentKeyHandler}
>
<TopNavItemLabelContainer odysseyDesignTokens={odysseyDesignTokens}>
{label}
</TopNavItemLabelContainer>
</NavItemContentClickContainer>
) : (
<Link href={href} target={target} onClick={onClick}>
<TopNavItemLabelContainer odysseyDesignTokens={odysseyDesignTokens}>
{label}
</TopNavItemLabelContainer>
</Link>
)
}
</TopNavListItemContainer>
);
};

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) => (
<TopNavItemContent {...item} key={item.id} />
)),
[topNavLinkItems],
);

return (
<TopNavContainer odysseyDesignTokens={odysseyDesignTokens}>
{SearchFieldComponent && (
<SearchFieldContainer odysseyDesignTokens={odysseyDesignTokens}>
{SearchFieldComponent}
</SearchFieldContainer>
)}
<TopNavListContainer>
{processedNavItems?.map((item) => item)}
</TopNavListContainer>
<LinkAndProfileWrapper>
{(AdditionalNavItemComponent || settingsPageHref || helpPageHref) && (
<AdditionalLinkContainerWithBorder
odysseyDesignTokens={odysseyDesignTokens}
>
{AdditionalNavItemComponent && (
<AdditionalNavItemContainer
odysseyDesignTokens={odysseyDesignTokens}
>
{AdditionalNavItemComponent}
</AdditionalNavItemContainer>
)}
{settingsPageHref && (
<LinkContainer odysseyDesignTokens={odysseyDesignTokens}>
<Link href={settingsPageHref} ariaLabel="settings page">
<SettingsIcon />
</Link>
</LinkContainer>
)}
{helpPageHref && (
<LinkContainer odysseyDesignTokens={odysseyDesignTokens}>
<Link href={helpPageHref} ariaLabel="help page">
<QuestionCircleIcon />
</Link>
</LinkContainer>
)}
</AdditionalLinkContainerWithBorder>
)}
{userProfile && <UserProfile {...userProfile} />}
</LinkAndProfileWrapper>
</TopNavContainer>
);
};

const MemoizedTopNav = memo(TopNav);
MemoizedTopNav.displayName = "TopNav";

export { MemoizedTopNav as TopNav };
1 change: 1 addition & 0 deletions packages/odyssey-react-mui/src/labs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ export * from "./Switch";

export * from "./NavAccordion";
export * from "./SideNav";
export * from "./TopNav";
Loading

0 comments on commit 6f3165f

Please sign in to comment.