@econnect/webcomponents-library
Documentation
Styleguide
Documentation
Styleguide
  • Getting Started

    • Installation
    • Patch Notes
    • Styleguide
  • Styling

    • e-connect colors
  • UI Components

    • Form

      • Search Field
      • Form Field
      • Forms
    • Filter

      • Filter
      • Search Filter
    • Grid

      • Grid
      • Grid Row
    • Inputs

      • File Input
      • Input Checkbox
      • Input Dropdown
      • Input Date
      • Input Date Time
      • Input Date Time Local
      • Input File
      • Input Label
      • Input Multi Select
      • Input Number
      • Input Radio
      • Input Range
      • Input Select
      • Input Switch
      • Input Slider
      • Input Text
      • Input TextArea
      • Input Time
      • Input Time Range
    • Controls

      • Booleans
      • Button
      • Checkboxes
      • Date Displays
      • Dropdown Lists
      • Text Displays
    • Headers

      • e-header
    • Cards

      • e-card
    • Images

      • Icon
      • Images
      • Flag Image
      • Hamburger
    • Iframe
    • Pager
    • Settings
    • Document Validation
    • Navigation

      • Navigation
      • Navigation Menu
      • Navigation Footer
      • Navigation Button
      • Navigation Button Item
      • Navigation Search Item

Input Dropdown v1.0

The <e-input-dropdown> component is a component that can be used for .

Usage

An e-input-dropdown has attributes that must be provided,

<e-input-dropdown
  id=""
  label=""
  :options=""
  :meta=""
/>

Attributes

id | Type: String | required
label | Type: String | Default: ''
options | Type: Array | Default: selectOptions

const selectOptions = [
  {
    id: 0,
    text: 'Example Option 1',
    value: 'exampleOption1',
    description: '',
    disabled: false
  },
  {
    id: 1,
    text: 'Example Option 2',
    value: 'exampleOption2',
    description: '',
    disabled: false
  }
]

Examples

Below a few interactive examples of unavailable can be found.

Default
<e-input-dropdown
  id="dropdownInputWithOptions"
  :value="'exampleOption1'"
  :options="selectOptions"
/>

or

<e-input-dropdown
  id="dropdownInputDefault"
  :value="'exampleOption1'"
  :meta="{
    options: selectOptions
  }"
/>
Labelled, label='dropdownInput with Label'
<e-input-dropdown
  id='dropdownInputWithLabel'
  :label="'dropdownInput with Label'"
  :value="'exampleOption1'"
  :options="selectOptions"
/>

Source Code

e-input-dropdown.vue
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, toRef, ref, unref, watch } from 'vue';
import { size, join, isEmpty, filter, find } from 'lodash-es';
import { useDebounceFn } from '@vueuse/core';
import { useI18n } from 'vue-i18n';

import type { EFormFieldOptionType } from 'types/form/EFormFieldOptionType';
import { useMetaUtils, useDropdownUtils } from 'utils';

import { EInput_Dropdown } from 'types/e-input/interfaces/EInput_Dropdown';

import EIcon from '../e-icon/e-icon.vue';

export type Props = EInput_Dropdown;
const props = withDefaults(defineProps<Props>(), {
  id: '',
  label: '',
  showLabel: true,
  inherited: false,
  required: false,
  disabled: false,
  options: () => {
    return [] as EFormFieldOptionType[];
  },
  meta: () => {
    return {
      disabled: false,
      searchable: false,
      nullable: true,
      options: [] as EFormFieldOptionType[]
    };
  }
});

const emit = defineEmits<{
  (e: 'blur', event: FocusEvent): void;
  (e: 'keyup.enter', event: KeyboardEvent): void;
  (e: 'update:value', value: string): void;
  (e: 'update:search', value: string): void;
}>();

const { optionSettings, searchHeight, windowSize, getIconRotation } = useDropdownUtils();

const { t } = useI18n({ useScope: 'global' });

