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>