chore(fms-vue): WIP 基线快照——收编工作区未提交的前端改动
不含 fms-api Java 侧改动与后续导航状态化阶段提交。主要内容: - ui 组件库:date/dropdown/form/input/popover/select/tooltip 调整, 新增 Segmented 组件;fms-table 可编辑单元格渲染器与 FileCard 更新 - 模块管理:列表配置加载自动补全、查询缺配徽标与一键补充、 结构编辑内核 useStructureEditing/useBandLayout、子表表格编辑方式、 分组标题多语言、I18nKeyCell 等(含配套 spec) - 布局/主题/仪表盘/演示页小幅更新
This commit is contained in:
1 parent
a1079a7150
commit
05d0c7f304
58 files changed
+3698
-1343
No files matched your search
@@ -182,9 +182,9 @@ function onMoreClick(info) {
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* 底部三个操作按钮必须是等宽 flex 子项:在 flex-basis: 0% 下浏览器按
|
||||
内容盒均分剩余空间,任何一方额外的 padding / border 都会让其 border-box
|
||||
更宽。故这里与 .popover-trigger 统一 padding:0 + border:none,
|
||||
/* 底部三个操作按钮(含「更多」Dropdown 零包裹后的 Button)必须是等宽 flex 子项:
|
||||
在 flex-basis: 0% 下浏览器按内容盒均分剩余空间,任何一方额外的 padding / border
|
||||
都会让其 border-box 更宽。故统一 padding:0 + border:none,
|
||||
让三个按钮 border-box 完全相等 */
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
@@ -219,22 +219,6 @@ function onMoreClick(info) {
|
||||
}
|
||||
}
|
||||
|
||||
/* 「更多」按钮被 Dropdown 包裹:Popover 的 .popover-trigger 才是 flex 子项,
|
||||
必须让它与两个直接按钮一样 flex:1 且 padding/border 保持一致,
|
||||
三个操作按钮宽度才会一致 */
|
||||
:deep(.popover-trigger) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
justify-self: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
border: none;
|
||||
|
||||
.button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 操作栏按钮间的竖向分隔线:独立元素,不依赖按钮类名 / 禁用态,
|
||||
|
||||
@@ -11,6 +11,8 @@ const props = defineProps({
|
||||
virtual: { type: Boolean, default: true },
|
||||
virtualX: { type: Boolean, default: true },
|
||||
colResizable: { type: Boolean, default: true },
|
||||
// 边框自绘:true=外框圆角描边+行分隔线(默认);false=无外框仅行分隔线(嵌入卡片场景)。
|
||||
// stk-table 库内边框恒为 false,不用其渐变边框。
|
||||
bordered: { type: [Boolean, String], default: true },
|
||||
noDataFull: { type: Boolean, default: true },
|
||||
showOverflow: { type: Boolean, default: true },
|
||||
@@ -37,6 +39,9 @@ const props = defineProps({
|
||||
default: undefined,
|
||||
validator: (value) => value === 'light' || value === 'dark',
|
||||
},
|
||||
// 行高:唯一真源,同时驱动虚拟滚动计算与库注入的 --row-height 等 CSS 变量,外部勿用 CSS 覆盖
|
||||
rowHeight: { type: Number, default: 34 },
|
||||
headerRowHeight: { type: Number, default: 40 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:columns', 'change', 'row-order-change'])
|
||||
@@ -185,6 +190,7 @@ function rebindActiveEditingRow(rows) {
|
||||
state.value = {
|
||||
editing: true,
|
||||
focusCol: previousState.focusCol,
|
||||
focused: previousState.focused,
|
||||
initialValues: captureEditableValues(replacement),
|
||||
}
|
||||
}
|
||||
@@ -203,12 +209,15 @@ function activateEditing(row, focusCol) {
|
||||
focusCol,
|
||||
initialValues: captureEditableValues(row),
|
||||
}
|
||||
pendingFocusKey = String(getRowKey(row))
|
||||
return
|
||||
}
|
||||
|
||||
const current = state.value
|
||||
if (current.editing && current.focusCol === focusCol) return
|
||||
state.value = { ...current, editing: true, focusCol }
|
||||
// 同行切换列:编辑器会因 focusCol 变化重挂载,标记待聚焦以恢复焦点+全选
|
||||
pendingFocusKey = String(getRowKey(row))
|
||||
}
|
||||
|
||||
function editorValuesEqual(col, previous, current) {
|
||||
@@ -218,11 +227,29 @@ function editorValuesEqual(col, previous, current) {
|
||||
return String(previous ?? '') === String(current ?? '')
|
||||
}
|
||||
|
||||
const selectOnFocus = ref(true)
|
||||
// 最近激活、等待编辑器挂载后聚焦的行 key。用于区分「用户激活单元格(点击/键盘/程序化)」
|
||||
// 与「数据变更引发的重挂载」:前者需要自动聚焦+全选,后者不应聚焦——否则失焦引发的
|
||||
// 重渲染会把焦点抢回来(表现为离开单元格时高亮一闪而过)。
|
||||
let pendingFocusKey = null
|
||||
const editingCtx = {
|
||||
isEditing: (row) => props.editable && getRowEditingState(row)?.value.editing,
|
||||
shouldFocus: (row, col) => getRowEditingState(row)?.value.focusCol === col.dataIndex,
|
||||
shouldSelectOnFocus: selectOnFocus,
|
||||
// 焦点恢复判定依据:重渲染前该行编辑器是否持有焦点(blur 时已置 false)
|
||||
wasFocused: (row) => Boolean(getRowEditingState(row)?.value.focused),
|
||||
consumePendingFocus: (row) => {
|
||||
if (pendingFocusKey == null) return false
|
||||
if (pendingFocusKey !== String(getRowKey(row))) return false
|
||||
pendingFocusKey = null
|
||||
return true
|
||||
},
|
||||
onEditorFocus: (row) => {
|
||||
const state = getRowEditingState(row)
|
||||
if (state?.value) state.value = { ...state.value, focused: true }
|
||||
},
|
||||
onEditorBlur: (row) => {
|
||||
const state = getRowEditingState(row)
|
||||
if (state?.value) state.value = { ...state.value, focused: false }
|
||||
},
|
||||
shouldNotify: (row, col, newValue) => {
|
||||
const values = getRowEditingState(row)?.value.initialValues
|
||||
if (!values?.has(col.dataIndex)) return true
|
||||
@@ -429,14 +456,11 @@ function onWholeRowDragEnd() {
|
||||
wholeRowDragSource.value = -1
|
||||
}
|
||||
|
||||
// 行高同时作为 CSS 变量与 StkTable 的 row-height / header-row-height
|
||||
const rowHeight = 34
|
||||
const headerRowHeight = 40
|
||||
// 行高/表头行高通过 prop 传给 StkTable:库内部会将其注入为滚动容器上的
|
||||
// --row-height / --header-row-height / --footer-row-height,并参与虚拟滚动计算,
|
||||
// 此处只补充库不管理的表头/表尾背景色。
|
||||
const customVars = {
|
||||
'--header-row-height': `${headerRowHeight}px`,
|
||||
'--row-height': `${rowHeight}px`,
|
||||
'--footer-row-height': '36px',
|
||||
'--th-bgc': 'var(--fms-card)',
|
||||
'--th-bgc': 'var(--fms-table-header-bg)',
|
||||
'--tf-bgc': 'var(--fms-background)',
|
||||
}
|
||||
|
||||
@@ -456,7 +480,7 @@ async function scrollToRow(rowKey) {
|
||||
if (!table) return
|
||||
const idx = props.dataSource.findIndex((row) => String(getRowKey(row)) === String(rowKey))
|
||||
if (idx < 0) return
|
||||
table.scrollTo(idx * rowHeight, null)
|
||||
table.scrollTo(idx * props.rowHeight, null)
|
||||
}
|
||||
|
||||
// 新增行等程序化场景使用:滚动到目标行,并让该行立即进入编辑状态。
|
||||
@@ -603,20 +627,11 @@ function handleAreaSelectionChange(ranges) {
|
||||
return
|
||||
}
|
||||
|
||||
// 非键盘导航(鼠标点击等):立即激活。
|
||||
// 同一行已编辑时仍更新 focusCol(保证焦点切换到新列),
|
||||
// 但暂时禁用 select 全选(避免鼠标点击触发全选文本)。
|
||||
// 非键盘导航(鼠标点击等):立即激活。同行切换列时编辑器重挂载并恢复焦点+全选
|
||||
// (直接输入即替换整格内容);点击已聚焦编辑器内部则由原生行为定位光标。
|
||||
pendingEditingTarget = null
|
||||
clearEditActivateTimer()
|
||||
if (activeEditingRow === row) {
|
||||
selectOnFocus.value = false
|
||||
activateEditing(row, focusCol)
|
||||
nextTick(() => {
|
||||
selectOnFocus.value = true
|
||||
})
|
||||
} else {
|
||||
activateEditing(row, focusCol)
|
||||
}
|
||||
activateEditing(row, focusCol)
|
||||
lastEditActivateAt = performance.now()
|
||||
}
|
||||
|
||||
@@ -675,7 +690,10 @@ defineExpose({
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="fms-table"
|
||||
:class="{ 'is-whole-row-drag': rowDraggable && !rowDragHandle }"
|
||||
:class="[
|
||||
{ 'is-whole-row-drag': rowDraggable && !rowDragHandle },
|
||||
bordered ? 'fms-table--bordered' : '',
|
||||
]"
|
||||
@keydown.capture="handleKeydownCapture"
|
||||
@dragstart="onWholeRowDragStart"
|
||||
@dragover="onWholeRowDragOver"
|
||||
@@ -688,8 +706,8 @@ defineExpose({
|
||||
v-bind="$attrs"
|
||||
:style="customVars"
|
||||
:theme="tableTheme"
|
||||
:row-height="rowHeight"
|
||||
:header-row-height="headerRowHeight"
|
||||
:row-height="props.rowHeight"
|
||||
:header-row-height="props.headerRowHeight"
|
||||
:virtual="effectiveVirtual"
|
||||
:virtual-x="virtualX"
|
||||
:smooth-scroll="true"
|
||||
@@ -728,9 +746,14 @@ defineExpose({
|
||||
.fms-table {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
/* 自绘外框:描边沿圆弧走,四角闭合(库内边框恒 false,渐变线不参与)。
|
||||
false 时不描边,嵌入卡片内只留行分隔线 */
|
||||
&.fms-table--bordered {
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 整行拖拽:数据行鼠标提示可拖拽;拖拽中的行半透明、落点高亮 */
|
||||
@@ -769,28 +792,14 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 边框由纯 CSS solid border 实现(不用 stk-table 的渐变),彻底避免渐变叠加和粗细不一致问题。
|
||||
* stk-table bordered=false — 不做任何边框绘制。
|
||||
* 外层 .fms-table div — 圆角外框(border)。
|
||||
* 单元格 border-bottom — 行列间的横线。
|
||||
* 最后一行 tbody tr — 去掉 border-bottom(避免与 div bottom border 重叠)。
|
||||
*/
|
||||
/* 自绘行分隔线(库 bordered=false 时不画任何边框):
|
||||
单元格只画 border-bottom,外框(含四角圆弧描边)由外层 .fms-table--bordered 统一承担,
|
||||
避免库渐变线叠加与最后一行/外框的双线问题 */
|
||||
.fms-table__inner.stk-table {
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
// 表头底部用稍粗的线或同色线均可,这里保持一致
|
||||
thead th {
|
||||
border-bottom-color: var(--fms-border);
|
||||
}
|
||||
|
||||
// 最后一行不画底线,由外层 div 的 border-bottom 统一表现
|
||||
.stk-tbody-main tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fms-checkbox {
|
||||
@@ -867,7 +876,7 @@ defineExpose({
|
||||
|
||||
.fms-cell-editor {
|
||||
width: 100%;
|
||||
height: calc(100% - 4px);
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -879,6 +888,8 @@ defineExpose({
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
// input-wrapper 默认 content-box,height:100% 再加边框会溢出单元格,改边框盒
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.fms-cell-editor-root .select-trigger {
|
||||
@@ -907,8 +918,14 @@ defineExpose({
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 编辑器所在 td 需要确定高度,否则非虚拟模式下(≤100 行,库不给 td 定高,
|
||||
// 只有 tr 有 height)子元素 height:100% 无法解析,Input 会塌成内容高度(约 20px)
|
||||
.fms-table__inner.stk-table .stk-tbody-main td:has(> .fms-cell-editor-root) {
|
||||
height: var(--row-height);
|
||||
}
|
||||
|
||||
.fms-table__inner td:has(> .fms-cell-editor-root) {
|
||||
padding-inline: 3px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
// actions 列按钮容器
|
||||
|
||||
@@ -78,11 +78,17 @@ function isEditorDisabled(editor, row) {
|
||||
function createFocusHandler(ctx, row, col, selectText = false) {
|
||||
return ({ component, el }) => {
|
||||
if (!ctx.shouldFocus(row, col)) return
|
||||
// 仅两类场景聚焦:用户刚激活该单元格(consumePendingFocus 一次性),
|
||||
// 或重渲染前本编辑器就持有焦点(wasFocused,数据回灌重挂载后恢复焦点)。
|
||||
// 其余重挂载不聚焦,避免失焦引发的更新把焦点抢回来。
|
||||
const pending = ctx.consumePendingFocus?.(row)
|
||||
const wasFocused = Boolean(ctx.wasFocused?.(row))
|
||||
if (!pending && !wasFocused) return
|
||||
const input = el?.matches?.('input') ? el : el?.querySelector?.('input')
|
||||
if (document.activeElement === input) return
|
||||
|
||||
const instance = component?.exposed || component?.proxy
|
||||
const shouldSelect = selectText && (ctx.shouldSelectOnFocus?.value ?? true)
|
||||
const shouldSelect = selectText && (pending || wasFocused)
|
||||
const opts = shouldSelect ? { cursor: 'all', preventScroll: true } : { preventScroll: true }
|
||||
if (instance?.focus) {
|
||||
instance.focus(opts)
|
||||
@@ -104,7 +110,9 @@ function createInput(row, col, editor, ctx) {
|
||||
'onUpdate:modelValue': (value) => {
|
||||
setInputDraft(row, col.dataIndex, value)
|
||||
},
|
||||
onFocus: () => ctx.onEditorFocus?.(row),
|
||||
onBlur: (event) => {
|
||||
ctx.onEditorBlur?.(row)
|
||||
const value = event.target.value
|
||||
commitInputDraft(row, col.dataIndex, value)
|
||||
notifyChange(ctx, editor, row, col, value)
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
:deep(svg) {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
/* 补偿光学视觉差:图标十字视觉重心偏高,微调下移与中文字形对齐 */
|
||||
margin-top: 1px;
|
||||
}
|
||||
padding: 0 var(--fms-control-padding-x);
|
||||
font-size: var(--fms-font-size);
|
||||
@@ -21,12 +23,7 @@
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
border-color 150ms ease,
|
||||
color 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: scale(0.97);
|
||||
color 150ms ease;
|
||||
}
|
||||
|
||||
.button:focus-visible {
|
||||
|
||||
@@ -139,6 +139,7 @@ watch(pickerConfig, () => {
|
||||
:offset="8"
|
||||
:disabled="disabled"
|
||||
:follow-trigger-width="true"
|
||||
unwrapped
|
||||
>
|
||||
<template #trigger>
|
||||
<div class="fms-date-trigger" @click="toggle">
|
||||
|
||||
@@ -10,12 +10,6 @@
|
||||
/* 窄容器下保持日历可读(7 列日期格子约 31px),
|
||||
浮层跟随触发器宽度,视觉统一不受影响 */
|
||||
min-width: 240px;
|
||||
|
||||
/* 让触发器撑满组件宽度(覆盖 popover 默认 inline-flex) */
|
||||
.popover-trigger {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 触发器(类 Input 外观) ---------- */
|
||||
@@ -24,7 +18,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: var(--fms-control-height);
|
||||
/* min-height 而非 height:与 Input/Select 一致,内容不足时撑到控件高度,
|
||||
被放进更矮的容器(如表格编辑单元格)时可被 min-height:0 压缩 */
|
||||
min-height: var(--fms-control-height);
|
||||
padding: 0 var(--fms-control-padding-x);
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
|
||||
@@ -195,6 +195,7 @@ watch(pickerConfig, () => {
|
||||
:offset="8"
|
||||
:disabled="disabled"
|
||||
:follow-trigger-width="true"
|
||||
unwrapped
|
||||
>
|
||||
<template #trigger>
|
||||
<div class="fms-range-trigger" @click="toggle">
|
||||
|
||||
@@ -6,6 +6,7 @@ import Input from '../input/input.vue'
|
||||
|
||||
const popoverOpen = ref(false)
|
||||
const popoverKeyword = ref('')
|
||||
const popoverUserClicks = ref(0)
|
||||
const popoverPlacements = [
|
||||
'top',
|
||||
'top-start',
|
||||
@@ -32,20 +33,20 @@ const popoverPlacements = [
|
||||
|
||||
<p class="demo-label">基础用法</p>
|
||||
<div class="demo-panel">
|
||||
<Popover trigger="click" placement="top" arrow>
|
||||
<Popover trigger="click" placement="top" arrow unwrapped>
|
||||
<template #trigger><Button>点击打开</Button></template>
|
||||
<template #content>
|
||||
<p class="popover-demo-title">标题</p>
|
||||
<p class="popover-demo-text">这是一段浮层内容,用于说明当前操作。</p>
|
||||
</template>
|
||||
</Popover>
|
||||
<Popover trigger="hover" placement="top" arrow>
|
||||
<Popover trigger="hover" placement="top" arrow unwrapped>
|
||||
<template #trigger><Button type="outline">悬停打开</Button></template>
|
||||
<template #content>
|
||||
<p class="popover-demo-text">hover 触发的浮层,移入浮层内不会关闭。</p>
|
||||
</template>
|
||||
</Popover>
|
||||
<Popover trigger="focus" placement="top" arrow>
|
||||
<Popover trigger="focus" placement="top" arrow unwrapped>
|
||||
<template #trigger><Input /></template>
|
||||
<template #content>
|
||||
<p class="popover-demo-text">focus 触发,失焦后关闭。</p>
|
||||
@@ -55,7 +56,14 @@ const popoverPlacements = [
|
||||
|
||||
<p class="demo-label">十二方位</p>
|
||||
<div class="demo-panel popover-placement-grid">
|
||||
<Popover v-for="p in popoverPlacements" :key="p" trigger="hover" :placement="p" arrow>
|
||||
<Popover
|
||||
v-for="p in popoverPlacements"
|
||||
:key="p"
|
||||
trigger="hover"
|
||||
:placement="p"
|
||||
arrow
|
||||
unwrapped
|
||||
>
|
||||
<template #trigger
|
||||
><Button type="outline">{{ p }}</Button></template
|
||||
>
|
||||
@@ -67,7 +75,7 @@ const popoverPlacements = [
|
||||
|
||||
<p class="demo-label">携带表单内容</p>
|
||||
<div class="demo-panel">
|
||||
<Popover trigger="click" placement="bottom-start">
|
||||
<Popover trigger="click" placement="bottom-start" unwrapped>
|
||||
<template #trigger><Button>筛选</Button></template>
|
||||
<template #content>
|
||||
<div class="popover-demo-form">
|
||||
@@ -80,16 +88,47 @@ const popoverPlacements = [
|
||||
|
||||
<p class="demo-label">禁用与受控</p>
|
||||
<div class="demo-panel">
|
||||
<Popover trigger="click" placement="top" arrow disabled>
|
||||
<Popover trigger="click" placement="top" arrow disabled unwrapped>
|
||||
<template #trigger><Button type="outline">禁用</Button></template>
|
||||
<template #content><p class="popover-demo-text">禁用后不响应触发</p></template>
|
||||
</Popover>
|
||||
<Popover v-model:open="popoverOpen" trigger="manual" placement="top" arrow>
|
||||
<Popover v-model:open="popoverOpen" trigger="manual" placement="top" arrow unwrapped>
|
||||
<template #trigger><Button type="outline">manual 受控</Button></template>
|
||||
<template #content><p class="popover-demo-text">由外部状态控制开关</p></template>
|
||||
</Popover>
|
||||
<Button type="secondary" @click="popoverOpen = true">打开</Button>
|
||||
<Button type="secondary" @click="popoverOpen = false">关闭</Button>
|
||||
</div>
|
||||
|
||||
<p class="demo-label">零包裹模式(unwrapped)</p>
|
||||
<div class="demo-panel">
|
||||
<Popover trigger="click" placement="top" arrow unwrapped>
|
||||
<template #trigger><Button>组件作触发器</Button></template>
|
||||
<template #content>
|
||||
<p class="popover-demo-text">事件注入到 Button 组件根元素,DOM 中无中间包裹层。</p>
|
||||
</template>
|
||||
</Popover>
|
||||
|
||||
<Popover trigger="click" placement="top" arrow unwrapped>
|
||||
<template #trigger
|
||||
><Button @click="popoverUserClicks++">点击(用户 + 内部)</Button></template
|
||||
>
|
||||
<template #content>
|
||||
<p class="popover-demo-text">
|
||||
用户 @click 与内部 toggle 均触发,互不覆盖(已点击 {{ popoverUserClicks }} 次)。
|
||||
</p>
|
||||
</template>
|
||||
</Popover>
|
||||
|
||||
<Popover trigger="click" placement="top" arrow unwrapped>
|
||||
<template #trigger>
|
||||
<Button type="outline">多根</Button>
|
||||
<Button type="outline">fragment</Button>
|
||||
</template>
|
||||
<template #content>
|
||||
<p class="popover-demo-text">多根插槽无法注入事件,自动回退到 span 包裹层。</p>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { BarChart3, CalendarDays, LayoutGrid, List } from '@lucide/vue'
|
||||
import Segmented from '../segmented/segmented.vue'
|
||||
|
||||
const period = ref('daily')
|
||||
const view = ref('list')
|
||||
const align = ref('left')
|
||||
const withIcon = ref('list')
|
||||
const blockValue = ref('list')
|
||||
const verticalValue = ref('list')
|
||||
const singleDisabled = ref('list')
|
||||
const groupDisabled = ref('list')
|
||||
|
||||
const periodOptions = [
|
||||
{ label: '日', value: 'daily' },
|
||||
{ label: '周', value: 'weekly' },
|
||||
{ label: '月', value: 'monthly' },
|
||||
]
|
||||
|
||||
const viewOptions = [
|
||||
{ label: '列表', value: 'list', icon: List },
|
||||
{ label: '卡片', value: 'grid', icon: LayoutGrid },
|
||||
{ label: '日历', value: 'calendar', icon: CalendarDays },
|
||||
{ label: '图表', value: 'chart', icon: BarChart3 },
|
||||
]
|
||||
|
||||
// 省略 label 即渲染为纯图标选项
|
||||
const iconOnlyOptions = viewOptions.map((item) => ({ value: item.value, icon: item.icon }))
|
||||
const disabledOptions = [
|
||||
...viewOptions.slice(0, 3),
|
||||
{ label: '图表(禁用)', value: 'chart', disabled: true },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="showcase-section" aria-labelledby="segmented-title">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">Components</p>
|
||||
<h2 id="segmented-title">Segmented 分段控制器</h2>
|
||||
<p>在一组互斥选项中切换,等价于带底座滑块的单选组,支持图标、尺寸与方向键导航。</p>
|
||||
</div>
|
||||
|
||||
<p class="demo-label">基础用法(当前值:{{ period }})</p>
|
||||
<div class="demo-panel">
|
||||
<Segmented v-model="period" :options="periodOptions" />
|
||||
</div>
|
||||
|
||||
<p class="demo-label">带图标</p>
|
||||
<div class="demo-panel">
|
||||
<Segmented v-model="withIcon" :options="viewOptions" />
|
||||
</div>
|
||||
|
||||
<p class="demo-label">仅图标(省略 label)</p>
|
||||
<div class="demo-panel">
|
||||
<Segmented v-model="view" :options="iconOnlyOptions" />
|
||||
</div>
|
||||
|
||||
<p class="demo-label">全圆角</p>
|
||||
<div class="demo-panel">
|
||||
<Segmented v-model="align" shape="round" :options="periodOptions" />
|
||||
</div>
|
||||
|
||||
<p class="demo-label">通栏(block)</p>
|
||||
<div class="demo-panel">
|
||||
<Segmented v-model="blockValue" block :options="viewOptions" />
|
||||
</div>
|
||||
|
||||
<p class="demo-label">纵向</p>
|
||||
<div class="demo-panel">
|
||||
<Segmented v-model="verticalValue" vertical :options="viewOptions" />
|
||||
</div>
|
||||
|
||||
<p class="demo-label">禁用态</p>
|
||||
<div class="demo-panel demo-panel-column">
|
||||
<Segmented v-model="singleDisabled" :options="disabledOptions" />
|
||||
<Segmented v-model="groupDisabled" disabled :options="periodOptions" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -28,8 +28,9 @@ const props = defineProps({
|
||||
mouseLeaveDelay: { type: Number, default: 200 },
|
||||
/** 与触发器的间距(px) */
|
||||
offset: { type: Number, default: 4 },
|
||||
/** 触发器是否撑满父容器宽度;透传给 Popover(见 Popover.stretchTrigger) */
|
||||
stretchTrigger: { type: Boolean, default: false },
|
||||
/** 零包裹模式(默认开启):把事件注入到触发器单根 vnode,不再包 span;
|
||||
布局由用户内容自己决定;多根/文本节点自动回退到 Popover 的 span 包裹层 */
|
||||
unwrapped: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open', 'openChange', 'click'])
|
||||
@@ -180,7 +181,7 @@ provide('dropdownActiveIndex', activeIndex)
|
||||
:disabled="disabled"
|
||||
:arrow="false"
|
||||
:offset="offset"
|
||||
:stretch-trigger="stretchTrigger"
|
||||
:unwrapped="unwrapped"
|
||||
:mouse-enter-delay="mouseEnterDelay"
|
||||
:mouse-leave-delay="mouseLeaveDelay"
|
||||
@update:open="onOpenChange"
|
||||
|
||||
@@ -54,13 +54,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 子菜单项包裹在嵌套 Popover 的 trigger 中(inline-flex 会收缩宽度),
|
||||
改为块级填满 .dropdown-menu,保证整行可 hover / 可点击 */
|
||||
.dropdown-menu :deep(.popover-trigger) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown-item:hover:not(.dropdown-item-disabled),
|
||||
.dropdown-item-active {
|
||||
background: var(--fms-secondary-hover);
|
||||
|
||||
@@ -43,6 +43,7 @@ function onItemClick(item) {
|
||||
placement="right-start"
|
||||
:offset="0"
|
||||
:arrow="false"
|
||||
unwrapped
|
||||
>
|
||||
<template #trigger>
|
||||
<span
|
||||
|
||||
@@ -41,7 +41,7 @@ const rootEl = ref(null)
|
||||
/* ---------- 合并规则 ---------- */
|
||||
const mergedRules = computed(() => {
|
||||
if (Array.isArray(props.rules) && props.rules.length > 0) return props.rules
|
||||
return form?.rules?.[props.name] || []
|
||||
return form?.rules?.value?.[props.name] || []
|
||||
})
|
||||
|
||||
/* ---------- 状态 ---------- */
|
||||
@@ -96,7 +96,7 @@ async function runValidation() {
|
||||
const nextErrors = await validateRules({
|
||||
name: props.name,
|
||||
label: props.label,
|
||||
value: form?.model?.[props.name],
|
||||
value: form?.model?.value?.[props.name],
|
||||
rules: mergedRules.value,
|
||||
})
|
||||
errors.value = nextErrors
|
||||
@@ -119,9 +119,14 @@ function clearValidation() {
|
||||
// change 触发:值变化即校验。watch 非 immediate,初始值不会触发报红;
|
||||
// 重置后值被父层清空时(值空且未触碰过)也跳过,避免重置即整片报红
|
||||
watch(
|
||||
() => form?.model?.[props.name],
|
||||
(value) => {
|
||||
() => [form?.model?.value, form?.model?.value?.[props.name]],
|
||||
([model, value], [prevModel] = []) => {
|
||||
if (validateTrigger.value !== 'change') return
|
||||
// model 对象被父级整体替换(如切换编辑目标):视为全新表单,清除校验态,不触发报红
|
||||
if (model !== prevModel) {
|
||||
clearValidation()
|
||||
return
|
||||
}
|
||||
if (!touched.value && isEmptyValue(value)) return
|
||||
touched.value = true
|
||||
runValidation()
|
||||
@@ -138,7 +143,7 @@ function onRootFocusOut(event) {
|
||||
window.clearTimeout(blurTimer)
|
||||
blurTimer = window.setTimeout(() => {
|
||||
// 首次失焦且值为空:仅标记触碰,不报错,避免打开表单即整片报红
|
||||
if (!touched.value && isEmptyValue(form?.model?.[props.name])) {
|
||||
if (!touched.value && isEmptyValue(form?.model?.value?.[props.name])) {
|
||||
touched.value = true
|
||||
return
|
||||
}
|
||||
|
||||
@@ -95,10 +95,11 @@ function onReset(event) {
|
||||
}
|
||||
|
||||
/* ---------- 上下文 ---------- */
|
||||
// 下发给 FormItem:简单值用 computed 包裹保证响应式,model / rules 直接传引用
|
||||
// 下发给 FormItem:model / rules 用 computed 包裹——父级可能整体替换这两个 prop 的对象
|
||||
// (如切换编辑目标、克隆草稿),provide 只执行一次,直接传引用会让 FormItem 校验读到过期对象
|
||||
provide('fms-form', {
|
||||
model: props.model,
|
||||
rules: props.rules,
|
||||
model: computed(() => props.model),
|
||||
rules: computed(() => props.rules),
|
||||
layout: computed(() => props.layout),
|
||||
labelAlign: computed(() => props.labelAlign),
|
||||
labelWidth: computed(() => props.labelWidth),
|
||||
|
||||
@@ -19,6 +19,7 @@ import Popover from './popover/popover.vue'
|
||||
import Radio from './radio/radio.vue'
|
||||
import RadioGroup from './radio/radio-group.vue'
|
||||
import Scrollbar from './scrollbar/scrollbar.vue'
|
||||
import Segmented from './segmented/segmented.vue'
|
||||
import Select from './select/select.vue'
|
||||
import Spin from './spin/spin.vue'
|
||||
import Splitter from './splitter/splitter.vue'
|
||||
@@ -51,6 +52,7 @@ export {
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Scrollbar,
|
||||
Segmented,
|
||||
Select,
|
||||
Spin,
|
||||
Splitter,
|
||||
|
||||
@@ -256,9 +256,14 @@ onMounted(() => {
|
||||
nextTick(measureDefaultTextareaHeight)
|
||||
})
|
||||
|
||||
/* 程序化聚焦/选中文本(内联重命名等场景需要 focus + select) */
|
||||
/* 程序化聚焦/选中文本(内联重命名、表格单元格编辑等场景需要 focus + select) */
|
||||
defineExpose({
|
||||
focus: () => inputEl.value?.focus(),
|
||||
// opts:preventScroll 透传原生 focus;cursor: 'all' 表示聚焦后全选内容
|
||||
focus: (opts = undefined) => {
|
||||
const nativeOpts = opts?.preventScroll !== undefined ? { preventScroll: opts.preventScroll } : undefined
|
||||
inputEl.value?.focus(nativeOpts)
|
||||
if (opts?.cursor === 'all') inputEl.value?.select()
|
||||
},
|
||||
select: () => inputEl.value?.select(),
|
||||
blur: () => inputEl.value?.blur(),
|
||||
})
|
||||
|
||||
@@ -4,12 +4,6 @@
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
/* 触发器撑满父容器(stretch-trigger):结构树等需要占满整行可用宽度的场景 */
|
||||
.popover-trigger--stretch {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 在完整轮廓上统一投影,让箭头和主体拥有连续的视觉重量。 */
|
||||
.popover {
|
||||
position: fixed;
|
||||
@@ -35,8 +29,8 @@
|
||||
/* 与 Antdv Next 一致:箭头是浮层轮廓的一部分,不使用独立边框或阴影。 */
|
||||
.popover-arrow {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
@@ -47,38 +41,40 @@
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
width: 16px;
|
||||
height: 8px;
|
||||
width: 20px;
|
||||
height: 10px;
|
||||
background: var(--fms-card);
|
||||
clip-path: polygon(0 100%, 50% 0, 100% 100%);
|
||||
}
|
||||
|
||||
/* 浮层在触发器上方,箭头在浮层下边,尖角朝下 */
|
||||
/* 浮层在触发器上方,箭头在浮层下边,尖角朝下。
|
||||
位移收 20%(=4px,箭头盒高 20px)让三角形与内容块重叠:
|
||||
同色背景连成一体,消除 drop-shadow 投影在交界处产生的分割线 */
|
||||
.popover-top .popover-arrow {
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(100%) rotate(180deg);
|
||||
transform: translateX(-50%) translateY(80%) rotate(180deg);
|
||||
}
|
||||
|
||||
/* 浮层在触发器下方,箭头在浮层上边,尖角朝上 */
|
||||
.popover-bottom .popover-arrow {
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-100%);
|
||||
transform: translateX(-50%) translateY(-80%);
|
||||
}
|
||||
|
||||
/* 浮层在触发器左侧,箭头在浮层右边,尖角朝右 */
|
||||
.popover-left .popover-arrow {
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateX(100%) translateY(-50%) rotate(90deg);
|
||||
transform: translateX(80%) translateY(-50%) rotate(90deg);
|
||||
}
|
||||
|
||||
/* 浮层在触发器右侧,箭头在浮层左边,尖角朝左 */
|
||||
.popover-right .popover-arrow {
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateX(-100%) translateY(-50%) rotate(-90deg);
|
||||
transform: translateX(-80%) translateY(-50%) rotate(-90deg);
|
||||
}
|
||||
|
||||
/* start / end 对齐时,箭头从居中移到靠近触发器边缘一侧 */
|
||||
@@ -98,10 +94,18 @@
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.popover-left-end .popover-arrow,
|
||||
/* left/right-end:bottom 定位本身已把箭头放到底部,必须同时覆盖 transform
|
||||
去掉基础的 translateY(-50%)(否则上移半个盒高会把箭头推到矮内容块的顶部) */
|
||||
.popover-left-end .popover-arrow {
|
||||
top: auto;
|
||||
bottom: 4px;
|
||||
transform: translateX(80%) rotate(90deg);
|
||||
}
|
||||
|
||||
.popover-right-end .popover-arrow {
|
||||
top: auto;
|
||||
bottom: 16px;
|
||||
bottom: 4px;
|
||||
transform: translateX(-80%) rotate(-90deg);
|
||||
}
|
||||
|
||||
/* 过渡只动 opacity / transform,低成本且支持 reduced-motion */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { Comment, Fragment, cloneVNode, computed, nextTick, onBeforeUnmount, ref, useSlots, watch } from 'vue'
|
||||
import { PLACEMENTS, computePosition } from '../utils/position.js'
|
||||
|
||||
/* ---------- props / emits / slots ---------- */
|
||||
@@ -35,13 +35,16 @@ const props = defineProps({
|
||||
/** 浮层宽度是否严格跟随触发器宽度。
|
||||
用于 Select 等需与触发框等宽对齐的场景;默认 false 由内容自适应撑开。 */
|
||||
followTriggerWidth: { type: Boolean, default: false },
|
||||
/** 触发器是否撑满父容器宽度(display:block + width:100%)。
|
||||
默认 false(inline-flex 收缩到内容宽度,适配按钮/图标触发器);
|
||||
需要触发器占据整行可用宽度的场景(如结构树)设为 true。 */
|
||||
stretchTrigger: { type: Boolean, default: false },
|
||||
/** 零包裹模式:true 时把 6 个事件直接注入触发器单根 vnode(不再包 span),
|
||||
布局规则由用户内容自己决定(block 天然撑满 / flex 跟随父级);
|
||||
多根 fragment / 文本节点自动回退到 span 包裹层。
|
||||
注意:与 antdv 一致,用户触发器节点需能接受
|
||||
mouseenter/mouseleave/focus/click 事件。 */
|
||||
unwrapped: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open', 'openChange'])
|
||||
const slots = useSlots()
|
||||
|
||||
/* ---------- 公共状态 ---------- */
|
||||
const triggerEl = ref(null)
|
||||
@@ -200,6 +203,77 @@ function onFocusOut(event) {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
/* ---------- 零包裹模式:事件注入 + 锚点指令 ---------- */
|
||||
// 锚点指令:把用户触发器 vnode 的根 DOM 同步到 triggerEl(定位锚点 / 外部点击
|
||||
// contains / followTriggerWidth 宽度测量三处复用,语义与包裹层 span 完全一致)。
|
||||
// 生命周期钩子驱动,无轮询;不覆盖用户自己的 ref / directives。
|
||||
const triggerRefDirective = {
|
||||
mounted: (el) => {
|
||||
triggerEl.value = el
|
||||
// 受控打开 / contextmenu 已打开后重渲染的场景补一次定位
|
||||
if (isOpen.value) nextTick(updatePosition)
|
||||
},
|
||||
updated: (el) => {
|
||||
triggerEl.value = el
|
||||
},
|
||||
unmounted: (el) => {
|
||||
if (triggerEl.value === el) triggerEl.value = null
|
||||
},
|
||||
}
|
||||
|
||||
// 归一化触发器 vnode:unwrapped 且插槽为单根元素/组件时,cloneVNode 注入 6 个
|
||||
// 事件 handler(mergeProps 自动与用户已有 on* 合并为 [用户, 内部] 数组,两者都触发),
|
||||
// 并追加锚点指令;多根 fragment / 文本节点返回 null 走 span 包裹层。
|
||||
// 兼容 Fragment 单子节点:Dropdown 等用 <slot name="trigger"><slot /></slot> 包装触发器时,
|
||||
// renderSlot 会产出 Fragment vnode;解包到其唯一真实子节点即可正常零包裹。
|
||||
// computed + slots.trigger 响应式依赖:仅插槽内容或 unwrapped 变化时重算,零冗余开销。
|
||||
const injectedTrigger = computed(() => {
|
||||
if (!props.unwrapped) return null
|
||||
const nodes = slots.trigger?.() ?? []
|
||||
const real = nodes.filter((n) => n.type !== Comment)
|
||||
if (real.length !== 1) return null
|
||||
let vnode = real[0]
|
||||
// Fragment 单子节点解包:兼容 Dropdown#trigger 嵌套 slot 经 renderSlot 产生的 Fragment。
|
||||
// 嵌套 slot(<slot name="trigger"><slot /></slot>)会产出多层 Fragment 包裹,
|
||||
// 循环解包直到拿到真实元素/组件。
|
||||
while (vnode.type === Fragment) {
|
||||
const fragChildren = Array.isArray(vnode.children) ? vnode.children : []
|
||||
const fragReal = fragChildren.filter((n) => n.type !== Comment)
|
||||
if (fragReal.length !== 1) return null
|
||||
vnode = fragReal[0]
|
||||
}
|
||||
// 文本 / 注释等 symbol 类型 vnode 无法承载事件与指令,回退包裹层
|
||||
if (typeof vnode.type === 'symbol') return null
|
||||
const injected = cloneVNode(
|
||||
vnode,
|
||||
{
|
||||
onClick,
|
||||
onContextmenu: onContextMenu,
|
||||
onMouseenter: onMouseEnter,
|
||||
onMouseleave: onMouseLeave,
|
||||
onFocusin: onFocusIn,
|
||||
onFocusout: onFocusOut,
|
||||
},
|
||||
true,
|
||||
)
|
||||
// cloneVNode(merge=true) 在 props 变化时自动把 patchFlag 置 -1、dynamicProps 置 null,
|
||||
// 保证注入的属性完整参与 diff,不会按旧 flag 跳过更新。
|
||||
// 注意:Vue 3 vnode 的指令字段名是 `dirs`(非 `directives`),必须用 `dirs`
|
||||
// 才会被 mountElement 遍历执行;拼成 `directives` 会被静默忽略。
|
||||
// 注意:必须直接返回 cloneVNode 结果(保留 __v_isVNode / shapeFlag 等内部标记),
|
||||
// 只在其上挂 dirs 指令;不能对象展开构造新对象,否则渲染函数不会按 vnode 渲染、
|
||||
// on* 事件和 dirs 都会被丢弃,零包裹模式的事件注入和锚点同步全部失效。
|
||||
injected.dirs = [...(vnode.dirs || []), { dir: triggerRefDirective }]
|
||||
return injected
|
||||
})
|
||||
|
||||
// 渲染函数组件包装:<component :is="vnode"> 在 Vue 3 不会可靠地把 vnode 的 on* 事件
|
||||
// 和 dirs 挂到元素上;用 render-function 组件返回 vnode 才能让 renderer 按 vnode 完整挂载
|
||||
// (应用 props.on* 为 DOM 监听、遍历 dirs 执行指令、设置锚点 triggerEl)。
|
||||
const InjectedTriggerComp = computed(() => ({
|
||||
render: () => injectedTrigger.value,
|
||||
}))
|
||||
|
||||
/* ---------- 全局关闭:click outside / Esc ---------- */
|
||||
function onDocumentPointerDown(event) {
|
||||
if (!isOpen.value) return
|
||||
@@ -249,10 +323,14 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 零包裹:直接渲染注入事件后的用户 vnode,锚点由指令同步;无中间 DOM -->
|
||||
<component :is="InjectedTriggerComp" v-if="injectedTrigger" />
|
||||
|
||||
<!-- 包裹层 fallback:unwrapped=false、多根 fragment、文本节点三种情况走此分支 -->
|
||||
<span
|
||||
v-else
|
||||
ref="triggerEl"
|
||||
class="popover-trigger"
|
||||
:class="{ 'popover-trigger--stretch': stretchTrigger }"
|
||||
@click="onClick"
|
||||
@contextmenu="onContextMenu"
|
||||
@mouseenter="onMouseEnter"
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/* ============================================================
|
||||
* Segmented 分段控制器
|
||||
* - 容器为浅灰轨道,选中项由绝对定位的 thumb 白色滑块托底
|
||||
* - thumb 的位移与尺寸由 JS 测量选中项后写入内联样式,测量完成前不显示
|
||||
* 尺寸全部取自 --fms-segmented-* token,颜色复用语义色 token。
|
||||
* ============================================================ */
|
||||
|
||||
.segmented {
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
padding: var(--fms-segmented-track-padding);
|
||||
border-radius: var(--fms-segmented-radius);
|
||||
background: var(--fms-secondary);
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: var(--fms-segmented-font-size);
|
||||
line-height: var(--fms-line-height);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 撑满父容器:容器与选项共同分配可用空间 */
|
||||
.segmented-block {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.segmented-vertical.segmented-block {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 选项组:thumb 的定位上下文 */
|
||||
.segmented-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.segmented-vertical .segmented-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---------- 滑块 ---------- */
|
||||
.segmented-thumb {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: var(--fms-segmented-item-radius);
|
||||
background: var(--fms-card);
|
||||
box-shadow: var(--fms-shadow);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.segmented-vertical .segmented-thumb {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* 测量出首个位置后才显示,避免 0 尺寸的空滑块闪现 */
|
||||
.segmented-thumb-visible {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* ---------- 选项 ---------- */
|
||||
.segmented-item {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
min-height: var(--fms-segmented-height);
|
||||
padding: 0 var(--fms-segmented-padding-x);
|
||||
border-radius: var(--fms-segmented-item-radius);
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: pointer;
|
||||
/* 提升为独立合成层,规避 Safari 下与滑块的层叠闪烁 */
|
||||
transform: translateZ(0);
|
||||
transition:
|
||||
color 150ms ease,
|
||||
background-color 150ms ease;
|
||||
}
|
||||
|
||||
.segmented-block .segmented-item {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
/* 未选中且未禁用:悬停给浅底色并提亮文字(选中态由 thumb 表达,不叠加悬停底色) */
|
||||
.segmented-item:not(.segmented-item-selected):not(.segmented-item-disabled):hover {
|
||||
background: var(--fms-secondary-hover);
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
.segmented-item-selected {
|
||||
color: var(--fms-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.segmented-item-disabled {
|
||||
color: var(--fms-disabled-text);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 键盘焦点:原生 radio 视觉隐藏,焦点圈投射到整个选项 */
|
||||
.segmented-item:has(.segmented-input:focus-visible) {
|
||||
outline: 2px solid var(--fms-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 原生 radio 不可见但保留可聚焦与同组方向键导航 */
|
||||
.segmented-input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 标签内容:图标 + 文本居中,超长省略 */
|
||||
.segmented-item-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.segmented-item-icon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.segmented-item-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 全圆角:覆盖默认的项圆角 */
|
||||
.segmented-shape-round {
|
||||
--fms-segmented-radius: 999px;
|
||||
--fms-segmented-item-radius: 999px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.segmented-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRaw, useId, watch } from 'vue'
|
||||
|
||||
/* ---------- props / emits / slots ---------- */
|
||||
const props = defineProps({
|
||||
/** 双向绑定的选中值 */
|
||||
modelValue: { type: [String, Number, Boolean], default: undefined },
|
||||
/**
|
||||
* 选项数据源,与 #label 插槽配合使用:
|
||||
* ['daily', 'weekly'] 或 [{ label, value, disabled?, icon? }];
|
||||
* 对象项不给 label 即为纯图标项,icon 可为组件或 VNode
|
||||
*/
|
||||
options: { type: Array, default: () => [] },
|
||||
/** 圆角形状:default 跟随控件圆角,round 全圆角 */
|
||||
shape: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value) => ['default', 'round'].includes(value),
|
||||
},
|
||||
/** 纵向排列,滑块上下移动 */
|
||||
vertical: { type: Boolean, default: false },
|
||||
/** 撑满父容器宽度(纵向时为高度),各项等分空间 */
|
||||
block: { type: Boolean, default: false },
|
||||
/** 禁用整组 */
|
||||
disabled: { type: Boolean, default: false },
|
||||
/** 原生 radio 组名,未设置时自动生成,避免多组之间互斥冲突 */
|
||||
name: { type: String, default: undefined },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
/* ---------- 选项归一 ---------- */
|
||||
// 兼容原始值与对象两种形态,保留 index 与 raw 供插槽使用
|
||||
const mergedOptions = computed(() =>
|
||||
(props.options || []).map((option, index) => {
|
||||
const isObject = option !== null && typeof option === 'object'
|
||||
const value = isObject ? option.value : option
|
||||
// 对象形态只在显式给了 label 时显示文字(纯图标项留空),原始值形态以自身为文本
|
||||
const rawLabel = isObject ? option.label : option
|
||||
return {
|
||||
value,
|
||||
label: rawLabel == null ? '' : String(rawLabel),
|
||||
disabled: isObject ? Boolean(option.disabled) : false,
|
||||
// options 常由 ref 提供(深层响应式),toRaw 还原组件本身,避免「组件被响应式化」告警
|
||||
icon: isObject && option.icon ? toRaw(option.icon) : undefined,
|
||||
index,
|
||||
raw: option,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
/* ---------- 选中值 ---------- */
|
||||
// 未传 modelValue 时走非受控:内部保存选中值,仍可通过 change 监听
|
||||
const innerValue = ref(undefined)
|
||||
const currentValue = computed(() =>
|
||||
props.modelValue !== undefined ? props.modelValue : innerValue.value,
|
||||
)
|
||||
|
||||
// useId 依赖实例上下文,必须在 setup 同步阶段取,不能延迟到 computed 求值
|
||||
const autoName = useId()
|
||||
const groupName = computed(() => props.name || autoName)
|
||||
|
||||
function isSelected(option) {
|
||||
return Object.is(option.value, currentValue.value)
|
||||
}
|
||||
|
||||
/* ---------- 事件 ---------- */
|
||||
function onChange(option) {
|
||||
if (props.disabled || option.disabled) return
|
||||
// 原生 radio 仅在值变化时派发 change,无需自行去重
|
||||
if (props.modelValue !== undefined) emit('update:modelValue', option.value)
|
||||
else innerValue.value = option.value
|
||||
emit('change', option.value)
|
||||
}
|
||||
|
||||
/* ---------- 滑块测量 ---------- */
|
||||
const groupEl = ref(null)
|
||||
const thumbStyle = ref({})
|
||||
const thumbVisible = ref(false)
|
||||
let observer = null
|
||||
|
||||
// 读取选中项在 group 内的偏移与尺寸,写入 thumb 的内联样式(无过渡,切换即到位)
|
||||
function measureThumb() {
|
||||
const group = groupEl.value
|
||||
if (!group) return
|
||||
const activeEl = group.querySelector('.segmented-item-selected')
|
||||
if (!activeEl) {
|
||||
thumbVisible.value = false
|
||||
return
|
||||
}
|
||||
thumbStyle.value = props.vertical
|
||||
? { transform: `translateY(${activeEl.offsetTop}px)`, height: `${activeEl.offsetHeight}px` }
|
||||
: { transform: `translateX(${activeEl.offsetLeft}px)`, width: `${activeEl.offsetWidth}px` }
|
||||
thumbVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(measureThumb)
|
||||
// 容器尺寸变化(block 拉伸、字体加载、选项文字变化)时重新定位滑块
|
||||
if (typeof ResizeObserver !== 'undefined' && groupEl.value) {
|
||||
observer = new ResizeObserver(() => measureThumb())
|
||||
observer.observe(groupEl.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
})
|
||||
|
||||
// 选中项、选项集或排列方向变化时重新测量;v-for 更新在 nextTick 后才落到 DOM
|
||||
watch([currentValue, () => props.options, () => props.vertical], () => nextTick(measureThumb))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="segmented"
|
||||
:class="[
|
||||
`segmented-shape-${shape}`,
|
||||
{
|
||||
'segmented-block': block,
|
||||
'segmented-vertical': vertical,
|
||||
'segmented-disabled': disabled,
|
||||
},
|
||||
]"
|
||||
role="radiogroup"
|
||||
:aria-disabled="disabled || undefined"
|
||||
:aria-orientation="vertical ? 'vertical' : 'horizontal'"
|
||||
>
|
||||
<div ref="groupEl" class="segmented-group">
|
||||
<!-- 滑块:先于选项渲染,作为选中态底座托在下方 -->
|
||||
<div
|
||||
class="segmented-thumb"
|
||||
:class="{ 'segmented-thumb-visible': thumbVisible }"
|
||||
:style="thumbStyle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<label
|
||||
v-for="option in mergedOptions"
|
||||
:key="option.index"
|
||||
class="segmented-item"
|
||||
:class="{
|
||||
'segmented-item-selected': isSelected(option),
|
||||
'segmented-item-disabled': disabled || option.disabled,
|
||||
}"
|
||||
>
|
||||
<input
|
||||
class="segmented-input"
|
||||
type="radio"
|
||||
:name="groupName"
|
||||
:value="String(option.value)"
|
||||
:checked="isSelected(option)"
|
||||
:disabled="disabled || option.disabled"
|
||||
@change="onChange(option)"
|
||||
/>
|
||||
<span class="segmented-item-label">
|
||||
<slot
|
||||
name="label"
|
||||
:option="option.raw"
|
||||
:index="option.index"
|
||||
:checked="isSelected(option)"
|
||||
>
|
||||
<component
|
||||
:is="option.icon"
|
||||
v-if="option.icon"
|
||||
class="segmented-item-icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-if="option.label !== ''" class="segmented-item-text">{{ option.label }}</span>
|
||||
</slot>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './index.scss';
|
||||
</style>
|
||||
@@ -4,13 +4,6 @@
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: 100%;
|
||||
|
||||
/* Popover 触发器默认 inline-flex 会收缩到内容宽度,
|
||||
改为块级填满 .select,保证触发器与下拉宽度一致 */
|
||||
:deep(.popover-trigger) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.select-trigger {
|
||||
@@ -52,7 +45,10 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 触发器输入框:透明内嵌,撑满框体;筛选直接在输入框内键入 */
|
||||
/* 触发器输入框:透明内嵌,撑满框体;筛选直接在输入框内键入。
|
||||
文本垂直居中由触发器的 flex align-items 负责,行高跟随全局行高即可,
|
||||
勿写死 var(--fms-control-height)——那会把触发器内容顶到 28px+边框,
|
||||
破坏 min-height 的约束(表单里实际渲染 30px,表格编辑行被撑高) */
|
||||
.select-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -63,7 +59,7 @@
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: var(--fms-control-height);
|
||||
line-height: var(--fms-line-height);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ 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`
|
||||
@@ -210,9 +213,23 @@ function openDropdown() {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -297,6 +314,8 @@ function selectOption(option) {
|
||||
emit('update:modelValue', option.value)
|
||||
emit('change', option.value, option)
|
||||
open.value = false
|
||||
// 非 filterable 模式焦点在下拉容器上,随浮层卸载会丢失;交还输入框保持键盘流
|
||||
refocusTrigger()
|
||||
}
|
||||
|
||||
// 供默认插槽使用:按原始值选中并关闭(插槽自定义选项无规范化 option)
|
||||
@@ -306,6 +325,7 @@ function selectValue(value) {
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value, undefined)
|
||||
open.value = false
|
||||
refocusTrigger()
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
@@ -318,7 +338,7 @@ function clearValue() {
|
||||
activeIndex.value = -1
|
||||
selectedCache.value = null
|
||||
// 键盘触发清除时按钮会被卸载,先把焦点交还输入框,避免焦点丢失后浮层残留。
|
||||
triggerInputRef.value?.focus({ preventScroll: true })
|
||||
refocusTrigger()
|
||||
emit('update:modelValue', undefined)
|
||||
emit('change', undefined, undefined)
|
||||
}
|
||||
@@ -353,6 +373,8 @@ function cancelEditing() {
|
||||
editing.value = false
|
||||
filterQuery.value = ''
|
||||
open.value = false
|
||||
// Esc 取消:焦点在下拉容器上会随卸载丢失,交还输入框保持键盘流
|
||||
refocusTrigger()
|
||||
}
|
||||
|
||||
function onTriggerKeydown(event) {
|
||||
@@ -417,6 +439,7 @@ function onTriggerKeydown(event) {
|
||||
:offset="4"
|
||||
:disabled="disabled"
|
||||
:follow-trigger-width="true"
|
||||
unwrapped
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<!-- 触发器:类 Input 的框体,直接在此输入框键入即可筛选;含清除按钮与折叠箭头 -->
|
||||
|
||||
@@ -36,6 +36,7 @@ const emit = defineEmits(['update:open', 'openChange'])
|
||||
:mouse-enter-delay="mouseEnterDelay"
|
||||
:mouse-leave-delay="mouseLeaveDelay"
|
||||
:disabled="disabled"
|
||||
unwrapped
|
||||
@update:open="emit('update:open', $event)"
|
||||
@open-change="emit('openChange', $event)"
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user