Skip to content

Commit f555d13

Browse files
feat: Collapsible
1 parent 8c69655 commit f555d13

File tree

23 files changed

+994
-9
lines changed

23 files changed

+994
-9
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { Ref } from 'vue'
2+
import { createContext } from '~/hooks/createContext.ts'
3+
import type { PrimitiveProps } from '~/primitive'
4+
5+
export interface CollapsibleProps extends PrimitiveProps {
6+
defaultOpen?: boolean
7+
open?: boolean
8+
disabled?: boolean
9+
}
10+
11+
// eslint-disable-next-line ts/consistent-type-definitions
12+
export type CollapsibleEmits = {
13+
'update:open': [value: boolean]
14+
}
15+
16+
export interface CollapsibleContextValue {
17+
contentId: string
18+
disabled?: Ref<boolean>
19+
open: Ref<boolean>
20+
onOpenToggle: () => void
21+
}
22+
23+
export const [provideCollapsibleContext, useCollapsibleContext] = createContext<CollapsibleContextValue>('Collapsible')
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<script setup lang="ts">
2+
import { toRef } from 'vue'
3+
import { type CollapsibleEmits, type CollapsibleProps, provideCollapsibleContext } from './Collapsible.ts'
4+
import { getState } from './utils.ts'
5+
import { Primitive } from '~/primitive/index.ts'
6+
import { useControllableState } from '~/hooks/useControllableState.ts'
7+
import { useId } from '~/hooks/useId.ts'
8+
9+
defineOptions({
10+
name: 'Collapsible',
11+
})
12+
13+
const props = withDefaults(defineProps<CollapsibleProps>(), {
14+
open: undefined,
15+
defaultOpen: false,
16+
})
17+
const emit = defineEmits<CollapsibleEmits>()
18+
19+
const open = useControllableState(props, emit, 'open', props.defaultOpen)
20+
21+
provideCollapsibleContext({
22+
contentId: useId(),
23+
disabled: toRef(props, 'disabled'),
24+
open,
25+
onOpenToggle() {
26+
open.value = !open.value
27+
},
28+
})
29+
</script>
30+
31+
<template>
32+
<Primitive
33+
:as="as"
34+
:as-child="asChild"
35+
:data-state="getState(open)"
36+
:data-disabled="disabled ? '' : undefined"
37+
>
38+
<slot />
39+
</Primitive>
40+
</template>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { PrimitiveProps } from '~/primitive/index.ts'
2+
3+
export interface CollapsibleContentProps extends PrimitiveProps {
4+
/**
5+
* Used to force mounting when more control is needed. Useful when
6+
* controlling animation with Vue animation libraries.
7+
*/
8+
forceMount?: boolean
9+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<script setup lang="ts">
2+
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
3+
import type { CollapsibleContentProps } from './CollapsibleContent.ts'
4+
import { useCollapsibleContext } from './Collapsible.ts'
5+
import { getState } from './utils.ts'
6+
import Primitive from '~/primitive/Primitive.vue'
7+
import { usePresence } from '~/presence/usePresence.ts'
8+
9+
defineOptions({
10+
name: 'CollapsibleContent',
11+
})
12+
13+
const props = defineProps<CollapsibleContentProps>()
14+
const elRef = shallowRef<HTMLElement>()
15+
16+
const context = useCollapsibleContext()
17+
18+
const isPresent = usePresence(elRef, () => props.forceMount || context.open.value)
19+
20+
const width = shallowRef(0)
21+
const height = shallowRef(0)
22+
23+
// when opening we want it to immediately open to retrieve dimensions
24+
// when closing we delay `present` to retrieve dimensions before closing
25+
const isOpen = computed(() => context.open.value || isPresent.value)
26+
let isMountAnimationPrevented = isOpen.value
27+
let originalStyles: Record<string, string>
28+
29+
let rAf: number
30+
31+
onMounted(() => {
32+
rAf = requestAnimationFrame(() => {
33+
isMountAnimationPrevented = false
34+
})
35+
})
36+
37+
onBeforeUnmount(() => {
38+
cancelAnimationFrame(rAf)
39+
})
40+
41+
watch(
42+
() => [context.open.value, elRef.value],
43+
async () => {
44+
const node = elRef.value
45+
46+
if (context.open.value)
47+
await nextTick()
48+
49+
if (!node)
50+
return
51+
52+
const nodeStyle = node.style
53+
54+
originalStyles = originalStyles || {
55+
transitionDuration: nodeStyle.transitionDuration,
56+
animationName: nodeStyle.animationName,
57+
}
58+
59+
// block any animations/transitions so the element renders at its full dimensions
60+
nodeStyle.transitionDuration = '0s'
61+
nodeStyle.animationName = 'none'
62+
63+
// get width and height from full dimensions
64+
const rect = node.getBoundingClientRect()
65+
height.value = rect.height
66+
width.value = rect.width
67+
68+
// kick off any animations/transitions that were originally set up if it isn't the initial mount
69+
if (!isMountAnimationPrevented) {
70+
nodeStyle.transitionDuration = originalStyles.transitionDuration!
71+
nodeStyle.animationName = originalStyles.animationName!
72+
}
73+
},
74+
)
75+
</script>
76+
77+
<template>
78+
<Primitive
79+
:id="context.contentId"
80+
:ref="(el: any) => {
81+
elRef = (el?.$el ?? el) || undefined
82+
}"
83+
:as="as"
84+
:as-child="asChild"
85+
:data-state="getState(context.open.value)"
86+
:data-disabled="context.disabled?.value ? '' : undefined"
87+
:hidden="!isOpen"
88+
:style="{
89+
[`--oku-collapsible-content-height`]: `${height}px`,
90+
[`--oku-collapsible-content-width`]: `${width}px`,
91+
}"
92+
>
93+
<slot v-if="isOpen" />
94+
</Primitive>
95+
</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 CollapsibleTriggerProps extends PrimitiveProps {}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<script setup lang="ts">
2+
import { useAttrs } from 'vue'
3+
import type { CollapsibleTriggerProps } from './CollapsibleTrigger.ts'
4+
import { useCollapsibleContext } from './Collapsible.ts'
5+
import { getState } from './utils.ts'
6+
import Primitive from '~/primitive/Primitive.vue'
7+
import { composeEventHandlers } from '~/utils/composeEventHandlers.ts'
8+
9+
defineOptions({
10+
name: 'CollapsibleTrigger',
11+
inheritAttrs: false,
12+
})
13+
14+
withDefaults(defineProps<CollapsibleTriggerProps>(), {
15+
as: 'button',
16+
})
17+
const attrs = useAttrs()
18+
19+
const context = useCollapsibleContext()
20+
21+
const onClick = composeEventHandlers((e) => {
22+
(attrs.onClick as Function | undefined)?.(e)
23+
}, context.onOpenToggle)
24+
</script>
25+
26+
<template>
27+
<Primitive
28+
:as="as"
29+
:as-child="asChild"
30+
type="button"
31+
:aria-controls="context.contentId"
32+
:aria-expanded="context.open.value || false"
33+
:data-state="getState(context.open.value)"
34+
:data-disabled="context.disabled?.value ? '' : undefined"
35+
:disabled="context.disabled?.value"
36+
v-bind="{
37+
...attrs,
38+
onClick,
39+
}"
40+
>
41+
<slot />
42+
</Primitive>
43+
</template>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { default as Collapsible } from './Collapsible.vue'
2+
export { default as CollapsibleContent } from './CollapsibleContent.vue'
3+
export { default as CollapsibleTrigger } from './CollapsibleTrigger.vue'
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { defineComponent, shallowRef } from 'vue'
2+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../index.ts'
3+
import './styles.css'
4+
5+
export default { title: 'Components/Collapsible' }
6+
7+
export function Styled() {
8+
return (
9+
<Collapsible class="collapsible_root">
10+
<CollapsibleTrigger class="collapsible_trigger">Trigger</CollapsibleTrigger>
11+
<CollapsibleContent class="collapsible_content">Content 1</CollapsibleContent>
12+
</Collapsible>
13+
)
14+
}
15+
16+
export function Controlled() {
17+
return defineComponent({
18+
setup() {
19+
const open = shallowRef(false)
20+
function setOpen(value: boolean) {
21+
open.value = value
22+
}
23+
24+
return () => (
25+
<Collapsible open={open.value} onUpdate:open={setOpen} class="collapsible_root">
26+
<CollapsibleTrigger class="collapsible_trigger">
27+
{open.value ? 'close' : 'open'}
28+
</CollapsibleTrigger>
29+
<CollapsibleContent class="collapsible_content" asChild>
30+
<article>Content 1</article>
31+
</CollapsibleContent>
32+
</Collapsible>
33+
)
34+
},
35+
})
36+
}
37+
38+
export function Animated() {
39+
return (
40+
<>
41+
<h1>Closed by default</h1>
42+
<Collapsible class="collapsible_root">
43+
<CollapsibleTrigger class="collapsible_trigger">Trigger</CollapsibleTrigger>
44+
<CollapsibleContent class="collapsible_animatedContent">
45+
<div style={{ padding: '10px' }}>Content 1</div>
46+
</CollapsibleContent>
47+
</Collapsible>
48+
49+
<h1>Open by default</h1>
50+
<Collapsible defaultOpen class="collapsible_root">
51+
<CollapsibleTrigger class="collapsible_trigger">Trigger</CollapsibleTrigger>
52+
<CollapsibleContent class="collapsible_animatedContent">
53+
<div style={{ padding: '10px' }}>Content 1</div>
54+
</CollapsibleContent>
55+
</Collapsible>
56+
</>
57+
)
58+
}
59+
60+
export function AnimatedHorizontal() {
61+
return (
62+
<Collapsible class="collapsible_root">
63+
<CollapsibleTrigger class="collapsible_trigger">Trigger</CollapsibleTrigger>
64+
<CollapsibleContent class="collapsible_animatedWidthContent">
65+
<div style={{ padding: '10px' }}>Content</div>
66+
</CollapsibleContent>
67+
</Collapsible>
68+
)
69+
};
70+
71+
export function Chromatic() {
72+
return (
73+
<>
74+
<h1>Uncontrolled</h1>
75+
<h2>Closed</h2>
76+
<Collapsible class="collapsible_root">
77+
<CollapsibleTrigger class="collapsible_trigger">Trigger</CollapsibleTrigger>
78+
<CollapsibleContent class="collapsible_content">Content 1</CollapsibleContent>
79+
</Collapsible>
80+
81+
<h2>Open</h2>
82+
<Collapsible class="collapsible_root" defaultOpen>
83+
<CollapsibleTrigger class="collapsible_trigger">Trigger</CollapsibleTrigger>
84+
<CollapsibleContent class="collapsible_content">Content 1</CollapsibleContent>
85+
</Collapsible>
86+
87+
<h1>Controlled</h1>
88+
<h2>Closed</h2>
89+
<Collapsible class="collapsible_root" open={false}>
90+
<CollapsibleTrigger class="collapsible_trigger">Trigger</CollapsibleTrigger>
91+
<CollapsibleContent class="collapsible_content">Content 1</CollapsibleContent>
92+
</Collapsible>
93+
94+
<h2>Open</h2>
95+
<Collapsible class="collapsible_root" open>
96+
<CollapsibleTrigger class="collapsible_trigger">Trigger</CollapsibleTrigger>
97+
<CollapsibleContent class="collapsible_content">Content 1</CollapsibleContent>
98+
</Collapsible>
99+
100+
<h1>Disabled</h1>
101+
<Collapsible class="collapsible_root" disabled>
102+
<CollapsibleTrigger class="collapsible_trigger">Trigger</CollapsibleTrigger>
103+
<CollapsibleContent class="collapsible_content">Content 1</CollapsibleContent>
104+
</Collapsible>
105+
106+
<h1>State attributes</h1>
107+
<h2>Closed</h2>
108+
<Collapsible class="collapsible_rootAttr">
109+
<CollapsibleTrigger class="collapsible_triggerAttr">Trigger</CollapsibleTrigger>
110+
<CollapsibleContent class="collapsible_contentAttr">Content 1</CollapsibleContent>
111+
</Collapsible>
112+
113+
<h2>Open</h2>
114+
<Collapsible class="collapsible_rootAttr" defaultOpen>
115+
<CollapsibleTrigger class="collapsible_triggerAttr">Trigger</CollapsibleTrigger>
116+
<CollapsibleContent class="collapsible_contentAttr">Content 1</CollapsibleContent>
117+
</Collapsible>
118+
119+
<h2>Disabled</h2>
120+
<Collapsible class="collapsible_rootAttr" defaultOpen disabled>
121+
<CollapsibleTrigger class="collapsible_triggerAttr">Trigger</CollapsibleTrigger>
122+
<CollapsibleContent class="collapsible_contentAttr">Content 1</CollapsibleContent>
123+
</Collapsible>
124+
</>
125+
)
126+
}
127+
Chromatic.parameters = { chromatic: { disable: false } }

0 commit comments

Comments
 (0)