20260816220132
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user