src/components/input-and-actions/VueSelect/VueSelect.vue
<template>
<div ref="selectRef" :class="[$style.vueSelect, $attrs.class]">
<div :class="[disabled && $style.disabled, errors.length > 0 && $style.error]" @keydown="onKeyDown">
<vue-text
:for="id"
look="label"
weight="semi-bold"
:color="errors.length > 0 ? 'danger' : 'text-medium'"
:class="[$style.label, hideLabel && 'sr-only']"
as="label"
>
{{ label }}
<sup v-if="required">*</sup>
</vue-text>
<div :class="$style.selectWrapper">
<select
:id="id"
:data-testid="'native-' + id"
:name="name"
:title="label"
:required="required"
:disabled="disabled"
:multiple="multiSelect"
:class="[
$style.nativeSelect,
placeholder && inputValue.length === 0 && $style.hasPlaceholder,
multiSelect && inputValue.length > 1 && $style.hasCount,
$style[size],
]"
v-bind="$attrs"
@input="onInput"
>
<option v-if="placeholder && inputValue.length === 0" value="" disabled selected>{{ placeholder }}</option>
<option
v-for="(option, idx) in options"
:key="`${option.value}-${idx}`"
:value="option.value"
:selected="inputValue.includes(option.value)"
>
{{ option.label }}
</option>
</select>
<div
:id="'custom-' + id"
:data-testid="'custom-' + id"
role="combobox"
:aria-expanded="show"
:aria-label="label"
:class="[
$style.customSelect,
placeholder && inputValue.length === 0 && $style.hasPlaceholder,
multiSelect && inputValue.length > 1 && $style.hasCount,
$style[size],
]"
:tabindex="disabled ? -1 : 0"
aria-haspopup="listbox"
@click.stop.prevent="toggleMenu"
>
{{ displayItem ? displayItem.label : placeholder }}
</div>
<vue-badge
v-if="multiSelect && inputValue.length > 1"
:status="badgeStatus"
:class="$style.count"
@click="toggleMenu"
>
+{{ inputValue.length - 1 }}
</vue-badge>
<div :class="$style.icon" :data-testid="'toggle-' + id" @click.stop.prevent="toggleMenu">
<vue-icon-chevron-down />
</div>
</div>
<vue-text
look="support"
:color="errors.length > 0 ? 'danger' : 'text-low'"
:class="[$style.description, hideDescription && 'sr-only']"
>
{{ errors.length > 0 ? errorMessage : description }}
</vue-text>
</div>
<vue-collapse :show="show" :duration="duration">
<vue-menu
ref="menuRef"
:items="options"
:class="[
$style.menu,
$style[alignXMenu],
$style[alignYMenu],
$style[size],
/* c8 ignore start */ hideLabel && $style.hideLabel /* c8 ignore end */,
]"
@click="onItemClick"
@close="toggleMenu"
/>
</vue-collapse>
</div>
</template>
<script setup lang="ts">
import { computed, ref, nextTick, useCssModule } from 'vue';
import { onClickOutside } from '@vueuse/core';
import { useField } from 'vee-validate';
import { IItem } from '~/interfaces/IItem';
import { getDomRef } from '~/composables/get-dom-ref';
import { BadgeStatus, HorizontalDirection, ShirtSize, VerticalDirection } from '~/components/prop-types';
import VueIconChevronDown from '~/components/icons/VueIconChevronDown.vue';
import VueText from '~/components/typography/VueText/VueText.vue';
import VueCollapse from '~/components/behavior/VueCollapse/VueCollapse.vue';
import VueMenu from '~/components/data-display/VueMenu/VueMenu.vue';
import VueBadge from '~/components/data-display/VueBadge/VueBadge.vue';
// Interface
interface SelectProps {
id: string;
name: string;
label: string;
items: Array<IItem>;
hideLabel?: boolean;
hideDescription?: boolean;
required?: boolean;
validation?: string | null;
modelValue?: string | boolean | number | IItem | Array<string | boolean | number | IItem> | object | unknown;
disabled?: boolean;
placeholder?: string;
description?: string;
errorMessage?: string;
duration?: number;
alignXMenu?: HorizontalDirection;
alignYMenu?: VerticalDirection;
size?: ShirtSize;
multiSelect?: boolean;
badgeStatus?: BadgeStatus;
}
interface SelectEmits {
(event: 'update:modelValue', selected: Array<IItem> | IItem): void;
}
const props = withDefaults(defineProps<SelectProps>(), {
validation: null,
modelValue: () => undefined as Array<string | boolean | number | IItem>,
placeholder: '',
description: '',
errorMessage: '',
duration: 250,
alignXMenu: 'left',
alignYMenu: 'bottom',
size: 'md',
badgeStatus: 'info',
});
const emit = defineEmits<SelectEmits>();
// Deps
const $style = useCssModule();
const { errors, resetField, handleChange } = useField(props.id, props.validation, {
initialValue: props.modelValue,
validateOnValueUpdate: false,
type: 'default',
syncVModel: false,
});
// Data
const selectRef = getDomRef<HTMLElement>(null);
const menuRef = getDomRef<{ focus: (selectedItem?: IItem) => void }>(null);
const show = ref(false);
const inputValue = computed<Array<any>>(() => {
if (Array.isArray(props.modelValue)) {
return props.modelValue.map((v: any | IItem) => getValue(v));
} else {
const value = getValue(props.modelValue);
return /* c8 ignore start */ value !== undefined ? [value] : /* c8 ignore end */ [];
}
});
const options = computed<Array<IItem>>(() =>
props.items.map((item: IItem) => ({
...item,
trailingIcon: inputValue.value.includes(item.value) ? 'checkmark' : null,
})),
);
const displayItem = computed(() => {
if (inputValue.value.length > 0) {
return options.value.find((option) => option.value === inputValue.value[0]);
} else {
return undefined;
}
});
// Methods
const getValue = (valueOrItem: any | IItem) => {
if (valueOrItem !== undefined && valueOrItem?.value !== undefined) {
return valueOrItem.value;
/* c8 ignore start */
} else if (typeof valueOrItem === 'string' && valueOrItem.length === 0) {
return undefined;
} else if (valueOrItem !== undefined && valueOrItem !== null) {
return valueOrItem;
} else {
return undefined;
}
/* c8 ignore end */
};
const open = async () => {
if (props.disabled) {
return;
}
show.value = true;
await nextTick();
if (typeof menuRef.value.focus !== 'undefined') {
menuRef.value?.focus(displayItem.value);
}
};
const close = async (focusInput = true) => {
if (focusInput) {
const customSelect = selectRef.value?.querySelector(`#custom-${props.id}`) as HTMLElement;
customSelect?.focus();
}
show.value = false;
await nextTick();
handleChange(props.multiSelect ? inputValue.value.join('') : inputValue.value);
};
const toggleMenu = () => {
const nativeSelect = document.querySelector(`#${props.id}`) as HTMLSelectElement;
nativeSelect?.focus();
if (show.value === true) {
close();
} else {
open();
}
};
// Event Handler
const onInput = (e: Event) => {
resetField();
const selected: Array<IItem> = [];
const target = e.target as HTMLSelectElement;
const length: number = target.options.length;
for (let i = 0; i < length; i++) {
const option: any = target.options[i];
if (option.selected && option.disabled === false) {
selected.push({ label: option.text, value: option.value });
}
}
handleChange(props.multiSelect ? selected : selected[0]);
emit('update:modelValue', props.multiSelect ? selected : selected[0]);
};
const onItemClick = (item: IItem) => {
resetField();
if (props.multiSelect) {
const selectedValues = inputValue.value.includes(item.value)
? inputValue.value.filter((value) => value !== item.value)
: [...inputValue.value, item.value];
handleChange(selectedValues.length === 0 ? null : selectedValues.join(''));
emit(
'update:modelValue',
props.items.filter((i) => selectedValues.includes(i.value)),
);
} else {
handleChange(item.value);
emit('update:modelValue', item);
close();
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (['Tab', 'ShiftLeft', 'ShiftRight'].includes(e.code)) {
return;
}
e.preventDefault();
e.stopPropagation();
if (e.code === 'Escape') {
close();
} else {
open();
}
};
onClickOutside(selectRef, async () => {
show.value = false;
await nextTick();
});
</script>
<script lang="ts">
export default {
inheritAttrs: false,
};
</script>
<style lang="scss" module>
@import 'assets/_design-system.scss';
.vueSelect {
position: relative;
display: flex;
flex-direction: column;
min-width: $select-min-width;
select::-ms-expand {
display: none;
}
.selectWrapper {
position: relative;
display: flex;
flex-direction: column;
.count {
cursor: pointer;
position: absolute;
top: 50%;
right: $space-40;
transform: translateY(-50%);
}
.icon {
cursor: pointer;
position: absolute;
top: 0;
right: $space-12;
bottom: 0;
display: inline-flex;
align-items: center;
color: $select-color;
i {
width: $select-icon-size;
height: $select-icon-size;
}
}
}
.nativeSelect,
.customSelect {
align-items: center;
outline: none;
color: $select-color;
font-size: $select-font-size;
font-family: $select-font-family;
font-weight: $select-font-weight;
background: $select-background-color;
border: $select-border;
border-radius: $select-border-radius;
padding: $select-padding;
line-height: $select-line-height;
width: 100%;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
cursor: pointer;
&:hover {
outline: none;
border: $select-border-hover;
}
&:focus {
outline: none;
box-shadow: $select-outline;
}
&:active {
outline: none;
}
&.sm {
height: $input-control-sm-height;
}
&.md {
height: $input-control-md-height;
}
&.lg {
height: $input-control-lg-height;
}
&.hasCount {
padding-right: $space-16 + $select-icon-size + $space-52;
}
}
.hasPlaceholder {
color: $select-placeholder-color;
}
.error {
select {
background: $select-bg-error;
border: $select-border-error;
}
.nativeSelect,
.customSelect {
background: $select-bg-error;
border: $select-border-error;
}
}
.disabled {
opacity: $select-disabled-disabled-opacity;
}
.label {
display: flex;
height: $select-label-height;
margin-bottom: $select-label-gap;
}
.description {
display: flex;
height: $select-description-height;
margin-top: $select-description-gap;
}
.customSelect,
.menu {
display: none;
width: 100%;
&.sm {
top: $select-label-height + $select-label-gap + $input-control-sm-height + $select-description-gap;
&.hideLabel {
top: $input-control-sm-height + $select-description-gap;
}
}
&.md {
top: $select-label-height + $select-label-gap + $input-control-md-height + $select-description-gap;
&.hideLabel {
top: $input-control-md-height + $select-description-gap;
}
}
&.lg {
top: $select-label-height + $select-label-gap + $input-control-lg-height + $select-description-gap;
&.hideLabel {
top: $input-control-lg-height + $select-description-gap;
}
}
&.left {
left: 0;
}
&.center {
left: 50%;
transform: translateX(-50%);
}
&.right {
right: 0;
}
&.top {
top: $select-label-height;
transform: translateY(-100%);
&.hideLabel {
top: -$select-description-gap;
}
&.center {
transform: translate(-50%, -100%);
}
}
}
@media (hover: hover) {
.nativeSelect {
display: none;
}
.customSelect,
.menu {
display: flex;
}
}
}
</style>