Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add flashcard component #1382

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "pcln-design-system",
"comment": "add Flashcard component",
"type": "minor"
}
],
"packageName": "pcln-design-system"
}
69 changes: 69 additions & 0 deletions packages/core/src/Flashcard/Flashcard.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<Flashcard backside={backside}>{frontside}</Flashcard>)
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(<Flashcard backside={backside}>{frontside}</Flashcard>)

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(
<>
<span>outside</span>
<Flashcard defaultOpen backside={backside} onOpenChange={handleChange}>
{frontside}
</Flashcard>
</>
)

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(
<Flashcard backside={backside} open={false}>
{frontside}
</Flashcard>
)

const front = screen.getByText(frontside)
front.click()
await wait(1000)

const back = screen.queryByText(backside)
expect(back).not.toBeInTheDocument()
})
})
30 changes: 30 additions & 0 deletions packages/core/src/Flashcard/Flashcard.stories.args.tsx
Original file line number Diff line number Diff line change
@@ -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<ArgTypes<IFlashcardProps>> = {
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<IFlashcardProps> = {
backside: <Grid p={5}>Back</Grid>,
backsideBg: 'secondary.light',
bg: 'primary.light',
borderRadius: 'xl',
children: <Grid p={5}>Front</Grid>,
duration: 0.5,
perspective: 200,
rotation: 'y',
}
197 changes: 197 additions & 0 deletions packages/core/src/Flashcard/Flashcard.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<IFlashcardProps>

const ExampleImage = () => (
<img
src='https://s1.pclncdn.com/design-assets/hero/beach.jpg?opto&optimize=medium&auto=jpg&width=600&height=450&fit=crop'
alt='an example'
style={{ width: '100%', display: 'block' }}
/>
)

export const Playground: FlashcardStory = {
render: (args) => (
<Grid width='fit-content'>
<Flashcard {...args} />,
</Grid>
),
}

export const Multiple: FlashcardStory = {
render: (args) => (
<Grid gap={4} templateColumns='1fr 1fr' width='fit-content'>
<Flashcard {...args} />
<Flashcard {...args} />
<Flashcard {...args} />
<Flashcard {...args} />
</Grid>
),
}

export const DifferentSizes: FlashcardStory = {
render: (args) => (
<Grid width={200} height={200} placeItems='center'>
<Flashcard {...args} backside={<Grid p={4}>Back</Grid>}>
<Grid p={5}>Front</Grid>
</Flashcard>
</Grid>
),
}

const GridCell = (props: IGridProps) => <Grid placeItems='center' p={3} background='white' {...props} />
export const ContentShift: FlashcardStory = {
render: (args) => (
<Grid templateColumns='repeat(3, auto)' gap={2} background='black' border='8px solid'>
<GridCell>Top Left</GridCell>
<GridCell>Top</GridCell>
<GridCell>Top Right</GridCell>
<GridCell>Left</GridCell>
<Grid background='white'>
<Flashcard
{...args}
backside={
<Grid p={5} placeItems='center'>
Back
<br />
<br />
Lorem ipsum dolor sit amet consectetur adipisicing elit.
</Grid>
}
>
<Grid p={4} placeItems='center'>
Front
</Grid>
</Flashcard>
</Grid>
<GridCell>Right</GridCell>
<GridCell>Bottom Left</GridCell>
<GridCell>Bottom</GridCell>
<GridCell>Bottom Right</GridCell>
</Grid>
),
}

export const OverflowContent: FlashcardStory = {
...Playground,
args: {
...defaultArgs,
children: (
<Grid overflow='hidden' borderRadius={16}>
<Grid p={4} gap={3} overflow='auto' maxHeight={200} maxWidth={300}>
<Text textStyle='heading3'>Front</Text>
<Text textStyle='paragraph'>
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.
</Text>
</Grid>
</Grid>
),
backside: (
<Grid overflow='hidden' borderRadius={16}>
<Grid p={4} gap={3} overflow='auto' maxHeight={200} maxWidth={300}>
<Text textStyle='heading3'>Back</Text>
<Text textStyle='paragraph'>
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.
</Text>
</Grid>
</Grid>
),
},
}

export const Image: FlashcardStory = {
...Playground,
args: {
...defaultArgs,
perspective: 400,
children: (
<Grid width={400} height={300} placeItems='center' overflow='hidden' borderRadius={16}>
<ExampleImage />
</Grid>
),
backside: (
<Grid position='relative' width={400} height={300} p={5} gap={3} overflow='hidden' borderRadius={16}>
<Grid
position='absolute'
zIndex={-1}
style={{ inset: 0, filter: 'blur(.5rem)', transform: 'rotateY(180deg)' }}
>
<ExampleImage />
</Grid>
<Grid position='absolute' zIndex={-1} style={{ inset: 0, opacity: 0.75 }} background='white' />
<Text textStyle='heading3'>Beach</Text>
<Text textStyle='paragraph'>
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.
</Text>
</Grid>
),
},
}

export const Controlled: FlashcardStory = {
render: (args) => {
const [open, setOpen] = useState(false)

const flipButton = (
<IconButton
icon={<ArrowLeft />}
style={{ position: 'absolute', top: '1rem', left: '1rem', border: '2px solid currentcolor' }}
onClick={() => setOpen(!open)}
borderRadius='full'
/>
)

return (
<Grid gap={5} placeItems='center'>
<Button onClick={() => setOpen(!open)}>Toggle the Flashcard</Button>
<Flashcard {...args} open={open} backside={<Grid p={6}> {flipButton} Back </Grid>}>
<Grid p={6}> {flipButton} Front </Grid>
</Flashcard>
</Grid>
)
},
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) => (
<Grid width='fit-content'>
<Flashcard {...args} backside={<Grid {...outsideProps}> Outside Back </Grid>}>
<Grid gap={3} {...outsideProps}>
<Text>Outside Front</Text>
<Flashcard {...args} backside={<Grid {...insideProps}> Inside Back </Grid>}>
<Grid {...insideProps}>Inside Front</Grid>
</Flashcard>
</Grid>
</Flashcard>
</Grid>
),
args: {
...defaultArgs,
boxShadowSize: 'md',
},
}

const meta: Meta<typeof Flashcard> = {
title: 'Flashcard',
component: Flashcard,
args: defaultArgs,
argTypes: argTypes,
}

export default meta
37 changes: 37 additions & 0 deletions packages/core/src/Flashcard/Flashcard.styled.tsx
Original file line number Diff line number Diff line change
@@ -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<FlashcardRotation | 'reset', TargetAndTransition> = {
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<IFlashcardProps> & HTMLMotionProps<'div'>

export type FlashcardContainerProps = HTMLMotionProps<'div'> &
Partial<Omit<IFlashcardProps, 'borderRadius' | 'children'>>

export const CardContainer: (props: FlashcardContainerProps) => JSX.Element = styled(motion.div)`
perspective: ${(props: FlashcardContainerProps) => props.perspective}px;
`

export const OuterCardMotion: ForwardRefComponent<HTMLDivElement, FlashcardMotionProps> = styled(motion.div)``

export const InnerCardMotion: ForwardRefComponent<HTMLDivElement, FlashcardMotionProps> = 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)};
}
`
Loading