From 995636121e90fffd29fcca15437e168cdedd5749 Mon Sep 17 00:00:00 2001 From: CarolineVP Date: Mon, 22 Aug 2022 12:17:39 +0200 Subject: [PATCH 1/3] Add `SidePanel` in Design System --- src/SidePanel/SidePanel.interface.ts | 8 +++ src/SidePanel/SidePanel.stories.tsx | 85 ++++++++++++++++++++++++++++ src/SidePanel/SidePanel.style.ts | 64 +++++++++++++++++++++ src/SidePanel/SidePanel.tsx | 30 ++++++++++ src/SidePanel/index.ts | 3 + src/index.ts | 3 +- 6 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 src/SidePanel/SidePanel.interface.ts create mode 100644 src/SidePanel/SidePanel.stories.tsx create mode 100644 src/SidePanel/SidePanel.style.ts create mode 100644 src/SidePanel/SidePanel.tsx create mode 100644 src/SidePanel/index.ts diff --git a/src/SidePanel/SidePanel.interface.ts b/src/SidePanel/SidePanel.interface.ts new file mode 100644 index 000000000..26b51437c --- /dev/null +++ b/src/SidePanel/SidePanel.interface.ts @@ -0,0 +1,8 @@ +import * as React from 'react' + +export interface SidePanelProps { + onClose: () => void + navigationContent?: React.ReactNode + tabsBarContent?: React.ReactNode + title: string +} diff --git a/src/SidePanel/SidePanel.stories.tsx b/src/SidePanel/SidePanel.stories.tsx new file mode 100644 index 000000000..9e476f041 --- /dev/null +++ b/src/SidePanel/SidePanel.stories.tsx @@ -0,0 +1,85 @@ +import * as React from 'react' +import styled from 'styled-components' + +import { Button as BaseButton } from '../Button' +import { TabsBar } from '../TabsBar' +import { TabsBarItem } from '../TabsBarItem' +import { Text } from '../Text' + +import { SidePanel } from './index' + +const Wrapper = styled.div` + min-height: 500px; + max-height: 100vh; +` + +const Container = styled.div` + display: flex; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + opacity: 0; + &[data-open='true'] { + opacity: 1; + } +` + +const Button = styled(BaseButton)` + z-index: 101; +` + +const WrappedSidePanel: React.FunctionComponent = () => { + const [isOpened, setOpened] = React.useState(false) + + return ( + + + + + setOpened(false)} + tabsBarContent={ + + + Section 1 + + + } + navigationContent={Section content} + /> + + + ) +} + +export const TabItem = styled(TabsBarItem)` + font-size: 14px; +` + +export default { + title: 'Layouts/SidePanel', + decorators: [WrappedSidePanel], + component: SidePanel, +} + +export const basic = () => { + return ( + + {}} + tabsBarContent={ + + + Section 1 + + + } + navigationContent={Section content} + /> + + ) +} diff --git a/src/SidePanel/SidePanel.style.ts b/src/SidePanel/SidePanel.style.ts new file mode 100644 index 000000000..9a8a7317f --- /dev/null +++ b/src/SidePanel/SidePanel.style.ts @@ -0,0 +1,64 @@ +import styled, { css } from 'styled-components' + +import { zIndex } from '../_internal/theme/zIndex' +import { HeaderBar } from '../HeaderBar' +import { Layout } from '../Layout' +import { RoundIconButton } from '../RoundIconButton' +import { TabsBarItem } from '../TabsBarItem' +import { Text } from '../Text' +import { theme } from '../theme' + +export const CloseButton = styled(RoundIconButton).attrs({ icon: 'close' })` + position: absolute; + top: 36px; + right: 36px; +` + +export const Container = styled(Layout)` + --layout-left-padding: 0; + --layout-right-padding: 0; + + position: absolute; + top: 0; + bottom: 0; + right: 0; + display: grid; + gap: 24px; + align-content: start; + width: 480px; + padding: 36px; + box-shadow: ${theme.shadow('regular')}; + overflow: auto; + overflow: overlay; + z-index: ${zIndex.modals}; +` + +export const Empty = styled(Text).attrs({ variation: 'lowContrast' })` + text-align: center; +` + +export const Header = styled.div` + display: flex; + gap: 12px; + align-items: center; +` + +export const Section = styled.div<{ scrollable?: true }>` + display: grid; + gap: 12px; + + ${(props) => + props.scrollable && + css` + margin: -24px -36px; + padding: 24px 36px; + overflow: auto; + overflow: overlay; + `} +` + +export const TabItem = styled(TabsBarItem)` + font-size: 14px; +` + +export const Tabs = styled(HeaderBar).attrs({ small: true })`` diff --git a/src/SidePanel/SidePanel.tsx b/src/SidePanel/SidePanel.tsx new file mode 100644 index 000000000..40a97fa63 --- /dev/null +++ b/src/SidePanel/SidePanel.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' + +import { Title } from '../Title' + +import { SidePanelProps } from './SidePanel.interface' +import { CloseButton, Container, Header, Tabs } from './SidePanel.style' + +export const SidePanel: React.FunctionComponent = ({ + onClose, + tabsBarContent, + title, + navigationContent, +}) => { + return ( + +
+ {title} + + +
+ + {tabsBarContent && ( + + {tabsBarContent} + {navigationContent} + + )} +
+ ) +} diff --git a/src/SidePanel/index.ts b/src/SidePanel/index.ts new file mode 100644 index 000000000..7acff520e --- /dev/null +++ b/src/SidePanel/index.ts @@ -0,0 +1,3 @@ +export { SidePanel } from './SidePanel' + +export { SidePanelProps } from './SidePanel.interface' diff --git a/src/index.ts b/src/index.ts index 55217a86a..848df88b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,7 +102,7 @@ export { ActionBar, ActionBarProps } from './ActionBar' export { HeaderBar, HeaderBarProps } from './HeaderBar' export { Card, CardProps } from './Card' export { ExpansionPanel, ExpansionPanelProps } from './ExpansionPanel' -export { Drawer, DrawerProps, DrawerStep } from './Drawer' +export { Drawer, DrawerProps, DrawerStep } from './Drawer' export { ExpansionPanelItem, ExpansionPanelItemProps, @@ -112,6 +112,7 @@ export { export { Loader, LoaderProps } from './Loader' export { LoaderDots, LoaderDotsProps } from './LoaderDots' export { LoadingBar, LoadingBarProps } from './LoadingBar' +export { SidePanel, SidePanelProps } from './SidePanel' export { TogglePanel, TogglePanelProps, From 156944b7674193ff09983103ef757918a04e5932 Mon Sep 17 00:00:00 2001 From: CarolineVP Date: Fri, 23 Sep 2022 12:07:58 +0200 Subject: [PATCH 2/3] Base `SidePanel` on `modal` --- src/SidePanel/SidePanel.interface.ts | 24 +++- src/SidePanel/SidePanel.spec.tsx | 119 ++++++++++++++++++++ src/SidePanel/SidePanel.stories.tsx | 154 ++++++++++++++----------- src/SidePanel/SidePanel.style.ts | 162 ++++++++++++++++++++------- src/SidePanel/SidePanel.tsx | 105 +++++++++++++---- src/index.ts | 2 +- 6 files changed, 436 insertions(+), 130 deletions(-) create mode 100644 src/SidePanel/SidePanel.spec.tsx diff --git a/src/SidePanel/SidePanel.interface.ts b/src/SidePanel/SidePanel.interface.ts index 26b51437c..bd46932d3 100644 --- a/src/SidePanel/SidePanel.interface.ts +++ b/src/SidePanel/SidePanel.interface.ts @@ -1,8 +1,22 @@ +import { Modal } from '@delangle/use-modal' import * as React from 'react' -export interface SidePanelProps { - onClose: () => void - navigationContent?: React.ReactNode - tabsBarContent?: React.ReactNode - title: string +import { WithFloatingPanelBehavior } from '../withFloatingPanelBehavior' +import { WithTriggerElement } from '../withTriggerElement' + +export interface SidePanelInnerProps + extends Omit, 'title' | 'children'> { + modal: Modal + parentFloatingPanelRef: React.RefObject | null + title?: React.ReactNode + hideCloseIcon?: boolean + children?: + | React.ReactNode + | ((modal: Modal) => React.ReactNode) } + +export interface SidePanelProps + extends WithTriggerElement< + WithFloatingPanelBehavior, + HTMLDivElement + > {} diff --git a/src/SidePanel/SidePanel.spec.tsx b/src/SidePanel/SidePanel.spec.tsx new file mode 100644 index 000000000..1a693f514 --- /dev/null +++ b/src/SidePanel/SidePanel.spec.tsx @@ -0,0 +1,119 @@ +import { render, within, act, fireEvent } from '@testing-library/react' +import * as React from 'react' +import sinon from 'sinon' + +import { SidePanel } from './index' + +jest.useFakeTimers() + +describe('SidePanel component', () => { + describe('with react node children', () => { + it('should not render if not opened once', () => { + const { queryByTestId } = render( + null}> +
CONTENT
+
+ ) + + const modalContainer = queryByTestId('modal-container') + + expect(modalContainer).toBeNull() + }) + + it('should render if opened once', () => { + const { queryByTestId, getByTestId } = render( + null} + triggerElement={ + + } + > +
CONTENT
+
+ ) + + fireEvent.click(getByTestId('modal-trigger-element')) + fireEvent.click(getByTestId('modal-overlay')) + + const modalContainer = queryByTestId('modal-container') as HTMLElement + + expect(modalContainer).toBeTruthy() + expect(within(modalContainer).queryByTestId('content')).toBeTruthy() + }) + + it('should not render if not opened once', () => { + const { queryByTestId } = render( + null}> +
CONTENT
+
+ ) + + const modalContainer = queryByTestId('modal-container') + + expect(modalContainer).toBeNull() + }) + }) + + describe('with render props children', () => { + it('should not call children if modal is closed and has never been opened', () => { + const spyChildren = sinon.spy() + + render( + null} open={false}> + {spyChildren} + + ) + + expect(spyChildren.callCount).toEqual(0) + }) + + it('should have state = "opening" if modal is mounted with open=true"', () => { + const spyChildren = sinon.spy() + + render( + null} open> + {spyChildren} + + ) + + expect(spyChildren.lastCall.args[0].state).toEqual('opening') + }) + + it('should have state="opened" if opened for more than 1 second"', () => { + const spyChildren = sinon.spy() + render( + null} open> + {spyChildren} + + ) + + act(() => { + jest.advanceTimersByTime(2000) + }) + + expect(spyChildren.lastCall.args[0].state).toEqual('opened') + }) + + it('should have state="closing" if open just switched to "false"', () => { + const spyChildren = sinon.spy() + + const { rerender } = render( + null} open> + {spyChildren} + + ) + + act(() => { + jest.advanceTimersByTime(1000) + }) + + rerender( + null} open={false}> + {spyChildren} + + ) + + expect(spyChildren.lastCall.args[0].state).toEqual('closing') + }) + }) +}) diff --git a/src/SidePanel/SidePanel.stories.tsx b/src/SidePanel/SidePanel.stories.tsx index 9e476f041..24c58d8f3 100644 --- a/src/SidePanel/SidePanel.stories.tsx +++ b/src/SidePanel/SidePanel.stories.tsx @@ -1,85 +1,113 @@ import * as React from 'react' import styled from 'styled-components' -import { Button as BaseButton } from '../Button' +import { withGrid } from '../_storybook/withGrid' +import { ActionBar } from '../ActionBar' +import { Button } from '../Button' +import { HeaderBar } from '../HeaderBar' import { TabsBar } from '../TabsBar' import { TabsBarItem } from '../TabsBarItem' import { Text } from '../Text' -import { SidePanel } from './index' +import { SidePanel, SidePanelProps } from './index' -const Wrapper = styled.div` - min-height: 500px; - max-height: 100vh; +const Line = styled(Text)` + margin-bottom: 12px; ` -const Container = styled.div` - display: flex; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - opacity: 0; - &[data-open='true'] { - opacity: 1; - } +const TabItem = styled(TabsBarItem)` + font-size: 14px; ` -const Button = styled(BaseButton)` - z-index: 101; +const Tabs = styled(HeaderBar).attrs({ small: true })` + padding-top: 12px; ` -const WrappedSidePanel: React.FunctionComponent = () => { - const [isOpened, setOpened] = React.useState(false) - - return ( - - - - - setOpened(false)} - tabsBarContent={ - - - Section 1 - - - } - navigationContent={Section content} - /> - - - ) +const TabsBarContent: React.FunctionComponent = () => ( + + + + Section 1 + + + Section content + +) + +const ScrollableContent: React.FunctionComponent = () => ( + + {Array.from({ length: 20 }, (_, index) => ( + Element n°{index + 1} + ))} + +) + +const ScrollableContentWithActionBar: React.FunctionComponent = () => ( +
+ {Array.from({ length: 20 }, (_, index) => ( + Item {index + 1} + ))} + + + + +
+) + +const GRID_PROPS = { + triggerElement: , } -export const TabItem = styled(TabsBarItem)` - font-size: 14px; -` +const GRID_LINES = [{ title: 'Regular' }] + +const GRID_ITEMS = [ + { + props: { + title: 'Example', + children: , + }, + label: 'Short with tab bar and title', + }, + { + props: { + title: 'Item list', + children: , + }, + label: 'Scrollable', + }, + { + props: { + title: 'Item list', + children: , + }, + label: 'Scrollable with action bar', + }, +] + +const Grid = withGrid({ + props: GRID_PROPS, + lines: GRID_LINES, + items: GRID_ITEMS, + itemHorizontalSpace: 36, +})(SidePanel) export default { - title: 'Layouts/SidePanel', - decorators: [WrappedSidePanel], + title: 'Modals/SidePanel', component: SidePanel, } -export const basic = () => { - return ( - - {}} - tabsBarContent={ - - - Section 1 - - - } - navigationContent={Section content} - /> - - ) +export const basic = (props: SidePanelProps) => ( + Open Side Panel} + {...props} + /> +) + +basic.parameters = { + design: { + type: 'figma', + url: 'https://www.figma.com/file/LfGEUbovutcTpygwzrfTYbl5/Desktop-components?node-id=62%3A2', + }, } + +export const gallery = () => diff --git a/src/SidePanel/SidePanel.style.ts b/src/SidePanel/SidePanel.style.ts index 9a8a7317f..49a2bd5f1 100644 --- a/src/SidePanel/SidePanel.style.ts +++ b/src/SidePanel/SidePanel.style.ts @@ -1,64 +1,150 @@ -import styled, { css } from 'styled-components' +import styled, { keyframes } from 'styled-components' import { zIndex } from '../_internal/theme/zIndex' -import { HeaderBar } from '../HeaderBar' +import { ActionBarContent } from '../ActionBar/ActionBar.style' +import { animations } from '../animations' +import { breakpoints } from '../breakpoints' import { Layout } from '../Layout' -import { RoundIconButton } from '../RoundIconButton' -import { TabsBarItem } from '../TabsBarItem' -import { Text } from '../Text' +import { mixins } from '../mixins' import { theme } from '../theme' -export const CloseButton = styled(RoundIconButton).attrs({ icon: 'close' })` - position: absolute; - top: 36px; - right: 36px; -` +const FADE_IN = keyframes` + from { + background-color: transparent; + } -export const Container = styled(Layout)` - --layout-left-padding: 0; - --layout-right-padding: 0; + to { + background-color: rgba(50, 50, 50, 0.7); + } +` +export const SidePanelContainer = styled(Layout)` position: absolute; top: 0; bottom: 0; right: 0; - display: grid; + display: flex; gap: 24px; - align-content: start; - width: 480px; - padding: 36px; - box-shadow: ${theme.shadow('regular')}; + flex-direction: column; overflow: auto; overflow: overlay; + padding: 36px; z-index: ${zIndex.modals}; -` -export const Empty = styled(Text).attrs({ variation: 'lowContrast' })` - text-align: center; + --modal-width: 480px; + + @media (${breakpoints.above.phone}) { + width: var(--modal-width); + max-width: calc(100vw - 48px); + } + + @media (${breakpoints.below.phone}) { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + padding: 20px; + } ` -export const Header = styled.div` +export const SidePanelOverlay = styled.div` + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: rgba(50, 50, 50, 0.7); display: flex; - gap: 12px; align-items: center; + justify-content: center; + + @media (${breakpoints.below.phone}) { + background-color: transparent; + + &[data-state='opening'] { + & ${SidePanelContainer} { + animation: ${animations('emergeSlantFromTop')}; + } + } + + &[data-state='closing'] { + & ${SidePanelContainer} { + animation: ${animations('diveSlant')}; + } + } + } + + @media (${breakpoints.above.phone}) { + &[data-state='opening'] { + animation: ${FADE_IN} var(--modal-animation-duration) linear 0ms; + + & ${SidePanelContainer} { + animation: ${animations('emergeSlantFromBottom')}; + } + } + + &[data-state='closing'] { + animation: ${FADE_IN} var(--modal-animation-duration) linear 0ms reverse; + pointer-events: none; + background-color: transparent; + + & ${SidePanelContainer} { + animation: ${animations('diveSlant')}; + } + } + } + + &[data-state='closed'] { + opacity: 0; + pointer-events: none; + } ` -export const Section = styled.div<{ scrollable?: true }>` - display: grid; - gap: 12px; - - ${(props) => - props.scrollable && - css` - margin: -24px -36px; - padding: 24px 36px; - overflow: auto; - overflow: overlay; - `} +export const HeaderBarContainer = styled.div` + display: flex; + + ${mixins.removeLayoutPadding({ top: true, right: true, left: true })}; + ${mixins.addLayoutPadding({ top: true, right: true, left: true })}; + + @media (${breakpoints.above.phone}) { + align-items: baseline; + justify-content: space-between; + + & > *:not(:last-child) { + margin-right: 12px; + } + } + + @media (${breakpoints.below.phone}) { + flex-direction: column-reverse; + + & > *:not(:first-child) { + margin-bottom: 12px; + } + } ` -export const TabItem = styled(TabsBarItem)` - font-size: 14px; +export const SidePanelContent = styled.div` + flex: 1 1 100%; + min-height: 0; + display: flex; + z-index: ${zIndex.modals}; ` -export const Tabs = styled(HeaderBar).attrs({ small: true })`` +export const SidePanelScrollableContent = styled.div` + flex: 1; + display: flex; + gap: 24px; + flex-direction: column; + overflow-y: auto; + overflow-x: hidden; + + @media (${breakpoints.below.phone}) { + padding-bottom: 72px; + + & ${ActionBarContent}:not([data-count='0']):not([data-count='1']) { + box-shadow: ${theme.shadow('low')}; + } + } +` diff --git a/src/SidePanel/SidePanel.tsx b/src/SidePanel/SidePanel.tsx index 40a97fa63..028bbeea6 100644 --- a/src/SidePanel/SidePanel.tsx +++ b/src/SidePanel/SidePanel.tsx @@ -1,30 +1,89 @@ import * as React from 'react' +import * as ReactDOM from 'react-dom' +import { isFunction, isString } from '../_internal/data' +import { ANIMATION_DURATIONS } from '../animations' +import { RoundIconButton } from '../RoundIconButton' import { Title } from '../Title' +import { useCurrentBackground } from '../useCurrentBackground' +import { + withFloatingPanelBehavior, + WithFloatingPanelBehavior, +} from '../withFloatingPanelBehavior' +import { withTriggerElement } from '../withTriggerElement' -import { SidePanelProps } from './SidePanel.interface' -import { CloseButton, Container, Header, Tabs } from './SidePanel.style' +import { SidePanelInnerProps } from './SidePanel.interface' +import { + SidePanelOverlay, + SidePanelContainer, + SidePanelContent, + SidePanelScrollableContent, + HeaderBarContainer, +} from './SidePanel.style' -export const SidePanel: React.FunctionComponent = ({ - onClose, - tabsBarContent, - title, - navigationContent, -}) => { - return ( - -
- {title} +const InnerSidePanel = React.forwardRef( + (props, ref) => { + const { + modal, + parentFloatingPanelRef, + children, + title, + hideCloseIcon, + ...rest + } = props - -
+ const backgroundColor = useCurrentBackground({ useRootTheme: true }) - {tabsBarContent && ( - - {tabsBarContent} - {navigationContent} - - )} -
- ) -} + if (!modal.hasAlreadyBeenOpened) { + return null + } + + return ReactDOM.createPortal( + + + {(title || !hideCloseIcon) && ( + + {isString(title) ? ( + {title} + ) : ( + title ??
+ )} + {!hideCloseIcon && ( + + )} + + )} + + + {isFunction(children) ? children(modal) : children} + + + + , + parentFloatingPanelRef?.current ?? document.body + ) + } +) + +export const SidePanel = withTriggerElement()< + WithFloatingPanelBehavior +>( + withFloatingPanelBehavior({ + animated: true, + persistent: true, + animationDuration: ANIMATION_DURATIONS.m, + })(InnerSidePanel) +) diff --git a/src/index.ts b/src/index.ts index 848df88b6..7a865e92a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -112,7 +112,6 @@ export { export { Loader, LoaderProps } from './Loader' export { LoaderDots, LoaderDotsProps } from './LoaderDots' export { LoadingBar, LoadingBarProps } from './LoadingBar' -export { SidePanel, SidePanelProps } from './SidePanel' export { TogglePanel, TogglePanelProps, @@ -130,6 +129,7 @@ export { Modal, ModalProps } from './Modal' export { ModalState } from '@delangle/use-modal' export { prompt } from './prompt' export { confirm } from './confirm' +export { SidePanel, SidePanelProps } from './SidePanel' /* *Alerts From d8b9e2ae83ac9c2479aed8ffe7ddcb48c2d9307b Mon Sep 17 00:00:00 2001 From: CarolineVP Date: Fri, 30 Sep 2022 10:54:10 +0200 Subject: [PATCH 3/3] Fix: update style --- src/SidePanel/SidePanel.style.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/SidePanel/SidePanel.style.ts b/src/SidePanel/SidePanel.style.ts index 49a2bd5f1..cb8e116c4 100644 --- a/src/SidePanel/SidePanel.style.ts +++ b/src/SidePanel/SidePanel.style.ts @@ -29,6 +29,7 @@ export const SidePanelContainer = styled(Layout)` overflow: auto; overflow: overlay; padding: 36px; + box-shadow: ${theme.shadow('regular')}; z-index: ${zIndex.modals}; --modal-width: 480px; @@ -49,16 +50,6 @@ export const SidePanelContainer = styled(Layout)` ` export const SidePanelOverlay = styled.div` - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; - background-color: rgba(50, 50, 50, 0.7); - display: flex; - align-items: center; - justify-content: center; - @media (${breakpoints.below.phone}) { background-color: transparent;