const localValue = ref(props.value);

const searchValue = ref('');

const selectFace = ref<HTMLButtonElement>();
const selectSearchInput = ref<HTMLInputElement>();
const optionsContainer = ref<HTMLDivElement>();

const faceRect = ref({} as DOMRect);

const isOpen = ref(false);
const isHovered = ref(false);
const isFocused = ref(false);

const metaData = computed(() => useMetaUtils(toRef(props.meta)));
const isDisabled = computed(() => unref(metaData).isDisabled.value);
const isNullable = computed(() => unref(metaData).isNullable.value);
const isSearchable = computed(() => unref(metaData).isSearchable.value);

const optionCount = computed(() => size(optionList.value));

const optionsHeight = computed(() => {
  const gapSize = optionSettings.gapSize;
  const optionHeight = optionSettings.optionHeight;

  return optionCount.value * optionHeight + gapSize - gapSize;
});

const gapHeight = computed(() => {
  const gapSize = optionSettings.gapSize;

  return (optionCount.value + 1) * gapSize;
});

const dropdownContentHeight = computed(() => {
  const dynamicHeight = optionsHeight.value + searchHeight.value + gapHeight.value;
  return Math.min(dynamicHeight, optionSettings.maxHeight);
});

const distanceToBottomOfWindow = computed(() => {
  return Math.round(windowSize.value.height - (faceRect.value?.bottom || 0));
});

const isDropUp = computed(() => {
  const bufferRange = optionSettings.bottomBuffer;

  return dropdownContentHeight.value > distanceToBottomOfWindow.value + bufferRange;
});

const stateClasses = computed(() => {
  return {
    disabled: isDisabled.value,
    open: isOpen.value,
    hovered: isHovered.value || isFocused.value
  };
});

const inputClasses = computed(() => {
  return {
    disabled: isDisabled.value,
    open: isOpen.value,
    hovered: isHovered.value || isFocused.value,
    up: isDropUp.value
  };
});

const optionContainerStyle = computed(() => {
  return {
    'max-height': `${optionSettings.maxHeight}px`
  };
});

const iconState = computed(() => {
  const states: string[] = [];

  if (stateClasses.value.disabled) {
    return 'disabled';
  }

  if (stateClasses.value.open) {
    states.push('open');
  }
  if (stateClasses.value.hovered) {
    states.push('hovered');
  }

  if (states.length > 0) {
    return join(states, '-');
  } else {
    return 'default';
  }
});

const iconStyle = computed(() => {
  return 'dropdown-input';
});

const iconRotation = computed(() => {
  return getIconRotation(isDisabled.value, isHovered.value, isFocused.value, isOpen.value);
});

const optionList = computed(() => {
  let result = [] as EFormFieldOptionType[];

  if (!isEmpty(props.options)) {
    result = result.concat(props.options);
  } else if (!isEmpty(props.meta?.options)) {
    result = result.concat(props.meta?.options);
  }

  return result as EFormFieldOptionType[];
});

const filteredOptions = computed(() => {
  const lowerCaseSearchValue = searchValue.value.toLowerCase();

  const visibleOptions = filter(optionList.value, option => !option.hidden);

  return filter(visibleOptions, option => {
    const lowerCaseValue = (option.value || '').toLowerCase();
    const lowerCaseTextValue = (option.text || lowerCaseValue).toLowerCase();

    return (
      lowerCaseValue.includes(lowerCaseSearchValue) ||
      lowerCaseTextValue.includes(lowerCaseSearchValue)
    );
  });
});

const hasNoOptions = computed(() => size(filteredOptions.value) < 1);

const activeOption = computed(() => {
  return find(optionList.value, option => {
    if (isEmpty(option.value)) {
      return false;
    } else {
      return option.value == localValue.value;
    }
  }) as EFormFieldOptionType;
});

const displayText = computed(() => {
  return activeOption.value?.text;
});

function setFocus(newValue: boolean) {
  isFocused.value = newValue;
}

