diff --git a/common/changes/@visactor/vtable/feat-frozen-shadow-overflow_2026-09-15-10-00.json b/common/changes/@visactor/vtable/feat-frozen-shadow-overflow_2026-09-15-10-00.json new file mode 100644 index 0000000000..d962ba35ff --- /dev/null +++ b/common/changes/@visactor/vtable/feat-frozen-shadow-overflow_2026-09-15-10-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "feat: support overflow-based frozen column shadow visibility", + "type": "minor" + } + ], + "packageName": "@visactor/vtable" +} diff --git a/docs/assets/demo/en/basic-functionality/frozen-col.md b/docs/assets/demo/en/basic-functionality/frozen-col.md index eec9767485..fcbe5b03ed 100644 --- a/docs/assets/demo/en/basic-functionality/frozen-col.md +++ b/docs/assets/demo/en/basic-functionality/frozen-col.md @@ -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; diff --git a/docs/assets/demo/zh/basic-functionality/frozen-col.md b/docs/assets/demo/zh/basic-functionality/frozen-col.md index 7e81582f7e..cce6899454 100644 --- a/docs/assets/demo/zh/basic-functionality/frozen-col.md +++ b/docs/assets/demo/zh/basic-functionality/frozen-col.md @@ -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; diff --git a/docs/assets/option/en/common/frozen-column-line-style.md b/docs/assets/option/en/common/frozen-column-line-style.md index 9a04876273..5066d6b9d8 100644 --- a/docs/assets/option/en/common/frozen-column-line-style.md +++ b/docs/assets/option/en/common/frozen-column-line-style.md @@ -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. diff --git a/docs/assets/option/zh/common/frozen-column-line-style.md b/docs/assets/option/zh/common/frozen-column-line-style.md index e2cd954a22..925ed5fd78 100644 --- a/docs/assets/option/zh/common/frozen-column-line-style.md +++ b/docs/assets/option/zh/common/frozen-column-line-style.md @@ -18,12 +18,8 @@ 结束颜色 ###${prefix} visible(string) -阴影显示时机,默认为 `scrolling`。 +阴影显示时机,默认为 `always`。 - always: 总是显示 - scrolling: 滚动时显示 - - - - - +- overflow: 仅在冻结边界外有被遮挡的表格主体内容时显示。左侧阴影在滚离起点后显示,右侧阴影在右边仍有内容时显示;停止滚动后保持,滚到对应边缘立即隐藏。没有冻结列或主体没有水平溢出时不显示。 diff --git a/packages/vtable/__tests__/frozen-column-shadow.test.ts b/packages/vtable/__tests__/frozen-column-shadow.test.ts new file mode 100644 index 0000000000..3fa9b878dc --- /dev/null +++ b/packages/vtable/__tests__/frozen-column-shadow.test.ts @@ -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 = {}) { + 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); + }); +}); diff --git a/packages/vtable/src/scenegraph/component/table-component.ts b/packages/vtable/src/scenegraph/component/table-component.ts index 77cac2a666..8352992d2c 100644 --- a/packages/vtable/src/scenegraph/component/table-component.ts +++ b/packages/vtable/src/scenegraph/component/table-component.ts @@ -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); @@ -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); + } } /** @@ -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, @@ -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, @@ -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; diff --git a/packages/vtable/src/state/state.ts b/packages/vtable/src/state/state.ts index 534370b9c5..f5f9060f8f 100644 --- a/packages/vtable/src/state/state.ts +++ b/packages/vtable/src/state/state.ts @@ -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) { @@ -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, { diff --git a/packages/vtable/src/themes/theme-define.ts b/packages/vtable/src/themes/theme-define.ts index 17d4086157..0a1fa0d2fd 100644 --- a/packages/vtable/src/themes/theme-define.ts +++ b/packages/vtable/src/themes/theme-define.ts @@ -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 { @@ -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'; } }; diff --git a/packages/vtable/src/ts-types/theme.ts b/packages/vtable/src/ts-types/theme.ts index dd69a52206..2eb114a36f 100644 --- a/packages/vtable/src/ts-types/theme.ts +++ b/packages/vtable/src/ts-types/theme.ts @@ -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?: {