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 @@ - - - +
+ +
+
- - @if (hasSelectableColumn) { - - } - - @if ((hasMasterDetailColumn || hasRowTemplate) && hasMainColumns && !hasRowTemplateWithArrowDirectionRight) { - - } - - - @if (!actionRight && hasItems && hasMainColumns && (visibleActions.length > 1 || isSingleAction)) { - - } - - @if (!hasMainColumns) { - - } - - @if (this.isDraggable || hasSomeFixed()) { - @for (column of mainColumns; track trackBy(i); let i = $index) { - + @if (hasSelectableColumn) { + } - } @else { - @for (column of mainColumns; track trackBy(i); let i = $index) { + + @if ( + (hasMasterDetailColumn || hasRowTemplate) && hasMainColumns && !hasRowTemplateWithArrowDirectionRight + ) { + + } + + + @if (!actionRight && hasItems && hasMainColumns && (visibleActions.length > 1 || isSingleAction)) { + } + + @if (!hasMainColumns) { + } - } - @if (hasRowTemplateWithArrowDirectionRight && hasMainColumns && (hasVisibleActions || hideColumnsManager)) { - - } + @if (this.isDraggable || hasSomeFixed()) { + @for (column of mainColumns; track trackBy(i); let i = $index) { + + } + } @else { + @for (column of mainColumns; track trackBy(i); let i = $index) { + + } + } - @if ( - hasVisibleActions && - actionRight && - hasItems && - hasMainColumns && - (visibleActions.length > 1 || isSingleAction) - ) { - - } - - + @if (hasRowTemplateWithArrowDirectionRight && hasMainColumns && (hasVisibleActions || hideColumnsManager)) { + + } - @if (!hasItems || !hasMainColumns) { - - - + @if ( + hasVisibleActions && + actionRight && + hasItems && + hasMainColumns && + (visibleActions.length > 1 || isSingleAction) + ) { + + } - - } + +
-
- @if (!hideSelectAll) { - - } -
-
- @if (height) { -
- {{ hasValidColumns ? literals.noVisibleColumn : literals.noColumns }} -
- } @else { - {{ hasValidColumns ? literals.noVisibleColumn : literals.noColumns }} - } -
-
- @if (this.isDraggable && !column.fixed) { - +
+
+ @if (!hideSelectAll) { + } - -
-
- - -
+ #columnActionLeft + [class.po-table-header-master-detail]="!isSingleAction" + [class.po-table-header-single-action]="isSingleAction" + >
+ @if (height) { +
+ {{ hasValidColumns ? literals.noVisibleColumn : literals.noColumns }} +
+ } @else { + {{ hasValidColumns ? literals.noVisibleColumn : literals.noColumns }} + }
+
+ @if (this.isDraggable && !column.fixed) { + + } + + +
+
+
+ + +
+
- {{ literals.noData }} -
+ - @if (hasMainColumns) { - - - @if (selectable) { - - - + + + @if (!hasItems || !hasMainColumns) { + + + - } - - @if ( - (columnMasterDetail && !hideDetail && !hasRowTemplate) || - (hasRowTemplate && !hasRowTemplateWithArrowDirectionRight) - ) { - + + } + + @if (hasMainColumns) { + + + @if (selectable) { + + } + + @if ( + (columnMasterDetail && !hideDetail && !hasRowTemplate) || + (hasRowTemplate && !hasRowTemplateWithArrowDirectionRight) + ) { + + } + + @if (!actionRight && (visibleActions.length > 1 || isSingleAction)) { - - } - - @if (!actionRight && (visibleActions.length > 1 || isSingleAction)) { - - - } - @for (column of mainColumns; track trackBy(columnIndex); let columnIndex = $index) { - - } - @if (hasRowTemplateWithArrowDirectionRight) { - + } + @if (hasRowTemplateWithArrowDirectionRight) { + + } + + @if (actionRight) { - + } + + @if (hasMainColumns && hasRowTemplate && row.$showDetail && isShowRowTemplate(row, rowIndex)) { + + + } - - @if (actionRight) { - - + @if (hasMainColumns && isShowMasterDetail(row)) { + + + } - - @if (hasMainColumns && hasRowTemplate && row.$showDetail && isShowRowTemplate(row, rowIndex)) { - - - - } - @if (hasMainColumns && isShowMasterDetail(row)) { - - - - } - - } -
+ {{ literals.noData }} +
+ + + + + + -
- @switch (column.type) { - @case ('columnTemplate') { - - - - - } - @case ('cellTemplate') { - - + @switch (column.type) { + @case ('columnTemplate') { + + + + + } + @case ('cellTemplate') { + + + + + } + @case ('boolean') { + + {{ getBooleanLabel(getCellData(row, column), column) }} + + } + @case ('currency') { + + {{ getCellData(row, column) | currency: column.format : 'symbol' : '1.2-2' }} + + } + @case ('date') { + + {{ getCellData(row, column) | date: column.format || 'dd/MM/yyyy' }} + + } + @case ('time') { + + {{ getCellData(row, column) | po_time: column.format || 'HH:mm:ss.ffffff' }} + + } + @case ('dateTime') { + + {{ getCellData(row, column) | date: column.format || 'dd/MM/yyyy HH:mm:ss' }} + + } + @case ('number') { + + {{ formatNumber(getCellData(row, column), column.format) }} + + } + @case ('link') { + - - - } - @case ('boolean') { - - {{ getBooleanLabel(getCellData(row, column), column) }} - - } - @case ('currency') { - - {{ getCellData(row, column) | currency: column.format : 'symbol' : '1.2-2' }} - - } - @case ('date') { - - {{ getCellData(row, column) | date: column.format || 'dd/MM/yyyy' }} - - } - @case ('time') { - - {{ getCellData(row, column) | po_time: column.format || 'HH:mm:ss.ffffff' }} - - } - @case ('dateTime') { - - {{ getCellData(row, column) | date: column.format || 'dd/MM/yyyy HH:mm:ss' }} - - } - @case ('number') { - - {{ formatNumber(getCellData(row, column), column.format) }} - - } - @case ('link') { - - - } - @case ('icon') { - - - } - @case ('subtitle') { - - - - } - @case ('label') { - - - - } - @default { - {{ getCellData(row, column) }} + + } + @case ('icon') { + + + } + @case ('subtitle') { + + + + } + @case ('label') { + + + + } + @default { + {{ getCellData(row, column) }} + } } - } -
-
+ + + + +
+ + +
+ + +
- - -
- - -
-
+ + } + +
+
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(); + } + } + } }