it-tools/src/ui/c-select/c-select.vue
2025-01-12 12:41:42 +01:00

264 lines
6.8 KiB
Vue

<script setup lang="ts" generic="T extends unknown">
import { useAppTheme } from '../theme/themes';
import type { CLabelProps } from '../c-label/c-label.types';
import type { CSelectOption } from './c-select.types';
import { useTheme } from './c-select.theme';
import { clamp } from '@/modules/shared/number.models';
import { useFuzzySearch } from '@/composable/fuzzySearch';
const props = withDefaults(
defineProps<{
options?: CSelectOption<T>[] | string[]
value?: T
placeholder?: string
size?: 'small' | 'medium' | 'large'
searchable?: boolean
} & CLabelProps >(),
{
options: () => [],
value: undefined,
placeholder: undefined,
size: 'medium',
searchable: false,
},
);
const emits = defineEmits(['update:value']);
const { options: rawOptions, placeholder, size: sizeName, searchable } = toRefs(props);
const options = computed(() => {
return rawOptions.value.map((option: string | CSelectOption<T>) => {
if (typeof option === 'string') {
return { label: option, value: option };
}
return option;
});
});
const keys = useMagicKeys();
const value = useVModel(props, 'value', emits);
const theme = useTheme();
const appTheme = useAppTheme();
const isOpen = ref(false);
const selectedOption = shallowRef<CSelectOption<T> | undefined>(options.value.find((option: CSelectOption<T>) => option.value === value.value));
const focusIndex = ref(0);
const elementRef = ref(null);
const size = computed(() => theme.value.sizes[sizeName.value as 'small' | 'medium' | 'large']);
const searchQuery = ref('');
const searchInputRef = ref();
whenever(() => !isOpen.value, () => {
focusIndex.value = 0;
searchQuery.value = '';
});
whenever(() => isOpen.value, () => {
nextTick(() => searchInputRef.value?.focus());
});
onClickOutside(elementRef, close);
whenever(keys.escape, close);
watch(
value,
(newValue) => {
const option = options.value.find((option: CSelectOption<T>) => option.value === newValue);
if (option) {
selectedOption.value = option;
}
},
);
const { searchResult: filteredOptions } = useFuzzySearch<CSelectOption<T>>({
search: searchQuery,
data: options.value,
options: {
keys: ['label'],
shouldSort: false,
threshold: 0.3,
filterEmpty: false,
},
});
function close() {
isOpen.value = false;
}
function toggleOpen() {
isOpen.value = !isOpen.value;
}
function selectOption({ option }: { option: CSelectOption<T> }) {
selectedOption.value = option;
// @ts-expect-error vue template generic is a bit flacky thanks to withDefaults
value.value = option.value;
isOpen.value = false;
}
function handleKeydown(event: KeyboardEvent) {
const { key } = event;
const isEnter = ['Enter'].includes(key);
const isArrowUpOrDown = ['ArrowUp', 'ArrowDown'].includes(key);
const isArrowDown = key === 'ArrowDown';
if (isEnter) {
const valueCanBeSelected = isOpen.value && focusIndex.value !== -1;
if (valueCanBeSelected) {
selectOption({ option: filteredOptions.value[focusIndex.value] });
}
else {
toggleOpen();
}
event.preventDefault();
return;
}
if (isArrowUpOrDown) {
const increment = isArrowDown ? 1 : -1;
focusIndex.value = clamp({
value: focusIndex.value + increment,
min: 0,
max: options.value.length - 1,
});
event.preventDefault();
}
}
function onSearchInput() {
focusIndex.value = 0;
}
</script>
<template>
<c-label v-bind="props">
<div ref="elementRef" relative class="c-select" w-full>
<div
flex flex-nowrap cursor-pointer items-center
:class="{ 'is-open': isOpen, 'important:border-primary': isOpen }"
class="c-select-input"
tabindex="0"
hover:important:border-primary
@click="toggleOpen"
@keydown="handleKeydown"
>
<div flex-1 truncate>
<slot name="displayed-value">
<input v-if="searchable && isOpen" ref="searchInputRef" v-model="searchQuery" type="text" placeholder="Search..." class="search-input" w-full color-current lh-normal @input="onSearchInput">
<span v-else-if="selectedOption" lh-normal>
{{ selectedOption.label }}
</span>
<span v-else class="placeholder" lh-normal>
{{ placeholder ?? 'Select an option' }}
</span>
</slot>
</div>
<icon-mdi-chevron-down class="chevron" />
</div>
<transition name="dropdown">
<div v-show="isOpen" class="c-select-dropdown" absolute z-10 mt-1 max-h-312px w-full overflow-y-auto pretty-scrollbar>
<template v-if="!filteredOptions.length">
<slot name="empty">
<div px-4 py-1 opacity-70>
No results found
</div>
</slot>
</template>
<template v-else>
<div
v-for="(option, index) in filteredOptions"
:key="option.label"
cursor-pointer
px-4
py-1
:class="{ active: selectedOption?.label === option.label, hover: focusIndex === index }"
class="c-select-dropdown-option"
@click="selectOption({ option })"
>
{{ option.label }}
</div>
</template>
</div>
</transition>
</div>
</c-label>
</template>
<style lang="less" scoped>
.c-select {
.search-input{
all: unset;
&::placeholder {
color: v-bind('appTheme.text.mutedColor');
}
}
.c-select-input {
background-color: v-bind('theme.backgroundColor');
border: 1px solid v-bind('theme.borderColor');
border-radius: 4px;
padding: 0 12px;
font-family: inherit;
font-size: v-bind('size.fontSize');
height: v-bind('size.height');
transition: border-color 0.2s ease-in-out;
.placeholder, .chevron {
color: v-bind('appTheme.text.mutedColor');
}
}
.c-select-dropdown {
background-color: v-bind('theme.backgroundColor');
border-radius: 4px;
// box-shadow: rgba(149, 157, 165, 0.2) 0px 8px 24px;
box-shadow: v-bind('theme.dropdownShadow');
font-family: inherit;
font-size: inherit;
line-height: 1;
padding: 6px;
.c-select-dropdown-option{
border-radius: 4px;
padding: 8px 12px;
background-color: transparent;
transition: background-color 0.2s ease-in-out;
&.active {
color: v-bind('theme.option.active.textColor');
}
&:hover, &.hover {
background-color: v-bind('theme.option.hover.backgroundColor');
}
}
}
}
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.2s, transform 0.2s;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.dropdown-enter-to,
.dropdown-leave-from {
opacity: 1;
transform: translateY(0);
}
</style>