Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@visactor/vtable",
"comment": "feat: support overflow-based frozen column shadow visibility",
"type": "minor"
}
],
"packageName": "@visactor/vtable"
}
12 changes: 11 additions & 1 deletion docs/assets/demo/en/basic-functionality/frozen-col.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,17 @@ fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/North_American
widthMode: 'standard',
allowFrozenColCount: 3,
frozenColCount: 1,
showFrozenIcon: true
showFrozenIcon: true,
theme: VTable.themes.DEFAULT.extends({
frozenColumnLine: {
shadow: {
width: 8,
startColor: 'rgba(0, 0, 0, 0.12)',
endColor: 'rgba(0, 0, 0, 0)',
visible: 'overflow'
}
}
})
};
tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID), option);
window['tableInstance'] = tableInstance;
Expand Down
12 changes: 11 additions & 1 deletion docs/assets/demo/zh/basic-functionality/frozen-col.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,17 @@ fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/North_American
widthMode: 'standard',
allowFrozenColCount: 3,
frozenColCount: 1,
showFrozenIcon: true
showFrozenIcon: true,
theme: VTable.themes.DEFAULT.extends({
frozenColumnLine: {
shadow: {
width: 8,
startColor: 'rgba(0, 0, 0, 0.12)',
endColor: 'rgba(0, 0, 0, 0)',
visible: 'overflow'
}
}
})
};
tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID), option);
window['tableInstance'] = tableInstance;
Expand Down
4 changes: 2 additions & 2 deletions docs/assets/option/en/common/frozen-column-line-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ End Color

###${prefix} visible(string)

Shadow Visible Time, default is `scrolling`.
Shadow Visible Time, default is `always`.

- always: always show
- scrolling: show when scrolling

- overflow: show only at frozen boundaries with body content hidden beyond that edge. The left shadow appears after scrolling away from the start; the right shadow appears while content remains to the right. Shadows persist after scrolling stops and disappear at the corresponding edge. No shadow is shown without frozen columns or horizontal body overflow.
8 changes: 2 additions & 6 deletions docs/assets/option/zh/common/frozen-column-line-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,8 @@
结束颜色

###${prefix} visible(string)
阴影显示时机,默认为 `scrolling`。
阴影显示时机,默认为 `always`。

- always: 总是显示
- scrolling: 滚动时显示





- overflow: 仅在冻结边界外有被遮挡的表格主体内容时显示。左侧阴影在滚离起点后显示,右侧阴影在右边仍有内容时显示;停止滚动后保持,滚到对应边缘立即隐藏。没有冻结列或主体没有水平溢出时不显示。
188 changes: 188 additions & 0 deletions packages/vtable/__tests__/frozen-column-shadow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { ListTable, PivotTable, themes } from '../src';
import type { ListTableConstructorOptions } from '../src';
import { createDiv, removeDom } from './dom';

