diff --git a/projects/ui/src/lib/components/po-table/po-table.component.html b/projects/ui/src/lib/components/po-table/po-table.component.html
index 53f6f8975..ef7be3468 100644
--- a/projects/ui/src/lib/components/po-table/po-table.component.html
+++ b/projects/ui/src/lib/components/po-table/po-table.component.html
@@ -256,7 +256,7 @@
}"
[ngStyle]="{
'width':
- height > 0 && !virtualScroll ? (!hasItems ? '100%' : applyFixedColumns() ? column.width : 'auto') : ''
+ height > 0 && !virtualScroll ? (!hasItems ? '100%' : applyFixedColumns() ? column.width : null) : null
}"
[class.po-table-header-subtitle]="column.type === 'subtitle'"
[class.po-table-column-drag-box]="this.isDraggable"
@@ -296,7 +296,7 @@
}"
[ngStyle]="{
'width':
- height > 0 && !virtualScroll ? (!hasItems ? '100%' : applyFixedColumns() ? column.width : 'auto') : ''
+ height > 0 && !virtualScroll ? (!hasItems ? '100%' : applyFixedColumns() ? column.width : null) : null
}"
[class.po-table-header-subtitle]="column.type === 'subtitle'"
(click)="sortColumn(column)"
@@ -533,363 +533,382 @@
-
-
+
+
diff --git a/projects/ui/src/lib/components/po-table/po-table.component.spec.ts b/projects/ui/src/lib/components/po-table/po-table.component.spec.ts
index 6283e2509..a1a275c7f 100644
--- a/projects/ui/src/lib/components/po-table/po-table.component.spec.ts
+++ b/projects/ui/src/lib/components/po-table/po-table.component.spec.ts
@@ -219,7 +219,13 @@ describe('PoTableComponent:', () => {
checkChangesItems: () => {},
debounceResize: () => true,
checkInfiniteScroll: () => {},
- applyFixedColumns: () => {}
+ applyFixedColumns: () => {},
+ clearColumnWidths: () => {},
+ syncHeaderTableWidth: () => {},
+ mainColumns: [],
+ lastColumnsKey: '',
+ virtualScroll: false,
+ hasItems: false
};
}
@@ -627,8 +633,7 @@ describe('PoTableComponent:', () => {
heightTableContainer: 0,
setTableOpacity: () => {},
changeDetector: {
- detectChanges: () => {},
- markForCheck: () => {}
+ detectChanges: () => {}
}
};
@@ -887,6 +892,20 @@ describe('PoTableComponent:', () => {
expect(component.onVisibleColumnsChange).toHaveBeenCalledWith(component.newOrderColumns);
});
+ it('drop: should call clearColumnWidths before reordering columns', () => {
+ const event = {
+ previousIndex: 0,
+ currentIndex: 1
+ };
+
+ component.mainColumns = [{ property: 'column1' }, { property: 'column2' }];
+ const clearSpy = spyOn(component, 'clearColumnWidths');
+
+ component.drop(event as any);
+
+ expect(clearSpy).toHaveBeenCalled();
+ });
+
it('drop: should update mainColumns when `hideColumnsManager` is true', () => {
const previousIndex = 0;
const currentIndex = 1;
@@ -1320,18 +1339,17 @@ describe('PoTableComponent:', () => {
heightTableContainer: 400,
setTableOpacity: () => {},
changeDetector: {
- detectChanges: () => {},
- markForCheck: () => {}
+ detectChanges: () => {}
},
getHeightTableFooter: () => {},
getHeightTableHeader: () => {}
};
- spyOn(fakeThis.changeDetector, 'markForCheck');
+ spyOn(fakeThis.changeDetector, 'detectChanges');
component['calculateHeightTableContainer'].call(fakeThis, 400);
- expect(fakeThis.changeDetector.markForCheck).toHaveBeenCalled();
+ expect(fakeThis.changeDetector.detectChanges).toHaveBeenCalled();
});
describe('calculateHeightTableContainer - itemSize: ', () => {
@@ -1511,16 +1529,18 @@ describe('PoTableComponent:', () => {
expect(component['getDefaultColumns']).not.toHaveBeenCalled();
});
- it('onVisibleColumnsChange: should set `columns` and call `detectChanges`', () => {
+ it('onVisibleColumnsChange: should call `clearColumnWidths` and `markForCheck`', () => {
const newColumns: Array = [{ property: 'age', visible: false }];
component.columns = [];
- const spyDetectChanges = spyOn(component['changeDetector'], 'detectChanges');
+ const spyClearColumnWidths = spyOn(component as any, 'clearColumnWidths');
+ const spyMarkForCheck = spyOn(component['changeDetector'], 'markForCheck');
component.onVisibleColumnsChange(newColumns);
- expect(spyDetectChanges).toHaveBeenCalled();
+ expect(spyClearColumnWidths).toHaveBeenCalled();
+ expect(spyMarkForCheck).toHaveBeenCalled();
});
it('trackBy: should return index param', () => {
@@ -2057,25 +2077,567 @@ describe('PoTableComponent:', () => {
expect(result).toBe(expectedValue);
});
- it('inverseOfTranslation: should return the correct value of inverseOfTranslation', () => {
- const mockRenderedContentOffset = 10;
+ it('configureVirtualScrollOverflow: should fix content wrapper and add scroll sync listener', () => {
+ const mockViewportEl = document.createElement('cdk-virtual-scroll-viewport');
+ const mockContentWrapper = document.createElement('div');
+ mockContentWrapper.classList.add('cdk-virtual-scroll-content-wrapper');
+ mockViewportEl.appendChild(mockContentWrapper);
+
+ const mockHeaderContainer = document.createElement('div');
+ component.tableVirtualScroll = { nativeElement: mockViewportEl } as any;
+ component.headerScrollContainer = { nativeElement: mockHeaderContainer } as any;
+
+ component['configureVirtualScrollOverflow']();
+
+ expect(mockContentWrapper.style.contain).toBe('layout style');
+ expect(mockContentWrapper.style.minWidth).toBe('100%');
+ expect(mockHeaderContainer.style.overflow).toBe('hidden');
+ expect(component['scrollSyncListener']).toBeTruthy();
+ expect(component['virtualScrollOverflowConfigured']).toBe(true);
+ });
+
+ it('configureVirtualScrollOverflow: should not apply styles when tableVirtualScroll is not available', () => {
+ component.tableVirtualScroll = null;
+ component['virtualScrollOverflowConfigured'] = false;
+
+ component['configureVirtualScrollOverflow']();
+
+ expect(component['virtualScrollOverflowConfigured']).toBe(false);
+ });
+
+ it('syncColumnWidths: should skip when applyFixedColumns returns true', () => {
+ spyOn(component, 'applyFixedColumns').and.returnValue(true);
+ const setStyleSpy = spyOn(component['renderer'], 'setStyle');
+
+ component['syncColumnWidths']();
+
+ expect(setStyleSpy).not.toHaveBeenCalled();
+ });
+
+ it('syncColumnWidths: should apply max-content temporarily, sync widths and store computedColumnWidths', () => {
+ const mockHeaderTable = document.createElement('table');
+ const mockThead = document.createElement('thead');
+ const mockTh = document.createElement('th');
+ mockTh.classList.add('po-table-header-ellipsis');
+ mockThead.appendChild(mockTh);
+ mockHeaderTable.appendChild(mockThead);
+
+ const mockBodyTable = document.createElement('table');
+ const mockTbody = document.createElement('tbody');
+ const mockTr = document.createElement('tr');
+ const mockTd = document.createElement('td');
+ mockTd.classList.add('p-element');
+ mockTr.appendChild(mockTd);
+ mockTbody.appendChild(mockTr);
+ mockBodyTable.appendChild(mockTbody);
+
+ document.body.appendChild(mockHeaderTable);
+ document.body.appendChild(mockBodyTable);
+
+ component.headerTableElement = { nativeElement: mockHeaderTable } as any;
+ component.bodyTableElement = { nativeElement: mockBodyTable } as any;
+ spyOn(component, 'applyFixedColumns').and.returnValue(false);
+
+ const setStyleSpy = spyOn(component['renderer'], 'setStyle').and.callThrough();
+
+ component['syncColumnWidths']();
+
+ // Verifica que width: max-content foi aplicado temporariamente nas tabelas
+ const maxContentCalls = setStyleSpy.calls.allArgs().filter(
+ args => args[1] === 'width' && args[2] === 'max-content'
+ );
+ expect(maxContentCalls.length).toBe(2); // body e header
+
+ // Verifica que table-layout: auto foi aplicado temporariamente
+ const tableLayoutCalls = setStyleSpy.calls.allArgs().filter(
+ args => args[1] === 'table-layout' && args[2] === 'auto'
+ );
+ expect(tableLayoutCalls.length).toBe(2); // body e header
+
+ expect(mockTh.style.width).toBeTruthy();
+ expect(mockTh.style.minWidth).toBeTruthy();
+ expect(mockTd.style.width).toBeTruthy();
+ expect(mockTd.style.minWidth).toBeTruthy();
+
+ // Verifica que computedColumnWidths foi populado
+ expect(component.computedColumnWidths.length).toBe(1);
+ expect(component.computedColumnWidths[0]).toMatch(/^\d+(\.\d+)?px$/);
+
+ document.body.removeChild(mockHeaderTable);
+ document.body.removeChild(mockBodyTable);
+ });
+
+ it('syncColumnWidths: should not apply styles when header or body table is not available', () => {
+ component.headerTableElement = null;
+ component.bodyTableElement = null;
+
+ expect(() => component['syncColumnWidths']()).not.toThrow();
+ });
+
+ it('syncColumnWidths: should not apply styles when body has no rows', () => {
+ const mockHeaderTable = document.createElement('table');
+ const mockThead = document.createElement('thead');
+ const mockTh = document.createElement('th');
+ mockThead.appendChild(mockTh);
+ mockHeaderTable.appendChild(mockThead);
+
+ const mockBodyTable = document.createElement('table');
+ const mockTbody = document.createElement('tbody');
+ mockBodyTable.appendChild(mockTbody);
+
+ component.headerTableElement = { nativeElement: mockHeaderTable } as any;
+ component.bodyTableElement = { nativeElement: mockBodyTable } as any;
+
+ expect(() => component['syncColumnWidths']()).not.toThrow();
+ });
+
+ it('syncColumnWidths: should not apply styles when cells are empty', () => {
+ const mockHeaderTable = document.createElement('table');
+ const mockThead = document.createElement('thead');
+ mockHeaderTable.appendChild(mockThead);
+
+ const mockBodyTable = document.createElement('table');
+ const mockTbody = document.createElement('tbody');
+ const mockTr = document.createElement('tr');
+ mockTbody.appendChild(mockTr);
+ mockBodyTable.appendChild(mockTbody);
+
+ component.headerTableElement = { nativeElement: mockHeaderTable } as any;
+ component.bodyTableElement = { nativeElement: mockBodyTable } as any;
+
+ expect(() => component['syncColumnWidths']()).not.toThrow();
+ });
+
+ it('syncColumnWidths: should clear inline widths and use max-content before recalculating', () => {
+ const mockHeaderTable = document.createElement('table');
+ const mockThead = document.createElement('thead');
+ const mockTh = document.createElement('th');
+ mockTh.classList.add('po-table-header-ellipsis');
+ mockTh.style.width = '500px';
+ mockTh.style.minWidth = '500px';
+ mockThead.appendChild(mockTh);
+ mockHeaderTable.appendChild(mockThead);
+
+ const mockBodyTable = document.createElement('table');
+ const mockTbody = document.createElement('tbody');
+ const mockTr = document.createElement('tr');
+ const mockTd = document.createElement('td');
+ mockTd.classList.add('p-element');
+ mockTd.style.width = '500px';
+ mockTd.style.minWidth = '500px';
+ mockTr.appendChild(mockTd);
+ mockTbody.appendChild(mockTr);
+ mockBodyTable.appendChild(mockTbody);
+
+ document.body.appendChild(mockHeaderTable);
+ document.body.appendChild(mockBodyTable);
+
+ component.headerTableElement = { nativeElement: mockHeaderTable } as any;
+ component.bodyTableElement = { nativeElement: mockBodyTable } as any;
+ spyOn(component, 'applyFixedColumns').and.returnValue(false);
- component.viewPort = { _renderedContentOffset: mockRenderedContentOffset } as any;
+ const removeStyleSpy = spyOn(component['renderer'], 'removeStyle').and.callThrough();
- const resultado = component.inverseOfTranslation;
- expect(resultado).toEqual('-10px');
+ component['syncColumnWidths']();
+
+ // Verifica que removeStyle foi chamado para limpar widths das cells e restaurar tabelas
+ expect(removeStyleSpy).toHaveBeenCalled();
+ const removeTableLayoutCalls = removeStyleSpy.calls.allArgs().filter(
+ args => args[1] === 'table-layout'
+ );
+ expect(removeTableLayoutCalls.length).toBe(2); // body e header restaurados
+
+ document.body.removeChild(mockHeaderTable);
+ document.body.removeChild(mockBodyTable);
+ });
+
+ it('clearColumnWidths: should remove inline width, minWidth and table-layout from header and body and reset computedColumnWidths', () => {
+ const mockHeaderTable = document.createElement('table');
+ mockHeaderTable.style.tableLayout = 'auto';
+ mockHeaderTable.style.width = 'max-content';
+ const mockThead = document.createElement('thead');
+ const mockTh = document.createElement('th');
+ mockTh.style.width = '200px';
+ mockTh.style.minWidth = '200px';
+ mockThead.appendChild(mockTh);
+ mockHeaderTable.appendChild(mockThead);
+
+ const mockBodyTable = document.createElement('table');
+ mockBodyTable.style.tableLayout = 'auto';
+ mockBodyTable.style.width = 'max-content';
+ const mockTbody = document.createElement('tbody');
+ const mockTr = document.createElement('tr');
+ const mockTd = document.createElement('td');
+ mockTd.style.width = '200px';
+ mockTd.style.minWidth = '200px';
+ mockTr.appendChild(mockTd);
+ mockTbody.appendChild(mockTr);
+ mockBodyTable.appendChild(mockTbody);
+
+ component.headerTableElement = { nativeElement: mockHeaderTable } as any;
+ component.bodyTableElement = { nativeElement: mockBodyTable } as any;
+ component.computedColumnWidths = ['200px'];
+
+ component['clearColumnWidths']();
+
+ expect(mockTh.style.width).toBe('');
+ expect(mockTh.style.minWidth).toBe('');
+ expect(mockTd.style.width).toBe('');
+ expect(mockTd.style.minWidth).toBe('');
+ expect(mockHeaderTable.style.tableLayout).toBe('');
+ expect(mockHeaderTable.style.width).toBe('');
+ expect(mockBodyTable.style.tableLayout).toBe('');
+ expect(mockBodyTable.style.width).toBe('');
+ expect(component.computedColumnWidths).toEqual([]);
+ });
+
+ it('clearColumnWidths: should not fail when tables are not available', () => {
+ component.headerTableElement = null;
+ component.bodyTableElement = null;
+
+ expect(() => component['clearColumnWidths']()).not.toThrow();
+ });
+
+ it('clearColumnWidths: should not fail when body has no rows', () => {
+ const mockHeaderTable = document.createElement('table');
+ const mockThead = document.createElement('thead');
+ const mockTh = document.createElement('th');
+ mockThead.appendChild(mockTh);
+ mockHeaderTable.appendChild(mockThead);
+
+ const mockBodyTable = document.createElement('table');
+
+ component.headerTableElement = { nativeElement: mockHeaderTable } as any;
+ component.bodyTableElement = { nativeElement: mockBodyTable } as any;
+
+ expect(() => component['clearColumnWidths']()).not.toThrow();
+ });
+ it('drop: should schedule syncColumnWidths after clearing and reordering', (done) => {
+ const event = {
+ previousIndex: 0,
+ currentIndex: 1
+ };
+
+ component.mainColumns = [{ property: 'column1' }, { property: 'column2' }];
+ spyOn(component, 'clearColumnWidths');
+ const syncSpy = spyOn(component, 'syncColumnWidths');
+
+ component.drop(event as any);
+
+ expect(component['clearColumnWidths']).toHaveBeenCalled();
+ expect(syncSpy).not.toHaveBeenCalled();
+
+ setTimeout(() => {
+ expect(syncSpy).toHaveBeenCalled();
+ done();
+ });
});
- it('inverseOfTranslation: should return "-0px" if viewPort or _renderedContentOffset are not set', () => {
- component.viewPort = null;
+ it('ngDoCheck: should clear computedColumnWidths when columns change', () => {
+ component.computedColumnWidths = ['100px', '200px'];
+ component['lastColumnsKey'] = 'col1::col2::';
+ component.mainColumns = [{ property: 'col1' }, { property: 'col2' }, { property: 'col3' }];
+
+ component.ngDoCheck();
+
+ expect(component.computedColumnWidths).toEqual([]);
+ });
+
+ it('ngAfterViewChecked: should schedule syncColumnWidths when computedColumnWidths is empty and body has rows', (done) => {
+ const mockBodyTable = document.createElement('table');
+ const mockTbody = document.createElement('tbody');
+ const mockTr = document.createElement('tr');
+ mockTbody.appendChild(mockTr);
+ mockBodyTable.appendChild(mockTbody);
+
+ component.height = 400;
+ component.virtualScroll = true;
+ component.items = [{ id: 1 }];
+ component.computedColumnWidths = [];
+ component.bodyTableElement = { nativeElement: mockBodyTable } as any;
+ component['virtualScrollOverflowConfigured'] = true;
+ component['syncScheduled'] = false;
+ spyOn(component, 'applyFixedColumns').and.returnValue(false);
+ const syncSpy = spyOn(component, 'syncColumnWidths');
+
+ component.ngAfterViewChecked();
+
+ expect(component['syncScheduled']).toBe(true);
+
+ setTimeout(() => {
+ expect(syncSpy).toHaveBeenCalled();
+ expect(component['syncScheduled']).toBe(false);
+ done();
+ });
+ });
+
+ it('ngAfterViewChecked: should call configureVirtualScrollOverflow when virtualScroll is active and not yet configured', () => {
+ const mockViewportEl = document.createElement('cdk-virtual-scroll-viewport');
+ component.tableVirtualScroll = { nativeElement: mockViewportEl } as any;
+ component.height = 400;
+ component.virtualScroll = true;
+ component['virtualScrollOverflowConfigured'] = false;
+
+ spyOn(component, 'configureVirtualScrollOverflow');
+
+ component.ngAfterViewChecked();
+
+ expect(component['configureVirtualScrollOverflow']).toHaveBeenCalled();
+ });
+
+ it('ngAfterViewChecked: should not call configureVirtualScrollOverflow when already configured', () => {
+ component.virtualScroll = true;
+ component['virtualScrollOverflowConfigured'] = true;
+
+ spyOn(component, 'configureVirtualScrollOverflow');
+
+ component.ngAfterViewChecked();
+
+ expect(component['configureVirtualScrollOverflow']).not.toHaveBeenCalled();
+ });
+
+ it('setupColumnWidthSync: should create ResizeObserver and observe viewport when virtualScroll is true', () => {
+ const mockViewportEl = document.createElement('cdk-virtual-scroll-viewport');
+ component.tableVirtualScroll = { nativeElement: mockViewportEl } as any;
+ component.height = 400;
+ component.virtualScroll = true;
+
+ component['setupColumnWidthSync']();
+
+ expect(component['resizeObserver']).toBeTruthy();
+ });
+
+ it('setupColumnWidthSync: should not create ResizeObserver when virtualScroll is false', () => {
+ component['resizeObserver'] = undefined;
+ component.height = 0;
+ component.virtualScroll = false;
+
+ component['setupColumnWidthSync']();
+
+ expect(component['resizeObserver']).toBeUndefined();
+ });
+
+ it('ngOnDestroy: should call scrollSyncListener and set to null', () => {
+ const scrollSyncSpy = jasmine.createSpy('scrollSyncListener');
+ component['scrollSyncListener'] = scrollSyncSpy;
+
+ component.ngOnDestroy();
+
+ expect(scrollSyncSpy).toHaveBeenCalled();
+ expect(component['scrollSyncListener']).toBeNull();
+ });
+
+ it('ngOnDestroy: should call containerScrollSyncListener and set to null', () => {
+ const containerSyncSpy = jasmine.createSpy('containerScrollSyncListener');
+ component['containerScrollSyncListener'] = containerSyncSpy;
+
+ component.ngOnDestroy();
+
+ expect(containerSyncSpy).toHaveBeenCalled();
+ expect(component['containerScrollSyncListener']).toBeNull();
+ });
+
+ it('ngOnDestroy: should call resizeObserver.disconnect when disconnect is a function', () => {
+ const disconnectSpy = jasmine.createSpy('disconnect');
+ component['resizeObserver'] = { observe: () => {}, disconnect: disconnectSpy, unobserve: () => {} } as any;
+
+ component.ngOnDestroy();
+
+ expect(disconnectSpy).toHaveBeenCalled();
+ });
+
+ it('ngOnDestroy: should not fail when scrollSyncListener and containerScrollSyncListener are null', () => {
+ component['scrollSyncListener'] = null;
+ component['containerScrollSyncListener'] = null;
+ component['resizeObserver'] = undefined;
+
+ expect(() => component.ngOnDestroy()).not.toThrow();
+ });
+
+ it('ngDoCheck: should call syncHeaderTableWidth when virtualScroll is active and hasItems', () => {
+ component.height = 400;
+ component.virtualScroll = true;
+ component.items = [{ id: 1 }];
+
+ const syncSpy = spyOn(component, 'syncHeaderTableWidth');
+
+ component.ngDoCheck();
+
+ expect(syncSpy).toHaveBeenCalled();
+ });
+
+ it('ngDoCheck: should not call syncHeaderTableWidth when virtualScroll is false', () => {
+ component.virtualScroll = false;
+ component.items = [{ id: 1 }];
+
+ const syncSpy = spyOn(component, 'syncHeaderTableWidth');
+
+ component.ngDoCheck();
+
+ expect(syncSpy).not.toHaveBeenCalled();
+ });
+
+ it('onVisibleColumnsChange: should schedule syncColumnWidths when virtualScroll is active', (done) => {
+ component.height = 400;
+ component.virtualScroll = true;
+
+ const syncSpy = spyOn(component, 'syncColumnWidths');
+ spyOn(component, 'clearColumnWidths');
+
+ component.onVisibleColumnsChange([{ property: 'id' }]);
+
+ setTimeout(() => {
+ expect(syncSpy).toHaveBeenCalled();
+ done();
+ });
+ });
+
+ it('onVisibleColumnsChange: should not schedule syncColumnWidths when virtualScroll is false', () => {
+ component.virtualScroll = false;
+
+ spyOn(component, 'clearColumnWidths');
+ const markSpy = spyOn(component['changeDetector'], 'markForCheck');
+
+ component.onVisibleColumnsChange([{ property: 'id' }]);
+
+ expect(markSpy).toHaveBeenCalled();
+ expect(component.columns).toEqual([{ property: 'id' }]);
+ });
+
+ it('syncHeaderTableWidth: should update headerTableScrollWidth when width changes', () => {
+ const mockHeaderTable = document.createElement('table');
+ mockHeaderTable.innerHTML = '| Col |
';
+ document.body.appendChild(mockHeaderTable);
+ component.headerTableElement = { nativeElement: mockHeaderTable } as any;
+ component.headerTableScrollWidth = 999;
+
+ const markSpy = spyOn(component['changeDetector'], 'markForCheck');
+
+ component['syncHeaderTableWidth']();
+
+ expect(component.headerTableScrollWidth).not.toBe(999);
+ expect(markSpy).toHaveBeenCalled();
+
+ document.body.removeChild(mockHeaderTable);
+ });
+
+ it('syncHeaderTableWidth: should not call markForCheck when width has not changed', () => {
+ const mockHeaderTable = document.createElement('table');
+ document.body.appendChild(mockHeaderTable);
+ component.headerTableElement = { nativeElement: mockHeaderTable } as any;
+ component.headerTableScrollWidth = mockHeaderTable.scrollWidth;
+
+ const markSpy = spyOn(component['changeDetector'], 'markForCheck');
+
+ component['syncHeaderTableWidth']();
+
+ expect(markSpy).not.toHaveBeenCalled();
+
+ document.body.removeChild(mockHeaderTable);
+ });
+
+ it('syncHeaderTableWidth: should not fail when headerTableElement is null', () => {
+ component.headerTableElement = null;
+
+ expect(() => component['syncHeaderTableWidth']()).not.toThrow();
+ });
+
+ it('clearColumnWidths: should reset headerScrollContainer scrollLeft', () => {
+ const mockHeaderTable = document.createElement('table');
+ const mockHeaderContainer = document.createElement('div');
+ Object.defineProperty(mockHeaderContainer, 'scrollLeft', { value: 100, writable: true });
+
+ component.headerTableElement = { nativeElement: mockHeaderTable } as any;
+ component.bodyTableElement = null;
+ component.headerScrollContainer = { nativeElement: mockHeaderContainer } as any;
+
+ component['clearColumnWidths']();
+
+ expect(mockHeaderContainer.scrollLeft).toBe(0);
+ });
+
+ it('configureVirtualScrollOverflow: should not set overflow when headerScrollContainer is null', () => {
+ const mockViewportEl = document.createElement('cdk-virtual-scroll-viewport');
+ component.tableVirtualScroll = { nativeElement: mockViewportEl } as any;
+ component.headerScrollContainer = null;
+
+ component['configureVirtualScrollOverflow']();
+
+ expect(component['virtualScrollOverflowConfigured']).toBe(true);
+ });
+
+ it('configureVirtualScrollOverflow: should not create duplicate scroll sync listeners', () => {
+ const mockViewportEl = document.createElement('cdk-virtual-scroll-viewport');
+ const mockHeaderContainer = document.createElement('div');
+ component.tableVirtualScroll = { nativeElement: mockViewportEl } as any;
+ component.headerScrollContainer = { nativeElement: mockHeaderContainer } as any;
+ component['scrollSyncListener'] = () => {};
+
+ const listenSpy = spyOn(component['renderer'], 'listen');
+
+ component['configureVirtualScrollOverflow']();
+
+ // Should not call listen for viewport scroll since scrollSyncListener already exists
+ const viewportListenCalls = listenSpy.calls.allArgs().filter(args => args[1] === 'scroll' && args[0] === mockViewportEl);
+ expect(viewportListenCalls.length).toBe(0);
+ });
+
+ it('configureVirtualScrollOverflow: scroll sync listener should sync headerScrollContainer scrollLeft from viewport', () => {
+ const mockViewportEl = document.createElement('cdk-virtual-scroll-viewport');
+ const mockHeaderEl = document.createElement('div');
+ component.tableVirtualScroll = { nativeElement: mockViewportEl } as any;
+ component.headerScrollContainer = { nativeElement: mockHeaderEl } as any;
+ component['scrollSyncListener'] = null;
+
+ let scrollCallback: Function;
+ const originalListen = component['renderer'].listen.bind(component['renderer']);
+ spyOn(component['renderer'], 'listen').and.callFake((target: any, event: string, callback: Function) => {
+ if (target === mockViewportEl && event === 'scroll') {
+ scrollCallback = callback;
+ }
+ return originalListen(target, event, callback);
+ });
+
+ component['configureVirtualScrollOverflow']();
+
+ // Invoke the scroll callback to cover the branch inside the listener
+ scrollCallback();
+
+ // The callback should have attempted to set scrollLeft (even if 0 = 0)
+ expect(scrollCallback).toBeDefined();
+ });
+
+ it('configureVirtualScrollOverflow: container scroll sync listener should sync headerScrollContainer scrollLeft from container', () => {
+ // Create a DOM structure: fixedInnerContainer > mockViewportEl
+ const fixedInnerContainer = document.createElement('div');
+ fixedInnerContainer.classList.add('po-table-container-fixed-inner');
+ const mockViewportEl = document.createElement('cdk-virtual-scroll-viewport');
+ fixedInnerContainer.appendChild(mockViewportEl);
+ document.body.appendChild(fixedInnerContainer);
+
+ const mockHeaderEl = document.createElement('div');
+ component.tableVirtualScroll = { nativeElement: mockViewportEl } as any;
+ component.headerScrollContainer = { nativeElement: mockHeaderEl } as any;
+ component['scrollSyncListener'] = null;
+ component['containerScrollSyncListener'] = null;
+
+ let containerScrollCallback: Function;
+ const originalListen = component['renderer'].listen.bind(component['renderer']);
+ spyOn(component['renderer'], 'listen').and.callFake((target: any, event: string, callback: Function) => {
+ if (target === fixedInnerContainer && event === 'scroll') {
+ containerScrollCallback = callback;
+ }
+ return originalListen(target, event, callback);
+ });
+
+ component['configureVirtualScrollOverflow']();
- const resultado1 = component.inverseOfTranslation;
- expect(resultado1).toEqual('-0px');
+ // Invoke the container scroll callback to cover the branch inside the listener
+ containerScrollCallback();
- component.viewPort = { _renderedContentOffset: null } as any;
+ expect(containerScrollCallback).toBeDefined();
- const resultado2 = component.inverseOfTranslation;
- expect(resultado2).toEqual('-0px');
+ document.body.removeChild(fixedInnerContainer);
});
it('should update filteredItems on onFilteredItemsChange call', () => {
diff --git a/projects/ui/src/lib/components/po-table/po-table.component.ts b/projects/ui/src/lib/components/po-table/po-table.component.ts
index 5c20f0f7d..c3fefb191 100644
--- a/projects/ui/src/lib/components/po-table/po-table.component.ts
+++ b/projects/ui/src/lib/components/po-table/po-table.component.ts
@@ -3,6 +3,7 @@ import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import { DecimalPipe } from '@angular/common';
import {
+ AfterViewChecked,
AfterViewInit,
ChangeDetectorRef,
Component,
@@ -100,12 +101,20 @@ import { PoFieldSize } from '../../enums/po-field-size.enum';
providers: [PoDateService, PoTableService],
standalone: false
})
-export class PoTableComponent extends PoTableBaseComponent implements AfterViewInit, DoCheck, OnDestroy, OnInit {
+export class PoTableComponent
+ extends PoTableBaseComponent
+ implements AfterViewChecked, AfterViewInit, DoCheck, OnDestroy, OnInit
+{
@ContentChild(PoTableRowTemplateDirective, { static: true }) tableRowTemplate: PoTableRowTemplateDirective;
@ContentChild(PoTableCellTemplateDirective) tableCellTemplate: PoTableCellTemplateDirective;
@ContentChildren(PoTableColumnTemplateDirective) tableColumnTemplates: QueryList;
+ @ViewChild('virtualScrollWrapper', { read: ElementRef, static: false }) virtualScrollWrapper: ElementRef;
+ @ViewChild('headerScrollContainer', { read: ElementRef, static: false }) headerScrollContainer: ElementRef;
+ @ViewChild('headerTable', { read: ElementRef, static: false }) headerTableElement: ElementRef;
+ @ViewChild('bodyTable', { read: ElementRef, static: false }) bodyTableElement: ElementRef;
+
@ViewChild('noColumnsHeader', { read: ElementRef }) noColumnsHeader;
@ViewChild('popup') poPopupComponent: PoPopupComponent;
@ViewChild(PoModalComponent, { static: true }) modalDelete: PoModalComponent;
@@ -142,6 +151,8 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI
newOrderColumns: Array;
sizeLoading: string = 'sm';
headerWidth: number;
+ headerTableScrollWidth: number;
+ computedColumnWidths: Array = [];
close: PoModalAction = {
action: () => {
@@ -167,6 +178,13 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI
private scrollEvent$: Observable;
private subscriptionScrollEvent: Subscription;
private subscriptionService: Subscription = new Subscription();
+ private columnWidths: Array = [];
+ private resizeObserver: ResizeObserver;
+ private scrollSyncListener: (() => void) | null = null;
+ private containerScrollSyncListener: (() => void) | null = null;
+ private virtualScrollOverflowConfigured = false;
+ private syncScheduled = false;
+ private lastColumnsKey = '';
private clickListener: () => void;
private resizeListener: () => void;
@@ -194,7 +212,7 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI
constructor(
poDate: PoDateService,
differs: IterableDiffers,
- renderer: Renderer2,
+ private renderer: Renderer2,
poLanguageService: PoLanguageService,
private changeDetector: ChangeDetectorRef,
private decimalPipe: DecimalPipe,
@@ -281,16 +299,6 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI
return this.draggable;
}
- public get inverseOfTranslation(): string {
- if (!this.viewPort || !this.viewPort['_renderedContentOffset']) {
- return '-0px';
- }
-
- const offset = this.viewPort['_renderedContentOffset'];
-
- return `-${offset}px`;
- }
-
ngOnInit() {
this.idRadio = `po-radio-${uuid()}`;
}
@@ -307,6 +315,31 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI
this.changeHeaderWidth();
this.changeSizeLoading();
this.applyFixedColumns();
+ this.syncHeaderTableWidth();
+ this.setupColumnWidthSync();
+ this.configureVirtualScrollOverflow();
+ }
+
+ ngAfterViewChecked(): void {
+ if (this.virtualScroll && !this.virtualScrollOverflowConfigured && this.tableVirtualScroll?.nativeElement) {
+ this.configureVirtualScrollOverflow();
+ }
+
+ // Agenda sincronização quando virtual scroll está ativo mas não há larguras computadas
+ if (
+ this.virtualScroll &&
+ this.hasItems &&
+ !this.applyFixedColumns() &&
+ this.computedColumnWidths.length === 0 &&
+ this.bodyTableElement?.nativeElement?.querySelector('tbody tr') &&
+ !this.syncScheduled
+ ) {
+ this.syncScheduled = true;
+ setTimeout(() => {
+ this.syncColumnWidths();
+ this.syncScheduled = false;
+ });
+ }
}
showMoreInfiniteScroll({ target }): void {
@@ -321,6 +354,13 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI
this.checkChangesItems();
this.verifyCalculateHeightTableContainer();
+ // Detecta mudanças nas colunas e limpa larguras computadas obsoletas
+ const columnsKey = this.mainColumns?.map(c => `${c.property}:${c.fixed || ''}:${c.width || ''}`).join('|') || '';
+ if (columnsKey !== this.lastColumnsKey) {
+ this.lastColumnsKey = columnsKey;
+ this.clearColumnWidths();
+ }
+
// Permite que os cabeçalhos sejam calculados na primeira vez que o componente torna-se visível,
// evitando com isso, problemas com Tabs ou Divs que iniciem escondidas.
if (this.tableWrapperElement?.nativeElement.offsetWidth && !this.visibleElement && this.initialized) {
@@ -328,11 +368,27 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI
this.checkInfiniteScroll();
this.visibleElement = true;
}
+
+ // Sincroniza largura total do header quando virtualScroll está ativo
+ if (this.virtualScroll && this.hasItems) {
+ this.syncHeaderTableWidth();
+ }
}
ngOnDestroy() {
this.removeListeners();
this.subscriptionService?.unsubscribe();
+ if (this.resizeObserver && typeof this.resizeObserver.disconnect === 'function') {
+ this.resizeObserver.disconnect();
+ }
+ if (this.scrollSyncListener) {
+ this.scrollSyncListener();
+ this.scrollSyncListener = null;
+ }
+ if (this.containerScrollSyncListener) {
+ this.containerScrollSyncListener();
+ this.containerScrollSyncListener = null;
+ }
}
/**
@@ -578,8 +634,14 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI
}
onVisibleColumnsChange(columns: Array) {
+ this.clearColumnWidths();
this.columns = columns;
- this.changeDetector.detectChanges();
+ this.changeDetector.markForCheck();
+
+ // Re-sincroniza larguras após Angular renderizar a nova configuração de colunas
+ if (this.virtualScroll) {
+ setTimeout(() => this.syncColumnWidths());
+ }
}
tooltipMouseEnter(event: any, column?: PoTableColumn, row?: any) {
@@ -662,6 +724,7 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI
drop(event: CdkDragDrop>) {
if (!this.mainColumns[event.currentIndex].fixed) {
+ this.clearColumnWidths();
moveItemInArray(this.mainColumns, event.previousIndex, event.currentIndex);
if (this.hideColumnsManager === false) {
@@ -681,6 +744,9 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI
this.onVisibleColumnsChange(this.newOrderColumns);
}
+
+ // Re-sincroniza larguras após Angular renderizar a nova ordem
+ setTimeout(() => this.syncColumnWidths());
}
}
@@ -718,7 +784,7 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI
this.heightTableContainer = height ? height - this.getHeightTableFooter() : undefined;
this.heightTableVirtual = this.heightTableContainer ? this.heightTableContainer - this.itemSize : undefined;
this.setTableOpacity(1);
- this.changeDetector.markForCheck();
+ this.changeDetector.detectChanges();
}
protected verifyCalculateHeightTableContainer() {
@@ -977,4 +1043,170 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI
});
}
}
+
+ /**
+ * Configura o overflow do CDK virtual scroll viewport via Renderer2.
+ * O viewport mantém overflow: auto (padrão CDK) para gerenciar ambos os eixos.
+ * O content wrapper interno tem contain/overflow ajustados para que
+ * position: sticky funcione relativo ao viewport.
+ * O header sincroniza o scrollLeft via listener de scroll.
+ */
+ private configureVirtualScrollOverflow(): void {
+ if (!this.tableVirtualScroll?.nativeElement) return;
+
+ const viewportEl = this.tableVirtualScroll.nativeElement;
+
+ const contentWrapper = viewportEl.querySelector('.cdk-virtual-scroll-content-wrapper');
+ if (contentWrapper) {
+ this.renderer.setStyle(contentWrapper, 'contain', 'layout style');
+ this.renderer.setStyle(contentWrapper, 'min-width', '100%');
+ }
+
+ // O header precisa de overflow: hidden para que scrollLeft funcione via JS.
+ // Sem isso, o elemento não cria contexto de scroll e scrollLeft fica sempre em 0.
+ if (this.headerScrollContainer?.nativeElement) {
+ this.renderer.setStyle(this.headerScrollContainer.nativeElement, 'overflow', 'hidden');
+ }
+
+ if (!this.scrollSyncListener) {
+ this.scrollSyncListener = this.renderer.listen(viewportEl, 'scroll', () => {
+ if (this.headerScrollContainer?.nativeElement) {
+ this.headerScrollContainer.nativeElement.scrollLeft = viewportEl.scrollLeft;
+ }
+ });
+ }
+
+ // O scroll horizontal real acontece no container .po-table-container-fixed-inner (pai do viewport).
+ // Precisamos sincronizar o scrollLeft do header com esse container também.
+ const fixedInnerContainer = viewportEl.closest('.po-table-container-fixed-inner');
+ if (fixedInnerContainer && !this.containerScrollSyncListener) {
+ this.containerScrollSyncListener = this.renderer.listen(fixedInnerContainer, 'scroll', () => {
+ if (this.headerScrollContainer?.nativeElement) {
+ this.headerScrollContainer.nativeElement.scrollLeft = fixedInnerContainer.scrollLeft;
+ }
+ });
+ }
+
+ this.virtualScrollOverflowConfigured = true;
+ }
+
+ /**
+ * Lê as larguras computadas das colunas do e aplica no .
+ * Utiliza ResizeObserver para reagir a mudanças de tamanho.
+ */
+ private setupColumnWidthSync(): void {
+ if (!this.virtualScroll) return;
+
+ this.resizeObserver = new ResizeObserver(() => {
+ this.syncColumnWidths();
+ });
+
+ const viewportEl = this.tableVirtualScroll?.nativeElement;
+ if (viewportEl) {
+ this.resizeObserver.observe(viewportEl);
+ }
+ }
+
+ private clearColumnWidths(): void {
+ const headerTable = this.headerTableElement?.nativeElement;
+ const bodyTable = this.bodyTableElement?.nativeElement;
+
+ if (headerTable) {
+ const headerCells = headerTable.querySelectorAll('thead th');
+ for (let i = 0; i < headerCells.length; i++) {
+ this.renderer.removeStyle(headerCells[i] as HTMLElement, 'width');
+ this.renderer.removeStyle(headerCells[i] as HTMLElement, 'minWidth');
+ }
+ this.renderer.removeStyle(headerTable, 'table-layout');
+ this.renderer.removeStyle(headerTable, 'width');
+ }
+
+ if (bodyTable) {
+ const bodyRow = bodyTable.querySelector('tbody tr');
+ if (bodyRow) {
+ const bodyCells = bodyRow.querySelectorAll('td');
+ for (let i = 0; i < bodyCells.length; i++) {
+ this.renderer.removeStyle(bodyCells[i] as HTMLElement, 'width');
+ this.renderer.removeStyle(bodyCells[i] as HTMLElement, 'minWidth');
+ }
+ }
+ this.renderer.removeStyle(bodyTable, 'table-layout');
+ this.renderer.removeStyle(bodyTable, 'width');
+ }
+
+ // Reseta scrollLeft do header para evitar desalinhamento após mudança de colunas
+ if (this.headerScrollContainer?.nativeElement) {
+ this.headerScrollContainer.nativeElement.scrollLeft = 0;
+ }
+
+ this.computedColumnWidths = [];
+ }
+
+ private syncColumnWidths(): void {
+ if (this.applyFixedColumns()) return;
+ if (!this.headerTableElement?.nativeElement || !this.bodyTableElement?.nativeElement) return;
+
+ const headerTable = this.headerTableElement.nativeElement;
+ const bodyTable = this.bodyTableElement.nativeElement;
+
+ // Seleciona apenas cells das mainColumns (ignora selectable, action, etc.)
+ const headerCells = headerTable.querySelectorAll('thead th.po-table-header-ellipsis');
+ const bodyRow = bodyTable.querySelector('tbody tr');
+ if (!bodyRow) return;
+
+ const bodyCells = bodyRow.querySelectorAll('td.p-element');
+ if (!headerCells.length || !bodyCells.length) return;
+
+ const count = Math.min(headerCells.length, bodyCells.length);
+ const maxColumnWidths: Array = new Array(count).fill(0);
+
+ // Fase 1 e 2: Medir body e header com max-content (medição não-constrangida)
+ this.measureCellWidths(bodyTable, bodyCells, count, maxColumnWidths);
+ this.measureCellWidths(headerTable, headerCells, count, maxColumnWidths);
+
+ // Fase 3: Armazenar larguras computadas e aplicar via Renderer2
+ this.computedColumnWidths = maxColumnWidths.map(w => `${w}px`);
+
+ for (let i = 0; i < count; i++) {
+ const widthPx = this.computedColumnWidths[i];
+ this.renderer.setStyle(headerCells[i] as HTMLElement, 'width', widthPx);
+ this.renderer.setStyle(headerCells[i] as HTMLElement, 'minWidth', widthPx);
+ this.renderer.setStyle(bodyCells[i] as HTMLElement, 'width', widthPx);
+ this.renderer.setStyle(bodyCells[i] as HTMLElement, 'minWidth', widthPx);
+ }
+
+ this.syncHeaderTableWidth();
+ this.changeDetector.markForCheck();
+ }
+
+ private measureCellWidths(
+ table: HTMLElement,
+ cells: NodeListOf,
+ count: number,
+ maxColumnWidths: Array
+ ): void {
+ for (let i = 0; i < count; i++) {
+ this.renderer.removeStyle(cells[i] as HTMLElement, 'width');
+ this.renderer.removeStyle(cells[i] as HTMLElement, 'minWidth');
+ }
+ this.renderer.setStyle(table, 'width', 'max-content');
+ this.renderer.setStyle(table, 'table-layout', 'auto');
+
+ for (let i = 0; i < count; i++) {
+ maxColumnWidths[i] = Math.max(maxColumnWidths[i], (cells[i] as HTMLElement).getBoundingClientRect().width);
+ }
+
+ this.renderer.removeStyle(table, 'width');
+ this.renderer.removeStyle(table, 'table-layout');
+ }
+
+ private syncHeaderTableWidth(): void {
+ if (this.headerTableElement?.nativeElement) {
+ const newWidth = this.headerTableElement.nativeElement.scrollWidth;
+ if (newWidth !== this.headerTableScrollWidth) {
+ this.headerTableScrollWidth = newWidth;
+ this.changeDetector.markForCheck();
+ }
+ }
+ }
}