feat(form): group-list 标题吸顶改为按 header.sticky 显式开启

默认关闭避免长列表误吸顶,嵌套层按外层真实标题/吸底按钮高度让位;属性面板列表通过 stickyAddButton 保持原表现。
This commit is contained in:
roymondchen 2026-09-09 11:14:52 +08:00
parent 7e4546899b
commit 2a4a54a495
11 changed files with 379 additions and 35 deletions

View File

@ -2,7 +2,7 @@
## 配置类型
::: details 查看 ContainerCommonConfig / RowConfig / TabConfig / TabPaneConfig / FieldsetConfig / PanelConfig / StepConfig / FlexLayoutConfig / GroupListConfig / TableConfig / TableColumnConfig / TableGroupListCommonConfig 配置类型定义
::: details 查看 ContainerCommonConfig / RowConfig / TabConfig / TabPaneConfig / FieldsetConfig / PanelConfig / StepConfig / FlexLayoutConfig / GroupListConfig / TableConfig / TableColumnConfig / TableGroupListCommonConfig / GroupListHeaderConfig 配置类型定义
<<< @/../packages/form-schema/src/base.ts#ContainerCommonConfig{ts}
<<< @/../packages/form-schema/src/base.ts#RowConfig{ts}
@ -27,6 +27,8 @@
<<< @/../packages/form-schema/src/base.ts#TableGroupListCommonConfig{ts}
<<< @/../packages/form-schema/src/base.ts#GroupListHeaderConfig{ts}
<<< @/../packages/form-schema/src/base.ts#FormItem{ts}
:::
@ -211,6 +213,27 @@
}]
}]"></demo-block>
#### 标题吸顶
列表项较多、需要边滚动边看清当前编辑的是哪一项时,可以用 `header.sticky` 让卡片标题吸顶:
```ts
{
type: 'groupList',
name: 'group',
header: { sticky: true },
items: [/* ... */],
}
```
吸顶默认关闭,需要逐层显式开启:外层开了不会带动内层,嵌套列表想一起吸顶就各自配一次,内层标题会自动下移让开外层标题。
标题让位的高度默认按 65px 计算。如果自定义了卡片标题的样式导致实际高度不同、内层标题出现重叠或空隙,用 `header.height` 告知真实高度即可(它只影响内层的让位距离,不会改变标题本身的高度)。
::: warning 行为变更
1.8.0-beta.28 之前group-list 的卡片标题在任何情况下都会吸顶。现在改为默认关闭、由 `header.sticky` 显式开启。升级后如需保持原有表现,请在对应配置上补上 `header: { sticky: true }`
:::
### table
<demo-block type="form" :config="[{

View File

