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 fdc0de4ff8..a9df5e7dcf 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" @@ -315,7 +315,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)" @@ -550,384 +550,379 @@ - - - - +
+
- - @if (hasSelectableColumn) { - - } + + @if (hasSelectableColumn) { + + } - @if ((hasMasterDetailColumn || hasRowTemplate) && hasMainColumns && !hasRowTemplateWithArrowDirectionRight) { - - } + @if ( + (hasMasterDetailColumn || hasRowTemplate) && hasMainColumns && !hasRowTemplateWithArrowDirectionRight + ) { + + } - - @if (!actionRight && hasItems && hasMainColumns && (visibleActions.length > 1 || isSingleAction)) { - - } + @if (!actionRight && hasItems && hasMainColumns && (visibleActions.length > 1 || isSingleAction)) { + + } - @if (!hasMainColumns) { - - } + } + + } - @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) { - + } + } @else { + @for (column of mainColumns; track trackBy(i); let i = $index) { + +
+ + +
+ + } } - } - @if (hasRowTemplateWithArrowDirectionRight && hasMainColumns && (hasVisibleActions || hideColumnsManager)) { - - } - - @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 (!hideSelectAll) { + + } +
+
- @if (height) { -
+ @if (!hasMainColumns) { +
+ @if (height) { +
+ {{ hasValidColumns ? literals.noVisibleColumn : literals.noColumns }} +
+ } @else { {{ hasValidColumns ? literals.noVisibleColumn : literals.noColumns }} - - } @else { - {{ hasValidColumns ? literals.noVisibleColumn : literals.noColumns }} - } -
-
- @if (this.isDraggable && !column.fixed) { - @if (iconNameLib === 'AnimaliaIcon') { +
+ @if (this.isDraggable && !column.fixed) { } - @if (iconNameLib === 'PoIcon') { - - - - - - - - - } - } - - -
-
-
+ +
+
- - - -
- {{ 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 ('icon') { + + + } + @case ('subtitle') { + + + + } + @case ('label') { + + + + } + @default { + {{ getCellData(row, column) }} + } } - @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) }} - } - } -
-
+ + + + +
+ + +
+ + +
- - -
- - -
-
+ + } + +
+
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 96c178e3d5..a304fc3861 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,7 +633,8 @@ describe('PoTableComponent:', () => { heightTableContainer: 0, setTableOpacity: () => {}, changeDetector: { - detectChanges: () => {} + detectChanges: () => {}, + markForCheck: () => {} } }; @@ -880,10 +887,10 @@ describe('PoTableComponent:', () => { component.drop(event as any); - expect(component.newOrderColumns[previousIndex]).toEqual(mockColumns[currentIndex]); - expect(component.newOrderColumns[currentIndex]).toEqual(mockColumns[previousIndex]); - expect(component.newOrderColumns[2]).toEqual(mockColumns[2]); - expect(component.onVisibleColumnsChange).toHaveBeenCalledWith(component.newOrderColumns); + expect(component.mainColumns[previousIndex]).toEqual(mockColumns[currentIndex]); + expect(component.mainColumns[currentIndex]).toEqual(mockColumns[previousIndex]); + expect(component.mainColumns[2]).toEqual(mockColumns[2]); + expect(component.onVisibleColumnsChange).toHaveBeenCalledWith(component.mainColumns); }); it('drop: should update mainColumns when `hideColumnsManager` is true', () => { @@ -1113,6 +1120,15 @@ describe('PoTableComponent:', () => { expect(component['debounceResize']).toHaveBeenCalled(); }); + it('constructor: should register click listener on document', () => { + expect(component['clickListener']).toBeDefined(); + + const eventClick = document.createEvent('Event'); + eventClick.initEvent('click', true, true); + + expect(() => document.dispatchEvent(eventClick)).not.toThrow(); + }); + it('ngAfterViewInit: should set initialize to true`', () => { component['initialized'] = false; @@ -1319,7 +1335,8 @@ describe('PoTableComponent:', () => { heightTableContainer: 400, setTableOpacity: () => {}, changeDetector: { - detectChanges: () => {} + detectChanges: () => {}, + markForCheck: () => {} }, getHeightTableFooter: () => {}, getHeightTableHeader: () => {} @@ -1459,8 +1476,8 @@ describe('PoTableComponent:', () => { }); it('removeListeners: shouldn`t call `resizeListener` and `clickListener`', () => { - component['resizeListener'] = undefined; - component['clickListener'] = undefined; + Object.defineProperty(component, 'resizeListener', { value: undefined, configurable: true }); + Object.defineProperty(component, 'clickListener', { value: undefined, configurable: true }); component['removeListeners'](); @@ -1509,16 +1526,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', () => { @@ -1934,6 +1953,8 @@ describe('PoTableComponent:', () => { { id: 1, name: 'teste', $selected: true }, { id: 2, name: 'teste2' } ]; + component['modalDelete'] = { close: () => {} } as any; + spyOn(component['poNotification'], 'success'); spyOn(component['eventDelete'], 'emit'); component.deleteItems(); @@ -1950,6 +1971,8 @@ describe('PoTableComponent:', () => { { id: 1, name: 'teste', $selected: true }, { id: 2, name: 'teste2' } ]; + component['modalDelete'] = { close: () => {} } as any; + spyOn(component['poNotification'], 'success'); spyOn(component, 'removeItem'); component.deleteItems(); @@ -1967,7 +1990,9 @@ describe('PoTableComponent:', () => { { id: 2, name: 'teste2' } ]; component.itemsSelected = [{ id: 1, name: 'teste', $selected: true }]; + component['modalDelete'] = { close: () => {} } as any; + spyOn(component['poNotification'], 'success'); spyOn(component, 'setTableResponseProperties'); spyOn(component['defaultService'], 'deleteItem').and.returnValue(of({})); spyOn(component['defaultService'], 'getFilteredItems').and.returnValue( @@ -1988,6 +2013,8 @@ describe('PoTableComponent:', () => { { id: 2, name: 'teste2' } ]; component.itemsSelected = [{ id: 1, name: 'teste', $selected: true }]; + component['modalDelete'] = { close: () => {} } as any; + spyOn(component['poNotification'], 'success'); spyOn(component['defaultService'], 'deleteItem').and.returnValue(of({})); spyOn(component['eventDelete'], 'emit'); @@ -2023,6 +2050,8 @@ describe('PoTableComponent:', () => { { id: 1, name: 'teste', $selected: true }, { id: 2, name: 'teste2' } ]; + component['modalDelete'] = { close: () => {} } as any; + spyOn(component['poNotification'], 'success'); component['changesAfterDelete'](newItems); expect(component.selectAll).toBeFalsy(); }); @@ -2055,325 +2084,2102 @@ 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); - component.viewPort = { _renderedContentOffset: mockRenderedContentOffset } as any; + const mockHeaderContainer = document.createElement('div'); + component.tableVirtualScroll = { nativeElement: mockViewportEl } as any; + component.headerScrollContainer = { nativeElement: mockHeaderContainer } as any; - const resultado = component.inverseOfTranslation; - expect(resultado).toEqual('-10px'); - }); + component['configureVirtualScrollOverflow'](); - it('inverseOfTranslation: should return "-0px" if viewPort or _renderedContentOffset are not set', () => { - component.viewPort = null; + expect(mockContentWrapper.style.contain).toBe('layout style'); + expect(mockHeaderContainer.style.overflow).toBe('hidden'); + expect(component['scrollSyncListener']).toBeTruthy(); + expect(component['virtualScrollOverflowConfigured']).toBe(true); + }); - const resultado1 = component.inverseOfTranslation; - expect(resultado1).toEqual('-0px'); + it('configureVirtualScrollOverflow: should not apply styles when tableVirtualScroll is not available', () => { + component.tableVirtualScroll = null; + component['virtualScrollOverflowConfigured'] = false; - component.viewPort = { _renderedContentOffset: null } as any; + component['configureVirtualScrollOverflow'](); - const resultado2 = component.inverseOfTranslation; - expect(resultado2).toEqual('-0px'); + expect(component['virtualScrollOverflowConfigured']).toBe(false); }); - it('should update filteredItems on onFilteredItemsChange call', () => { - component.items = [ - { id: 1, name: 'item1' }, - { id: 2, name: 'item2' } - ]; + it('syncHeaderScrollLeft: should set scrollLeft on headerScrollContainer when nativeElement is available', () => { + const mockHeaderContainer = {} as any; + let assignedScrollLeft = 0; + Object.defineProperty(mockHeaderContainer, 'scrollLeft', { + get: () => assignedScrollLeft, + set: (v: number) => { + assignedScrollLeft = v; + }, + configurable: true + }); + component.headerScrollContainer = { nativeElement: mockHeaderContainer } as any; - component.onFilteredItemsChange(items); + component['syncHeaderScrollLeft'](150); - expect(component.filteredItems).toBe(items); + expect(mockHeaderContainer.scrollLeft).toBe(150); }); - it('onFilteredItemsChange should call `sortArray` if exist sortedColumn', () => { - component.sortedColumn = { - property: { property: 'name' }, - ascending: true - }; - component.items = [ - { id: 2, name: 'item2' }, - { id: 1, name: 'item1' } - ]; - - spyOn(component, 'sortArray'); - component.onFilteredItemsChange(items); + it('syncHeaderScrollLeft: should not throw when headerScrollContainer is null', () => { + component.headerScrollContainer = null; - expect(component.sortArray).toHaveBeenCalled(); + expect(() => component['syncHeaderScrollLeft'](100)).not.toThrow(); }); - }); - }); - describe('Templates:', () => { - it('shouldn`t contain `po-tooltip` class if link is disabled', fakeAsync(() => { - const mouseEnterEvent = new Event('mouseenter', { bubbles: true }); - component.columns = [ - { property: 'link', label: 'linkTest', type: 'link', tooltip: 'tooltipTest', disabled: () => true } - ]; - component.items = [{ link: 'tooltipTest' }]; - fixture.detectChanges(); + it('registerScrollSyncListeners: viewport scroll callback should call syncHeaderScrollLeft with viewport scrollLeft', () => { + const mockViewportEl = document.createElement('cdk-virtual-scroll-viewport'); + Object.defineProperty(mockViewportEl, 'scrollLeft', { value: 75, writable: true, configurable: true }); + component['scrollSyncListener'] = null; + component['containerScrollSyncListener'] = null; - const columnLink = nativeElement.querySelector('.po-table-link-disabled'); - columnLink.dispatchEvent(mouseEnterEvent); - fixture.detectChanges(); - tick(100); + const syncSpy = spyOn(component, 'syncHeaderScrollLeft'); - expect(nativeElement.querySelector('.po-tooltip')).toBeNull(); - })); + component['registerScrollSyncListeners'](mockViewportEl); - it('should contain `po-table-column-detail-toggle` class if tableRowTemplate exists', () => { - component.items = items; - component.columns = columns; + // Dispara o evento de scroll no viewport para acionar o listener nativo registrado + mockViewportEl.dispatchEvent(new Event('scroll')); - component.tableRowTemplate = mockTableDetailDiretive; + expect(syncSpy).toHaveBeenCalledWith(75); + }); - fixture.detectChanges(); - const poTableColumnDetailToggle = nativeElement.querySelector('.po-table-column-detail-toggle'); + it('registerScrollSyncListeners: container scroll callback should call syncHeaderScrollLeft with container scrollLeft', () => { + const fixedInnerContainer = document.createElement('div'); + fixedInnerContainer.classList.add('po-table-container-fixed-inner'); + Object.defineProperty(fixedInnerContainer, 'scrollLeft', { value: 200, writable: true, configurable: true }); + const mockViewportEl = document.createElement('cdk-virtual-scroll-viewport'); + fixedInnerContainer.appendChild(mockViewportEl); + document.body.appendChild(fixedInnerContainer); - expect(poTableColumnDetailToggle).toBeTruthy(); - }); + component['scrollSyncListener'] = null; + component['containerScrollSyncListener'] = null; - it('shouldn`t contain `po-table-column-detail-toggle` class if tableRowTemplate is undefined', () => { - component.items = items; - component.columns = columns; + const syncSpy = spyOn(component, 'syncHeaderScrollLeft'); - component.tableRowTemplate = undefined; + component['registerScrollSyncListeners'](mockViewportEl); - fixture.detectChanges(); + // Dispara o evento de scroll no container para acionar o listener nativo registrado + fixedInnerContainer.dispatchEvent(new Event('scroll')); - const poTableColumnDetailToggle = nativeElement.querySelector('.po-table-column-detail-toggle'); + expect(syncSpy).toHaveBeenCalledWith(200); - expect(poTableColumnDetailToggle).toBeFalsy(); - }); + document.body.removeChild(fixedInnerContainer); + }); - it('should show loading on table', () => { - component.loading = true; + it('syncColumnWidths: should not apply styles when header or body table is not available', () => { + component.headerTableElement = null; + component.bodyTableElement = null; - fixture.detectChanges(); + expect(() => component['syncColumnWidths']()).not.toThrow(); + }); - expect(nativeElement.querySelector('div.po-table-container-sticky')).toBeTruthy(); - expect(nativeElement.querySelector('po-loading-overlay')).toBeTruthy(); - }); + 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); - it('shouldn`t show loading on table', () => { - component.loading = false; + const mockBodyTable = document.createElement('table'); + const mockTbody = document.createElement('tbody'); + mockBodyTable.appendChild(mockTbody); - fixture.detectChanges(); + component.headerTableElement = { nativeElement: mockHeaderTable } as any; + component.bodyTableElement = { nativeElement: mockBodyTable } as any; - expect(nativeElement.querySelector('div.po-table-container-sticky')).toBeFalsy(); - expect(nativeElement.querySelector('po-loading-overlay')).toBeFalsy(); - }); + expect(() => component['syncColumnWidths']()).not.toThrow(); + }); - it('should show td with `po-table-column-actions` class if has more than 1 action', () => { - component.actions = actions; - fixture.detectChanges(); + it('syncColumnWidths: should not apply styles when cells are empty', () => { + const mockHeaderTable = document.createElement('table'); + const mockThead = document.createElement('thead'); + mockHeaderTable.appendChild(mockThead); - expect(tableElement.querySelector('.po-table-column-actions')).toBeTruthy(); - }); + const mockBodyTable = document.createElement('table'); + const mockTbody = document.createElement('tbody'); + const mockTr = document.createElement('tr'); + mockTbody.appendChild(mockTr); + mockBodyTable.appendChild(mockTbody); - it('should show detail selectable if detail hideSelect is undefined', () => { - component.columns = columnsWithDetailInterface; - component.selectable = true; - component.hideDetail = false; - component.items[0].$showDetail = true; - fixture.detectChanges(); + component.headerTableElement = { nativeElement: mockHeaderTable } as any; + component.bodyTableElement = { nativeElement: mockBodyTable } as any; - const details = tableElement.querySelector('.po-table-column-master-detail-space-checkbox'); + expect(() => component['syncColumnWidths']()).not.toThrow(); + }); - expect(details).toBeTruthy(); - }); + it('clearColumnWidths: should reset table layout on both tables, remove sync colgroup and reset computedColumnWidths', () => { + const mockHeaderTable = document.createElement('table'); + mockHeaderTable.style.tableLayout = 'fixed'; + mockHeaderTable.style.width = '500px'; + mockHeaderTable.style.minWidth = '500px'; + const headerColgroup = document.createElement('colgroup'); + headerColgroup.setAttribute('data-po-sync', 'true'); + mockHeaderTable.appendChild(headerColgroup); + const mockThead = document.createElement('thead'); + const mockHeaderTr = document.createElement('tr'); + const mockTh = document.createElement('th'); + mockTh.style.width = '200px'; + mockTh.style.minWidth = '200px'; + mockTh.style.maxWidth = '200px'; + mockHeaderTr.appendChild(mockTh); + mockThead.appendChild(mockHeaderTr); + mockHeaderTable.appendChild(mockThead); + + const mockBodyTable = document.createElement('table'); + mockBodyTable.style.tableLayout = 'fixed'; + mockBodyTable.style.width = '500px'; + mockBodyTable.style.minWidth = '500px'; + const bodyColgroup = document.createElement('colgroup'); + bodyColgroup.setAttribute('data-po-sync', 'true'); + mockBodyTable.appendChild(bodyColgroup); + const mockTbody = document.createElement('tbody'); + const mockTr = document.createElement('tr'); + const mockTd = document.createElement('td'); + mockTd.style.width = '200px'; + 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; + component.computedColumnWidths = ['200px']; + component['columnWidthsSynced'] = true; + + component['clearColumnWidths'](); + + // resetTableLayout remove table-layout, width, min-width e colgroup das duas tabelas + expect(mockHeaderTable.style.tableLayout).toBe(''); + expect(mockHeaderTable.style.width).toBe(''); + expect(mockHeaderTable.style.minWidth).toBe(''); + expect(mockHeaderTable.querySelector('colgroup[data-po-sync="true"]')).toBeNull(); + expect(mockBodyTable.style.tableLayout).toBe(''); + expect(mockBodyTable.style.width).toBe(''); + expect(mockBodyTable.style.minWidth).toBe(''); + expect(mockBodyTable.querySelector('colgroup[data-po-sync="true"]')).toBeNull(); + // NÃO remove estilos inline de células individuais + expect(mockTh.style.width).toBe('200px'); + expect(mockTd.style.width).toBe('200px'); + expect(component.computedColumnWidths).toEqual([]); + expect(component['columnWidthsSynced']).toBe(false); + + document.body.removeChild(mockHeaderTable); + document.body.removeChild(mockBodyTable); + }); - it('should show detail selectable if detail hideSelect is false', () => { - component.columns = columnsWithDetailInterface; - component.columns[5].detail.hideSelect = false; - component.selectable = true; - component.hideDetail = false; - component.items[0].$showDetail = true; - fixture.detectChanges(); + it('clearColumnWidths: should not fail when tables are not available', () => { + component.headerTableElement = null; + component.bodyTableElement = null; - const details = tableElement.querySelector('.po-table-column-master-detail-space-checkbox'); + expect(() => component['clearColumnWidths']()).not.toThrow(); + }); - expect(details).toBeTruthy(); - }); + 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); - it('shouldn`t show detail selectable if detail hideSelect is true', () => { - component.columns = columnsWithDetailInterface; - component.columns[5].detail.hideSelect = true; - component.selectable = true; - component.hideDetail = false; - component.items[0].$showDetail = true; - fixture.detectChanges(); + const mockBodyTable = document.createElement('table'); - const details = tableElement.querySelector('.po-table-column-master-detail-space-checkbox'); + component.headerTableElement = { nativeElement: mockHeaderTable } as any; + component.bodyTableElement = { nativeElement: mockBodyTable } as any; - expect(details).toBeNull(); - }); + expect(() => component['clearColumnWidths']()).not.toThrow(); + }); - it('should show detail selectable if not have a detail interface', () => { - component.columns = columnsWithDetail; - component.selectable = true; - component.hideDetail = false; - component.items[0].$showDetail = true; - fixture.detectChanges(); + it('applyColgroup: should create a colgroup with col elements matching widths array', () => { + const table = document.createElement('table'); + document.body.appendChild(table); - const details = tableElement.querySelector('.po-table-column-master-detail-space-checkbox'); + component['applyColgroup'](table, [100, 200, 300]); - expect(details).toBeTruthy(); - }); + const colgroup = table.querySelector('colgroup[data-po-sync="true"]'); + expect(colgroup).toBeTruthy(); + expect(colgroup.children.length).toBe(3); + expect((colgroup.children[0] as HTMLElement).style.width).toBe('100px'); + expect((colgroup.children[1] as HTMLElement).style.width).toBe('200px'); + expect((colgroup.children[2] as HTMLElement).style.width).toBe('300px'); - it('should have only one action', () => { - component.actions = singleAction; - component.firstAction.icon = undefined; - fixture.detectChanges(); + document.body.removeChild(table); + }); - const actionsColumn = tableElement.querySelector('.po-table-actions'); - expect(actionsColumn).toBeFalsy(); + it('applyColgroup: should replace existing colgroup with same data-po-sync attribute', () => { + const table = document.createElement('table'); + document.body.appendChild(table); - const actionColumn = tableElement.querySelector('.po-table-column-single-action'); - const actionItem = actionColumn.querySelectorAll('.po-table-single-action'); - const iconActionItem = actionColumn.querySelector('.po-icon'); + component['applyColgroup'](table, [100]); + component['applyColgroup'](table, [200, 300]); - expect(actionItem.length).toBe(1); - expect(iconActionItem).toBeNull(); - }); + const colgroups = table.querySelectorAll('colgroup[data-po-sync="true"]'); + expect(colgroups.length).toBe(1); + expect(colgroups[0].children.length).toBe(2); - it('should have only one action with icon', () => { - singleAction[0].icon = 'po-icon-news'; - component.actions = singleAction; + document.body.removeChild(table); + }); - fixture.detectChanges(); + it('applyColgroup: should insert colgroup before any other table children', () => { + const table = document.createElement('table'); + const thead = document.createElement('thead'); + table.appendChild(thead); + document.body.appendChild(table); - const actionsColumn = tableElement.querySelector('.po-table-actions'); - expect(actionsColumn).toBeFalsy(); + component['applyColgroup'](table, [100]); - const actionColumn = tableElement.querySelector('.po-table-column-single-action'); - const actionItem = actionColumn.querySelectorAll('.po-table-single-action'); - const iconActionItem = actionColumn.querySelector('.po-icon'); + expect(table.firstElementChild.tagName).toBe('COLGROUP'); - expect(actionItem.length).toBe(1); - expect(iconActionItem).toBeTruthy(); - }); + document.body.removeChild(table); + }); - it('should show td with no columns message if doesn`t have columns', () => { - const noColumnsMessage = component.literals.noColumns; + it('removeColgroup: should remove colgroup with data-po-sync="true" attribute', () => { + const table = document.createElement('table'); + const colgroup = document.createElement('colgroup'); + colgroup.setAttribute('data-po-sync', 'true'); + table.appendChild(colgroup); + document.body.appendChild(table); - component.items = []; - component.columns = []; + component['removeColgroup'](table); - fixture.detectChanges(); + expect(table.querySelector('colgroup')).toBeNull(); - expect(tableElement.querySelector('.po-table-header-column').innerHTML.includes(noColumnsMessage)).toBe(true); - }); + document.body.removeChild(table); + }); - it('shouldn`t display action if it is single and `visible` is `false`.', () => { - component.actions = [{ label: 'PO ', type: 'color-07', visible: false }]; + it('removeColgroup: should not remove colgroup without data-po-sync attribute', () => { + const table = document.createElement('table'); + const colgroup = document.createElement('colgroup'); + table.appendChild(colgroup); + document.body.appendChild(table); - fixture.detectChanges(); + component['removeColgroup'](table); - expect(nativeElement.querySelector('.po-table-single-action')).toBeNull(); - }); + expect(table.querySelector('colgroup')).toBeTruthy(); - it('shouldn`t display `po-container` class if container is undefined.', () => { - expect(nativeElement.querySelector('.po-container')).toBeFalsy(); - }); + document.body.removeChild(table); + }); - it('should display `po-container` class if container is `border`.', fakeAsync(() => { - component.container = 'border'; - fixture.detectChanges(); + it('removeColgroup: should not throw when no colgroup exists in table', () => { + const table = document.createElement('table'); - tick(1200); + expect(() => component['removeColgroup'](table)).not.toThrow(); + }); - expect(nativeElement.querySelector('.po-container')).toBeTruthy(); - })); + it('clearColumnWidths: should preserve inline width styles on body cells', () => { + const mockHeaderTable = document.createElement('table'); + mockHeaderTable.innerHTML = ''; - it('should display `po-container` and `po-container-no-shadow` class if container is `shadow`.', fakeAsync(() => { - component.container = 'shadow'; - fixture.detectChanges(); + const mockBodyTable = document.createElement('table'); + mockBodyTable.innerHTML = ''; - tick(1200); + component.headerTableElement = { nativeElement: mockHeaderTable } as any; + component.bodyTableElement = { nativeElement: mockBodyTable } as any; - expect(nativeElement.querySelector('.po-container')).toBeTruthy(); - })); + component['clearColumnWidths'](); - it('shouldn`t find .po-table-header-column-manager-button if hasn`t columns and items', () => { - component.items = undefined; - component.columns = undefined; + const bodyTd = mockBodyTable.querySelector('td') as HTMLElement; + expect(bodyTd.style.width).toBe('100px'); + }); - fixture.detectChanges(); + it('clearColumnWidths: should remove only colgroup with data-po-sync attribute from body table', () => { + const mockBodyTable = document.createElement('table'); + const colgroupSync = document.createElement('colgroup'); + colgroupSync.setAttribute('data-po-sync', 'true'); + const colgroupOther = document.createElement('colgroup'); + mockBodyTable.appendChild(colgroupSync); + mockBodyTable.appendChild(colgroupOther); + document.body.appendChild(mockBodyTable); - expect(nativeElement.querySelector('.po-table-header-column-manager-button')).toBe(null); - }); + component.headerTableElement = null; + component.bodyTableElement = { nativeElement: mockBodyTable } as any; - it('shouldn`t find .po-table-header-column-manager-button if has only type detail columns', () => { - component.columns = [...columnsDetail]; + component['clearColumnWidths'](); - fixture.detectChanges(); + expect(mockBodyTable.querySelector('colgroup[data-po-sync="true"]')).toBeNull(); + expect(mockBodyTable.querySelectorAll('colgroup').length).toBe(1); - expect(nativeElement.querySelector('.po-table-header-column-manager-button')).toBe(null); - }); + document.body.removeChild(mockBodyTable); + }); - it('should display one icon.', () => { - component.items = [{ po: 'favorite' }]; - component.columns = [columnIcons]; + it('setupColumnWidthSync: should return early when virtualScroll is false', () => { + component.virtualScroll = false; + component['resizeObserver'] = undefined; - fixture.detectChanges(); + component['setupColumnWidthSync'](); - expect(nativeElement.querySelectorAll(`po-table-column-icon po-table-icon`).length).toBe(1); - }); + expect(component['resizeObserver']).toBeUndefined(); + }); - it('should display two icons.', () => { - component.items = [{ po: ['favorite', 'documentation'] }]; - component.columns = [columnIcons]; + it('setupColumnWidthSync: should return early when resizeObserver is already configured', () => { + component.virtualScroll = true; + const existingObserver = { observe: () => {}, disconnect: () => {}, unobserve: () => {} } as any; + component['resizeObserver'] = existingObserver; - fixture.detectChanges(); + component['setupColumnWidthSync'](); - expect(nativeElement.querySelectorAll(`po-table-column-icon po-table-icon`).length).toBe(2); - }); + expect(component['resizeObserver']).toBe(existingObserver); + }); - it('should not display po-table-column-manager', () => { - component.hideColumnsManager = true; - fixture.detectChanges(); + describe('syncColumnWidths (updated behavior):', () => { + let syncMockHeaderTable: HTMLElement; + let syncMockBodyTable: HTMLElement; + + function setupSyncTables(headerCellsCount: number, bodyCellsCount: number) { + syncMockHeaderTable = document.createElement('table'); + const thead = document.createElement('thead'); + const headerTr = document.createElement('tr'); + for (let i = 0; i < headerCellsCount; i++) { + const th = document.createElement('th'); + if (i >= 2) th.classList.add('po-table-header-ellipsis'); + else if (i === 0) th.classList.add('po-table-column-selectable'); + else th.classList.add('po-table-header-master-detail'); + headerTr.appendChild(th); + } + thead.appendChild(headerTr); + syncMockHeaderTable.appendChild(thead); + + syncMockBodyTable = document.createElement('table'); + const tbody = document.createElement('tbody'); + const bodyTr = document.createElement('tr'); + bodyTr.classList.add('po-table-row'); + for (let i = 0; i < bodyCellsCount; i++) { + const td = document.createElement('td'); + if (i >= 2) td.classList.add('p-element'); + bodyTr.appendChild(td); + } + tbody.appendChild(bodyTr); + syncMockBodyTable.appendChild(tbody); - expect(nativeElement.querySelector(`po-table-column-manager`)).toBe(null); - }); + document.body.appendChild(syncMockHeaderTable); + document.body.appendChild(syncMockBodyTable); - it('should call attr-p-spacing `medium` if p-spacing not set', () => { - localStorage.removeItem('po-default-size'); - component.componentsSize = undefined; - component.spacing = undefined; - component.columns = [...columnsWithDetail]; + component.headerTableElement = { nativeElement: syncMockHeaderTable } as any; + component.bodyTableElement = { nativeElement: syncMockBodyTable } as any; + } - fixture.detectChanges(); + afterEach(() => { + if (syncMockHeaderTable?.parentNode) { + document.body.removeChild(syncMockHeaderTable); + } + if (syncMockBodyTable?.parentNode) { + document.body.removeChild(syncMockBodyTable); + } + }); - expect(nativeElement.querySelector('[p-spacing="medium"]')).toBeTruthy(); - }); + it('should run sync regardless of applyFixedColumns return value', () => { + setupSyncTables(5, 5); + spyOn(component, 'applyFixedColumns').and.returnValue(true); - it('should call attr-p-spacing `small` if p-spacing is `small` and row is not interactive', () => { - component.columns = [{ property: 'name' }, { property: 'age' }]; - component.spacing = PoTableColumnSpacing.Small; - fixture.detectChanges(); + component['syncColumnWidths'](); - expect(nativeElement.querySelector('[p-spacing="small"]')).toBeTruthy(); - }); + expect(syncMockHeaderTable.style.tableLayout).toBe('fixed'); + }); - it('should display .po-table-header-master-detail if columns contains detail and rowTemplate is undefined', () => { - component.items = [...items]; - component.columns = [...columnsWithDetail]; + it('should apply colgroup on both header and body tables', () => { + setupSyncTables(5, 5); - component.tableRowTemplate = undefined; + component['syncColumnWidths'](); - fixture.detectChanges(); + expect(syncMockHeaderTable.querySelector('colgroup[data-po-sync="true"]')).toBeTruthy(); + expect(syncMockBodyTable.querySelector('colgroup[data-po-sync="true"]')).toBeTruthy(); + }); - expect(nativeElement.querySelector(`th.po-table-header-master-detail`)).toBeTruthy(); - }); + it('should apply table-layout fixed on both header and body tables', () => { + setupSyncTables(5, 5); - it(`shouldn't display .po-table-header-master-detail if columns contains detail - and rowTemplate but hideColumnsManager is false`, () => { - component.items = [...items]; - component.columns = [...columnsWithDetail]; - component.actions = []; - component.hideColumnsManager = false; + component['syncColumnWidths'](); + + expect(syncMockHeaderTable.style.tableLayout).toBe('fixed'); + expect(syncMockBodyTable.style.tableLayout).toBe('fixed'); + }); + + it('should set columnWidthsSynced to true after sync', () => { + setupSyncTables(5, 5); + + component['syncColumnWidths'](); + + expect(component['columnWidthsSynced']).toBe(true); + }); + + it('should set width and min-width on header table based on body table width', () => { + setupSyncTables(5, 5); + + component['syncColumnWidths'](); + + expect(syncMockHeaderTable.style.width).toMatch(/^\d+(\.\d+)?px$/); + expect(syncMockHeaderTable.style.minWidth).toBe(syncMockHeaderTable.style.width); + }); + + it('should not throw when body has no rendered rows', () => { + setupSyncTables(5, 0); + syncMockBodyTable.querySelector('tbody').innerHTML = ''; + + expect(() => component['syncColumnWidths']()).not.toThrow(); + }); + + it('should not throw when only no-data row is present in body', () => { + setupSyncTables(5, 5); + const tbody = syncMockBodyTable.querySelector('tbody'); + tbody.innerHTML = ''; + + expect(() => component['syncColumnWidths']()).not.toThrow(); + }); + + it('should call syncHeaderTableWidth and changeDetector markForCheck', () => { + setupSyncTables(5, 5); + const syncHeaderSpy = spyOn(component, 'syncHeaderTableWidth'); + const markForCheckSpy = spyOn(component['changeDetector'], 'markForCheck'); + + component['syncColumnWidths'](); + + expect(syncHeaderSpy).toHaveBeenCalled(); + expect(markForCheckSpy).toHaveBeenCalled(); + }); + + it('should match number of columns in colgroup with header cells count', () => { + setupSyncTables(5, 5); + + component['syncColumnWidths'](); + + const colgroup = syncMockHeaderTable.querySelector('colgroup[data-po-sync="true"]'); + expect(colgroup.children.length).toBe(5); + }); + + it('should not apply styles when headerTableElement is null', () => { + component.headerTableElement = null; + component.bodyTableElement = { nativeElement: document.createElement('table') } as any; + + expect(() => component['syncColumnWidths']()).not.toThrow(); + }); + + it('should not apply styles when bodyTableElement is null', () => { + component.headerTableElement = { nativeElement: document.createElement('table') } as any; + component.bodyTableElement = null; + + expect(() => component['syncColumnWidths']()).not.toThrow(); + }); + + it('should return when header row has no cells', () => { + const mockHeaderTable = document.createElement('table'); + mockHeaderTable.innerHTML = ''; + + const mockBodyTable = document.createElement('table'); + mockBodyTable.innerHTML = ''; + + document.body.appendChild(mockHeaderTable); + document.body.appendChild(mockBodyTable); + + component.headerTableElement = { nativeElement: mockHeaderTable } as any; + component.bodyTableElement = { nativeElement: mockBodyTable } as any; + + component['syncColumnWidths'](); + + expect(mockHeaderTable.querySelector('colgroup[data-po-sync="true"]')).toBeNull(); + expect(mockHeaderTable.style.tableLayout).toBe(''); + + document.body.removeChild(mockHeaderTable); + document.body.removeChild(mockBodyTable); + }); + + it('should apply colgroup even when no header cell has ellipsis class', () => { + const mockHeaderTable = document.createElement('table'); + mockHeaderTable.innerHTML = + ''; + + const mockBodyTable = document.createElement('table'); + mockBodyTable.innerHTML = + ''; + + document.body.appendChild(mockHeaderTable); + document.body.appendChild(mockBodyTable); + + component.headerTableElement = { nativeElement: mockHeaderTable } as any; + component.bodyTableElement = { nativeElement: mockBodyTable } as any; + + component['syncColumnWidths'](); + + expect(mockHeaderTable.querySelector('colgroup[data-po-sync="true"]')).toBeTruthy(); + expect(mockBodyTable.querySelector('colgroup[data-po-sync="true"]')).toBeTruthy(); + expect(component['columnWidthsSynced']).toBe(true); + + document.body.removeChild(mockHeaderTable); + document.body.removeChild(mockBodyTable); + }); + }); + + describe('syncColumnWidths (auxiliary columns coverage):', () => { + let auxMockHeaderTable: HTMLElement; + let auxMockBodyTable: HTMLElement; + + function createFullVirtualScrollMock(columnsConfig: Array<{ width?: string }>) { + auxMockHeaderTable = document.createElement('table'); + let headerHtml = ''; + headerHtml += ''; + headerHtml += ''; + columnsConfig.forEach(col => { + const widthStyle = col.width ? `style="width:${col.width}"` : ''; + headerHtml += ``; + }); + headerHtml += ''; + auxMockHeaderTable.innerHTML = headerHtml; + + auxMockBodyTable = document.createElement('table'); + let bodyHtml = ''; + bodyHtml += ''; + bodyHtml += ''; + columnsConfig.forEach(col => { + const widthStyle = col.width ? `style="width:${col.width}"` : ''; + bodyHtml += ``; + }); + bodyHtml += ''; + auxMockBodyTable.innerHTML = bodyHtml; + + document.body.appendChild(auxMockHeaderTable); + document.body.appendChild(auxMockBodyTable); + + component.headerTableElement = { nativeElement: auxMockHeaderTable } as any; + component.bodyTableElement = { nativeElement: auxMockBodyTable } as any; + } + + afterEach(() => { + if (auxMockHeaderTable?.parentNode) { + document.body.removeChild(auxMockHeaderTable); + } + if (auxMockBodyTable?.parentNode) { + document.body.removeChild(auxMockBodyTable); + } + }); + + it('should include auxiliary columns when applying colgroup on header', () => { + createFullVirtualScrollMock([{ width: '100px' }, { width: '200px' }, { width: '200px' }]); + + component['syncColumnWidths'](); + + const colgroup = auxMockHeaderTable.querySelector('colgroup[data-po-sync="true"]'); + expect(colgroup).toBeTruthy(); + expect(colgroup.children.length).toBe(5); + }); + + it('should apply colgroup even when applyFixedColumns returns true', () => { + createFullVirtualScrollMock([{ width: '100px' }, { width: '200px' }]); + spyOn(component, 'applyFixedColumns').and.returnValue(true); + + component['syncColumnWidths'](); + + expect(auxMockHeaderTable.querySelector('colgroup[data-po-sync="true"]')).toBeTruthy(); + }); + + it('should include label-type columns without width in colgroup', () => { + createFullVirtualScrollMock([{ width: '100px' }, { width: '200px' }, {}]); + + component['syncColumnWidths'](); + + const colgroup = auxMockHeaderTable.querySelector('colgroup[data-po-sync="true"]'); + expect(colgroup.children.length).toBe(5); + }); + + it('should set columnWidthsSynced to true after sync with auxiliary columns', () => { + createFullVirtualScrollMock([{ width: '100px' }, { width: '200px' }, { width: '200px' }]); + + component['syncColumnWidths'](); + + expect(component['columnWidthsSynced']).toBe(true); + }); + + it('should apply table-layout fixed and width on body table', () => { + createFullVirtualScrollMock([{ width: '100px' }, { width: '200px' }]); + + component['syncColumnWidths'](); + + expect(auxMockBodyTable.style.tableLayout).toBe('fixed'); + expect(auxMockBodyTable.style.width).toMatch(/^\d+(\.\d+)?px$/); + expect(auxMockBodyTable.style.minWidth).toMatch(/^\d+(\.\d+)?px$/); + }); + }); + + 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('drop: should schedule syncColumnWidths via else branch when hideColumnsManager is true and virtualScroll is active', fakeAsync(() => { + const event = { previousIndex: 0, currentIndex: 1 }; + + component.mainColumns = [{ property: 'column1' }, { property: 'column2' }]; + component['_virtualScroll'] = true; + component.hideColumnsManager = true; + component.computedColumnWidths = ['100px', '200px']; + component['resizeObserver']?.disconnect(); + component['resizeObserver'] = jasmine.createSpyObj('ResizeObserver', ['observe', 'disconnect', 'unobserve']); + spyOn(component, 'clearColumnWidths'); + const syncSpy = spyOn(component, 'syncColumnWidths'); + const visibleSpy = spyOn(component, 'onVisibleColumnsChange'); + + component.drop(event as any); + + expect(visibleSpy).not.toHaveBeenCalled(); + expect(syncSpy).not.toHaveBeenCalled(); + + tick(0); + + expect(syncSpy).toHaveBeenCalledTimes(1); + })); + + it('drop: should not schedule syncColumnWidths twice when hideColumnsManager is false and virtualScroll is active', fakeAsync(() => { + const event = { previousIndex: 0, currentIndex: 1 }; + + component.mainColumns = [{ property: 'column1' }, { property: 'column2' }]; + component['_virtualScroll'] = true; + component.hideColumnsManager = false; + component.computedColumnWidths = ['100px', '200px']; + component['resizeObserver']?.disconnect(); + component['resizeObserver'] = jasmine.createSpyObj('ResizeObserver', ['observe', 'disconnect', 'unobserve']); + spyOn(component, 'clearColumnWidths'); + const syncSpy = spyOn(component, 'syncColumnWidths'); + + component.drop(event as any); + + tick(0); + + expect(syncSpy).toHaveBeenCalledTimes(1); + })); + + 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 viewport has rendered rows', () => { + component.height = 400; + component.virtualScroll = true; + component.items = [{ id: 1 }]; + component.computedColumnWidths = []; + component.viewPort = { getRenderedRange: () => ({ start: 0, end: 1 }) } as any; + component.tableVirtualScroll = null; + component['virtualScrollOverflowConfigured'] = true; + component['resizeObserver'] = { observe: () => {}, disconnect: () => {}, unobserve: () => {} }; + component['syncScheduled'] = false; + const syncSpy = spyOn(component, 'syncColumnWidths'); + spyOn(window, 'requestAnimationFrame').and.callFake((cb: FrameRequestCallback) => { + cb(0); + return 0; + }); + + component.ngAfterViewChecked(); + + expect(syncSpy).toHaveBeenCalled(); + expect(component['syncScheduled']).toBe(false); + }); + + it('ngAfterViewChecked: should not schedule syncColumnWidths when sync is already scheduled', () => { + component.height = 400; + component.virtualScroll = true; + component.items = [{ id: 1 }]; + component.computedColumnWidths = []; + component.viewPort = { getRenderedRange: () => ({ start: 0, end: 1 }) } as any; + component.tableVirtualScroll = null; + component['virtualScrollOverflowConfigured'] = true; + component['resizeObserver'] = { observe: () => {}, disconnect: () => {}, unobserve: () => {} }; + component['syncScheduled'] = true; + const syncSpy = spyOn(component, 'syncColumnWidths'); + const rafSpy = spyOn(window, 'requestAnimationFrame'); + + component.ngAfterViewChecked(); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(syncSpy).not.toHaveBeenCalled(); + }); + + it('ngAfterViewChecked: should update heightTableVirtual when header height changes in virtual scroll', () => { + component.height = 400; + component.virtualScroll = true; + component.heightTableContainer = 500; + component['lastHeaderHeight'] = 0; + component.heightTableVirtual = 0; + component.headerScrollContainer = { nativeElement: { offsetHeight: 40 } } as any; + component['virtualScrollOverflowConfigured'] = true; + component['resizeObserver'] = { observe: () => {}, disconnect: () => {}, unobserve: () => {} }; + spyOn(component['changeDetector'], 'markForCheck'); + spyOn(window, 'requestAnimationFrame').and.callFake((cb: FrameRequestCallback) => { + cb(0); + return 0; + }); + + component.ngAfterViewChecked(); + + expect(component['lastHeaderHeight']).toBe(40); + expect(component.heightTableVirtual).toBe(460); + expect(component['changeDetector'].markForCheck).toHaveBeenCalled(); + }); + + it('ngAfterViewChecked: should not update heightTableVirtual when header height is unchanged', () => { + component.height = 400; + component.virtualScroll = true; + component.heightTableContainer = 500; + component['lastHeaderHeight'] = 40; + component.headerScrollContainer = { nativeElement: { offsetHeight: 40 } } as any; + component['virtualScrollOverflowConfigured'] = true; + component['resizeObserver'] = { observe: () => {}, disconnect: () => {}, unobserve: () => {} }; + const rafSpy = spyOn(window, 'requestAnimationFrame'); + + component.ngAfterViewChecked(); + + expect(rafSpy).not.toHaveBeenCalled(); + }); + + 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('should update filteredItems on onFilteredItemsChange call', () => { + component.items = [ + { id: 1, name: 'item1' }, + { id: 2, name: 'item2' } + ]; + + component.onFilteredItemsChange(items); + + expect(component.filteredItems).toBe(items); + }); + + it('onFilteredItemsChange should call `sortArray` if exist sortedColumn', () => { + component.sortedColumn = { + property: { property: 'name' }, + ascending: true + }; + component.items = [ + { id: 2, name: 'item2' }, + { id: 1, name: 'item1' } + ]; + + spyOn(component, 'sortArray'); + component.onFilteredItemsChange(items); + + expect(component.sortArray).toHaveBeenCalled(); + }); + }); + + describe('Column width distribution methods:', () => { + it('measureNaturalColumnWidths: should measure max width across all body rows', () => { + const headerTable = document.createElement('table'); + headerTable.innerHTML = 'Header'; + + const bodyTable = document.createElement('table'); + bodyTable.innerHTML = ` + Short + A much longer text value + `; + + document.body.appendChild(headerTable); + document.body.appendChild(bodyTable); + + component.headerTableElement = { nativeElement: headerTable } as any; + component.bodyTableElement = { nativeElement: bodyTable } as any; + + const headerCells: Array = Array.from(headerTable.querySelectorAll('thead > tr > th')); + const bodyCells: Array = Array.from(bodyTable.querySelectorAll('tbody tr.po-table-row > td')); + + const widths = component['measureNaturalColumnWidths'](headerTable, bodyTable, headerCells, bodyCells, 1); + + expect(widths.length).toBe(1); + expect(widths[0]).toBeGreaterThanOrEqual(0); + + document.body.removeChild(headerTable); + document.body.removeChild(bodyTable); + }); + + it('measureNaturalColumnWidths: should set tables to max-content during measurement', () => { + const headerTable = document.createElement('table'); + headerTable.innerHTML = 'Col'; + + const bodyTable = document.createElement('table'); + bodyTable.innerHTML = 'Data'; + + document.body.appendChild(headerTable); + document.body.appendChild(bodyTable); + + component.headerTableElement = { nativeElement: headerTable } as any; + component.bodyTableElement = { nativeElement: bodyTable } as any; + + const headerCells: Array = Array.from(headerTable.querySelectorAll('th')); + const bodyCells: Array = Array.from(bodyTable.querySelectorAll('tr > td')); + + component['measureNaturalColumnWidths'](headerTable, bodyTable, headerCells, bodyCells, 1); + + // Após measureNaturalColumnWidths, as tabelas ficam em max-content (resetadas depois por applySharedColumnLayout) + expect(headerTable.style.width).toBe('max-content'); + expect(bodyTable.style.width).toBe('max-content'); + + document.body.removeChild(headerTable); + document.body.removeChild(bodyTable); + }); + + it('distributeColumnWidths: should distribute extra width among elastic columns', () => { + const headerCells = [ + createMockCell('po-table-header-ellipsis'), + createMockCell('po-table-header-ellipsis'), + createMockCell('po-table-header-ellipsis') + ]; + + component.mainColumns = [{ property: 'col1', width: '100px' }, { property: 'col2' }, { property: 'col3' }]; + component.tableVirtualScroll = { nativeElement: createMockViewport(600) } as any; + + const naturalWidths = [100, 50, 50]; + const result = component['distributeColumnWidths'](headerCells, naturalWidths); + + const total = result.reduce((s, w) => s + w, 0); + expect(total).toBe(600); + }); + + it('distributeColumnWidths: should not fill when content exceeds container', () => { + const headerCells = [createMockCell('po-table-header-ellipsis'), createMockCell('po-table-header-ellipsis')]; + + component.mainColumns = [ + { property: 'col1', width: '400px' }, + { property: 'col2', width: '400px' } + ]; + component.tableVirtualScroll = { nativeElement: createMockViewport(500) } as any; + + const naturalWidths = [400, 400]; + const result = component['distributeColumnWidths'](headerCells, naturalWidths); + + expect(result[0]).toBe(400); + expect(result[1]).toBe(400); + }); + + it('distributeColumnWidths: should distribute proportionally when all columns have width and extra space remains', () => { + const headerCells = [ + createMockCell('po-table-header-ellipsis'), + createMockCell('po-table-header-ellipsis'), + createMockCell('po-table-header-ellipsis') + ]; + + // Todas as colunas têm width definido → sem elásticas → cai no distributeProportionally + component.mainColumns = [ + { property: 'col1', width: '100px' }, + { property: 'col2', width: '200px' }, + { property: 'col3', width: '100px' } + ]; + component.tableVirtualScroll = { nativeElement: createMockViewport(600) } as any; + + const naturalWidths = [100, 200, 100]; // total = 400, container = 600, extra = 200 + const result = component['distributeColumnWidths'](headerCells, naturalWidths); + + const total = result.reduce((s, w) => s + w, 0); + expect(total).toBe(600); + // Distribuição proporcional: col2 (200) recebe mais que col1/col3 (100) + expect(result[1]).toBeGreaterThan(result[0]); + }); + + it('distributeProportionally: should distribute extra width proportionally among data columns', () => { + const widths = [100, 200, 300]; + const dataIndexes = [0, 1, 2]; + + component['distributeProportionally'](widths, dataIndexes, 60); + + expect(widths[0]).toBe(110); + expect(widths[1]).toBe(220); + expect(widths[2]).toBe(330); + }); + + it('distributeProportionally: should not modify widths when dataTotal is 0', () => { + const widths = [0, 0, 0]; + const dataIndexes = [0, 1, 2]; + + component['distributeProportionally'](widths, dataIndexes, 100); + + expect(widths[0]).toBe(0); + expect(widths[1]).toBe(0); + expect(widths[2]).toBe(0); + }); + + it('distributeAmong: should divide extra width equally among indexes', () => { + const widths = [100, 100, 100]; + const indexes = [0, 2]; + + component['distributeAmong'](widths, indexes, 60); + + expect(widths[0]).toBe(130); + expect(widths[1]).toBe(100); + expect(widths[2]).toBe(130); + }); + + it('distributeAmong: should not modify widths when indexes array is empty', () => { + const widths = [100, 200]; + + component['distributeAmong'](widths, [], 50); + + expect(widths[0]).toBe(100); + expect(widths[1]).toBe(200); + }); + + it('roundWidthsToTarget: should round widths so total equals target exactly', () => { + const widths = [33.3, 33.3, 33.4]; + const result = component['roundWidthsToTarget'](widths, 100, [0, 1, 2]); + + const total = result.reduce((s, w) => s + w, 0); + expect(total).toBe(100); + }); + + it('roundWidthsToTarget: should distribute remainder to adjustIndexes from end', () => { + const widths = [50.1, 50.1]; + const result = component['roundWidthsToTarget'](widths, 101, [0, 1]); + + expect(result[1]).toBeGreaterThanOrEqual(result[0]); + expect(result[0] + result[1]).toBe(101); + }); + + it('roundWidthsToTarget: should use all indexes as fallback when adjustIndexes is empty', () => { + const widths = [33.3, 33.3, 33.4]; + const result = component['roundWidthsToTarget'](widths, 100, []); + + const total = result.reduce((s, w) => s + w, 0); + expect(total).toBe(100); + }); + + it('getColumnWidth: should return "auto" when column.width is a percentage string', () => { + const result = component.getColumnWidth({ width: '30%' }, 0); + + expect(result).toBe('auto'); + }); + + it('getColumnWidth: should return "auto" when computedColumnWidths is a percentage string', () => { + component.computedColumnWidths = ['40%']; + + const result = component.getColumnWidth({}, 0); + + expect(result).toBe('auto'); + }); + + it('getColumnWidth: should return the literal value when width is in px', () => { + const result = component.getColumnWidth({ width: '200px' }, 0); + + expect(result).toBe('200px'); + }); + + it('getColumnWidth: should return undefined when there is no width for the column', () => { + const result = component.getColumnWidth({}, 0); + + expect(result).toBeUndefined(); + }); + + it('getColumnIndexes: should identify data columns and elastic columns', () => { + const headerCells = [ + createMockCell('po-table-column-selectable'), + createMockCell('po-table-header-ellipsis'), + createMockCell('po-table-header-ellipsis'), + createMockCell('po-table-header-ellipsis') + ]; + + component.mainColumns = [ + { property: 'col1', width: '100px' }, + { property: 'col2' }, + { property: 'col3', width: '200px' } + ]; + + const { dataIndexes, elasticIndexes } = component['getColumnIndexes'](headerCells); + + expect(dataIndexes).toEqual([1, 2, 3]); + expect(elasticIndexes).toEqual([2]); + }); + + it('resolvePercentWidths: should resolve percentage widths against container', () => { + const headerCells = [createMockCell('po-table-header-ellipsis'), createMockCell('po-table-header-ellipsis')]; + + component.mainColumns = [ + { property: 'col1', width: '50%' }, + { property: 'col2', width: '50%' } + ]; + spyOn(component, 'applyFixedColumns').and.returnValue(true); + + const widths = [100, 100]; + component['resolvePercentWidths'](headerCells, widths, 800); + + expect(widths[0]).toBe(400); + expect(widths[1]).toBe(400); + }); + + it('resolvePercentWidths: should resolve percent widths for mixed columns (no fixed-columns guard)', () => { + const headerCells = [createMockCell('po-table-header-ellipsis'), createMockCell('po-table-header-ellipsis')]; + component.mainColumns = [{ property: 'col1', width: '50%' }, { property: 'col2' }]; + + const widths = [100, 200]; + component['resolvePercentWidths'](headerCells, widths, 800); + + expect(widths[0]).toBe(300); + expect(widths[1]).toBe(200); + }); + + it('resolvePercentWidths: should keep natural width when it is larger than the computed percent (floor)', () => { + const headerCells = [createMockCell('po-table-header-ellipsis'), createMockCell('po-table-header-ellipsis')]; + + component.mainColumns = [ + { property: 'col1', width: '10%' }, + { property: 'col2', width: '400px' } + ]; + + const widths = [500, 400]; + component['resolvePercentWidths'](headerCells, widths, 800); + + expect(widths[0]).toBe(500); + expect(widths[1]).toBe(400); + }); + + it('resolvePercentWidths: should return early when no columns have percent width', () => { + const headerCells = [createMockCell('po-table-header-ellipsis'), createMockCell('po-table-header-ellipsis')]; + + // Todas com width em px → nenhuma coluna % → percentColumns vazio → return + component.mainColumns = [ + { property: 'col1', width: '200px' }, + { property: 'col2', width: '300px' } + ]; + spyOn(component, 'applyFixedColumns').and.returnValue(true); + + const widths = [200, 300]; + component['resolvePercentWidths'](headerCells, widths, 800); + + expect(widths[0]).toBe(200); + expect(widths[1]).toBe(300); + }); + + it('resolvePercentWidths: should not resolve when containerWidth is 0', () => { + const headerCells = [createMockCell('po-table-header-ellipsis')]; + component.mainColumns = [{ property: 'col1', width: '50%' }]; + spyOn(component, 'applyFixedColumns').and.returnValue(true); + + const widths = [100]; + component['resolvePercentWidths'](headerCells, widths, 0); + + expect(widths[0]).toBe(100); + }); + + it('resolvePercentWidths: should resolve percent widths considering non-percent columns total', () => { + const headerCells = [ + createMockCell('po-table-header-ellipsis'), + createMockCell('po-table-header-ellipsis'), + createMockCell('po-table-header-ellipsis') + ]; + + // col1 = px, col2 = %, col3 = px → nonPercentTotal = widths[0] + widths[2] + component.mainColumns = [ + { property: 'col1', width: '200px' }, + { property: 'col2', width: '50%' }, + { property: 'col3', width: '100px' } + ]; + spyOn(component, 'applyFixedColumns').and.returnValue(true); + + const widths = [200, 80, 100]; // nonPercentTotal = 200 + 100 = 300, available = 800 - 300 = 500 + component['resolvePercentWidths'](headerCells, widths, 800); + + // col2 com 50% de 500 available = 250 + expect(widths[0]).toBe(200); + expect(widths[1]).toBe(250); + expect(widths[2]).toBe(100); + }); + + it('resolvePercentWidths: should return early when available space is zero or negative', () => { + const headerCells = [createMockCell('po-table-header-ellipsis'), createMockCell('po-table-header-ellipsis')]; + + // col1 = px (ocupa tudo), col2 = % → available = 500 - 600 = -100 → return + component.mainColumns = [ + { property: 'col1', width: '600px' }, + { property: 'col2', width: '50%' } + ]; + spyOn(component, 'applyFixedColumns').and.returnValue(true); + + const widths = [600, 80]; + component['resolvePercentWidths'](headerCells, widths, 500); + + // Não modifica — available <= 0 + expect(widths[0]).toBe(600); + expect(widths[1]).toBe(80); + }); + + function createMockCell(...classes: Array): HTMLElement { + const cell = document.createElement('th'); + classes.forEach(c => cell.classList.add(c)); + return cell; + } + + function createMockViewport(clientWidth: number): HTMLElement { + const el = document.createElement('div'); + Object.defineProperty(el, 'clientWidth', { value: clientWidth, configurable: true }); + return el; + } + + function buildSyncTables() { + const headerTable = document.createElement('table'); + headerTable.innerHTML = + ''; + const bodyTable = document.createElement('table'); + bodyTable.innerHTML = ''; + document.body.appendChild(headerTable); + document.body.appendChild(bodyTable); + component.headerTableElement = { nativeElement: headerTable } as any; + component.bodyTableElement = { nativeElement: bodyTable } as any; + component.tableVirtualScroll = { nativeElement: createMockViewport(600) } as any; + return { headerTable, bodyTable }; + } + + // --- applyMonotonicElasticWidths --- + it('applyMonotonicElasticWidths: should not change widths when there are no elastic columns', () => { + spyOn(component, 'getColumnIndexes').and.returnValue({ dataIndexes: [1], elasticIndexes: [] }); + const natural = [56, 100, 56]; + + component['applyMonotonicElasticWidths']([], natural); + + expect(natural).toEqual([56, 100, 56]); + expect(component['elasticNaturalMaxWidths']).toEqual([]); + }); + + it('applyMonotonicElasticWidths: should grow the cache when the measured width is larger', () => { + spyOn(component, 'getColumnIndexes').and.returnValue({ dataIndexes: [1], elasticIndexes: [1] }); + component['elasticNaturalMaxWidths'] = []; + const natural = [56, 200, 56]; + + component['applyMonotonicElasticWidths']([], natural); + + expect(component['elasticNaturalMaxWidths'][1]).toBe(200); + expect(natural[1]).toBe(200); + }); + + it('applyMonotonicElasticWidths: should keep the cached width (never shrink) when measured is smaller', () => { + spyOn(component, 'getColumnIndexes').and.returnValue({ dataIndexes: [1], elasticIndexes: [1] }); + component['elasticNaturalMaxWidths'] = [0, 300, 0]; + const natural = [56, 200, 56]; + + component['applyMonotonicElasticWidths']([], natural); + + expect(natural[1]).toBe(300); + }); + + it('applyMonotonicElasticWidths: should reset the cache array when its length differs', () => { + spyOn(component, 'getColumnIndexes').and.returnValue({ dataIndexes: [1], elasticIndexes: [1] }); + component['elasticNaturalMaxWidths'] = [10, 20]; + const natural = [56, 200, 56]; + + component['applyMonotonicElasticWidths']([], natural); + + expect(component['elasticNaturalMaxWidths'].length).toBe(3); + expect(component['elasticNaturalMaxWidths'][1]).toBe(200); + }); + + // --- syncColumnWidths: guarda anti-loop / frozen --- + it('syncColumnWidths: should skip re-sync when synced, no fixed columns and nothing changed', () => { + const { headerTable, bodyTable } = buildSyncTables(); + component.viewPort = { getRenderedRange: () => ({ start: 0, end: 2 }) } as any; + spyOn(component, 'hasSomeFixed').and.returnValue(false); + component['columnWidthsSynced'] = true; + component['lastSyncViewportWidth'] = 600; + component['maxRenderedRangeEnd'] = 2; + const measureSpy = spyOn(component, 'measureNaturalColumnWidths').and.callThrough(); + + component['syncColumnWidths'](); + + expect(measureSpy).not.toHaveBeenCalled(); + + document.body.removeChild(headerTable); + document.body.removeChild(bodyTable); + }); + + it('syncColumnWidths: should NOT skip re-sync when there are fixed columns (frozen)', () => { + const { headerTable, bodyTable } = buildSyncTables(); + component.viewPort = { getRenderedRange: () => ({ start: 0, end: 2 }) } as any; + spyOn(component, 'hasSomeFixed').and.returnValue(true); + component['columnWidthsSynced'] = true; + component['lastSyncViewportWidth'] = 600; + component['maxRenderedRangeEnd'] = 2; + const measureSpy = spyOn(component, 'measureNaturalColumnWidths').and.callThrough(); + + component['syncColumnWidths'](); + + expect(measureSpy).toHaveBeenCalled(); + + document.body.removeChild(headerTable); + document.body.removeChild(bodyTable); + }); + + it('syncColumnWidths: elastic column width should grow across syncs and never shrink (monotonic)', () => { + const { headerTable, bodyTable } = buildSyncTables(); + component.viewPort = { getRenderedRange: () => ({ start: 0, end: 2 }) } as any; + spyOn(component, 'hasSomeFixed').and.returnValue(false); + spyOn(component, 'getColumnIndexes').and.returnValue({ dataIndexes: [0, 1], elasticIndexes: [0, 1] }); + spyOn(component, 'getViewportContentWidth').and.returnValue(0); + const measureSpy = spyOn(component, 'measureNaturalColumnWidths'); + + measureSpy.and.returnValue([100, 200]); + component['syncColumnWidths'](); + expect(component['elasticNaturalMaxWidths']).toEqual([100, 200]); + + component['columnWidthsSynced'] = false; + measureSpy.and.returnValue([100, 150]); + component['syncColumnWidths'](); + expect(component['elasticNaturalMaxWidths'][1]).toBe(200); + + document.body.removeChild(headerTable); + document.body.removeChild(bodyTable); + }); + + // --- syncFrozenColumnOffsets --- + it('syncFrozenColumnOffsets: should return when there are no fixed columns', () => { + spyOn(component, 'hasSomeFixed').and.returnValue(false); + const bodyTable = document.createElement('table'); + + expect(() => component['syncFrozenColumnOffsets']([], bodyTable, 0)).not.toThrow(); + }); + + it('syncFrozenColumnOffsets: should apply the same left offset to header and body fixed cells', () => { + spyOn(component, 'hasSomeFixed').and.returnValue(true); + const th0 = createMockCell('po-table-column-fixed'); + const th1 = createMockCell('po-table-column-fixed'); + Object.defineProperty(th0, 'getBoundingClientRect', { value: () => ({ width: 120 }) }); + Object.defineProperty(th1, 'getBoundingClientRect', { value: () => ({ width: 200 }) }); + const bodyTable = document.createElement('table'); + bodyTable.innerHTML = ''; + document.body.appendChild(bodyTable); + + component['syncFrozenColumnOffsets']([th0, th1], bodyTable, 2); + + const tds = bodyTable.querySelectorAll('td'); + expect(th0.style.left).toBe('-1px'); + expect(th1.style.left).toBe('119px'); + expect((tds[0] as HTMLElement).style.left).toBe('-1px'); + expect((tds[1] as HTMLElement).style.left).toBe('119px'); + expect((tds[0] as HTMLElement).classList.contains('po-table-column-fixed')).toBe(true); + + document.body.removeChild(bodyTable); + }); + + it('syncFrozenColumnOffsets: should mirror the right offset for right-aligned fixed columns', () => { + spyOn(component, 'hasSomeFixed').and.returnValue(true); + const th = createMockCell('po-table-column-fixed'); + th.style.right = '50px'; + const bodyTable = document.createElement('table'); + bodyTable.innerHTML = ''; + document.body.appendChild(bodyTable); + + component['syncFrozenColumnOffsets']([th], bodyTable, 1); + + const td = bodyTable.querySelector('td') as HTMLElement; + expect(td.style.right).toBe('50px'); + expect(td.classList.contains('po-table-column-fixed')).toBe(true); + + document.body.removeChild(bodyTable); + }); + + it('syncFrozenColumnOffsets: should do nothing when no header cell has the fixed class', () => { + spyOn(component, 'hasSomeFixed').and.returnValue(true); + const th = createMockCell('po-frozen-column'); + const bodyTable = document.createElement('table'); + bodyTable.innerHTML = ''; + document.body.appendChild(bodyTable); + + component['syncFrozenColumnOffsets']([th], bodyTable, 1); + + const td = bodyTable.querySelector('td') as HTMLElement; + expect(td.style.left).toBe(''); + expect(td.style.right).toBe(''); + + document.body.removeChild(bodyTable); + }); + + // --- checkChangesItems / clearColumnWidths (resets do virtual scroll) --- + it('checkChangesItems: should reset columnWidthsSynced when items change and virtualScroll is on', () => { + component.height = 400; + component.virtualScroll = true; + component['columnWidthsSynced'] = true; + (component as any).differ = { diff: () => ({}) }; + + component['checkChangesItems'](); + + expect(component['columnWidthsSynced']).toBe(false); + }); + + it('checkChangesItems: should not touch columnWidthsSynced when virtualScroll is off', () => { + component.virtualScroll = false; + component['columnWidthsSynced'] = true; + (component as any).differ = { diff: () => ({}) }; + + component['checkChangesItems'](); + + expect(component['columnWidthsSynced']).toBe(true); + }); + + it('clearColumnWidths: should reset the virtual scroll sync markers and elastic cache', () => { + component['lastSyncViewportWidth'] = 500; + component['maxRenderedRangeEnd'] = 10; + component['elasticNaturalMaxWidths'] = [1, 2, 3]; + component.headerTableElement = null; + component.bodyTableElement = null; + + component['clearColumnWidths'](); + + expect(component['lastSyncViewportWidth']).toBe(-1); + expect(component['maxRenderedRangeEnd']).toBe(-1); + expect(component['elasticNaturalMaxWidths']).toEqual([]); + }); + }); + + describe('Table layout helper methods:', () => { + it('resetTableLayout: should remove colgroup, table-layout, width and min-width from table', () => { + const table = document.createElement('table'); + table.style.tableLayout = 'fixed'; + table.style.width = '500px'; + table.style.minWidth = '500px'; + const colgroup = document.createElement('colgroup'); + colgroup.setAttribute('data-po-sync', 'true'); + table.appendChild(colgroup); + document.body.appendChild(table); + + component['resetTableLayout'](table); + + expect(table.style.tableLayout).toBe(''); + expect(table.style.width).toBe(''); + expect(table.style.minWidth).toBe(''); + expect(table.querySelector('colgroup[data-po-sync="true"]')).toBeNull(); + + document.body.removeChild(table); + }); + + it('applySharedColumnLayout: should apply colgroup, table-layout fixed, width and min-width', () => { + const table = document.createElement('table'); + document.body.appendChild(table); + + component['applySharedColumnLayout'](table, [100, 200, 300], 600); + + expect(table.style.tableLayout).toBe('fixed'); + expect(table.style.width).toBe('600px'); + expect(table.style.minWidth).toBe('600px'); + const colgroup = table.querySelector('colgroup[data-po-sync="true"]'); + expect(colgroup).toBeTruthy(); + expect(colgroup.children.length).toBe(3); + + document.body.removeChild(table); + }); + + it('getViewportContentWidth: should return clientWidth of tableVirtualScroll', () => { + const el = document.createElement('div'); + Object.defineProperty(el, 'clientWidth', { value: 750, configurable: true }); + component.tableVirtualScroll = { nativeElement: el } as any; + + expect(component['getViewportContentWidth']()).toBe(750); + }); + + it('getViewportContentWidth: should return 0 when tableVirtualScroll is null', () => { + component.tableVirtualScroll = null; + + expect(component['getViewportContentWidth']()).toBe(0); + }); + }); + + describe('Scrollbar gutter and header scroll sync:', () => { + it('updateScrollbarGutter: should not throw when elements are not available', () => { + component.tableVirtualScroll = null; + component.headerScrollContainer = null; + + expect(() => component['updateScrollbarGutter']()).not.toThrow(); + }); + + it('updateScrollbarGutter: should remove gutter styles when no vertical scroll', () => { + const viewportEl = document.createElement('div'); + Object.defineProperty(viewportEl, 'scrollHeight', { value: 100, configurable: true }); + Object.defineProperty(viewportEl, 'clientHeight', { value: 200, configurable: true }); + + const headerEl = document.createElement('div'); + headerEl.style.borderRight = '15px solid transparent'; + headerEl.style.boxSizing = 'border-box'; + + component.tableVirtualScroll = { nativeElement: viewportEl } as any; + component.headerScrollContainer = { nativeElement: headerEl } as any; + + component['updateScrollbarGutter'](); + + expect(headerEl.style.borderRight).toBe(''); + expect(headerEl.style.boxSizing).toBe(''); + }); + + it('syncHeaderScrollFromViewport: should sync header scrollLeft from viewport', () => { + const viewportEl = document.createElement('div'); + Object.defineProperty(viewportEl, 'scrollLeft', { value: 120, configurable: true }); + + const headerEl = {} as any; + let headerScrollLeft = 0; + Object.defineProperty(headerEl, 'scrollLeft', { + get: () => headerScrollLeft, + set: (v: number) => { + headerScrollLeft = v; + }, + configurable: true + }); + component.tableVirtualScroll = { nativeElement: viewportEl } as any; + component.headerScrollContainer = { nativeElement: headerEl } as any; + + component['syncHeaderScrollFromViewport'](); + + expect(headerScrollLeft).toBe(120); + }); + + it('syncHeaderScrollFromViewport: should not throw when tableVirtualScroll is null', () => { + component.tableVirtualScroll = null; + + expect(() => component['syncHeaderScrollFromViewport']()).not.toThrow(); + }); + }); + + describe('Infinite scroll reevaluation:', () => { + it('reevaluateInfiniteScroll: should restore infiniteScroll and call checkInfiniteScroll when conditions are met', () => { + component['requestedInfiniteScroll'] = true; + component['subscriptionScrollEvent'] = null; + component.height = 400; + component.infiniteScroll = false; + + const checkSpy = spyOn(component, 'checkInfiniteScroll'); + + component['reevaluateInfiniteScroll'](); + + expect(component.infiniteScroll).toBe(true); + expect(checkSpy).toHaveBeenCalled(); + }); + + it('reevaluateInfiniteScroll: should not restore when requestedInfiniteScroll is false', () => { + component['requestedInfiniteScroll'] = false; + component['subscriptionScrollEvent'] = null; + component.height = 400; + component.infiniteScroll = false; + + const checkSpy = spyOn(component, 'checkInfiniteScroll'); + + component['reevaluateInfiniteScroll'](); + + expect(component.infiniteScroll).toBe(false); + expect(checkSpy).not.toHaveBeenCalled(); + }); + + it('reevaluateInfiniteScroll: should not restore when subscriptionScrollEvent already exists', () => { + component['requestedInfiniteScroll'] = true; + component['subscriptionScrollEvent'] = { unsubscribe: () => {} } as any; + component.height = 400; + + const checkSpy = spyOn(component, 'checkInfiniteScroll'); + + component['reevaluateInfiniteScroll'](); + + expect(checkSpy).not.toHaveBeenCalled(); + }); + + it('reevaluateInfiniteScroll: should not restore when height is 0', () => { + component['requestedInfiniteScroll'] = true; + component['subscriptionScrollEvent'] = null; + component.height = 0; + + const checkSpy = spyOn(component, 'checkInfiniteScroll'); + + component['reevaluateInfiniteScroll'](); + + expect(checkSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Column drag autoscroll:', () => { + it('onColumnDragMoved: should start autoscroll left when pointer is near left edge', () => { + const viewportEl = document.createElement('div'); + Object.defineProperty(viewportEl, 'getBoundingClientRect', { + value: () => ({ left: 100, right: 500, top: 0, bottom: 400 }) + }); + component.tableVirtualScroll = { nativeElement: viewportEl } as any; + + const startSpy = spyOn(component, 'startDragAutoScroll'); + + component.onColumnDragMoved({ pointerPosition: { x: 120 } } as any); + + expect(startSpy).toHaveBeenCalledWith(-1); + }); + + it('onColumnDragMoved: should start autoscroll right when pointer is near right edge', () => { + const viewportEl = document.createElement('div'); + Object.defineProperty(viewportEl, 'getBoundingClientRect', { + value: () => ({ left: 100, right: 500, top: 0, bottom: 400 }) + }); + component.tableVirtualScroll = { nativeElement: viewportEl } as any; + + const startSpy = spyOn(component, 'startDragAutoScroll'); + + component.onColumnDragMoved({ pointerPosition: { x: 480 } } as any); + + expect(startSpy).toHaveBeenCalledWith(1); + }); + + it('onColumnDragMoved: should stop autoscroll when pointer is in the middle', () => { + const viewportEl = document.createElement('div'); + Object.defineProperty(viewportEl, 'getBoundingClientRect', { + value: () => ({ left: 100, right: 500, top: 0, bottom: 400 }) + }); + component.tableVirtualScroll = { nativeElement: viewportEl } as any; + + const stopSpy = spyOn(component, 'stopDragAutoScroll'); + + component.onColumnDragMoved({ pointerPosition: { x: 300 } } as any); + + expect(stopSpy).toHaveBeenCalled(); + }); + + it('onColumnDragMoved: should return early when tableVirtualScroll is null', () => { + component.tableVirtualScroll = null; + + const startSpy = spyOn(component, 'startDragAutoScroll'); + const stopSpy = spyOn(component, 'stopDragAutoScroll'); + + component.onColumnDragMoved({ pointerPosition: { x: 100 } } as any); + + expect(startSpy).not.toHaveBeenCalled(); + expect(stopSpy).not.toHaveBeenCalled(); + }); + + it('onColumnDragEnded: should stop autoscroll and sync header scroll', () => { + const stopSpy = spyOn(component, 'stopDragAutoScroll'); + const syncSpy = spyOn(component, 'syncHeaderScrollFromViewport'); + + component.onColumnDragEnded(); + + expect(stopSpy).toHaveBeenCalled(); + expect(syncSpy).toHaveBeenCalled(); + }); + + it('startDragAutoScroll: should set dragAutoScrollDirection and start animation frame', () => { + const viewportEl = document.createElement('div'); + Object.defineProperty(viewportEl, 'scrollLeft', { value: 0, writable: true, configurable: true }); + component.tableVirtualScroll = { nativeElement: viewportEl } as any; + component.headerScrollContainer = { nativeElement: document.createElement('div') } as any; + component['dragAutoScrollFrame'] = null; + + spyOn(window, 'requestAnimationFrame').and.returnValue(123); + + component['startDragAutoScroll'](1); + + expect(component['dragAutoScrollDirection']).toBe(1); + expect(component['dragAutoScrollFrame']).toBe(123); + }); + + it('startDragAutoScroll: should not start new frame when already running', () => { + component['dragAutoScrollFrame'] = 456; + const rafSpy = spyOn(window, 'requestAnimationFrame'); + + component['startDragAutoScroll'](1); + + expect(component['dragAutoScrollDirection']).toBe(1); + expect(rafSpy).not.toHaveBeenCalled(); + }); + + it('startDragAutoScroll: step should scroll viewport, sync header and request next frame', () => { + let scrollLeft = 50; + const viewportEl = document.createElement('div'); + Object.defineProperty(viewportEl, 'scrollLeft', { + get: () => scrollLeft, + set: (v: number) => { + scrollLeft = v; + }, + configurable: true + }); + component.tableVirtualScroll = { nativeElement: viewportEl } as any; + component.headerScrollContainer = { nativeElement: { scrollLeft: 0 } } as any; + component['dragAutoScrollFrame'] = null; + component['dragAutoScrollDirection'] = 0; + + const syncSpy = spyOn(component, 'syncHeaderScrollLeft'); + let stepCallback: FrameRequestCallback; + spyOn(window, 'requestAnimationFrame').and.callFake((cb: FrameRequestCallback) => { + if (!stepCallback) { + stepCallback = cb; + } + return 999; + }); + + component['startDragAutoScroll'](1); + + // Executa o step callback manualmente + stepCallback(0); + + expect(scrollLeft).toBe(62); // 50 + 12 * 1 + expect(syncSpy).toHaveBeenCalledWith(62); + expect(component['dragAutoScrollFrame']).toBe(999); + }); + + it('startDragAutoScroll: step should stop when direction becomes 0', () => { + const viewportEl = document.createElement('div'); + Object.defineProperty(viewportEl, 'scrollLeft', { value: 0, writable: true, configurable: true }); + component.tableVirtualScroll = { nativeElement: viewportEl } as any; + component['dragAutoScrollFrame'] = null; + + let stepCallback: FrameRequestCallback; + const rafSpy = spyOn(window, 'requestAnimationFrame').and.callFake((cb: FrameRequestCallback) => { + stepCallback = cb; + return 111; + }); + + component['startDragAutoScroll'](1); + // Simula stopDragAutoScroll setando direction = 0 + component['dragAutoScrollDirection'] = 0; + + stepCallback(0); + + expect(component['dragAutoScrollFrame']).toBeNull(); + }); + + it('startDragAutoScroll: step should stop when viewportEl is not available', () => { + component.tableVirtualScroll = { nativeElement: document.createElement('div') } as any; + component['dragAutoScrollFrame'] = null; + + let stepCallback: FrameRequestCallback; + spyOn(window, 'requestAnimationFrame').and.callFake((cb: FrameRequestCallback) => { + stepCallback = cb; + return 222; + }); + + component['startDragAutoScroll'](1); + // Remove o viewport antes do step executar + component.tableVirtualScroll = null; + + stepCallback(0); + + expect(component['dragAutoScrollFrame']).toBeNull(); + }); + + it('stopDragAutoScroll: should cancel animation frame and reset state', () => { + component['dragAutoScrollFrame'] = 789; + component['dragAutoScrollDirection'] = 1; + + const cancelSpy = spyOn(window, 'cancelAnimationFrame'); + + component['stopDragAutoScroll'](); + + expect(component['dragAutoScrollDirection']).toBe(0); + expect(component['dragAutoScrollFrame']).toBeNull(); + expect(cancelSpy).toHaveBeenCalledWith(789); + }); + + it('stopDragAutoScroll: should not call cancelAnimationFrame when no frame is active', () => { + component['dragAutoScrollFrame'] = null; + component['dragAutoScrollDirection'] = 1; + + const cancelSpy = spyOn(window, 'cancelAnimationFrame'); + + component['stopDragAutoScroll'](); + + expect(component['dragAutoScrollDirection']).toBe(0); + expect(cancelSpy).not.toHaveBeenCalled(); + }); + }); + + describe('ngAfterViewInit and ngDoCheck changes:', () => { + it('ngAfterViewInit: should capture requestedInfiniteScroll from initial infiniteScroll value', () => { + component.height = 400; + component.infiniteScroll = true; + component['requestedInfiniteScroll'] = false; + + // Simula o trecho específico do ngAfterViewInit que captura requestedInfiniteScroll + component['requestedInfiniteScroll'] = component.infiniteScroll; + + expect(component['requestedInfiniteScroll']).toBe(true); + }); + + it('shouldScheduleVirtualScrollColumnSyncWithoutWidths: should return true when columnWidthsSynced is false and has rendered items', () => { + component.height = 400; + component.virtualScroll = true; + component.items = [{ id: 1 }]; + component['columnWidthsSynced'] = false; + component['syncScheduled'] = false; + component.viewPort = { getRenderedRange: () => ({ start: 0, end: 1 }) } as any; + + expect(component['shouldScheduleVirtualScrollColumnSyncWithoutWidths']()).toBe(true); + }); + + it('shouldScheduleVirtualScrollColumnSyncWithoutWidths: should return false when columnWidthsSynced is true and no new rows rendered', () => { + component.height = 400; + component.virtualScroll = true; + component.items = [{ id: 1 }]; + component['columnWidthsSynced'] = true; + component['syncScheduled'] = false; + component['maxRenderedRangeEnd'] = 1; + component.viewPort = { getRenderedRange: () => ({ start: 0, end: 1 }) } as any; + + expect(component['shouldScheduleVirtualScrollColumnSyncWithoutWidths']()).toBe(false); + }); + + it('shouldScheduleVirtualScrollColumnSyncWithoutWidths: should return true when synced but new rows entered the buffer', () => { + component.height = 400; + component.virtualScroll = true; + component.items = [{ id: 1 }]; + component['columnWidthsSynced'] = true; + component['syncScheduled'] = false; + component['maxRenderedRangeEnd'] = 1; + component.viewPort = { getRenderedRange: () => ({ start: 0, end: 5 }) } as any; + + expect(component['shouldScheduleVirtualScrollColumnSyncWithoutWidths']()).toBe(true); + }); + + it('shouldScheduleVirtualScrollColumnSyncWithoutWidths: should return false when virtualScroll is false', () => { + component.virtualScroll = false; + component.items = [{ id: 1 }]; + component['syncScheduled'] = false; + component.viewPort = { getRenderedRange: () => ({ start: 0, end: 1 }) } as any; + + expect(component['shouldScheduleVirtualScrollColumnSyncWithoutWidths']()).toBe(false); + }); + + it('shouldScheduleVirtualScrollColumnSyncWithoutWidths: should return false when there are no items', () => { + component.height = 400; + component.virtualScroll = true; + component.items = []; + component['syncScheduled'] = false; + component.viewPort = { getRenderedRange: () => ({ start: 0, end: 1 }) } as any; + + expect(component['shouldScheduleVirtualScrollColumnSyncWithoutWidths']()).toBe(false); + }); + + it('shouldScheduleVirtualScrollColumnSyncWithoutWidths: should return false when sync is already scheduled', () => { + component.height = 400; + component.virtualScroll = true; + component.items = [{ id: 1 }]; + component['syncScheduled'] = true; + component.viewPort = { getRenderedRange: () => ({ start: 0, end: 1 }) } as any; + + expect(component['shouldScheduleVirtualScrollColumnSyncWithoutWidths']()).toBe(false); + }); + + it('shouldScheduleVirtualScrollColumnSyncWithoutWidths: should return false when no rows are rendered yet', () => { + component.height = 400; + component.virtualScroll = true; + component.items = [{ id: 1 }]; + component['columnWidthsSynced'] = false; + component['syncScheduled'] = false; + component.viewPort = { getRenderedRange: () => ({ start: 0, end: 0 }) } as any; + + expect(component['shouldScheduleVirtualScrollColumnSyncWithoutWidths']()).toBe(false); + }); + + it('ngDoCheck: should include structureKey in columns change detection', () => { + component.computedColumnWidths = ['100px']; + component['columnWidthsSynced'] = true; + component.mainColumns = [{ property: 'col1' }]; + component['lastColumnsKey'] = ''; + + component.ngDoCheck(); + + expect(component['columnWidthsSynced']).toBe(false); + expect(component.computedColumnWidths).toEqual([]); + }); + }); + }); + + describe('Templates:', () => { + it('shouldn`t contain `po-tooltip` class if link is disabled', fakeAsync(() => { + const mouseEnterEvent = new Event('mouseenter', { bubbles: true }); + component.columns = [ + { property: 'link', label: 'linkTest', type: 'link', tooltip: 'tooltipTest', disabled: () => true } + ]; + component.items = [{ link: 'tooltipTest' }]; + fixture.detectChanges(); + + const columnLink = nativeElement.querySelector('.po-table-link-disabled'); + columnLink.dispatchEvent(mouseEnterEvent); + fixture.detectChanges(); + tick(100); + + expect(nativeElement.querySelector('.po-tooltip')).toBeNull(); + })); + + it('should contain `po-table-column-detail-toggle` class if tableRowTemplate exists', () => { + component.items = items; + component.columns = columns; + + component.tableRowTemplate = mockTableDetailDiretive; + + fixture.detectChanges(); + const poTableColumnDetailToggle = nativeElement.querySelector('.po-table-column-detail-toggle'); + + expect(poTableColumnDetailToggle).toBeTruthy(); + }); + + it('shouldn`t contain `po-table-column-detail-toggle` class if tableRowTemplate is undefined', () => { + component.items = items; + component.columns = columns; + + component.tableRowTemplate = undefined; + + fixture.detectChanges(); + + const poTableColumnDetailToggle = nativeElement.querySelector('.po-table-column-detail-toggle'); + + expect(poTableColumnDetailToggle).toBeFalsy(); + }); + + it('should show loading on table', () => { + component.loading = true; + + fixture.detectChanges(); + + expect(nativeElement.querySelector('div.po-table-container-sticky')).toBeTruthy(); + expect(nativeElement.querySelector('po-loading-overlay')).toBeTruthy(); + }); + + it('shouldn`t show loading on table', () => { + component.loading = false; + + fixture.detectChanges(); + + expect(nativeElement.querySelector('div.po-table-container-sticky')).toBeFalsy(); + expect(nativeElement.querySelector('po-loading-overlay')).toBeFalsy(); + }); + + it('should show td with `po-table-column-actions` class if has more than 1 action', () => { + component.actions = actions; + fixture.detectChanges(); + + expect(tableElement.querySelector('.po-table-column-actions')).toBeTruthy(); + }); + + it('should show detail selectable if detail hideSelect is undefined', () => { + component.columns = columnsWithDetailInterface; + component.selectable = true; + component.hideDetail = false; + component.items[0].$showDetail = true; + fixture.detectChanges(); + + const details = tableElement.querySelector('.po-table-column-master-detail-space-checkbox'); + + expect(details).toBeTruthy(); + }); + + it('should show detail selectable if detail hideSelect is false', () => { + component.columns = columnsWithDetailInterface; + component.columns[5].detail.hideSelect = false; + component.selectable = true; + component.hideDetail = false; + component.items[0].$showDetail = true; + fixture.detectChanges(); + + const details = tableElement.querySelector('.po-table-column-master-detail-space-checkbox'); + + expect(details).toBeTruthy(); + }); + + it('shouldn`t show detail selectable if detail hideSelect is true', () => { + component.columns = columnsWithDetailInterface; + component.columns[5].detail.hideSelect = true; + component.selectable = true; + component.hideDetail = false; + component.items[0].$showDetail = true; + fixture.detectChanges(); + + const details = tableElement.querySelector('.po-table-column-master-detail-space-checkbox'); + + expect(details).toBeNull(); + }); + + it('should show detail selectable if not have a detail interface', () => { + component.columns = columnsWithDetail; + component.selectable = true; + component.hideDetail = false; + component.items[0].$showDetail = true; + fixture.detectChanges(); + + const details = tableElement.querySelector('.po-table-column-master-detail-space-checkbox'); + + expect(details).toBeTruthy(); + }); + + it('should have only one action', () => { + component.actions = singleAction; + component.firstAction.icon = undefined; + fixture.detectChanges(); + + const actionsColumn = tableElement.querySelector('.po-table-actions'); + expect(actionsColumn).toBeFalsy(); + + const actionColumn = tableElement.querySelector('.po-table-column-single-action'); + const actionItem = actionColumn.querySelectorAll('.po-table-single-action'); + const iconActionItem = actionColumn.querySelector('po-icon'); + + expect(actionItem.length).toBe(1); + expect(iconActionItem).toBeNull(); + }); + + it('should have only one action with icon', () => { + singleAction[0].icon = 'po-icon-news'; + component.actions = singleAction; + + fixture.detectChanges(); + + const actionsColumn = tableElement.querySelector('.po-table-actions'); + expect(actionsColumn).toBeFalsy(); + + const actionColumn = tableElement.querySelector('.po-table-column-single-action'); + const actionItem = actionColumn.querySelectorAll('.po-table-single-action'); + const iconActionItem = actionColumn.querySelector('po-icon'); + + expect(actionItem.length).toBe(1); + expect(iconActionItem).toBeTruthy(); + }); + + it('should show td with no columns message if doesn`t have columns', () => { + const noColumnsMessage = component.literals.noColumns; + + component.items = []; + component.columns = []; + + fixture.detectChanges(); + + expect(tableElement.querySelector('.po-table-header-column').innerHTML.includes(noColumnsMessage)).toBe(true); + }); + + it('shouldn`t display action if it is single and `visible` is `false`.', () => { + component.actions = [{ label: 'PO ', type: 'color-07', visible: false }]; + + fixture.detectChanges(); + + expect(nativeElement.querySelector('.po-table-single-action')).toBeNull(); + }); + + it('shouldn`t display `po-container` class if container is undefined.', () => { + expect(nativeElement.querySelector('.po-container')).toBeFalsy(); + }); + + it('should display `po-container` class if container is `border`.', fakeAsync(() => { + component.container = 'border'; + fixture.detectChanges(); + + tick(1200); + + expect(nativeElement.querySelector('.po-container')).toBeTruthy(); + })); + + it('should display `po-container` and `po-container-no-shadow` class if container is `shadow`.', fakeAsync(() => { + component.container = 'shadow'; + fixture.detectChanges(); + + tick(1200); + + expect(nativeElement.querySelector('.po-container')).toBeTruthy(); + })); + + it('shouldn`t find .po-table-header-column-manager-button if hasn`t columns and items', () => { + component.items = undefined; + component.columns = undefined; + + fixture.detectChanges(); + + expect(nativeElement.querySelector('.po-table-header-column-manager-button')).toBe(null); + }); + + it('shouldn`t find .po-table-header-column-manager-button if has only type detail columns', () => { + component.columns = [...columnsDetail]; + + fixture.detectChanges(); + + expect(nativeElement.querySelector('.po-table-header-column-manager-button')).toBe(null); + }); + + it('should display one icon.', () => { + component.items = [{ po: 'favorite' }]; + component.columns = [columnIcons]; + + fixture.detectChanges(); + + expect(nativeElement.querySelectorAll(`po-table-column-icon po-table-icon`).length).toBe(1); + }); + + it('should display two icons.', () => { + component.items = [{ po: ['favorite', 'documentation'] }]; + component.columns = [columnIcons]; + + fixture.detectChanges(); + + expect(nativeElement.querySelectorAll(`po-table-column-icon po-table-icon`).length).toBe(2); + }); + + it('should not display po-table-column-manager', () => { + component.hideColumnsManager = true; + fixture.detectChanges(); + + expect(nativeElement.querySelector(`po-table-column-manager`)).toBe(null); + }); + + it('should call attr-p-spacing `medium` if p-spacing not set', () => { + localStorage.removeItem('po-default-size'); + component.componentsSize = undefined; + component.spacing = undefined; + component.columns = [...columnsWithDetail]; + + fixture.detectChanges(); + + expect(nativeElement.querySelector('[p-spacing="medium"]')).toBeTruthy(); + }); + + it('should call attr-p-spacing `small` if p-spacing is `small` and row is not interactive', () => { + component.columns = [{ property: 'name' }, { property: 'age' }]; + component.spacing = PoTableColumnSpacing.Small; + fixture.detectChanges(); + + expect(nativeElement.querySelector('[p-spacing="small"]')).toBeTruthy(); + }); + + it('should display .po-table-header-master-detail if columns contains detail and rowTemplate is undefined', () => { + component.items = [...items]; + component.columns = [...columnsWithDetail]; + + component.tableRowTemplate = undefined; + + fixture.detectChanges(); + + expect(nativeElement.querySelector(`th.po-table-header-master-detail`)).toBeTruthy(); + }); + + it(`shouldn't display .po-table-header-master-detail if columns contains detail + and rowTemplate but hideColumnsManager is false`, () => { + component.items = [...items]; + component.columns = [...columnsWithDetail]; + component.actions = []; + component.hideColumnsManager = false; component.tableRowTemplate = { ...mockTableDetailDiretive, @@ -2978,7 +4784,7 @@ describe('PoTableComponent:', () => { it(`ngOnDestroy: should unsubscribe 'subscriptionService'`, () => { const fakeSubscription = { unsubscribe: () => {} }; spyOn(fakeSubscription, 'unsubscribe'); - component['subscriptionService'] = fakeSubscription; + Object.defineProperty(component, 'subscriptionService', { value: fakeSubscription, configurable: true }); component.ngOnDestroy(); @@ -2987,11 +4793,11 @@ describe('PoTableComponent:', () => { it(`ngOnDestroy: should not unsubscribe if 'subscriptionService' is falsy.`, () => { const fakeSubscription = { unsubscribe: () => {} }; - component['subscriptionService'] = fakeSubscription; + Object.defineProperty(component, 'subscriptionService', { value: fakeSubscription, configurable: true }); spyOn(fakeSubscription, 'unsubscribe'); - component['subscriptionService'] = undefined; + Object.defineProperty(component, 'subscriptionService', { value: undefined, configurable: true }); component.ngOnDestroy(); expect(fakeSubscription.unsubscribe).not.toHaveBeenCalled(); @@ -3131,6 +4937,10 @@ describe('PoTableComponent:', () => { component.height = 1000; spyOnProperty(component, 'hasItems').and.returnValue(true); component.infiniteScroll = true; + component.virtualScroll = false; + + // Mock tableScrollable com scrollHeight menor que height + component.tableScrollable = new ElementRef({ scrollHeight: 200, closest: () => null }); component['checkInfiniteScroll'](); @@ -3138,6 +4948,48 @@ describe('PoTableComponent:', () => { expect(spyIncludeInfiniteScroll).not.toHaveBeenCalled(); }); + it('checkInfiniteScroll: should use heightTableVirtual as availableHeight when clientHeight is 0 in virtual scroll', () => { + const spyIncludeInfiniteScroll = spyOn(component, 'includeInfiniteScroll'); + + component.height = 400; + component.virtualScroll = true; + spyOnProperty(component, 'hasItems').and.returnValue(true); + component.infiniteScroll = true; + component.heightTableVirtual = 300; + + // clientHeight = 0 → fallback para heightTableVirtual (300) + const mockViewport = document.createElement('div'); + Object.defineProperty(mockViewport, 'scrollHeight', { value: 500, configurable: true }); + Object.defineProperty(mockViewport, 'clientHeight', { value: 0, configurable: true }); + component.tableVirtualScroll = { nativeElement: mockViewport } as any; + + component['checkInfiniteScroll'](); + + // scrollHeight (500) >= availableHeight (300) → includeInfiniteScroll chamado + expect(spyIncludeInfiniteScroll).toHaveBeenCalled(); + }); + + it('checkInfiniteScroll: should use this.height as availableHeight when both clientHeight and heightTableVirtual are 0 in virtual scroll', () => { + const spyIncludeInfiniteScroll = spyOn(component, 'includeInfiniteScroll'); + + component.height = 200; + component.virtualScroll = true; + spyOnProperty(component, 'hasItems').and.returnValue(true); + component.infiniteScroll = true; + component.heightTableVirtual = 0; + + // clientHeight = 0, heightTableVirtual = 0 → fallback para this.height (200) + const mockViewport = document.createElement('div'); + Object.defineProperty(mockViewport, 'scrollHeight', { value: 500, configurable: true }); + Object.defineProperty(mockViewport, 'clientHeight', { value: 0, configurable: true }); + component.tableVirtualScroll = { nativeElement: mockViewport } as any; + + component['checkInfiniteScroll'](); + + // scrollHeight (500) >= availableHeight (200) → includeInfiniteScroll chamado + expect(spyIncludeInfiniteScroll).toHaveBeenCalled(); + }); + it('getWidthColumnManager, should return width of column manager', () => { const fakeThis = { columnManager: { 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 a1d228324a..9ef77fb26d 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 @@ -1,8 +1,9 @@ -import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; +import { CdkDragDrop, CdkDragMove, moveItemInArray } from '@angular/cdk/drag-drop'; import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; import { DecimalPipe } from '@angular/common'; import { + AfterViewChecked, AfterViewInit, ChangeDetectorRef, Component, @@ -12,6 +13,7 @@ import { ElementRef, Inject, IterableDiffers, + NgZone, OnDestroy, OnInit, Optional, @@ -28,11 +30,11 @@ import { PoDateService } from '../../services/po-date/po-date.service'; import { PoLanguageService } from '../../services/po-language/po-language.service'; import { PoNotificationService } from '../../services/po-notification/po-notification.service'; import { convertToBoolean, getDefaultSizeFn, PO_TABLE_ROW_HEIGHT_BY_SPACING, uuid } from '../../utils/util'; +import { AnimaliaIconDictionary, ICONS_DICTIONARY } from '../po-icon'; import { PoModalAction, PoModalComponent } from '../po-modal'; import { PoPopupComponent } from '../po-popup/po-popup.component'; import { PoTableColumnLabel } from './po-table-column-label/po-table-column-label.interface'; -import { AnimaliaIconDictionary, ICONS_DICTIONARY } from '../po-icon'; import { PoTableRowTemplateArrowDirection } from './enums/po-table-row-template-arrow-direction.enum'; import { PoTableAction } from './interfaces/po-table-action.interface'; import { PoTableColumn } from './interfaces/po-table-column.interface'; @@ -103,12 +105,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; @@ -130,6 +140,7 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI @ViewChild(CdkVirtualScrollViewport, { static: false }) public viewPort: CdkVirtualScrollViewport; poNotification = inject(PoNotificationService); + private readonly ngZone = inject(NgZone); heightTableContainer: number; heightTableVirtual: number; @@ -142,9 +153,10 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI idRadio: string; inputFieldValue = ''; JSON: JSON; - newOrderColumns: Array; sizeLoading: string = 'sm'; headerWidth: number; + headerTableScrollWidth: number; + computedColumnWidths: Array = []; close: PoModalAction = { action: () => { @@ -163,17 +175,36 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI private _columnManagerTarget: ElementRef; private _columnManagerTargetFixed: ElementRef; - private _iconToken: { [key: string]: string }; - private differ; + private readonly differ; private footerHeight; private timeoutResize; private visibleElement = false; private scrollEvent$: Observable; private subscriptionScrollEvent: Subscription; private subscriptionService: Subscription = new Subscription(); + private resizeObserver: ResizeObserver; + private scrollSyncListener: (() => void) | null = null; + private containerScrollSyncListener: (() => void) | null = null; + private dragAutoScrollFrame: number | null = null; + private dragAutoScrollDirection = 0; + private virtualScrollOverflowConfigured = false; + private syncScheduled = false; + private columnWidthsSynced = false; + private lastSyncViewportWidth = -1; + private maxRenderedRangeEnd = -1; + private elasticNaturalMaxWidths: Array = []; + private requestedInfiniteScroll = false; + private lastColumnsKey = ''; + private lastHeaderHeight = 0; + + private readonly SELECTOR_HEADER_ROW = 'thead > tr'; + private readonly SELECTOR_BODY_DATA_ROW = 'tbody tr.po-table-row:not(.po-table-row-no-data)'; + private readonly SELECTOR_CDK_CONTENT_WRAPPER = '.cdk-virtual-scroll-content-wrapper'; + private readonly SELECTOR_FIXED_INNER_CONTAINER = '.po-table-container-fixed-inner'; private clickListener: () => void; private resizeListener: () => void; + private _iconToken: { [key: string]: string }; @ViewChild('columnManagerTarget') set columnManagerTarget(value: ElementRef) { this._columnManagerTarget = value; @@ -193,26 +224,24 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI return this._columnManagerTargetFixed; } - get iconNameLib() { - return this._iconToken.NAME_LIB; - } - /* eslint-disable max-params */ constructor( poDate: PoDateService, differs: IterableDiffers, - renderer: Renderer2, + private readonly renderer: Renderer2, poLanguageService: PoLanguageService, - private changeDetector: ChangeDetectorRef, - private decimalPipe: DecimalPipe, - private defaultService: PoTableService, - @Optional() @Inject(ICONS_DICTIONARY) value: { [key: string]: string } + private readonly changeDetector: ChangeDetectorRef, + private readonly decimalPipe: DecimalPipe, + private readonly defaultService: PoTableService, + @Optional() @Inject(ICONS_DICTIONARY) iconToken?: { [key: string]: string } ) { super(poDate, poLanguageService, defaultService); this.JSON = JSON; this.differ = differs.find([]).create(null); + this._iconToken = iconToken ?? AnimaliaIconDictionary; + // TODO: #5550 ao remover este listener, no portal, quando as colunas forem fixas não sofrem // alteração de largura, pois o ngDoCheck não é executado. this.clickListener = renderer.listen('document', 'click', () => {}); @@ -220,11 +249,13 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI this.resizeListener = renderer.listen('window', 'resize', (event: any) => { this.debounceResize(); }); - - this._iconToken = value ?? AnimaliaIconDictionary; } /* eslint-enable max-params */ + get iconNameLib() { + return this._iconToken.NAME_LIB; + } + get hasRowTemplateWithArrowDirectionRight() { return this.tableRowTemplate?.tableRowTemplateArrowDirection === PoTableRowTemplateArrowDirection.Right; } @@ -292,16 +323,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()}`; } @@ -315,9 +336,51 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI ngAfterViewInit() { this.initialized = true; + this.requestedInfiniteScroll = this.infiniteScroll; this.changeHeaderWidth(); this.changeSizeLoading(); this.applyFixedColumns(); + this.syncHeaderTableWidth(); + this.setupColumnWidthSync(); + this.configureVirtualScrollOverflow(); + } + + ngAfterViewChecked(): void { + if (this.virtualScroll && !this.virtualScrollOverflowConfigured && this.tableVirtualScroll?.nativeElement) { + this.configureVirtualScrollOverflow(); + } + + if (this.virtualScroll && !this.resizeObserver && this.tableVirtualScroll?.nativeElement) { + this.setupColumnWidthSync(); + } + + if (this.virtualScroll && this.heightTableContainer) { + const currentHeaderHeight = this.headerScrollContainer?.nativeElement?.offsetHeight; + if (currentHeaderHeight && currentHeaderHeight !== this.lastHeaderHeight) { + this.lastHeaderHeight = currentHeaderHeight; + requestAnimationFrame(() => { + this.heightTableVirtual = this.heightTableContainer - currentHeaderHeight; + this.changeDetector.markForCheck(); + }); + } + } + + if (this.shouldScheduleVirtualScrollColumnSyncWithoutWidths()) { + this.syncScheduled = true; + requestAnimationFrame(() => { + this.syncColumnWidths(); + this.syncScheduled = false; + }); + } + } + + private shouldScheduleVirtualScrollColumnSyncWithoutWidths(): boolean { + if (!this.virtualScroll || !this.hasItems || this.syncScheduled) return false; + + const renderedEnd = this.viewPort?.getRenderedRange().end ?? 0; + if (renderedEnd <= 0) return false; + + return !this.columnWidthsSynced || renderedEnd > this.maxRenderedRangeEnd; } showMoreInfiniteScroll({ target }): void { @@ -332,18 +395,48 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI this.checkChangesItems(); this.verifyCalculateHeightTableContainer(); - // 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. + const structureKey = [ + this.actionRight, + this.selectable, + this.hasVisibleActions, + this.hasMasterDetailColumn, + this.hasRowTemplate, + this.hasRowTemplateWithArrowDirectionRight + ].join(':'); + const columnsKey = + (this.mainColumns?.map(c => `${c.property}:${c.fixed || ''}:${c.width || ''}`).join('|') || '') + + `#${structureKey}`; + if (columnsKey !== this.lastColumnsKey) { + this.lastColumnsKey = columnsKey; + this.clearColumnWidths(); + } + if (this.tableWrapperElement?.nativeElement.offsetWidth && !this.visibleElement && this.initialized) { this.debounceResize(); this.checkInfiniteScroll(); this.visibleElement = true; } + + 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; + } + this.stopDragAutoScroll(); } /** @@ -375,6 +468,20 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI return !this.columns.some(column => !column.width); } + /** + * Retorna o valor de `width` inline para a célula. Colunas com largura declarada em `%` recebem + * `auto` (como o auto-layout nativo do master), permitindo que o browser dimensione pelo conteúdo + * e use o `%` apenas como `max-width`/`min-width`. Colunas com `px` ou `computedColumnWidths` + * recebem o valor literal. + */ + getColumnWidth(column: any, index: number): string | undefined { + const width = column.width || this.computedColumnWidths?.[index]; + if (typeof width === 'string' && width.trim().endsWith('%')) { + return 'auto'; + } + return width; + } + /** * Método que colapsa uma linha com detalhe quando executada. * @@ -589,8 +696,13 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI } onVisibleColumnsChange(columns: Array) { + this.clearColumnWidths(); this.columns = columns; - this.changeDetector.detectChanges(); + this.changeDetector.markForCheck(); + + if (this.virtualScroll) { + setTimeout(() => this.syncColumnWidths()); + } } tooltipMouseEnter(event: any, column?: PoTableColumn, row?: any) { @@ -673,28 +785,84 @@ 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) { - this.newOrderColumns = this.mainColumns; + const newOrderColumns = this.mainColumns; const detail = this.columns.filter(item => item.property === 'detail')[0]; if (detail !== undefined) { - this.newOrderColumns.push(detail); + newOrderColumns.push(detail); } - this.columns.map((item, index) => { + this.columns.forEach((item, index) => { if (!item.visible) { - this.newOrderColumns.splice(index, 0, item); + newOrderColumns.splice(index, 0, item); } }); - this.columns = this.newOrderColumns; + this.columns = newOrderColumns; - this.onVisibleColumnsChange(this.newOrderColumns); + this.onVisibleColumnsChange(newOrderColumns); + } else if (this.virtualScroll) { + setTimeout(() => this.syncColumnWidths()); } } } + onColumnDragMoved(event: CdkDragMove): void { + const viewportEl = this.tableVirtualScroll?.nativeElement as HTMLElement | undefined; + if (!viewportEl) { + return; + } + + const rect = viewportEl.getBoundingClientRect(); + const edgeThreshold = 48; + const pointerX = event.pointerPosition.x; + + if (pointerX < rect.left + edgeThreshold) { + this.startDragAutoScroll(-1); + } else if (pointerX > rect.right - edgeThreshold) { + this.startDragAutoScroll(1); + } else { + this.stopDragAutoScroll(); + } + } + + onColumnDragEnded(): void { + this.stopDragAutoScroll(); + this.syncHeaderScrollFromViewport(); + } + + private startDragAutoScroll(direction: number): void { + this.dragAutoScrollDirection = direction; + if (this.dragAutoScrollFrame !== null) { + return; + } + + const speed = 12; + const step = () => { + const viewportEl = this.tableVirtualScroll?.nativeElement as HTMLElement | undefined; + if (!viewportEl || this.dragAutoScrollDirection === 0) { + this.dragAutoScrollFrame = null; + return; + } + viewportEl.scrollLeft += this.dragAutoScrollDirection * speed; + this.syncHeaderScrollLeft(viewportEl.scrollLeft); + this.dragAutoScrollFrame = requestAnimationFrame(step); + }; + + this.dragAutoScrollFrame = requestAnimationFrame(step); + } + + private stopDragAutoScroll(): void { + this.dragAutoScrollDirection = 0; + if (this.dragAutoScrollFrame !== null) { + cancelAnimationFrame(this.dragAutoScrollFrame); + this.dragAutoScrollFrame = null; + } + } + public getTemplate(column: PoTableColumn): TemplateRef { const template: PoTableColumnTemplateDirective = this.tableColumnTemplates?.find( tableColumnTemplate => tableColumnTemplate.targetProperty === column.property @@ -727,7 +895,8 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI this.itemSize = PO_TABLE_ROW_HEIGHT_BY_SPACING[this.spacing] ?? PO_TABLE_ROW_HEIGHT_BY_SPACING[PoTableColumnSpacing.Medium]; this.heightTableContainer = height ? height - this.getHeightTableFooter() : undefined; - this.heightTableVirtual = this.heightTableContainer ? this.heightTableContainer - this.itemSize : undefined; + const headerHeight = this.headerScrollContainer?.nativeElement?.offsetHeight || this.itemSize; + this.heightTableVirtual = this.heightTableContainer ? this.heightTableContainer - headerHeight : undefined; this.setTableOpacity(1); this.changeDetector.detectChanges(); } @@ -743,14 +912,16 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI protected checkInfiniteScroll(): void { if (this.hasInfiniteScroll()) { let scrollHeight = 0; + let availableHeight = this.height; if (this.virtualScroll) { scrollHeight = this.tableVirtualScroll.nativeElement.scrollHeight; + availableHeight = this.tableVirtualScroll.nativeElement.clientHeight || this.heightTableVirtual || this.height; } else { scrollHeight = this.tableScrollable.nativeElement.scrollHeight; } - if (scrollHeight >= this.height) { + if (scrollHeight >= availableHeight) { this.includeInfiniteScroll(); } else { this.infiniteScroll = false; @@ -807,6 +978,10 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI if (changesItems && !this.hasColumns && this.hasItems) { this.columns = this.getDefaultColumns(this.items[0]); } + + if (changesItems && this.virtualScroll) { + this.columnWidthsSynced = false; + } } private checkingIfColumnHasTooltip(column, row) { @@ -873,7 +1048,7 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI private deleteItemsService(newItemsFiltered: Array) { this.subscriptionService.add( this.defaultService.deleteItem(this.paramDeleteApi, this.itemsSelected[0][this.paramDeleteApi]).subscribe({ - next: value => { + next: () => { if (this.hasService) { const filteredParams = { ...this.paramsFilter, @@ -890,7 +1065,7 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI this.items = newItemsFiltered; this.changesAfterDelete(newItemsFiltered); }, - error: error => { + error: () => { this.poNotification.error(this.literals.deleteApiError); this.modalDelete.close(); this.eventDelete.emit(this.items); @@ -988,4 +1163,468 @@ export class PoTableComponent extends PoTableBaseComponent implements AfterViewI }); } } + + private configureVirtualScrollOverflow(): void { + if (!this.tableVirtualScroll?.nativeElement) return; + + const viewportEl = this.tableVirtualScroll.nativeElement; + + this.applyVirtualScrollStyles(viewportEl); + this.registerScrollSyncListeners(viewportEl); + + this.virtualScrollOverflowConfigured = true; + } + + private applyVirtualScrollStyles(viewportEl: HTMLElement): void { + const contentWrapper = viewportEl.querySelector(this.SELECTOR_CDK_CONTENT_WRAPPER); + if (contentWrapper) { + this.renderer.setStyle(contentWrapper, 'contain', 'layout style'); + } + + if (this.headerScrollContainer?.nativeElement) { + this.renderer.setStyle(this.headerScrollContainer.nativeElement, 'overflow', 'hidden'); + this.renderer.setStyle(this.headerScrollContainer.nativeElement, 'will-change', 'scroll-position'); + } + } + + private registerScrollSyncListeners(viewportEl: HTMLElement): void { + const fixedInnerContainer = viewportEl.closest(this.SELECTOR_FIXED_INNER_CONTAINER); + + this.ngZone.runOutsideAngular(() => { + if (!this.scrollSyncListener) { + const handler = () => this.syncHeaderScrollLeft(viewportEl.scrollLeft); + viewportEl.addEventListener('scroll', handler, { passive: true }); + this.scrollSyncListener = () => viewportEl.removeEventListener('scroll', handler); + } + + if (fixedInnerContainer && !this.containerScrollSyncListener) { + const handler = () => this.syncHeaderScrollLeft(fixedInnerContainer.scrollLeft); + fixedInnerContainer.addEventListener('scroll', handler, { passive: true }); + this.containerScrollSyncListener = () => fixedInnerContainer.removeEventListener('scroll', handler); + } + }); + } + + private syncHeaderScrollLeft(scrollLeft: number): void { + if (this.headerScrollContainer?.nativeElement) { + this.headerScrollContainer.nativeElement.scrollLeft = scrollLeft; + } + } + + private updateScrollbarGutter(): void { + const viewportEl = this.tableVirtualScroll?.nativeElement as HTMLElement | undefined; + const headerEl = this.headerScrollContainer?.nativeElement as HTMLElement | undefined; + if (!viewportEl || !headerEl) { + return; + } + + const hasVerticalScroll = viewportEl.scrollHeight > viewportEl.clientHeight; + + if (!hasVerticalScroll) { + this.renderer.removeStyle(viewportEl, 'scrollbar-gutter'); + this.renderer.removeStyle(headerEl, 'border-right'); + this.renderer.removeStyle(headerEl, 'box-sizing'); + return; + } + + this.renderer.setStyle(viewportEl, 'scrollbar-gutter', 'stable'); + const forceReflow = viewportEl.offsetWidth; + const gutter = forceReflow - viewportEl.clientWidth; + + this.renderer.setStyle(headerEl, 'box-sizing', 'border-box'); + this.renderer.setStyle(headerEl, 'border-right', `${gutter}px solid transparent`); + } + + private setupColumnWidthSync(): void { + if (!this.virtualScroll || this.resizeObserver) return; + + const viewportEl = this.tableVirtualScroll?.nativeElement; + if (!viewportEl) return; + + this.resizeObserver = new ResizeObserver(this.syncColumnWidths.bind(this)); + this.resizeObserver.observe(viewportEl); + } + + private clearColumnWidths(): void { + const headerTable = this.headerTableElement?.nativeElement as HTMLElement | undefined; + const bodyTable = this.bodyTableElement?.nativeElement as HTMLElement | undefined; + + if (headerTable) { + this.resetTableLayout(headerTable); + } + + if (bodyTable) { + this.resetTableLayout(bodyTable); + } + + this.columnWidthsSynced = false; + this.lastSyncViewportWidth = -1; + this.maxRenderedRangeEnd = -1; + this.elasticNaturalMaxWidths = []; + this.computedColumnWidths = []; + } + + private resetTableLayout(table: HTMLElement): void { + this.removeColgroup(table); + this.renderer.removeStyle(table, 'table-layout'); + this.renderer.removeStyle(table, 'width'); + this.renderer.removeStyle(table, 'min-width'); + } + + private removeColgroup(table: HTMLElement): void { + const existingColgroup = table.querySelector(':scope > colgroup[data-po-sync="true"]'); + if (existingColgroup) { + existingColgroup.remove(); + } + } + + private applyColgroup(table: HTMLElement, widths: Array): void { + this.removeColgroup(table); + const colgroup = this.renderer.createElement('colgroup'); + this.renderer.setAttribute(colgroup, 'data-po-sync', 'true'); + widths.forEach(width => { + const col = this.renderer.createElement('col'); + this.renderer.setStyle(col, 'width', `${width}px`); + this.renderer.appendChild(colgroup, col); + }); + this.renderer.insertBefore(table, colgroup, table.firstChild); + } + + private syncColumnWidths(): void { + const headerTable = this.headerTableElement?.nativeElement as HTMLElement | undefined; + const bodyTable = this.bodyTableElement?.nativeElement as HTMLElement | undefined; + if (!headerTable || !bodyTable) { + return; + } + + const headerRow = headerTable.querySelector(this.SELECTOR_HEADER_ROW); + const bodyRow = bodyTable.querySelector(this.SELECTOR_BODY_DATA_ROW); + if (!headerRow || !bodyRow) { + return; + } + + const headerCells = Array.from(headerRow.children) as Array; + const bodyCells = Array.from(bodyRow.children) as Array; + const count = Math.min(headerCells.length, bodyCells.length); + if (!count) { + return; + } + + this.updateScrollbarGutter(); + + const viewportWidth = this.getViewportContentWidth(); + const renderedEnd = this.viewPort?.getRenderedRange().end ?? -1; + + // Guarda anti-loop do ResizeObserver: as próprias escritas de layout do sync (scrollbar-gutter, + // colgroup, table-layout) redisparam o observer. Se já sincronizou e nem a largura do viewport + // nem o range renderizado avançaram, não há o que recalcular. + // Exceção: com colunas fixas (frozen), os offsets sticky precisam de re-sincronizações extras + // para assentar; nesse caso não pulamos (o cache monotônico evita oscilação/flicker). + if ( + this.columnWidthsSynced && + !this.hasSomeFixed() && + viewportWidth === this.lastSyncViewportWidth && + renderedEnd <= this.maxRenderedRangeEnd + ) { + return; + } + + const naturalWidths = this.measureNaturalColumnWidths(headerTable, bodyTable, headerCells, bodyCells, count); + this.applyMonotonicElasticWidths(headerCells, naturalWidths); + const finalWidths = this.distributeColumnWidths(headerCells, naturalWidths); + const totalWidth = finalWidths.reduce((total, width) => total + width, 0); + + this.applySharedColumnLayout(headerTable, finalWidths, totalWidth); + this.applySharedColumnLayout(bodyTable, finalWidths, totalWidth); + + this.syncFrozenColumnOffsets(headerCells, bodyTable, count); + + this.lastSyncViewportWidth = viewportWidth; + if (renderedEnd > this.maxRenderedRangeEnd) { + this.maxRenderedRangeEnd = renderedEnd; + } + this.columnWidthsSynced = true; + this.syncHeaderTableWidth(); + this.syncHeaderScrollFromViewport(); + + this.reevaluateInfiniteScroll(); + + this.changeDetector.markForCheck(); + } + + /** + * Mantém, por coluna elástica (sem `width`), a maior largura natural já medida. No virtual scroll + * só existem no DOM as linhas renderizadas no momento; conforme novas linhas entram no buffer (na + * carga inicial ou ao rolar), a coluna cresce para caber o conteúdo mais largo e nunca encolhe, + * evitando o truncamento causado por medir apenas um subconjunto das linhas. + */ + private applyMonotonicElasticWidths(headerCells: Array, naturalWidths: Array): void { + const { elasticIndexes } = this.getColumnIndexes(headerCells); + if (!elasticIndexes.length) return; + + if (this.elasticNaturalMaxWidths.length !== naturalWidths.length) { + this.elasticNaturalMaxWidths = new Array(naturalWidths.length).fill(0); + } + + elasticIndexes.forEach(index => { + if (naturalWidths[index] > this.elasticNaturalMaxWidths[index]) { + this.elasticNaturalMaxWidths[index] = naturalWidths[index]; + } else { + naturalWidths[index] = this.elasticNaturalMaxWidths[index]; + } + }); + } + + /** + * No virtual scroll o cabeçalho e o corpo são tabelas separadas, e a diretiva `pFrozenColumn` + * calcula o offset `left`/`right` das colunas fixas por tabela e em `setTimeout` — o que não se + * alinha com a sincronização de largura (colgroup) e faz os offsets do header e do corpo + * divergirem (a "sombra" do frozen fica desalinhada ao rolar na horizontal). + * + * Aqui o componente passa a ser autoritativo: recalcula o `left` acumulado a partir das larguras + * já sincronizadas (idênticas nas duas tabelas) e aplica o MESMO valor nas `th` do header e nas + * `td` de todas as linhas renderizadas do corpo, garantindo o alinhamento header/corpo. + */ + private syncFrozenColumnOffsets(headerCells: Array, bodyTable: HTMLElement, count: number): void { + if (!this.hasSomeFixed()) return; + + const leftByIndex = new Map(); + const rightByIndex = new Map(); + let cumulativeLeft = 0; + + for (let i = 0; i < count; i++) { + const headerCell = headerCells[i]; + if (!headerCell?.classList.contains('po-table-column-fixed')) { + continue; + } + + if (headerCell.style.right && !headerCell.style.left) { + rightByIndex.set(i, headerCell.style.right); + continue; + } + + const left = `${cumulativeLeft - 1}px`; + this.renderer.setStyle(headerCell, 'left', left); + leftByIndex.set(i, left); + cumulativeLeft += headerCell.getBoundingClientRect().width; + } + + if (!leftByIndex.size && !rightByIndex.size) { + return; + } + + const bodyRows = bodyTable.querySelectorAll(this.SELECTOR_BODY_DATA_ROW); + bodyRows.forEach(row => { + const cells = row.children; + leftByIndex.forEach((left, index) => { + const cell = cells[index] as HTMLElement | undefined; + if (cell) { + this.renderer.addClass(cell, 'po-table-column-fixed'); + this.renderer.setStyle(cell, 'left', left); + this.renderer.removeStyle(cell, 'right'); + } + }); + rightByIndex.forEach((right, index) => { + const cell = cells[index] as HTMLElement | undefined; + if (cell) { + this.renderer.addClass(cell, 'po-table-column-fixed'); + this.renderer.setStyle(cell, 'right', right); + this.renderer.removeStyle(cell, 'left'); + } + }); + }); + } + + private syncHeaderScrollFromViewport(): void { + const viewportEl = this.tableVirtualScroll?.nativeElement as HTMLElement | undefined; + if (viewportEl) { + this.syncHeaderScrollLeft(viewportEl.scrollLeft); + } + } + + private reevaluateInfiniteScroll(): void { + if (this.requestedInfiniteScroll && !this.subscriptionScrollEvent && this.height > 0) { + this.infiniteScroll = true; + this.checkInfiniteScroll(); + } + } + + private measureNaturalColumnWidths( + headerTable: HTMLElement, + bodyTable: HTMLElement, + headerCells: Array, + bodyCells: Array, + count: number + ): Array { + this.resetTableLayout(headerTable); + this.resetTableLayout(bodyTable); + this.renderer.setStyle(headerTable, 'width', 'max-content'); + this.renderer.setStyle(bodyTable, 'width', 'max-content'); + + const widths: Array = []; + for (let i = 0; i < count; i++) { + widths.push(headerCells[i].getBoundingClientRect().width); + } + + const bodyRows = bodyTable.querySelectorAll(this.SELECTOR_BODY_DATA_ROW); + bodyRows.forEach(row => { + const cells = row.children; + const cellCount = Math.min(cells.length, count); + for (let i = 0; i < cellCount; i++) { + const cellWidth = (cells[i] as HTMLElement).getBoundingClientRect().width; + if (cellWidth > widths[i]) { + widths[i] = cellWidth; + } + } + }); + + return widths; + } + + private distributeColumnWidths(headerCells: Array, naturalWidths: Array): Array { + const widths = naturalWidths.slice(); + const containerWidth = this.getViewportContentWidth(); + + this.resolvePercentWidths(headerCells, widths, containerWidth); + + const { dataIndexes, elasticIndexes } = this.getColumnIndexes(headerCells); + const naturalTotal = widths.reduce((total, width) => total + width, 0); + const extraWidth = containerWidth - naturalTotal; + + let target = Math.round(naturalTotal); + let shouldFill = false; + + if (containerWidth > 0 && extraWidth > 0) { + shouldFill = true; + if (elasticIndexes.length) { + this.distributeAmong(widths, elasticIndexes, extraWidth); + target = containerWidth; + } else if (dataIndexes.length) { + this.distributeProportionally(widths, dataIndexes, extraWidth); + target = containerWidth; + } + } + + if (!shouldFill) { + return widths; + } + + const adjustIndexes = elasticIndexes.length ? elasticIndexes : dataIndexes; + return this.roundWidthsToTarget(widths, target, adjustIndexes); + } + + private getColumnIndexes(headerCells: Array): { + dataIndexes: Array; + elasticIndexes: Array; + } { + const dataIndexes: Array = []; + const elasticIndexes: Array = []; + let dataColumnIndex = 0; + + headerCells.forEach((cell, index) => { + if (cell.classList.contains('po-table-header-ellipsis')) { + dataIndexes.push(index); + const column = this.mainColumns[dataColumnIndex]; + if (column && !column.width) { + elasticIndexes.push(index); + } + dataColumnIndex++; + } + }); + + return { dataIndexes, elasticIndexes }; + } + + private resolvePercentWidths(headerCells: Array, widths: Array, containerWidth: number): void { + if (containerWidth <= 0) { + return; + } + + const percentColumns: Array<{ index: number; percent: number }> = []; + let dataColumnIndex = 0; + + headerCells.forEach((cell, index) => { + if (cell.classList.contains('po-table-header-ellipsis')) { + const width = this.mainColumns[dataColumnIndex]?.width; + if (typeof width === 'string' && width.trim().endsWith('%')) { + const percent = parseFloat(width); + if (!isNaN(percent) && percent > 0) { + percentColumns.push({ index, percent }); + } + } + dataColumnIndex++; + } + }); + + if (!percentColumns.length) { + return; + } + + const percentIndexes = new Set(percentColumns.map(column => column.index)); + const nonPercentTotal = widths.reduce( + (total, width, index) => (percentIndexes.has(index) ? total : total + width), + 0 + ); + const available = containerWidth - nonPercentTotal; + if (available <= 0) { + return; + } + + const totalPercent = percentColumns.reduce((total, column) => total + column.percent, 0); + const divisor = Math.max(totalPercent, 100); + percentColumns.forEach(column => { + const percentWidth = (column.percent / divisor) * available; + widths[column.index] = Math.max(percentWidth, widths[column.index]); + }); + } + + private distributeAmong(widths: Array, indexes: Array, extraWidth: number): void { + if (!indexes.length) { + return; + } + const share = extraWidth / indexes.length; + indexes.forEach(index => (widths[index] += share)); + } + + private distributeProportionally(widths: Array, dataIndexes: Array, extraWidth: number): void { + const dataTotal = dataIndexes.reduce((total, index) => total + widths[index], 0); + if (dataTotal > 0) { + dataIndexes.forEach(index => (widths[index] += extraWidth * (widths[index] / dataTotal))); + } + } + + private roundWidthsToTarget(widths: Array, target: number, adjustIndexes: Array): Array { + const rounded = widths.map(width => Math.max(0, Math.floor(width))); + const currentTotal = rounded.reduce((total, width) => total + width, 0); + let remainder = Math.round(target) - currentTotal; + + const fillIndexes = adjustIndexes.length ? adjustIndexes : rounded.map((_, index) => index); + for (let position = fillIndexes.length - 1; remainder > 0 && position >= 0; position--, remainder--) { + rounded[fillIndexes[position]] += 1; + } + + return rounded; + } + + private getViewportContentWidth(): number { + const viewportEl = this.tableVirtualScroll?.nativeElement as HTMLElement | undefined; + return viewportEl ? viewportEl.clientWidth : 0; + } + + private applySharedColumnLayout(table: HTMLElement, widths: Array, totalWidth: number): void { + this.applyColgroup(table, widths); + this.renderer.setStyle(table, 'table-layout', 'fixed'); + this.renderer.setStyle(table, 'width', `${totalWidth}px`); + this.renderer.setStyle(table, 'min-width', `${totalWidth}px`); + } + + private syncHeaderTableWidth(): void { + if (this.headerTableElement?.nativeElement) { + const newWidth = this.headerTableElement.nativeElement.scrollWidth; + if (newWidth !== this.headerTableScrollWidth) { + this.headerTableScrollWidth = newWidth; + this.changeDetector.markForCheck(); + } + } + } }