function setHovered(newValue: boolean) {
  isHovered.value = newValue;
}

function clickFace() {
  selectFace.value?.click();
  selectFace.value?.focus();
}

function updateFaceRect() {
  const target = selectFace.value;
  faceRect.value = target?.getBoundingClientRect() ?? ({} as DOMRect);
}

function focusSearch() {
  isOpen.value = true;

  nextTick(() => {
    selectSearchInput.value?.focus();
  });
}

function catchClick(event) {
  const selectFaceClicked = selectFace.value.contains(event.target);
  const isMyOptionsContainer = optionsContainer.value.contains(event.target);

  if (isOpen.value) {
    if (selectFaceClicked) {
      isOpen.value = false;
      isHovered.value = false;
    }
    if (!isMyOptionsContainer) {
      isOpen.value = false;
      isHovered.value = false;
      isFocused.value = false;
    }
  } else {
    if (selectFaceClicked) {
      focusSearch();
    }
  }
}

function optionSelected(event: KeyboardEvent | MouseEvent, option: EFormFieldOptionType) {
  localValue.value = option.value;
  searchValue.value = '';

  isOpen.value = false;
  isHovered.value = false;
  isFocused.value = false;

  // Only focus input if keyboard event
  if ((event as KeyboardEvent).code) {
    selectFace.value?.focus();
  }

  emitUpdate(option.value);
}

function emitUpdate(newValue: string) {
  emit('update:value', newValue);
}

const debounceSearchUpdate = useDebounceFn(emitSearch, 500);

function emitSearch() {
  emit('update:search', searchValue.value);
}

watch(
  () => props.value,
  newValue => {
    localValue.value = newValue;
  }
);

watch(
  () => isHovered.value,
  newValue => {
    if (newValue) {
      updateFaceRect();
    }
  }
);

onMounted(() => {
  window.addEventListener('click', catchClick);
});

onUnmounted(() => {
  window.removeEventListener('click', catchClick);
});
</script>

<template>
  <div
    class="e-input-dropdown"
    @mouseenter="updateFaceRect"
    @click.self.stop="!isDisabled && clickFace()"
  >
    <label
      v-if="label.length > 0 && showLabel"
      class="input-label"
      :title="label"
      tabindex="0"
      @click.prevent.stop="!isDisabled && clickFace()"
      @mouseenter="setHovered(true)"
      @mouseleave.self="setHovered(false)"
      @keydown.space.prevent
      @keydown.enter.prevent
      @keyup.space.prevent="!isDisabled && clickFace()"
      @keyup.enter.prevent="!isDisabled && clickFace()"
      v-text="label"
    />

    <div
      class="input-content"
      :class="inputClasses"
      role="combobox"
      aria-labelledby="select button"
      aria-haspopup="listbox"
      aria-expanded="false"
      aria-controls="select-dropdown"
    >
      <button
        :id="props.id"
        ref="selectFace"
        class="select-face"
        :class="stateClasses"
        type="button"
        :disabled="isDisabled"
        :title="displayText"
        @blur="!isOpen && setFocus(false)"
        @focus.capture="setFocus(true)"
        @keydown.space.prevent
        @keyup.esc.prevent="isOpen && catchClick($event)"
        @keyup.space.prevent="catchClick($event)"
        @mouseenter="setHovered(true)"
        @mouseleave.self="setHovered(false)"
      >
        <input
          v-if="isSearchable && (isOpen || !displayText)"
          ref="selectSearchInput"
          v-model="searchValue"
          class="input-label"
          :title="t('forms.common.dropdown.search')"
          :placeholder="t('forms.common.dropdown.search')"
          @input="debounceSearchUpdate"
        />

        <span
          v-else
          :class="{ 'no-selection': !displayText }"
          v-text="displayText || t('forms.common.dropdown.select')"
        />

        <div
          class="e-icon-spacer"
          :class="{ nullable: isNullable && displayText }"
        >
          <e-icon
            v-if="filteredOptions.length"
            :icon="isNullable && displayText ? ['close'] : ['dropdown-arrow']"
            :icon-state="iconState"
            :style-key="iconStyle"
            :rotate="iconRotation"
            @click="isNullable && displayText && optionSelected($event, { value: null })"
          />
        </div>
      </button>

      <div
        ref="optionsContainer"
        class="container-wrapper"
      >
        <div
          v-show="isOpen"
          class="option-container"
          @keyup.esc.capture.prevent="clickFace()"
          @focusout.self="isOpen = false"
        >
          <div
            class="option-list"
            :style="optionContainerStyle"
          >
            <transition-group name="options-transition">
              <div
                v-if="hasNoOptions"
                :key="`${id}-no-options`"
                class="fallback-option"
              >
                <span
                  class="option-text"
                  :title="t('forms.common.dropdown.xFound', 0)"
                  v-text="t('forms.common.dropdown.xFound', 0)"
                />
              </div>

              <div
                v-for="option in filteredOptions"
                :key="`${id}-${option.id}`"
                class="option"
                :class="{
                  active: option.value === activeOption?.value
                }"
              >
                <input
                  :id="option.id"
                  v-model="localValue"
                  type="radio"
                  :tabindex="-1"
                  :name="id"
                  :disabled="option.disabled === true"
                />
                <label
                  class="option-text"
                  :for="option.id"
                  :tabindex="0"
                  :title="option.text"
                  @keydown.space.prevent
                  @keydown.enter.prevent
                  @keyup.space.prevent="optionSelected($event, option)"
                  @keyup.enter.prevent="optionSelected($event, option)"
                  @click.prevent.stop="optionSelected($event, option)"
                  v-text="option.text"
                />
              </div>
            </transition-group>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped lang="scss">
