Components
Dropdown
Displays a menu to the user — such as a set of actions or functions — triggered by a button.
Source Code
This component is composed of the following components:
You will also need to install the HeadlessUI Float - Vue package
yarn add @headlessui-float/vue
Create the following a composable called useHoverMenu.ts
in your project.
export const useHoverMenu = (delay = 350, openDelay = 200) => {
const show = ref(false);
const closeTimer = ref(null) as Ref<ReturnType<typeof setTimeout> | null>;
const openTimer = ref(null) as Ref<ReturnType<typeof setTimeout> | null>;
const open = () => {
if (closeTimer.value) {
clearTimeout(closeTimer.value);
closeTimer.value = null;
}
openTimer.value = setTimeout(() => {
show.value = true;
openTimer.value = null;
}, openDelay);
};
const close = () => {
if (openTimer.value) {
clearTimeout(openTimer.value);
openTimer.value = null;
}
closeTimer.value = setTimeout(() => {
show.value = false;
closeTimer.value = null;
}, delay);
};
return { show, closeTimer, openTimer, open, close };
};
Copy the following code into your project.
Dropdown
<template>
<HMenu ref="parent" as="div" class="relative inline-flex">
<Float :offset="8" :placement="placement" :show="show" transition-name="dropdown">
<HMenuButton
class="inline-flex w-full"
as="div"
@mouseover="hover && open()"
@mouseleave="hover && close()"
@click="show = !show"
>
<slot></slot>
</HMenuButton>
<HMenuItems
@mouseover="hover && open()"
static
class="z-10 rounded-md border bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
:class="[width]"
>
<slot name="header"></slot>
<UIDropdownItem :width="width" :items="items" @close="close()" />
<slot name="footer"></slot>
</HMenuItems>
</Float>
</HMenu>
</template>
<script setup lang="ts">
import { Float, type FloatProps } from "@headlessui-float/vue";
const props = withDefaults(
defineProps<{
openDelay?: number;
closeDelay?: number;
hover?: boolean;
items?: any[];
width?: string;
placement?: FloatProps["placement"];
}>(),
{
openDelay: 50,
closeDelay: 250,
items: () => [],
width: "w-56",
placement: "bottom",
}
);
const { show, open, close } = useHoverMenu(toValue(props.closeDelay), toValue(props.openDelay));
const parent = ref(null) as Ref<HTMLElement | null>;
onClickOutside(parent, () => {
close();
});
</script>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 200ms ease-in-out;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>
DropdownItem
<template>
<div class="w-full rounded-md bg-background p-1 text-sm">
<template v-for="(item, i) in items" :key="i">
<hr v-if="item.divider" class="my-1.5" />
<HMenuItem v-slot="{ active, disabled }" v-else-if="!item.children">
<button
@click="item.onClick?.(() => $emit('close')) ?? $emit('close')"
:disabled="disabled"
class="flex w-full items-center justify-between rounded px-2 py-1.5 transition"
:class="[active && 'bg-muted']"
>
<div class="inline-flex w-full items-center gap-3">
<Icon :name="item.icon" class="h-4 w-4" v-if="item.icon" />
<UIAvatar :src="item.avatar" class="h-4 w-4" v-if="item.avatar" />
<p class="truncate">{{ item.label }}</p>
</div>
<span
v-if="item.shortcut"
class="flex shrink-0 items-center justify-center text-xs text-muted-foreground"
>
<span>{{ item.shortcut }}</span>
</span>
</button>
</HMenuItem>
<Float
v-else
:show="status[item.id]"
placement="right-start"
:flip="{ fallbackPlacements: ['left-start', 'left', 'bottom', 'top'] }"
shift
:offset="8"
>
<HMenuItem @click="open(item.id)" as="div" v-slot="{ active, disabled }">
<button
:disabled="disabled"
class="flex w-full items-center justify-between rounded px-2 py-1.5 transition hover:bg-muted"
@mouseenter="open(item.id)"
@mouseleave="delayClose(item.id)"
:class="[active && 'bg-muted']"
>
<div class="inline-flex w-full items-center gap-2">
<Icon :name="item.icon" class="h-4 w-4" v-if="item.icon" />
<UIAvatar :src="item.avatar" class="h-4 w-4" v-if="item.avatar" />
<p class="truncate">{{ item.label }}</p>
</div>
<span class="flex shrink-0 items-center justify-center">
<Icon name="heroicons:chevron-right" class="h-4 w-4" />
</span>
</button>
</HMenuItem>
<UIDropdownItem
class="border"
:width="width"
:items="item.children"
@mouseenter="open(item.id)"
@mouseleave="delayClose(item.id)"
@close="$emit('close')"
/>
</Float>
</template>
</div>
</template>
<script setup lang="ts">
import { Float } from "@headlessui-float/vue";
const props = defineProps<{
items: any[];
hover?: boolean;
width?: string;
}>();
const emit = defineEmits<{
close: [any];
}>();
const defaultStatus: Record<string, boolean> = {};
for (const item of props.items) {
defaultStatus[item.id] = false;
}
const status = ref(defaultStatus) as Ref<Record<string, boolean>>;
const delay = 200;
const currentId = ref(null) as Ref<string | null>;
const timer = ref(null) as Ref<ReturnType<typeof setTimeout> | null>;
async function open(id: string) {
if (currentId.value !== null && currentId.value !== id) {
close(currentId.value);
}
if (timer.value !== null) {
clearTimeout(timer.value);
timer.value = null;
}
currentId.value = id;
status.value[id] = true;
}
function close(id: string) {
currentId.value = null;
status.value[id] = false;
}
function delayClose(id: string) {
timer.value = setTimeout(() => {
close(id);
}, delay);
}
</script>
Table of contents