u
This commit is contained in:
1 parent
15e0ae5b67
commit
e434374db5
71 files changed
+5726
-329
No files matched your search
@@ -301,6 +301,27 @@ function handleClick(ev: MouseEvent) {
|
||||
min-height: v('control-height-lg', 40px);
|
||||
}
|
||||
|
||||
/* ---------- 图标按钮(内容只有一个图标):任意变体都渲染成正方形 ---------- */
|
||||
/* 覆盖两种写法:图标放在默认插槽(内容里只有单个 svg),或独用 #icon 插槽。
|
||||
* 只加宽高约束,不动配色——所以 ghost / outline 等变体的图标按钮同样适用;
|
||||
* 与上面的 type="icon" 变体取同一组尺寸,互不冲突。 */
|
||||
.#{b('button')}:has(> .#{e('button', 'content')} > svg:only-child),
|
||||
.#{b('button')}:has(> .#{e('button', 'icon')}:only-child) {
|
||||
width: v('control-height', 32px);
|
||||
min-width: v('control-height', 32px);
|
||||
padding: 0;
|
||||
|
||||
&.#{m('button', 'small')} {
|
||||
width: v('control-height-sm', 24px);
|
||||
min-width: v('control-height-sm', 24px);
|
||||
}
|
||||
|
||||
&.#{m('button', 'large')} {
|
||||
width: v('control-height-lg', 40px);
|
||||
min-width: v('control-height-lg', 40px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 尊重系统「减少动态效果」偏好:停掉 spinner,去掉按下缩放 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.#{b('button')} {
|
||||
|
||||
@@ -225,7 +225,7 @@ watch(open, (value) => {
|
||||
type="text"
|
||||
:disabled="disabled"
|
||||
:value="inputText"
|
||||
:placeholder="placeholder ?? locale.date.placeholder"
|
||||
:placeholder="placeholder"
|
||||
:aria-label="placeholder ?? locale.date.placeholder"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="open"
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface DatePickerProps {
|
||||
disabledDate?: (date: Date) => boolean
|
||||
/** 无值时的默认时间 */
|
||||
timeDefault?: DateTimeDefault
|
||||
/** 占位文案;不传则取语言包(`date.placeholder`) */
|
||||
/** 占位文案;不传则不显示占位文案 */
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, provide, ref } from 'vue'
|
||||
import { computed, inject, onBeforeUnmount, provide, ref, watch } from 'vue'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { useComponentDefaults } from '../_composables/useComponentDefaults'
|
||||
import { useNamespace } from '../_composables/useNamespace'
|
||||
@@ -39,6 +39,29 @@ const merged = useComponentDefaults('form-group', props)
|
||||
const innerExpanded = ref(props.defaultExpanded)
|
||||
const expanded = computed(() => props.modelValue ?? innerExpanded.value)
|
||||
|
||||
/**
|
||||
* 折叠动画进行中:只有「收起 / 动画中」需要给内容加 overflow: hidden
|
||||
* (隐藏收起后的内容、防止动画期间内容外溢)。
|
||||
* 展开且静止时放开裁切,否则输入框的聚焦光环 / 阴影会在分组边界被切掉。
|
||||
*/
|
||||
const animating = ref(false)
|
||||
let animatingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(expanded, (value, oldValue) => {
|
||||
if (value === oldValue) return
|
||||
animating.value = true
|
||||
if (animatingTimer) clearTimeout(animatingTimer)
|
||||
// 与下方 grid-template-rows 的 0.2s 过渡对齐,略留余量
|
||||
animatingTimer = setTimeout(() => {
|
||||
animating.value = false
|
||||
animatingTimer = null
|
||||
}, 220)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (animatingTimer) clearTimeout(animatingTimer)
|
||||
})
|
||||
|
||||
function toggleCollapse() {
|
||||
if (!props.collapsible) return
|
||||
const next = !expanded.value
|
||||
@@ -108,7 +131,10 @@ provide(formGroupKey, {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div :class="e('collapse')" :style="{ gridTemplateRows: expanded ? '1fr' : '0fr' }">
|
||||
<div
|
||||
:class="[e('collapse'), { [is('collapsed')]: !expanded, [is('collapsing')]: animating }]"
|
||||
:style="{ gridTemplateRows: expanded ? '1fr' : '0fr' }"
|
||||
>
|
||||
<div :class="e('body')" :style="groupStyle">
|
||||
<slot />
|
||||
</div>
|
||||
@@ -141,7 +167,8 @@ provide(formGroupKey, {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.#{e('form-group', 'title')}.#{is('collapsible')}:hover .#{e('form-group', 'title-text')} {
|
||||
.#{e('form-group', 'title')}.#{is('collapsible')}:hover .#{e('form-group', 'title-text')},
|
||||
.#{e('form-group', 'title')}.#{is('collapsible')}:hover .#{e('form-group', 'arrow')} {
|
||||
color: v('color-primary', #18181b);
|
||||
}
|
||||
|
||||
@@ -157,7 +184,9 @@ provide(formGroupKey, {
|
||||
color: v('text-3', rgba(0, 0, 0, 0.45));
|
||||
/* 收起时箭头转向右侧,展开朝下 */
|
||||
transform: rotate(-90deg);
|
||||
transition: transform v('duration', 0.2s) v('ease', ease);
|
||||
transition:
|
||||
transform v('duration', 0.2s) v('ease', ease),
|
||||
color v('duration', 0.2s) v('ease', ease);
|
||||
}
|
||||
|
||||
.#{e('form-group', 'title')}.#{is('expanded')} .#{e('form-group', 'arrow')} {
|
||||
@@ -172,10 +201,17 @@ provide(formGroupKey, {
|
||||
|
||||
> * {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* 裁切只在「收起 / 折叠动画中」开启:收起态内容不可见、动画期间防外溢;
|
||||
* 展开且静止时必须放开——常开 overflow: hidden 会把输入框的聚焦光环 / 阴影
|
||||
* 在分组边界(最后一行的下缘等)整条切掉。 */
|
||||
.#{e('form-group', 'collapse')}.#{is('collapsed')} > *,
|
||||
.#{e('form-group', 'collapse')}.#{is('collapsing')} > * {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.#{e('form-group', 'body')} {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -288,6 +288,16 @@ onBeforeUnmount(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 控件行:不足控件行高的「短控件」(Switch / 单勾选框等)垂直居中。
|
||||
* 基础布局是 align-items: flex-start(label 与控件首行对齐),短控件会贴行顶,
|
||||
* 比左侧按控件行高居中的 label 高出一截;输入类控件本身就是控件行高,不受影响。
|
||||
* help / extra 是 __control 的兄弟节点,多行内容照样从行顶往下排。 */
|
||||
.#{e('form-item', 'content')} {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: v('control-height', 32px);
|
||||
}
|
||||
|
||||
.#{e('form-item', 'help')} {
|
||||
margin-top: v('space-1', 4px);
|
||||
color: v('text-3', rgba(0, 0, 0, 0.45));
|
||||
|
||||
@@ -528,7 +528,7 @@ defineExpose({
|
||||
:disabled="disabled"
|
||||
:readonly="!filterable && !allowFreeInput"
|
||||
:value="inputDisplay"
|
||||
:placeholder="placeholder ?? locale.common.selectPlaceholder"
|
||||
:placeholder="placeholder"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
role="combobox"
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface SelectProps {
|
||||
remote?: boolean
|
||||
/** 远程数据加载状态;开启时右侧仅展示加载图标 */
|
||||
loading?: boolean
|
||||
/** 无值时的占位文案;不传则取语言包(`common.selectPlaceholder`) */
|
||||
/** 无值时的占位文案;不传则不显示占位文案 */
|
||||
placeholder?: string
|
||||
/** 无匹配项时的文案;不传则取语言包 */
|
||||
notFoundText?: string
|
||||
|
||||
@@ -92,26 +92,70 @@ describe('性能门控:横向虚拟滚动', () => {
|
||||
expect(stkProps(wrapper).virtualX).toBe(false)
|
||||
})
|
||||
|
||||
it('恰好 20 列仍关闭(边界)', () => {
|
||||
const wrapper = mountTable({ columns: manyColumns(20), seq: false })
|
||||
it('恰好 30 列仍关闭(边界:阈值是 > 30 才开)', () => {
|
||||
const wrapper = mountTable({ columns: manyColumns(30), seq: false })
|
||||
expect(stkProps(wrapper).virtualX).toBe(false)
|
||||
})
|
||||
|
||||
it('超过 20 列时开启', () => {
|
||||
const wrapper = mountTable({ columns: manyColumns(25), seq: false })
|
||||
it('31 列时开启', () => {
|
||||
const wrapper = mountTable({ columns: manyColumns(31), seq: false })
|
||||
expect(stkProps(wrapper).virtualX).toBe(true)
|
||||
})
|
||||
|
||||
it('内部列(序号列)也计入列数', () => {
|
||||
// 20 个业务列 + 序号列 = 21 > 20,应当开启
|
||||
const wrapper = mountTable({ columns: manyColumns(20), seq: true })
|
||||
// 既有契约:按 displayColumns 计,内部注入列也算。
|
||||
// 30 个业务列 + 序号列 = 31 > 30,应当开启。
|
||||
const wrapper = mountTable({ columns: manyColumns(30), seq: true })
|
||||
expect(stkProps(wrapper).virtualX).toBe(true)
|
||||
})
|
||||
|
||||
it('显式传 virtual-x=false 时不开启', () => {
|
||||
const wrapper = mountTable({ columns: manyColumns(30), virtualX: false, seq: false })
|
||||
const wrapper = mountTable({ columns: manyColumns(40), virtualX: false, seq: false })
|
||||
expect(stkProps(wrapper).virtualX).toBe(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* 门控与固定列**解耦**:固定列不参与横虚门控,只看列数。
|
||||
*
|
||||
* 这一组锁住的是「解耦」这个决定本身 —— 若有人把固定列判据加回
|
||||
* effectiveVirtualX,这里会红。理由见 G3Table.vue 里 effectiveVirtualX
|
||||
* 的注释:固定列是业务侧的展示选择,不该反过来决定性能策略
|
||||
* (序号列默认开启且自带 fixed:'left',一旦参与门控等于永久关闭横虚)。
|
||||
*
|
||||
* ⚠️ 代价(已知边界,未修复):列数 > 30 且带固定列时,冻结列仍会抖动
|
||||
* (上游横虚的窗口重排所致)。下面的用例刻意固定了这个事实,
|
||||
* 避免它被误认为「已修好」。
|
||||
*/
|
||||
it('左固定列不影响门控:列数够就开启(与固定列无关)', () => {
|
||||
const cols = manyColumns(31)
|
||||
cols[0].fixed = 'left'
|
||||
const wrapper = mountTable({ columns: cols, seq: false })
|
||||
expect(stkProps(wrapper).virtualX).toBe(true)
|
||||
})
|
||||
|
||||
it('右固定列同样不影响门控', () => {
|
||||
const cols = manyColumns(31)
|
||||
cols[cols.length - 1].fixed = 'right'
|
||||
const wrapper = mountTable({ columns: cols, seq: false })
|
||||
expect(stkProps(wrapper).virtualX).toBe(true)
|
||||
})
|
||||
|
||||
it('序号列不参与门控:22 列(21 业务列 + 序号)仍关闭横虚', () => {
|
||||
// table-test 页的真实形态:阈值 30 之下,走的是横虚关闭的原生路径,
|
||||
// 因此固定列走 sticky、不抖。
|
||||
const wrapper = mountTable({ columns: manyColumns(21), seq: true })
|
||||
expect(stkProps(wrapper).virtualX).toBe(false)
|
||||
})
|
||||
|
||||
it('带固定列且列数超阈值时仍开启横虚(记录已知缺陷边界)', () => {
|
||||
// 31 列 + 固定列 → 横虚开启 → 冻结列会抖(上游缺陷,见上方说明)。
|
||||
// 这条用例是**事实记录**:它保证将来修好之后,这个期望必须被显式改写,
|
||||
// 而不是悄悄变化。
|
||||
const cols = manyColumns(31)
|
||||
cols[0].fixed = 'left'
|
||||
const wrapper = mountTable({ columns: cols, seq: true })
|
||||
expect(stkProps(wrapper).virtualX).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('内部列注入', () => {
|
||||
|
||||
@@ -247,8 +247,38 @@ const effectiveVirtual = computed(
|
||||
* (cellStyleMap 的 --cw + width/min-width/max-width 三重锁),
|
||||
* 未设 width 的列会失去「按比例铺满容器」的原生自适应。
|
||||
* 列数少、总宽不超容器时用不上横向虚拟,关掉以保住原生列宽分配。
|
||||
*
|
||||
* 阈值 30 的由来:20 太低 —— 带序号列的常规业务表(20 业务列 + 序号 = 21)
|
||||
* 就会越过它,横虚在这些表上收益很小却带来下面那条已知缺陷。
|
||||
* 30 让常规模块表留在「横虚关闭」的原生列宽路径上。
|
||||
*
|
||||
* ⚠️ 列数按 `displayColumns` 计,**内部注入列(序号 / 选择 / 拖拽)也算**。
|
||||
* 这是既有契约(有测试锁定),不要为了「只数业务列」而改 —— 那会让
|
||||
* 「30 业务列 + 序号列」这类边界在开与不开之间反复摇摆。
|
||||
*/
|
||||
const MIN_VIRTUAL_X_COLUMN_COUNT = 30
|
||||
|
||||
/**
|
||||
* 横向虚拟滚动门控。
|
||||
*
|
||||
* ⚠️ 门控**只看列数,与固定列无关**。这是刻意的解耦:固定列是业务侧的
|
||||
* 展示选择,不该反过来决定性能策略(序号列默认开启且自带 fixed:'left',
|
||||
* 若让固定列参与门控,等于所有常规表都永久关闭横虚)。
|
||||
*
|
||||
* 但必须知道这条已知边界(**未修复,属上游缺陷**):
|
||||
* 横虚开启时,固定列**不是靠 sticky 吸附的**,而是被重排到渲染数组两侧 ——
|
||||
* 库的 virtualX_columnPart 单级表头分支:
|
||||
* leftCols + tableHeaderLast.slice(startIndex, endIndex) + rightCols
|
||||
* 其中 slice 的窗口随 scrollLeft 每帧重算(updateVirtualScrollX 二分查找起始列)。
|
||||
* 于是滚动时「列集合在增删、表格总宽在跳变」,固定列是拼在这个不断重建的
|
||||
* 数组两端 —— 表现为**固定列抖动、横向滚动不顺**。
|
||||
* (症状与 sticky 的 `fixed-cell--active` 翻转很像,别误判成吸附问题。)
|
||||
*
|
||||
* ⇒ 因此:**列数 > 30 且存在固定列时,冻结列仍会抖动**。
|
||||
* 这是当前阈值下的已知代价。若将来出现 31+ 列且必须冻结列的宽表,
|
||||
* 在本组件修好之前,业务侧可用 `:virtual-x="false"` 显式兜底
|
||||
* (关掉横虚后固定列走 sticky 路径,实测不抖)。
|
||||
*/
|
||||
const MIN_VIRTUAL_X_COLUMN_COUNT = 20
|
||||
const effectiveVirtualX = computed(
|
||||
() => props.virtualX && displayColumns.value.length > MIN_VIRTUAL_X_COLUMN_COUNT,
|
||||
)
|
||||
@@ -1477,6 +1507,26 @@ defineExpose({
|
||||
clearSelectedArea,
|
||||
setRowExpand,
|
||||
})
|
||||
|
||||
/**
|
||||
* 🧪 临时 A/B 开关:固定列 sticky 补丁(style/_overrides.scss)。
|
||||
*
|
||||
* ⚠️ 这是**验证脚手架,不是功能**。补丁本身是否必要尚未实测确认,
|
||||
* 加这个开关是为了能在同一页面上做对照,而不用反复改 SCSS。
|
||||
* 结论确定后应删掉本段与 SCSS 里的 `:not(.g3-fixed-patch-off)`,
|
||||
* 只保留被验证有效的那种写法。
|
||||
*
|
||||
* 用法:URL 加 `?g3-fixed-patch=off` → 关闭补丁,回到库默认行为。
|
||||
* 只在组件初始化时读一次(对照实验不要求运行期切换)。
|
||||
*/
|
||||
const fixedPatchOff = (() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).get('g3-fixed-patch') === 'off'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1487,6 +1537,7 @@ defineExpose({
|
||||
bordered ? is('bordered') : '',
|
||||
autoFitWidth ? is('auto-fit-width') : '',
|
||||
editorSeamless ? is('editor-seamless') : '',
|
||||
fixedPatchOff ? 'g3-fixed-patch-off' : '',
|
||||
]"
|
||||
data-slot="table"
|
||||
@keydown.capture="handleKeydownCapture"
|
||||
|
||||
@@ -219,6 +219,69 @@ $cs-border: inset 0 var(--cs-t, 0) 0 0 var(--cs-bc, transparent),
|
||||
inset var(--cs-l, 0) 0 0 0 var(--cs-bc, transparent),
|
||||
inset var(--cs-r, 0) 0 0 0 var(--cs-bc, transparent);
|
||||
|
||||
// ==========================================================================
|
||||
// 固定列:让「是否吸附」恒定,不再由滚动位置决定(修横滚/拖滚动条抖动)
|
||||
// --------------------------------------------------------------------------
|
||||
// 症状:横向滚动、或拖动横向滚动条时,冻结列不是稳稳钉住,而是**来回抖动**
|
||||
// (吸附一下就弹回,再吸附一下)。
|
||||
//
|
||||
// 根因:库把 `position: sticky` **只挂在 `.fixed-cell--active` 上**
|
||||
// (style.less 的 `.fixed-cell--active { position: sticky }`),
|
||||
// 而这个类是由**滚动位置算出来的**,见 useFixedCol.ts 的 updateFixedShadow:
|
||||
// · 左固定列:`if (isFixedLeft && position + scrollLeft > left)`
|
||||
// · 右固定列:`const needFix = scrollLeft + clientWidth - left < position`
|
||||
// 于是滚动过程中该 class 被反复增删 → `position` 在 sticky / static 之间跳变
|
||||
// → 每一帧「吸附 ↔ 脱离」交替,视觉上就是抖动。
|
||||
//
|
||||
// 关键:库其实**已经把内联 left/right 无条件写在了 `.fixed-cell` 上**
|
||||
// (useFixedStyle.ts:`if (fixed) { … left/right = getFixedColPosition(col) }`,
|
||||
// 只看 col.fixed,与滚动位置无关),且该值来自**声明列宽累加**、静态可靠。
|
||||
// 也就是说坐标一直是对的、一直在,**缺的只是那条 position: sticky**。
|
||||
//
|
||||
// 修法:给所有 `.fixed-cell` 补恒常 sticky。`fixed` 是静态配置,是否吸附本来
|
||||
// 就不该依赖滚动位置——这样无论怎么滚都不会再翻转 class。
|
||||
//
|
||||
// ⚠️ z-index 必须**一起补**,这是本修法唯一的陷阱:
|
||||
// 库的 z-index 同样只挂在 `--active` 上(style.less):
|
||||
// th.fixed-cell--active { z-index: 3 }
|
||||
// td.fixed-cell--active { z-index: 1 }
|
||||
// .stk-footer td.fixed-cell--active { z-index: 2 }
|
||||
// 只补 position 会造出「sticky 但没有 z-index」的单元格:它参与了吸附定位,
|
||||
// 却不再创建层叠上下文,**会被横滚过去的普通单元格盖住**(症状是冻结列内容
|
||||
// 时隐时现、被别的列压住)。所以这里按库同样的三档补齐,取值照抄,保证
|
||||
// `--active` 加上去时与不加时视觉完全一致。
|
||||
//
|
||||
// ⚠️ 必须是 sticky 而不是 absolute:absolute 会脱离文档流,单元格宽度不再参与
|
||||
// 表格布局,列宽会塌掉。sticky 保留占位。
|
||||
// ⚠️ 只加 position / z-index,不动 left/right —— 那两个值由库内联给出,这里写死
|
||||
// 会破坏「按声明列宽累加」的计算(多级表头/多固定列下每列偏移都不同)。
|
||||
//
|
||||
// --------------------------------------------------------------------------
|
||||
// 🧪 A/B 开关:本段补丁的作用**尚未经过实测确认**(见上方的背景说明)。
|
||||
// 在页面 URL 后加 `?g3-fixed-patch=off` 即可整体关掉它,用于对照:
|
||||
// · 开(默认):所有固定列恒常 sticky
|
||||
// · 关(?g3-fixed-patch=off):回到库的默认行为(只有 --active 才 sticky)
|
||||
// 对照结论确定后,应当**删掉这个开关**并只保留被验证有效的那一种写法。
|
||||
// --------------------------------------------------------------------------
|
||||
.#{b('table')}:not(.g3-fixed-patch-off) {
|
||||
.#{e('table', 'inner')}.stk-table {
|
||||
thead th.fixed-cell {
|
||||
position: sticky;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.stk-tbody-main td.fixed-cell {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stk-footer td.fixed-cell {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑格**不参与**行状态叠色:它与普通列的编辑格必须长得一样(纯基色 + 焦点环)。
|
||||
// 普通列的编辑格由 G3Table 的规则刷成不透明的 `--td-bgc`、不叠行色;冻结列若再叠一层,
|
||||
// 同一行里就会出现「冻结列的编辑格有色、普通列的编辑格纯色」这种一眼可见的不一致。
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
provide,
|
||||
reactive,
|
||||
ref,
|
||||
useSlots,
|
||||
watch,
|
||||
type VNode,
|
||||
} from 'vue'
|
||||
@@ -97,6 +98,7 @@ const { b, e, is } = useNamespace('tree')
|
||||
const textDirection = useDirection()
|
||||
const locale = useLocale()
|
||||
const teleportTo = usePopupContainer()
|
||||
const slots = useSlots()
|
||||
|
||||
const fieldNames = computed(() => resolveFieldNames(props.fieldNames))
|
||||
const flat = computed(() => buildFlat(props.treeData, fieldNames.value))
|
||||
@@ -576,6 +578,8 @@ const treeContext: TreeContext = {
|
||||
get fieldNames() {
|
||||
return fieldNames.value
|
||||
},
|
||||
// title 插槽随上下文下发,嵌套层级的节点才能渲染业务插槽(见 TreeContext.titleSlot)
|
||||
titleSlot: slots.title,
|
||||
get checkable() {
|
||||
return props.checkable
|
||||
},
|
||||
@@ -676,11 +680,7 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node
|
||||
:key="String(getNodeKey(node, fieldNames))"
|
||||
:node="node"
|
||||
:level="0"
|
||||
>
|
||||
<template v-if="$slots.title" #title="scope">
|
||||
<slot name="title" v-bind="scope" />
|
||||
</template>
|
||||
</G3TreeNode>
|
||||
/>
|
||||
</template>
|
||||
<div v-else :class="e('empty')">
|
||||
<slot name="empty">{{ locale.empty.description }}</slot>
|
||||
|
||||
@@ -38,6 +38,15 @@ const checked = computed(() => tree!.checkedSet.has(nodeKey.value))
|
||||
const halfChecked = computed(() => tree!.halfSet.has(nodeKey.value))
|
||||
const loading = computed(() => tree!.loadingSet.has(nodeKey.value))
|
||||
|
||||
/**
|
||||
* title 插槽渲染器:插槽来自 Tree 的上下文(TreeContext.titleSlot)。
|
||||
* 渲染函数里读 node / expanded / selected,保证这些值变化时插槽内容跟着重渲染。
|
||||
*/
|
||||
const titleSlotComp = {
|
||||
render: () =>
|
||||
tree?.titleSlot?.({ node: props.node, expanded: expanded.value, selected: selected.value }),
|
||||
}
|
||||
|
||||
/** 过滤未命中的节点整体不渲染(不影响数据,只影响可见性) */
|
||||
const visible = computed(
|
||||
() => !tree!.filterActive || !tree!.filterVisible || tree!.filterVisible.has(nodeKey.value),
|
||||
@@ -219,15 +228,16 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node
|
||||
<component :is="node.icon" v-if="!isVNode(node.icon)" />
|
||||
<VNodeRenderer v-else :node="node.icon" />
|
||||
</span>
|
||||
<slot name="title" :node="node" :expanded="expanded" :selected="selected">
|
||||
<span :class="e('title')">
|
||||
<span
|
||||
v-for="(part, index) in titleParts"
|
||||
:key="index"
|
||||
:class="part.hit ? e('highlight') : undefined"
|
||||
>{{ part.text }}</span>
|
||||
</span>
|
||||
</slot>
|
||||
<!-- title 插槽从上下文取(Tree 下发):嵌套层级的节点不经过 Tree 的模板渲染,
|
||||
若只用本地插槽,业务插槽会只有顶层节点生效 -->
|
||||
<component :is="titleSlotComp" v-if="tree!.titleSlot" />
|
||||
<span v-else :class="e('title')">
|
||||
<span
|
||||
v-for="(part, index) in titleParts"
|
||||
:key="index"
|
||||
:class="part.hit ? e('highlight') : undefined"
|
||||
>{{ part.text }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -262,7 +272,7 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: v('space-1', 4px);
|
||||
gap: v('tree-node-gap-x', 2px);
|
||||
box-sizing: border-box;
|
||||
min-height: v('tree-node-height', 28px);
|
||||
/* 缩进 = 基础留白 + 每层缩进量 × 层级(层级由行内 CSS 变量下发)。
|
||||
@@ -338,8 +348,10 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: v('tree-switcher-size', 22px);
|
||||
height: v('tree-switcher-size', 22px);
|
||||
/* 盒子只比图标大一圈:原先 22px 的盒子套 16px 图标,左右各留 3px 死区,
|
||||
加上行 gap 与内容 padding,箭头到标题的横向空隙过大。 */
|
||||
width: v('tree-switcher-size', 18px);
|
||||
height: v('tree-switcher-size', 18px);
|
||||
border-radius: v('radius-sm', 4px);
|
||||
color: v('text-3', rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
@@ -349,10 +361,6 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.#{e('tree-node', 'switcher')}:hover:not(.#{is('placeholder')}) {
|
||||
background: v('fill-hover', rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
.#{e('tree-node', 'switcher-icon')} {
|
||||
width: v('tree-icon-size', 16px);
|
||||
height: v('tree-icon-size', 16px);
|
||||
@@ -386,7 +394,7 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node
|
||||
align-items: center;
|
||||
gap: v('space-1', 4px);
|
||||
min-width: 0;
|
||||
padding: 0 v('tree-title-padding-x', 6px);
|
||||
padding: 0 v('tree-title-padding-x', 2px);
|
||||
}
|
||||
|
||||
.#{e('tree-node', 'content')}.#{is('block')} {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Component, VNode } from 'vue'
|
||||
import type { Component, Slot, VNode } from 'vue'
|
||||
|
||||
/** 树节点标识 */
|
||||
export type TreeKey = string | number
|
||||
@@ -118,6 +118,12 @@ export interface TreeProps {
|
||||
/** 节点级上下文(Tree 通过 provide 下发,TreeNode 消费) */
|
||||
export interface TreeContext {
|
||||
fieldNames: Required<TreeFieldNames>
|
||||
/**
|
||||
* Tree 收到的 #title 插槽,随上下文下发。
|
||||
* 嵌套层级的节点不经过 Tree 的模板渲染(由 TreeNode 递归渲染),
|
||||
* 靠上下文才能拿到业务插槽,而不是只有顶层节点生效。
|
||||
*/
|
||||
titleSlot?: Slot
|
||||
checkable: boolean
|
||||
selectable: boolean
|
||||
multiple: boolean
|
||||
|
||||
Reference in new issue
Block a user