Skip to content

Commit

Permalink
feat(experimental/primitives/collapsible): add collapse transition
Browse files Browse the repository at this point in the history
  • Loading branch information
gravitano committed Aug 8, 2023
1 parent 29b3838 commit 25bfc1e
Show file tree
Hide file tree
Showing 3 changed files with 344 additions and 12 deletions.
12 changes: 10 additions & 2 deletions packages/primitives/src/collapsible/collapsible.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const Default: Story = (args) => ({
<Collapsible>
<CollapsibleButton
v-slot="{open}"
class="flex py-2 justify-between w-full gap-4 items-center"
class="flex py-2 bg-white justify-between w-full gap-4 items-center"
>
<h3 class="text-sm">
Title
Expand All @@ -39,7 +39,9 @@ export const Default: Story = (args) => ({
/>
</CollapsibleButton>
<CollapsibleContent class="text-sm text-gray-800">
<CollapsibleContent
class="text-sm text-gray-800"
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
euismod eu lorem et ultricies. In porta lorem at dui semper
porttitor. Nullam quis cursus dui. Cras tincidunt vehicula
Expand Down Expand Up @@ -82,5 +84,11 @@ export const VModel: Story = (args) => ({
porttitor. Nullam quis cursus dui. Cras tincidunt vehicula
</CollapsibleContent>
</Collapsible>
<div class="mt-4">
<button @click="value = !value" class="rounded-lg bg-blue-600 text-white px-3 py-2 text-sm">
{{ value ? 'Close' : 'Open' }}
</button>
</div>
`,
});
32 changes: 22 additions & 10 deletions packages/primitives/src/collapsible/collapsible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
Ref,
InjectionKey,
unref,
withDirectives,
vShow,
} from 'vue';
import CollapseTransition from '../transition/CollapseTransition.vue';

export interface CollapsibleContext {
open: Ref<boolean>;
Expand Down Expand Up @@ -131,16 +134,25 @@ export const CollapsibleContent = defineComponent({
setup(props, {slots}) {
const {open} = useCollapsible();

const defaultSlot = slots.default?.({
open: unref(open),
});
const defaultRender = h(props.as, {}, defaultSlot);

return () =>
open.value
? props.as === 'template'
? defaultSlot
: defaultRender
: null;
h(
CollapseTransition,
{},
{
default: () =>
withDirectives(
h(
props.as,
{
'data-state': open.value ? 'open' : 'closed',
},
slots.default?.({
open: unref(open),
}),
),
[[vShow, open.value]],
),
},
);
},
});
312 changes: 312 additions & 0 deletions packages/primitives/src/transition/CollapseTransition.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
<template>
<transition
:name="name"
@before-appear="beforeAppear"
@appear="appear"
@after-appear="afterAppear"
@appear-cancelled="appearCancelled"
@before-enter="beforeEnter"
@enter="enter"
@after-enter="afterEnter"
@enter-cancelled="enterCancelled"
@before-leave="beforeLeave"
@leave="leave"
@after-leave="afterLeave"
@leave-cancelled="leaveCancelled"
>
<slot></slot>
</transition>
</template>

<script>
// @credit: https://github.com/ivanvermeyen/vue-collapse-transition
export default {
name: 'CollapseTransition',
props: {
name: {
type: String,
required: false,
default: 'collapse',
},
dimension: {
type: String,
required: false,
default: 'height',
validator: (value) => {
return ['height', 'width'].includes(value);
},
},
duration: {
type: Number,
required: false,
default: 300,
},
easing: {
type: String,
required: false,
default: 'ease-in-out',
},
},
watch: {
dimension() {
this.clearCachedDimensions();
},
},
data() {
return {
cachedStyles: null,
};
},
computed: {
transition() {
let transitions = [];
Object.keys(this.cachedStyles).forEach((key) => {
transitions.push(
`${this.convertToCssProperty(key)} ${this.duration}ms ${this.easing}`,
);
});
return transitions.join(', ');
},
},
methods: {
beforeAppear(el) {
// Emit the event to the parent
this.$emit('before-appear', el);
},
appear(el) {
// Emit the event to the parent
this.$emit('appear', el);
},
afterAppear(el) {
// Emit the event to the parent
this.$emit('after-appear', el);
},
appearCancelled(el) {
// Emit the event to the parent
this.$emit('appear-cancelled', el);
},
beforeEnter(el) {
// Emit the event to the parent
this.$emit('before-enter', el);
},
enter(el, done) {
// Because width and height may be 'auto',
// first detect and cache the dimensions
this.detectAndCacheDimensions(el);
// The order of applying styles is important:
// - 1. Set styles for state before transition
// - 2. Force repaint
// - 3. Add transition style
// - 4. Set styles for state after transition
// If the order is not right and you open any 2nd level submenu
// for the first time, the transition will not work.
this.setClosedDimensions(el);
this.hideOverflow(el);
this.forceRepaint(el);
this.setTransition(el);
this.setOpenedDimensions(el);
// Emit the event to the parent
this.$emit('enter', el, done);
// Call done() when the transition ends
// to trigger the @after-enter event.
setTimeout(done, this.duration);
},
afterEnter(el) {
// Clean up inline styles
this.unsetOverflow(el);
this.unsetTransition(el);
this.unsetDimensions(el);
this.clearCachedDimensions();
// Emit the event to the parent
this.$emit('after-enter', el);
},
enterCancelled(el) {
// Emit the event to the parent
this.$emit('enter-cancelled', el);
},
beforeLeave(el) {
// Emit the event to the parent
this.$emit('before-leave', el);
},
leave(el, done) {
// For some reason, @leave triggered when starting
// from open state on page load. So for safety,
// check if the dimensions have been cached.
this.detectAndCacheDimensions(el);
// The order of applying styles is less important
// than in the enter phase, as long as we repaint
// before setting the closed dimensions.
// But it is probably best to use the same
// order as the enter phase.
this.setOpenedDimensions(el);
this.hideOverflow(el);
this.forceRepaint(el);
this.setTransition(el);
this.setClosedDimensions(el);
// Emit the event to the parent
this.$emit('leave', el, done);
// Call done() when the transition ends
// to trigger the @after-leave event.
// This will also cause v-show
// to reapply 'display: none'.
setTimeout(done, this.duration);
},
afterLeave(el) {
// Clean up inline styles
this.unsetOverflow(el);
this.unsetTransition(el);
this.unsetDimensions(el);
this.clearCachedDimensions();
// Emit the event to the parent
this.$emit('after-leave', el);
},
leaveCancelled(el) {
// Emit the event to the parent
this.$emit('leave-cancelled', el);
},
detectAndCacheDimensions(el) {
// Cache actual dimensions
// only once to void invalid values when
// triggering during a transition
if (this.cachedStyles) return;
const visibility = el.style.visibility;
const display = el.style.display;
// Trick to get the width and
// height of a hidden element
el.style.visibility = 'hidden';
el.style.display = '';
this.cachedStyles = this.detectRelevantDimensions(el);
// Restore any original styling
el.style.visibility = visibility;
el.style.display = display;
},
clearCachedDimensions() {
this.cachedStyles = null;
},
detectRelevantDimensions(el) {
// These properties will be transitioned
if (this.dimension === 'height') {
return {
height: el.offsetHeight + 'px',
paddingTop:
el.style.paddingTop || this.getCssValue(el, 'padding-top'),
paddingBottom:
el.style.paddingBottom || this.getCssValue(el, 'padding-bottom'),
};
}
if (this.dimension === 'width') {
return {
width: el.offsetWidth + 'px',
paddingLeft:
el.style.paddingLeft || this.getCssValue(el, 'padding-left'),
paddingRight:
el.style.paddingRight || this.getCssValue(el, 'padding-right'),
};
}
return {};
},
setTransition(el) {
el.style.transition = this.transition;
},
unsetTransition(el) {
el.style.transition = '';
},
hideOverflow(el) {
el.style.overflow = 'hidden';
},
unsetOverflow(el) {
el.style.overflow = '';
},
setClosedDimensions(el) {
Object.keys(this.cachedStyles).forEach((key) => {
el.style[key] = '0';
});
},
setOpenedDimensions(el) {
Object.keys(this.cachedStyles).forEach((key) => {
el.style[key] = this.cachedStyles[key];
});
},
unsetDimensions(el) {
Object.keys(this.cachedStyles).forEach((key) => {
el.style[key] = '';
});
},
forceRepaint(el) {
// Force repaint to make sure the animation is triggered correctly.
// Thanks: https://markus.oberlehner.net/blog/transition-to-height-auto-with-vue/
getComputedStyle(el)[this.dimension];
},
getCssValue(el, style) {
return getComputedStyle(el, null).getPropertyValue(style);
},
convertToCssProperty(style) {
// Example: convert 'paddingTop' to 'padding-top'
// Thanks: https://gist.github.com/tan-yuki/3450323
const upperChars = style.match(/([A-Z])/g);
if (!upperChars) {
return style;
}
for (let i = 0, n = upperChars.length; i < n; i++) {
style = style.replace(
new RegExp(upperChars[i]),
'-' + upperChars[i].toLowerCase(),
);
}
if (style.slice(0, 1) === '-') {
style = style.slice(1);
}
return style;
},
},
};
</script>

0 comments on commit 25bfc1e

Please sign in to comment.