Skip to content

Commit 8594375

Browse files
teleskop150750productdevbook
authored andcommitted
feat: Collapsible
1 parent f8786a7 commit 8594375

File tree

10 files changed

+172
-77
lines changed

10 files changed

+172
-77
lines changed

packages/vue-primitives/src/collapsible/CollapsibleContent.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { computed, type CSSProperties, nextTick, onMounted, type Ref, shallowRef } from 'vue'
1+
import { type CSSProperties, nextTick, onMounted, type Ref, shallowRef, watchEffect } from 'vue'
22
import { usePresence } from '../presence/index.ts'
33
import { useCollapsibleContext } from './CollapsibleRoot.ts'
44

@@ -10,13 +10,26 @@ export interface CollapsibleContentProps {
1010
forceMount?: boolean
1111
}
1212

13-
export function useCollapsibleContent($el: Ref<HTMLElement | undefined>, props: CollapsibleContentProps) {
13+
export interface UseCollapsibleContentOptions {
14+
isOpen: Ref<boolean>
15+
el: Ref<HTMLElement | undefined>
16+
}
17+
18+
export interface UseCollapsibleContentReturns {
19+
'id': string
20+
'data-state': 'open' | 'closed'
21+
'data-disabled'?: string
22+
'hidden': boolean
23+
'style': CSSProperties
24+
}
25+
26+
export function useCollapsibleContent(options: UseCollapsibleContentOptions, props: CollapsibleContentProps): () => UseCollapsibleContentReturns {
1427
const context = useCollapsibleContext('CollapsibleContent')
1528

1629
let originalStyles: Pick<CSSStyleDeclaration, 'transitionDuration' | 'animationName'>
1730

18-
const isPresent = usePresence($el, () => props.forceMount || context.open.value, () => {
19-
const node = $el.value
31+
const isPresent = usePresence(options.el, () => props.forceMount || context.open.value, () => {
32+
const node = options.el.value
2033
if (!node)
2134
return
2235

@@ -43,20 +56,22 @@ export function useCollapsibleContent($el: Ref<HTMLElement | undefined>, props:
4356

4457
// when opening we want it to immediately open to retrieve dimensions
4558
// when closing we delay `present` to retrieve dimensions before closing
46-
const isOpen = computed(() => context.open.value || isPresent.value)
59+
watchEffect(() => {
60+
options.isOpen.value = context.open.value || isPresent.value
61+
})
4762

48-
const blockAnimationStyles = shallowRef<CSSProperties | undefined>(isOpen.value
63+
const blockAnimationStyles = shallowRef<CSSProperties | undefined>(options.isOpen.value
4964
? {
5065
transitionDuration: '0s !important',
5166
animationName: 'none !important',
5267
}
5368
: undefined)
5469

5570
onMounted(async () => {
56-
if (!isOpen.value)
71+
if (!options.isOpen.value)
5772
return
5873

59-
const node = $el.value
74+
const node = options.el.value
6075
if (!node)
6176
return
6277

@@ -74,9 +89,15 @@ export function useCollapsibleContent($el: Ref<HTMLElement | undefined>, props:
7489
nodeStyle.animationName = 'none'
7590
})
7691

77-
return {
78-
context,
79-
isOpen,
80-
blockAnimationStyles,
81-
}
92+
return (): UseCollapsibleContentReturns => ({
93+
'id': context.contentId,
94+
'data-state': context.open.value ? 'open' : 'closed',
95+
'data-disabled': context.disabled() ? '' : undefined,
96+
'hidden': !options.isOpen.value,
97+
'style': {
98+
'--radix-collapsible-content-height': '0px',
99+
'--radix-collapsible-content-width': '0px',
100+
...blockAnimationStyles.value,
101+
},
102+
})
82103
}

packages/vue-primitives/src/collapsible/CollapsibleContent.vue

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,25 @@ import { useForwardElement } from '@oku-ui/hooks'
33
import { Primitive } from '@oku-ui/primitive'
44
import { shallowRef } from 'vue'
55
import { type CollapsibleContentProps, useCollapsibleContent } from './CollapsibleContent.ts'
6-
import { getState } from './utils.ts'
76
87
defineOptions({
98
name: 'CollapsibleContent',
109
})
1110
1211
const props = defineProps<CollapsibleContentProps>()
13-
const $el = shallowRef<HTMLElement>()
14-
const forwardElement = useForwardElement($el)
12+
const el = shallowRef<HTMLElement>()
13+
const forwardElement = useForwardElement(el)
1514
16-
const collapsibleContent = useCollapsibleContent($el, props)
15+
const isOpen = shallowRef(false)
16+
17+
const collapsibleContent = useCollapsibleContent({
18+
el,
19+
isOpen,
20+
}, props)
1721
</script>
1822

1923
<template>
20-
<Primitive
21-
:id="collapsibleContent.context.contentId"
22-
:ref="forwardElement"
23-
:data-state="getState(collapsibleContent.context.open.value)"
24-
:data-disabled="collapsibleContent.context.disabled() ? '' : undefined"
25-
:hidden="!collapsibleContent.isOpen.value"
26-
:style="{
27-
'--radix-collapsible-content-height': '0px',
28-
'--radix-collapsible-content-width': '0px',
29-
...collapsibleContent.blockAnimationStyles.value,
30-
}"
31-
>
32-
<slot v-if="collapsibleContent.isOpen.value" />
24+
<Primitive :ref="forwardElement" v-bind="collapsibleContent()">
25+
<slot v-if="isOpen" />
3326
</Primitive>
3427
</template>

packages/vue-primitives/src/collapsible/CollapsibleRoot.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Ref } from 'vue'
2-
import { createContext } from '@oku-ui/hooks'
2+
import type { ConvertEmitsToUseEmits } from '../utils/vue.ts'
3+
import { createContext, useControllableStateV2, useId } from '../hooks/index.ts'
34

45
export interface CollapsibleRootProps {
56
defaultOpen?: boolean
@@ -13,9 +14,42 @@ export type CollapsibleRootEmits = {
1314

1415
export interface CollapsibleContext {
1516
contentId: string
16-
disabled: () => boolean
17+
disabled: () => boolean | undefined
1718
open: Ref<boolean>
1819
onOpenToggle: () => void
1920
}
2021

2122
export const [provideCollapsibleContext, useCollapsibleContext] = createContext<CollapsibleContext>('Collapsible')
23+
24+
export interface UseCollapsibleRootProps {
25+
open?: () => boolean | undefined
26+
defaultOpen?: boolean
27+
disabled?: () => boolean
28+
}
29+
30+
export type UseCollapsibleRootEmits = ConvertEmitsToUseEmits<CollapsibleRootEmits>
31+
32+
export interface UseCollapsibleRootReturns {
33+
'data-state': 'open' | 'closed'
34+
'data-disabled'?: boolean
35+
}
36+
37+
export function useCollapsibleRoot(props: UseCollapsibleRootProps, emits: UseCollapsibleRootEmits): () => UseCollapsibleRootReturns {
38+
const open = useControllableStateV2(props.open, emits.onUpdateOpen, props.defaultOpen)
39+
40+
provideCollapsibleContext({
41+
contentId: useId(),
42+
disabled() {
43+
return props.disabled?.()
44+
},
45+
open,
46+
onOpenToggle() {
47+
open.value = !open.value
48+
},
49+
})
50+
51+
return (): UseCollapsibleRootReturns => ({
52+
'data-state': open.value ? 'open' : 'closed',
53+
'data-disabled': props.disabled?.() ? true : undefined,
54+
})
55+
}
Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
<script setup lang="ts">
2-
import { useControllableState, useId } from '@oku-ui/hooks'
3-
import { Primitive } from '@oku-ui/primitive'
4-
import { type CollapsibleRootEmits, type CollapsibleRootProps, provideCollapsibleContext } from './CollapsibleRoot.ts'
5-
import { getState } from './utils.ts'
2+
import { Primitive } from '../primitive/index.ts'
3+
import { type CollapsibleRootEmits, type CollapsibleRootProps, useCollapsibleRoot } from './CollapsibleRoot.ts'
64
75
defineOptions({
86
name: 'CollapsibleRoot',
@@ -14,25 +12,23 @@ const props = withDefaults(defineProps<CollapsibleRootProps>(), {
1412
})
1513
const emit = defineEmits<CollapsibleRootEmits>()
1614
17-
const open = useControllableState(props, 'open', v => emit('update:open', v), props.defaultOpen)
18-
19-
provideCollapsibleContext({
20-
contentId: useId(),
15+
const collapsibleRoot = useCollapsibleRoot({
16+
open() {
17+
return props.open
18+
},
19+
defaultOpen: props.defaultOpen,
2120
disabled() {
2221
return props.disabled
2322
},
24-
open,
25-
onOpenToggle() {
26-
open.value = !open.value
23+
}, {
24+
onUpdateOpen(value) {
25+
emit('update:open', value)
2726
},
2827
})
2928
</script>
3029

3130
<template>
32-
<Primitive
33-
:data-state="getState(open)"
34-
:data-disabled="disabled ? '' : undefined"
35-
>
31+
<Primitive v-bind="collapsibleRoot()">
3632
<slot />
3733
</Primitive>
3834
</template>
Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import type { PrimitiveProps } from '@oku-ui/primitive'
1+
import type { PrimitiveProps } from '../primitive/index.ts'
2+
import { composeEventHandlers, type ConvertEmitsToUseEmits } from '../utils/vue.ts'
3+
import { useCollapsibleContext } from './CollapsibleRoot.ts'
24

35
export interface CollapsibleTriggerProps {
46
as?: PrimitiveProps['as']
@@ -7,3 +9,33 @@ export interface CollapsibleTriggerProps {
79
export type CollapsibleTriggerEmits = {
810
click: [event: MouseEvent]
911
}
12+
13+
export type UseCollapsibleTriggerEmits = ConvertEmitsToUseEmits<CollapsibleTriggerEmits>
14+
15+
export interface UseCollapsibleTriggerReturns {
16+
'type': 'button'
17+
'aria-controls': string
18+
'aria-expanded': boolean
19+
'data-state': 'open' | 'closed'
20+
'data-disabled'?: string
21+
'disabled': boolean | undefined
22+
'onClick': (event: MouseEvent) => void
23+
}
24+
25+
export function useCollapsibleTrigger(emits: UseCollapsibleTriggerEmits): () => UseCollapsibleTriggerReturns {
26+
const context = useCollapsibleContext('CollapsibleTrigger')
27+
28+
const onClick = composeEventHandlers<MouseEvent>((event) => {
29+
emits.onClick?.(event)
30+
}, context.onOpenToggle)
31+
32+
return (): UseCollapsibleTriggerReturns => ({
33+
'type': 'button',
34+
'aria-controls': context.contentId,
35+
'aria-expanded': context.open.value || false,
36+
'data-state': context.open.value ? 'open' : 'closed',
37+
'data-disabled': context.disabled() ? '' : undefined,
38+
'disabled': context.disabled(),
39+
onClick,
40+
})
41+
}
Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
<script setup lang="ts">
2-
import type { CollapsibleTriggerEmits, CollapsibleTriggerProps } from './CollapsibleTrigger.ts'
3-
import { Primitive } from '@oku-ui/primitive'
4-
import { composeEventHandlers } from '@oku-ui/shared'
5-
import { useCollapsibleContext } from './CollapsibleRoot.ts'
6-
import { getState } from './utils.ts'
2+
import { Primitive } from '../primitive/index.ts'
3+
import { type CollapsibleTriggerEmits, type CollapsibleTriggerProps, useCollapsibleTrigger } from './CollapsibleTrigger.ts'
74
85
defineOptions({
96
name: 'CollapsibleTrigger',
@@ -14,24 +11,15 @@ withDefaults(defineProps<CollapsibleTriggerProps>(), {
1411
})
1512
const emit = defineEmits<CollapsibleTriggerEmits>()
1613
17-
const context = useCollapsibleContext('CollapsibleTrigger')
18-
19-
const onClick = composeEventHandlers<MouseEvent>((event) => {
20-
emit('click', event)
21-
}, context.onOpenToggle)
14+
const collapsibleTrigger = useCollapsibleTrigger({
15+
onClick(event) {
16+
emit('click', event)
17+
},
18+
})
2219
</script>
2320

2421
<template>
25-
<Primitive
26-
:as="as"
27-
type="button"
28-
:aria-controls="context.contentId"
29-
:aria-expanded="context.open.value || false"
30-
:data-state="getState(context.open.value)"
31-
:data-disabled="context.disabled() ? '' : undefined"
32-
:disabled="context.disabled()"
33-
@click="onClick"
34-
>
22+
<Primitive :as="as" v-bind="collapsibleTrigger()">
3523
<slot />
3624
</Primitive>
3725
</template>
Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
1-
export { type CollapsibleContentProps } from './CollapsibleContent.ts'
1+
export {
2+
type CollapsibleContentProps,
3+
useCollapsibleContent,
4+
type UseCollapsibleContentOptions,
5+
type UseCollapsibleContentReturns,
6+
} from './CollapsibleContent.ts'
27
export { default as CollapsibleContent } from './CollapsibleContent.vue'
38
export {
49
type CollapsibleContext,
510
type CollapsibleRootEmits,
611
type CollapsibleRootProps,
712
provideCollapsibleContext,
813
useCollapsibleContext,
14+
useCollapsibleRoot,
15+
type UseCollapsibleRootEmits,
16+
type UseCollapsibleRootProps,
17+
type UseCollapsibleRootReturns,
918
} from './CollapsibleRoot.ts'
10-
1119
export { default as CollapsibleRoot } from './CollapsibleRoot.vue'
12-
export { type CollapsibleTriggerEmits, type CollapsibleTriggerProps } from './CollapsibleTrigger.ts'
20+
export {
21+
type CollapsibleTriggerEmits,
22+
type CollapsibleTriggerProps,
23+
useCollapsibleTrigger,
24+
type UseCollapsibleTriggerEmits,
25+
type UseCollapsibleTriggerReturns,
26+
} from './CollapsibleTrigger.ts'
1327
export { default as CollapsibleTrigger } from './CollapsibleTrigger.vue'

packages/vue-primitives/src/label/Label.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { PrimitiveProps } from '@oku-ui/primitive'
1+
import type { PrimitiveProps } from '../primitive/index.ts'
2+
import type { ConvertEmitsToUseEmits } from '../utils/vue.ts'
23

34
export interface LabelProps {
45
as?: PrimitiveProps['as']
@@ -8,9 +9,7 @@ export type LabelEmits = {
89
mousedown: [event: MouseEvent]
910
}
1011

11-
export interface UseLabelEmits {
12-
onMousedown?: (event: MouseEvent) => void
13-
}
12+
export type UseLabelEmits = ConvertEmitsToUseEmits<LabelEmits>
1413

1514
export interface UseLabelReturns {
1615
onMousedown?: (event: MouseEvent) => void
@@ -29,7 +28,7 @@ export function useLabel(emits?: UseLabelEmits): () => UseLabelReturns {
2928
event.preventDefault()
3029
}
3130

32-
return () => ({
31+
return (): UseLabelReturns => ({
3332
onMousedown,
3433
})
3534
}

packages/vue-primitives/src/label/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,4 @@ export {
55
type UseLabelEmits,
66
type UseLabelReturns,
77
} from './Label.ts'
8-
98
export { default as Label } from './Label.vue'

packages/vue-primitives/src/shared/getRawChildren.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
11
import { PatchFlags } from '@vue/shared'
22
import { Comment, Fragment, type VNode } from 'vue'
33

4+
export type ConvertEmitsToUseEmits<T extends Record<string, any[]>> = {
5+
[K in keyof T as K extends `update:${infer Rest}`
6+
? `onUpdate${Capitalize<Rest>}`
7+
: `on${Capitalize<string & K>}`]?: (event: T[K][0]) => void
8+
}
9+
10+
export function composeEventHandlers<E extends Event>(
11+
originalEventHandler?: (event: E) => void,
12+
ourEventHandler?: (event: E) => void,
13+
{ checkForDefaultPrevented = true } = {},
14+
) {
15+
return function handleEvent(event: E) {
16+
originalEventHandler?.(event)
17+
18+
if (checkForDefaultPrevented === false || !((event as unknown) as Event).defaultPrevented)
19+
ourEventHandler?.(event)
20+
}
21+
}
22+
423
// TODO: wip
524
export function getRawChildren(children: VNode[]): VNode[] {
625
let ret: VNode[] = []

0 commit comments

Comments
 (0)