Skip to content

Commit c1b946d

Browse files
feat: Accordion
1 parent 613b214 commit c1b946d

22 files changed

+1503
-43
lines changed

packages/vue-primitives/src/App.vue

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { computed, onMounted, shallowRef, watchSyncEffect } from 'vue'
44
// import Primitive from './primitive/Primitive.vue'
55
import { Toggle } from './toggle/index.ts'
66
import { ToggleGroup, ToggleGroupItem } from './toggle-group/index.ts'
7+
import Accordion from './accordion/Accordion.vue'
8+
import AccordionItem from './accordion/AccordionItem.vue'
9+
import { AccordionContent, AccordionHeader, AccordionTrigger } from './accordion/index.ts'
710
811
const open = shallowRef(true)
912
const dis = shallowRef(true)
@@ -31,11 +34,11 @@ function log(event: Event) {
3134
console.error('LOG')
3235
}
3336
34-
const a = shallowRef<any>()
37+
// const a = shallowRef<any>()
3538
36-
watchSyncEffect(() => {
37-
console.log('a:', a.value?.$el)
38-
})
39+
// watchSyncEffect(() => {
40+
// console.log('a:', a.value?.$el)
41+
// })
3942
4043
onMounted(() => {
4144
// console.log(a.value)
@@ -44,14 +47,39 @@ onMounted(() => {
4447

4548
<template>
4649
<div>
47-
<button @click="toggle">
50+
<div>
51+
<Accordion type="single">
52+
<AccordionItem value="ff">
53+
<AccordionHeader>
54+
<AccordionTrigger>
55+
1
56+
</AccordionTrigger>
57+
</AccordionHeader>
58+
<AccordionContent>
59+
ff
60+
</AccordionContent>
61+
</AccordionItem>
62+
<AccordionItem value="ggg">
63+
<AccordionHeader>
64+
<AccordionTrigger>
65+
2
66+
</AccordionTrigger>
67+
</AccordionHeader>
68+
<AccordionContent>
69+
gg
70+
</AccordionContent>
71+
</AccordionItem>
72+
</Accordion>
73+
</div>
74+
75+
<!-- <button @click="toggle">
4876
ON {{ open }}
4977
</button>
5078
<button @click="toggleDis">
5179
Dis {{ dis }}
52-
</button>
80+
</button> -->
5381

54-
<div>
82+
<!-- <div>
5583
<ToggleGroup type="single">
5684
<ToggleGroupItem value="1">
5785
1
@@ -69,7 +97,7 @@ onMounted(() => {
6997
</div>
7098
<div>
7199
<pre>{{ on }}</pre>
72-
</div>
100+
</div> -->
73101
</div>
74102
</template>
75103

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import type { AriaAttributes, Ref } from 'vue'
2+
import { createCollection } from '~/collection/index.ts'
3+
import { createContext } from '~/hooks/createContext.ts'
4+
import type { PrimitiveProps } from '~/primitive/index.ts'
5+
6+
export type AccordionType = 'single' | 'multiple'
7+
type Direction = 'ltr' | 'rtl'
8+
9+
export interface AccordionProps<T extends AccordionType> extends AccordionImplProps {
10+
type: T
11+
12+
value?: T extends 'single' ? AccordionSingleProps['value'] : AccordionMultipleProps['value']
13+
14+
defaultValue?: T extends 'single' ? AccordionSingleProps['defaultValue'] : AccordionMultipleProps['defaultValue']
15+
16+
collapsible?: AccordionSingleProps['collapsible']
17+
}
18+
19+
// eslint-disable-next-line ts/consistent-type-definitions
20+
export type AccordionEmits<T extends AccordionType> = {
21+
/**
22+
* The callback that fires when the state of the toggle group changes.
23+
*/
24+
'update:value': [value: T extends 'single' ? NonNullable<AccordionSingleProps['value']> : NonNullable<AccordionMultipleProps['value']>]
25+
}
26+
27+
interface AccordionSingleProps {
28+
/**
29+
* The controlled stateful value of the accordion item whose content is expanded.
30+
*/
31+
value?: string
32+
/**
33+
* The value of the item whose content is expanded when the accordion is initially rendered. Use
34+
* `defaultValue` if you do not need to control the state of an accordion.
35+
*/
36+
defaultValue?: string
37+
/**
38+
* Whether an accordion item can be collapsed after it has been opened.
39+
* @default false
40+
*/
41+
collapsible?: boolean
42+
}
43+
44+
interface AccordionMultipleProps {
45+
/**
46+
* The controlled stateful value of the accordion items whose contents are expanded.
47+
*/
48+
value?: string[]
49+
/**
50+
* The value of the items whose contents are expanded when the accordion is initially rendered. Use
51+
* `defaultValue` if you do not need to control the state of an accordion.
52+
*/
53+
defaultValue?: string[]
54+
}
55+
56+
export interface AccordionImplProps extends PrimitiveProps {
57+
/**
58+
* Whether or not an accordion is disabled from user interaction.
59+
*
60+
* @defaultValue false
61+
*/
62+
disabled?: boolean
63+
/**
64+
* The layout in which the Accordion operates.
65+
* @default vertical
66+
*/
67+
orientation?: AriaAttributes['aria-orientation']
68+
/**
69+
* The language read direction.
70+
*/
71+
dir?: Direction
72+
}
73+
74+
export const ACCORDION_KEYS = ['Home', 'End', 'ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight']
75+
76+
export const [Collection, useCollection] = createCollection<HTMLButtonElement, undefined>('Accordion')
77+
78+
export interface AccordionContext {
79+
collapsible: boolean
80+
81+
disabled: Ref<boolean>
82+
direction: Ref<AccordionImplProps['dir']>
83+
orientation: AccordionImplProps['orientation']
84+
85+
value: Ref<string[]>
86+
onItemOpen: (value: string) => void
87+
onItemClose: (value: string) => void
88+
}
89+
90+
export const [provideAccordionContext, useAccordionContext] = createContext<AccordionContext>('AccordionContext')
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
<script setup lang="ts" generic="T extends AccordionType">
2+
import { computed, shallowRef, toRef, useAttrs } from 'vue'
3+
import { ACCORDION_KEYS, type AccordionEmits, type AccordionProps, type AccordionType, Collection, provideAccordionContext, useCollection } from './Accordion.ts'
4+
import { useDirection } from '~/direction/Direction.ts'
5+
import { useControllableState } from '~/hooks/useControllableState.ts'
6+
import { Primitive } from '~/primitive/index.ts'
7+
import { composeEventHandlers } from '~/utils/composeEventHandlers.ts'
8+
import { arrayify } from '~/utils/array.ts'
9+
10+
defineOptions({
11+
name: 'Accordion',
12+
inheritAttrs: false,
13+
})
14+
15+
const props = withDefaults(defineProps<AccordionProps<T>>(), {
16+
disabled: false,
17+
orientation: 'vertical',
18+
collapsible: false,
19+
})
20+
const emit = defineEmits<AccordionEmits<T>>()
21+
const attrs = useAttrs()
22+
23+
const elRef = shallowRef<HTMLElement>()
24+
25+
const direction = useDirection(() => props.dir)
26+
27+
const value = useControllableState(props, emit, 'value', props.defaultValue)
28+
const TYPE_SINGLE = 'single' as const satisfies AccordionType
29+
type SingleValue = NonNullable<AccordionProps<'single'>['value']>
30+
type MultipleValue = NonNullable<AccordionProps<'multiple'>['value']>
31+
type Value = T extends 'single' ? SingleValue : MultipleValue
32+
33+
const collectionContext = Collection.provideCollectionContext(elRef)
34+
const getItems = useCollection(collectionContext)
35+
const handleKeydown = composeEventHandlers<KeyboardEvent>(() => {
36+
;(attrs.onKeydown as Function | undefined)?.()
37+
}, (event) => {
38+
if (!ACCORDION_KEYS.includes(event.key))
39+
return
40+
const target = event.target as HTMLElement
41+
const triggerCollection = getItems().filter(item => !item.ref?.disabled)
42+
const triggerIndex = triggerCollection.findIndex(item => item.ref === target)
43+
const triggerCount = triggerCollection.length
44+
45+
if (triggerIndex === -1)
46+
return
47+
48+
// Prevents page scroll while user is navigating
49+
event.preventDefault()
50+
51+
let nextIndex = triggerIndex
52+
const homeIndex = 0
53+
const endIndex = triggerCount - 1
54+
55+
const moveNext = () => {
56+
nextIndex = triggerIndex + 1
57+
if (nextIndex > endIndex) {
58+
nextIndex = homeIndex
59+
}
60+
}
61+
62+
const movePrev = () => {
63+
nextIndex = triggerIndex - 1
64+
if (nextIndex < homeIndex) {
65+
nextIndex = endIndex
66+
}
67+
}
68+
69+
switch (event.key) {
70+
case 'Home':
71+
nextIndex = homeIndex
72+
break
73+
case 'End':
74+
nextIndex = endIndex
75+
break
76+
case 'ArrowRight':
77+
if (props.orientation === 'horizontal') {
78+
if (direction.value === 'ltr') {
79+
moveNext()
80+
}
81+
else {
82+
movePrev()
83+
}
84+
}
85+
break
86+
case 'ArrowDown':
87+
if (props.orientation === 'vertical') {
88+
moveNext()
89+
}
90+
break
91+
case 'ArrowLeft':
92+
if (props.orientation === 'horizontal') {
93+
if (direction.value === 'ltr') {
94+
movePrev()
95+
}
96+
else {
97+
moveNext()
98+
}
99+
}
100+
break
101+
case 'ArrowUp':
102+
if (props.orientation === 'vertical') {
103+
movePrev()
104+
}
105+
break
106+
}
107+
108+
const clampedIndex = nextIndex % triggerCount
109+
triggerCollection[clampedIndex]?.ref?.focus()
110+
})
111+
112+
provideAccordionContext({
113+
collapsible: props.collapsible,
114+
115+
disabled: toRef(props, 'disabled'),
116+
direction,
117+
orientation: props.orientation,
118+
value: computed(() => {
119+
if (props.type === TYPE_SINGLE)
120+
return typeof value.value === 'string' ? [value.value] : []
121+
return Array.isArray(value.value) ? value.value : []
122+
}),
123+
onItemOpen(itemValue) {
124+
if (props.type === TYPE_SINGLE) {
125+
value.value = itemValue as Value
126+
}
127+
else {
128+
value.value = [...arrayify<SingleValue>(value.value || []), itemValue] as Value
129+
}
130+
},
131+
onItemClose(itemValue) {
132+
if (props.type === TYPE_SINGLE) {
133+
if (props.collapsible) {
134+
value.value = '' as Value
135+
}
136+
}
137+
else {
138+
value.value = arrayify<SingleValue>(value.value || []).filter(value => value !== itemValue) as Value
139+
}
140+
},
141+
})
142+
</script>
143+
144+
<template>
145+
<Primitive
146+
:ref="(el: any) => {
147+
const node = (el?.$el ?? el)
148+
elRef = node?.hasAttribute ? node : undefined
149+
}"
150+
:as="as"
151+
:as-child="asChild"
152+
v-bind="{
153+
...attrs,
154+
onKeydown(e: KeyboardEvent) {
155+
if (!props.disabled)
156+
handleKeydown(e)
157+
},
158+
}"
159+
:data-orientation="props.orientation"
160+
>
161+
<slot />
162+
</Primitive>
163+
</template>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import type { CollapsibleContentProps } from '~/collapsible/index.ts'
2+
3+
export interface AccordionContentProps extends CollapsibleContentProps {}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<script setup lang="ts">
2+
import { useAccordionContext } from './Accordion.ts'
3+
import type { AccordionContentProps } from './AccordionContent.ts'
4+
import { useAccordionItemContext } from './AccordionItem.ts'
5+
import { CollapsibleContent } from '~/collapsible/index.ts'
6+
7+
defineOptions({
8+
name: 'AccordionContent',
9+
})
10+
11+
withDefaults(defineProps<AccordionContentProps>(), {
12+
forceMount: undefined,
13+
})
14+
15+
const accordionContext = useAccordionContext()
16+
const itemContext = useAccordionItemContext()
17+
</script>
18+
19+
<template>
20+
<CollapsibleContent
21+
:as="as"
22+
:as-child="asChild"
23+
:force-mount="forceMount"
24+
role="region"
25+
:aria-labelledby="itemContext.triggerId"
26+
:data-orientation="accordionContext.orientation"
27+
style="--radix-accordion-content-height: var(--radix-collapsible-content-height); --radix-accordion-content-width: var(--radix-collapsible-content-width);"
28+
>
29+
<slot />
30+
</CollapsibleContent>
31+
</template>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import type { PrimitiveProps } from '~/primitive/index.ts'
2+
3+
export interface AccordionHeaderProps extends PrimitiveProps {}

0 commit comments

Comments
 (0)