From b78be4c568b2ff9b2c29a2b7d87f06985f430e7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=AA=20Boaventura?= Date: Thu, 18 Apr 2024 10:29:49 -0300 Subject: [PATCH] feat: add flyover design system component (#581) --- .eslintrc.cjs | 2 + package.json | 1 + src/components/Flyover.tsx | 156 ++++++++++++++++++++++++++++ src/components/HonorableModal.tsx | 35 ++++--- src/index.ts | 1 + src/stories/Flyover.stories.tsx | 166 ++++++++++++++++++++++++++++++ yarn.lock | 61 +++++++++++ 7 files changed, 408 insertions(+), 14 deletions(-) create mode 100644 src/components/Flyover.tsx create mode 100644 src/stories/Flyover.stories.tsx diff --git a/.eslintrc.cjs b/.eslintrc.cjs index f9bf485d..4b2cc576 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -7,6 +7,7 @@ module.exports = { 'plugin:storybook/recommended', 'prettier', ], + plugins: ['prettier'], globals: { JSX: true, }, @@ -19,6 +20,7 @@ module.exports = { 'import-newlines/enforce': 'off', // Allow css prop for styled-components 'react/no-unknown-property': ['error', { ignore: ['css'] }], + 'prettier/prettier': 'error', }, ignorePatterns: ['/coverage/**/*'], } diff --git a/package.json b/package.json index 575ec42e..cd587e7b 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "eslint-plugin-import": "2.29.1", "eslint-plugin-import-newlines": "1.3.4", "eslint-plugin-jsx-a11y": "6.8.0", + "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react": "7.33.2", "eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-storybook": "0.6.15", diff --git a/src/components/Flyover.tsx b/src/components/Flyover.tsx new file mode 100644 index 00000000..5cb4a202 --- /dev/null +++ b/src/components/Flyover.tsx @@ -0,0 +1,156 @@ +import { type ReactNode, type Ref, forwardRef, useEffect } from 'react' +import PropTypes from 'prop-types' + +import styled, { type StyledComponentPropsWithRef } from 'styled-components' + +import { type ModalProps } from 'honorable' + +import useLockedBody from '../hooks/useLockedBody' + +import { CloseIcon } from '../icons' + +import { HonorableModal } from './HonorableModal' +import IconFrame from './IconFrame' + +type FlyoverPropsType = Omit & { + header?: ReactNode + lockBody?: boolean + scrollable?: boolean + asForm?: boolean + formProps?: StyledComponentPropsWithRef<'form'> + width?: string + minWidth?: number + [x: string]: unknown +} + +const propTypes = { + header: PropTypes.node, + lockBody: PropTypes.bool, + scrollable: PropTypes.bool, + asForm: PropTypes.bool, + width: PropTypes.string, + minWidth: PropTypes.number, +} as const + +const FlyoverSC = styled.div(({ theme }) => ({ + position: 'relative', + backgroundColor: theme.colors['fill-zero'], + height: '100%', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', +})) + +const FlyoverContentSC = styled.div<{ + $scrollable: boolean +}>(({ theme, $scrollable }) => ({ + position: 'relative', + zIndex: 0, + margin: 0, + padding: theme.spacing.large, + backgroundColor: theme.colors['fill-zero'], + ...theme.partials.text.body1, + flexGrow: 1, + ...($scrollable + ? { overflow: 'auto' } + : { + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + }), +})) + +const FlyoverHeaderWrapSC = styled.div(({ theme }) => ({ + alignItems: 'center', + justifyContent: 'start', + gap: theme.spacing.small, + height: 56, + borderBottom: `1px solid ${theme.colors.border}`, + backgroundColor: theme.colors.grey[950], + display: 'flex', + padding: `${theme.spacing.small}px ${theme.spacing.medium}px`, +})) + +const FlyoverHeaderSC = styled.h1(({ theme }) => ({ + margin: 0, + ...theme.partials.text.subtitle1, + color: theme.colors.semanticDefault, +})) + +function FlyoverRef( + { + children, + header, + open = false, + onClose, + lockBody = true, + asForm = false, + formProps = {}, + scrollable = true, + width = '40%', + minWidth = 570, + ...props + }: FlyoverPropsType, + ref: Ref +) { + const [, setBodyLocked] = useLockedBody(open && lockBody) + + useEffect(() => { + setBodyLocked(lockBody && open) + }, [lockBody, open, setBodyLocked]) + + return ( + + + {!!header && ( + + } + /> + {header} + + )} + {children} + + + ) +} + +const Flyover = forwardRef(FlyoverRef) + +Flyover.propTypes = propTypes + +export default Flyover diff --git a/src/components/HonorableModal.tsx b/src/components/HonorableModal.tsx index 0f662041..0a8d391f 100644 --- a/src/components/HonorableModal.tsx +++ b/src/components/HonorableModal.tsx @@ -17,7 +17,7 @@ import { useState, } from 'react' import { createPortal } from 'react-dom' -import { Transition } from 'react-transition-group' +import { Transition, type TransitionStatus } from 'react-transition-group' import { Div, useTheme } from 'honorable' import useRootStyles from 'honorable/dist/hooks/useRootStyles.js' @@ -25,6 +25,7 @@ import { useKeyDown } from '@react-hooks-library/core' import { type ComponentProps } from 'honorable/dist/types.js' import resolvePartStyles from 'honorable/dist/resolvers/resolvePartStyles.js' import filterUndefinedValues from 'honorable/dist/utils/filterUndefinedValues.js' +import { type CSSObject } from 'styled-components' export const modalParts = ['Backdrop'] as const @@ -33,6 +34,8 @@ export type ModalBaseProps = { onClose?: (event?: MouseEvent | KeyboardEvent) => void fade?: boolean transitionDuration?: number + InnerTransitionStyle?: transitionConfig + InnerDefaultStyle?: CSSObject disableEscapeKey?: boolean portal?: boolean } @@ -40,6 +43,17 @@ export type ModalBaseProps = { export type ModalProps = ModalBaseProps & ComponentProps +type transitionConfig = { + [key in TransitionStatus]?: CSSObject +} + +const defaultInnerTransitionStyle: transitionConfig = { + entering: { opacity: 0 }, + entered: { opacity: 1 }, + exiting: { opacity: 0 }, + exited: { opacity: 0 }, +} + function ModalRef(props: ModalProps, ref: Ref) { const { open = true, @@ -48,6 +62,8 @@ function ModalRef(props: ModalProps, ref: Ref) { transitionDuration = 250, disableEscapeKey = false, portal = false, + InnerTransitionStyle, + InnerDefaultStyle, ...otherProps } = props const theme = useTheme() @@ -159,18 +175,9 @@ function ModalRef(props: ModalProps, ref: Ref) { function wrapFadeInner(element: ReactElement) { if (!fade) return element - const defaultStyle = { + const defaultInnerStyle = { opacity: 0, transition: `opacity ${transitionDuration}ms ease`, - ...resolvePartStyles('InnerDefaultStyle', props, theme), - } - - const transitionStyles = { - entering: { opacity: 1 }, - entered: { opacity: 1 }, - exiting: { opacity: 0 }, - exited: { opacity: 0 }, - ...resolvePartStyles('InnerTransitionStyle', props, theme), } return ( @@ -178,11 +185,11 @@ function ModalRef(props: ModalProps, ref: Ref) { in={isOpen && !isClosing} timeout={transitionDuration} > - {(state: string) => + {(state) => cloneElement(element, { ...element.props, - ...defaultStyle, - ...transitionStyles[state as keyof typeof transitionStyles], + ...(InnerDefaultStyle || defaultInnerStyle), + ...(InnerTransitionStyle || defaultInnerTransitionStyle)[state], }) } diff --git a/src/index.ts b/src/index.ts index 3bfec27c..137ce760 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,6 +72,7 @@ export { default as Sidebar } from './components/Sidebar' export { default as SidebarSection } from './components/SidebarSection' export { default as SidebarItem } from './components/SidebarItem' export { default as Modal } from './components/Modal' +export { default as Flyover } from './components/Flyover' export { HonorableModal } from './components/HonorableModal' export type { ChecklistProps, diff --git a/src/stories/Flyover.stories.tsx b/src/stories/Flyover.stories.tsx new file mode 100644 index 00000000..fe0bea30 --- /dev/null +++ b/src/stories/Flyover.stories.tsx @@ -0,0 +1,166 @@ +import { Div, Flex, H3, P } from 'honorable' +import { useState } from 'react' + +import styled from 'styled-components' + +import { Button, Card, Code, Flyover, FormField, Input2, SearchIcon } from '..' +import { jsCode } from '../constants' + +export default { + title: 'Flyover', + component: Flyover, + argTypes: {}, +} + +function ExtraContent() { + return ( +
+

+ Some extra content to check that body scroll is disabled when Flyover is + open. +

+ {Array.from({ length: 5 }).map(() => ( +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus + tempor, mi pulvinar vestibulum viverra, magnan ipsum suscipit turpis, + molestie imperdiet nisi lorem id erat. Vestibulum pellentesque vel + odio et consequat. Sed lacinia leo sit amet velit consequat lobortis. + Vivamus facilisis sagittis est vel pellentesque. Sed quis ipsum + ullamcorper, posuere ipsum a, tincidunt tellus. Cras tortor purus, + dictum sit amet facilisis vitae, commodo vitae elit. Duis a diam + blandit, hendrerit velit non, tincidunt turpis. Ut at lectus ornare, + volutpat elit interdum, placerat dolor. Pellentesque et semper massa. + Aliquam nec nisl eu nibh fringilla vehicula. Suspendisse a purus quam. +

+ ))} +
+ ) +} + +function Template(args: any) { + const [open, setOpen] = useState(false) + + return ( + <> +

{args.header} Flyover

+ + setOpen(false)} + asForm={!!args.asForm} + formProps={{ + onSubmit: (e) => { + e.preventDefault() + alert('Form submitted') + }, + }} + {...args} + > + {!args.asForm && ( + <> +

+ Uninstalling this application will disable all future upgrades. +

+

+ If you'd also like to remove the running instance from your + cluster, be sure to run `plural destroy` from this application's + repository. +

+ + )} + + {args.asForm && ( + + + + + + + + + + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus + tempor, mi pulvinar vestibulum viverra, magnan ipsum suscipit + turpis, molestie imperdiet nisi lorem id erat. Vestibulum + pellentesque vel odio et consequat. Sed lacinia leo sit amet velit + consequat lobortis. Vivamus facilisis sagittis est vel + pellentesque. Sed quis ipsum ullamcorper, posuere ipsum a, + tincidunt tellus. Cras tortor purus, dictum sit amet facilisis + vitae, commodo vitae elit. Duis a diam blandit, hendrerit velit + non, tincidunt turpis. Ut at lectus ornare, volutpat elit + interdum, placerat dolor. Pellentesque et semper massa. Aliquam + nec nisl eu nibh fringilla vehicula. Suspendisse a purus quam. +

+ + } /> + +
+ )} +
+ + + + + ) +} + +const NonScrollCode = styled(Code)((_) => ({ + overflow: 'hidden', +})) + +function NonScrollTemplate(args: any) { + const [open, setOpen] = useState(false) + + return ( + <> +

{args.header} Flyover

+ + setOpen(false)} + {...args} + > + {jsCode} + + + + + + ) +} + +export const Default = Template.bind({}) + +Default.args = { + header: 'Default', + asForm: false, + scrollable: true, +} + +export const Form = Template.bind({}) + +Form.args = { + header: 'Form', + asForm: true, + scrollable: true, +} + +export const NonScrollable = NonScrollTemplate.bind({}) + +NonScrollable.args = { + header: 'Non-scrollable', + scrollable: false, +} diff --git a/yarn.lock b/yarn.lock index 96039ff6..d7f0e235 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2883,6 +2883,13 @@ __metadata: languageName: node linkType: hard +"@pkgr/core@npm:^0.1.0": + version: 0.1.1 + resolution: "@pkgr/core@npm:0.1.1" + checksum: 6f25fd2e3008f259c77207ac9915b02f1628420403b2630c92a07ff963129238c9262afc9e84344c7a23b5cc1f3965e2cd17e3798219f5fd78a63d144d3cceba + languageName: node + linkType: hard + "@pluralsh/design-system@workspace:.": version: 0.0.0-use.local resolution: "@pluralsh/design-system@workspace:." @@ -2936,6 +2943,7 @@ __metadata: eslint-plugin-import: 2.29.1 eslint-plugin-import-newlines: 1.3.4 eslint-plugin-jsx-a11y: 6.8.0 + eslint-plugin-prettier: ^5.1.3 eslint-plugin-react: 7.33.2 eslint-plugin-react-hooks: 4.6.0 eslint-plugin-storybook: 0.6.15 @@ -10194,6 +10202,26 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-prettier@npm:^5.1.3": + version: 5.1.3 + resolution: "eslint-plugin-prettier@npm:5.1.3" + dependencies: + prettier-linter-helpers: ^1.0.0 + synckit: ^0.8.6 + peerDependencies: + "@types/eslint": ">=8.0.0" + eslint: ">=8.0.0" + eslint-config-prettier: "*" + prettier: ">=3.0.0" + peerDependenciesMeta: + "@types/eslint": + optional: true + eslint-config-prettier: + optional: true + checksum: eb2a7d46a1887e1b93788ee8f8eb81e0b6b2a6f5a66a62bc6f375b033fc4e7ca16448da99380be800042786e76cf5c0df9c87a51a2c9b960ed47acbd7c0b9381 + languageName: node + linkType: hard + "eslint-plugin-react-hooks@npm:4.6.0": version: 4.6.0 resolution: "eslint-plugin-react-hooks@npm:4.6.0" @@ -10607,6 +10635,13 @@ __metadata: languageName: node linkType: hard +"fast-diff@npm:^1.1.2": + version: 1.3.0 + resolution: "fast-diff@npm:1.3.0" + checksum: d22d371b994fdc8cce9ff510d7b8dc4da70ac327bcba20df607dd5b9cae9f908f4d1028f5fe467650f058d1e7270235ae0b8230809a262b4df587a3b3aa216c3 + languageName: node + linkType: hard + "fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.2": version: 3.3.2 resolution: "fast-glob@npm:3.3.2" @@ -15945,6 +15980,15 @@ __metadata: languageName: node linkType: hard +"prettier-linter-helpers@npm:^1.0.0": + version: 1.0.0 + resolution: "prettier-linter-helpers@npm:1.0.0" + dependencies: + fast-diff: ^1.1.2 + checksum: 00ce8011cf6430158d27f9c92cfea0a7699405633f7f1d4a45f07e21bf78e99895911cbcdc3853db3a824201a7c745bd49bfea8abd5fb9883e765a90f74f8392 + languageName: node + linkType: hard + "prettier@npm:3.0.3": version: 3.0.3 resolution: "prettier@npm:3.0.3" @@ -18457,6 +18501,16 @@ __metadata: languageName: node linkType: hard +"synckit@npm:^0.8.6": + version: 0.8.8 + resolution: "synckit@npm:0.8.8" + dependencies: + "@pkgr/core": ^0.1.0 + tslib: ^2.6.2 + checksum: 9ed5d33abb785f5f24e2531efd53b2782ca77abf7912f734d170134552b99001915531be5a50297aa45c5701b5c9041e8762e6cd7a38e41e2461c1e7fccdedf8 + languageName: node + linkType: hard + "tabbable@npm:^6.0.1": version: 6.0.1 resolution: "tabbable@npm:6.0.1" @@ -18828,6 +18882,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:^2.6.2": + version: 2.6.2 + resolution: "tslib@npm:2.6.2" + checksum: 329ea56123005922f39642318e3d1f0f8265d1e7fcb92c633e0809521da75eeaca28d2cf96d7248229deb40e5c19adf408259f4b9640afd20d13aecc1430f3ad + languageName: node + linkType: hard + "tsutils@npm:^3.21.0": version: 3.21.0 resolution: "tsutils@npm:3.21.0"