不含 fms-api Java 侧改动与后续导航状态化阶段提交。主要内容: - ui 组件库:date/dropdown/form/input/popover/select/tooltip 调整, 新增 Segmented 组件;fms-table 可编辑单元格渲染器与 FileCard 更新 - 模块管理:列表配置加载自动补全、查询缺配徽标与一键补充、 结构编辑内核 useStructureEditing/useBandLayout、子表表格编辑方式、 分组标题多语言、I18nKeyCell 等(含配套 spec) - 布局/主题/仪表盘/演示页小幅更新
549 lines
19 KiB
Vue
549 lines
19 KiB
Vue
<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>
|