20260816220132

This commit is contained in:
oneao committed 2026-08-16 22:01:32 +08:00
1 parent b6f9a4c912
commit 72993ecdcd
179 files changed
+20050 -633

No files matched your search

@@ -0,0 +1,525 @@
<script setup>
import { computed, nextTick, ref, useId, watch } from 'vue'
import { Check, ChevronDown, LoaderCircle, X } from '@lucide/vue'
import Popover from '../popover/popover.vue'
/* ---------- props / emits ---------- */
const props = defineProps({
/** 双向绑定的选中值(类型取决于 valueKey 指向的字段,默认 string/number,也可为 object 等) */
modelValue: { type: [String, Number, Object, Boolean], default: undefined },
/** 选项数组;每项默认取 { label, value, disabled },可用 labelKey/valueKey 自定义字段名;
提供时渲染标准选项,未传则渲染默认插槽 */
options: { type: Array, default: undefined },
/** 禁用态 */
disabled: { type: Boolean, default: false },
/** 有值时展示清除按钮 */
clearable: { type: Boolean, default: false },
/** 是否开启筛选(默认开启;开启后直接在触发器输入框输入即可筛选) */
filterable: { type: Boolean, default: true },
/** 自定义筛选函数:(input, option) => boolean;不传则用默认的 label/value 包含匹配 */
filterOption: { type: Function, default: undefined },
/** 远程搜索模式:输入时触发 search 事件,选项由父层更新,不再执行本地筛选 */
remote: { type: Boolean, default: false },
/** 远程数据加载状态;开启时右侧仅展示加载图标 */
loading: { type: Boolean, default: false },
/** 无匹配项时的文案 */
notFoundText: { type: String, default: '无匹配选项' },
/** 是否允许自由输入:true 时类普通 input,可输入任意文本并作为值;false(默认)必须选择下拉项才生效 */
allowFreeInput: { type: Boolean, default: false },
/** 选项对象中作为显示文本的字段名,默认 'label';如后端字段为 name/title 时可用 */
labelKey: { type: String, default: 'label' },
/** 选项对象中作为值的字段名,默认 'value';如后端字段为 id/code 时可用 */
valueKey: { type: String, default: 'value' },
})
const emit = defineEmits(['update:modelValue', 'change', 'search'])
/* ---------- 状态 ---------- */
const dropdownEl = ref(null)
const selectListEl = ref(null)
const selectTriggerRef = ref(null)
const open = ref(false)
const activeIndex = ref(-1)
const filterQuery = ref('')
const triggerInputRef = ref(null)
const triggerFocused = ref(false)
const editing = ref(false)
const selectedCache = ref(null)
const selectId = useId()
const listboxId = `${selectId}-listbox`
/* ---------- 计算属性 ---------- */
// 规范化选项:兼容字符串与对象两种形态,保留原始索引供键盘高亮定位;
// 同时保留 raw(原始数据),供自定义 filterOption 访问原始字段(如 keyword)
const normalizedOptions = computed(() =>
(props.options || []).map((opt, index) => {
if (opt !== null && typeof opt === 'object') {
const rawValue = opt[props.valueKey]
const rawLabel = opt[props.labelKey] ?? rawValue
return {
label: rawLabel == null ? '' : String(rawLabel),
value: rawValue,
selected: Object.is(rawValue, props.modelValue),
disabled: Boolean(opt.disabled),
index,
raw: opt,
}
}
return {
label: String(opt),
value: opt,
selected: Object.is(opt, props.modelValue),
disabled: false,
index,
raw: opt,
}
}),
)
// 可交互选项的原始索引列表(跳过 disabled),键盘导航只在这些索引间移动
const enabledIndices = computed(() =>
visibleOptions.value.reduce((acc, opt) => {
if (!opt.disabled) acc.push(opt.index)
return acc
}, []),
)
const normalizedFilterQuery = computed(() =>
String(filterQuery.value || '')
.trim()
.toLowerCase(),
)
// 默认筛选:对 label 或 value 做大小写不敏感的包含匹配
function defaultFilter(query, option) {
if (!query) return true
return (
String(option.label).toLowerCase().includes(query) ||
String(option.value).toLowerCase().includes(query)
)
}
// 筛选后的可见选项;未开启筛选或与空串匹配时返回全部
const visibleOptions = computed(() =>
!props.filterable || props.remote
? normalizedOptions.value
: normalizedOptions.value.filter((opt) =>
props.filterOption
? props.filterOption(filterQuery.value, opt.raw)
: defaultFilter(normalizedFilterQuery.value, opt),
),
)
const hasValue = computed(
() => props.modelValue !== undefined && props.modelValue !== null && props.modelValue !== '',
)
const selectedOption = computed(() => normalizedOptions.value.find((opt) => opt.selected))
const selectedDisplayLabel = computed(() => {
if (selectedOption.value) return selectedOption.value.label
if (selectedCache.value && Object.is(selectedCache.value.value, props.modelValue)) {
return selectedCache.value.label
}
return String(props.modelValue)
})
// 初次展开时继续回显当前值,避免聚焦导致内容被清空;只有用户实际输入后才展示筛选词。
const inputDisplay = computed(() => {
if (open.value && editing.value) return filterQuery.value
return hasValue.value ? selectedDisplayLabel.value : ''
})
const activeDescendant = computed(() =>
open.value && activeIndex.value >= 0 ? `${listboxId}-option-${activeIndex.value}` : undefined,
)
const hasInputContent = computed(() =>
open.value && editing.value ? filterQuery.value.length > 0 : hasValue.value,
)
const showClear = computed(
() => props.clearable && !props.disabled && hasInputContent.value && triggerFocused.value,
)
watch(
[() => props.modelValue, selectedOption],
([value, option]) => {
if (!hasValue.value) {
selectedCache.value = null
} else if (option) {
selectedCache.value = { value, label: option.label }
} else if (!selectedCache.value || !Object.is(selectedCache.value.value, value)) {
selectedCache.value = null
}
},
{ immediate: true },
)
/* ---------- 打开 / 关闭 ---------- */
// Popover 受控(manual)模式下,@update:open 仅用于接收 Popover 自身发起的关闭意图
// (外部点击 / Esc)并同步回 open;打开由 Select 主动控制,Popover 不会 emit update:open。
function onOpenChange(value) {
open.value = value
}
// 监听 open 变化统一处理打开/关闭副作用:修复 manual 受控下 Popover 不在「打开」路径
// emit update:open,导致宽度同步、高亮、聚焦等逻辑不执行的问题
watch(open, (value) => {
if (value) {
// 展开即清空筛选词:不论之前是否键入过文本,重新打开都加载全部选项(而非沿用上次筛选)。
// 与旧系统 FmsSelect/FmsAutoComplete 的 onFocus 中 immediate("")(拉全量列表)一致。
filterQuery.value = ''
nextTick(() => {
// 筛选词 watcher 完成后再定位选中项,避免清空旧筛选词时把高亮覆盖为第一项。
activeIndex.value =
enabledIndices.value.find((index) =>
Object.is(normalizedOptions.value[index].value, props.modelValue),
) ?? -1
// 触发器输入框承接键入与键盘事件;聚焦下拉容器仅在无输入框场景(默认插槽)兜底
if (props.filterable) triggerInputRef.value?.focus()
else dropdownEl.value?.focus()
scrollActiveIntoView()
})
} else {
activeIndex.value = -1
// 关闭时处理自由输入:允许随意输入则把当前文本作为值提交;否则回填为上次选中值
commitFreeInputOrRevert()
editing.value = false
}
})
// 筛选词变化时:列表随之过滤,高亮重置到首个可见可选项
watch(filterQuery, () => {
if (!open.value) return
activeIndex.value = enabledIndices.value[0] ?? -1
nextTick(resetListScroll)
})
// 远程结果或外部选项替换后重新定位第一项,避免旧索引指向错误或禁用选项。
watch(normalizedOptions, () => {
if (!open.value) return
activeIndex.value = enabledIndices.value[0] ?? -1
nextTick(resetListScroll)
})
/* ---------- 触发器交互 ---------- */
function openDropdown() {
if (props.disabled) return
if (!open.value) open.value = true
}
// 聚焦(Tab / 点击空白处后聚焦)即展开;已聚焦时再点击浏览器不会重发 focus 事件,
// 故点击也要兜底展开,让用户再次点击触发器即可重新打开下拉
function onTriggerFocus() {
openDropdown()
}
// 以整个触发器的焦点为准,让清除按钮也能通过 Tab 获得焦点而不被提前卸载。
function onTriggerFocusIn() {
triggerFocused.value = true
}
function focusRemainsInsideSelect(target) {
return Boolean(
target && (selectTriggerRef.value?.contains(target) || dropdownEl.value?.contains(target)),
)
}
function onSelectFocusOut(event) {
if (focusRemainsInsideSelect(event.relatedTarget)) return
triggerFocused.value = false
if (props.allowFreeInput && editing.value) {
commitFreeInputOrRevert()
editing.value = false
}
open.value = false
}
// 非交互选项按下时保留输入框焦点,避免 focusout 在 click 选中前卸载下拉内容。
function onDropdownPointerDown(event) {
const interactiveSelector =
'a[href], button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
if (event.target.closest?.(interactiveSelector)) return
event.preventDefault()
}
function onTriggerClick() {
triggerInputRef.value?.focus()
openDropdown()
}
function onInput(event) {
if (props.disabled) return
editing.value = true
filterQuery.value = event.target.value
if (props.remote) emit('search', filterQuery.value)
// 键入时不提交 modelValue;远程查询由父层处理,自由输入在关闭或失焦时统一提交。
if (!open.value) open.value = true
}
// 关闭下拉时的收尾逻辑:
// - 自由输入(allowFreeInput):仅在实际编辑后提交原始文本;选择下拉项前会结束编辑,避免覆盖选项值。
// - 非自由输入:若输入框文本精确匹配某选项 label 则选中该项;否则回退到上次选中值。
function commitFreeInputOrRevert() {
if (props.allowFreeInput) {
if (editing.value && !Object.is(filterQuery.value, props.modelValue)) {
emit('update:modelValue', filterQuery.value)
emit('change', filterQuery.value, undefined)
}
filterQuery.value = ''
return
}
const text = filterQuery.value.trim()
if (!text) {
filterQuery.value = hasValue.value ? (selectedOption.value?.label ?? '') : ''
return
}
const match = normalizedOptions.value.find((opt) => !opt.disabled && opt.label === text)
if (match) {
emit('update:modelValue', match.value)
emit('change', match.value, match)
filterQuery.value = ''
} else if (hasValue.value) {
// 输入不匹配任何选项:回退到上次选中值,并重置筛选词
filterQuery.value = selectedOption.value?.label ?? ''
} else {
filterQuery.value = ''
}
}
/* ---------- 选中 / 清除 ---------- */
function selectOption(option) {
if (props.disabled || option.disabled) return
editing.value = false
selectedCache.value = { value: option.value, label: option.label }
emit('update:modelValue', option.value)
emit('change', option.value, option)
open.value = false
}
// 供默认插槽使用:按原始值选中并关闭(插槽自定义选项无规范化 option)
function selectValue(value) {
if (props.disabled) return
editing.value = false
emit('update:modelValue', value)
emit('change', value, undefined)
open.value = false
}
function closeDropdown() {
open.value = false
}
function clearValue() {
editing.value = false
filterQuery.value = ''
activeIndex.value = -1
selectedCache.value = null
// 键盘触发清除时按钮会被卸载,先把焦点交还输入框,避免焦点丢失后浮层残留。
triggerInputRef.value?.focus({ preventScroll: true })
emit('update:modelValue', undefined)
emit('change', undefined, undefined)
}
/* ---------- 键盘导航 ---------- */
function moveActive(direction) {
const indices = enabledIndices.value
if (indices.length === 0) {
activeIndex.value = -1
return
}
const pos = indices.indexOf(activeIndex.value)
const nextPos =
pos === -1
? direction === 1
? 0
: indices.length - 1
: (pos + direction + indices.length) % indices.length
activeIndex.value = indices[nextPos]
nextTick(scrollActiveIntoView)
}
function scrollActiveIntoView() {
dropdownEl.value?.querySelector('.select-option-active')?.scrollIntoView({ block: 'nearest' })
}
function resetListScroll() {
if (selectListEl.value) selectListEl.value.scrollTop = 0
}
function cancelEditing() {
editing.value = false
filterQuery.value = ''
open.value = false
}
function onTriggerKeydown(event) {
// 尚未展开时,方向键 / Home / End / Enter 先展开下拉
if (!open.value && ['ArrowDown', 'ArrowUp', 'Home', 'End', 'Enter'].includes(event.key)) {
event.preventDefault()
open.value = true
return
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
moveActive(1)
break
case 'ArrowUp':
event.preventDefault()
moveActive(-1)
break
case 'Home':
event.preventDefault()
activeIndex.value = enabledIndices.value[0] ?? -1
nextTick(scrollActiveIntoView)
break
case 'End':
event.preventDefault()
activeIndex.value = enabledIndices.value[enabledIndices.value.length - 1] ?? -1
nextTick(scrollActiveIntoView)
break
case 'Enter': {
event.preventDefault()
// 非自由输入模式:若输入框文本恰好匹配某选项,优先按文本选中(避免方向键未移动时的歧义)
if (!props.allowFreeInput) {
const exact = normalizedOptions.value.find(
(opt) => !opt.disabled && opt.label === filterQuery.value.trim(),
)
if (exact) {
selectOption(exact)
break
}
}
const option = normalizedOptions.value[activeIndex.value]
if (option && !option.disabled) selectOption(option)
break
}
case 'Escape':
if (!open.value) break
event.preventDefault()
event.stopPropagation()
cancelEditing()
break
}
}
</script>
<template>
<div class="select" :class="{ 'select-disabled': disabled }">
<Popover
:open="open"
trigger="manual"
placement="bottom-start"
:arrow="false"
:offset="4"
:disabled="disabled"
:follow-trigger-width="true"
@update:open="onOpenChange"
>
<!-- 触发器:类 Input 的框体,直接在此输入框键入即可筛选;含清除按钮与折叠箭头 -->
<template #trigger>
<span
ref="selectTriggerRef"
class="select-trigger"
:class="{
'select-trigger-open': open,
'select-trigger-focused': triggerFocused,
}"
@click="onTriggerClick"
@focusin="onTriggerFocusIn"
@focusout="onSelectFocusOut"
>
<input
ref="triggerInputRef"
class="select-input"
type="text"
:disabled="disabled"
:readonly="!filterable && !allowFreeInput"
:value="inputDisplay"
autocomplete="off"
spellcheck="false"
role="combobox"
aria-haspopup="listbox"
:aria-autocomplete="filterable || allowFreeInput ? 'list' : 'none'"
:aria-expanded="open"
:aria-controls="listboxId"
:aria-activedescendant="activeDescendant"
:aria-busy="loading || undefined"
@input="onInput"
@focus="onTriggerFocus"
@keydown="onTriggerKeydown"
/>
<LoaderCircle v-if="loading" class="select-loading" role="status" aria-label="加载中" />
<button
v-else-if="showClear"
class="select-clear"
type="button"
aria-label="清除"
@pointerdown.prevent
@click.stop="clearValue"
>
<X />
</button>
<ChevronDown
v-else
class="select-arrow"
:class="{ 'select-arrow-open': open }"
aria-hidden="true"
/>
</span>
</template>
<!-- 下拉列表:tabindex 承接键盘事件;宽度由 Popover 的 follow-trigger-width 跟随触发器 -->
<template #content>
<div
ref="dropdownEl"
:id="listboxId"
class="select-dropdown"
role="listbox"
tabindex="-1"
@focusout="onSelectFocusOut"
@pointerdown="onDropdownPointerDown"
@keydown="onTriggerKeydown"
>
<template v-if="options != null">
<div ref="selectListEl" class="select-list">
<div
v-for="opt in visibleOptions"
:key="opt.value"
v-memo="[opt, activeIndex === opt.index]"
:id="`${listboxId}-option-${opt.index}`"
class="select-option"
:class="{
'select-option-active': activeIndex === opt.index,
'select-option-selected': opt.selected,
'select-option-disabled': opt.disabled,
}"
role="option"
:aria-selected="opt.selected"
:aria-disabled="opt.disabled || undefined"
@mouseenter="!opt.disabled && (activeIndex = opt.index)"
@pointerdown.prevent
@click="selectOption(opt)"
>
<span class="select-option-label">{{ opt.label }}</span>
<Check v-if="opt.selected" class="select-option-check" aria-hidden="true" />
</div>
<div v-if="visibleOptions.length === 0" class="select-empty">
{{ notFoundText }}
</div>
</div>
</template>
<!-- 默认插槽:自定义选项逃生舱,透出 select / close 便于调用方控制关闭 -->
<slot v-else :select="selectValue" :close="closeDropdown" />
</div>
</template>
</Popover>
</div>
</template>
<style scoped lang="scss">
@use './index.scss';
</style>