1
0
mirror of synced 2026-10-07 23:28:17 +08:00

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

@@ -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)',
});
});
});