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 schema from '#triplit/schema';
|
||||||
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
||||||
import { sortByReleaseDate } from '~/utils/sort';
|
import { sortByReleaseDate } from '~/utils/sort';
|
||||||
|
import RowVirtualizerFixed from './RowVirtualizerFixed.vue';
|
||||||
|
|
||||||
const { openDialog } = useDialog();
|
const { openDialog } = useDialog();
|
||||||
const { allModels } = useModels();
|
const { allModels } = useModels();
|
||||||
@@ -21,6 +22,8 @@ const isOpen = ref(false);
|
|||||||
const dropdownButton = ref<HTMLElement | null>(null);
|
const dropdownButton = ref<HTMLElement | null>(null);
|
||||||
const dropdownContent = ref<HTMLElement | null>(null);
|
const dropdownContent = ref<HTMLElement | null>(null);
|
||||||
const searchInputRef = ref<HTMLInputElement | 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 navigatingWithKeyboard = ref(false);
|
||||||
const focusedOptionId = ref<string | null>(null);
|
const focusedOptionId = ref<string | null>(null);
|
||||||
@@ -61,35 +64,42 @@ const filteredProviders = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const flatOptions = 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) {
|
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);
|
const enabledModels = provider.models.filter(m => m.enabled).sort(sortByReleaseDate);
|
||||||
for (const model of enabledModels) {
|
if (enabledModels.length > 0) {
|
||||||
options.push({ id: model.id, model, provider });
|
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;
|
return options;
|
||||||
});
|
});
|
||||||
|
|
||||||
const focusedIndex = computed(() => {
|
const modelOnlyOptions = computed(() =>
|
||||||
if (!focusedOptionId.value) return -1;
|
flatOptions.value.filter(o => o.type === 'model')
|
||||||
return flatOptions.value.findIndex(o => o.id === focusedOptionId.value);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const setFocusToOption = (index: number) => {
|
const setFocusToModel = (index: number) => {
|
||||||
if (flatOptions.value.length === 0) return;
|
if (modelOnlyOptions.value.length === 0) return;
|
||||||
navigatingWithKeyboard.value = true;
|
navigatingWithKeyboard.value = true;
|
||||||
|
const clampedIndex = Math.max(0, Math.min(index, modelOnlyOptions.value.length - 1));
|
||||||
const clampedIndex = Math.max(0, Math.min(index, flatOptions.value.length - 1));
|
focusedOptionId.value = modelOnlyOptions.value[clampedIndex]!.id;
|
||||||
focusedOptionId.value = flatOptions.value[clampedIndex]!.id;
|
|
||||||
scrollFocusedIntoView();
|
scrollFocusedIntoView();
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollFocusedIntoView = () => {
|
const scrollFocusedIntoView = () => {
|
||||||
nextTick(() => {
|
if (!focusedOptionId.value) return;
|
||||||
const focusedEl = document.getElementById(`model-option-${focusedOptionId.value}`);
|
|
||||||
focusedEl?.scrollIntoView({ block: 'nearest' });
|
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'>) => {
|
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();
|
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, () => {
|
watch(() => props.providers, () => {
|
||||||
if (selectedModel.value) {
|
if (selectedModel.value) {
|
||||||
const modelStillExists = allModels.value.some(model => model.id === selectedModel.value?.id);
|
const modelStillExists = allModels.value.some(model => model.id === selectedModel.value?.id);
|
||||||
@@ -134,7 +153,11 @@ const closeDropdown = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSearchKeyDown = (event: KeyboardEvent) => {
|
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) {
|
switch (event.key) {
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
@@ -143,42 +166,31 @@ const handleSearchKeyDown = (event: KeyboardEvent) => {
|
|||||||
break;
|
break;
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (focusedIndex.value === -1 || focusedIndex.value === flatOptions.value.length - 1) {
|
if (currentModelIndex === -1 || currentModelIndex === modelOnlyOptions.value.length - 1) {
|
||||||
setFocusToOption(0);
|
setFocusToModel(0);
|
||||||
} else {
|
} else {
|
||||||
setFocusToOption(focusedIndex.value + 1);
|
setFocusToModel(currentModelIndex + 1);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (focusedIndex.value === -1 || focusedIndex.value === 0) {
|
if (currentModelIndex === -1 || currentModelIndex === 0) {
|
||||||
setFocusToOption(flatOptions.value.length - 1);
|
setFocusToModel(modelOnlyOptions.value.length - 1);
|
||||||
} else {
|
} else {
|
||||||
setFocusToOption(focusedIndex.value - 1);
|
setFocusToModel(currentModelIndex - 1);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Home':
|
case 'Home':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setFocusToOption(0);
|
setFocusToModel(0);
|
||||||
break;
|
break;
|
||||||
case 'End':
|
case 'End':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setFocusToOption(flatOptions.value.length - 1);
|
setFocusToModel(modelOnlyOptions.value.length - 1);
|
||||||
break;
|
break;
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (focusedOptionId.value) {
|
handleSelectFromFocused();
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -226,7 +238,7 @@ onUnmounted(() => {
|
|||||||
aria-label="Select model"
|
aria-label="Select model"
|
||||||
:aria-activedescendant="focusedOptionId ? `model-option-${focusedOptionId}` : undefined"
|
:aria-activedescendant="focusedOptionId ? `model-option-${focusedOptionId}` : undefined"
|
||||||
:style="floatingStyles"
|
: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">
|
:class="transformOrigin">
|
||||||
|
|
||||||
<div class="relative">
|
<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" />
|
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>
|
||||||
|
|
||||||
<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"
|
<div v-if="filteredProviders.length === 0"
|
||||||
class="px-4 py-8 text-center text-sm text-[var(--text-secondary)]">
|
class="px-4 py-8 text-center text-sm text-[var(--text-secondary)]">
|
||||||
No models found
|
No models found
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-for="provider in filteredProviders" :key="provider.id" class="mb-2">
|
<template v-else>
|
||||||
<div v-if="provider.models.filter(m => m.enabled).length > 0"
|
<RowVirtualizerFixed ref="virtualizerRef" :items="flatOptions" key-field="id"
|
||||||
class="px-4 py-1.5 text-[13px] font-medium text-[var(--text-secondary)] case-capital tracking-wider flex justify-between">
|
:scroll-element="scrollContainerRef" :item-size="42" :overscan="10">
|
||||||
{{ provider.name }}
|
<template v-slot="{ item, index }">
|
||||||
<button
|
<template v-if="item.type === 'header'">
|
||||||
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers', params: provider.id })"
|
<div
|
||||||
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)]">
|
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">
|
||||||
<span class="i-mynaui-cog-four text-4"></span>
|
{{ item.provider.name }}
|
||||||
</button>
|
<button
|
||||||
</div>
|
@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)]">
|
||||||
<button v-for="model in provider.models.filter(m => m.enabled).sort(sortByReleaseDate)"
|
<span class="i-mynaui-cog-four text-4"></span>
|
||||||
:key="model.id" :id="`model-option-${model.id}`" role="option"
|
</button>
|
||||||
:aria-selected="focusedOptionId === model.id" @click="selectModel(model, provider)"
|
</div>
|
||||||
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"
|
</template>
|
||||||
:class="{
|
<template v-else>
|
||||||
'bg-[var(--color-hover)]': selectedModel?.id === model.id,
|
<button :id="`model-option-${item.model.id}`" role="option"
|
||||||
'ring-2 ring-inset ring-[var(--color-accent)]': focusedOptionId === model.id && navigatingWithKeyboard
|
:aria-selected="focusedOptionId === item.model.id"
|
||||||
}">
|
@click="selectModel(item.model, item.provider)"
|
||||||
<ModelInfo :model="model" size="medium" />
|
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"
|
||||||
</button>
|
:class="{
|
||||||
</div>
|
'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>
|
||||||
|
|
||||||
<div class="p-1 border-t border-[var(--color-border)]">
|
<div class="p-1 border-t border-[var(--color-border)]">
|
||||||
|
|||||||
Reference in New Issue
Block a user