u
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user