feat(editor): add keyboard navigation to type selector and column resize to structure editor

- SearchableSelect: support ArrowUp/Down to navigate filtered options, Enter to select, Escape to dismiss
- TableStructureEditor: add drag-to-resize column widths and horizontal scrolling for the columns table
This commit is contained in:
t8y2 committed 2026-05-29 00:22:08 +08:00
1 parent a1e00fcf1a
commit ab12ab5c11
2 files changed
+113 -30

No files matched your search

@@ -39,6 +39,8 @@ const emit = defineEmits<{
const open = ref(false);
const searchText = ref("");
const searchInput = ref<InstanceType<typeof Input>>();
const listContainer = ref<HTMLDivElement>();
const highlightIndex = ref(-1);
const selectedLabel = computed(() => {
if (!props.modelValue) return props.placeholder;
@@ -55,6 +57,7 @@ watch(open, async (value) => {
emit("update:open", value);
if (!value) {
searchText.value = "";
highlightIndex.value = -1;
return;
}
await nextTick();
@@ -62,6 +65,19 @@ watch(open, async (value) => {
input?.focus();
});
watch(searchText, () => {
highlightIndex.value = 0;
});
watch(highlightIndex, async () => {
await nextTick();
const container = listContainer.value;
if (!container || highlightIndex.value < 0) return;
const buttons = container.querySelectorAll("button");
const target = buttons[highlightIndex.value];
target?.scrollIntoView({ block: "nearest" });
});
function selectOption(option: string) {
emit("update:modelValue", option);
open.value = false;
@@ -71,6 +87,34 @@ function selectCustomOption() {
if (!canSelectCustom.value) return;
selectOption(customOptionValue.value);
}
function optionCount() {
return filteredOptions.value.length + (canSelectCustom.value ? 1 : 0);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "ArrowDown") {
event.preventDefault();
const total = optionCount();
if (total === 0) return;
highlightIndex.value = highlightIndex.value < total - 1 ? highlightIndex.value + 1 : 0;
} else if (event.key === "ArrowUp") {
event.preventDefault();
const total = optionCount();
if (total === 0) return;
highlightIndex.value = highlightIndex.value > 0 ? highlightIndex.value - 1 : total - 1;
} else if (event.key === "Enter") {
if (highlightIndex.value < 0 || highlightIndex.value >= optionCount()) return;
event.preventDefault();
if (highlightIndex.value < filteredOptions.value.length) {
selectOption(filteredOptions.value[highlightIndex.value]);
} else {
selectCustomOption();
}
} else if (event.key === "Escape") {
open.value = false;
}
}
</script>
<template>
@@ -101,18 +145,24 @@ function selectCustomOption() {
:placeholder="searchPlaceholder"
class="h-6 border-0 px-0 text-sm shadow-none focus-visible:ring-0"
@update:model-value="(value) => (searchText = String(value))"
@keydown="handleKeydown"
/>
</div>
<div class="max-h-64 overflow-y-auto py-1">
<div ref="listContainer" class="max-h-64 overflow-y-auto py-1">
<div v-if="loading" class="px-2 py-2 text-sm text-muted-foreground">
{{ loadingText }}
</div>
<template v-else-if="filteredOptions.length">
<button
v-for="option in filteredOptions"
v-for="(option, index) in filteredOptions"
:key="option"
type="button"
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
:class="
cn(
'flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
index === highlightIndex && 'bg-accent text-accent-foreground',
)
"
@click="selectOption(option)"
>
<Check :class="cn('h-3.5 w-3.5 shrink-0', option === modelValue ? 'opacity-100' : 'opacity-0')" />
@@ -123,7 +173,12 @@ function selectCustomOption() {
<button
v-if="canSelectCustom"
type="button"
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
:class="
cn(
'flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
filteredOptions.length === highlightIndex && 'bg-accent text-accent-foreground',
)
"
@click="selectCustomOption"
>
<Check class="h-3.5 w-3.5 shrink-0 opacity-0" />
@@ -135,7 +190,12 @@ function selectCustomOption() {
<button
v-else-if="canSelectCustom"
type="button"
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
:class="
cn(
'flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
0 === highlightIndex && 'bg-accent text-accent-foreground',
)
"
@click="selectCustomOption"
>
<Check class="h-3.5 w-3.5 shrink-0 opacity-0" />