@ -17,12 +17,13 @@
*/
/**
* group-list
* group-list
*
*
*
*/
export const stickyAddButton = (text: string) => ({
scrollLastItemIntoView: true as const,
header: { sticky: true as const },
addButtonConfig: {
sticky: true as const,
text,

View File

@ -122,6 +122,7 @@ describe('CodeSelect', () => {
const container = wrapper.findComponent({ name: 'MContainer' });
const config = container.props('config') as any;
expect(config.scrollLastItemIntoView).toBe(true);
expect(config.header.sticky).toBe(true);
expect(config.addButtonConfig.sticky).toBe(true);
expect(config.addButtonConfig.text).toBe('添加');
const codeTypeSelect = config.items[0];

View File

@ -84,6 +84,7 @@ describe('DisplayConds', () => {
expect(capturedConfig.type).toBe('groupList');
expect(capturedConfig.defaultAdd).toEqual({ cond: [] });
expect(capturedConfig.scrollLastItemIntoView).toBe(true);
expect(capturedConfig.header.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.text).toBe('新增条件组');
expect(capturedConfig.items[0].type).toBe('groupList');
@ -93,6 +94,7 @@ describe('DisplayConds', () => {
expect(capturedConfig.items[0].movable).toBe(false);
expect(capturedConfig.items[0].flat).toBe(true);
expect(capturedConfig.items[0].scrollLastItemIntoView).toBe(true);
expect(capturedConfig.items[0].header.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增条件');
expect(capturedConfig.items[0].items.every((item: any) => item.span === undefined)).toBe(true);

View File

@ -133,11 +133,13 @@ describe('EventSelect', () => {
expect(wrapper.find('.fake-table').exists()).toBe(false);
expect(capturedConfig.type).toBe('group-list');
expect(capturedConfig.scrollLastItemIntoView).toBe(true);
expect(capturedConfig.header.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.text).toBe('添加事件');
expect(capturedConfig.defaultAdd).toEqual({ name: '', actions: [] });
expect(capturedConfig.movable).toBe(false);
expect(capturedConfig.items[0].scrollLastItemIntoView).toBe(true);
expect(capturedConfig.items[0].header.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增动作');
});

View File

@ -830,6 +830,20 @@ export interface AddButtonConfig {
}
// #endregion AddButtonConfig
// #region GroupListHeaderConfig
/** group-list 卡片标题;仅 sticky 开启时吸顶 */
export interface GroupListHeaderConfig {
/** 标题吸顶 */
sticky?: boolean;
/**
*
* **** 65px
* px CSS 使
*/
height?: number | string;
}
// #endregion GroupListHeaderConfig
// #region TableGroupListCommonConfig
export interface TableGroupListCommonConfig extends FormItem {
type: 'table' | 'groupList' | 'group-list';
@ -846,6 +860,8 @@ export interface TableGroupListCommonConfig extends FormItem {
/** 新增后滚动到最后一项group-list 形态,默认关闭) */
scrollLastItemIntoView?: boolean;
addButtonConfig?: AddButtonConfig;
/** group-list 形态的标题吸顶默认关闭table 形态忽略 */
header?: GroupListHeaderConfig;
}
// #endregion TableGroupListCommonConfig

View File

@ -1,5 +1,12 @@
<template>
<div class="m-fields-group-list">
<div
class="m-fields-group-list"
:class="{
'is-header-sticky': Boolean(config.header?.sticky),
'is-footer-sticky': isFooterSticky($slots),
}"
:style="headerVars"
>
<div v-if="config.extra" v-html="config.extra" style="color: rgba(0, 0, 0, 0.45)"></div>
<div v-if="!displayItems.length" class="el-table__empty-block">
<span class="el-table__empty-text t-table__empty">暂无{{ config.titlePrefix || '' }}数据</span>
@ -34,7 +41,7 @@
<div
class="m-fields-group-list-footer"
:class="{ 'is-sticky-full': Boolean(config.addButtonConfig?.sticky) }"
v-if="!isCompare && ($slots['toggle-button'] || $slots['add-button'])"
v-if="hasFooter($slots)"
>
<slot name="toggle-button"></slot>
<div style="display: flex; justify-content: flex-end; flex: 1">
@ -49,6 +56,7 @@ import { computed } from 'vue';
import { cloneDeep } from 'lodash-es';
import type { ContainerChangeEventData, GroupListConfig } from '../schema';
import { getGroupListHeaderVars } from '../utils/tableGroupList';
import MFieldsGroupListItem from './GroupListItem.vue';
@ -104,6 +112,19 @@ const onAddDiffCount = () => emit('addDiffCount');
const asList = (value: unknown): any[] => (Array.isArray(value) ? value : []);
const headerVars = computed(() => getGroupListHeaderVars(props.config.header));
/**
* `$slots` 而不是 `useSlots()`宿主切换 `addable` 时插槽会增删
* computed 缓存不随插槽变化失效只有在渲染期读才拿得到最新的
*/
const hasFooter = (slots: Record<string, unknown>) =>
!props.isCompare && Boolean(slots['toggle-button'] || slots['add-button']);
/** 只有真的渲染出吸底 footer内层列表才需要为它让位 */
const isFooterSticky = (slots: Record<string, unknown>) =>
hasFooter(slots) && Boolean(props.config.addButtonConfig?.sticky);
const currentList = computed(() => asList(props.model[props.name]));
/** 对比时按当前/历史较长一侧对齐,已删除的项也能渲染出来 */

View File

@ -70,27 +70,60 @@
flex-shrink: 0;
gap: 8px;
}
// 卡片标题吸顶补不透明背景避免滚动时正文透出
// z-index 必须低于吸底 footer否则标题会盖住新增按钮
> .el-card__header,
> .t-card__header {
position: sticky;
top: var(--m-group-list-header-sticky-top, 0px);
z-index: 7;
background-color: var(--m-group-list-header-bg, #fff);
}
}
// 嵌套标题叠在外层标题之下层级更低以免盖住外层底部分隔线
.m-fields-group-list-item .m-fields-group-list-item {
> .el-card__header,
> .t-card__header {
top: calc(
var(--m-group-list-header-sticky-top, 0px) +
var(--m-group-list-header-height)
// 嵌套吸底按钮叠在外层 footer 之上新增条件叠在新增条件组上面
// 每穿过一层自己渲染了吸底 footer的列表才多让出一个 footer 高度外层没有吸底按钮时
// 内层不能凭空抬高自定义属性不能自增所以用 item / list 两个变量交替接力
> .m-fields-group-list-item {
--m-group-list-item-footer-bottom: var(
--m-group-list-nested-footer-bottom,
0px
);
}
&.is-footer-sticky > .m-fields-group-list-item {
--m-group-list-item-footer-bottom: calc(
var(--m-group-list-nested-footer-bottom, 0px) +
var(--m-group-list-footer-height)
);
}
.m-fields-group-list-item .m-fields-group-list {
--m-group-list-nested-footer-bottom: var(
--m-group-list-item-footer-bottom,
0px
);
}
// header.sticky 时标题吸顶直接子 item避免外层吸顶带动未开启的内层
&.is-header-sticky {
// 从外层 item 接过已累加的偏移最外层没有该变量时走 fallback
--m-group-list-nested-sticky-top: var(--m-group-list-child-list-sticky-top);
--m-group-list-nested-header-z: var(--m-group-list-child-list-header-z);
> .m-fields-group-list-item {
--m-group-list-item-sticky-top: var(
--m-group-list-nested-sticky-top,
var(--m-group-list-header-sticky-top, 0px)
);
z-index: 6;
--m-group-list-item-header-z: var(--m-group-list-nested-header-z, 7);
--m-group-list-child-list-sticky-top: calc(
var(--m-group-list-item-sticky-top) + var(--m-group-list-header-height)
);
--m-group-list-child-list-header-z: calc(
var(--m-group-list-item-header-z) - 1
);
// 卡片标题吸顶补不透明背景避免滚动时正文透出
// z-index 必须低于吸底 footer否则标题会盖住新增按钮
> .el-card__header,
> .t-card__header {
position: sticky;
top: var(--m-group-list-item-sticky-top);
z-index: var(--m-group-list-item-header-z);
background-color: var(--m-group-list-header-bg, #fff);
}
}
}
@ -113,10 +146,10 @@
}
// 吸底全宽主按钮event-select / code-select / display-conds 外层列表
// z-index 高于 item header避免吸顶标题挡住新增按钮
// z-index 高于 item header避免吸顶标题挡住新增按钮多层时 bottom 由内层 list 累加
> .m-fields-group-list-footer.is-sticky-full {
position: sticky;
bottom: 0;
bottom: var(--m-group-list-nested-footer-bottom, 0px);
z-index: 7;
margin-bottom: 0;
padding: var(--m-group-list-footer-padding-top) 0
@ -133,14 +166,6 @@
height: var(--m-group-list-footer-button-height);
}
}
// 嵌套吸底按钮叠在外层 footer 之上新增条件叠在新增条件组上面
.m-fields-group-list-item
.m-fields-group-list
> .m-fields-group-list-footer.is-sticky-full {
bottom: var(--m-group-list-footer-height);
z-index: 7;
}
}
/** 最外层的groupList需要每个item需要增加空白区域界限时增加outer-gorup_list可以实现 */

View File

@ -18,7 +18,9 @@
import { cloneDeep } from 'lodash-es';
import type { GroupListConfig, TableColumnConfig, TableConfig } from '../schema';
import { isNumber } from '@tmagic/utils';
import type { GroupListConfig, GroupListHeaderConfig, TableColumnConfig, TableConfig } from '../schema';
/**
* table / group-list `TableGroupList.vue`
@ -31,6 +33,31 @@ import type { GroupListConfig, TableColumnConfig, TableConfig } from '../schema'
/** group-list 形态的 type兼容驼峰与中划线两种写法 */
export const isGroupListType = (type: unknown): boolean => type === 'groupList' || type === 'group-list';
/**
* CSS
*
* JSON `'48'` CSS
* `calc()` `px`
*/
const toCssLength = (height: number | string | undefined): string | undefined => {
const value = `${height ?? ''}`.trim();
if (!value) return undefined;
if (isNumber(value)) return `${value}px`;
// 走到这里还是 number 的只剩 NaN / Infinity不写变量交给样式默认值兜底
return typeof height === 'number' ? undefined : value;
};
/** header.sticky 开启时把 height 写成 CSS 变量,给嵌套吸顶偏移用 */
export const getGroupListHeaderVars = (
header: GroupListHeaderConfig | undefined,
): Record<string, string> | undefined => {
if (!header?.sticky) return undefined;
const height = toCssLength(header.height);
return height ? { '--m-group-list-header-height': height } : undefined;
};
/** 按 label 文案长度估算 label 宽度(中文按 20px、其他按 8px最小 80px */
export const calcLabelWidth = (label: string): string => {
if (!label) return '0px';

View File

@ -202,6 +202,63 @@ describe('GroupList container', () => {
).toBe(false);
warn.mockRestore();
});
test('点击删除按钮移除对应项', async () => {
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
await nextTick();
await wrapper.findAll('.delete-button')[0].trigger('click');
await nextTick();
expect((wrapper.vm as any).values.list).toEqual([{ text: 'b' }]);
expect(wrapper.findAll('.m-fields-group-list-item')).toHaveLength(1);
});
test('点击复制按钮在末尾追加一份副本', async () => {
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
await nextTick();
const copyButton = wrapper.findAll('button').find((btn) => btn.text().includes('复制'));
await copyButton?.trigger('click');
await nextTick();
const { list } = (wrapper.vm as any).values;
expect(list).toHaveLength(3);
expect(list[2]).toEqual({ text: 'a' });
// 深拷贝,改副本不应牵动原项
expect(list[2]).not.toBe(list[0]);
});
test('点击下移 / 上移交换相邻两项', async () => {
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
await nextTick();
// 首项的「上移」与末项的「下移」只是 v-show 隐藏,仍在 DOM 里,按 item 作用域取才不会点错
const buttonIn = (index: number, text: string) =>
wrapper
.findAll('.m-fields-group-list-item')
[index].findAll('button')
.find((btn) => btn.text().includes(text));
await buttonIn(0, '下移')?.trigger('click');
await nextTick();
expect((wrapper.vm as any).values.list).toEqual([{ text: 'b' }, { text: 'a' }]);
await buttonIn(1, '上移')?.trigger('click');
await nextTick();
expect((wrapper.vm as any).values.list).toEqual([{ text: 'a' }, { text: 'b' }]);
});
test('移动越界时夹到列表两端,不丢项', async () => {
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
await nextTick();
const item = wrapper.findAllComponents({ name: 'MFormGroupListItem' })[1];
item.vm.$emit('swap-item', 1, 5);
await nextTick();
expect((wrapper.vm as any).values.list).toEqual([{ text: 'a' }, { text: 'b' }]);
});
});
describe('labelPosition 透传', () => {
@ -501,6 +558,131 @@ describe('GroupList container', () => {
await nextTick();
expect(wrapper.find('.m-fields-group-list-footer.is-sticky-full').exists()).toBe(true);
expect(wrapper.text()).toContain('添加');
expect(wrapper.find('.m-fields-group-list').classes()).toContain('is-footer-sticky');
});
// 没有吸底 footer 的层不能让内层凭空抬高
test('未开 addButtonConfig.sticky 时根节点不带 is-footer-sticky', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
expect(wrapper.find('.m-fields-group-list').classes()).not.toContain('is-footer-sticky');
});
test('对比模式下不渲染 footer也不带 is-footer-sticky', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
addButtonConfig: { sticky: true, text: '添加', props: { type: 'primary', plain: true, text: false } },
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
{ isCompare: true, lastValues: { list: [{ text: 'a' }] } },
);
await nextTick();
expect(wrapper.find('.m-fields-group-list-footer').exists()).toBe(false);
expect(wrapper.find('.m-fields-group-list').classes()).not.toContain('is-footer-sticky');
});
test('header.sticky 时根节点带 is-header-sticky', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
header: { sticky: true },
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
expect(wrapper.find('.m-fields-group-list.is-header-sticky').exists()).toBe(true);
});
test('未开 header.sticky 时不加吸顶 class', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
expect(wrapper.find('.m-fields-group-list.is-header-sticky').exists()).toBe(false);
});
test('header.height 在 sticky 开启时写入 CSS 变量', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
header: { sticky: true, height: 48 },
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
const el = wrapper.find('.m-fields-group-list').element as HTMLElement;
expect(el.style.getPropertyValue('--m-group-list-header-height')).toBe('48px');
});
test('仅配置 header.height 未开 sticky 时不写 CSS 变量', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
header: { height: 48 },
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }] },
);
await nextTick();
const el = wrapper.find('.m-fields-group-list').element as HTMLElement;
expect(el.style.getPropertyValue('--m-group-list-header-height')).toBe('');
expect(wrapper.find('.m-fields-group-list.is-header-sticky').exists()).toBe(false);
});
test('嵌套列表各自按 header.sticky 决定是否吸顶', async () => {
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'groups',
header: { sticky: true },
items: [
{
type: 'group-list',
name: 'cond',
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
},
],
{ groups: [{ cond: [{ text: 'a' }] }] },
);
await nextTick();
const lists = wrapper.findAll('.m-fields-group-list');
expect(lists[0].classes()).toContain('is-header-sticky');
expect(lists[1].classes()).not.toContain('is-header-sticky');
});
});
});

