175 lines
5.8 KiB
Vue
175 lines
5.8 KiB
Vue
<script setup lang="ts">
|
|
import { Check, ChevronDown, Search } from 'lucide-vue-next';
|
|
import { computed, nextTick, ref, watch } from 'vue';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import type {
|
|
PropertySelectDropdownEmits,
|
|
PropertySelectDropdownProps,
|
|
} from '@/types';
|
|
|
|
const props = withDefaults(defineProps<PropertySelectDropdownProps>(), {
|
|
disabled: false,
|
|
searchable: false,
|
|
});
|
|
|
|
const emit = defineEmits<PropertySelectDropdownEmits>();
|
|
const maximumVisibleOptions = 12;
|
|
const isOpen = ref(false);
|
|
const searchInput = ref<HTMLInputElement | null>(null);
|
|
const searchQuery = ref('');
|
|
|
|
const normalizedValue = computed(() => String(props.modelValue ?? ''));
|
|
const normalizedSearchQuery = computed(() =>
|
|
searchQuery.value.trim().toLocaleLowerCase(),
|
|
);
|
|
const selectedOption = computed(
|
|
() =>
|
|
props.options.find(
|
|
(option) => String(option.value) === normalizedValue.value,
|
|
) ?? null,
|
|
);
|
|
const visibleOptions = computed(() => {
|
|
const query = normalizedSearchQuery.value;
|
|
|
|
return props.options
|
|
.filter((option) => {
|
|
if (!props.searchable || query === '') {
|
|
return true;
|
|
}
|
|
|
|
return [option.label, option.description].some((value) =>
|
|
value?.toLocaleLowerCase().includes(query),
|
|
);
|
|
})
|
|
.slice(0, maximumVisibleOptions);
|
|
});
|
|
|
|
watch(isOpen, (open) => {
|
|
searchQuery.value = '';
|
|
|
|
if (open && props.searchable) {
|
|
void nextTick(() => searchInput.value?.focus());
|
|
}
|
|
});
|
|
|
|
const focusSearch = (event: Event): void => {
|
|
if (!props.searchable) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
void nextTick(() => searchInput.value?.focus());
|
|
};
|
|
|
|
const handleSearchKeydown = (event: KeyboardEvent): void => {
|
|
if (event.key !== 'Escape' && event.key !== 'Tab') {
|
|
event.stopPropagation();
|
|
}
|
|
};
|
|
|
|
const selectOption = (value: number | string): void => {
|
|
if (
|
|
!props.editable ||
|
|
props.disabled ||
|
|
String(value) === normalizedValue.value
|
|
) {
|
|
return;
|
|
}
|
|
|
|
emit('update:modelValue', value);
|
|
emit('commit', value);
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<DropdownMenu v-if="editable" v-model:open="isOpen">
|
|
<DropdownMenuTrigger as-child>
|
|
<button
|
|
type="button"
|
|
class="focus-visible:ring-ring/50 data-[state=open]:bg-primary/[0.08] data-[state=open]:text-primary hover:bg-primary/[0.055] hover:text-primary group -mx-1 flex min-h-7 w-[calc(100%+0.5rem)] items-center gap-2 rounded px-1 text-left text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-60"
|
|
:aria-label="`Select ${label}`"
|
|
:disabled="disabled"
|
|
>
|
|
<span class="min-w-0 flex-1 truncate">
|
|
{{ selectedOption?.label ?? 'Not set' }}
|
|
</span>
|
|
<ChevronDown
|
|
class="text-muted-foreground size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180"
|
|
/>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
|
|
<DropdownMenuContent
|
|
align="end"
|
|
:side-offset="6"
|
|
class="w-(--reka-dropdown-menu-trigger-width) min-w-52 rounded p-1"
|
|
@open-auto-focus="focusSearch"
|
|
>
|
|
<div
|
|
v-if="searchable"
|
|
class="border-outline-variant bg-surface-container-low mb-1 flex items-center gap-2 rounded border px-2.5 py-2"
|
|
@click.stop
|
|
@pointerdown.stop
|
|
>
|
|
<Search class="text-muted-foreground size-3.5 shrink-0" />
|
|
<input
|
|
ref="searchInput"
|
|
v-model="searchQuery"
|
|
type="search"
|
|
class="placeholder:text-muted-foreground min-w-0 flex-1 border-0 bg-transparent p-0 text-sm outline-none"
|
|
:placeholder="`Search ${label.toLocaleLowerCase()}...`"
|
|
:aria-label="`Search ${label}`"
|
|
@keydown="handleSearchKeydown"
|
|
/>
|
|
</div>
|
|
|
|
<DropdownMenuItem
|
|
v-for="option in visibleOptions"
|
|
:key="String(option.value)"
|
|
class="hover:bg-primary/[0.04] focus:bg-primary/[0.08] cursor-pointer gap-2.5 rounded px-2 py-2"
|
|
:class="
|
|
normalizedValue === String(option.value)
|
|
? 'bg-primary/[0.055] text-primary'
|
|
: ''
|
|
"
|
|
@select="selectOption(option.value)"
|
|
>
|
|
<span class="min-w-0 flex-1">
|
|
<span class="block truncate font-medium">
|
|
{{ option.label }}
|
|
</span>
|
|
<span
|
|
v-if="option.description"
|
|
class="text-muted-foreground block truncate text-[11px]"
|
|
>
|
|
{{ option.description }}
|
|
</span>
|
|
</span>
|
|
<Check
|
|
v-if="normalizedValue === String(option.value)"
|
|
class="text-primary size-4 shrink-0"
|
|
/>
|
|
</DropdownMenuItem>
|
|
|
|
<p
|
|
v-if="visibleOptions.length === 0"
|
|
class="text-muted-foreground px-2 py-4 text-center text-xs"
|
|
>
|
|
No matching {{ label.toLocaleLowerCase() }} found.
|
|
</p>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<div
|
|
v-else
|
|
class="flex min-h-7 min-w-0 items-center gap-2 text-sm font-medium"
|
|
>
|
|
<span class="truncate">{{ selectedOption?.label ?? 'Not set' }}</span>
|
|
</div>
|
|
</template>
|