This commit is contained in:
oneao committed 2026-10-09 17:32:14 +08:00
1 parent e99a9fb274
commit 0be0b0767a
788 files changed
+112023 -14941

No files matched your search

@@ -1,5 +1,20 @@
import { expect, test } from '@playwright/test'
test.describe('Checkbox', () => {
test('选中与未选中状态在同一行保持垂直对齐', async ({ page }) => {
await page.goto('/')
const checked = page.locator('.g3-checkbox.is-checked').filter({ hasText: '已选中' })
const unchecked = page.locator('.g3-checkbox').filter({ hasText: '未选中' })
const checkedBox = await checked.locator('.g3-checkbox__box').boundingBox()
const uncheckedBox = await unchecked.locator('.g3-checkbox__box').boundingBox()
expect(checkedBox).not.toBeNull()
expect(uncheckedBox).not.toBeNull()
expect(Math.abs(checkedBox!.y - uncheckedBox!.y)).toBeLessThanOrEqual(1)
})
})
/**
* 组件细节回归 —— 每一条都对应一个真实报障,之前全部漏测。
*
@@ -116,13 +131,47 @@ test.describe('Tree', () => {
expect(bg, `选中底色不应为透明:${bg}`).not.toBe('rgba(0, 0, 0, 0)')
})
test('整层都是叶子时不渲染展开箭头占位', async ({ page }) => {
test('叶子保留隐藏的箭头占位列(缩进对齐,曾整块跳过箭头列)', async ({ page }) => {
await page.goto('/')
// 扁平树:两个节点都没有子节点
const flatTree = page.locator('.g3-tree').filter({ hasText: '节点一(无子节点)' }).first()
await expect(flatTree.locator('.g3-tree-node')).toHaveCount(2)
await expect(flatTree.locator('.g3-tree-node__switcher')).toHaveCount(0)
// 占位列仍渲染(保留宽度),但不可见
const switchers = flatTree.locator('.g3-tree-node__switcher')
await expect(switchers).toHaveCount(2)
await expect(switchers.first()).toHaveCSS('visibility', 'hidden')
})
test('子节点相对父节点缩进一个层级(子节点曾落到父节点文字左侧)', async ({ page }) => {
await page.goto('/')
const tree = page.getByTestId('tree')
const box = async (text: string) =>
(await tree.locator('.g3-tree-node__title').filter({ hasText: new RegExp(`^${text}$`) }).boundingBox())!
// default-expand-all:父「前端」下挂子「Vue」
const parent = await box('前端')
const child = await box('Vue')
// 子节点文字必须在父节点文字右侧,且大致缩进一个层级(indent 默认 18px)
expect(child.x).toBeGreaterThan(parent.x)
expect(child.x - parent.x).toBeGreaterThanOrEqual(16)
expect(child.x - parent.x).toBeLessThanOrEqual(20)
})
test('相邻节点行之间留有间距(曾贴在一起)', async ({ page }) => {
await page.goto('/')
const tree = page.getByTestId('tree')
const rows = tree.locator('.g3-tree-node')
// DOM 顺序:前端 → Vue(前端首个子节点)→ …
const first = (await rows.nth(0).boundingBox())!
const second = (await rows.nth(1).boundingBox())!
const gap = second.y - (first.y + first.height)
// 默认 gap 6px;给 4~10 的容差(亚像素 / 缩放)
expect(gap).toBeGreaterThanOrEqual(4)
expect(gap).toBeLessThanOrEqual(10)
})
test('拖拽需要长按武装:直接拖动不会产生落点', async ({ page }) => {
@@ -335,7 +335,7 @@ const App = {
<div data-testid="tree">
<G3Tree :tree-data="treeData" checkable default-expand-all block-node />
</div>
<!-- 右键菜单:两个节点都不是父节点(验证叶子不占展开位) -->
<!-- 右键菜单:两个节点都是叶子,验证叶子保留(隐藏的)箭头占位列 -->
<G3Tree :tree-data="flatTreeData" :context-menu="treeMenuItems" />
<!-- 拖拽排序:长按 400ms 才武装,避免手抖 -->
<G3Tree :tree-data="treeData" draggable default-expand-all />
@@ -0,0 +1,31 @@
import { createSSRApp, defineComponent, h, ref } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useFloating } from './useFloating'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('useFloating SSR', () => {
it('does not access browser globals when initially closed', async () => {
vi.stubGlobal('document', undefined)
vi.stubGlobal('window', undefined)
const app = createSSRApp(
defineComponent({
setup() {
useFloating({
open: ref(false),
placement: () => 'bottom',
triggerRef: ref(null),
})
return () => h('div')
},
}),
)
await expect(renderToString(app)).resolves.toBe('<div></div>')
})
})
@@ -56,6 +56,7 @@ export function useFloating(options: UseFloatingOptions): UseFloatingReturn {
let rafId: number | null = null
let resizeObserver: ResizeObserver | null = null
let globalListenersBound = false
const resolvedBase = computed(() => resolvedPlacement.value.split('-')[0])
@@ -120,17 +121,21 @@ export function useFloating(options: UseFloatingOptions): UseFloatingReturn {
}
function bindGlobal(): void {
if (globalListenersBound || typeof document === 'undefined' || typeof window === 'undefined') return
document.addEventListener('pointerdown', onDocumentPointerDown, true)
document.addEventListener('keydown', onDocumentKeydown)
window.addEventListener('resize', scheduleUpdate)
window.addEventListener('scroll', scheduleUpdate, true)
globalListenersBound = true
}
function unbindGlobal(): void {
if (!globalListenersBound) return
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
document.removeEventListener('keydown', onDocumentKeydown)
window.removeEventListener('resize', scheduleUpdate)
window.removeEventListener('scroll', scheduleUpdate, true)
globalListenersBound = false
}
watch(
@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { setGlobalConfig } from '../_utils/global-config'
import { resolvePopupContainer } from './usePopupContainer'
afterEach(() => {
vi.unstubAllGlobals()
setGlobalConfig({ popupContainer: undefined })
})
describe('resolvePopupContainer', () => {
it('accepts element and selector containers in the browser', () => {
const element = document.createElement('div')
expect(resolvePopupContainer(element)).toBe(element)
expect(resolvePopupContainer('#local')).toBe('#local')
})
it('resolves a configured fallback when HTMLElement is unavailable', () => {
vi.stubGlobal('HTMLElement', undefined)
setGlobalConfig({ popupContainer: '#global' })
expect(resolvePopupContainer()).toBe('#global')
expect(resolvePopupContainer(() => null)).toBe('#global')
expect(resolvePopupContainer('#local')).toBe('#local')
})
it('falls back to body without browser globals', () => {
vi.stubGlobal('HTMLElement', undefined)
expect(resolvePopupContainer()).toBe('body')
})
})
@@ -2,6 +2,10 @@ import { computed, type ComputedRef } from 'vue'
import { useConfig } from '../config-provider/context'
import { getGlobalConfig, type PopupContainer } from '../_utils/global-config'
function isHTMLElement(value: unknown): value is HTMLElement {
return typeof HTMLElement !== 'undefined' && value instanceof HTMLElement
}
/**
* 解析弹层挂载容器。
*
@@ -10,11 +14,11 @@ import { getGlobalConfig, type PopupContainer } from '../_utils/global-config'
*/
export function resolvePopupContainer(container?: PopupContainer | null): string | HTMLElement {
const value = typeof container === 'function' ? container() : container
if (value instanceof HTMLElement) return value
if (isHTMLElement(value)) return value
if (typeof value === 'string' && value) return value
const fallback = getGlobalConfig().popupContainer
const resolved = typeof fallback === 'function' ? fallback() : fallback
if (resolved instanceof HTMLElement) return resolved
if (isHTMLElement(resolved)) return resolved
if (typeof resolved === 'string' && resolved) return resolved
return 'body'
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, useSlots, watch } from 'vue'
import { Check, Minus } from '@lucide/vue'
import { useNamespace } from '../_composables/useNamespace'
import type { CheckboxProps } from './types'
@@ -29,6 +29,7 @@ const emit = defineEmits<{
const { b, e, m, is } = useNamespace('checkbox')
const slots = useSlots()
const inputRef = ref<HTMLInputElement | null>(null)
/** 选中态优先显示勾选;仅在未选中且半选时显示减号 */
@@ -44,6 +45,8 @@ const classes = computed(() => [
[is('checked')]: props.modelValue,
[is('indeterminate')]: props.indeterminate,
[is('disabled')]: props.disabled,
// 有文案时才需要「勾选框对齐首行」的定位;无文案保持默认居中
[is('with-label')]: !!slots.default,
},
])
@@ -91,6 +94,7 @@ watch(() => [props.indeterminate, props.modelValue], syncIndeterminate)
.#{b('checkbox')} {
display: inline-flex;
vertical-align: middle;
align-items: center;
gap: v('space-2', 8px);
color: v('text-1', rgba(0, 0, 0, 0.88));
@@ -100,6 +104,16 @@ watch(() => [props.indeterminate, props.modelValue], syncIndeterminate)
user-select: none;
}
/* 有文案时对齐到首行:root 顶对齐,勾选框下沉半行高差,使其中心落在
第一行文字中心;单行结果与默认 center 等价,多行富内容则对齐首行而非整块居中 */
.#{b('checkbox')}.#{is('with-label')} {
align-items: flex-start;
}
.#{b('checkbox')}.#{is('with-label')} .#{e('checkbox', 'box')} {
margin-top: calc((v('line-height', 1.5) * 1em - v('checkbox-size', 16px)) / 2);
}
.#{e('checkbox', 'input')} {
position: absolute;
width: 1px;
@@ -382,7 +382,7 @@ const titleText = computed(() => {
<template v-else>{{ viewDate.getFullYear() }} 年</template>
</button>
<button
v-if="config.hasMonth"
v-if="config.hasMonth && mode !== 'year'"
type="button"
:class="[e('title-button'), { [is('active')]: mode === 'month' }]"
:aria-expanded="mode === 'month'"
@@ -532,12 +532,15 @@ const titleText = computed(() => {
@use '@g3soft/tokens/scss' as *;
.#{b('calendar')} {
/* 日历主体与时间列共用的高度:两者等高,面板不会一块高一块矮 */
--g3-cal-h: #{v('calendar-height', 282px)};
/* 日历主体与时间列共用的高度:两者等高,面板不会一块高一块矮。
292 = 表头 40 + 星期行 36 + 6 行 × 36(对齐 antd 的单元格尺度) */
--g3-cal-h: #{v('calendar-height', 292px)};
/* 单元格内块尺寸(范围带的厚度、选中/今日方块都用它) */
--g3-cal-cell: #{v('calendar-cell-size', 24px)};
display: flex;
flex-direction: column;
min-width: 240px;
padding: v('space-2', 8px);
/* 左右 16px:日历定宽 252(7 × 36)+ 32 = 面板宽 284 */
padding: v('space-2', 8px) v('space-4', 16px);
color: v('text-1', rgba(0, 0, 0, 0.88));
font-size: v('font-size', 14px);
}
@@ -545,8 +548,14 @@ const titleText = computed(() => {
.#{e('calendar', 'header')} {
display: flex;
align-items: center;
gap: v('space-2', 8px);
padding: v('space-1', 4px) 0 v('space-2', 8px);
/* 间距压到 4px:一行要放下 4 个导航按钮 + 年/月标题,标题的「数字 + 单位」必须
留在同一行(nowrap),横向空间很紧 */
gap: v('space-1', 4px);
/* 定高 40 + 底部一条分割线:对齐 antd 的日期面板表头。
border-box 让 40px 含掉那 1px 边框,否则表头会变成 41px、日历区整体多 1px */
box-sizing: border-box;
height: v('control-height-lg', 40px);
border-bottom: 1px solid v('border-color', #d9d9d9);
}
.#{e('calendar', 'nav-group')} {
@@ -564,21 +573,23 @@ const titleText = computed(() => {
border: 0;
border-radius: v('radius-sm', 4px);
background: transparent;
color: v('text-2', rgba(0, 0, 0, 0.65));
color: v('text-3', rgba(0, 0, 0, 0.45));
cursor: pointer;
svg {
width: 16px;
height: 16px;
/* antd 的箭头是 1.5px 细线;lucide 默认 2px 偏粗 */
stroke-width: 1.5;
}
/* 与 antd 一致:hover 只变色,不加底色 */
&:hover:not(:disabled) {
background: v('fill', rgba(0, 0, 0, 0.04));
color: v('text-1', rgba(0, 0, 0, 0.88));
}
&:disabled {
color: v('text-disabled', rgba(0, 0, 0, 0.25));
opacity: 0.25;
cursor: not-allowed;
}
}
@@ -601,10 +612,14 @@ const titleText = computed(() => {
font-size: inherit;
font-weight: 600;
cursor: pointer;
/* 「2026 年」不能被拆成两行:数字和单位必须连在一起。
不参与收缩(flex-shrink: 0),否则标题区一窄按钮就先压扁再折行 */
white-space: nowrap;
flex-shrink: 0;
/* 与 antd 一致:hover 只变主色,不加底色 */
&:hover,
&.is-active {
background: v('fill', rgba(0, 0, 0, 0.04));
color: v('color-primary', #18181b);
}
}
@@ -617,8 +632,9 @@ const titleText = computed(() => {
}
.#{e('calendar', 'weekday')} {
padding: v('space-1', 4px) 0;
color: v('text-3', rgba(0, 0, 0, 0.45));
height: 36px;
line-height: 36px;
color: v('text-1', rgba(0, 0, 0, 0.88));
font-size: v('font-size-sm', 12px);
text-align: center;
}
@@ -628,8 +644,8 @@ const titleText = computed(() => {
display: inline-flex;
align-items: center;
justify-content: center;
height: v('cal-cell-height', 32px);
padding: 0;
/* 纵向 6px = (行高 36 − 内块 24) / 2;行高由它撑出,不再写死 height */
padding: 6px 0;
border: 0;
background: transparent;
color: inherit;
@@ -638,7 +654,23 @@ const titleText = computed(() => {
font-variant-numeric: tabular-nums;
cursor: pointer;
&:hover:not(:disabled) {
/* 范围高亮是一整条「带」:厚度只取内块高、横向铺满单元格。
相邻格子的带首尾相接,就连成一条连续的选区背景(antd 同款) */
&::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: var(--g3-cal-cell);
transform: translateY(-50%);
background: transparent;
pointer-events: none;
}
/* hover 只染内块,且不覆盖选中 / 范围态 */
&:hover:not(:disabled):not(.#{is('selected')}):not(.#{is('range-single')}):not(.#{is('range-start')}):not(.#{is('range-end')})
.#{e('calendar', 'cell-inner')} {
background: v('fill', rgba(0, 0, 0, 0.04));
}
@@ -649,48 +681,77 @@ const titleText = computed(() => {
&:disabled {
color: v('text-disabled', rgba(0, 0, 0, 0.25));
cursor: not-allowed;
&::before {
background: transparent;
}
}
}
.#{e('calendar', 'cell')}.#{is('other-month')} {
color: v('text-3', rgba(0, 0, 0, 0.45));
color: v('text-disabled', rgba(0, 0, 0, 0.25));
}
.#{e('calendar', 'cell')}.#{is('today')} .#{e('calendar', 'cell-inner')} {
color: v('color-primary', #18181b);
font-weight: 600;
/* 今日:内块描一圈主色(antd 用描边而不是改文字色,选中时描边仍与填充叠加可见) */
.#{e('calendar', 'cell')}.#{is('today')} .#{e('calendar', 'cell-inner')}::before {
content: '';
position: absolute;
inset: 0;
border: 1px solid v('color-primary', #18181b);
border-radius: v('radius-sm', 4px);
}
.#{e('calendar', 'cell')}.#{is('selected')} .#{e('calendar', 'cell-inner')},
.#{e('calendar', 'cell')}.#{is('range-single')} .#{e('calendar', 'cell-inner')} {
border-radius: 50%;
border-radius: v('radius-sm', 4px);
background: v('color-primary', #18181b);
color: v('color-primary-on', #fafafa);
}
.#{e('calendar', 'cell')}.#{is('range-start')},
.#{e('calendar', 'cell')}.#{is('range-end')} {
.#{e('calendar', 'cell')}.#{is('range-start')}::before,
.#{e('calendar', 'cell')}.#{is('range-end')}::before {
background: v('color-primary-soft', rgba(24, 24, 27, 0.12));
}
/* 起点只染右半段、终点只染左半段,两端各自收口 */
.#{e('calendar', 'cell')}.#{is('range-start')}::before {
left: 50%;
}
.#{e('calendar', 'cell')}.#{is('range-end')}::before {
right: 50%;
}
.#{e('calendar', 'cell')}.#{is('range-start')} .#{e('calendar', 'cell-inner')},
.#{e('calendar', 'cell')}.#{is('range-end')} .#{e('calendar', 'cell-inner')} {
border-radius: 50%;
background: v('color-primary', #18181b);
color: v('color-primary-on', #fafafa);
}
.#{e('calendar', 'cell')}.#{is('in-range')},
.#{e('calendar', 'cell')}.#{is('in-hover-range')} {
/* 端点内块只在「朝外」的一侧倒角,与相邻的浅色带拼出圆头 */
.#{e('calendar', 'cell')}.#{is('range-start')}:not(.#{is('range-end')})
.#{e('calendar', 'cell-inner')} {
border-radius: v('radius-sm', 4px) 0 0 v('radius-sm', 4px);
}
.#{e('calendar', 'cell')}.#{is('range-end')}:not(.#{is('range-start')})
.#{e('calendar', 'cell-inner')} {
border-radius: 0 v('radius-sm', 4px) v('radius-sm', 4px) 0;
}
.#{e('calendar', 'cell')}.#{is('in-range')}::before,
.#{e('calendar', 'cell')}.#{is('in-hover-range')}::before {
background: v('color-primary-soft', rgba(24, 24, 27, 0.12));
}
.#{e('calendar', 'cell-inner')} {
position: relative; /* 今日描边的定位上下文;并保证内块压在范围带之上 */
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
width: var(--g3-cal-cell);
height: var(--g3-cal-cell);
}
/* ---------- 年 / 月选择器 ---------- */
@@ -744,20 +805,21 @@ const titleText = computed(() => {
.#{e('calendar', 'body')} {
display: flex;
align-items: stretch;
/* main 定宽 252,面板内容宽也是 252(8px 16px 内边距)→ 本来没有余量。
保留居中 + gap:与时间列的间距走 gap;万一外层给了更宽的面板也还能水平居中 */
justify-content: center;
gap: v('space-2', 8px);
}
.#{e('calendar', 'main')} {
/* 定宽:日历 7 列各约 31px。用固定宽度而不是被触发框拉伸,
/* 定宽:日历 7 列各 36px(7 × 36 = 252)。用固定宽度而不是被触发框拉伸,
否则输入框很宽时(表单满宽)日期格子间距会被撑得很散 */
flex: 0 0 auto;
width: v('calendar-main-width', 217px);
width: v('calendar-main-width', 252px);
display: flex;
flex-direction: column;
padding-right: v('space-2', 8px);
}
/* 带时间列时与右侧等高:日期行均匀铺满,不会上挤下空 */
.#{b('calendar')}.#{is('has-time')} .#{e('calendar', 'main')} {
/* 定高:日期网格正好铺满(40 + 252);切到年 / 月视图时选择器也撑到同样高度,
面板不会在视图间忽高忽低,时间列也始终与日历区等高 */
height: var(--g3-cal-h);
}
@@ -198,6 +198,11 @@ function setColumnRef(key: string, el: unknown) {
border-left: 1px solid v('border-color', #d9d9d9);
}
/* 当前列高亮:antd 用 JS 记录 active 列,这里用 :hover 近似,不引入额外状态 */
.#{e('time-select', 'col')}:hover {
background: color-mix(in srgb, v('color-primary', #18181b) 4%, transparent);
}
.#{e('time-select', 'list')} {
flex: 1 1 auto;
min-height: 0;
@@ -219,10 +224,15 @@ function setColumnRef(key: string, el: unknown) {
.#{e('time-select', 'item')} {
height: v('time-item-height', 28px);
line-height: v('time-item-height', 28px);
/* 左右各留 4px:内块 48 宽、圆角 4,与 antd 的时间单元格一致 */
margin: 0 v('space-1', 4px);
border-radius: v('radius-sm', 4px);
color: v('text-1', rgba(0, 0, 0, 0.88));
font-size: v('font-size', 14px);
font-variant-numeric: tabular-nums;
text-align: center;
/* 数字靠左并对齐到统一缩进(antd 的时间列不是居中的) */
padding-inline-start: 14px;
text-align: left;
cursor: pointer;
}
@@ -230,10 +240,9 @@ function setColumnRef(key: string, el: unknown) {
background: v('fill', rgba(0, 0, 0, 0.04));
}
/* 选中:浅主色底(antd 的时间列不是实心主色 + 白字) */
.#{e('time-select', 'item')}.#{is('active')} {
background: v('color-primary', #18181b);
color: v('color-primary-on', #fafafa);
font-weight: 500;
background: v('color-primary-soft', rgba(24, 24, 27, 0.12));
}
.#{b('time-select')}.#{is('disabled')} {
@@ -350,7 +350,6 @@ onBeforeUnmount(() => {
margin: 0;
padding: v('space-3', 12px) v('space-5', 24px);
border: 0;
border-bottom: 1px solid v('border-color', #d9d9d9);
}
.#{e('drawer', 'header-main')} {
@@ -412,7 +411,6 @@ onBeforeUnmount(() => {
margin: 0;
padding: v('space-2', 8px) v('space-5', 24px);
border: 0;
border-top: 1px solid v('border-color', #d9d9d9);
}
.#{e('drawer', 'resizer')} {
@@ -390,7 +390,6 @@ onBeforeUnmount(() => {
align-items: center;
gap: v('space-2', 8px);
padding: v('space-3', 12px) v('space-5', 24px);
border-bottom: 1px solid v('border-color', #d9d9d9);
cursor: move;
user-select: none;
}
@@ -452,7 +451,6 @@ onBeforeUnmount(() => {
justify-content: flex-end;
gap: v('space-2', 8px);
padding: v('space-3', 12px) v('space-5', 24px);
border-top: 1px solid v('border-color', #d9d9d9);
}
/* ---------- 缩放手柄 ---------- */
@@ -254,17 +254,25 @@ const visible = computed(() => !props.hideOnSinglePage || totalPages.value > 1)
color: v('text-2', rgba(0, 0, 0, 0.65));
}
.#{e('pagination', 'list')} {
display: flex;
align-items: center;
gap: v('space-1', 4px);
margin: 0;
padding: 0;
list-style: none;
}
/* 提高选择器特异性(.g3-pagination .g3-pagination__list)以盖住宿主页面
针对 `ul` / `li + li` 的元素选择器(如文档站 .vp-doc ul、.vp-doc li + li):
否则列表被加上 padding-left 缩进、非首项被加上 margin-top,表现为
「左侧箭头比其它项高一截、没对齐」。 */
.#{b('pagination')} {
.#{e('pagination', 'list')} {
display: flex;
align-items: center;
gap: v('space-1', 4px);
margin: 0;
padding: 0;
list-style: none;
}
.#{e('pagination', 'item')} {
display: inline-flex;
.#{e('pagination', 'item')} {
display: inline-flex;
margin: 0;
padding: 0;
}
}
.#{e('pagination', 'page')},
@@ -35,7 +35,6 @@ import {
getNodeChildren,
getNodeKey,
getParentKeys,
isLeafNode,
resolveFieldNames,
} from './utils'
@@ -564,27 +563,6 @@ onBeforeUnmount(() => {
closeMenu()
})
/* ---------- 叶子占位 ----------
缩进占位只对「同一批兄弟里存在可展开节点」的叶子有意义:整层都是叶子时
再空出一块就是白占位置(右键菜单示例里两个节点都没有子节点,却各留一格)。
懒加载下「未加载过」的节点算可展开,占位保留 —— 展开后那里要放箭头。 */
const placeholderLeaves = computed(() => {
const set = new Set<string>()
const leafOptions = { lazy: props.lazy, loadedSet: loadedModel.set.value }
const walk = (nodes: TreeNodeData[]) => {
const hasExpandable = nodes.some((node) => !isLeafNode(node, fieldNames.value, leafOptions))
for (const node of nodes) {
if (hasExpandable && isLeafNode(node, fieldNames.value, leafOptions)) {
set.add(String(getNodeKey(node, fieldNames.value)))
}
const children = getNodeChildren(node, fieldNames.value)
if (children) walk(children)
}
}
walk(props.treeData)
return set
})
/* ---------- 上下文 ----------
用 getter 暴露响应式值:读取发生在消费方(TreeNode 的渲染 / computed)的
响应式作用域里,因此依赖能被正确收集,不需要额外包一层 reactive。 */
@@ -622,9 +600,6 @@ const treeContext: TreeContext = {
get selectedSet() {
return selectedSet.value
},
get placeholderKeys() {
return placeholderLeaves.value
},
get checkedSet() {
return conductResult.value.checked
},
@@ -190,10 +190,10 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node
@dragend="onRowDragEnd"
@contextmenu="tree!.onNodeContextMenu(node, nodeKey, $event)"
>
<!-- 叶子只在「同层兄弟里有可展开节点」时留等宽占位(对齐箭头);
整层都是叶子时不再空出一块 -->
<!-- 箭头列:所有节点都保留(叶子用 visibility:hidden 占位),
这样每一层都稳定占用「缩进 + 箭头列」,子节点相对父节点缩进一层 —— 与 naive-ui 一致。
若叶子跳过这一列,叶子会落回父节点文字的左侧,层级看起来是错的。 -->
<span
v-if="!leaf || tree!.placeholderKeys.has(nodeKey)"
:class="[e('switcher'), { [is('placeholder')]: leaf }]"
@click.stop="!disabled && !leaf && tree!.toggleExpand(node, nodeKey)"
>
@@ -249,6 +249,14 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node
<style lang="scss">
@use '@g3soft/tokens/scss' as *;
/* 节点行间距:给每个节点的包裹层加「顶部间距」。
刻意不用上下对称 padding —— 子节点块嵌在父节点的包裹层内部,对称 padding 会让
「父行 → 首个子行」只剩一半间距,而兄弟行之间是一整份。只加顶部间距后,
任意相邻两行的间距都相等。 */
.#{e('tree-node', 'wrapper')} {
padding-top: v('tree-node-gap', 6px);
}
/* 节点行 */
.#{b('tree-node')} {
position: relative;
@@ -256,7 +264,7 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node
align-items: center;
gap: v('space-1', 4px);
box-sizing: border-box;
min-height: v('tree-node-height', 32px);
min-height: v('tree-node-height', 28px);
/* 缩进 = 基础留白 + 每层缩进量 × 层级(层级由行内 CSS 变量下发) */
padding-left: calc(
#{v('tree-gutter-x', 4px)} + #{v('tree-indent', 18px)} * var(--g3-tree-level, 0)
@@ -333,6 +341,11 @@ const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node
color: v('text-3', rgba(0, 0, 0, 0.45));
}
/* 叶子:保留这一列的宽度(保证缩进对齐)但不显示内容 */
.#{e('tree-node', 'switcher')}.#{is('placeholder')} {
visibility: hidden;
}
.#{e('tree-node', 'switcher')}:hover:not(.#{is('placeholder')}) {
background: v('fill-hover', rgba(0, 0, 0, 0.08));
}
@@ -128,8 +128,6 @@ export interface TreeContext {
blockNode: boolean
expandedSet: Set<string>
selectedSet: Set<string>
/** 需要渲染缩进占位的叶子节点:同一批兄弟里存在可展开节点时才留位对齐 */
placeholderKeys: Set<string>
checkedSet: Set<string>
halfSet: Set<string>
loadedSet: Set<string>