u
This commit is contained in:
1 parent
e434374db5
commit
b4498e5350
31 files changed
+2007
-294
No files matched your search
@@ -66,22 +66,26 @@ test.describe('G3Table 表格', () => {
|
||||
expect(innerBox!.height).toBeLessThanOrEqual(wrapBox!.height + 2)
|
||||
})
|
||||
|
||||
test('边框由库绘制:不再有自绘外框与圆角', async ({ page }) => {
|
||||
// 组件已改为把边框完全交给库(传 bordered: true),自己不再画外框 / 圆角。
|
||||
test('边框自绘:外框是根节点的圆角描边,库的外框被清掉', async ({ page }) => {
|
||||
// 边框设计(见 docs/table-design.md §3.2):外框与横线由组件自绘,纵向列线用库的
|
||||
// 渐变。组件把 `bordered` 翻成库的 'v' 档(见 G3Table.vue 的 `:bordered` 绑定)。
|
||||
// data-testid 透传给内层 StkTable(组件 inheritAttrs: false),所以按类名定位根节点。
|
||||
const root = page.locator('[data-testid="table-wrap"] .g3-table').first()
|
||||
await expect(root).toHaveClass(/is-bordered/)
|
||||
|
||||
// 自绘外框已删除:根节点不该再有 border / 圆角
|
||||
const radius = await root.evaluate((el) => getComputedStyle(el).borderRadius)
|
||||
expect(radius).toBe('0px')
|
||||
const borderWidth = await root.evaluate((el) => getComputedStyle(el).borderTopWidth)
|
||||
expect(borderWidth).toBe('0px')
|
||||
// 自绘外框:根节点上有 1px 描边 + 圆角(库的外框做不出圆角,这正是自绘的理由)
|
||||
const outer = await root.evaluate((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
return { width: s.borderTopWidth, radius: s.borderTopLeftRadius }
|
||||
})
|
||||
expect(outer.width).toBe('1px')
|
||||
expect(outer.radius).not.toBe('0px')
|
||||
|
||||
// 边框改由库的 .bordered 提供
|
||||
await expect(page.locator('[data-testid="table-wrap"] .stk-table').first()).toHaveClass(
|
||||
/bordered/,
|
||||
)
|
||||
// 库那层外框必须被抹掉:'v' 档是字符串,库会同时挂 .bordered 与 .bordered-v,
|
||||
// 前者的 `border-left` 若留着,就和自绘外框叠成双线
|
||||
const stk = page.locator('[data-testid="table-wrap"] .stk-table').first()
|
||||
await expect(stk).toHaveClass(/bordered-v/)
|
||||
expect(await stk.evaluate((el) => getComputedStyle(el).borderLeftWidth)).toBe('0px')
|
||||
})
|
||||
|
||||
test('表头显示序号列(seq 默认开启)', async ({ page }) => {
|
||||
@@ -633,22 +637,42 @@ test.describe('G3Table 布局:表头固定与分隔线', () => {
|
||||
expect(Math.abs(after!.y - before!.y)).toBeLessThan(2)
|
||||
})
|
||||
|
||||
test('边框交由库绘制:库根节点带 .bordered,且不再叠加自绘实线', async ({ page }) => {
|
||||
test('边框自绘:库只保留竖线,横线走渐变而不是真 border', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
// 我们传 bordered: true,库会给根节点挂 .bordered(并因是字符串才挂 bordered-xxx)
|
||||
// 我们传的是库的 'v' 档(字符串才挂 bordered-xxx)
|
||||
const stk = wrap.locator('.stk-table').first()
|
||||
await expect(stk).toHaveClass(/bordered/)
|
||||
await expect(stk).toHaveClass(/bordered-v/)
|
||||
|
||||
// 自绘的行分隔线已删除,单元格不应再有真的 border-bottom
|
||||
// (库用 background-image 渐变画线,不走 border)
|
||||
const td = await wrap
|
||||
.locator('.stk-tbody-main td')
|
||||
.first()
|
||||
.evaluate((el) => getComputedStyle(el).borderBottomWidth)
|
||||
expect(td).toBe('0px')
|
||||
/** 数「非透明的背景渐变层数」——即这一格画了几条线(不看库内部怎么拼) */
|
||||
const lineCount = (locator: import('@playwright/test').Locator) =>
|
||||
locator.evaluate((el) =>
|
||||
getComputedStyle(el)
|
||||
.backgroundImage.split(/,(?![^(]*\))/)
|
||||
.filter((layer) => /rgb\(\d+, \d+, \d+\)/.test(layer)).length,
|
||||
)
|
||||
|
||||
// 横线:`'v'` 档把 --bg-border-bottom 清零(只剩竖线一条),组件按底座配方把它
|
||||
// 拿回来 —— 所以这里应当数出**两条**。但它**不能**是真 border:格线一进盒模型,
|
||||
// 单元格多 1px 就会被行内编辑器顶高(行高是虚拟滚动 translateY 的基准)。
|
||||
const td = wrap.locator('.stk-tbody-main td').first()
|
||||
const normalLines = await lineCount(td)
|
||||
expect(normalLines).toBeGreaterThan(1)
|
||||
expect(await td.evaluate((el) => getComputedStyle(el).borderBottomWidth)).toBe('0px')
|
||||
|
||||
// 竖线:末列的外侧那条已被抹掉(归自绘外框管),所以比普通列**正好少一条**
|
||||
const lastTd = wrap.locator('.stk-tbody-main td:last-child').first()
|
||||
expect(await lineCount(lastTd)).toBe(normalLines - 1)
|
||||
|
||||
// 行高没被格线动过(30 = 默认 rowHeight,也是虚拟滚动 translateY 的基准)
|
||||
expect(
|
||||
await wrap
|
||||
.locator('.stk-tbody-main tr')
|
||||
.first()
|
||||
.evaluate((el) => Math.round(el.getBoundingClientRect().height)),
|
||||
).toBe(30)
|
||||
})
|
||||
|
||||
test('库自绘边框:单元格带可见的横竖渐变线', async ({ page }) => {
|
||||
|
||||
@@ -39,7 +39,9 @@ const { b, m, is } = useNamespace('form')
|
||||
const merged = useComponentDefaults('form', props)
|
||||
|
||||
const resolvedLayout = computed(() => merged.value.layout ?? 'horizontal')
|
||||
const resolvedLabelAlign = computed(() => merged.value.labelAlign ?? 'right')
|
||||
// 默认左对齐:横排下右对齐需要定宽 label 才有意义,未给定宽时右对齐会让标签贴到控件一侧;
|
||||
// 竖排(layout="vertical")下 label 占满整列,右对齐更是直接把文字推到边缘。要右对齐请显式传。
|
||||
const resolvedLabelAlign = computed(() => merged.value.labelAlign ?? 'left')
|
||||
const resolvedLabelWidth = computed(() => merged.value.labelWidth)
|
||||
const resolvedValidateTrigger = computed(() => merged.value.validateTrigger ?? 'change')
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import G3Form from './Form.vue'
|
||||
import G3FormItem from './FormItem.vue'
|
||||
import G3FormGroup from './FormGroup.vue'
|
||||
|
||||
/**
|
||||
* 分组只是分区标题 + 一组共用列数 / 布局 / 间距的字段,**没有折叠能力**:
|
||||
* 标题不带箭头、不响应点击,组件也不该再声明折叠相关的 prop。
|
||||
*/
|
||||
const COMPONENTS = { G3Form, G3FormGroup, G3FormItem }
|
||||
|
||||
/** extraAttrs 走模板字符串,贴近真实写法 */
|
||||
function mountGroup(extraAttrs = '') {
|
||||
return mount({
|
||||
components: COMPONENTS,
|
||||
data() {
|
||||
return { model: { a: '' } }
|
||||
},
|
||||
template: `
|
||||
<G3Form :model="model">
|
||||
<G3FormGroup title="分组" ${extraAttrs}>
|
||||
<G3FormItem name="a" label="字段"><input /></G3FormItem>
|
||||
</G3FormGroup>
|
||||
</G3Form>
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
describe('FormGroup', () => {
|
||||
it('标题只是分区标题,没有折叠入口', () => {
|
||||
const wrapper = mountGroup()
|
||||
|
||||
const title = wrapper.find('.g3-form-group__title')
|
||||
expect(title.text()).toBe('分组')
|
||||
expect(title.attributes('role')).toBeUndefined()
|
||||
expect(title.attributes('tabindex')).toBeUndefined()
|
||||
expect(title.attributes('aria-expanded')).toBeUndefined()
|
||||
expect(title.classes()).not.toContain('is-collapsible')
|
||||
// 箭头与折叠容器都不该存在
|
||||
expect(wrapper.find('.g3-form-group__arrow').exists()).toBe(false)
|
||||
expect(wrapper.find('.g3-form-group__collapse').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('点击标题不会改变内容可见性', async () => {
|
||||
const wrapper = mountGroup()
|
||||
|
||||
await wrapper.find('.g3-form-group__title').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// 字段内容直接挂在 body 下,行高不受任何折叠状态影响
|
||||
const body = wrapper.find('.g3-form-group__body')
|
||||
expect(body.find('[data-field="a"]').exists()).toBe(true)
|
||||
expect(body.attributes('style') ?? '').not.toContain('0fr')
|
||||
})
|
||||
|
||||
it('不再声明折叠相关 prop', () => {
|
||||
const declared = Object.keys(
|
||||
(G3FormGroup as unknown as { props?: Record<string, unknown> }).props ?? {},
|
||||
)
|
||||
expect(declared).not.toContain('collapsible')
|
||||
expect(declared).not.toContain('defaultExpanded')
|
||||
expect(declared).not.toContain('modelValue')
|
||||
})
|
||||
|
||||
it('仍然提供组内列数上下文(columns > 1 走网格)', () => {
|
||||
const wrapper = mountGroup(':columns="2"')
|
||||
expect(wrapper.find('.g3-form-group').classes()).toContain('g3-form-group--grid')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onBeforeUnmount, provide, ref, watch } from 'vue'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { computed, inject, provide } from 'vue'
|
||||
import { useComponentDefaults } from '../_composables/useComponentDefaults'
|
||||
import { useNamespace } from '../_composables/useNamespace'
|
||||
import { formGroupKey, formKey } from './context'
|
||||
@@ -11,15 +10,17 @@ import type { FormGroupProps } from './types'
|
||||
* 表单分组 —— 一组字段共用独立的列数 / 布局 / 间距,便于在长表单里做宫格嵌套。
|
||||
*
|
||||
* 组内覆盖优先于外层 Form:FormItem 的 label 宽度、布局与跨列数都会先看组上下文。
|
||||
* 标题行可点击折叠(collapsible 默认开启);折叠用 grid-template-rows 过渡而不是卸载,
|
||||
* 收起期间字段与校验状态全部保留(也可用 v-model 受控展开态)。
|
||||
*
|
||||
* 标题只是分区标题,**不提供折叠**:把字段藏到标题后面,配置类表单里更容易漏看,
|
||||
* 分组本身也起不到收起省空间的作用(字段数是固定的)。同理也不做 hover 反馈——
|
||||
* 不可点的东西不该看起来可点。
|
||||
*
|
||||
* 视觉保持「无色块、无粘顶」的中性分区:分层只交给外层 Form 的组间距(见 Form 的 gap),
|
||||
* 组件本身不画底色 / 描边 / 分隔线,避免分组风格与页面主题或设计器分组撞车。
|
||||
*/
|
||||
defineOptions({ name: 'G3FormGroup' })
|
||||
|
||||
const props = withDefaults(defineProps<FormGroupProps>(), {
|
||||
modelValue: undefined,
|
||||
collapsible: true,
|
||||
defaultExpanded: true,
|
||||
columns: undefined,
|
||||
layout: undefined,
|
||||
labelWidth: undefined,
|
||||
@@ -28,47 +29,11 @@ const props = withDefaults(defineProps<FormGroupProps>(), {
|
||||
title: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [expanded: boolean] }>()
|
||||
|
||||
const { b, e, m, is } = useNamespace('form-group')
|
||||
const { b, e, m } = useNamespace('form-group')
|
||||
const form = inject(formKey, null)
|
||||
|
||||
const merged = useComponentDefaults('form-group', props)
|
||||
|
||||
/** 展开态:受控优先,否则内部维护(初值取 defaultExpanded) */
|
||||
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
|
||||
if (props.modelValue === undefined) innerExpanded.value = next
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
|
||||
/** 组内列数:显式 columns > 外层 Form.columns > 1 */
|
||||
const columns = computed(() => merged.value.columns ?? form?.columns.value ?? 1)
|
||||
|
||||
@@ -115,29 +80,14 @@ provide(formGroupKey, {
|
||||
|
||||
<template>
|
||||
<div :class="[b(), { [m('grid')]: columns > 1 }]" :style="rootStyle">
|
||||
<div
|
||||
v-if="title || $slots.title"
|
||||
:class="[e('title'), { [is('collapsible')]: collapsible, [is('expanded')]: expanded }]"
|
||||
:role="collapsible ? 'button' : undefined"
|
||||
:tabindex="collapsible ? 0 : undefined"
|
||||
:aria-expanded="collapsible ? expanded : undefined"
|
||||
@click="toggleCollapse"
|
||||
@keydown.enter.prevent="toggleCollapse"
|
||||
@keydown.space.prevent="toggleCollapse"
|
||||
>
|
||||
<ChevronDown v-if="collapsible" :class="e('arrow')" aria-hidden="true" />
|
||||
<div v-if="title || $slots.title" :class="e('title')">
|
||||
<span :class="e('title-text')">
|
||||
<slot name="title">{{ title }}</slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="[e('collapse'), { [is('collapsed')]: !expanded, [is('collapsing')]: animating }]"
|
||||
:style="{ gridTemplateRows: expanded ? '1fr' : '0fr' }"
|
||||
>
|
||||
<div :class="e('body')" :style="groupStyle">
|
||||
<slot />
|
||||
</div>
|
||||
<div :class="e('body')" :style="groupStyle">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -162,56 +112,6 @@ provide(formGroupKey, {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.#{e('form-group', 'title')}.#{is('collapsible')} {
|
||||
cursor: pointer;
|
||||
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', 'arrow')} {
|
||||
color: v('color-primary', #18181b);
|
||||
}
|
||||
|
||||
.#{e('form-group', 'title')}.#{is('collapsible')}:focus-visible {
|
||||
@include focus-ring();
|
||||
border-radius: v('radius-sm', 4px);
|
||||
}
|
||||
|
||||
.#{e('form-group', 'arrow')} {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: v('text-3', rgba(0, 0, 0, 0.45));
|
||||
/* 收起时箭头转向右侧,展开朝下 */
|
||||
transform: rotate(-90deg);
|
||||
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')} {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
/* 折叠用网格行过渡:0fr 收起但内容不卸载,字段状态与校验态都还在 */
|
||||
.#{e('form-group', 'collapse')} {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows v('duration', 0.2s) v('ease', ease);
|
||||
|
||||
> * {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 裁切只在「收起 / 折叠动画中」开启:收起态内容不可见、动画期间防外溢;
|
||||
* 展开且静止时必须放开——常开 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;
|
||||
|
||||
@@ -156,3 +156,34 @@ describe('FormItem 校验与提示', () => {
|
||||
expect(wrapper.find('.g3-form-item__help').text()).toBe('名称不能为空')
|
||||
})
|
||||
})
|
||||
|
||||
describe('label 对齐默认值', () => {
|
||||
function mountWithLabelAlign(extra = '') {
|
||||
return mount({
|
||||
components: COMPONENTS,
|
||||
data() {
|
||||
return { model: { a: '' } }
|
||||
},
|
||||
template: `
|
||||
<G3Form :model="model" :label-width="80" ${extra}>
|
||||
<G3FormItem name="a" label="名称"><span class="plain" /></G3FormItem>
|
||||
</G3Form>
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
it('未传 labelAlign 时默认左对齐', () => {
|
||||
const wrapper = mountWithLabelAlign()
|
||||
expect(wrapper.find('.g3-form-item__label').classes()).not.toContain('is-label-right')
|
||||
})
|
||||
|
||||
it('显式 label-align="right" 时才右对齐', () => {
|
||||
const wrapper = mountWithLabelAlign('label-align="right"')
|
||||
expect(wrapper.find('.g3-form-item__label').classes()).toContain('is-label-right')
|
||||
})
|
||||
|
||||
it('竖排下标签左对齐(不被默认值推到整列右边缘)', () => {
|
||||
const wrapper = mountWithLabelAlign('layout="vertical"')
|
||||
expect(wrapper.find('.g3-form-item__label').classes()).not.toContain('is-label-right')
|
||||
})
|
||||
})
|
||||
@@ -45,7 +45,7 @@ export interface FormProps {
|
||||
rules?: Record<string, FormRule[]>
|
||||
/** 布局;不传时回落全局默认值 */
|
||||
layout?: FormLayout
|
||||
/** label 文本对齐方式;不传时回落全局默认值 */
|
||||
/** label 文本对齐方式;不传时回落全局默认值,最终兜底为 left */
|
||||
labelAlign?: FormLabelAlign
|
||||
/** label 宽度(仅横向布局生效) */
|
||||
labelWidth?: string | number
|
||||
@@ -96,12 +96,6 @@ export interface FormItemProps {
|
||||
}
|
||||
|
||||
export interface FormGroupProps {
|
||||
/** 受控展开态(v-model);不传时由 defaultExpanded 决定 */
|
||||
modelValue?: boolean
|
||||
/** 是否可折叠:点击标题行切换展开 / 收起 */
|
||||
collapsible?: boolean
|
||||
/** 非受控时的初始展开态 */
|
||||
defaultExpanded?: boolean
|
||||
/**
|
||||
* 组内列数;不传时继承外层 Form 的 columns。
|
||||
* 传 1 可让分组的字段强制单列排布。
|
||||
|
||||
@@ -83,7 +83,9 @@ function toggle(event: MouseEvent) {
|
||||
width: v('switch-width', 32px);
|
||||
height: v('switch-height', 18px);
|
||||
border-radius: 999px;
|
||||
background: v('fill-hover', rgba(0, 0, 0, 0.08));
|
||||
/* 关闭态:中性填充 fill(0.04)。Semi 用 fill-0 打底、hover fill-1、按下 fill-2,
|
||||
我们用 fill / fill-hover 两档就近映射(见下方 hover / active) */
|
||||
background: v('fill', rgba(0, 0, 0, 0.04));
|
||||
transition: background-color v('duration', 0.2s) v('ease', ease);
|
||||
}
|
||||
|
||||
@@ -96,14 +98,46 @@ function toggle(event: MouseEvent) {
|
||||
height: v('switch-thumb-size', 14px);
|
||||
border-radius: 50%;
|
||||
background: v('bg-container', #fff);
|
||||
box-shadow: v('shadow', 0 2px 8px rgba(0, 0, 0, 0.15));
|
||||
transition: transform v('duration', 0.2s) v('ease', ease);
|
||||
/* 立体感来自滑块自身,而不是轨道 —— Semi 的 track 完全没有 box-shadow,
|
||||
全靠这一层把滑块「托」起来。两层叠加:
|
||||
· 0 4px 6px rgba(0,0,0,.1) 投影,制造悬浮高度
|
||||
· 0 0 1px rgba(0,0,0,.3) 1px 贴边描边,白滑块落在浅色轨道上也有清晰边界
|
||||
只留投影会「发虚」,只留描边会「发平」,两层缺一不可。 */
|
||||
box-shadow:
|
||||
0 4px 6px rgba(0, 0, 0, 0.1),
|
||||
0 0 1px rgba(0, 0, 0, 0.3);
|
||||
/* width 也要过渡:按下态滑块会延展,只过渡 transform 会「跳宽」 */
|
||||
transition:
|
||||
transform v('duration', 0.2s) v('ease', ease),
|
||||
width v('duration', 0.2s) v('ease', ease);
|
||||
}
|
||||
|
||||
/* ---------- 关闭态:hover / 按下 ---------- */
|
||||
.#{b('switch')}:hover:not(.#{is('disabled')}):not(.#{is('checked')}) .#{e('switch', 'track')} {
|
||||
background: v('fill-hover', rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
/* 关闭态按下:比 hover 再深一档。Semi 有 fill-2,我们只有 fill / fill-hover 两档。
|
||||
刻意**不用** box-shadow 叠色:track 的禁用态描边也用 box-shadow,
|
||||
两者会互相覆盖(选中/禁用的组合下直接丢失描边)。用 filter 压暗,与阴影互不干扰。 */
|
||||
.#{b('switch')}:active:not(.#{is('disabled')}):not(.#{is('checked')}) .#{e('switch', 'track')} {
|
||||
background: v('fill-hover', rgba(0, 0, 0, 0.08));
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
|
||||
/* ---------- 开启态 ---------- */
|
||||
.#{b('switch')}.#{is('checked')} .#{e('switch', 'track')} {
|
||||
background: v('color-primary', #18181b);
|
||||
}
|
||||
|
||||
.#{b('switch')}.#{is('checked')}:hover:not(.#{is('disabled')}) .#{e('switch', 'track')} {
|
||||
background: v('color-primary-hover', rgba(24, 24, 27, 0.9));
|
||||
}
|
||||
|
||||
.#{b('switch')}.#{is('checked')}:active:not(.#{is('disabled')}) .#{e('switch', 'track')} {
|
||||
background: v('color-primary-active', rgba(24, 24, 27, 0.8));
|
||||
}
|
||||
|
||||
.#{b('switch')}.#{is('checked')} .#{e('switch', 'thumb')} {
|
||||
/* 乘 --g3-dir-sign(LTR 1 / RTL -1):RTL 下选中态滑块向左移动 */
|
||||
transform: translateX(
|
||||
@@ -116,13 +150,59 @@ function toggle(event: MouseEvent) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 按下滑块延展(Semi 手感的关键)----------
|
||||
按下时滑块朝「要去的方向」拉长 4px,松开回弹。两态都要做:
|
||||
· 关闭态按下:滑块右缘延伸,暗示「将要往右走」;
|
||||
· 开启态按下:滑块左缘延伸 —— 位移同步回退 4px 抵掉,
|
||||
否则会把右边缘顶出轨道外。
|
||||
用 inset-inline-start 打底 + 逻辑方向符号,RTL 下自动跟随。 */
|
||||
.#{b('switch')}:active:not(.#{is('disabled')}) .#{e('switch', 'thumb')} {
|
||||
width: calc(#{v('switch-thumb-size', 14px)} + 4px);
|
||||
}
|
||||
|
||||
.#{b('switch')}.#{is('checked')}:active:not(.#{is('disabled')}) .#{e('switch', 'thumb')} {
|
||||
transform: translateX(
|
||||
calc(
|
||||
#{v('dir-sign', 1)} * (
|
||||
#{v('switch-width', 32px)} - #{v('switch-thumb-size', 14px)} - #{v('switch-thumb-offset', 2px)} *
|
||||
2 - 4px
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
.#{b('switch')}:focus-visible .#{e('switch', 'track')} {
|
||||
@include focus-ring();
|
||||
}
|
||||
|
||||
/* ---------- 禁用态 ----------
|
||||
关闭态禁用:Semi 是「透明底 + border 描边」($color-switch_disabled-border-default
|
||||
= --semi-color-border),比单纯压透明度更能表达「这里有个开关,但它是关的且不可用」。
|
||||
开启态禁用:保留主色底 + opacity,换灰会丢掉「它开着」这一信息。
|
||||
两态的滑块都去掉投影(禁用不该有「浮起」暗示),关闭态补描边保证白滑块有边界。 */
|
||||
.#{b('switch')}.#{is('disabled')} {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
color: v('text-disabled', rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
|
||||
.#{b('switch')}.#{is('disabled')} .#{e('switch', 'track')} {
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 1px v('border-color', #d9d9d9);
|
||||
}
|
||||
|
||||
.#{b('switch')}.#{is('disabled')}.#{is('checked')} .#{e('switch', 'track')} {
|
||||
background: v('color-primary', #18181b);
|
||||
box-shadow: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.#{b('switch')}.#{is('disabled')} .#{e('switch', 'thumb')} {
|
||||
box-shadow: inset 0 0 0 1px v('border-color', #d9d9d9);
|
||||
}
|
||||
|
||||
.#{b('switch')}.#{is('disabled')}.#{is('checked')} .#{e('switch', 'thumb')} {
|
||||
box-shadow: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -98,6 +98,17 @@ const props = withDefaults(
|
||||
virtualX?: boolean
|
||||
rowHeight?: number
|
||||
headerRowHeight?: number
|
||||
/**
|
||||
* 边框:`true`(默认)= 圆角外框 + 行分隔线;`false` = 无外框、只留行分隔线
|
||||
* (嵌进卡片 / 面板时用)。
|
||||
*
|
||||
* 不直接透传给底座(设计见 `docs/table-design.md` §3.2):底座的外框由
|
||||
* `border-left` + 三段背景渐变拼成,**做不出圆角**,且其右侧 / 底部边框会被
|
||||
* 滚动条占位吃掉。所以 `true` 会翻成底座的 `'v'` 档(只借它的竖线),**外框**
|
||||
* 改由组件用真 `border` + 圆角令牌画;**横线**则把底座清零的那条渐变配方拿回来
|
||||
* —— 格线为什么不一起用真 `border`,见 `style/_overrides.scss` 的「边框」一节
|
||||
* (真边线会把行撑高 1px,而行高是虚拟滚动的计算基准)。
|
||||
*/
|
||||
bordered?: boolean | string
|
||||
/**
|
||||
* 斑马纹(隔行底色),默认 `false`;等价于给底座的 `stripe` 传 true。
|
||||
@@ -1569,7 +1580,7 @@ const fixedPatchOff = (() => {
|
||||
:show-overflow="showOverflow"
|
||||
:empty-cell-text="''"
|
||||
:area-selection="editable"
|
||||
:bordered="bordered ? true : false"
|
||||
:bordered="bordered ? 'v' : false"
|
||||
@update:columns="onColumnsUpdate"
|
||||
@area-selection-change="handleAreaSelectionChange"
|
||||
@row-order-change="handleRowOrderChange"
|
||||
@@ -1599,10 +1610,10 @@ const fixedPatchOff = (() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 边框完全交给库:传 `bordered: true`,外框 / 圆角 / 横竖线都由库自己画。
|
||||
库文档(table/basic/bordered)说明:出于滚动条影响,表格**右侧和底部的边框
|
||||
由单元格的 border-right / border-bottom 实现,因此会消失**,需要时自行补 CSS。
|
||||
我们按库的原样使用,不再自绘外框与行分隔线。 */
|
||||
/* 边框:`bordered` 只翻译成底座的档位(true → 'v',false → false),真正的
|
||||
圆角外框与「横线怎么补回来」都在 `style/_overrides.scss` 的「边框」一节 ——
|
||||
为什么外框不再交给底座(做不出圆角、右侧底部边框被滚动条吃掉)、为什么格线
|
||||
不能用真 border(会把行撑高 1px),都写在那一节,别在这里重复。 */
|
||||
|
||||
.#{e('table', 'inner')} {
|
||||
width: 100%;
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
//
|
||||
// 为什么改变量而不是改选择器:
|
||||
// 库把 41 个主题值全部做成了 `.stk-table` 作用域内的 CSS 变量(实测见 style.css)。
|
||||
// 覆盖变量既能一次改全套(边框、hover、斑马纹、排序箭头、滚动条…),
|
||||
// 又不会因为库内部 DOM 结构调整而失效。fms 的做法是逐个选择器覆写,
|
||||
// 结果同一个「边框颜色」散落在 3 处(自己画外框 + 清库的渐变 + 补行分隔线),
|
||||
// 库一升级就要重新验证每一条。这里改成一个入口。
|
||||
// 覆盖变量既能一次改全套(hover、斑马纹、排序箭头、滚动条…),
|
||||
// 又不会因为库内部 DOM 结构调整而失效。
|
||||
//
|
||||
// ⚠ 唯一例外是**边框**(外框 + 横线):那部分由组件自绘,不走变量路线,
|
||||
// 见下方「边框」一节。底座的边框画法做不出圆角,也不是「改一个变量」能解决的
|
||||
// ——它更像结构问题,硬套变量映射只会把改动散到四处。
|
||||
//
|
||||
// 所有令牌都带 fallback:未加载 @g3soft/tokens 时组件仍可正常渲染。
|
||||
@use '@g3soft/tokens/scss' as *;
|
||||
@@ -134,6 +136,97 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 边框:外框圆角描边 + 行分隔线由组件自绘,纵向列线仍借底座的渐变
|
||||
// --------------------------------------------------------------------------
|
||||
// 底座不用 `border` 画线,而是把四条边做成 `background-image` 渐变
|
||||
// (--bg-border-*,由 --border-color 派生),并给 `bordered` 提供三档:
|
||||
// true=外框+横竖线 / 'h'=只要横线 / 'v'=只要竖线 / false=全不要。
|
||||
// 这套画法有两处绕不过去:
|
||||
// · 外框是「`border-left` + 三段背景渐变」拼出来的,**做不出圆角**;且底座文档
|
||||
// (table/basic/bordered)写明:受滚动条占位影响,表格**右侧与底部的边框会
|
||||
// 消失**,需要时自行补 CSS;
|
||||
// · 越往后越多边线要单独摘掉(最右列外侧线该归外框管,右固定列的左侧线又必须
|
||||
// 留着),而它们共用 `--border-color` 一个派生入口 —— 只能去覆盖渐变本身。
|
||||
//
|
||||
// 于是这里定成:**外框改用真 border 自绘,所有格线(横线 + 竖线)都保持底座的
|
||||
// 背景渐变画法**。
|
||||
// 外框 → `.g3-table.is-bordered` 上的真 `border` + 圆角令牌(外层容器已有
|
||||
// `overflow: hidden`,圆角能把内部内容裁掉);
|
||||
// 横线 → 把 `bordered-v` 清零的 --bg-border-bottom **恢复成底座那份配方**;
|
||||
// 竖线 → 保留底座的 --bg-border-right 渐变,只抹掉最右列那一条。
|
||||
//
|
||||
// 组件侧只负责把 `bordered` 翻成 `true` / `'v'` / `false`(见 G3Table.vue 的
|
||||
// `:bordered` 绑定)——`'v'` 必然同时带上 `.bordered` 与 `.bordered-v` 两个类
|
||||
// (底座 `{[`bordered-${bordered}`]: typeof bordered === 'string'}`),
|
||||
// 所以下面既要清掉 `.bordered` 画的外框,也要接受 `.bordered-v` 关掉的上下渐变
|
||||
// (只把横线那条拿回来,`--bg-border-top` 继续透明,免得与自绘外框叠成双线)。
|
||||
//
|
||||
// ⚠⚠ 格线**绝对不能**改成真 `border-bottom` / `border-top`(这是第一版踩的坑,
|
||||
// e2e `行内编辑 › 进入编辑:行高不变` 当场变红):行内编辑器被钉死在
|
||||
// `var(--row-height)`(30px,见 G3Table.vue 的 editorSeamless 区块),而 td 的
|
||||
// 高度是「按内容撑开」的 —— 一旦 td 多出 1px 边线,内容盒只剩 29px 放不下 30px
|
||||
// 的编辑器,整行被顶到 31px。行高是虚拟滚动 `translateY` 的计算基准,涨 1px
|
||||
// 会一路累积成行错位。**背景渐变不参与盒模型**,所以格线只能走 background-image。
|
||||
//
|
||||
// ⚠ 颜色统一走 `--g3-border-color` 令牌,亮 / 暗主题自动跟随,别再写死色值。
|
||||
// ==========================================================================
|
||||
|
||||
// 外框:只有 `bordered` 为真才有(false = 嵌进卡片,只留行分隔线)。
|
||||
// `box-sizing: border-box` 必须有:根节点默认是 content-box,加 1px 描边会让它
|
||||
// 比父容器高 2px(`:not(.is-bordered)` 时不描边,故不设)。
|
||||
.#{b('table')}.#{is('bordered')} {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #{v('border-color', #d9d9d9)};
|
||||
border-radius: v('radius-lg', 8px);
|
||||
}
|
||||
|
||||
.#{b('table')} {
|
||||
.#{e('table', 'inner')}.stk-table {
|
||||
// 横线:拿回 `bordered-v` 清零的那条。配方照抄底座(style.css 的
|
||||
// `--bg-border-bottom`),因为这是它的变量、也只能按它的语法写。
|
||||
//
|
||||
// 两条规则都挂在 `.bordered-v` 下:它只在真开了边框时才存在(见 G3Table.vue
|
||||
// 的 `:bordered`),所以 `bordered: false` 的无框模式不会被这里添线。
|
||||
&.bordered-v {
|
||||
--bg-border-bottom: linear-gradient(
|
||||
0deg,
|
||||
var(--border-color) var(--border-width),
|
||||
transparent var(--border-width)
|
||||
);
|
||||
|
||||
// 合计行:分隔线画在 footer 单元格的**上**边(数据区最后一行被 sticky 的
|
||||
// footer 压住,它那条横线指望不上)。同样用渐变,理由见上。
|
||||
.stk-footer td {
|
||||
background-image:
|
||||
var(--bg-border-right),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
var(--border-color) var(--border-width),
|
||||
transparent var(--border-width)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 去掉底座的外框:根节点上那条 `border-left` 与三段背景渐变(外框已由上面画)。
|
||||
.#{e('table', 'inner')}.stk-table.bordered-v {
|
||||
border-left: none;
|
||||
background-image: none;
|
||||
|
||||
// 最右列的外侧竖线同样归外框管,抹掉单元格自己那条(两条紧挨着会被看成 2px)。
|
||||
//
|
||||
// ⚠️ 只能改 --bg-border-right 这一个变量,**不能整体清空 background-image**:
|
||||
// 底座会给「被前面内容穿透遮挡」的右固定列补 `.fixed-cell--border-left`,
|
||||
// 用 --bg-border-left 画它左边的分隔线;整份清空会把这根线一起干掉
|
||||
// (症状:操作列压住内容却没有分割线)。
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
--bg-border-right: linear-gradient(transparent, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 表头 / 表尾:不透明底色(防 sticky 滚动时透出下方内容)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -225,7 +225,11 @@ export interface TableProps<T extends TableRow = TableRow> {
|
||||
rowHeight?: number
|
||||
/** 表头行高 */
|
||||
headerRowHeight?: number
|
||||
/** 边框:true=外框圆角描边+行分隔线;false=无外框仅行分隔线 */
|
||||
/**
|
||||
* 边框:`true`(默认)= 圆角外框 + 行分隔线;`false` = 无外框仅行分隔线。
|
||||
* 外框由本组件用真 `border` 自绘(底座的外框做不出圆角),格线仍走底座的背景
|
||||
* 渐变,见 `style/_overrides.scss` 的「边框」一节。
|
||||
*/
|
||||
bordered?: boolean | string
|
||||
/**
|
||||
* 斑马纹(隔行底色),默认 `false`。透传给底座的 `stripe`。
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('resolveSeqConfig', () => {
|
||||
|
||||
it('seq 为 true 时用默认标题与宽度', () => {
|
||||
const cfg = resolveSeqConfig(true)
|
||||
expect(cfg).toEqual({ enabled: true, title: '序号', width: 50 })
|
||||
expect(cfg).toEqual({ enabled: true, title: '序号', width: 60 })
|
||||
})
|
||||
|
||||
it('传对象时接受自定义标题与宽度', () => {
|
||||
@@ -40,7 +40,7 @@ describe('resolveSeqConfig', () => {
|
||||
})
|
||||
|
||||
it('只给 title 时宽度回落默认值', () => {
|
||||
expect(resolveSeqConfig({ title: '序号列' }).width).toBe(50)
|
||||
expect(resolveSeqConfig({ title: '序号列' }).width).toBe(60)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -36,12 +36,12 @@ export interface SeqConfig {
|
||||
export function resolveSeqConfig(
|
||||
seq: boolean | { title?: string; width?: number } | undefined,
|
||||
): SeqConfig {
|
||||
if (!seq) return { enabled: false, title: '序号', width: 50 }
|
||||
if (!seq) return { enabled: false, title: '序号', width: 60 }
|
||||
const cfg: { title?: string; width?: number } = seq === true ? { } : seq
|
||||
return {
|
||||
enabled: true,
|
||||
title: cfg.title ?? '序号',
|
||||
width: Number(cfg.width ?? 50),
|
||||
width: Number(cfg.width ?? 60),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -390,7 +390,10 @@ const VNodeRenderer = (renderProps: {
|
||||
gap: v('space-1', 4px);
|
||||
box-sizing: border-box;
|
||||
min-height: v('tabs-tab-height', 38px);
|
||||
padding: 0 v('space-3', 12px);
|
||||
/* 左右只留 4px:选中线是 border-bottom,宽度跟着盒子走。
|
||||
padding 一大,线就比文字宽出一圈(原来 12px → 线比文字宽 24px)。
|
||||
页签之间的间隔改由 margin-right 承担(见下),不再靠这里的 padding。 */
|
||||
padding: 0 v('space-1', 4px);
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
@@ -406,6 +409,21 @@ const VNodeRenderer = (renderProps: {
|
||||
border-color v('duration', 0.2s) v('ease', ease);
|
||||
}
|
||||
|
||||
/* 页签间距走 margin,把「间隔」与「选中线宽度」解耦:
|
||||
选中线依赖 padding,间隔依赖 margin,两者互不干扰。
|
||||
首个页签贴齐左边缘(Semi 的 nth-of-type(1) 处理),否则整排会右移 16px。
|
||||
横向用 margin-right(末尾多出的一截由 nav-list 的负 margin 抵消);
|
||||
垂直模式沿用 column 的 gap,不吃这套。 */
|
||||
.#{b('tabs')}:not(.#{m('tabs', 'left')}):not(.#{m('tabs', 'right')})
|
||||
.#{e('tabs', 'tab')}:not(:last-child) {
|
||||
margin-right: v('space-4', 16px);
|
||||
}
|
||||
|
||||
.#{b('tabs')}:not(.#{m('tabs', 'left')}):not(.#{m('tabs', 'right')}) .#{e('tabs', 'nav-list')} {
|
||||
/* 抵消最后一个页签多出的 margin-right,避免栏内右侧凭空多出 16px 空隙 */
|
||||
margin-right: calc(-1 * #{v('space-4', 16px)});
|
||||
}
|
||||
|
||||
.#{e('tabs', 'tab')}:hover:not(:disabled) {
|
||||
color: v('color-primary', #18181b);
|
||||
}
|
||||
@@ -419,6 +437,9 @@ const VNodeRenderer = (renderProps: {
|
||||
.#{b('tabs')}.#{m('tabs', 'right')} .#{e('tabs', 'tab')} {
|
||||
justify-content: flex-start;
|
||||
border-bottom: 0;
|
||||
/* 垂直模式的指示线在左/右侧(border-left/right),不吃底部的 padding,
|
||||
所以这里可以放心用常规内边距,让文字与竖线之间有呼吸感 */
|
||||
padding: 0 v('space-3', 12px);
|
||||
}
|
||||
|
||||
.#{b('tabs')}.#{m('tabs', 'left')} .#{e('tabs', 'tab')}.#{is('tab-active')} {
|
||||
|
||||
Reference in new issue
Block a user