Skip to content

Commit

Permalink
Add controlled collapsible component (#83)
Browse files Browse the repository at this point in the history
* Add controlled collapsible component

* Fix stories

* Update component directory

* Update collapsible component

* Refactor Collapsible component to handle open state more efficiently
  • Loading branch information
yigiterdev authored Feb 29, 2024
1 parent 82cf50a commit f98fe4c
Show file tree
Hide file tree
Showing 3 changed files with 82 additions and 11 deletions.
27 changes: 27 additions & 0 deletions src/components/Collapsible/Collapsible.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { StoryObj, Meta } from '@storybook/react'
import { useEffect, useState } from 'react'

import { Text } from '~/components/Text'

Expand All @@ -11,6 +12,28 @@ export default {

type Story = StoryObj<typeof Collapsible>

const CollapsibleStory = () => {
const [isOpen, setIsOpen] = useState(false)

useEffect(() => {
setIsOpen(true)
}, [])

return (
<Collapsible
open={isOpen}
label="My Heading"
onOpenChange={open => setIsOpen(open)}
>
{[1, 2, 3, 4, 5].map(x => (
<Text variant="normal" as="p" color="text80" key={x}>
Item {x}
</Text>
))}
</Collapsible>
)
}

export const Default: Story = {
args: {
label: 'My Heading',
Expand All @@ -21,3 +44,7 @@ export const Default: Story = {
)),
},
}

export const Controlled: Story = {
render: () => <CollapsibleStory />,
}
25 changes: 25 additions & 0 deletions src/components/Collapsible/Collapsible.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import { cleanup, render, screen, fireEvent } from '@testing-library/react'
import { useState } from 'react'

import { Collapsible } from './Collapsible'

const TestComponent = () => {
const [isOpen, setIsOpen] = useState(false)

return (
<Collapsible
open={isOpen}
onOpenChange={open => setIsOpen(open)}
label="Hello"
>
World
</Collapsible>
)
}

describe('<Collapsible />', () => {
afterEach(cleanup)

Expand All @@ -15,6 +30,16 @@ describe('<Collapsible />', () => {
expect(screen.getByText(/World/)).toBeInTheDocument()
})

it('controlled', () => {
render(<TestComponent />)
expect(screen.getByText(/Hello/)).toBeInTheDocument()
expect(screen.queryByText(/World/)).toBeNull()

fireEvent.click(screen.getByRole('button'))

expect(screen.getByText(/World/)).toBeInTheDocument()
})

it('with default open', () => {
render(
<Collapsible label="Hello" defaultOpen>
Expand Down
41 changes: 30 additions & 11 deletions src/components/Collapsible/Collapsible.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,45 @@ type CollapsibleProps = BoxProps &
}

export const Collapsible = (props: CollapsibleProps) => {
const { className, children, defaultOpen, onOpenChange, label, ...rest } =
props
const {
className,
children,
defaultOpen,
open,
onOpenChange,
label,
...rest
} = props

const [expanded, toggleExpanded] = useState(defaultOpen)
const isOpen = open ?? expanded

const handleSetExpanded = (isExpanded: boolean) => {
if (open !== undefined) {
return
}

toggleExpanded(isExpanded)
}

const handleOpenChange = (isOpen: boolean) => {
handleSetExpanded(isOpen)

const handleOpenChange = (open: boolean) => {
toggleExpanded(open)
onOpenChange?.(open)
onOpenChange?.(isOpen)
}

return (
<CollapsiblePrimitive.Root
open={isOpen}
defaultOpen={defaultOpen}
onOpenChange={handleOpenChange}
asChild
>
<Box
as={motion.div}
className={clsx(className, styles.root)}
initial={{ height: defaultOpen ? 'auto' : styles.COLLAPSED_HEIGHT }}
animate={{ height: expanded ? 'auto' : styles.COLLAPSED_HEIGHT }}
initial={{ height: isOpen ? 'auto' : styles.COLLAPSED_HEIGHT }}
animate={{ height: isOpen ? 'auto' : styles.COLLAPSED_HEIGHT }}
transition={{ ease: 'easeOut', duration: 0.3 }}
borderRadius="md"
background="backgroundSecondary"
Expand All @@ -55,22 +74,22 @@ export const Collapsible = (props: CollapsibleProps) => {
position="absolute"
right="0"
marginRight="4"
initial={{ rotate: defaultOpen ? 180 : 0 }}
animate={{ rotate: expanded ? 180 : 0 }}
initial={{ rotate: isOpen ? 180 : 0 }}
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ ease: 'linear', duration: 0.1 }}
>
<ChevronDownIcon className={styles.icon} color="text50" />
</Box>
</CollapsiblePrimitive.Trigger>
<AnimatePresence>
{expanded && (
{isOpen && (
<CollapsiblePrimitive.Content
className={styles.content}
asChild
forceMount
>
<motion.div
initial={{ opacity: defaultOpen ? 1 : 0 }}
initial={{ opacity: isOpen ? 1 : 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ ease: 'easeOut', duration: 0.3 }}
Expand Down

0 comments on commit f98fe4c

Please sign in to comment.