Files
workspace/code/fms/fms-vue/src/components/ui/select/select.vue
T
oneao 05d0c7f304 chore(fms-vue): WIP 基线快照——收编工作区未提交的前端改动
不含 fms-api Java 侧改动与后续导航状态化阶段提交。主要内容:
- ui 组件库:date/dropdown/form/input/popover/select/tooltip 调整,
  新增 Segmented 组件;fms-table 可编辑单元格渲染器与 FileCard 更新
- 模块管理:列表配置加载自动补全、查询缺配徽标与一键补充、
  结构编辑内核 useStructureEditing/useBandLayout、子表表格编辑方式、
  分组标题多语言、I18nKeyCell 等(含配套 spec)
- 布局/主题/仪表盘/演示页小幅更新
2026-08-30 20:06:19 +08:00

549 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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)
// 选中/清除后焦点会随下拉内容卸载丢到 body,主动交还输入框时会触发 focus 事件,
// 但此时不应重新展开(「聚焦即展开」仅面向用户主动聚焦),用标记抑制这一次。
const suppressNextAutoOpen = ref(false)
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
}
// 程序化归还焦点(选中/清除/取消后):抑制本次聚焦触发的自动展开。
// focus() 在已聚焦时不派发事件,标记可能残留,nextTick 兜底复位避免误吞下一次真实聚焦。
function refocusTrigger() {
suppressNextAutoOpen.value = true
triggerInputRef.value?.focus({ preventScroll: true })
nextTick(() => {
suppressNextAutoOpen.value = false
})
}
// 聚焦(Tab / 点击空白处后聚焦)即展开;已聚焦时再点击浏览器不会重发 focus 事件,
// 故点击也要兜底展开,让用户再次点击触发器即可重新打开下拉
function onTriggerFocus() {
if (suppressNextAutoOpen.value) {
suppressNextAutoOpen.value = false
return
}
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
// 非 filterable 模式焦点在下拉容器上,随浮层卸载会丢失;交还输入框保持键盘流
refocusTrigger()
}
// 供默认插槽使用:按原始值选中并关闭(插槽自定义选项无规范化 option)
function selectValue(value) {
if (props.disabled) return
editing.value = false
emit('update:modelValue', value)
emit('change', value, undefined)
open.value = false
refocusTrigger()
}
function closeDropdown() {
open.value = false
}
function clearValue() {
editing.value = false
filterQuery.value = ''
activeIndex.value = -1
selectedCache.value = null
// 键盘触发清除时按钮会被卸载,先把焦点交还输入框,避免焦点丢失后浮层残留。
refocusTrigger()
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
// Esc 取消:焦点在下拉容器上会随卸载丢失,交还输入框保持键盘流
refocusTrigger()
}
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"
unwrapped
@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>