Skip to content

EpTabs

Props

NameDescriptionTypeDefault
activeTabIndexThe index of the active tab.number0
itemsThe tabs to display.array[]
variantThe variant of the tabs, default or classic.string'default'

Events

NameDescriptionPayload
tab-clickEmitted when a tab is clicked.-

INFO

This component does not use slots.

Component Code

vue
<template>
  <div
    ref="tabList"
    class="ep-tabs"
    :class="{ 'ep-tabs--classic': variant === 'classic' }"
    role="tablist"
  >
    <component
      :is="item.to ? 'router-link' : 'button'"
      v-for="(item, index) in tabs"
      :id="`tab-${index}`"
      :key="index"
      :aria-controls="`tabpanel-${index}`"
      :class="[
        'ep-tabs__tab-item',
        { 'ep-tabs__tab-item--active': !item.to && index === activeTabIndex }
      ]"
      :to="item.to ? item.to : undefined"
      :exact="item.to ? item.exact : undefined"
      role="tab"
      :aria-selected="index === activeTabIndex"
      :tabindex="index === activeTabIndex ? 0 : -1"
      @click="onClick({ item, index })"
      @keydown="handleKeydown(index, $event)"
    >
      <span>{{ item.label }}</span>
    </component>
  </div>
</template>

<script setup>
  import { computed, ref } from 'vue'

  defineOptions({
    name: 'EpTabs'
  })

  const props = defineProps({
    /**
     * The index of the active tab.
     */
    activeTabIndex: {
      type: Number,
      default: 0
    },
    /**
     * The tabs to display.
     */
    items: {
      type: Array,
      default: () => []
    },
    /**
     * The variant of the tabs, default or classic.
     */
    variant: {
      type: String,
      default: 'default'
    }
  })

  const emit = defineEmits([
    /**
     * Emitted when a tab is clicked.
     */
    'tab-click'
  ])

  const tabs = computed(() => {
    return props.items.map(item => (typeof item === 'object' ? item : { label: item }))
  })

  const onClick = ({ item, index }) => {
    if (!item.to) {
      emit('tab-click', { item, index })
    }
  }

  const handleKeydown = (index, event) => {
    const keyActions = {
      ArrowRight: () => focusTab((index + 1) % tabs.value.length),
      ArrowLeft: () => focusTab((index - 1 + tabs.value.length) % tabs.value.length),
      Home: () => focusTab(0),
      End: () => focusTab(tabs.value.length - 1),
      Enter: () => emit('tab-click', index),
      ' ': () => emit('tab-click', index),
    }

    if (keyActions[event.key]) {
      keyActions[event.key]()
    }
  }

  const tabList = ref(null)

  const focusTab = (index) => {
    // Programmatically move focus to the new tab
    // const tabElements = document.querySelectorAll('[role="tab"]')
    // tabElements[index]?.focus()

    // Query only within this component's tab list
    const tabElements = tabList.value?.querySelectorAll('[role="tab"]') || []
    tabElements[index]?.focus()
  }
</script>

Styles (SCSS)

scss
@use '../mixins/_mixins' as *;

:root {
  --ep-tabs-hover-border-color: var(--border-color--lighter);
  --ep-tabs-hover-text-color: var(--text-color);
  --ep-tabs-active-border-color: var(--primary-color-base);
  --ep-tabs-active-text-color: var(--text-color--loud);
  --ep-tabs-focus-outline-color: var(--ep-focus-outline-color);
}

.ep-tabs {
  display: flex;
  height: 100%;
  gap: 0 3rem;
  user-select: none;

  &__tab-item {
    display: inline-flex;
    cursor: default;

    span {
      position: relative;
      border-bottom: 3px solid transparent;
      top: 1px;
      display: inline-flex;
      align-items: center;
      white-space: nowrap;
    }

    @include hover {
      &:not(.ep-tabs__tab-item--active):not(.router-link-exact-active):hover span {
        border-bottom-color: var(--ep-tabs-hover-border-color);
        color: var(--ep-tabs-hover-text-color);
        cursor: pointer;
      }
    }

    &--active span,
    &.router-link-exact-active span {
      border-bottom-color: var(--ep-tabs-active-border-color);
      color: var(--ep-tabs-active-text-color);
    }
  }

  &--classic {
    align-items: end;
    gap: 0;

    .ep-tabs__tab-item {
      span {
        padding: 1rem 1.5rem;
        height: 4rem;
        border: 1px solid transparent;
        border-bottom: 0;
        border-top-right-radius: var(--border-radius);
        border-top-left-radius: var(--border-radius);
      }

      @include hover {
        &:not(.ep-tabs__tab-item--active):not(.router-link-exact-active):hover span {
          border-bottom-color: transparent;
          color: var(--ep-tabs-hover-text-color);
        }
      }

      &--active span,
      &.router-link-exact-active span {
        border-bottom-color: transparent;
        background: var(--interface-surface);
        border-color: var(--border-color);
      }
    }
  }
}