feat(ui): add single selection and keyboard navigation to TagsSelect
Add `multiple` prop to support single-value selection Implement keyboard navigation (Up/Down/Enter) for dropdown options Replace native select elements with TagsSelect across views
This commit is contained in:
@@ -1,17 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
export type TagsSelectOption = {
|
||||
id: number | string;
|
||||
name: string;
|
||||
label?: string;
|
||||
subcategory?: string | null;
|
||||
};
|
||||
|
||||
type OptionRow = {
|
||||
value: string;
|
||||
label: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type CandidateRow = { type: 'option'; id: string; value: string; label: string } | { type: 'create'; id: string };
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id: string;
|
||||
modelValue: string[];
|
||||
modelValue: string[] | string;
|
||||
options: TagsSelectOption[];
|
||||
multiple?: boolean;
|
||||
max?: number;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
@@ -21,6 +31,7 @@ const props = withDefaults(
|
||||
createLabel?: string;
|
||||
}>(),
|
||||
{
|
||||
multiple: true,
|
||||
max: 0,
|
||||
placeholder: '搜索或选择',
|
||||
searchPlaceholder: '搜索',
|
||||
@@ -32,7 +43,7 @@ const props = withDefaults(
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]];
|
||||
'update:modelValue': [value: string[] | string];
|
||||
create: [name: string];
|
||||
}>();
|
||||
|
||||
@@ -40,19 +51,25 @@ const root = ref<HTMLElement | null>(null);
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const isOpen = ref(false);
|
||||
const search = ref('');
|
||||
const activeIndex = ref(-1);
|
||||
|
||||
const optionRows = computed(() =>
|
||||
props.options.map((option) => ({
|
||||
props.options.map((option, index) => ({
|
||||
value: String(option.id),
|
||||
label: option.subcategory ? `${option.name} · ${option.subcategory}` : option.name
|
||||
label: option.label ?? (option.subcategory ? `${option.name} · ${option.subcategory}` : option.name),
|
||||
id: `${props.id}-option-${index}`
|
||||
}))
|
||||
);
|
||||
|
||||
const selectedValues = computed(() => new Set(props.modelValue));
|
||||
const maxReached = computed(() => props.max > 0 && props.modelValue.length >= props.max);
|
||||
const modelValues = computed(() => {
|
||||
if (Array.isArray(props.modelValue)) return props.modelValue;
|
||||
return props.modelValue ? [props.modelValue] : [];
|
||||
});
|
||||
const selectedValues = computed(() => new Set(modelValues.value));
|
||||
const maxReached = computed(() => props.multiple && props.max > 0 && modelValues.value.length >= props.max);
|
||||
|
||||
const selectedRows = computed(() =>
|
||||
props.modelValue
|
||||
modelValues.value
|
||||
.map((value) => optionRows.value.find((option) => option.value === value))
|
||||
.filter((option) => option !== undefined)
|
||||
);
|
||||
@@ -69,16 +86,55 @@ const hasExactMatch = computed(() => {
|
||||
});
|
||||
const canCreate = computed(() => props.allowCreate && createName.value !== '' && !hasExactMatch.value && !maxReached.value);
|
||||
const createText = computed(() => props.createLabel.replace('{name}', createName.value));
|
||||
const optionsListId = computed(() => `${props.id}-options`);
|
||||
const createOptionId = computed(() => `${props.id}-create`);
|
||||
const candidateRows = computed<CandidateRow[]>(() => {
|
||||
const rows: CandidateRow[] = filteredRows.value
|
||||
.filter((option) => isSearchSelectable(option.value))
|
||||
.map((option) => ({ type: 'option', id: option.id, value: option.value, label: option.label }));
|
||||
|
||||
if (canCreate.value) {
|
||||
rows.push({ type: 'create', id: createOptionId.value });
|
||||
}
|
||||
|
||||
return rows;
|
||||
});
|
||||
const activeCandidate = computed(() => candidateRows.value[activeIndex.value]);
|
||||
const activeDescendant = computed(() => activeCandidate.value?.id);
|
||||
|
||||
function setDefaultActiveIndex() {
|
||||
const keyword = createName.value.toLowerCase();
|
||||
const exactIndex = keyword
|
||||
? candidateRows.value.findIndex((candidate) => candidate.type === 'option' && candidate.label.toLowerCase() === keyword)
|
||||
: -1;
|
||||
activeIndex.value = exactIndex >= 0 ? exactIndex : candidateRows.value.length ? 0 : -1;
|
||||
}
|
||||
|
||||
function clampActiveIndex() {
|
||||
if (!candidateRows.value.length) {
|
||||
activeIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeIndex.value < 0) {
|
||||
activeIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
activeIndex.value = Math.min(activeIndex.value, candidateRows.value.length - 1);
|
||||
}
|
||||
|
||||
async function openDropdown() {
|
||||
isOpen.value = true;
|
||||
await nextTick();
|
||||
setDefaultActiveIndex();
|
||||
searchInput.value?.focus();
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
isOpen.value = false;
|
||||
search.value = '';
|
||||
activeIndex.value = -1;
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
@@ -89,25 +145,31 @@ function toggleDropdown() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(value: string) {
|
||||
function updateValue(values: string[]) {
|
||||
emit('update:modelValue', props.multiple ? values : (values[0] ?? ''));
|
||||
}
|
||||
|
||||
function selectOption(value: string) {
|
||||
if (!props.multiple) {
|
||||
updateValue([value]);
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedValues.value.has(value)) {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.filter((item) => item !== value)
|
||||
);
|
||||
updateValue(modelValues.value.filter((item) => item !== value));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!maxReached.value) {
|
||||
emit('update:modelValue', [...props.modelValue, value]);
|
||||
updateValue([...modelValues.value, value]);
|
||||
search.value = '';
|
||||
setDefaultActiveIndex();
|
||||
}
|
||||
}
|
||||
|
||||
function remove(value: string) {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.filter((item) => item !== value)
|
||||
);
|
||||
updateValue(modelValues.value.filter((item) => item !== value));
|
||||
}
|
||||
|
||||
function createOption() {
|
||||
@@ -116,6 +178,37 @@ function createOption() {
|
||||
search.value = '';
|
||||
}
|
||||
|
||||
function isSearchSelectable(value: string) {
|
||||
return !props.multiple || (!selectedValues.value.has(value) && !maxReached.value);
|
||||
}
|
||||
|
||||
function isActiveOption(option: OptionRow) {
|
||||
const candidate = activeCandidate.value;
|
||||
return candidate?.type === 'option' && candidate.value === option.value;
|
||||
}
|
||||
|
||||
function isCreateActive() {
|
||||
return activeCandidate.value?.type === 'create';
|
||||
}
|
||||
|
||||
function moveActive(delta: number) {
|
||||
if (!candidateRows.value.length) return;
|
||||
const nextIndex = activeIndex.value < 0 ? 0 : activeIndex.value + delta;
|
||||
activeIndex.value = (nextIndex + candidateRows.value.length) % candidateRows.value.length;
|
||||
}
|
||||
|
||||
function commitSearch() {
|
||||
const candidate = activeCandidate.value;
|
||||
if (candidate?.type === 'option') {
|
||||
selectOption(candidate.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (candidate?.type === 'create') {
|
||||
createOption();
|
||||
}
|
||||
}
|
||||
|
||||
function onRootKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
closeDropdown();
|
||||
@@ -135,10 +228,13 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
||||
});
|
||||
|
||||
watch(search, setDefaultActiveIndex);
|
||||
watch(candidateRows, clampActiveIndex);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="tags-select" @keydown="onRootKeydown">
|
||||
<div ref="root" class="tags-select" :class="{ 'tags-select--single': !multiple }" @keydown="onRootKeydown">
|
||||
<button
|
||||
:id="id"
|
||||
type="button"
|
||||
@@ -175,19 +271,27 @@ onBeforeUnmount(() => {
|
||||
class="tags-select__search"
|
||||
type="search"
|
||||
:placeholder="searchPlaceholder"
|
||||
:aria-activedescendant="activeDescendant"
|
||||
:aria-controls="optionsListId"
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
@keydown.enter.stop.prevent="commitSearch"
|
||||
@keydown.down.stop.prevent="moveActive(1)"
|
||||
@keydown.up.stop.prevent="moveActive(-1)"
|
||||
/>
|
||||
|
||||
<div class="tags-select__options" role="listbox" aria-multiselectable="true">
|
||||
<div :id="optionsListId" class="tags-select__options" role="listbox" :aria-multiselectable="multiple ? 'true' : undefined">
|
||||
<button
|
||||
v-for="option in filteredRows"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="tags-select__option"
|
||||
:class="{ selected: selectedValues.has(option.value) }"
|
||||
:id="option.id"
|
||||
:class="{ selected: selectedValues.has(option.value), active: isActiveOption(option) }"
|
||||
role="option"
|
||||
:aria-selected="selectedValues.has(option.value)"
|
||||
:disabled="!selectedValues.has(option.value) && maxReached"
|
||||
@click="toggle(option.value)"
|
||||
@click="selectOption(option.value)"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
<span v-if="selectedValues.has(option.value)" class="tags-select__state">已选</span>
|
||||
@@ -196,6 +300,8 @@ onBeforeUnmount(() => {
|
||||
v-if="canCreate"
|
||||
type="button"
|
||||
class="tags-select__option tags-select__create"
|
||||
:id="createOptionId"
|
||||
:class="{ active: isCreateActive() }"
|
||||
:disabled="creating"
|
||||
@click="createOption"
|
||||
>
|
||||
|
||||
@@ -251,11 +251,16 @@ select {
|
||||
}
|
||||
|
||||
.tags-select__option:hover,
|
||||
.tags-select__option.active,
|
||||
.tags-select__option.selected {
|
||||
background: #edf7ef;
|
||||
color: #1f5c40;
|
||||
}
|
||||
|
||||
.tags-select__option.active {
|
||||
box-shadow: inset 0 0 0 2px rgba(31, 111, 80, 0.18);
|
||||
}
|
||||
|
||||
.tags-select__option.selected {
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -532,6 +537,10 @@ select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inline-row .tags-select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inline-row input {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,10 @@ const habitatForm = ref({
|
||||
});
|
||||
|
||||
const selectedConfig = computed(() => configTypes.find((item) => item.key === activeConfigType.value) ?? configTypes[0]);
|
||||
const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name })));
|
||||
const pokemonSelectOptions = computed(() =>
|
||||
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.id} ${pokemon.name}` }))
|
||||
);
|
||||
|
||||
function toIds(values: string[]): number[] {
|
||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||
@@ -503,10 +507,14 @@ onMounted(() => {
|
||||
<div class="field"><label for="pokemon-name">名字</label><input id="pokemon-name" v-model="pokemonForm.name" /></div>
|
||||
<div class="field">
|
||||
<label for="pokemon-environment">喜欢的环境</label>
|
||||
<select id="pokemon-environment" v-model="pokemonForm.environmentId">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="item in options.environments" :key="item.id" :value="item.id">{{ item.name }}</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
id="pokemon-environment"
|
||||
v-model="pokemonForm.environmentId"
|
||||
:options="options.environments"
|
||||
:multiple="false"
|
||||
placeholder="请选择"
|
||||
search-placeholder="搜索喜欢的环境"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="pokemon-skills">特长</label>
|
||||
@@ -560,17 +568,25 @@ onMounted(() => {
|
||||
<div class="field"><label for="item-name">名称</label><input id="item-name" v-model="itemForm.name" /></div>
|
||||
<div class="field">
|
||||
<label for="item-category">分类</label>
|
||||
<select id="item-category" v-model="itemForm.categoryId">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="item in options.itemCategories" :key="item.id" :value="item.id">{{ item.name }}</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
id="item-category"
|
||||
v-model="itemForm.categoryId"
|
||||
:options="options.itemCategories"
|
||||
:multiple="false"
|
||||
placeholder="请选择"
|
||||
search-placeholder="搜索分类"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="item-usage">用途</label>
|
||||
<select id="item-usage" v-model="itemForm.usageId">
|
||||
<option value="">无</option>
|
||||
<option v-for="item in options.itemUsages" :key="item.id" :value="item.id">{{ item.name }}</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
id="item-usage"
|
||||
v-model="itemForm.usageId"
|
||||
:options="options.itemUsages"
|
||||
:multiple="false"
|
||||
placeholder="无"
|
||||
search-placeholder="搜索用途"
|
||||
/>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<label><input v-model="itemForm.dyeable" type="checkbox" /> 可染色</label>
|
||||
@@ -626,10 +642,14 @@ onMounted(() => {
|
||||
<h2>材料单</h2>
|
||||
<div class="field">
|
||||
<label for="recipe-item">物品</label>
|
||||
<select id="recipe-item" v-model="recipeForm.itemId">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="item in itemRows" :key="item.id" :value="String(item.id)">{{ item.name }}</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
id="recipe-item"
|
||||
v-model="recipeForm.itemId"
|
||||
:options="itemSelectOptions"
|
||||
:multiple="false"
|
||||
placeholder="请选择"
|
||||
search-placeholder="搜索物品"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="recipe-methods">入手方式</label>
|
||||
@@ -646,10 +666,14 @@ onMounted(() => {
|
||||
<div class="field">
|
||||
<label>需要材料</label>
|
||||
<div v-for="(row, index) in recipeForm.materials" :key="index" class="inline-row">
|
||||
<select v-model="row.itemId">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="item in itemRows" :key="item.id" :value="String(item.id)">{{ item.name }}</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
:id="`recipe-material-${index}`"
|
||||
v-model="row.itemId"
|
||||
:options="itemSelectOptions"
|
||||
:multiple="false"
|
||||
placeholder="请选择"
|
||||
search-placeholder="搜索物品"
|
||||
/>
|
||||
<input v-model.number="row.quantity" type="number" min="1" />
|
||||
<button type="button" @click="recipeForm.materials.splice(index, 1)">删除</button>
|
||||
</div>
|
||||
@@ -682,10 +706,14 @@ onMounted(() => {
|
||||
<div class="field">
|
||||
<label>配方</label>
|
||||
<div v-for="(row, index) in habitatForm.recipeItems" :key="index" class="inline-row">
|
||||
<select v-model="row.itemId">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="item in itemRows" :key="item.id" :value="String(item.id)">{{ item.name }}</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
:id="`habitat-recipe-item-${index}`"
|
||||
v-model="row.itemId"
|
||||
:options="itemSelectOptions"
|
||||
:multiple="false"
|
||||
placeholder="请选择"
|
||||
search-placeholder="搜索物品"
|
||||
/>
|
||||
<input v-model.number="row.quantity" type="number" min="1" />
|
||||
<button type="button" @click="habitatForm.recipeItems.splice(index, 1)">删除</button>
|
||||
</div>
|
||||
@@ -694,10 +722,14 @@ onMounted(() => {
|
||||
<div class="field">
|
||||
<label>可出现的宝可梦</label>
|
||||
<div v-for="(row, index) in habitatForm.pokemonAppearances" :key="index" class="appearance-row">
|
||||
<select v-model="row.pokemonId">
|
||||
<option value="">Pokemon</option>
|
||||
<option v-for="item in pokemonRows" :key="item.id" :value="String(item.id)">#{{ item.id }} {{ item.name }}</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
:id="`appearance-pokemon-${index}`"
|
||||
v-model="row.pokemonId"
|
||||
:options="pokemonSelectOptions"
|
||||
:multiple="false"
|
||||
placeholder="Pokemon"
|
||||
search-placeholder="搜索 Pokemon"
|
||||
/>
|
||||
<TagsSelect
|
||||
:id="`appearance-maps-${index}`"
|
||||
v-model="row.mapIds"
|
||||
|
||||
@@ -61,18 +61,26 @@ watch([tab, itemQuery], loadItems);
|
||||
|
||||
<div class="field">
|
||||
<label for="category">分类</label>
|
||||
<select id="category" v-model="categoryId">
|
||||
<option value="">全部</option>
|
||||
<option v-for="item in options.itemCategories" :key="item.id" :value="item.id">{{ item.name }}</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
id="category"
|
||||
v-model="categoryId"
|
||||
:options="options.itemCategories"
|
||||
:multiple="false"
|
||||
placeholder="全部"
|
||||
search-placeholder="搜索分类"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="usage">用途</label>
|
||||
<select id="usage" v-model="usageId">
|
||||
<option value="">全部</option>
|
||||
<option v-for="item in options.itemUsages" :key="item.id" :value="item.id">{{ item.name }}</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
id="usage"
|
||||
v-model="usageId"
|
||||
:options="options.itemUsages"
|
||||
:multiple="false"
|
||||
placeholder="全部"
|
||||
search-placeholder="搜索用途"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
||||
@@ -54,12 +54,14 @@ watch(query, loadPokemon);
|
||||
|
||||
<div class="field">
|
||||
<label for="environment">喜欢的环境</label>
|
||||
<select id="environment" v-model="environmentId">
|
||||
<option value="">全部</option>
|
||||
<option v-for="item in options.environments" :key="item.id" :value="item.id">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
id="environment"
|
||||
v-model="environmentId"
|
||||
:options="options.environments"
|
||||
:multiple="false"
|
||||
placeholder="全部"
|
||||
search-placeholder="搜索喜欢的环境"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
||||
Reference in New Issue
Block a user