This commit is contained in:
oneao committed 2026-10-14 17:25:47 +08:00
1 parent 15e0ae5b67
commit e434374db5
71 files changed
+5726 -329

No files matched your search

@@ -180,11 +180,12 @@ export interface TreeContextMenuItem {
| --- | --- | --- |
| `--g3-tree-node-height` | `28px` | 节点行高 |
| `--g3-tree-node-gap` | `6px` | 相邻节点行之间的竖向间距 |
| `--g3-tree-node-gap-x` | `2px` | 节点行内的横向间距(箭头 / 勾选框 / 内容之间) |
| `--g3-tree-indent` | `18px` | 每层缩进量 |
| `--g3-tree-icon-size` | `16px` | 图标尺寸 |
| `--g3-tree-switcher-size` | `22px` | 展开箭头点击区 |
| `--g3-tree-switcher-size` | `18px` | 展开箭头点击区 |
| `--g3-tree-gutter-x` | `4px` | 节点行左右留白 |
| `--g3-tree-title-padding-x` | `6px` | 标题内边距 |
| `--g3-tree-title-padding-x` | `2px` | 标题内边距 |
| `--g3-tree-drop-line-size` | `2px` | 拖拽落点指示线粗细 |
## 实现说明
@@ -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