From 97a6e44cb4c8682a7dc64770facd86e4fad9dfc5 Mon Sep 17 00:00:00 2001 From: Justin Trenary Date: Tue, 15 Aug 2023 02:44:52 -0400 Subject: [PATCH] init Flashcard component --- ...-flashcard-component_2023-08-16-04-48.json | 10 + .../core/src/Flashcard/Flashcard.spec.tsx | 69 ++++++ .../src/Flashcard/Flashcard.stories.args.tsx | 30 +++ .../core/src/Flashcard/Flashcard.stories.tsx | 197 ++++++++++++++++++ .../core/src/Flashcard/Flashcard.styled.tsx | 37 ++++ packages/core/src/Flashcard/Flashcard.tsx | 98 +++++++++ packages/core/src/Flashcard/index.ts | 3 + packages/core/src/index.ts | 2 + packages/core/src/theme/theme.ts | 2 + 9 files changed, 448 insertions(+) create mode 100644 common/changes/pcln-design-system/add-flashcard-component_2023-08-16-04-48.json create mode 100644 packages/core/src/Flashcard/Flashcard.spec.tsx create mode 100644 packages/core/src/Flashcard/Flashcard.stories.args.tsx create mode 100644 packages/core/src/Flashcard/Flashcard.stories.tsx create mode 100644 packages/core/src/Flashcard/Flashcard.styled.tsx create mode 100644 packages/core/src/Flashcard/Flashcard.tsx create mode 100644 packages/core/src/Flashcard/index.ts diff --git a/common/changes/pcln-design-system/add-flashcard-component_2023-08-16-04-48.json b/common/changes/pcln-design-system/add-flashcard-component_2023-08-16-04-48.json new file mode 100644 index 0000000000..1a3a20f611 --- /dev/null +++ b/common/changes/pcln-design-system/add-flashcard-component_2023-08-16-04-48.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "pcln-design-system", + "comment": "add Flashcard component", + "type": "minor" + } + ], + "packageName": "pcln-design-system" +} \ No newline at end of file diff --git a/packages/core/src/Flashcard/Flashcard.spec.tsx b/packages/core/src/Flashcard/Flashcard.spec.tsx new file mode 100644 index 0000000000..f18ca1c88a --- /dev/null +++ b/packages/core/src/Flashcard/Flashcard.spec.tsx @@ -0,0 +1,69 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import { Flashcard } from '..' + +const frontside = 'Front' +const backside = 'Back' +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +describe('Flashcard', () => { + it('renders the front content', () => { + render({frontside}) + const front = screen.getByText(frontside) + const back = screen.queryByText(backside) + expect(front).toBeInTheDocument() + expect(back).not.toBeInTheDocument() + }) + + it('renders the back content when flipped', async () => { + render({frontside}) + + const front = screen.getByText(frontside) + + front.click() + await wait(1000) + + const front2 = screen.queryByText(frontside) + const back = screen.getByText(backside) + expect(front2).not.toBeInTheDocument() + expect(back).toBeInTheDocument() + }) + + it('renders the front content when dismissed', async () => { + const handleChange = jest.fn() + + render( + <> + outside + + {frontside} + + + ) + + const outside = screen.getByText('outside') + outside.click() + await wait(1000) + + const front = screen.getByText(frontside) + const back = screen.queryByText(backside) + expect(front).toBeInTheDocument() + expect(back).not.toBeInTheDocument() + expect(handleChange).toHaveBeenCalled() + }) + + it('handles controlled state when open', async () => { + render( + + {frontside} + + ) + + const front = screen.getByText(frontside) + front.click() + await wait(1000) + + const back = screen.queryByText(backside) + expect(back).not.toBeInTheDocument() + }) +}) diff --git a/packages/core/src/Flashcard/Flashcard.stories.args.tsx b/packages/core/src/Flashcard/Flashcard.stories.args.tsx new file mode 100644 index 0000000000..7d59910598 --- /dev/null +++ b/packages/core/src/Flashcard/Flashcard.stories.args.tsx @@ -0,0 +1,30 @@ +import type { IFlashcardProps } from '..' +import { borderRadii, Grid, paletteColors, shadows } from '..' + +import type { ArgTypes } from '@storybook/react' +import React from 'react' +import { flashCardRotations } from './Flashcard.styled' + +export const argTypes: Partial> = { + backside: { table: { disable: true } }, + backsideBg: { control: { type: 'select' }, options: paletteColors }, + bg: { control: { type: 'select' }, options: paletteColors }, + borderRadius: { control: { type: 'select' }, options: Object.keys(borderRadii) }, + boxShadowSize: { control: { type: 'select' }, options: Object.keys(shadows) }, + children: { table: { disable: true } }, + duration: { control: { type: 'number' } }, + open: { control: { type: 'boolean' } }, + perspective: { control: { type: 'number' } }, + rotation: { control: { type: 'select' }, options: flashCardRotations }, +} + +export const defaultArgs: Partial = { + backside: Back, + backsideBg: 'secondary.light', + bg: 'primary.light', + borderRadius: 'xl', + children: Front, + duration: 0.5, + perspective: 200, + rotation: 'y', +} diff --git a/packages/core/src/Flashcard/Flashcard.stories.tsx b/packages/core/src/Flashcard/Flashcard.stories.tsx new file mode 100644 index 0000000000..eb1ace2884 --- /dev/null +++ b/packages/core/src/Flashcard/Flashcard.stories.tsx @@ -0,0 +1,197 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { ArrowLeft } from 'pcln-icons' +import React, { useState } from 'react' + +import type { IFlashcardProps, IGridProps } from '..' +import { Button, Flashcard, Grid, IconButton, Text } from '..' +import { argTypes, defaultArgs } from './Flashcard.stories.args' + +type FlashcardStory = StoryObj + +const ExampleImage = () => ( + an example +) + +export const Playground: FlashcardStory = { + render: (args) => ( + + , + + ), +} + +export const Multiple: FlashcardStory = { + render: (args) => ( + + + + + + + ), +} + +export const DifferentSizes: FlashcardStory = { + render: (args) => ( + + Back}> + Front + + + ), +} + +const GridCell = (props: IGridProps) => +export const ContentShift: FlashcardStory = { + render: (args) => ( + + Top Left + Top + Top Right + Left + + + Back +
+
+ Lorem ipsum dolor sit amet consectetur adipisicing elit. +
+ } + > + + Front + + +
+ Right + Bottom Left + Bottom + Bottom Right +
+ ), +} + +export const OverflowContent: FlashcardStory = { + ...Playground, + args: { + ...defaultArgs, + children: ( + + + Front + + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Dolore accusamus mollitia ipsa aliquid, + placeat sint consequatur inventore doloribus in culpa dolorum excepturi possimus ab. Deleniti + repellat quo aspernatur nihil. Maiores. + + + + ), + backside: ( + + + Back + + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Dolore accusamus mollitia ipsa aliquid, + placeat sint consequatur inventore doloribus in culpa dolorum excepturi possimus ab. Deleniti + repellat quo aspernatur nihil. Maiores. + + + + ), + }, +} + +export const Image: FlashcardStory = { + ...Playground, + args: { + ...defaultArgs, + perspective: 400, + children: ( + + + + ), + backside: ( + + + + + + Beach + + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Dolore accusamus mollitia ipsa aliquid, + placeat sint consequatur inventore doloribus in culpa dolorum excepturi possimus ab. Deleniti + repellat quo aspernatur nihil. Maiores. + + + ), + }, +} + +export const Controlled: FlashcardStory = { + render: (args) => { + const [open, setOpen] = useState(false) + + const flipButton = ( + } + style={{ position: 'absolute', top: '1rem', left: '1rem', border: '2px solid currentcolor' }} + onClick={() => setOpen(!open)} + borderRadius='full' + /> + ) + + return ( + + + {flipButton} Back }> + {flipButton} Front + + + ) + }, + argTypes: { open: { table: { disable: true } } }, + args: { perspective: 300 }, +} + +const insideProps = { width: 200, height: 200, placeItems: 'center', p: 3 } +const outsideProps = { width: 300, height: 300, placeItems: 'center', p: 3 } +export const Nested: FlashcardStory = { + render: (args) => ( + + Outside Back }> + + Outside Front + Inside Back }> + Inside Front + + + + + ), + args: { + ...defaultArgs, + boxShadowSize: 'md', + }, +} + +const meta: Meta = { + title: 'Flashcard', + component: Flashcard, + args: defaultArgs, + argTypes: argTypes, +} + +export default meta diff --git a/packages/core/src/Flashcard/Flashcard.styled.tsx b/packages/core/src/Flashcard/Flashcard.styled.tsx new file mode 100644 index 0000000000..a06b17f701 --- /dev/null +++ b/packages/core/src/Flashcard/Flashcard.styled.tsx @@ -0,0 +1,37 @@ +import type { IFlashcardProps } from '..' + +import themeGet from '@styled-system/theme-get' +import type { ForwardRefComponent, HTMLMotionProps, TargetAndTransition } from 'framer-motion' +import { motion } from 'framer-motion' +import styled from 'styled-components' + +export const flashCardRotations = ['x', 'y', 'x-reverse', 'y-reverse'] as const +export type FlashcardRotation = (typeof flashCardRotations)[number] + +export const flashcardRotations: Record = { + x: { rotateX: 180 }, + y: { rotateY: 180 }, + 'x-reverse': { rotateX: -180 }, + 'y-reverse': { rotateY: -180 }, + reset: { rotateX: 0, rotateY: 0 }, +} as const + +export type FlashcardMotionProps = Partial & HTMLMotionProps<'div'> + +export type FlashcardContainerProps = HTMLMotionProps<'div'> & + Partial> + +export const CardContainer: (props: FlashcardContainerProps) => JSX.Element = styled(motion.div)` + perspective: ${(props: FlashcardContainerProps) => props.perspective}px; +` + +export const OuterCardMotion: ForwardRefComponent = styled(motion.div)`` + +export const InnerCardMotion: ForwardRefComponent = styled(motion.div)` + background-color: ${(props: FlashcardMotionProps) => themeGet(`palette.${props.bg}`)(props)}; + border-radius: ${(props: FlashcardMotionProps) => themeGet(`borderRadii.${props.borderRadius}`)(props)}; + box-shadow: ${(props: FlashcardMotionProps) => themeGet(`shadows.${props.boxShadowSize}`)(props)}; + &:hover { + box-shadow: ${(props: FlashcardMotionProps) => themeGet(`shadows.2xl`)(props)}; + } +` diff --git a/packages/core/src/Flashcard/Flashcard.tsx b/packages/core/src/Flashcard/Flashcard.tsx new file mode 100644 index 0000000000..074688e230 --- /dev/null +++ b/packages/core/src/Flashcard/Flashcard.tsx @@ -0,0 +1,98 @@ +import type { BorderRadius, BoxShadowSize, PaletteColor } from '..' +import { + CardContainer, + FlashcardRotation, + InnerCardMotion, + OuterCardMotion, + flashcardRotations, +} from './Flashcard.styled' + +import React, { useEffect, useRef, useState } from 'react' + +export interface IFlashcardProps { + backside: React.ReactNode + backsideBg?: PaletteColor + bg?: PaletteColor + borderRadius?: BorderRadius + boxShadowSize?: BoxShadowSize + children: React.ReactNode + defaultOpen?: boolean + duration?: number + onOpenChange?: (open: boolean) => void + open?: boolean + perspective?: number | string + rotation?: FlashcardRotation +} + +const Flashcard = ({ + backside, + backsideBg, + bg, + borderRadius = 'xl', + boxShadowSize, + children, + defaultOpen = false, + duration = 0.5, + onOpenChange, + open, + perspective = 200, + rotation = 'y', +}: IFlashcardProps) => { + const [_open, setOpen] = useState(open ?? defaultOpen) + const [_bg, setBg] = useState(_open ? backsideBg : bg) + const [_children, setChildren] = useState(_open ? backside : children) + const ref = useRef(null) + + useEffect(() => setChildren(_open ? backside : children), [children, backside]) + useEffect(() => setBg(_open ? backsideBg : bg), [bg, backsideBg]) + + const handleOpenChange = (newOpen: boolean) => { + onOpenChange?.(newOpen) + setOpen(newOpen) + setTimeout(() => { + setChildren(newOpen ? backside : children) + setBg(newOpen ? backsideBg : bg) + }, (duration * 1000) / 2) + } + + useEffect(() => handleOpenChange(open), [open]) + + useEffect(() => { + if (open !== undefined) return + const handleOutsideClick = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target)) handleOpenChange(false) + } + document.addEventListener('mousedown', handleOutsideClick) + return () => document.removeEventListener('mousedown', handleOutsideClick) + }, [ref]) + + return ( + + + { + e.stopPropagation() + handleOpenChange(true) + } + : undefined + } + ref={ref} + transition={{ duration }} + > + {_children} + + + + ) +} + +export default Flashcard diff --git a/packages/core/src/Flashcard/index.ts b/packages/core/src/Flashcard/index.ts new file mode 100644 index 0000000000..6be4be21fe --- /dev/null +++ b/packages/core/src/Flashcard/index.ts @@ -0,0 +1,3 @@ +export type { IFlashcardProps } from './Flashcard' + +export { default as Flashcard } from './Flashcard' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 216dcb6cd4..88a607dc9d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ import * as storybookArgs from './storybook/args' export type { IButtonProps } from './Button' export type { IDialogProps } from './Dialog' +export type { IFlashcardProps } from './Flashcard' export type { IGridProps } from './Grid' export { Absolute } from './Absolute' @@ -24,6 +25,7 @@ export { Dialog } from './Dialog' export { Divider } from './Divider' export { DotLoader } from './DotLoader' export { Flag } from './Flag' +export { Flashcard } from './Flashcard' export { Flex } from './Flex' export { FormField, InputField } from './FormField' export { GenericBanner } from './GenericBanner' diff --git a/packages/core/src/theme/theme.ts b/packages/core/src/theme/theme.ts index 84e013d2ea..d6f05a34a6 100644 --- a/packages/core/src/theme/theme.ts +++ b/packages/core/src/theme/theme.ts @@ -312,6 +312,8 @@ export const shadows = { '0 -1px 0 0 rgba(0,0,0,0.03),0 24px 72px 0 rgba(0,0,0,0.48),0 8px 16px 0 rgba(0,0,0,0.12),0 24px 64px 0 rgba(0,0,0,0.2)', } +export type BoxShadowSize = keyof typeof shadows + export const textShadows = { sm: `0 1px 2px rgba(0,0,0,0.5)`, md: `0 2px 4px rgba(0,0,0,0.5)`,