describe('frozen column shadow visibility', () => {
const tables: (ListTable | PivotTable)[] = [];
const containers: HTMLElement[] = [];
const columns = Array.from({ length: 8 }, (_, i) => ({ field: `c${i}`, title: `Column ${i}`, width: 100 }));
const records = Array.from({ length: 6 }, (_, i) => Object.fromEntries(columns.map(col => [col.field, i])));
const overflow = themes.DEFAULT.extends({
frozenColumnLine: {
shadow: { width: 8, startColor: 'rgba(0,0,0,0.12)', endColor: 'transparent', visible: 'overflow' }
}
});

function container() {
const div = createDiv();
div.style.width = '500px';
div.style.height = '300px';
containers.push(div);
return div;
}

function createTable(options: Partial<ListTableConstructorOptions> = {}) {
const table = new ListTable(container(), { columns, records, theme: overflow, ...options });
tables.push(table);
return table;
}

function expectShadows(table: ListTable | PivotTable, left: boolean, right: boolean) {
const component = table.scenegraph.component;
expect(component.frozenShadowLine.attribute.visible).toBe(left);
expect(component.rightFrozenShadowLine.attribute.visible).toBe(right);
}

afterEach(() => {
tables.forEach(table => table.release());
tables.length = 0;
containers.forEach(removeDom);
containers.length = 0;
});

test('never shows shadows without frozen columns, including scrollbar show and hide', () => {
const table = createTable();
expectShadows(table, false, false);
table.setScrollLeft(100);
table.stateManager.showHorizontalScrollBar(false);
expectShadows(table, false, false);
table.scenegraph.component.hideFrozenColumnShadow();
expectShadows(table, false, false);
});

test('updates both boundaries immediately for programmatic and scrollbar scrolling', () => {
const table = createTable({ frozenColCount: 1, rightFrozenColCount: 1 });
expectShadows(table, false, true);
expect(table.scenegraph.component.rightFrozenShadowLine.attribute.height).toBeGreaterThan(0);
table.setScrollLeft(100);
expectShadows(table, true, true);
// Hiding the scrolling UI must not hide a shadow while content is still covered.
table.scenegraph.component.hideFrozenColumnShadow();
expectShadows(table, true, true);
table.stateManager.updateHorizontalScrollBar(1);
expectShadows(table, true, false);
table.stateManager.updateHorizontalScrollBar(0);
expectShadows(table, false, true);
table.setScrollLeft(100);
table.setScrollLeft(0);
expectShadows(table, false, true);
});

test.each([{ frozenColCount: 1 }, { rightFrozenColCount: 1 }, { frozenColCount: 1, rightFrozenColCount: 1 }])(
'hides shadows when all body columns fit: %o',
options => {
const table = createTable({ ...options, columns: columns.slice(0, 3) });
table.setScrollLeft(100);
table.stateManager.showHorizontalScrollBar(false);
expectShadows(table, false, false);
}
);

test('recalculates visibility when frozen columns, viewport or theme change', async () => {
const table = createTable();
table.setScrollLeft(100);
table.setFrozenColCount(1);
expectShadows(table, true, false);
table.setFrozenColCount(0);
expectShadows(table, false, false);
await table.updateOption({ columns, records, frozenColCount: 1, rightFrozenColCount: 1, theme: overflow });
table.setScrollLeft(100);
expectShadows(table, true, true);
containers[0].style.width = '1000px';
table.resize();
expectShadows(table, false, false);
containers[0].style.width = '500px';
table.resize();
expectShadows(table, false, true);
table.updateTheme(themes.DEFAULT);
expectShadows(table, true, true);
table.updateTheme(overflow);
expectShadows(table, false, true);
await table.updateOption({ columns, records, theme: overflow });
expectShadows(table, false, false);
});

test('does not change visibility when scrolling is cancelled', () => {
const table = createTable({ frozenColCount: 1, rightFrozenColCount: 1 });
table.on('can_scroll', () => false);
table.setScrollLeft(100);
table.stateManager.updateHorizontalScrollBar(1);
expect(table.scrollLeft).toBe(0);
expectShadows(table, false, true);
});

test('hides the right shadow at the fractional-width scroll limit', () => {
const table = createTable({
columns: columns.map(column => ({ ...column, width: 100.25 })),
frozenColCount: 1,
rightFrozenColCount: 1,
customConfig: { _disableColumnAndRowSizeRound: true }
});
expectShadows(table, false, true);
table.setScrollLeft(10000);
expectShadows(table, true, false);
table.setScrollLeft(0);
expectShadows(table, false, true);
});

test('keeps the existing always and scrolling modes', () => {
const table = createTable({ frozenColCount: 1, theme: themes.DEFAULT });
expectShadows(table, true, false);
table.setScrollLeft(100);
table.setScrollLeft(0);
expectShadows(table, true, false);
table.updateTheme(
themes.DEFAULT.extends({
frozenColumnLine: {
shadow: {
width: 8,
startColor: '#000',
endColor: 'transparent',
visible: 'scrolling'
}
}
})
);
expectShadows(table, false, false);
table.scenegraph.component.showFrozenColumnShadow();
expectShadows(table, true, true);
table.scenegraph.component.hideFrozenColumnShadow();
expectShadows(table, false, false);
});

test('uses the body scroll range with independently scrollable frozen columns', () => {
const table = createTable({
frozenColCount: 4,
maxFrozenWidth: 100,
scrollFrozenCols: true,
unfreezeAllOnExceedsMaxWidth: false
});
expect(table.getFrozenColsContentWidth()).toBeGreaterThan(table.getFrozenColsWidth());
table.setScrollLeft(1000);
expect(table.scrollLeft).toBeGreaterThan(0);
expectShadows(table, true, false);
table.setScrollLeft(0);
table.stateManager.setFrozenColsScrollLeft(100);
expectShadows(table, false, false);
});

test('positions both shadows for pivot tables with frozen columns on both sides', () => {
const table = new PivotTable(container(), {
rows: ['region'],
columns: ['year'],
indicators: ['sales'],
records: Array.from({ length: 8 }, (_, i) => ({ region: 'A', year: `${2020 + i}`, sales: i })),
defaultColWidth: 100,
frozenColCount: 1,
rightFrozenColCount: 1,
theme: overflow
});
tables.push(table);
expectShadows(table, false, true);
expect(table.scenegraph.component.rightFrozenShadowLine.attribute.height).toBeGreaterThan(0);
table.setScrollLeft(100);
expectShadows(table, true, true);
table.setScrollLeft(10000);
expectShadows(table, true, false);
});
});
38 changes: 34 additions & 4 deletions packages/vtable/src/scenegraph/component/table-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,8 +503,7 @@ export class TableComponent {
});
}

