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.

<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>
<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>