vuesion/vuesion

View on GitHub
src/components/input-and-actions/VueDropdown/VueDropdown.vue

Summary

Maintainability
Test Coverage
A
100%
<template>
  <div ref="dropdownRef" :class="$style.vueDropdown" @keydown.enter.space.up.down.esc.stop.prevent="onKeyDown">
    <div :class="$style.wrapper" data-testid="dropdown" @click.stop.prevent="onClick">
      <slot>
        <vue-button look="outline" :aria-expanded="show.toString()" :size="size" trailing-icon="chevron-down">
          {{ buttonText }}
        </vue-button>
      </slot>
    </div>

    <vue-collapse :show="show" :duration="duration">
      <vue-menu
        ref="menuRef"
        :items="items"
        :class="[$style.menu, $style[alignXMenu], $style[alignYMenu], $style[size]]"
        @click="onItemClick"
      />
    </vue-collapse>
  </div>
</template>

<script setup lang="ts">
import { nextTick, ref, useCssModule } from 'vue';
import { onClickOutside } from '@vueuse/core';
import { getDomRef } from '~/composables/get-dom-ref';
import { IItem } from '~/interfaces/IItem';
import { HorizontalDirection, ShirtSize, VerticalDirection } from '~/components/prop-types';
import VueMenu from '~/components/data-display/VueMenu/VueMenu.vue';
import VueButton from '~/components/input-and-actions/VueButton/VueButton.vue';
import VueCollapse from '~/components/behavior/VueCollapse/VueCollapse.vue';

// Interface
interface DropdownProps {
  items: Array<IItem>;
  buttonText?: string;
  duration?: number;
  alignXMenu?: HorizontalDirection;
  alignYMenu?: VerticalDirection;
  size?: ShirtSize;
}
interface DropdownEmits {
  (event: 'click', e: MouseEvent): void;
  (event: 'item-click', item: IItem): void;
}
withDefaults(defineProps<DropdownProps>(), {
  buttonText: '',
  duration: 250,
  alignXMenu: 'left',
  alignYMenu: 'bottom',
  size: 'md',
});
const emit = defineEmits<DropdownEmits>();

// Deps
const $style = useCssModule();

// Data
const dropdownRef = getDomRef<HTMLDivElement>(null);
const menuRef = getDomRef<{ focus: () => void }>(null);
const show = ref(false);

// Methods
const close = () => (show.value = false);

// Event Handlers
const onClick = (e: MouseEvent) => {
  show.value = !show.value;
  emit('click', e);
};
const onItemClick = (item: IItem) => {
  emit('item-click', item);
  close();
};
const onKeyDown = async (e: KeyboardEvent) => {
  if (e.code === 'Escape') {
    close();
  } else {
    show.value = true;

    await nextTick();

    /* c8 ignore start */
    menuRef.value.focus && menuRef.value.focus();
    /* c8 ignore end */
  }
};

onClickOutside(dropdownRef, () => close());
</script>

<style lang="scss" module>
@import 'assets/_design-system.scss';

.vueDropdown {
  display: inline-flex;
  position: relative;

  .wrapper {
    cursor: pointer;
  }

  .menu {
    &.sm {
      top: $input-control-sm-height + $dropdown-button-menu-gap;
    }

    &.md {
      top: $input-control-md-height + $dropdown-button-menu-gap;
    }

    &.lg {
      top: $input-control-lg-height + $dropdown-button-menu-gap;
    }

    &.left {
      left: 0;
    }

    &.center {
      left: 50%;
      transform: translateX(-50%);
    }

    &.right {
      right: 0;
    }

    &.top {
      top: -$dropdown-button-menu-gap;
      transform: translateY(-100%);

      &.center {
        transform: translate(-50%, -100%);
      }
    }
  }
}
</style>