Accordion
A stacked set of collapsible panels.
Demo
Related Components
This requires the following components to be installed:
Related Utils
This requires the following utils to be installed:
Related Types
This requires the following types to be installed:
Related Theme
This requires the following theme to be installed:
Component
vue
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import type { DynamicSlots } from '@/ui/utils/utils'
import type { AccordionRootEmits, AccordionRootProps } from 'reka-ui'
import Icon from '@/ui/components/Icon.vue'
import theme from '@/ui/theme/accordion'
import { get } from '@/ui/utils/get'
import { reactivePick } from '@vueuse/shared'
import { AccordionContent, AccordionHeader, AccordionRoot, AccordionTrigger, AccordionItem as RekaAccordionItem, useForwardPropsEmits } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed } from 'vue'
const accordion = tv(theme)
export interface AccordionItem {
label?: string
icon?: string
trailingIcon?: string
slot?: string
content?: string
value?: string
disabled?: boolean
[key: string]: any
}
export interface AccordionProps<T extends AccordionItem = AccordionItem> extends Pick<AccordionRootProps, 'collapsible' | 'defaultValue' | 'modelValue' | 'type' | 'disabled' | 'unmountOnHide'> {
as?: any
items?: T[]
trailingIcon?: string
labelKey?: string
class?: any
ui?: Partial<typeof accordion.slots>
}
export interface AccordionEmits extends AccordionRootEmits {}
type SlotProps<T extends AccordionItem> = (props: { item: T, index: number, open: boolean }) => any
export type AccordionSlots<T extends AccordionItem = AccordionItem> = {
leading: SlotProps<T>
default: SlotProps<T>
trailing: SlotProps<T>
content: SlotProps<T>
body: SlotProps<T>
} & DynamicSlots<T, 'body', { index: number, open: boolean }>
</script>
<script setup lang="ts" generic="T extends AccordionItem">
const props = withDefaults(defineProps<AccordionProps<T>>(), {
type: 'single',
collapsible: true,
unmountOnHide: true,
labelKey: 'label',
})
const emits = defineEmits<AccordionEmits>()
const slots = defineSlots<AccordionSlots<T>>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'collapsible', 'defaultValue', 'disabled', 'modelValue', 'type', 'unmountOnHide'), emits)
const ui = computed(() => accordion({
disabled: props.disabled,
}))
</script>
<template>
<AccordionRoot v-bind="rootProps" :class="ui.root({ class: [props.class, props.ui?.root] })">
<RekaAccordionItem
v-for="(item, index) in props.items"
v-slot="{ open }"
:key="index"
:value="item.value || String(index)"
:disabled="item.disabled"
:class="ui.item({ class: props.ui?.item })"
>
<AccordionHeader :class="ui.header({ class: props.ui?.header })">
<AccordionTrigger :class="ui.trigger({ class: props.ui?.trigger, disabled: item.disabled })">
<slot name="leading" :item="item" :index="index" :open="open">
<Icon v-if="item.icon" :name="item.icon" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
</slot>
<span v-if="get(item, props.labelKey as string) || !!slots.default" :class="ui.label({ class: props.ui?.label })">
<slot :item="item" :index="index" :open="open">{{ get(item, props.labelKey as string) }}</slot>
</span>
<slot name="trailing" :item="item" :index="index" :open="open">
<Icon v-if="item.trailingIcon || trailingIcon" :name="item.trailingIcon || trailingIcon" :class=" ui.trailingIcon({ class: props.ui?.trailingIcon })" />
</slot>
</AccordionTrigger>
</AccordionHeader>
<AccordionContent v-if="item.content || !!slots.content || (item.slot && !!slots[item.slot as keyof AccordionSlots<T>]) || !!slots.body || (item.slot && !!slots[`${item.slot}-body` as keyof AccordionSlots<T>])" :class="ui.content({ class: props.ui?.content })">
<slot :name="((item.slot || 'content') as keyof AccordionSlots<T>)" :item="item" :index="index" :open="open">
<div :class="ui.body({ class: props.ui?.body })">
<slot :name="((item.slot ? `${item.slot}-body` : 'body') as keyof AccordionSlots<T>)" :item="item" :index="index" :open="open">
{{ item.content }}
</slot>
</div>
</slot>
</AccordionContent>
</RekaAccordionItem>
</AccordionRoot>
</template>vue
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import type { DynamicSlots } from '@/UI/Utils/utils'
import type { AccordionRootEmits, AccordionRootProps } from 'reka-ui'
import Icon from '@/UI/Components/Icon.vue'
import theme from '@/UI/Theme/accordion'
import { get } from '@/UI/Utils/get'
import { reactivePick } from '@vueuse/shared'
import { AccordionContent, AccordionHeader, AccordionRoot, AccordionTrigger, AccordionItem as RekaAccordionItem, useForwardPropsEmits } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed } from 'vue'
const accordion = tv(theme)
export interface AccordionItem {
label?: string
icon?: string
trailingIcon?: string
slot?: string
content?: string
value?: string
disabled?: boolean
[key: string]: any
}
export interface AccordionProps<T extends AccordionItem = AccordionItem> extends Pick<AccordionRootProps, 'collapsible' | 'defaultValue' | 'modelValue' | 'type' | 'disabled' | 'unmountOnHide'> {
as?: any
items?: T[]
trailingIcon?: string
labelKey?: string
class?: any
ui?: Partial<typeof accordion.slots>
}
export interface AccordionEmits extends AccordionRootEmits {}
type SlotProps<T extends AccordionItem> = (props: { item: T, index: number, open: boolean }) => any
export type AccordionSlots<T extends AccordionItem = AccordionItem> = {
leading: SlotProps<T>
default: SlotProps<T>
trailing: SlotProps<T>
content: SlotProps<T>
body: SlotProps<T>
} & DynamicSlots<T, 'body', { index: number, open: boolean }>
</script>
<script setup lang="ts" generic="T extends AccordionItem">
const props = withDefaults(defineProps<AccordionProps<T>>(), {
type: 'single',
collapsible: true,
unmountOnHide: true,
labelKey: 'label',
})
const emits = defineEmits<AccordionEmits>()
const slots = defineSlots<AccordionSlots<T>>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'collapsible', 'defaultValue', 'disabled', 'modelValue', 'type', 'unmountOnHide'), emits)
const ui = computed(() => accordion({
disabled: props.disabled,
}))
</script>
<template>
<AccordionRoot v-bind="rootProps" :class="ui.root({ class: [props.class, props.ui?.root] })">
<RekaAccordionItem
v-for="(item, index) in props.items"
v-slot="{ open }"
:key="index"
:value="item.value || String(index)"
:disabled="item.disabled"
:class="ui.item({ class: props.ui?.item })"
>
<AccordionHeader :class="ui.header({ class: props.ui?.header })">
<AccordionTrigger :class="ui.trigger({ class: props.ui?.trigger, disabled: item.disabled })">
<slot name="leading" :item="item" :index="index" :open="open">
<Icon v-if="item.icon" :name="item.icon" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
</slot>
<span v-if="get(item, props.labelKey as string) || !!slots.default" :class="ui.label({ class: props.ui?.label })">
<slot :item="item" :index="index" :open="open">{{ get(item, props.labelKey as string) }}</slot>
</span>
<slot name="trailing" :item="item" :index="index" :open="open">
<Icon v-if="item.trailingIcon || trailingIcon" :name="item.trailingIcon || trailingIcon" :class=" ui.trailingIcon({ class: props.ui?.trailingIcon })" />
</slot>
</AccordionTrigger>
</AccordionHeader>
<AccordionContent v-if="item.content || !!slots.content || (item.slot && !!slots[item.slot as keyof AccordionSlots<T>]) || !!slots.body || (item.slot && !!slots[`${item.slot}-body` as keyof AccordionSlots<T>])" :class="ui.content({ class: props.ui?.content })">
<slot :name="((item.slot || 'content') as keyof AccordionSlots<T>)" :item="item" :index="index" :open="open">
<div :class="ui.body({ class: props.ui?.body })">
<slot :name="((item.slot ? `${item.slot}-body` : 'body') as keyof AccordionSlots<T>)" :item="item" :index="index" :open="open">
{{ item.content }}
</slot>
</div>
</slot>
</AccordionContent>
</RekaAccordionItem>
</AccordionRoot>
</template>Theme
ts
export default {
slots: {
root: '',
item: '',
header: '',
trigger: '',
content: '',
body: '',
leadingIcon: '',
trailingIcon: '',
label: '',
},
variants: {
disabled: {
true: {
trigger: '',
},
},
},
}View Nuxt UI theme
ts
export default {
slots: {
root: 'w-full',
item: 'border-b border-default last:border-b-0',
header: 'flex',
trigger: 'group flex-1 flex items-center gap-1.5 font-medium text-sm py-3.5 focus-visible:outline-primary min-w-0',
content: 'data-[state=open]:animate-[accordion-down_200ms_ease-out] data-[state=closed]:animate-[accordion-up_200ms_ease-out] overflow-hidden focus:outline-none',
body: 'text-sm pb-3.5',
leadingIcon: 'shrink-0 size-5',
trailingIcon: 'shrink-0 size-5 ms-auto group-data-[state=open]:rotate-180 transition-transform duration-200',
label: 'text-start break-words',
},
variants: {
disabled: {
true: {
trigger: 'cursor-not-allowed opacity-75',
},
},
},
}Test
To test this component, you can use the following test file:
ts
import type { RenderOptions } from '@testing-library/vue'
import Accordion from '@/ui/components/Accordion.vue'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
describe('accordion', () => {
const items = [{
label: 'Getting Started',
icon: 'i-lucide-info',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.',
}, {
label: 'Installation',
icon: 'i-lucide-download',
disabled: true,
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.',
}, {
label: 'Theming',
icon: 'i-lucide-pipette',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.',
}, {
label: 'Layouts',
icon: 'i-lucide-layout-dashboard',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.',
}, {
label: 'Components',
icon: 'i-lucide-layers-3',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.',
}, {
label: 'Utilities',
icon: 'i-lucide-wrench',
trailingIcon: 'i-lucide-sun',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.',
slot: 'custom',
}]
const props = { items }
it.each<[string, RenderOptions<typeof Accordion>]>([
// Props
['with items', { props }],
['with modelValue', { props: { ...props, modelValue: '1' } }],
['with defaultValue', { props: { ...props, defaultValue: '1' } }],
['with as', { props: { ...props, as: 'section' } }],
['with type', { props: { ...props, type: 'multiple' as const } }],
['with disabled', { props: { ...props, disabled: true } }],
['with collapsible', { props: { ...props, collapsible: false } }],
['with unmountOnHide', { props: { ...props, unmountOnHide: false } }],
['with trailingIcon', { props: { ...props, trailingIcon: 'i-lucide-plus' } }],
['with as', { props: { ...props, as: 'section' } }],
['with class', { props: { ...props, class: 'w-96' } }],
['with ui', { props: { ...props, ui: { item: 'border-(--ui-border-accented)' } } }],
// Slots
['with leading slot', { props: { ...props, modelValue: '1' }, slots: { leading: () => 'Leading slot' } }],
['with default slot', { props: { ...props, modelValue: '1' }, slots: { default: () => 'Default slot' } }],
['with trailing slot', { props: { ...props, modelValue: '1' }, slots: { trailing: () => 'Trailing slot' } }],
['with content slot', { props: { ...props, modelValue: '1' }, slots: { content: () => 'Content slot' } }],
['with body slot', { props: { ...props, modelValue: '1' }, slots: { body: () => 'Body slot' } }],
['with custom slot', { props: { ...props, modelValue: '5' }, slots: { custom: () => 'Custom slot' } }],
['with custom body slot', { props: { ...props, modelValue: '5' }, slots: { 'custom-body': () => 'Custom body slot' } }],
])('render %s correctly', (name, options) => {
render(Accordion, {
attrs: {
'data-testid': 'accordion',
},
...options,
})
expect(screen.getByTestId('accordion')).toMatchSnapshot()
})
})ts
import type { RenderOptions } from '@testing-library/vue'
import Accordion from '@/UI/Components/Accordion.vue'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
describe('accordion', () => {
const items = [{
label: 'Getting Started',
icon: 'i-lucide-info',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.',
}, {
label: 'Installation',
icon: 'i-lucide-download',
disabled: true,
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.',
}, {
label: 'Theming',
icon: 'i-lucide-pipette',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.',
}, {
label: 'Layouts',
icon: 'i-lucide-layout-dashboard',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.',
}, {
label: 'Components',
icon: 'i-lucide-layers-3',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.',
}, {
label: 'Utilities',
icon: 'i-lucide-wrench',
trailingIcon: 'i-lucide-sun',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.',
slot: 'custom',
}]
const props = { items }
it.each<[string, RenderOptions<typeof Accordion>]>([
// Props
['with items', { props }],
['with modelValue', { props: { ...props, modelValue: '1' } }],
['with defaultValue', { props: { ...props, defaultValue: '1' } }],
['with as', { props: { ...props, as: 'section' } }],
['with type', { props: { ...props, type: 'multiple' as const } }],
['with disabled', { props: { ...props, disabled: true } }],
['with collapsible', { props: { ...props, collapsible: false } }],
['with unmountOnHide', { props: { ...props, unmountOnHide: false } }],
['with trailingIcon', { props: { ...props, trailingIcon: 'i-lucide-plus' } }],
['with as', { props: { ...props, as: 'section' } }],
['with class', { props: { ...props, class: 'w-96' } }],
['with ui', { props: { ...props, ui: { item: 'border-(--ui-border-accented)' } } }],
// Slots
['with leading slot', { props: { ...props, modelValue: '1' }, slots: { leading: () => 'Leading slot' } }],
['with default slot', { props: { ...props, modelValue: '1' }, slots: { default: () => 'Default slot' } }],
['with trailing slot', { props: { ...props, modelValue: '1' }, slots: { trailing: () => 'Trailing slot' } }],
['with content slot', { props: { ...props, modelValue: '1' }, slots: { content: () => 'Content slot' } }],
['with body slot', { props: { ...props, modelValue: '1' }, slots: { body: () => 'Body slot' } }],
['with custom slot', { props: { ...props, modelValue: '5' }, slots: { custom: () => 'Custom slot' } }],
['with custom body slot', { props: { ...props, modelValue: '5' }, slots: { 'custom-body': () => 'Custom body slot' } }],
])('render %s correctly', (name, options) => {
render(Accordion, {
attrs: {
'data-testid': 'accordion',
},
...options,
})
expect(screen.getByTestId('accordion')).toMatchSnapshot()
})
})