Input Multi Select v1.0
The <e-input-multi-select> component is a component that can be used for .
Usage
An e-input-multi-select has attributes that must be provided,
<e-input-multi-select
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 e-input-multi-select can be found.
Default
<e-input-multi-select
id='selectInputWithOptions'
:value="'exampleOption1'"
:options='selectOptions'
/>
or
<e-input-multi-select
id='selectInputDefault'
:value="'exampleOption1'"
:meta='{
options: selectOptions
}'
/>
Labelled, label='selectInput with Label'
<e-input-multi-select
id="selectInputWithLabel"
:label="'selectInput with Label'"
:value="'exampleOption1'"
:options="selectOptions"
/>
Source Code
e-input-multi-select.vue
<script lang="ts" setup>
import { computed, nextTick, onMounted, onUnmounted, ref, toRef, unref, watch } from 'vue';
import { filter, split, isEmpty, size, join, pullAt } 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_MultiSelect } from 'types/e-input/interfaces/EInput_MultiSelect';
import EIcon from '../e-icon/e-icon.vue';
export type Props = EInput_MultiSelect;
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 { t } = useI18n({ useScope: 'global' });
const { optionSettings, searchHeight, windowSize, getIconRotation } = useDropdownUtils();
const localValue = ref(filter(split(props.value, ', '), (t: string) => !isEmpty(t)));
const searchValue = ref('');
const searchText = ref(t('search.search'));
const searchPlaceholder = ref(t('search.searchPlaceholder'));
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(() => {
return 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 'select-input';
});
const iconRotation = computed(() => {
return getIconRotation(isDisabled.value, isHovered.value, isFocused.value, isOpen.value);
});
const optionList = computed(() => {
let result = [
{
id: '-1',
value: '',
text: t('functions.clear'),
disabled: !isNullable.value,
hidden: !isNullable.value
}
] 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 || '').toLowerCase();
return (
lowerCaseValue.includes(lowerCaseSearchValue) ||
lowerCaseTextValue.includes(lowerCaseSearchValue)
);
});
});
const hasNoOptions = computed(() => size(filteredOptions.value) < 1);
const activeValues = computed(() => {
return (localValue.value || []) as string[];
});
const displayText = computed(() => {
if (isEmpty(activeValues.value)) {
return '-';
} else {
return join(activeValues.value, ', ');
}
});
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) {
isOpen.value = true;
focusSearch();
}
}
}
function optionSelected(event: KeyboardEvent | MouseEvent, option: EFormFieldOptionType) {
if (event.type == 'click') {
(event.target as HTMLInputElement).blur();
}
updateLocalValue(option);
isHovered.value = false;
isFocused.value = false;
const newValue = join(localValue.value, ', ');
emitUpdate(newValue);
}
function updateLocalValue(option: EFormFieldOptionType) {
if (option.value === '') {
localValue.value = [];
} else {
const targetIndex = localValue.value.findIndex(o => o == option.value);
if (targetIndex !== -1) {
pullAt(localValue.value, [targetIndex]);
} else {
localValue.value.push(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 => {
if (isEmpty(newValue as string)) {
localValue.value = [];
} else {
localValue.value = filter(split(newValue, ', '), (t: string) => !isEmpty(t));
}
}
);
watch(
() => isHovered.value,
newValue => {
if (newValue) {
updateFaceRect();
}
}
);
onMounted(() => {
window.addEventListener('click', catchClick);
});
onUnmounted(() => {
window.removeEventListener('click', catchClick);
});
</script>
<template>
<div
class="e-input-multi-select"
@mouseenter="updateFaceRect"
@click.self.stop="!isDisabled && clickFace()"
>
<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="isOpen = !isOpen"
@mouseenter="setHovered(true)"
@mouseleave.self="setHovered(false)"
>
<span v-text="displayText" />
<div class="e-icon-spacer">
<e-icon
v-if="filteredOptions.length"
:icon="['dropdown-arrow']"
:icon-state="iconState"
:style-key="iconStyle"
:rotate="iconRotation"
/>
</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
v-if="isSearchable"
class="option-search"
>
<input
ref="selectSearchInput"
v-model="searchValue"
type="search"
:title="searchText"
:placeholder="searchPlaceholder"
@input="debounceSearchUpdate"
/>
</div>
<div
class="option-list"
:style="optionContainerStyle"
>
<transition-group name="options-transition">
<div
v-if="hasNoOptions"
:key="`${id}-no-options`"
class="fallback-option"
>
<label
class="option-text"
:title="t('search.xFound', [0])"
v-text="t('search.xFound', [0])"
/>
</div>
<div
v-for="option in filteredOptions"
:key="`${id}-${option.id}`"
class="option"
:class="{
active: localValue.includes(option.value),
clear: option.id === '-1'
}"
tabindex="0"
@keydown.space.prevent
@keydown.enter.prevent
@keyup.space.prevent="optionSelected($event, option)"
@keyup.enter.prevent="optionSelected($event, option)"
@click.prevent.stop="optionSelected($event, option)"
>
<input
v-if="option.value"
:id="`${id}-${option.id}`"
type="checkbox"
:name="option.text"
:value="option.value"
:checked="localValue.includes(option.value)"
tabindex="-1"
/>
<label
class="option-text"
:for="`${id}-${option.id}`"
:title="option.text"
v-text="option.text"
/>
</div>
</transition-group>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
$option-height: 26px;
.e-input-multi-select {
align-items: center;
display: flex;
flex: 1 1 auto;
flex-direction: row;
justify-content: space-between;
border: none;
position: relative;
.input-label {
cursor: pointer;
width: 40%;
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 {
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;
}
.e-icon-spacer {
height: 20px;
width: 20px;
flex: 0 0 auto;
}
&: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;
.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%;
border-radius: 0 0 15px 15px;
.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;
font: var(--e-navigation-button-font-family);
margin: 0;
width: 100%;
text-indent: 10px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
.option {
&.clear {
padding-left: 20px;
}
&: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) {
label {
color: var(--e-navigation-button-font-color);
}
&:focus-within {
label {
color: var(--e-secondary);
}
}
&.active {
label {
color: var(--e-accent);
}
}
}
&:focus-within,
&:hover {
background-color: var(--e-navigation-button-hover-color);
cursor: pointer;
label {
color: var(--e-navigation-button-font-hover-color);
}
&.active {
label {
color: var(--e-accent);
}
}
}
}
}
.fallback-option {
background-color: var(--e-background);
color: var(--e-disabled);
user-input: none;
.option-text {
color: var(--e-disabled);
}
}
}
}
}
&.open {
.select-face {
border-radius: 15px 15px 0 0;
background-color: var(--e-background-hover);
span {
color: var(--e-secondary);
}
}
&:not(&.up) {
.select-face {
box-shadow: 7px 7px 7px 1px var(--e-box-shadow-color);
}
.container-wrapper {
.option-container {
border-radius: 0 0 15px 15px;
}
}
}
&.up {
flex-direction: column-reverse;
.select-face {
border-radius: 0 0 15px 15px;
box-shadow: 7px 5px 7px 2px var(--e-box-shadow-color);
}
.container-wrapper {
height: auto;
top: auto;
bottom: 100%;
.option-container {
box-shadow: 7px 0 7px 2px var(--e-box-shadow-color);
border-radius: 15px 15px 0 0;
flex-direction: column-reverse;
.option-list {
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>