From cbba6f3c15c07bc915fc2e28255f64cf8246675b Mon Sep 17 00:00:00 2001 From: Jordan Koschei <91091570+jordankoschei-okta@users.noreply.github.com> Date: Thu, 15 Feb 2024 11:50:44 -0500 Subject: [PATCH] Add initial version of Tiles to Odyssey Labs (#2130) * feat: add Card styles * feat: rename Card to Tile * feat: update Tile to match redline * refactor: update Tile to match Figma * docs: add section about clickable vs buttons * refactor: apply code review recommendations --- .../odyssey-design-tokens/src/typography.json | 151 +++++++++++++----- packages/odyssey-react-mui/src/Button.tsx | 10 +- .../odyssey-react-mui/src/ButtonContext.tsx | 23 +++ packages/odyssey-react-mui/src/MenuContext.ts | 2 +- packages/odyssey-react-mui/src/Tile.tsx | 147 +++++++++++++++++ packages/odyssey-react-mui/src/index.ts | 1 + .../src/theme/components.tsx | 64 ++++++++ .../src/components/odyssey-labs/Tile/Tile.mdx | 24 +++ .../odyssey-labs/Tile/Tile.stories.tsx | 149 +++++++++++++++++ 9 files changed, 532 insertions(+), 39 deletions(-) create mode 100644 packages/odyssey-react-mui/src/ButtonContext.tsx create mode 100644 packages/odyssey-react-mui/src/Tile.tsx create mode 100644 packages/odyssey-storybook/src/components/odyssey-labs/Tile/Tile.mdx create mode 100644 packages/odyssey-storybook/src/components/odyssey-labs/Tile/Tile.stories.tsx diff --git a/packages/odyssey-design-tokens/src/typography.json b/packages/odyssey-design-tokens/src/typography.json index c31a84302d..31a535e61e 100644 --- a/packages/odyssey-design-tokens/src/typography.json +++ b/packages/odyssey-design-tokens/src/typography.json @@ -63,53 +63,130 @@ } }, "scale": { - "0": { "value": "0.857rem" }, - "1": { "value": "1rem" }, - "2": { "value": "1.143rem" }, - "3": { "value": "1.286rem" }, - "4": { "value": "1.429rem" }, - "5": { "value": "1.571rem" }, - "6": { "value": "1.786rem" }, - "7": { "value": "2rem" }, - "8": { "value": "2.286rem" }, - "9": { "value": "2.571rem" }, - "10": { "value": "2.857rem" }, - "11": { "value": "3.214rem" }, - "12": { "value": "3.643rem" } + "0": { + "value": "0.857rem" + }, + "1": { + "value": "1rem" + }, + "2": { + "value": "1.143rem" + }, + "3": { + "value": "1.286rem" + }, + "4": { + "value": "1.429rem" + }, + "5": { + "value": "1.571rem" + }, + "6": { + "value": "1.786rem" + }, + "7": { + "value": "2rem" + }, + "8": { + "value": "2.286rem" + }, + "9": { + "value": "2.571rem" + }, + "10": { + "value": "2.857rem" + }, + "11": { + "value": "3.214rem" + }, + "12": { + "value": "3.643rem" + } }, "size": { - "base": { "value": "87.5%" }, - "subordinate": { "value": "{typography.scale.0.value}" }, - "body": { "value": "{typography.scale.1.value}" }, - "heading6": { "value": "{typography.scale.2.value}" }, - "heading5": { "value": "{typography.scale.3.value}" }, - "heading4": { "value": "{typography.scale.5.value}" }, - "heading3": { "value": "{typography.scale.7.value}" }, - "heading2": { "value": "{typography.scale.8.value}" }, - "heading1": { "value": "{typography.scale.9.value}" } + "base": { + "value": "87.5%" + }, + "overline": { + "value": "0.7142857143rem" + }, + "subordinate": { + "value": "{typography.scale.0.value}" + }, + "body": { + "value": "{typography.scale.1.value}" + }, + "heading6": { + "value": "{typography.scale.2.value}" + }, + "heading5": { + "value": "{typography.scale.3.value}" + }, + "heading4": { + "value": "{typography.scale.5.value}" + }, + "heading3": { + "value": "{typography.scale.7.value}" + }, + "heading2": { + "value": "{typography.scale.8.value}" + }, + "heading1": { + "value": "{typography.scale.9.value}" + } }, "style": { - "normal": { "value": "normal" } + "normal": { + "value": "normal" + } }, "weight": { - "body": { "value": "400" }, - "bodyBold": { "value": "600" }, - "heading": { "value": "500" }, - "headingBold": { "value": "700" } + "body": { + "value": "400" + }, + "bodyBold": { + "value": "600" + }, + "heading": { + "value": "500" + }, + "headingBold": { + "value": "700" + } }, "lineHeight": { - "body": { "value": 1.5 }, - "ui": { "value": 1.2 }, - "overline": { "value": 1.3 }, - "heading6": { "value": 1.3 }, - "heading5": { "value": 1.3 }, - "heading4": { "value": 1.25 }, - "heading3": { "value": 1.25 }, - "heading2": { "value": 1.2 }, - "heading1": { "value": 1.2 } + "body": { + "value": 1.5 + }, + "ui": { + "value": 1.2 + }, + "overline": { + "value": 1.3 + }, + "heading6": { + "value": 1.3 + }, + "heading5": { + "value": 1.3 + }, + "heading4": { + "value": 1.25 + }, + "heading3": { + "value": 1.25 + }, + "heading2": { + "value": 1.2 + }, + "heading1": { + "value": 1.2 + } }, "lineLength": { - "max": { "value": "55ch" } + "max": { + "value": "55ch" + } } } } diff --git a/packages/odyssey-react-mui/src/Button.tsx b/packages/odyssey-react-mui/src/Button.tsx index d013976831..5a14fcd19c 100644 --- a/packages/odyssey-react-mui/src/Button.tsx +++ b/packages/odyssey-react-mui/src/Button.tsx @@ -19,6 +19,7 @@ import { ReactElement, useCallback, useImperativeHandle, + useMemo, useRef, } from "react"; @@ -26,6 +27,7 @@ import { MuiPropsContext, useMuiProps } from "./MuiPropsContext"; import { Tooltip } from "./Tooltip"; import type { HtmlProps } from "./HtmlProps"; import { FocusHandle } from "./inputUtils"; +import { useButton } from "./ButtonContext"; export const buttonSizeValues = ["small", "medium", "large"] as const; export const buttonTypeValues = ["button", "submit", "reset"] as const; @@ -142,7 +144,7 @@ const Button = ({ endIcon, id, isDisabled, - isFullWidth, + isFullWidth: isFullWidthProp, label = "", onClick, size = "medium", @@ -160,6 +162,12 @@ const Button = ({ // "secondary" in lieu of making a breaking change const variant = variantProp === "tertiary" ? "secondary" : variantProp; const localButtonRef = useRef(null); + const buttonContext = useButton(); + const isFullWidth = useMemo( + () => + buttonContext.isFullWidth ? buttonContext.isFullWidth : isFullWidthProp, + [buttonContext, isFullWidthProp] + ); useImperativeHandle( buttonRef, diff --git a/packages/odyssey-react-mui/src/ButtonContext.tsx b/packages/odyssey-react-mui/src/ButtonContext.tsx new file mode 100644 index 0000000000..74b8fe688d --- /dev/null +++ b/packages/odyssey-react-mui/src/ButtonContext.tsx @@ -0,0 +1,23 @@ +/*! + * 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 { createContext, useContext } from "react"; + +export type ButtonContextValue = { + isFullWidth: boolean; +}; + +export const ButtonContext = createContext({ + isFullWidth: false, +}); + +export const useButton = () => useContext(ButtonContext); diff --git a/packages/odyssey-react-mui/src/MenuContext.ts b/packages/odyssey-react-mui/src/MenuContext.ts index 1c60ae44a7..f958b77e03 100644 --- a/packages/odyssey-react-mui/src/MenuContext.ts +++ b/packages/odyssey-react-mui/src/MenuContext.ts @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { createContext, MouseEventHandler } from "react"; +import { MouseEventHandler, createContext } from "react"; export type MenuContextType = { closeMenu: () => void; diff --git a/packages/odyssey-react-mui/src/Tile.tsx b/packages/odyssey-react-mui/src/Tile.tsx new file mode 100644 index 0000000000..a1222ce63a --- /dev/null +++ b/packages/odyssey-react-mui/src/Tile.tsx @@ -0,0 +1,147 @@ +/*! + * 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 { ReactElement, memo, useMemo } from "react"; + +import { NullElement } from "./NullElement"; +import { + Card as MuiCard, + CardActions as MuiCardActions, + CardActionArea as MuiCardActionArea, +} from "@mui/material"; +import { Button } from "./Button"; +import { ButtonContext } from "./ButtonContext"; +import { Heading5, Paragraph, Support } from "./Typography"; +import { MoreIcon } from "./icons.generated"; +import { HtmlProps } from "./HtmlProps"; +import styled from "@emotion/styled"; +import { + DesignTokens, + useOdysseyDesignTokens, +} from "./OdysseyDesignTokensContext"; +import { MenuButton } from "./MenuButton"; + +export type TileProps = { + description?: string; + image?: ReactElement | NullElement; // Icon or image + menuItems?: ReactElement; + overline?: string; + title?: string; +} & ( // You can't have actions and onClick at the same time + | { + onClick: () => void; + button?: never; + menuItems?: never; + } + | { + onClick?: never; + button?: ReactElement | NullElement; + menuItems?: ReactElement; + } +) & + HtmlProps; + +const ImageContainer = styled.div<{ + odysseyDesignTokens: DesignTokens; + hasMenuItems: boolean; +}>` + display: flex; + align-items: flex-start; + max-height: 64px; + margin-block-end: ${(props) => props.odysseyDesignTokens.Spacing5}; + padding-right: ${(props) => + props.hasMenuItems ? props.odysseyDesignTokens.Spacing5 : 0}; +`; + +const MenuButtonContainer = styled.div<{ odysseyDesignTokens: DesignTokens }>` + position: absolute; + right: ${(props) => props.odysseyDesignTokens.Spacing3}; + top: ${(props) => props.odysseyDesignTokens.Spacing3}; +`; + +const Tile = ({ + button, + description, + image, + menuItems, + onClick, + overline, + title, +}: TileProps) => { + const odysseyDesignTokens = useOdysseyDesignTokens(); + + const cardContent = useMemo(() => { + return ( + <> + {image && ( + + {image} + + )} + + {overline && {overline}} + {title && {title}} + {description && ( + {description} + )} + + {button && ( + + + {button} + + + )} + + ); + }, [ + button, + description, + image, + menuItems, + overline, + title, + odysseyDesignTokens, + ]); + + return ( + + {onClick && ( + {cardContent} + )} + + {!onClick && cardContent} + + {menuItems && ( + + } + ariaLabel="Tile menu" + buttonVariant="floating" + menuAlignment="right" + size="small" + > + {menuItems} + + + )} + + ); +}; + +const MemoizedTile = memo(Tile); +MemoizedTile.displayName = "Tile"; + +export { MemoizedTile as Tile }; diff --git a/packages/odyssey-react-mui/src/index.ts b/packages/odyssey-react-mui/src/index.ts index 71c8526d29..37d6ce9feb 100644 --- a/packages/odyssey-react-mui/src/index.ts +++ b/packages/odyssey-react-mui/src/index.ts @@ -62,6 +62,7 @@ export * from "./Banner"; export * from "./Box"; export * from "./Breadcrumbs"; export * from "./Button"; +export * from "./Tile"; export * from "./Callout"; export * from "./Checkbox"; export * from "./CheckboxGroup"; diff --git a/packages/odyssey-react-mui/src/theme/components.tsx b/packages/odyssey-react-mui/src/theme/components.tsx index 7311a782fe..2bcd184917 100644 --- a/packages/odyssey-react-mui/src/theme/components.tsx +++ b/packages/odyssey-react-mui/src/theme/components.tsx @@ -701,6 +701,70 @@ export const components = ({ disableRipple: true, }, }, + MuiCard: { + styleOverrides: { + root: () => ({ + backgroundColor: odysseyTokens.HueNeutralWhite, + borderRadius: odysseyTokens.BorderRadiusOuter, + boxShadow: odysseyTokens.DepthMedium, + padding: odysseyTokens.Spacing5, + position: "relative", + transition: `all ${odysseyTokens.TransitionDurationMain} ${odysseyTokens.TransitionTimingMain}`, + + "& img": { + height: "64px", + }, + + "&.isClickable:hover": { + backgroundColor: odysseyTokens.HueNeutral50, + boxShadow: odysseyTokens.DepthHigh, + }, + + [`& .${typographyClasses.h5}`]: { + lineHeight: odysseyTokens.TypographyLineHeightHeading5, + marginBottom: odysseyTokens.Spacing3, + }, + + [`& .${typographyClasses.subtitle2}`]: { + marginBottom: odysseyTokens.Spacing1, + textTransform: "uppercase", + fontWeight: odysseyTokens.TypographyWeightBodyBold, + fontSize: odysseyTokens.TypographySizeOverline, + lineHeight: odysseyTokens.TypographyLineHeightOverline, + color: odysseyTokens.TypographyColorSubordinate, + letterSpacing: 1.3, + }, + + [`& .${typographyClasses.body1}`]: { + fontSize: odysseyTokens.TypographySizeSubordinate, + lineHeight: odysseyTokens.TypographyLineHeightBody, + }, + }), + }, + }, + MuiCardActionArea: { + styleOverrides: { + root: () => ({ + margin: `-${odysseyTokens.Spacing5}`, + padding: odysseyTokens.Spacing5, + width: `calc(100% + (${odysseyTokens.Spacing5} * 2))`, + + "&:hover": { + "& .MuiCardActionArea-focusHighlight": { + display: "none", + }, + }, + }), + }, + }, + MuiCardActions: { + styleOverrides: { + root: () => ({ + marginBlockStart: odysseyTokens.Spacing5, + padding: 0, + }), + }, + }, MuiCheckbox: { defaultProps: { size: "small", diff --git a/packages/odyssey-storybook/src/components/odyssey-labs/Tile/Tile.mdx b/packages/odyssey-storybook/src/components/odyssey-labs/Tile/Tile.mdx new file mode 100644 index 0000000000..0c542cc2c1 --- /dev/null +++ b/packages/odyssey-storybook/src/components/odyssey-labs/Tile/Tile.mdx @@ -0,0 +1,24 @@ +import { + Canvas, + Meta, + Title, + Subtitle, + Description, + Primary, + Controls, + Stories, +} from "@storybook/addon-docs"; +import { Story } from "@storybook/blocks"; +import * as TileStories from "./Tile.stories"; + + + + +<Subtitle of={TileStories} /> +<Description of={TileStories} /> +<Primary of={TileStories} /> + +`<Tile>` can either be clickable (via the `onClick` prop), or can include a button and/or menu (via the `button` and `menuItems` props). + +<Controls /> +<Stories /> diff --git a/packages/odyssey-storybook/src/components/odyssey-labs/Tile/Tile.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-labs/Tile/Tile.stories.tsx new file mode 100644 index 0000000000..83eed43966 --- /dev/null +++ b/packages/odyssey-storybook/src/components/odyssey-labs/Tile/Tile.stories.tsx @@ -0,0 +1,149 @@ +/*! + * 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 { Meta, StoryObj } from "@storybook/react"; +import { MuiThemeDecorator } from "../../../../.storybook/components"; +import { Box, Button, Tile, MenuItem } from "@okta/odyssey-react-mui"; + +const storybookMeta: Meta = { + title: "Labs Components/Tile", + component: Tile, + argTypes: { + title: { + control: "text", + description: "", + table: { + type: { + summary: "string", + }, + defaultValue: "", + }, + }, + description: { + control: "text", + description: "", + table: { + type: { + summary: "string", + }, + defaultValue: "", + }, + }, + overline: { + control: "text", + description: "", + table: { + type: { + summary: "string", + }, + defaultValue: "", + }, + }, + }, + args: { + title: "Title", + description: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor...", + overline: "Overline", + }, + decorators: [MuiThemeDecorator], + parameters: { + backgrounds: { + default: "gray", + values: [ + { name: "gray", value: "#f4f4f4" }, + { name: "white", value: "#ffffff" }, + ], + }, + }, +}; + +export default storybookMeta; + +export const Default: StoryObj = { + render: function C(props: { + title?: string; + description?: string; + overline?: string; + }) { + return ( + <Box sx={{ maxWidth: 262 }}> + <Tile + {...props} + image={<img src="https://placehold.co/128" alt="Example logo" />} + menuItems={ + <> + <MenuItem>Menu option</MenuItem> + <MenuItem>Menu option</MenuItem> + <MenuItem>Menu option</MenuItem> + </> + } + button={<Button variant="primary" label="Button" />} + /> + </Box> + ); + }, +}; + +export const Clickable: StoryObj = { + render: function C(props: { + title?: string; + description?: string; + overline?: string; + }) { + const onClick = () => { + alert("Clicked!"); + }; + + return ( + <Box sx={{ maxWidth: 262 }}> + <Tile + {...props} + image={<img src="https://placehold.co/128" alt="Example logo" />} + onClick={onClick} + /> + </Box> + ); + }, +}; + +export const ClickableWithoutImage: StoryObj = { + render: function C(props: { + title?: string; + description?: string; + overline?: string; + }) { + const onClick = () => { + alert("Clicked!"); + }; + + return ( + <Box sx={{ maxWidth: 262 }}> + <Tile {...props} onClick={onClick} /> + </Box> + ); + }, +}; + +export const ButtonWithoutImage: StoryObj = { + render: function C(props: { + title?: string; + description?: string; + overline?: string; + }) { + return ( + <Box sx={{ maxWidth: 262 }}> + <Tile {...props} button={<Button variant="primary" label="Button" />} /> + </Box> + ); + }, +};