Skip to content

Commit

Permalink
feat(accordion): accordion item header
Browse files Browse the repository at this point in the history
  • Loading branch information
Powerplex committed Jun 17, 2024
1 parent 72d42b2 commit 20c248f
Show file tree
Hide file tree
Showing 7 changed files with 189 additions and 25 deletions.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion packages/components/accordion/src/Accordion.doc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,14 @@ import { Accordion } from '@spark-ui/accordion'

### Default

A `Accordion` is closed by default. Interacting with its trigger will open the associated content.
An `Accordion` is closed by default. Interacting with its trigger will open the associated content.

Important, as per ARIA specifications:

> **Each accordion header button is wrapped in an element with role [heading](https://w3c.github.io/aria/#heading) that has a value set for [aria-level](https://w3c.github.io/aria/#aria-level) that is appropriate for the information architecture of the page.**
For this, you must wrap each `Accordion.ItemTrigger` with an `Accordion.ItemHeader` (`h3` by default, use `asChild` property of `ItemHeader` to set the heading level that matches your page structure).
For example, in the context of this storybook page, we use `h4` because the accordion is preceded by an `h3` already.

<Canvas of={stories.Default} />

Expand Down
58 changes: 35 additions & 23 deletions packages/components/accordion/src/Accordion.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ export default meta
export const Default: StoryFn = () => {
return (
<Accordion>
<Accordion.Item value="a">
<Accordion.ItemTrigger>Watercraft</Accordion.ItemTrigger>
<Accordion.Item value="watercraft">
<Accordion.ItemHeader asChild>
<h4>
<Accordion.ItemTrigger>Watercraft</Accordion.ItemTrigger>
</h4>
</Accordion.ItemHeader>
<Accordion.ItemContent>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
Expand All @@ -24,8 +28,12 @@ export const Default: StoryFn = () => {
</Accordion.ItemContent>
</Accordion.Item>

<Accordion.Item value="b">
<Accordion.ItemTrigger>Automobiles</Accordion.ItemTrigger>
<Accordion.Item value="automobiles">
<Accordion.ItemHeader asChild>
<h4>
<Accordion.ItemTrigger>Automobiles</Accordion.ItemTrigger>
</h4>
</Accordion.ItemHeader>
<Accordion.ItemContent>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
Expand All @@ -34,8 +42,12 @@ export const Default: StoryFn = () => {
</Accordion.ItemContent>
</Accordion.Item>

<Accordion.Item value="c">
<Accordion.ItemTrigger>Aircrafts</Accordion.ItemTrigger>
<Accordion.Item value="aircrafts">
<Accordion.ItemHeader asChild>
<h4>
<Accordion.ItemTrigger>Aircrafts</Accordion.ItemTrigger>
</h4>
</Accordion.ItemHeader>
<Accordion.ItemContent>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
Expand All @@ -50,7 +62,7 @@ export const Default: StoryFn = () => {
export const Disabled: StoryFn = () => {
return (
<Accordion disabled>
<Accordion.Item value="a">
<Accordion.Item value="watercraft">
<Accordion.ItemTrigger>Watercraft</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>
Expand All @@ -60,7 +72,7 @@ export const Disabled: StoryFn = () => {
</Accordion.ItemContent>
</Accordion.Item>

<Accordion.Item value="b">
<Accordion.Item value="automobiles">
<Accordion.ItemTrigger>Automobiles</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>
Expand All @@ -70,7 +82,7 @@ export const Disabled: StoryFn = () => {
</Accordion.ItemContent>
</Accordion.Item>

<Accordion.Item value="c">
<Accordion.Item value="aircrafts">
<Accordion.ItemTrigger>Aircrafts</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>
Expand All @@ -86,7 +98,7 @@ export const Disabled: StoryFn = () => {
export const DisabledItem: StoryFn = () => {
return (
<Accordion>
<Accordion.Item value="a">
<Accordion.Item value="watercraft">
<Accordion.ItemTrigger>Watercraft</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>
Expand All @@ -96,7 +108,7 @@ export const DisabledItem: StoryFn = () => {
</Accordion.ItemContent>
</Accordion.Item>

<Accordion.Item value="b" disabled>
<Accordion.Item value="automobiles" disabled>
<Accordion.ItemTrigger>Automobiles</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>
Expand All @@ -106,7 +118,7 @@ export const DisabledItem: StoryFn = () => {
</Accordion.ItemContent>
</Accordion.Item>

<Accordion.Item value="c">
<Accordion.Item value="aircrafts">
<Accordion.ItemTrigger>Aircrafts</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>
Expand All @@ -121,8 +133,8 @@ export const DisabledItem: StoryFn = () => {

export const Multiple: StoryFn = () => {
return (
<Accordion multiple defaultValue={['b', 'c']}>
<Accordion.Item value="a">
<Accordion multiple defaultValue={['automobiles', 'aircrafts']}>
<Accordion.Item value="watercraft">
<Accordion.ItemTrigger>Watercraft</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>
Expand All @@ -132,7 +144,7 @@ export const Multiple: StoryFn = () => {
</Accordion.ItemContent>
</Accordion.Item>

<Accordion.Item value="b">
<Accordion.Item value="automobiles">
<Accordion.ItemTrigger>Automobiles</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>
Expand All @@ -142,7 +154,7 @@ export const Multiple: StoryFn = () => {
</Accordion.ItemContent>
</Accordion.Item>

<Accordion.Item value="c">
<Accordion.Item value="aircrafts">
<Accordion.ItemTrigger>Aircrafts</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>
Expand All @@ -156,7 +168,7 @@ export const Multiple: StoryFn = () => {
}

export const Controlled: StoryFn = () => {
const [value, setValue] = useState(['b', 'c'])
const [value, setValue] = useState(['automobiles', 'aircrafts'])

return (
<div>
Expand All @@ -166,13 +178,13 @@ export const Controlled: StoryFn = () => {
onCheckedChange={setValue}
className="mb-lg"
>
<Checkbox value="a">Watercraft</Checkbox>
<Checkbox value="b">Automobiles</Checkbox>
<Checkbox value="c">Aircrafts</Checkbox>
<Checkbox value="watercraft">Watercraft</Checkbox>
<Checkbox value="automobiles">Automobiles</Checkbox>
<Checkbox value="aircrafts">Aircrafts</Checkbox>
</CheckboxGroup>

<Accordion multiple value={value} onValueChange={setValue}>
<Accordion.Item value="a">
<Accordion.Item value="watercraft">
<Accordion.ItemTrigger>Watercraft</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>
Expand All @@ -182,7 +194,7 @@ export const Controlled: StoryFn = () => {
</Accordion.ItemContent>
</Accordion.Item>

<Accordion.Item value="b">
<Accordion.Item value="automobiles">
<Accordion.ItemTrigger>Automobiles</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>
Expand All @@ -192,7 +204,7 @@ export const Controlled: StoryFn = () => {
</Accordion.ItemContent>
</Accordion.Item>

<Accordion.Item value="c">
<Accordion.Item value="aircrafts">
<Accordion.ItemTrigger>Aircrafts</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>
Expand Down
114 changes: 114 additions & 0 deletions packages/components/accordion/src/Accordion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,118 @@ describe('Accordion', () => {
expect(screen.getByText('Second panel')).toBeVisible()
})
})

it('should open the accordion content when clicking on the trigger (multiple)', async () => {
const user = userEvent.setup()

// Given an accordion in closed state and multiple mode enabled
render(
<Accordion multiple>
<Accordion.Item value="a">
<Accordion.ItemTrigger>First trigger</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>First panel</p>
</Accordion.ItemContent>
</Accordion.Item>

<Accordion.Item value="b">
<Accordion.ItemTrigger>Second trigger</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>Second panel</p>
</Accordion.ItemContent>
</Accordion.Item>
</Accordion>
)

// Then both panels are closed
expect(screen.getByText('First panel')).not.toBeVisible()
expect(screen.getByText('Second panel')).not.toBeVisible()

await user.click(screen.getByRole('button', { name: 'First trigger' }))

// Then first panel has been opened
expect(screen.getByText('First panel')).toBeVisible()
expect(screen.getByText('Second panel')).not.toBeVisible()

await user.click(screen.getByRole('button', { name: 'Second trigger' }))

// Then second panel is opened and the first one remains opened
await waitFor(() => {
expect(screen.getByText('First panel')).toBeVisible()
expect(screen.getByText('Second panel')).toBeVisible()
})
})

it('should not open content on click when disabled', async () => {
const user = userEvent.setup()

// Given a disabled accordion
render(
<Accordion disabled>
<Accordion.Item value="a">
<Accordion.ItemTrigger>First trigger</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>First panel</p>
</Accordion.ItemContent>
</Accordion.Item>

<Accordion.Item value="b">
<Accordion.ItemTrigger>Second trigger</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>Second panel</p>
</Accordion.ItemContent>
</Accordion.Item>
</Accordion>
)

// Then both panels are closed
expect(screen.getByText('First panel')).not.toBeVisible()
expect(screen.getByText('Second panel')).not.toBeVisible()

await user.click(screen.getByRole('button', { name: 'First trigger' }))

// Then the panel remain closed
expect(screen.getByText('First panel')).not.toBeVisible()
expect(screen.getByText('Second panel')).not.toBeVisible()
})

it('should not open content on click when specific item is disabled', async () => {
const user = userEvent.setup()

// Given an accordion whose second item is disabled
render(
<Accordion>
<Accordion.Item value="a">
<Accordion.ItemTrigger>First trigger</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>First panel</p>
</Accordion.ItemContent>
</Accordion.Item>

<Accordion.Item value="b" disabled>
<Accordion.ItemTrigger>Second trigger</Accordion.ItemTrigger>
<Accordion.ItemContent>
<p>Second panel</p>
</Accordion.ItemContent>
</Accordion.Item>
</Accordion>
)

// Then both panels are closed
expect(screen.getByText('First panel')).not.toBeVisible()
expect(screen.getByText('Second panel')).not.toBeVisible()

await user.click(screen.getByRole('button', { name: 'First trigger' }))

// Then first panel has been opened
expect(screen.getByText('First panel')).toBeVisible()
expect(screen.getByText('Second panel')).not.toBeVisible()

// When the used clicks on the disabled item trigger
await user.click(screen.getByRole('button', { name: 'Second trigger' }))

// Then first panel remain opened because second panel was disabled
expect(screen.getByText('First panel')).toBeVisible()
expect(screen.getByText('Second panel')).not.toBeVisible()
})
})
2 changes: 1 addition & 1 deletion packages/components/accordion/src/Accordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ type ExtentedZagInterface = Omit<
accordion.Context,
'id' | 'ids' | 'orientation' | 'getRootNode' | 'onValueChange'
> &
ComponentPropsWithoutRef<'div'>
Omit<ComponentPropsWithoutRef<'div'>, 'defaultChecked'>

export interface AccordionProps extends ExtentedZagInterface {
/**
Expand Down
25 changes: 25 additions & 0 deletions packages/components/accordion/src/AccordionItemHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Slot } from '@spark-ui/slot'
import { cx } from 'class-variance-authority'
import { type ComponentProps, forwardRef } from 'react'

export interface AccordionItemItemHeaderProps extends ComponentProps<'h3'> {
asChild?: boolean
}

export const ItemHeader = forwardRef<HTMLHeadingElement, AccordionItemItemHeaderProps>(
({ asChild = false, children, className }, ref) => {
const Component = asChild ? Slot : 'h3'

return (
<Component
ref={ref}
data-spark-component="accordion-item-header"
className={cx('rounded-[inherit]', className)}
>
{children}
</Component>
)
}
)

ItemHeader.displayName = 'Accordion.ItemHeader'
5 changes: 5 additions & 0 deletions packages/components/accordion/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,28 @@ import type { FC } from 'react'
import { Accordion as Root, type AccordionProps } from './Accordion'
import { Item } from './AccordionItem'
import { ItemContent } from './AccordionItemContent'
import { ItemHeader } from './AccordionItemHeader'
import { ItemTrigger } from './AccordionItemTrigger'

export const Accordion: FC<AccordionProps> & {
Item: typeof Item
ItemHeader: typeof ItemHeader
ItemTrigger: typeof ItemTrigger
ItemContent: typeof ItemContent
} = Object.assign(Root, {
Item,
ItemHeader,
ItemTrigger,
ItemContent,
})

Accordion.displayName = 'Accordion'
Item.displayName = 'Item'
ItemHeader.displayName = 'ItemHeader'
ItemTrigger.displayName = 'Accordion.Trigger'
ItemContent.displayName = 'Accordion.Content'

export { type AccordionProps } from './Accordion'
export { type AccordionItemHeaderProps } from './AccordionItemHeader'
export { type AccordionItemContentProps } from './AccordionItemContent'
export { type AccordionItemTriggerProps } from './AccordionItemTrigger'

0 comments on commit 20c248f

Please sign in to comment.