$option-height: 26px;

.e-input-dropdown {
  align-items: center;
  display: flex;
  flex: 1 1 auto;
  flex-direction: row;
  justify-content: space-between;
  border: none;
  position: relative;

  .input-label {
    cursor: pointer;
    text-align: left;
    white-space: nowrap;
    text-overflow: ellipsis;
    overflow: hidden;
    margin: 0;
  }

  .input-content {
    display: flex;
    flex-direction: column;
    position: relative;
    flex: 1 0 auto;
    width: auto;
    border-radius: 15px;
    max-width: 100%;

    .select-face,
    .option-container {
      background-color: var(--e-input-background);
      border: 1px solid var(--e-input-background-text-color);
      color: var(--e-input-background-text-color);
      text-overflow: ellipsis;
      width: inherit;
      white-space: nowrap;
      max-width: 100%;
    }

    .select-face {
      display: flex;
      gap: 10px;
      border-radius: 15px;
      margin: 0;
      height: calc($option-height + 24px);
      padding: 15px;
      justify-content: space-between;
      align-items: center;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;

      span,
      input {
        color: var(--e-body-font-color);
        font-family: var(--e-body-font-family);
        font-weight: normal;
        font-style: normal;
        text-align: left;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
        width: 100%;
        border: none;
        background-color: unset;
        outline: none;

        &.no-selection,
        &::placeholder {
          color: var(--e-gray-300);
        }
      }

      .e-icon-spacer {
        height: 20px;
        width: 20px;
        flex: 0 0 auto;

        &.nullable {
          padding: 2px;
          cursor: pointer;
        }
      }

      &:disabled {
        border-color: var(--e-disabled);

        span {
          color: var(--e-disabled);
        }
      }
    }

    .container-wrapper {
      top: 100%;
      width: 100%;
      height: 0;
      position: absolute;

      .option-container {
        border-radius: 0 0 15px 15px;
        box-shadow: 7px 7px 7px 1px var(--e-box-shadow-color);
        align-items: flex-start;
        display: flex;
        flex: 1 1 auto;
        flex-direction: column;
        position: relative;
        gap: 1px;
        z-index: 9999;
        overflow: hidden;

        .option-search {
          display: flex;
          position: sticky;
          align-items: center;
          width: 100%;

          input {
            background-color: var(--e-background);
            color: var(--e-text-input-font-color);
            font-family: var(--e-text-input-font-family);
            font-size: var(--e-text-input-font-size);
            font-weight: var(--e-text-input-font-weight);
            line-height: var(--e-text-input-font-line-height);
            text-indent: 10px;
            width: 100%;

            &::placeholder {
              color: var(--e-gray-300);
            }
          }
        }

        .option-list {
          align-items: flex-start;
          display: flex;
          flex-direction: column;
          position: relative;
          gap: 1px;
          z-index: 9999;
          overflow-x: hidden;
          overflow-y: auto;
          width: 100%;

          .options-transition-enter-active,
          .options-transition-leave-active {
            transition: all 0.3s ease-in-out;
          }
          .options-transition-enter-from,
          .options-transition-leave-to {
            opacity: 0;
            transform: scale3d(1, 0, 0);
          }

          .option,
          .fallback-option {
            display: flex;
            align-items: center;
            background-color: var(--e-background);
            border: none;
            margin: 0;
            line-height: $option-height;
            height: $option-height;
            outline: none;
            text-align: left;
            white-space: nowrap;
            width: 100%;

            .option-text {
              background-color: inherit;
              color: inherit;
              cursor: inherit;
              margin: 0;
              width: 100%;
              text-indent: 10px;
              font-size: small;
              white-space: nowrap;
              text-overflow: ellipsis;
              overflow: hidden;
            }
          }

          .option {
            input[type='radio'] {
              display: none;
            }

            &:disabled {
              background-color: var(--e-background);
              color: var(--e-disabled);
              cursor: not-allowed;
              user-input: none;

              .option-text {
                color: var(--e-disabled);
              }
            }

            &:not(&:disabled) {
              &:not(&:hover) {
                color: var(--e-navigation-button-font-color);

                &.active {
                  color: var(--e-accent);
                }
              }

              &:focus-within,
              &:hover {
                background-color: var(--e-navigation-button-hover-color);
                cursor: pointer;

                color: var(--e-navigation-button-font-hover-color);

                &.active {
                  color: var(--e-accent);
                }
              }
            }
          }

          .fallback-option {
            background-color: var(--e-background);
            color: var(--e-disabled);
            user-input: none;

            .option-text {
              color: var(--e-disabled);
            }
          }
        }
      }
    }

    &.up {
      flex-direction: column-reverse;

      .container-wrapper {
        height: auto;
        top: auto;
        bottom: 100%;

        .option-container {
          flex-direction: column-reverse;
        }
      }
    }

    &.open {
      .select-face {
        border-radius: 15px 15px 0 0;
        background-color: var(--e-background-hover);

        span {
          color: var(--e-secondary);
        }
      }

      .container-wrapper {
        .option-container {
          border-radius: 0 0 15px 15px;
        }
      }

      &.up {
        .select-face {
          border-radius: 0 0 15px 15px;
          box-shadow: 7px 7px 7px 1px var(--e-box-shadow-color);
        }

        .container-wrapper {
          .option-container {
            border-radius: 15px 15px 0 0;
          }
        }
      }
    }

    &:not(.disabled) {
      background-color: var(--e-background-hover);

      .select-face {
        cursor: pointer;
      }

      &:not(.open) {
        .select-face {
          &:focus,
          &:hover,
          &.hovered {
            background-color: var(--e-navigation-button-hover-color);

            span {
              color: var(--e-navigation-button-font-hover-color);
            }
          }
        }
      }
    }
  }
}
</style>
Last Updated:: 5/27/25, 10:23 AM
Contributors: AzureAD\MarcelLommers, Marcel Lommers, Antony Elfferich
Prev
Input Checkbox
Next
Input Date