View File

@ -5,7 +5,7 @@
*/
import { describe, expect, test } from 'vitest';
import { getGroupListRowConfig } from '@form/utils/tableGroupList';
import { getGroupListHeaderVars, getGroupListRowConfig } from '@form/utils/tableGroupList';
describe('getGroupListRowConfig', () => {
test('把 group-list 的 labelWidth / labelPosition 复制到 row 配置', () => {
@ -26,3 +26,47 @@ describe('getGroupListRowConfig', () => {
expect(row.items).toHaveLength(1);
});
});
describe('getGroupListHeaderVars', () => {
test('未开 sticky 时不写样式', () => {
expect(getGroupListHeaderVars(undefined)).toBeUndefined();
expect(getGroupListHeaderVars({})).toBeUndefined();
expect(getGroupListHeaderVars({ height: 48 })).toBeUndefined();
});
test('sticky 但 height 为空时不写变量,走样式默认值', () => {
expect(getGroupListHeaderVars({ sticky: true })).toBeUndefined();
expect(getGroupListHeaderVars({ sticky: true, height: '' })).toBeUndefined();
expect(getGroupListHeaderVars({ sticky: true, height: ' ' })).toBeUndefined();
expect(getGroupListHeaderVars({ sticky: true, height: Number.NaN })).toBeUndefined();
expect(getGroupListHeaderVars({ sticky: true, height: Number.POSITIVE_INFINITY })).toBeUndefined();
});
test('数字 height 转成 px', () => {
expect(getGroupListHeaderVars({ sticky: true, height: 48 })).toEqual({
'--m-group-list-header-height': '48px',
});
expect(getGroupListHeaderVars({ sticky: true, height: 0 })).toEqual({
'--m-group-list-header-height': '0px',
});
});
// 无单位的值会让嵌套层的 calc() 整体失效、吸顶静默失灵
test('无单位数字字符串补 px', () => {
expect(getGroupListHeaderVars({ sticky: true, height: '48' })).toEqual({
'--m-group-list-header-height': '48px',
});
expect(getGroupListHeaderVars({ sticky: true, height: ' 47.5 ' })).toEqual({
'--m-group-list-header-height': '47.5px',
});
});
test('带单位的字符串 height 原样写入', () => {
expect(getGroupListHeaderVars({ sticky: true, height: '4em' })).toEqual({
'--m-group-list-header-height': '4em',
});
expect(getGroupListHeaderVars({ sticky: true, height: 'var(--x)' })).toEqual({
'--m-group-list-header-height': 'var(--x)',
});
});
});