const rightFrozenScrollable =
this.table.options.scrollRightFrozenCols && this.table.getRightFrozenColsOffset() > 0;
const rightFrozenScrollable = this.table.options.scrollRightFrozenCols && this.table.getRightFrozenColsOffset() > 0;
if (!ignoreFrozenCols && rightFrozenScrollable) {
// 右冻结滚动条的滑块长度 = 右冻结视口宽 / 右冻结内容宽
const rightFrozenRangeEnd = Math.max(0.05, rightFrozenColsWidth / rightFrozenColsContentWidth);
Expand Down Expand Up @@ -577,6 +576,10 @@ export class TableComponent {
this.table.stateManager.setFrozenColsScrollLeft(oldFrozenHorizontalBarPos, false);
this.table.stateManager.setRightFrozenColsScrollLeft(oldRightFrozenHorizontalBarPos, false);
this.table.stateManager.setScrollTop(oldVerticalBarPos);
if (this.table.theme.frozenColumnLine?.shadow?.visible === 'overflow') {
this.setFrozenColumnShadow(this.table.frozenColCount - 1);
this.setRightFrozenColumnShadow(this.table.colCount - this.table.rightFrozenColCount);
}
}

/**
Expand Down Expand Up @@ -789,7 +792,7 @@ export class TableComponent {
!isRightFrozen && col === this.table.frozenColCount - 1 && (this.table.getFrozenColsOffset?.() ?? 0) > 0
? this.table.getFrozenColsWidth()
: getColX(col, this.table, isRightFrozen);
if (col < 0 || this.table.theme.frozenColumnLine?.shadow?.visible !== 'always') {
if (col < 0 || !this.isFrozenColumnShadowVisible(false)) {
this.frozenShadowLine.setAttributes({
visible: false,
x: colX,
Expand Down Expand Up @@ -818,7 +821,7 @@ export class TableComponent {
const colX = shouldFixViewport
? this.table.tableNoFrameWidth - this.table.getRightFrozenColsWidth()
: getColX(col, this.table, true);
if (col >= this.table.colCount || this.table.theme.frozenColumnLine?.shadow?.visible !== 'always') {
if (col >= this.table.colCount || !this.isFrozenColumnShadowVisible(true)) {
this.rightFrozenShadowLine.setAttributes({
visible: false,
x: colX - this.rightFrozenShadowLine.attribute.width,
Expand All @@ -832,6 +835,33 @@ export class TableComponent {
});
}
}
private isFrozenColumnShadowVisible(right: boolean): boolean {
const visible = this.table.theme.frozenColumnLine?.shadow?.visible;
if (visible !== 'overflow') {
return visible === 'always';
}
const frozenCount = right ? this.table.rightFrozenColCount : this.table.frozenColCount;
const sizeTolerance = this.table.options.customConfig?._disableColumnAndRowSizeRound ? 1 : 0;
const scrollRange = Math.max(0, Math.ceil(getBodyHorizontalScrollRange(this.table) - sizeTolerance));
return (
frozenCount > 0 && scrollRange > 0 && (right ? this.table.scrollLeft < scrollRange : this.table.scrollLeft > 0)
);
}

updateFrozenColumnShadowVisibility() {
if (this.table.theme.frozenColumnLine?.shadow?.visible !== 'overflow') {
return;
}
const leftVisible = this.isFrozenColumnShadowVisible(false);
const rightVisible = this.isFrozenColumnShadowVisible(true);
if (this.frozenShadowLine.attribute.visible !== leftVisible) {
this.frozenShadowLine.setAttribute('visible', leftVisible);
}
if (this.rightFrozenShadowLine.attribute.visible !== rightVisible) {
this.rightFrozenShadowLine.setAttribute('visible', rightVisible);
}
}

hideFrozenColumnShadow() {
const visible1 = this.table.theme.frozenColumnLine?.shadow?.visible;
const visible = this.table.theme.frozenColumnLine?.shadow?.visible ?? visible1;
Expand Down
2 changes: 2 additions & 0 deletions packages/vtable/src/state/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1258,6 +1258,7 @@ export class StateManager {
this.table.scenegraph.setX(-this.scroll.horizontalBarPos, xRatio === 1);
this.scroll.horizontalBarPos -= this.table.scenegraph.proxy.deltaX;
this.table.scenegraph.proxy.deltaX = 0;
this.table.scenegraph.component.updateFrozenColumnShadowVisibility();
// console.log(this.table.scenegraph.bodyGroup.lastChild.attribute);
// this.table.scenegraph.bodyGroup.lastChild.onBeforeAttributeUpdate = attr => {
// if (attr.x) {
Expand Down Expand Up @@ -1429,6 +1430,7 @@ export class StateManager {

// 更新scrollbar位置
this.table.scenegraph.component.updateHorizontalScrollBarPos(xRatio);
this.table.scenegraph.component.updateFrozenColumnShadowVisibility();

const dx = this.scroll.horizontalBarPos - oldHorizontalBarPos;
this.table.fireListeners(TABLE_EVENT_TYPE.SCROLL, {
Expand Down
4 changes: 2 additions & 2 deletions packages/vtable/src/themes/theme-define.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,7 @@ export class TableTheme implements ITableThemeDefine {
);
this._frozenColumnLine = {
get shadow():
| { width: number; startColor: string; endColor: string; visible: 'always' | 'scrolling' }
| { width: number; startColor: string; endColor: string; visible: 'always' | 'scrolling' | 'overflow' }
| undefined {
if (frozenColumnLine.shadow) {
return {
Expand All @@ -673,7 +673,7 @@ export class TableTheme implements ITableThemeDefine {
get endColor(): string {
return frozenColumnLine.shadow?.endColor ?? 'rgba(00, 24, 47, 0)';
},
get visible(): 'always' | 'scrolling' {
get visible(): 'always' | 'scrolling' | 'overflow' {
return frozenColumnLine.shadow?.visible ?? 'always';
}
};
Expand Down
7 changes: 5 additions & 2 deletions packages/vtable/src/ts-types/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,11 @@ export interface ITableThemeDefine {
width: number; //阴影整体宽度
startColor: string; //开始颜色
endColor: string; //结束颜色
/**滚动条是否可见 'always' | 'scrolling' | 'none' | 'focus',常驻|滚动时|不显示|聚焦在画布上时 。默认'scrolling'*/
visible?: 'always' | 'scrolling';
/**
* Shadow visibility. Defaults to 'always'.
* 'overflow' shows each frozen boundary only while body content is scrolled beneath it.
*/
visible?: 'always' | 'scrolling' | 'overflow';
};
/** TODO 暂未生效 */
border?: {
Expand Down
Loading