feat: virtualize model selector
This commit is contained in:
@@ -5,6 +5,7 @@ import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
||||
import { sortByReleaseDate } from '~/utils/sort';
|
||||
import RowVirtualizerFixed from './RowVirtualizerFixed.vue';
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
const { allModels } = useModels();
|
||||
@@ -21,6 +22,8 @@ const isOpen = ref(false);
|
||||
const dropdownButton = ref<HTMLElement | null>(null);
|
||||
const dropdownContent = ref<HTMLElement | null>(null);
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||
const scrollContainerRef = ref<HTMLDivElement | null>(null);
|
||||
const virtualizerRef = ref<InstanceType<typeof RowVirtualizerFixed> | null>(null);
|
||||
|
||||
const navigatingWithKeyboard = ref(false);
|
||||
const focusedOptionId = ref<string | null>(null);
|
||||
@@ -61,35 +64,42 @@ const filteredProviders = computed(() => {
|
||||
});
|
||||
|
||||
const flatOptions = computed(() => {
|
||||
const options: { id: string; model: Entity<typeof schema, 'models'>; provider: Entity<typeof schema, 'providers'> }[] = [];
|
||||
type FlatItem =
|
||||
| { type: 'header'; id: string; provider: Entity<typeof schema, 'providers'> }
|
||||
| { type: 'model'; id: string; model: Entity<typeof schema, 'models'>; provider: Entity<typeof schema, 'providers'> };
|
||||
|
||||
const options: FlatItem[] = [];
|
||||
for (const provider of filteredProviders.value) {
|
||||
const enabledModels = provider.models.filter(m => m.enabled).sort((a, b) => b.releasedAt && a.releasedAt ? b.releasedAt.getTime() - a.releasedAt.getTime() : 0);
|
||||
for (const model of enabledModels) {
|
||||
options.push({ id: model.id, model, provider });
|
||||
const enabledModels = provider.models.filter(m => m.enabled).sort(sortByReleaseDate);
|
||||
if (enabledModels.length > 0) {
|
||||
options.push({ type: 'header', id: `header-${provider.id}`, provider });
|
||||
for (const model of enabledModels) {
|
||||
options.push({ type: 'model', id: model.id, model, provider });
|
||||
}
|
||||
}
|
||||
}
|
||||
return options;
|
||||
});
|
||||
|
||||
const focusedIndex = computed(() => {
|
||||
if (!focusedOptionId.value) return -1;
|
||||
return flatOptions.value.findIndex(o => o.id === focusedOptionId.value);
|
||||
});
|
||||
const modelOnlyOptions = computed(() =>
|
||||
flatOptions.value.filter(o => o.type === 'model')
|
||||
);
|
||||
|
||||
const setFocusToOption = (index: number) => {
|
||||
if (flatOptions.value.length === 0) return;
|
||||
const setFocusToModel = (index: number) => {
|
||||
if (modelOnlyOptions.value.length === 0) return;
|
||||
navigatingWithKeyboard.value = true;
|
||||
|
||||
const clampedIndex = Math.max(0, Math.min(index, flatOptions.value.length - 1));
|
||||
focusedOptionId.value = flatOptions.value[clampedIndex]!.id;
|
||||
const clampedIndex = Math.max(0, Math.min(index, modelOnlyOptions.value.length - 1));
|
||||
focusedOptionId.value = modelOnlyOptions.value[clampedIndex]!.id;
|
||||
scrollFocusedIntoView();
|
||||
};
|
||||
|
||||
const scrollFocusedIntoView = () => {
|
||||
nextTick(() => {
|
||||
const focusedEl = document.getElementById(`model-option-${focusedOptionId.value}`);
|
||||
focusedEl?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
if (!focusedOptionId.value) return;
|
||||
|
||||
const index = flatOptions.value.findIndex(o => o.id === focusedOptionId.value);
|
||||
if (index !== -1) {
|
||||
virtualizerRef.value?.scrollToIndex(index, { align: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
const selectModel = (model: Entity<typeof schema, 'models'>, provider: Entity<typeof schema, 'providers'>) => {
|
||||
@@ -97,6 +107,15 @@ const selectModel = (model: Entity<typeof schema, 'models'>, provider: Entity<ty
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const handleSelectFromFocused = () => {
|
||||
const option = flatOptions.value.find(o => o.id === focusedOptionId.value);
|
||||
if (option && option.type === 'model') {
|
||||
selectModel(option.model, option.provider);
|
||||
} else if (modelOnlyOptions.value.length > 0) {
|
||||
selectModel(modelOnlyOptions.value[0]!.model, modelOnlyOptions.value[0]!.provider);
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.providers, () => {
|
||||
if (selectedModel.value) {
|
||||
const modelStillExists = allModels.value.some(model => model.id === selectedModel.value?.id);
|
||||
@@ -134,7 +153,11 @@ const closeDropdown = () => {
|
||||
};
|
||||
|
||||
const handleSearchKeyDown = (event: KeyboardEvent) => {
|
||||
if (flatOptions.value.length === 0) return;
|
||||
if (modelOnlyOptions.value.length === 0) return;
|
||||
|
||||
const currentModelIndex = focusedOptionId.value
|
||||
? modelOnlyOptions.value.findIndex(o => o.id === focusedOptionId.value)
|
||||
: -1;
|
||||
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
@@ -143,42 +166,31 @@ const handleSearchKeyDown = (event: KeyboardEvent) => {
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
if (focusedIndex.value === -1 || focusedIndex.value === flatOptions.value.length - 1) {
|
||||
setFocusToOption(0);
|
||||
if (currentModelIndex === -1 || currentModelIndex === modelOnlyOptions.value.length - 1) {
|
||||
setFocusToModel(0);
|
||||
} else {
|
||||
setFocusToOption(focusedIndex.value + 1);
|
||||
setFocusToModel(currentModelIndex + 1);
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
if (focusedIndex.value === -1 || focusedIndex.value === 0) {
|
||||
setFocusToOption(flatOptions.value.length - 1);
|
||||
if (currentModelIndex === -1 || currentModelIndex === 0) {
|
||||
setFocusToModel(modelOnlyOptions.value.length - 1);
|
||||
} else {
|
||||
setFocusToOption(focusedIndex.value - 1);
|
||||
setFocusToModel(currentModelIndex - 1);
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
setFocusToOption(0);
|
||||
setFocusToModel(0);
|
||||
break;
|
||||
case 'End':
|
||||
event.preventDefault();
|
||||
setFocusToOption(flatOptions.value.length - 1);
|
||||
setFocusToModel(modelOnlyOptions.value.length - 1);
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (focusedOptionId.value) {
|
||||
const option = flatOptions.value.find(o => o.id === focusedOptionId.value);
|
||||
if (option) {
|
||||
selectModel(option.model, option.provider);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (flatOptions.value.length > 0) {
|
||||
selectModel(flatOptions.value[0]!.model, flatOptions.value[0]!.provider);
|
||||
}
|
||||
|
||||
handleSelectFromFocused();
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -226,7 +238,7 @@ onUnmounted(() => {
|
||||
aria-label="Select model"
|
||||
:aria-activedescendant="focusedOptionId ? `model-option-${focusedOptionId}` : undefined"
|
||||
:style="floatingStyles"
|
||||
class="fixed z-30 w-full max-w-[420px] min-w-[280px] flex flex-col rounded-xl border border-[var(--color-border)] bg-[var(--bg-surface)] shadow-lg overflow-hidden"
|
||||
class="fixed z-100 w-full max-w-[420px] min-w-[280px] flex flex-col rounded-xl border border-[var(--color-border)] bg-[var(--bg-surface)] shadow-lg overflow-hidden"
|
||||
:class="transformOrigin">
|
||||
|
||||
<div class="relative">
|
||||
@@ -237,34 +249,43 @@ onUnmounted(() => {
|
||||
class="placeholder:text-[var(--text-tertiary)] w-full pl-9 pr-3 py-2 text-sm text-[var(--text-primary)] bg-transparent placeholder-[var(--text-secondary)] outline-none" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto [scrollbar-width:thin] py-2 select-none">
|
||||
<div ref="scrollContainerRef"
|
||||
class="flex-1 overflow-y-auto [scrollbar-width:thin] py-2 select-none">
|
||||
<div v-if="filteredProviders.length === 0"
|
||||
class="px-4 py-8 text-center text-sm text-[var(--text-secondary)]">
|
||||
No models found
|
||||
</div>
|
||||
|
||||
<div v-for="provider in filteredProviders" :key="provider.id" class="mb-2">
|
||||
<div v-if="provider.models.filter(m => m.enabled).length > 0"
|
||||
class="px-4 py-1.5 text-[13px] font-medium text-[var(--text-secondary)] case-capital tracking-wider flex justify-between">
|
||||
{{ provider.name }}
|
||||
<button
|
||||
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers', params: provider.id })"
|
||||
class="flex h-5 w-5 items-center justify-center hover:bg-[var(--color-hover)] rounded transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<span class="i-mynaui-cog-four text-4"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button v-for="model in provider.models.filter(m => m.enabled).sort(sortByReleaseDate)"
|
||||
:key="model.id" :id="`model-option-${model.id}`" role="option"
|
||||
:aria-selected="focusedOptionId === model.id" @click="selectModel(model, provider)"
|
||||
class="text-white w-full min-h-9 px-4 py-2 flex items-center justify-between hover:bg-[var(--color-hover)] transition-colors duration-150"
|
||||
:class="{
|
||||
'bg-[var(--color-hover)]': selectedModel?.id === model.id,
|
||||
'ring-2 ring-inset ring-[var(--color-accent)]': focusedOptionId === model.id && navigatingWithKeyboard
|
||||
}">
|
||||
<ModelInfo :model="model" size="medium" />
|
||||
</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<RowVirtualizerFixed ref="virtualizerRef" :items="flatOptions" key-field="id"
|
||||
:scroll-element="scrollContainerRef" :item-size="42" :overscan="10">
|
||||
<template v-slot="{ item, index }">
|
||||
<template v-if="item.type === 'header'">
|
||||
<div
|
||||
class="px-4 py-1.5 text-[13px] h-10.5 items-end font-medium text-[var(--text-secondary)] case-capital tracking-wider flex justify-between">
|
||||
{{ item.provider.name }}
|
||||
<button
|
||||
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers', params: item.provider.id })"
|
||||
class="flex h-5 w-5 items-center justify-center hover:bg-[var(--color-hover)] rounded transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<span class="i-mynaui-cog-four text-4"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button :id="`model-option-${item.model.id}`" role="option"
|
||||
:aria-selected="focusedOptionId === item.model.id"
|
||||
@click="selectModel(item.model, item.provider)"
|
||||
class="text-white w-full h-10.5 px-4 py-2 flex items-center justify-between hover:bg-[var(--color-hover)] transition-colors duration-150"
|
||||
:class="{
|
||||
'bg-[var(--color-hover)]': selectedModel?.id === item.model.id,
|
||||
'ring-2 ring-inset ring-[var(--color-accent)]': focusedOptionId === item.model.id && navigatingWithKeyboard
|
||||
}">
|
||||
<ModelInfo :model="item.model" size="medium" />
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</RowVirtualizerFixed>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="p-1 border-t border-[var(--color-border)]">
|
||||
|
||||
Reference in New Issue
Block a user