Skip to content

Commit

Permalink
feat: add drawer component
Browse files Browse the repository at this point in the history
  • Loading branch information
benlister-okta committed Feb 21, 2024
1 parent 1ebc897 commit 563655c
Show file tree
Hide file tree
Showing 6 changed files with 769 additions and 20 deletions.
30 changes: 16 additions & 14 deletions packages/odyssey-react-mui/src/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,15 @@ export type DialogProps = {
/**
* An optional Button object to be situated in the Dialog footer. Should almost always be of variant `primary`.
*/
callToActionFirstComponent?: ReactElement<typeof Button>;
primaryCallToActionComponent?: ReactElement<typeof Button>;
/**
* An optional Button object to be situated in the Dialog footer, alongside the `callToActionPrimaryComponent`.
*/
callToActionSecondComponent?: ReactElement<typeof Button>;
secondaryCallToActionComponent?: ReactElement<typeof Button>;
/**
* An optional Button object to be situated in the Dialog footer, alongside the other two `callToAction` components.
*/
callToActionLastComponent?: ReactElement<typeof Button>;
tertiaryCallToActionComponent?: ReactElement<typeof Button>;
/**
* The content of the Dialog. May be a `string` or any other `ReactNode` or array of `ReactNode`s.
*/
Expand All @@ -67,9 +67,9 @@ export type DialogProps = {
} & HtmlProps;

const Dialog = ({
callToActionFirstComponent,
callToActionSecondComponent,
callToActionLastComponent,
primaryCallToActionComponent,
secondaryCallToActionComponent,
tertiaryCallToActionComponent,
children,
isOpen,
onClose,
Expand All @@ -87,6 +87,7 @@ const Dialog = ({
const handleContentScroll = () => {
const dialogContentElement = dialogContentRef.current;
if (dialogContentElement) {
cancelAnimationFrame(frameId);
setIsContentScrollable(
dialogContentElement.scrollHeight > dialogContentElement.clientHeight,
);
Expand Down Expand Up @@ -124,22 +125,23 @@ const Dialog = ({
/>
</DialogTitle>
<DialogContent
dividers={isContentScrollable}
ref={dialogContentRef}
{...(isContentScrollable && {
//Sets tabIndex on content element if scrollable so content is easier to navigate with the keyboard
tabIndex: 0,
})}
dividers={isContentScrollable}
ref={dialogContentRef}
>
{content}
</DialogContent>

{(callToActionFirstComponent ||
callToActionSecondComponent ||
callToActionLastComponent) && (
{(primaryCallToActionComponent ||
secondaryCallToActionComponent ||
tertiaryCallToActionComponent) && (
<DialogActions>
{callToActionLastComponent}
{callToActionSecondComponent}
{callToActionFirstComponent}
{tertiaryCallToActionComponent}
{secondaryCallToActionComponent}
{primaryCallToActionComponent}
</DialogActions>
)}
</MuiDialog>
Expand Down
258 changes: 258 additions & 0 deletions packages/odyssey-react-mui/src/labs/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
/*!
* 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,
ReactNode,
useState,
useEffect,
useMemo,
useRef,
ReactElement,
} from "react";
import styled from "@emotion/styled";
import { useTranslation } from "react-i18next";
import { Drawer as MuiDrawer } from "@mui/material";

import type { HtmlProps } from "../HtmlProps";
import { Button } from "../Button";
import { CloseIcon } from "../icons.generated";
import { Heading5 } from "../Typography";
import {
useOdysseyDesignTokens,
DesignTokens,
} from "../OdysseyDesignTokensContext";

export const variantValues = ["temporary", "persistent"] as const;

export type DrawerProps = {
/**
* An optional Button object to be situated in the Drawerfooter. Should almost always be of variant `primary`.
*/
primaryCallToActionComponent?: ReactElement<typeof Button>;
/**
* An optional Button object to be situated in the Drawer footer, alongside the `callToActionPrimaryComponent`.
*/
secondaryCallToActionComponent?: ReactElement<typeof Button>;
/**
* An optional Button object to be situated in the Drawer footer, alongside the other two `callToAction` components.
*/
tertiaryCallToActionComponent?: ReactElement<typeof Button>;
/**
* The content of the Drawer. May be a `string` or any other `ReactNode` or array of `ReactNode`s.
*/
children?: ReactNode;
/**
* When set to `true`, the Drawer will be visible.
*/
isOpen?: boolean;
/**
* Callback that controls what happens when the Drawer is dismissed
*/
onClose: () => void;
/**
* Shows divider lines separating header, content, and footer (if using action buttons)
*/
showDividers: boolean;
/**
* The title of the Drawer
*/
title?: string;
/**
* Type of Drawer
*/
variant?: (typeof variantValues)[number];
ariaLabel: string;
} & HtmlProps;

interface DrawerStyleProps {
odysseyDesignTokens: DesignTokens;
showDividers: boolean;
}
const DrawerHeader = styled("div", {
shouldForwardProp: (prop) => prop !== "odysseyDesignTokens",
})<DrawerStyleProps>`
position: sticky;
display: flex;
justify-content: space-between;
align-items: center;
top: 0;
background-color: ${({ odysseyDesignTokens }) =>
odysseyDesignTokens.HueNeutralWhite};
margin: 0;
padding: ${({ odysseyDesignTokens }) => odysseyDesignTokens.Spacing4}
${({ odysseyDesignTokens }) => odysseyDesignTokens.Spacing5};
font-family: ${({ odysseyDesignTokens }) =>
odysseyDesignTokens.TypographyFamilyHeading};
color: ${({ odysseyDesignTokens }) => odysseyDesignTokens.HueNeutral900};
border-bottom: ${({ showDividers, odysseyDesignTokens }) =>
showDividers ? `1px solid ${odysseyDesignTokens.HueNeutral200}` : "none"};
`;

const DrawerContentWrapper = styled("div", {
shouldForwardProp: (prop) => prop !== "odysseyDesignTokens",
})<{
odysseyDesignTokens: DesignTokens;
}>`
overflow-y: auto;
`;

const DrawerContent = styled("div", {
shouldForwardProp: (prop) => prop !== "odysseyDesignTokens",
})<DrawerStyleProps>`
padding: ${({ showDividers, odysseyDesignTokens }) =>
showDividers
? `${odysseyDesignTokens.Spacing5}`
: `0 ${odysseyDesignTokens.Spacing5}`};
`;

const DrawerFooter = styled("div", {
shouldForwardProp: (prop) => prop !== "odysseyDesignTokens",
})<DrawerStyleProps>`
position: sticky;
display: flex;
justify-content: flex-end;
align-items: center;
bottom: 0;
background-color: ${({ odysseyDesignTokens }) =>
odysseyDesignTokens.HueNeutralWhite};
padding: ${({ odysseyDesignTokens }) => odysseyDesignTokens.Spacing4};
align-content: center;
border-top: ${({ showDividers, odysseyDesignTokens }) =>
showDividers ? `1px solid ${odysseyDesignTokens.HueNeutral200}` : "none"};
`;

const Drawer = ({
ariaLabel,
children,
isOpen,
onClose,
primaryCallToActionComponent,
secondaryCallToActionComponent,
showDividers = false,
tertiaryCallToActionComponent,
testId,
title,
translate,
variant = "temporary",
}: DrawerProps) => {
const [isContentScrollable, setIsContentScrollable] = useState(false);
const drawerContentRef = useRef<HTMLDivElement>(null);
const odysseyDesignTokens = useOdysseyDesignTokens();

//If RTL is set in the theme, align the drawer on the left side of the screen, uses right by default.
const { i18n } = useTranslation();
const anchorDirection = i18n.dir() === "rtl" ? "left" : "right";

useEffect(() => {
let frameId: number;

const handleContentScroll = () => {
const drawerContentElement = drawerContentRef.current;
if (drawerContentElement) {
cancelAnimationFrame(frameId);
setIsContentScrollable(
drawerContentElement.scrollHeight > drawerContentElement.clientHeight,
);
}
frameId = requestAnimationFrame(handleContentScroll);
};

if (isOpen) {
frameId = requestAnimationFrame(handleContentScroll);
}

return () => {
cancelAnimationFrame(frameId);
};
}, [isOpen]);

const dividersVisible = useMemo(() => {
return showDividers || isContentScrollable;
}, [showDividers, isContentScrollable]);

const hasFooter = useMemo(
() =>
primaryCallToActionComponent ||
secondaryCallToActionComponent ||
tertiaryCallToActionComponent,
[
primaryCallToActionComponent,
secondaryCallToActionComponent,
tertiaryCallToActionComponent,
],
);

return (
<MuiDrawer
data-se={testId}
anchor={anchorDirection}
open={isOpen}
onClose={onClose}
variant={variant}
sx={{
//Overrides defualt MUI inline style
...(variant === "persistent" && {
"& .MuiDrawer-paper": {
transition: "none",
},
}),
}}
>
<DrawerContentWrapper
{...(isContentScrollable && {
//Sets tabIndex on content element if scrollable so content is easier to navigate with the keyboard
tabIndex: 0,
})}
odysseyDesignTokens={odysseyDesignTokens}
ref={drawerContentRef}
>
<DrawerHeader
translate={translate}
odysseyDesignTokens={odysseyDesignTokens}
showDividers={dividersVisible}
>
<Heading5>{title}</Heading5>
<Button
ariaLabel={ariaLabel}
label=""
onClick={onClose}
size="small"
startIcon={<CloseIcon />}
variant="floating"
/>
</DrawerHeader>
<DrawerContent
showDividers={dividersVisible}
odysseyDesignTokens={odysseyDesignTokens}
>
{children}
</DrawerContent>
</DrawerContentWrapper>
{hasFooter && (
<DrawerFooter
odysseyDesignTokens={odysseyDesignTokens}
showDividers={dividersVisible}
>
{tertiaryCallToActionComponent}
{secondaryCallToActionComponent}
{primaryCallToActionComponent}
</DrawerFooter>
)}
</MuiDrawer>
);
};

const MemoizedDrawer = memo(Drawer);
MemoizedDrawer.displayName = "Drawer";

export { MemoizedDrawer as Drawer };
2 changes: 2 additions & 0 deletions packages/odyssey-react-mui/src/labs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export * from "./DataTable";
export * from "./DataTablePagination";
export * from "./DataFilters";

export * from "./Drawer";

export * from "./materialReactTableTypes";
export * from "./StaticTable";
export * from "./PaginatedTable";
Expand Down
33 changes: 33 additions & 0 deletions packages/odyssey-react-mui/src/theme/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ import {
import { DesignTokens } from "./theme";
import { CSSProperties } from "react";

//Widths used in `Drawer` component
const drawerSizes = {
persistent: "25.714rem", //~360px
temporary: "28.571rem", //~400px
};

export const components = ({
odysseyTokens,
shadowDomElement,
Expand Down Expand Up @@ -1043,6 +1049,33 @@ export const components = ({
}
`,
},
MuiDrawer: {
styleOverrides: {
root: {},
paper: ({ ownerState }) => ({
width:
ownerState.variant === "temporary"
? drawerSizes.temporary
: drawerSizes.persistent, //Temporary = overlay drawer, Persistent = inline drawer
display: "flex",
overflowY: "auto",
flexDirection: "column",
flexWrap: "nowrap",
justifyContent: "space-between",
alignItems: "stretch",
alignContent: "flex-end",
color: odysseyTokens.HueNeutral700,
...(ownerState.variant === "persistent" && {
position: "static",
borderRadius: odysseyTokens.BorderRadiusOuter,
border: "0",
}),
...(ownerState.variant === "temporary" && {
boxShadow: odysseyTokens.ShadowScale1,
}),
}),
},
},
MuiScopedCssBaseline: {
styleOverrides: {
root: {
Expand Down

0 comments on commit 563655c

Please sign in to comment.