Draggable summary-details bottom drawer
A draggable bottom drawer/card with two open positions - half and full (summary and details).
A draggable bottom drawer/card with two open positions - half and full (summary and details).
You need a drawer/card on bottom that has two states:
This is an example of how to build a custom component bottom drawer.
You can expand it by dragging or by clicking on the pill.
On clicking on the pill the drawer will cycle between half open
, full open
, closed
;
When dragging, on release the drawer will snap to the closest position.
The component exposes three slots:
#pill
#summary
#details
All three slots have access in slotProps
to:
cycleMode
methodsetMode
method to directly change the mode
mode
value - current mode, one of half
, full
, handler
<template>
<div class="q-pa-md">
<div v-for="i in 5" class="text-subtitle1 q-py-xl text-center">
Play with the drawer on bottom
</div>
<bottom-drawer>
<template #pill="{ cycleMode, mode }">
<span class="text-caption">[{{ mode }}] Drag drawer</span>
<div class="cursor-pointer" @click="cycleMode"></div>
<span class="text-caption">or click pill</span>
</template>
<template #summary>
<div class="text-h6">Our Changing Planet</div>
<div class="text-subtitle2">by John Doe</div>
<div>{{ lorem }}</div>
</template>
<template #details>
<div class="text-h6">Our Changing Planet - only shown when drawer is open</div>
<div class="text-subtitle2">by John Doe</div>
<div>{{ lorem }}</div>
</template>
</bottom-drawer>
</div>
</template>
<style lang="sass">
.slide-drawer
&--bottom
border-bottom-left-radius: 0
border-bottom-right-radius: 0
background-color: #333
background-image: radial-gradient(circle, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.4) 100%)
bottom: unset
top: 100%
transition: background-color 0.3s ease-in-out
> div:last-child,
> img:last-child
border-bottom-left-radius: 0
border-bottom-right-radius: 0
&.slide-drawer--open-half
background-color: #014a88
&.slide-drawer--open-full
background-color: #01884a
&__handler
&--horizontal
cursor: grab
> div
width: 60px
height: 8px
border-radius: 4px
background-color: rgba(200, 200, 200, 0.7)
</style>
<script setup lang="ts">
import BottomDrawer from './BottomDrawer.vue';
const lorem = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.';
</script>
<template>
<q-card
class="slide-drawer slide-drawer--bottom text-white fixed-bottom column no-wrap"
:class="`slide-drawer--open-${drawerMode}`"
:style="drawerStyle"
>
<q-card-section class="slide-drawer__handler--horizontal row flex-center q-pa-sm q-gutter-x-md" v-touch-pan.mouse.vertical.prevent="slideDrawer">
<slot name="pill" :cycle-mode="cycleDrawer" :mode="drawerMode" :set-mode="setMode"></slot>
</q-card-section>
<q-card-section class="col">
<slot name="summary" :cycle-mode="cycleDrawer" :mode="drawerMode" :set-mode="setMode"></slot>
</q-card-section>
<q-card-section v-if="drawerMode !== 'handler'" class="col">
<slot name="details" :cycle-mode="cycleDrawer" :mode="drawerMode" :set-mode="setMode"></slot>
</q-card-section>
</q-card>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onBeforeUnmount } from 'vue';
import { useQuasar } from 'quasar';
const props = defineProps({
drawerMinHeight: {
type: Number,
default: 36,
},
drawerTopOffset: {
type: Number,
default: 100,
},
drawerOpenRatioHalf: {
type: Number,
default: 50,
},
});
let animateTimeout = null;
const $q = useQuasar();
const drawerPos = ref(props.drawerMinHeight);
const drawerMaxHeight = computed(() => Math.max(0, $q.screen.height - props.drawerTopOffset));
const drawerOpenRatio = computed(() => Math.round((Math.max(0, drawerPos.value - props.drawerMinHeight) * 100) / Math.max(1, drawerMaxHeight.value - props.drawerMinHeight)));
const drawerStyle = computed(() => ({
height: `${ drawerMaxHeight.value }px`,
transform: `translateY(${ -drawerPos.value }px)`,
}));
const drawerMode = computed(() => {
if (drawerOpenRatio.value > props.drawerOpenRatioHalf) {
return 'full';
}
return drawerOpenRatio.value > 0
? 'half'
: 'handler';
});
function animateDrawerTo (height) {
if (animateTimeout !== null) {
clearTimeout(animateTimeout);
}
animateTimeout = null;
const diff = height - drawerPos.value;
if (diff !== 0) {
drawerPos.value += Math.abs(diff) < 2 ? diff : Math.round(diff / 2);
animateTimeout = setTimeout(() => {
animateDrawerTo(height);
}, 30);
}
}
function slideDrawer (ev) {
const { direction, delta, isFinal } = ev;
drawerPos.value = Math.max(props.drawerMinHeight, Math.min(drawerMaxHeight.value, drawerPos.value - delta.y));
if (isFinal === true) {
nextTick(() => {
const aboveHalf = drawerOpenRatio.value > props.drawerOpenRatioHalf;
// eslint-disable-next-line no-nested-ternary
const targetHeight = direction === 'up'
? (aboveHalf ? drawerMaxHeight.value : Math.round(drawerMaxHeight.value / 2))
: (aboveHalf ? Math.round(drawerMaxHeight.value / 2) : props.drawerMinHeight);
animateDrawerTo(targetHeight);
});
}
}
function cycleDrawer () {
// eslint-disable-next-line no-nested-ternary
const targetHeight = drawerMode.value === 'handler'
? Math.round(drawerMaxHeight.value / 2)
: (drawerMode.value === 'half' ? drawerMaxHeight.value : props.drawerMinHeight);
animateDrawerTo(targetHeight);
}
function setMode(mode: 'half' | 'full' | 'handler') {
if (['half', 'full', 'handler'].includes(mode)) {
// eslint-disable-next-line no-nested-ternary
const targetHeight = mode === 'half'
? Math.round(drawerMaxHeight.value / 2)
: (drawerMode.value === 'full' ? drawerMaxHeight.value : props.drawerMinHeight);
animateDrawerTo(targetHeight);
}
}
onBeforeUnmount(() => {
if (animateTimeout !== null) {
clearTimeout(animateTimeout);
}
});
</script>