vuesion/vuesion

View on GitHub
src/components/data-display/VueMenu/VueMenu.vue

Summary

Maintainability
Test Coverage
A
100%
<template>
  <ul ref="menuRef" data-testid="menu" :class="$style.vueMenu" @keydown="onKeyDown" @focus="focus">
    <li
      v-for="(item, idx) in items"
      :key="`${item.value}-${idx}`"
      :data-testid="`${item.value}-${idx}`"
      :class="[
        selectedItemIndex === idx ? $style.active : '',
        item.value === 'separator' ? $style.separator : '',
        item.disabled && $style.disabled,
      ]"
      :tabindex="item.value === 'separator' ? -1 : 0"
      @mouseenter="selectedItemIndex = idx"
      @mouseleave="selectedItemIndex = -1"
      @focus="selectedItemIndex = idx"
      @blur="selectedItemIndex = -1"
      @click.stop.prevent="onItemClick(item)"
    >
      <slot v-if="item.value !== 'separator'" name="option" :option="item">
        <div v-if="item.leadingIcon" :class="$style.leading">
          <component :is="`vue-icon-${item.leadingIcon}`" />
        </div>

        <div :class="$style.value">
          <vue-text>{{ item.label }}</vue-text>
          <vue-text v-if="item.description" look="support" color="text-medium">
            {{ item.description }}
          </vue-text>
        </div>

        <div v-if="item.trailingIcon" :class="$style.trailing">
          <component :is="`vue-icon-${item.trailingIcon}`" />
        </div>
      </slot>
    </li>
  </ul>
</template>

<script setup lang="ts">
import { computed, ref, useCssModule } from 'vue';
import debounce from 'lodash-es/debounce.js';
import { IItem } from '~/interfaces/IItem';
import { getDomRef } from '~/composables/get-dom-ref';
import VueText from '~/components/typography/VueText/VueText.vue';

// Interface
interface MenuProps {
  items: Array<IItem>;
  disableSearch?: boolean;
}
interface MenuEmits {
  (e: 'click', item: IItem): void;
  (e: 'close'): void;
}
const props = withDefaults(defineProps<MenuProps>(), {
  disableSearch: false,
});
const emit = defineEmits<MenuEmits>();

// Deps
const $style = useCssModule();

// Data
const menuRef = getDomRef<HTMLElement>(null);
const searchQuery = ref('');
const selectedItemIndex = ref<number>(-1);
const items = computed<Array<IItem>>(() => props.items as Array<IItem>);
const maxItems = computed(() => items.value.length);

// Event Handler & Methods
const onItemClick = (item: IItem) => {
  if (item.disabled === true) {
    return;
  }

  emit('click', item);
};
const handleSelection = (newIndex: number) => {
  if (newIndex === maxItems.value) {
    selectedItemIndex.value = 0;
  } else if (newIndex <= -1) {
    selectedItemIndex.value = maxItems.value - 1;
  } else {
    selectedItemIndex.value = newIndex;
  }

  focus();
};
const handleSearch = debounce(() => {
  selectedItemIndex.value = props.items.findIndex((item) => {
    const regex = new RegExp(searchQuery.value, 'i');
    return regex.test(item.label) && !!item.disabled === false;
  });
  searchQuery.value = '';

  if (selectedItemIndex.value > -1) {
    focus();
  }
}, 300);
const getNewIndex = (direction: string) => {
  let newIndex: number = direction === 'down' ? selectedItemIndex.value + 1 : selectedItemIndex.value - 1;

  if (items.value[newIndex] && items.value[newIndex].value === 'separator') {
    newIndex = direction === 'down' ? newIndex + 1 : newIndex - 1;
  }

  return newIndex;
};
const onKeyDown = (e: KeyboardEvent) => {
  e.stopPropagation();
  e.preventDefault();

  if (
    ['Enter', 'Space'].includes(e.code) &&
    selectedItemIndex.value > -1 &&
    !items.value[selectedItemIndex.value].disabled
  ) {
    onItemClick(items.value[selectedItemIndex.value]);
  } else if (e.code === 'Tab' || e.code === 'Escape') {
    emit('close');
  } else if (e.code === 'ArrowDown') {
    handleSelection(getNewIndex('down'));
  } else if (e.code === 'ArrowUp') {
    handleSelection(getNewIndex('up'));
  } else if (props.disableSearch === false) {
    searchQuery.value += e.key;
    handleSearch();
  }
};

/**
 * Only exposed for usage in parent components (e.g. dropdown)
 * doesn't need testing
 */
/* c8 ignore start */
const focus = (selectedItem: IItem | null = null) => {
  if (selectedItem) {
    selectedItemIndex.value = props.items.findIndex((i) => i.value === selectedItem.value);
  }

  const item = menuRef.value
    .querySelectorAll('li')
    .item(selectedItemIndex.value === -1 ? 0 : selectedItemIndex.value) as HTMLUListElement;
  item?.focus();
  menuRef.value?.scrollTo({ top: item?.offsetTop });
};

defineExpose({ focus });
/* c8 ignore end */
</script>

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

.vueMenu {
  position: absolute;
  display: inline-flex;
  flex-direction: column;
  background: $menu-bg;
  min-width: $menu-min-width;
  max-height: $menu-max-height;
  padding: $menu-padding;
  box-shadow: $menu-shadow;
  border-radius: $menu-border-radius;
  border: $menu-border;
  z-index: $menu-z-index;
  overflow-y: scroll !important;
  -webkit-overflow-scrolling: touch;

  > li {
    position: relative;
    display: flex;
    align-items: flex-start;
    padding: $menu-item-padding;
    color: $menu-item-color;
    cursor: pointer;
    outline: none;

    .leading,
    .trailing {
      padding-top: $space-2;
      height: $menu-item-icon-size;

      i {
        width: $menu-item-icon-size;
        height: $menu-item-icon-size;
      }
    }

    .leading {
      padding-right: $menu-item-icon-size-gap;
    }

    .trailing {
      padding-left: $menu-item-icon-size-gap;
    }

    .value {
      display: flex;
      flex-direction: column;
      flex: 1;
    }

    &.active {
      background: $menu-item-bg-active;
      color: $menu-item-color-active;
    }

    &.separator {
      padding: 0;
      height: 0;
      border-top: $menu-separator-border;
    }

    &.disabled {
      opacity: $menu-item-disabled-opacity;
      cursor: not-allowed;
    }
  }
}
</style>