diff --git a/playwright/cps-accessibility.spec.ts b/playwright/cps-accessibility.spec.ts index 97cecc415..59789cf86 100644 --- a/playwright/cps-accessibility.spec.ts +++ b/playwright/cps-accessibility.spec.ts @@ -222,7 +222,29 @@ const components: ComponentEntry[] = [ name: 'Tabs', selector: '.example-content cps-tab-group' }, - // { route: '/table', name: 'Table', selector: 'cps-table' }, + { + route: '/table', + name: 'Table', + selector: 'cps-table', + states: [ + 'Table 1', + 'Table 2', + 'Table 3', + 'Table 4', + 'Table 5', + 'Table 6', + 'Table 7', + 'Table 8', + 'Table 9', + 'Table 10' + ].map((tab) => ({ + label: tab, + setup: async (page: Page) => { + await page.getByRole('tab', { name: tab, exact: true }).click(); + await page.waitForSelector('cps-table'); + } + })) + }, { route: '/tag', name: 'Tag', selector: 'cps-tag' }, { route: '/textarea', name: 'Textarea', selector: 'cps-textarea' }, { diff --git a/projects/composition/src/app/pages/table-page/table-page.component.html b/projects/composition/src/app/pages/table-page/table-page.component.html index c6ab2d55e..9543c6eee 100644 --- a/projects/composition/src/app/pages/table-page/table-page.component.html +++ b/projects/composition/src/app/pages/table-page/table-page.component.html @@ -46,7 +46,7 @@ [sortable]="true" [virtualScroll]="true" [showColumnsToggleBtn]="true" - scrollHeight="500px" + scrollHeight="31.25rem" toolbarTitle="Sortable table with resizable columns, virtual scroller, global filter, internal columns toggle and with column filtering enabled" [filterableByColumns]="true" [resizableColumns]="true"> @@ -158,7 +158,7 @@ [selectable]="true" toolbarSize="small" (rowsToRemove)="onRowsToRemove($event)" - scrollHeight="calc(100vh - 310px)" + scrollHeight="calc(100vh - 19.375rem)" toolbarTitle="Table with nested header and small toolbar"> @@ -252,7 +252,7 @@ - + A B @@ -266,7 +266,7 @@ @@ -274,7 +274,7 @@ console.log("pwned")', - b: '', + b: 'CPS UI Kit logo', c: 'null === undefined' } ]; diff --git a/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.html b/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.html index bdecee49f..b70781361 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.html @@ -20,6 +20,7 @@ + } @if (sortOrder === 1) { -
+ } @if (sortOrder === -1) { -
+ } @if (isMultiSorted()) { - {{ getBadgeValue() }} + } diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/cps-sort-icon/cps-sort-icon.component.scss b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/cps-sort-icon/cps-sort-icon.component.scss index 69eeccb84..177c810be 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/cps-sort-icon/cps-sort-icon.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/cps-sort-icon/cps-sort-icon.component.scss @@ -1,31 +1,58 @@ +@use '../../../../../../../styles/mixins' as *; + :host { + display: inline-flex; + align-items: center; + vertical-align: bottom; + cursor: pointer; + + height: 1.5rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + padding-right: 0.5rem; + margin-top: -0.1875rem; + margin-bottom: -0.25rem; + margin-right: -0.5rem; + + @media (max-width: 37.5rem) { + margin-top: 0; + margin-bottom: 0; + margin-right: 0; + } + + &:focus { + outline: none; + } + + &:focus-visible { + @include focus-ring(0.1875rem, 0.25rem, 0.25rem); + } + .cps-sortable-column-icon { display: inline-flex; - margin-left: 8px; + margin-left: 0.5rem; } .sort-desc { - border: 4px solid transparent; + border: 0.25rem solid transparent; border-bottom-color: var(--cps-color-calm); - margin-bottom: 4px; } .sort-asc { - border: 4px solid transparent; + border: 0.25rem solid transparent; border-top-color: var(--cps-color-calm); - margin-top: 4px; } .sort-unsorted { flex-direction: column; vertical-align: bottom; .sort-unsorted-arrow-up { display: inline-flex; - border: 4px solid transparent; + border: 0.25rem solid transparent; border-bottom-color: var(--cps-color-line-dark); - margin-bottom: 4px; - margin-top: -4px; + margin-bottom: 0.25rem; + margin-top: -0.25rem; } .sort-unsorted-arrow-down { display: inline-flex; - border: 4px solid transparent; + border: 0.25rem solid transparent; border-top-color: var(--cps-color-line-dark); } } diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/cps-sort-icon/cps-sort-icon.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/cps-sort-icon/cps-sort-icon.component.spec.ts new file mode 100644 index 000000000..3723c22a5 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/cps-sort-icon/cps-sort-icon.component.spec.ts @@ -0,0 +1,348 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Subject } from 'rxjs'; +import { Table } from 'primeng/table'; +import { CpsSortIconComponent } from './cps-sort-icon.component'; + +describe('CpsSortIconComponent', () => { + let component: CpsSortIconComponent; + let fixture: ComponentFixture; + let mockTable: Table; + let sortSourceSubject: Subject; + + beforeEach(async () => { + sortSourceSubject = new Subject(); + mockTable = Object.create(Table.prototype) as Table; + (mockTable as { ngOnDestroy: () => void }).ngOnDestroy = jest.fn(); + (mockTable as { tableService: unknown }).tableService = { + sortSource$: sortSourceSubject.asObservable() + }; + (mockTable as { sortMode: string }).sortMode = 'single'; + (mockTable as { sortOrder: number }).sortOrder = 1; + (mockTable as { _multiSortMeta: unknown })._multiSortMeta = undefined; + (mockTable as { showInitialSortBadge: boolean }).showInitialSortBadge = + false; + (mockTable as { groupRowsBy: unknown }).groupRowsBy = undefined; + jest.spyOn(mockTable, 'isSorted').mockReturnValue(true); + jest.spyOn(mockTable, 'getSortMeta').mockReturnValue(null); + jest.spyOn(mockTable, 'sort').mockImplementation(() => {}); + + await TestBed.configureTestingModule({ + imports: [CpsSortIconComponent, NoopAnimationsModule], + providers: [{ provide: Table, useValue: mockTable }] + }).compileComponents(); + + fixture = TestBed.createComponent(CpsSortIconComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('defaults', () => { + it('should default field to empty string', () => { + expect(component.field).toBe(''); + }); + + it('should expose the injected Table as dt', () => { + expect(component.dt).toBe(mockTable); + }); + }); + + describe('host bindings', () => { + it('should have tabindex 0', () => { + expect(fixture.nativeElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should have role button', () => { + expect(fixture.nativeElement.getAttribute('role')).toBe('button'); + }); + + it('should reflect sortAriaLabel in aria-label', () => { + expect(fixture.nativeElement.getAttribute('aria-label')).toBe( + component.sortAriaLabel + ); + }); + }); + + describe('sortAriaLabel', () => { + it('should return "Sort descending" when sortOrder is 1', () => { + component.sortOrder = 1; + expect(component.sortAriaLabel).toBe('Sort descending'); + }); + + it('should return "Remove sort" when sortOrder is -1', () => { + component.sortOrder = -1; + expect(component.sortAriaLabel).toBe('Remove sort'); + }); + + it('should return "Sort ascending" when sortOrder is 0', () => { + component.sortOrder = 0; + expect(component.sortAriaLabel).toBe('Sort ascending'); + }); + }); + + describe('updateSortState', () => { + it('should set sortOrder from table.sortOrder in single mode when isSorted is true', () => { + (mockTable as { sortMode: string }).sortMode = 'single'; + (mockTable as { sortOrder: number }).sortOrder = -1; + jest.mocked(mockTable.isSorted).mockReturnValue(true); + component.updateSortState(); + expect(component.sortOrder).toBe(-1); + }); + + it('should set sortOrder to 0 in single mode when isSorted is false', () => { + (mockTable as { sortMode: string }).sortMode = 'single'; + jest.mocked(mockTable.isSorted).mockReturnValue(false); + component.updateSortState(); + expect(component.sortOrder).toBe(0); + }); + + it('should set sortOrder from sortMeta.order in multiple mode', () => { + (mockTable as { sortMode: string }).sortMode = 'multiple'; + jest.mocked(mockTable.getSortMeta).mockReturnValue({ + field: 'name', + order: -1 + }); + component.updateSortState(); + expect(component.sortOrder).toBe(-1); + }); + + it('should set sortOrder to 0 in multiple mode when getSortMeta returns null', () => { + (mockTable as { sortMode: string }).sortMode = 'multiple'; + jest.mocked(mockTable.getSortMeta).mockReturnValue(null); + component.updateSortState(); + expect(component.sortOrder).toBe(0); + }); + + it('should call cd.markForCheck', () => { + jest.spyOn(component.cd, 'markForCheck'); + component.updateSortState(); + expect(component.cd.markForCheck).toHaveBeenCalled(); + }); + }); + + describe('sortSource$ subscription', () => { + it('should call updateSortState when sortSource$ emits', () => { + jest.spyOn(component, 'updateSortState'); + sortSourceSubject.next(null); + expect(component.updateSortState).toHaveBeenCalled(); + }); + }); + + describe('getMultiSortMetaIndex', () => { + it('should return -1 when _multiSortMeta is undefined', () => { + (mockTable as { _multiSortMeta: unknown })._multiSortMeta = undefined; + expect(component.getMultiSortMetaIndex()).toBe(-1); + }); + + it('should return -1 when sortMode is not multiple', () => { + (mockTable as { sortMode: string }).sortMode = 'single'; + (mockTable as { _multiSortMeta: unknown })._multiSortMeta = [ + { field: 'name', order: 1 } + ]; + expect(component.getMultiSortMetaIndex()).toBe(-1); + }); + + it('should return -1 when multiSortMeta has one entry and showInitialSortBadge is false', () => { + (mockTable as { sortMode: string }).sortMode = 'multiple'; + (mockTable as { showInitialSortBadge: boolean }).showInitialSortBadge = + false; + (mockTable as { _multiSortMeta: unknown })._multiSortMeta = [ + { field: 'name', order: 1 } + ]; + component.field = 'name'; + expect(component.getMultiSortMetaIndex()).toBe(-1); + }); + + it('should return index when showInitialSortBadge is true with single entry', () => { + (mockTable as { sortMode: string }).sortMode = 'multiple'; + (mockTable as { showInitialSortBadge: boolean }).showInitialSortBadge = + true; + (mockTable as { _multiSortMeta: unknown })._multiSortMeta = [ + { field: 'name', order: 1 } + ]; + component.field = 'name'; + expect(component.getMultiSortMetaIndex()).toBe(0); + }); + + it('should return correct index when multiSortMeta has multiple entries', () => { + (mockTable as { sortMode: string }).sortMode = 'multiple'; + (mockTable as { _multiSortMeta: unknown })._multiSortMeta = [ + { field: 'name', order: 1 }, + { field: 'age', order: -1 } + ]; + component.field = 'age'; + expect(component.getMultiSortMetaIndex()).toBe(1); + }); + + it('should return -1 when field is not in multiSortMeta', () => { + (mockTable as { sortMode: string }).sortMode = 'multiple'; + (mockTable as { _multiSortMeta: unknown })._multiSortMeta = [ + { field: 'name', order: 1 }, + { field: 'age', order: -1 } + ]; + component.field = 'email'; + expect(component.getMultiSortMetaIndex()).toBe(-1); + }); + }); + + describe('getBadgeValue', () => { + it('should return index + 1 when groupRowsBy is not set', () => { + (mockTable as { sortMode: string }).sortMode = 'multiple'; + (mockTable as { groupRowsBy: unknown }).groupRowsBy = undefined; + (mockTable as { _multiSortMeta: unknown })._multiSortMeta = [ + { field: 'name', order: 1 }, + { field: 'age', order: -1 } + ]; + component.field = 'age'; + expect(component.getBadgeValue()).toBe(2); + }); + + it('should return index (0-based) when groupRowsBy is set', () => { + (mockTable as { sortMode: string }).sortMode = 'multiple'; + (mockTable as { groupRowsBy: unknown }).groupRowsBy = 'category'; + (mockTable as { _multiSortMeta: unknown })._multiSortMeta = [ + { field: 'name', order: 1 }, + { field: 'age', order: -1 } + ]; + component.field = 'age'; + expect(component.getBadgeValue()).toBe(1); + }); + + it('should return 0 when field is not in multiSortMeta', () => { + (mockTable as { sortMode: string }).sortMode = 'single'; + (mockTable as { _multiSortMeta: unknown })._multiSortMeta = undefined; + component.field = 'name'; + expect(component.getBadgeValue()).toBe(0); + }); + }); + + describe('isMultiSorted', () => { + it('should return false when sortMode is single', () => { + (mockTable as { sortMode: string }).sortMode = 'single'; + expect(component.isMultiSorted()).toBe(false); + }); + + it('should return false when sortMode is multiple but field not found', () => { + (mockTable as { sortMode: string }).sortMode = 'multiple'; + (mockTable as { _multiSortMeta: unknown })._multiSortMeta = [ + { field: 'age', order: 1 } + ]; + component.field = 'name'; + expect(component.isMultiSorted()).toBe(false); + }); + + it('should return true when sortMode is multiple and field found in multi-entry metadata', () => { + (mockTable as { sortMode: string }).sortMode = 'multiple'; + (mockTable as { _multiSortMeta: unknown })._multiSortMeta = [ + { field: 'name', order: 1 }, + { field: 'age', order: -1 } + ]; + component.field = 'name'; + expect(component.isMultiSorted()).toBe(true); + }); + }); + + describe('onKeydown', () => { + let event: Event; + + beforeEach(() => { + event = { + preventDefault: jest.fn(), + stopPropagation: jest.fn() + } as unknown as Event; + }); + + it('should call preventDefault', () => { + component.onKeydown(event); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should call stopPropagation', () => { + component.onKeydown(event); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('should call tableInstance.sort with the current field', () => { + component.field = 'name'; + component.onKeydown(event); + expect(jest.mocked(mockTable.sort)).toHaveBeenCalledWith({ + field: 'name' + }); + }); + }); + + describe('ngOnDestroy', () => { + it('should unsubscribe from sortSource$', () => { + jest.spyOn(component.subscription, 'unsubscribe'); + component.ngOnDestroy(); + expect(component.subscription.unsubscribe).toHaveBeenCalled(); + }); + + it('should stop reacting to sortSource$ after destroy', () => { + component.ngOnDestroy(); + jest.spyOn(component, 'updateSortState'); + sortSourceSubject.next(null); + expect(component.updateSortState).not.toHaveBeenCalled(); + }); + }); + + describe('template', () => { + it('should render sort-unsorted when sortOrder is 0', () => { + component.sortOrder = 0; + component.cd.markForCheck(); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector('.sort-unsorted') + ).toBeTruthy(); + expect(fixture.nativeElement.querySelector('.sort-desc')).toBeNull(); + expect(fixture.nativeElement.querySelector('.sort-asc')).toBeNull(); + }); + + it('should render sort-desc when sortOrder is 1', () => { + component.sortOrder = 1; + component.cd.markForCheck(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.sort-desc')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('.sort-unsorted')).toBeNull(); + expect(fixture.nativeElement.querySelector('.sort-asc')).toBeNull(); + }); + + it('should render sort-asc when sortOrder is -1', () => { + component.sortOrder = -1; + component.cd.markForCheck(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.sort-asc')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('.sort-unsorted')).toBeNull(); + expect(fixture.nativeElement.querySelector('.sort-desc')).toBeNull(); + }); + + it('should not render badge when not multi-sorted', () => { + (mockTable as { sortMode: string }).sortMode = 'single'; + component.cd.markForCheck(); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector('.cps-sortable-column-badge') + ).toBeNull(); + }); + + it('should render badge with 1-based index when multi-sorted', () => { + (mockTable as { sortMode: string }).sortMode = 'multiple'; + (mockTable as { _multiSortMeta: unknown })._multiSortMeta = [ + { field: 'name', order: 1 }, + { field: 'age', order: -1 } + ]; + component.field = 'name'; + component.cd.markForCheck(); + fixture.detectChanges(); + const badge = fixture.nativeElement.querySelector( + '.cps-sortable-column-badge' + ); + expect(badge).toBeTruthy(); + expect(badge.textContent.trim()).toBe('1'); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/cps-sort-icon/cps-sort-icon.component.ts b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/cps-sort-icon/cps-sort-icon.component.ts index 7a9c9f903..a93f27d44 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/cps-sort-icon/cps-sort-icon.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/cps-sort-icon/cps-sort-icon.component.ts @@ -16,7 +16,14 @@ import { Subscription } from 'rxjs'; imports: [], templateUrl: './cps-sort-icon.component.html', styleUrls: ['./cps-sort-icon.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + tabindex: '0', + role: 'button', + '[attr.aria-label]': 'sortAriaLabel', + '(keydown.enter)': 'onKeydown($event)', + '(keydown.space)': 'onKeydown($event)' + } }) export class CpsSortIconComponent implements OnInit, OnDestroy { @Input() field = ''; @@ -44,8 +51,16 @@ export class CpsSortIconComponent implements OnInit, OnDestroy { this.updateSortState(); } - onClick(event: Event) { + onKeydown(event: Event): void { event.preventDefault(); + event.stopPropagation(); + this._tableInstance.sort({ field: this.field }); + } + + get sortAriaLabel(): string { + if (this.sortOrder === 1) return 'Sort descending'; + if (this.sortOrder === -1) return 'Remove sort'; + return 'Sort ascending'; } updateSortState() { diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter-constraint/table-column-filter-constraint.component.scss b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter-constraint/table-column-filter-constraint.component.scss index 6eb3b1947..57a6bdf73 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter-constraint/table-column-filter-constraint.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter-constraint/table-column-filter-constraint.component.scss @@ -4,7 +4,7 @@ justify-content: center; } .cps-table-col-filter-category-autocomplete { - min-width: 200px; - max-width: 400px; + min-width: 12.5rem; + max-width: 25rem; } } diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter-constraint/table-column-filter-constraint.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter-constraint/table-column-filter-constraint.component.spec.ts new file mode 100644 index 000000000..ce31517b6 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter-constraint/table-column-filter-constraint.component.spec.ts @@ -0,0 +1,244 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { FilterMetadata } from 'primeng/api'; +import { Table } from 'primeng/table'; +import { TableColumnFilterConstraintComponent } from './table-column-filter-constraint.component'; +import { CpsColumnFilterCategoryOption } from '../../../cps-column-filter-types'; + +describe('TableColumnFilterConstraintComponent', () => { + let component: TableColumnFilterConstraintComponent; + let fixture: ComponentFixture; + let mockTable: Table; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TableColumnFilterConstraintComponent, NoopAnimationsModule] + }).compileComponents(); + + fixture = TestBed.createComponent(TableColumnFilterConstraintComponent); + component = fixture.componentInstance; + + mockTable = Object.create(Table.prototype) as Table; + (mockTable as { ngOnDestroy: () => void }).ngOnDestroy = jest.fn(); + (mockTable as { value: unknown[] }).value = []; + jest.spyOn(mockTable, 'isFilterBlank').mockReturnValue(false); + jest.spyOn(mockTable, '_filter').mockImplementation(() => {}); + component._tableInstance = mockTable; + + fixture.detectChanges(); + }); + + afterEach(() => { + document.body + .querySelectorAll('.cps-menu-container, .cps-overlay-panel') + .forEach((el) => el.remove()); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('default values', () => { + it('should default type to "text"', () => { + expect(component.type).toBe('text'); + }); + + it('should default field to undefined', () => { + expect(component.field).toBeUndefined(); + }); + + it('should default filterConstraint to undefined', () => { + expect(component.filterConstraint).toBeUndefined(); + }); + + it('should default categoryOptions to []', () => { + expect(component.categoryOptions).toEqual([]); + }); + + it('should default asButtonToggle to false', () => { + expect(component.asButtonToggle).toBe(false); + }); + + it('should default singleSelection to false', () => { + expect(component.singleSelection).toBe(false); + }); + + it('should default placeholder to empty string', () => { + expect(component.placeholder).toBe(''); + }); + + it('should default hasApplyButton to true', () => { + expect(component.hasApplyButton).toBe(true); + }); + + it('should initialize booleanOptions with True and False entries', () => { + expect(component.booleanOptions).toEqual([ + { label: 'True', value: 'true' }, + { label: 'False', value: 'false' } + ]); + }); + + it('should initialize categories to empty array', () => { + expect(component.categories).toEqual([]); + }); + + it('should return false for isCategoryDropdownOpened when no autocomplete is mounted', () => { + expect(component.isCategoryDropdownOpened).toBe(false); + }); + }); + + describe('_updateCategories (via ngOnChanges)', () => { + it('should not populate categories when type is not "category"', () => { + fixture.componentRef.setInput('type', 'text'); + fixture.componentRef.setInput('categoryOptions', ['A', 'B']); + fixture.detectChanges(); + expect(component.categories).toEqual([]); + }); + + it('should convert string categoryOptions to {label, value} objects', () => { + fixture.componentRef.setInput('type', 'category'); + fixture.componentRef.setInput('categoryOptions', ['Apple', 'Banana']); + fixture.detectChanges(); + expect(component.categories).toEqual([ + { label: 'Apple', value: 'Apple' }, + { label: 'Banana', value: 'Banana' } + ]); + }); + + it('should use object categoryOptions as-is', () => { + const options: CpsColumnFilterCategoryOption[] = [ + { label: 'Option A', value: 1 }, + { label: 'Option B', value: 2 } + ]; + fixture.componentRef.setInput('type', 'category'); + fixture.componentRef.setInput('categoryOptions', options); + fixture.detectChanges(); + expect(component.categories).toEqual(options); + }); + + it('should derive categories from table data when categoryOptions is empty', () => { + (mockTable as { value: unknown[] }).value = [ + { name: 'Alice' }, + { name: 'Bob' }, + { name: 'Charlie' } + ]; + fixture.componentRef.setInput('type', 'category'); + fixture.componentRef.setInput('field', 'name'); + fixture.detectChanges(); + expect(component.categories).toEqual([ + { label: 'Alice', value: 'Alice' }, + { label: 'Bob', value: 'Bob' }, + { label: 'Charlie', value: 'Charlie' } + ]); + }); + + it('should deduplicate categories derived from table data', () => { + (mockTable as { value: unknown[] }).value = [ + { status: 'Active' }, + { status: 'Active' }, + { status: 'Inactive' } + ]; + fixture.componentRef.setInput('type', 'category'); + fixture.componentRef.setInput('field', 'status'); + fixture.detectChanges(); + expect(component.categories).toEqual([ + { label: 'Active', value: 'Active' }, + { label: 'Inactive', value: 'Inactive' } + ]); + }); + }); + + describe('onValueChange', () => { + let filterConstraint: FilterMetadata; + + beforeEach(() => { + filterConstraint = { value: null, matchMode: 'contains' }; + fixture.componentRef.setInput('filterConstraint', filterConstraint); + }); + + it('should update filterConstraint.value', () => { + component.onValueChange('test'); + expect(filterConstraint.value).toBe('test'); + }); + + it('should call _filter when value is blank', () => { + jest.mocked(mockTable.isFilterBlank).mockReturnValue(true); + component.onValueChange(''); + expect(jest.mocked(mockTable._filter)).toHaveBeenCalled(); + }); + + it('should call _filter when hasApplyButton is false', () => { + fixture.componentRef.setInput('hasApplyButton', false); + component.onValueChange('test'); + expect(jest.mocked(mockTable._filter)).toHaveBeenCalled(); + }); + + it('should not call _filter when value is not blank and hasApplyButton is true', () => { + jest.mocked(mockTable.isFilterBlank).mockReturnValue(false); + component.onValueChange('test'); + expect(jest.mocked(mockTable._filter)).not.toHaveBeenCalled(); + }); + }); + + describe('onEnterKeyDown', () => { + it('should call _filter', () => { + const event = { preventDefault: jest.fn() }; + component.onEnterKeyDown(event); + expect(jest.mocked(mockTable._filter)).toHaveBeenCalled(); + }); + + it('should call event.preventDefault', () => { + const event = { preventDefault: jest.fn() }; + component.onEnterKeyDown(event); + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); + + describe('template', () => { + it('should render cps-input for type "text"', () => { + expect(fixture.nativeElement.querySelector('cps-input')).toBeTruthy(); + }); + + it('should render cps-input for type "number"', () => { + fixture.componentRef.setInput('type', 'number'); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('cps-input')).toBeTruthy(); + }); + + it('should render cps-button-toggle for type "boolean"', () => { + fixture.componentRef.setInput('type', 'boolean'); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector('cps-button-toggle') + ).toBeTruthy(); + }); + + it('should render cps-datepicker for type "date"', () => { + fixture.componentRef.setInput('type', 'date'); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector('cps-datepicker') + ).toBeTruthy(); + }); + + it('should render cps-autocomplete for type "category" when asButtonToggle is false', () => { + fixture.componentRef.setInput('type', 'category'); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector('cps-autocomplete') + ).toBeTruthy(); + }); + + it('should render cps-button-toggle and no cps-autocomplete for type "category" when asButtonToggle is true', () => { + fixture.componentRef.setInput('type', 'category'); + fixture.componentRef.setInput('asButtonToggle', true); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector('cps-button-toggle') + ).toBeTruthy(); + expect( + fixture.nativeElement.querySelector('cps-autocomplete') + ).toBeNull(); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter/table-column-filter.component.html b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter/table-column-filter.component.html index 98a7d116b..0b06f4af0 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter/table-column-filter.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter/table-column-filter.component.html @@ -14,7 +14,13 @@ {{ headerTitle }} @if (showCloseButton) { - + } } @@ -33,7 +39,7 @@
@for ( fieldConstraint of fieldConstraints; - track fieldConstraint; + track $index; let i = $index ) {
@@ -66,7 +72,7 @@ type="borderless" width="100%" size="small" - color="prepared" + color="calm" icon="delete" (clicked)="removeConstraint(fieldConstraint)" label="Remove condition"> @@ -94,28 +100,33 @@ } @if (showApplyButton) { }
- > - + [class.cps-table-col-filter-menu-button-active]="isFilterApplied" + [attr.aria-label]=" + isFilterApplied ? 'Filter applied, open filter menu' : 'Open filter menu' + " + aria-haspopup="dialog" + [attr.aria-expanded]="isMenuOpen" + (click)="columnFilterMenu.toggle($event)"> + + + diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter/table-column-filter.component.scss b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter/table-column-filter.component.scss index 2a1bfda4c..1e86a5c7f 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter/table-column-filter.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter/table-column-filter.component.scss @@ -1,3 +1,5 @@ +@use '../../../../../../../styles/mixins' as *; + $color-calm: var(--cps-color-calm); $dark-text-color: var(--cps-color-text-dark); $darkest-text-color: var(--cps-color-text-darkest); @@ -5,6 +7,8 @@ $line-color: var(--cps-color-line-mid); $operator-bg-color: var(--cps-color-bg-light); :host { + margin-left: 0.15625rem; + .cps-table-col-filter { display: inline-flex; .cps-table-col-filter-menu-button { @@ -13,16 +17,42 @@ $operator-bg-color: var(--cps-color-bg-light); align-items: center; cursor: pointer; text-decoration: none; - overflow: hidden; position: relative; - padding-left: 8px; - padding-right: 4px; + height: 1.5rem; + width: 1.5rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + margin-right: 0.0625rem; + padding-top: 0.375rem; + padding-bottom: 0.375rem; + margin-top: -0.375rem; + margin-bottom: -0.375rem; + + @media (max-width: 37.5rem) { + margin-top: 0; + margin-bottom: 0; + } + + background: none; + border: none; + color: inherit; + font: inherit; + min-width: 1.5rem; + + &:focus { + outline: none; + } + + &:focus-visible { + @include focus-ring(0.1875rem, 0.25rem, 0.25rem); + } } .cps-table-col-filter-menu-button:not( - .cps-table-col-filter-menu-button-active - ) { - &:hover { + .cps-table-col-filter-menu-button-active + ) { + &:hover, + &:focus-visible { color: $dark-text-color; } } @@ -34,14 +64,14 @@ $operator-bg-color: var(--cps-color-bg-light); } .cps-table-col-filter-menu-content { - padding-bottom: 12px; - min-width: 200px; - max-height: 500px; + padding-bottom: 0.75rem; + min-width: 12.5rem; + max-height: 31.25rem; overflow: auto; & &-header { - min-height: 32px; - padding: 8px; - border-bottom: 1px solid $line-color; + min-height: 2rem; + padding: 0.5rem; + border-bottom: 0.0625rem solid $line-color; background: $operator-bg-color; display: flex; justify-content: space-between; @@ -49,28 +79,37 @@ $operator-bg-color: var(--cps-color-bg-light); &-title { font-family: 'Source Sans Pro', sans-serif; color: $darkest-text-color; - max-width: 390px; + max-width: 24.375rem; cursor: default; } - cps-icon { - margin-left: 8px; + .cps-table-col-filter-close-btn { + margin-left: 0.5rem; cursor: pointer; + background: none; + border: none; + padding: 0; + color: inherit; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + min-height: 1.5rem; &:hover { color: var(--cps-color-calm); } } } & &-operator { - padding: 12px; - border-bottom: 1px solid $line-color; + padding: 0.75rem; + border-bottom: 0.0625rem solid $line-color; background: $operator-bg-color; } & &-constraints { .cps-table-col-filter-menu-content-constraint { - border-bottom: 1px solid $line-color; - padding: 12px; + border-bottom: 0.0625rem solid $line-color; + padding: 0.75rem; .cps-table-col-filter-match-mode-select { - margin-bottom: 8px; + margin-bottom: 0.5rem; } } .cps-table-col-filter-menu-content-constraint:last-child { @@ -78,17 +117,17 @@ $operator-bg-color: var(--cps-color-bg-light); } .cps-table-col-filter-remove-rule-btn { - padding-top: 8px; + padding-top: 0.5rem; } } .cps-table-col-filter-add-rule-btn { - padding: 8px 12px; + padding: 0.5rem 0.75rem; } .cps-table-col-filter-buttonbar { display: flex; align-items: center; justify-content: space-between; - padding: 0 12px; - padding-top: 12px; + padding: 0 0.75rem; + padding-top: 0.75rem; } } diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter/table-column-filter.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter/table-column-filter.component.spec.ts new file mode 100644 index 000000000..f5fa520d5 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter/table-column-filter.component.spec.ts @@ -0,0 +1,616 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { EventEmitter } from '@angular/core'; +import { FilterMetadata, FilterOperator } from 'primeng/api'; +import { Table } from 'primeng/table'; +import { TableColumnFilterComponent } from './table-column-filter.component'; +import { CpsColumnFilterMatchMode } from '../../../cps-column-filter-types'; + +describe('TableColumnFilterComponent', () => { + let component: TableColumnFilterComponent; + let fixture: ComponentFixture; + let mockTable: Table; + let onFilterEmitter: EventEmitter; + + function buildMockTable(): Table { + onFilterEmitter = new EventEmitter(); + const t = Object.create(Table.prototype) as Table; + (t as { ngOnDestroy: () => void }).ngOnDestroy = jest.fn(); + (t as { filters: Record }).filters = {}; + (t as { onFilter: EventEmitter }).onFilter = onFilterEmitter; + jest.spyOn(t, 'isFilterBlank').mockReturnValue(false); + jest.spyOn(t, '_filter').mockImplementation(() => {}); + return t; + } + + beforeEach(async () => { + mockTable = buildMockTable(); + + await TestBed.configureTestingModule({ + imports: [TableColumnFilterComponent, NoopAnimationsModule], + providers: [{ provide: Table, useValue: mockTable }] + }).compileComponents(); + + fixture = TestBed.createComponent(TableColumnFilterComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('field', 'name'); + fixture.detectChanges(); + }); + + afterEach(() => { + document.body + .querySelectorAll('.cps-menu-container, .cps-overlay-panel') + .forEach((el) => el.remove()); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('default values', () => { + it('should default type to "text"', () => { + expect(component.type).toBe('text'); + }); + + it('should default persistent to false', () => { + expect(component.persistent).toBe(false); + }); + + it('should default showClearButton to true', () => { + expect(component.showClearButton).toBe(true); + }); + + it('should default showCloseButton to false', () => { + expect(component.showCloseButton).toBe(false); + }); + + it('should default showMatchModes to true', () => { + expect(component.showMatchModes).toBe(true); + }); + + it('should default showOperator to true', () => { + expect(component.showOperator).toBe(true); + }); + + it('should default maxConstraints to 2', () => { + expect(component.maxConstraints).toBe(2); + }); + + it('should default headerTitle to empty string', () => { + expect(component.headerTitle).toBe(''); + }); + + it('should default hideOnClear to false', () => { + expect(component.hideOnClear).toBe(false); + }); + + it('should default asButtonToggle to false', () => { + expect(component.asButtonToggle).toBe(false); + }); + + it('should default singleSelection to false', () => { + expect(component.singleSelection).toBe(false); + }); + + it('should default placeholder to empty string', () => { + expect(component.placeholder).toBe(''); + }); + + it('should default operator to AND', () => { + expect(component.operator).toBe(FilterOperator.AND); + }); + + it('should default isFilterApplied to false', () => { + expect(component.isFilterApplied).toBe(false); + }); + + it('should default isMenuOpen to false', () => { + expect(component.isMenuOpen).toBe(false); + }); + + it('should expose Match All and Match Any operatorOptions', () => { + expect(component.operatorOptions).toEqual([ + { label: 'Match All', value: FilterOperator.AND, info: 'AND' }, + { label: 'Match Any', value: FilterOperator.OR, info: 'OR' } + ]); + }); + }); + + describe('ngOnInit', () => { + it('should force showApplyButton true when maxConstraints > 1 and type is not category', () => { + expect(component.showApplyButton).toBe(true); + }); + + it('should force showApplyButton false when type is "boolean"', () => { + (mockTable as any).filters = {}; + const f = TestBed.createComponent(TableColumnFilterComponent); + f.componentRef.setInput('field', 'name'); + f.componentRef.setInput('type', 'boolean'); + f.detectChanges(); + expect(f.componentInstance.showApplyButton).toBe(false); + }); + + it('should initialize field filter constraint when field is not in filters', () => { + const constraints = (mockTable as any).filters.name as FilterMetadata[]; + expect(Array.isArray(constraints)).toBe(true); + expect(constraints.length).toBe(1); + expect(constraints[0].value).toBeNull(); + expect(constraints[0].matchMode).toBe( + CpsColumnFilterMatchMode.STARTS_WITH + ); + expect(constraints[0].operator).toBe(FilterOperator.AND); + }); + + it('should not reinitialize filter constraint when field is already in filters', () => { + (mockTable as any).filters.name = [ + { value: 'existing', matchMode: 'contains', operator: 'and' } + ]; + const f = TestBed.createComponent(TableColumnFilterComponent); + f.componentRef.setInput('field', 'name'); + f.detectChanges(); + expect( + ((mockTable as any).filters.name as FilterMetadata[])[0].value + ).toBe('existing'); + }); + + it('should override match modes when matchModes input is provided', () => { + (mockTable as any).filters = {}; + const f = TestBed.createComponent(TableColumnFilterComponent); + f.componentRef.setInput('field', 'name'); + f.componentRef.setInput('matchModes', [ + CpsColumnFilterMatchMode.EQUALS, + CpsColumnFilterMatchMode.NOT_EQUALS + ]); + f.detectChanges(); + expect(f.componentInstance.currentMatchModes?.length).toBe(2); + expect(f.componentInstance.currentMatchModes?.[0].value).toBe( + CpsColumnFilterMatchMode.EQUALS + ); + }); + }); + + describe('currentMatchModes', () => { + function createWithType(type: string): TableColumnFilterComponent { + const f = TestBed.createComponent(TableColumnFilterComponent); + f.componentRef.setInput('field', 'name'); + f.componentRef.setInput('type', type as never); + f.detectChanges(); + return f.componentInstance; + } + + it('should set 6 match modes for type "text"', () => { + expect(component.currentMatchModes?.length).toBe(6); + }); + + it('should set 6 match modes for type "number"', () => { + expect(createWithType('number').currentMatchModes?.length).toBe(6); + }); + + it('should set 4 match modes for type "date"', () => { + expect(createWithType('date').currentMatchModes?.length).toBe(4); + }); + + it('should set currentMatchModes to undefined for type "category"', () => { + expect(createWithType('category').currentMatchModes).toBeUndefined(); + }); + + it('should set currentMatchModes to undefined for type "boolean"', () => { + expect(createWithType('boolean').currentMatchModes).toBeUndefined(); + }); + }); + + describe('_getDefaultMatchMode (via initialized filter constraint)', () => { + function constraintMatchMode( + type: string, + singleSelection = false + ): string { + const t = buildMockTable(); + const f = TestBed.createComponent(TableColumnFilterComponent); + (f.componentInstance as any)._tableInstance = t; + f.componentRef.setInput('field', 'col'); + f.componentRef.setInput('type', type as never); + f.componentRef.setInput('singleSelection', singleSelection); + f.detectChanges(); + return ((t as any).filters.col as FilterMetadata[])?.[0]?.matchMode ?? ''; + } + + it('should use startsWith for type "text"', () => { + const constraint = ( + (mockTable as any).filters.name as FilterMetadata[] + )[0]; + expect(constraint.matchMode).toBe(CpsColumnFilterMatchMode.STARTS_WITH); + }); + + it('should use equals for type "number"', () => { + expect(constraintMatchMode('number')).toBe( + CpsColumnFilterMatchMode.EQUALS + ); + }); + + it('should use dateIs for type "date"', () => { + expect(constraintMatchMode('date')).toBe( + CpsColumnFilterMatchMode.DATE_IS + ); + }); + + it('should use "in" for category with singleSelection=false', () => { + expect(constraintMatchMode('category', false)).toBe( + CpsColumnFilterMatchMode.IN + ); + }); + + it('should use "is" for category with singleSelection=true', () => { + expect(constraintMatchMode('category', true)).toBe( + CpsColumnFilterMatchMode.IS + ); + }); + }); + + describe('fieldConstraints', () => { + it('should return the array of constraints for the field', () => { + const constraints = component.fieldConstraints; + expect(Array.isArray(constraints)).toBe(true); + expect(constraints!.length).toBe(1); + }); + + it('should return null when filters is empty', () => { + (mockTable as any).filters = undefined; + expect(component.fieldConstraints).toBeNull(); + }); + }); + + describe('showRemoveIcon', () => { + it('should be false when there is one constraint', () => { + expect(component.showRemoveIcon).toBe(false); + }); + + it('should be true when there are two or more constraints', () => { + component.addConstraint(); + expect(component.showRemoveIcon).toBe(true); + }); + }); + + describe('isShowOperator', () => { + it('should be true with default settings (type="text", maxConstraints=2)', () => { + expect(component.isShowOperator).toBe(true); + }); + + it('should be false when showOperator is false', () => { + fixture.componentRef.setInput('showOperator', false); + expect(component.isShowOperator).toBe(false); + }); + + it('should be false when maxConstraints is 1', () => { + fixture.componentRef.setInput('maxConstraints', 1); + expect(component.isShowOperator).toBe(false); + }); + + it('should be false when type is "boolean"', () => { + fixture.componentRef.setInput('type', 'boolean'); + expect(component.isShowOperator).toBe(false); + }); + + it('should be false when type is "category"', () => { + fixture.componentRef.setInput('type', 'category'); + expect(component.isShowOperator).toBe(false); + }); + }); + + describe('isShowAddConstraint', () => { + it('should be true when constraints < maxConstraints', () => { + expect(component.isShowAddConstraint).toBe(true); + }); + + it('should be false when constraints equals maxConstraints', () => { + component.addConstraint(); + expect(component.isShowAddConstraint).toBe(false); + }); + + it('should be false when type is "boolean"', () => { + fixture.componentRef.setInput('type', 'boolean'); + expect(component.isShowAddConstraint).toBeFalsy(); + }); + + it('should be false when type is "category"', () => { + fixture.componentRef.setInput('type', 'category'); + expect(component.isShowAddConstraint).toBeFalsy(); + }); + }); + + describe('isCategoryDropdownOpened', () => { + it('should return false when type is not "category"', () => { + expect(component.isCategoryDropdownOpened).toBe(false); + }); + }); + + describe('addConstraint', () => { + it('should add a new constraint to the field filters', () => { + component.addConstraint(); + const constraints = (mockTable as any).filters.name as FilterMetadata[]; + expect(constraints.length).toBe(2); + }); + + it('should set new constraint value to null', () => { + component.addConstraint(); + const constraints = (mockTable as any).filters.name as FilterMetadata[]; + expect(constraints[1].value).toBeNull(); + }); + + it('should inherit the current operator on the new constraint', () => { + component.addConstraint(); + const constraints = (mockTable as any).filters.name as FilterMetadata[]; + expect(constraints[1].operator).toBe(FilterOperator.AND); + }); + }); + + describe('removeConstraint', () => { + it('should remove the specified constraint', () => { + component.addConstraint(); + const constraints = (mockTable as any).filters.name as FilterMetadata[]; + const toRemove = constraints[0]; + component.removeConstraint(toRemove); + expect(((mockTable as any).filters.name as FilterMetadata[]).length).toBe( + 1 + ); + expect((mockTable as any).filters.name).not.toContain(toRemove); + }); + + it('should call _filter after removing', () => { + component.addConstraint(); + const first = ((mockTable as any).filters.name as FilterMetadata[])[0]; + component.removeConstraint(first); + expect(jest.mocked(mockTable._filter)).toHaveBeenCalled(); + }); + }); + + describe('onMenuMatchModeChange', () => { + it('should update filterMeta.matchMode', () => { + const meta: FilterMetadata = { value: null, matchMode: 'contains' }; + component.onMenuMatchModeChange('equals', meta); + expect(meta.matchMode).toBe('equals'); + }); + + it('should call _filter when showApplyButton is false', () => { + fixture.componentRef.setInput('showApplyButton', false); + const meta: FilterMetadata = { value: null, matchMode: 'contains' }; + component.onMenuMatchModeChange('equals', meta); + expect(jest.mocked(mockTable._filter)).toHaveBeenCalled(); + }); + + it('should not call _filter when showApplyButton is true', () => { + const meta: FilterMetadata = { value: null, matchMode: 'contains' }; + component.onMenuMatchModeChange('equals', meta); + expect(jest.mocked(mockTable._filter)).not.toHaveBeenCalled(); + }); + }); + + describe('onOperatorChange', () => { + it('should update operator on component', () => { + component.onOperatorChange(FilterOperator.OR); + expect(component.operator).toBe(FilterOperator.OR); + }); + + it('should update operator on all constraints', () => { + component.addConstraint(); + component.onOperatorChange(FilterOperator.OR); + const constraints = (mockTable as any).filters.name as FilterMetadata[]; + expect(constraints.every((c) => c.operator === FilterOperator.OR)).toBe( + true + ); + }); + + it('should call _filter when showApplyButton is false', () => { + fixture.componentRef.setInput('showApplyButton', false); + component.onOperatorChange(FilterOperator.OR); + expect(jest.mocked(mockTable._filter)).toHaveBeenCalled(); + }); + + it('should not call _filter when showApplyButton is true', () => { + component.onOperatorChange(FilterOperator.OR); + expect(jest.mocked(mockTable._filter)).not.toHaveBeenCalled(); + }); + }); + + describe('clearFilter', () => { + it('should reset filter constraint value to null', () => { + ((mockTable as any).filters.name as FilterMetadata[])[0].value = 'test'; + component.clearFilter(); + expect( + ((mockTable as any).filters.name as FilterMetadata[])[0].value + ).toBeNull(); + }); + + it('should call _filter', () => { + component.clearFilter(); + expect(jest.mocked(mockTable._filter)).toHaveBeenCalled(); + }); + + it('should call hide when hideOnClear is true', () => { + fixture.componentRef.setInput('hideOnClear', true); + jest.spyOn(component.columnFilterMenu, 'hide'); + component.clearFilter(); + expect(component.columnFilterMenu.hide).toHaveBeenCalled(); + }); + + it('should not call hide when hideOnClear is false', () => { + jest.spyOn(component.columnFilterMenu, 'hide'); + component.clearFilter(); + expect(component.columnFilterMenu.hide).not.toHaveBeenCalled(); + }); + }); + + describe('clearFilterValues', () => { + it('should reset filter constraint value to null', () => { + ((mockTable as any).filters.name as FilterMetadata[])[0].value = 'test'; + component.clearFilterValues(); + expect( + ((mockTable as any).filters.name as FilterMetadata[])[0].value + ).toBeNull(); + }); + + it('should set isFilterApplied to false', () => { + component.isFilterApplied = true; + component.clearFilterValues(); + expect(component.isFilterApplied).toBe(false); + }); + }); + + describe('applyFilter', () => { + it('should call _filter', () => { + component.applyFilter(); + expect(jest.mocked(mockTable._filter)).toHaveBeenCalled(); + }); + + it('should call columnFilterMenu.hide', () => { + jest.spyOn(component.columnFilterMenu, 'hide'); + component.applyFilter(); + expect(component.columnFilterMenu.hide).toHaveBeenCalled(); + }); + }); + + describe('onMenuShown / onMenuHidden', () => { + it('should set isMenuOpen to true on menu shown', () => { + component.onMenuShown(); + expect(component.isMenuOpen).toBe(true); + }); + + it('should add class to parent element on menu shown', () => { + component.onMenuShown(); + expect( + component.elementRef.nativeElement.parentElement?.classList.contains( + 'cps-table-col-filter-menu-open' + ) + ).toBe(true); + }); + + it('should set isMenuOpen to false on menu hidden', () => { + component.onMenuShown(); + component.onMenuHidden(); + expect(component.isMenuOpen).toBe(false); + }); + + it('should remove class from parent element on menu hidden', () => { + component.onMenuShown(); + component.onMenuHidden(); + expect( + component.elementRef.nativeElement.parentElement?.classList.contains( + 'cps-table-col-filter-menu-open' + ) + ).toBe(false); + }); + }); + + describe('onBeforeMenuHidden', () => { + it('should reset filter constraint when filter is not applied', () => { + ((mockTable as any).filters.name as FilterMetadata[])[0].value = 'test'; + component.onBeforeMenuHidden(); + expect( + ((mockTable as any).filters.name as FilterMetadata[])[0].value + ).toBeNull(); + }); + + it('should not reset filter when filter is applied', () => { + component.isFilterApplied = true; + ((mockTable as any).filters.name as FilterMetadata[])[0].value = 'test'; + component.onBeforeMenuHidden(); + expect( + ((mockTable as any).filters.name as FilterMetadata[])[0].value + ).toBe('test'); + }); + }); + + describe('_updateFilterApplied (via onFilter)', () => { + it('should set isFilterApplied to true when any constraint value is not blank', () => { + jest.mocked(mockTable.isFilterBlank).mockReturnValue(false); + onFilterEmitter.emit({ + filters: { name: [{ value: 'test', matchMode: 'contains' }] } + }); + expect(component.isFilterApplied).toBe(true); + }); + + it('should set isFilterApplied to false when all constraint values are blank', () => { + jest.mocked(mockTable.isFilterBlank).mockReturnValue(true); + onFilterEmitter.emit({ + filters: { name: [{ value: '', matchMode: 'contains' }] } + }); + expect(component.isFilterApplied).toBe(false); + }); + + it('should set isFilterApplied to false when field is not in filters', () => { + onFilterEmitter.emit({ filters: {} }); + expect(component.isFilterApplied).toBe(false); + }); + + it('should handle non-array (single) filter metadata', () => { + jest.mocked(mockTable.isFilterBlank).mockReturnValue(false); + onFilterEmitter.emit({ + filters: { name: { value: 'test', matchMode: 'contains' } } + }); + expect(component.isFilterApplied).toBe(true); + }); + }); + + describe('onClick', () => { + it('should stop event propagation', () => { + const event = { stopPropagation: jest.fn() } as unknown as MouseEvent; + component.onClick(event); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + }); + + describe('template', () => { + it('should render the filter menu button', () => { + expect( + fixture.nativeElement.querySelector( + 'button.cps-table-col-filter-menu-button' + ) + ).toBeTruthy(); + }); + + it('should set aria-label to "Open filter menu" when filter is not applied', () => { + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector( + 'button.cps-table-col-filter-menu-button' + ); + expect(btn.getAttribute('aria-label')).toBe('Open filter menu'); + }); + + it('should set aria-label to "Filter applied, open filter menu" when filter is applied', () => { + component.isFilterApplied = true; + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector( + 'button.cps-table-col-filter-menu-button' + ); + expect(btn.getAttribute('aria-label')).toBe( + 'Filter applied, open filter menu' + ); + }); + + it('should set aria-expanded to false when menu is closed', () => { + const btn = fixture.nativeElement.querySelector( + 'button.cps-table-col-filter-menu-button' + ); + expect(btn.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should set aria-expanded to true when menu is open', () => { + component.onMenuShown(); + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector( + 'button.cps-table-col-filter-menu-button' + ); + expect(btn.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should add active class to button when filter is applied', () => { + component.isFilterApplied = true; + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector( + 'button.cps-table-col-filter-menu-button' + ); + expect( + btn.classList.contains('cps-table-col-filter-menu-button-active') + ).toBe(true); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter/table-column-filter.component.ts b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter/table-column-filter.component.ts index a0d74f1f7..8d856e0e0 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter/table-column-filter.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-filter/table-column-filter.component.ts @@ -202,6 +202,7 @@ export class TableColumnFilterComponent implements OnInit, OnDestroy { private _onFilterSub?: Subscription; isFilterApplied = false; + isMenuOpen = false; get isCategoryDropdownOpened() { if (this.type !== 'category') return false; @@ -428,6 +429,7 @@ export class TableColumnFilterComponent implements OnInit, OnDestroy { } onMenuShown() { + this.isMenuOpen = true; const parent = this.elementRef?.nativeElement?.parentElement; const className = 'cps-table-col-filter-menu-open'; parent.classList.add(className); @@ -438,6 +440,7 @@ export class TableColumnFilterComponent implements OnInit, OnDestroy { } onMenuHidden() { + this.isMenuOpen = false; const parent = this.elementRef?.nativeElement?.parentElement; const className = 'cps-table-col-filter-menu-open'; parent.classList.remove(className); diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component.html b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component.html new file mode 100644 index 000000000..31031ed68 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component.html @@ -0,0 +1,62 @@ + + +
+
+ + + Show all columns + +
+ @for (col of columns(); track col; let i = $index) { +
+ + + {{ + col[colHeaderName()] + }} + +
+ } +
+
diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component.scss b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component.scss new file mode 100644 index 000000000..f27343131 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component.scss @@ -0,0 +1,85 @@ +.cps-table-coltoggle-menu { + display: block; + max-height: 15.125rem; + overflow-x: hidden; + overflow-y: auto; + background: white; + + .cps-table-coltoggle-menu-item { + padding: 0.75rem; + justify-content: space-between; + display: flex; + cursor: pointer; + + &:hover { + background: var(--cps-color-highlight-hover); + } + + &-label { + color: var(--cps-color-text-dark); + } + + &-left { + display: flex; + align-items: center; + margin-right: 0.5rem; + } + + &-check { + background-color: transparent; + border: 0; + width: 1rem; + height: 1rem; + cursor: pointer; + display: inline-block; + vertical-align: middle; + box-sizing: border-box; + position: relative; + flex-shrink: 0; + transition: + border-color 90ms cubic-bezier(0, 0, 0.2, 0.1), + background-color 90ms cubic-bezier(0, 0, 0.2, 0.1); + margin-right: 0.5rem; + opacity: 0; + &::after { + color: var(--cps-color-calm); + top: 0.25rem; + left: 0.0625rem; + width: 0.5rem; + height: 0.1875rem; + border-left: 0.125rem solid currentColor; + transform: rotate(-45deg); + opacity: 1; + box-sizing: content-box; + position: absolute; + content: ''; + border-bottom: 0.125rem solid currentColor; + transition: opacity 90ms cubic-bezier(0, 0, 0.2, 0.1); + } + } + &.selected, + &.allselected { + font-weight: 600; + .cps-table-coltoggle-menu-item-label { + color: var(--cps-color-calm); + } + .cps-table-coltoggle-menu-item-check { + opacity: 1; + } + } + &.selected { + background: var(--cps-color-highlight-selected); + } + &.highlighten { + background: var(--cps-color-highlight-active); + } + &.selected.highlighten { + background: var(--cps-color-highlight-selected-dark); + } + } + + .select-all-option { + border-bottom: 0.0625rem solid lightgrey; + font-weight: 600; + } +} diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component.spec.ts new file mode 100644 index 000000000..67d85422e --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component.spec.ts @@ -0,0 +1,338 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TableColumnVisibilityToggleComponent } from './table-column-visibility-toggle.component'; + +const COLUMNS = [ + { header: 'Name', field: 'name' }, + { header: 'Age', field: 'age' }, + { header: 'City', field: 'city' } +]; + +describe('TableColumnVisibilityToggleComponent', () => { + let component: TableColumnVisibilityToggleComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TableColumnVisibilityToggleComponent, NoopAnimationsModule] + }).compileComponents(); + + fixture = TestBed.createComponent(TableColumnVisibilityToggleComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('columns', COLUMNS); + fixture.componentRef.setInput('selectedColumns', [...COLUMNS]); + fixture.detectChanges(); + }); + + afterEach(() => { + document.body + .querySelectorAll('.cps-menu-container') + .forEach((el) => el.remove()); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('default values', () => { + it('should default columns to empty array', () => { + const f = TestBed.createComponent(TableColumnVisibilityToggleComponent); + expect(f.componentInstance.columns()).toEqual([]); + }); + + it('should default selectedColumns to empty array', () => { + const f = TestBed.createComponent(TableColumnVisibilityToggleComponent); + expect(f.componentInstance.selectedColumns()).toEqual([]); + }); + + it('should default disabled to false', () => { + expect(component.disabled()).toBe(false); + }); + + it('should default colHeaderName to "header"', () => { + expect(component.colHeaderName()).toBe('header'); + }); + + it('should default isMenuOpen to false', () => { + expect(component.isMenuOpen()).toBe(false); + }); + + it('should default highlightedIndex to -1', () => { + expect(component.highlightedIndex()).toBe(-1); + }); + + it('should default activeDescendantId to null', () => { + expect(component.activeDescendantId()).toBeNull(); + }); + }); + + describe('host class bindings', () => { + it('should always have cps-table-tbar-coltoggle-btn class', () => { + expect( + fixture.nativeElement.classList.contains('cps-table-tbar-coltoggle-btn') + ).toBe(true); + }); + }); + + describe('toggle button', () => { + it('should render the toggle button', () => { + const btn = fixture.nativeElement.querySelector( + 'button.cps-table-tbar-icon-btn' + ); + expect(btn).toBeTruthy(); + }); + + it('should set aria-label on the toggle button', () => { + const btn = fixture.nativeElement.querySelector('button'); + expect(btn.getAttribute('aria-label')).toBe('Toggle column visibility'); + }); + + it('should set aria-expanded to false when menu is closed', () => { + const btn = fixture.nativeElement.querySelector('button'); + expect(btn.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should set aria-expanded to true when menu is open', () => { + component.onMenuShown(); + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector('button'); + expect(btn.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should be disabled when disabled input is true', () => { + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector('button'); + expect(btn.disabled).toBe(true); + }); + }); + + describe('listbox (when menu is open)', () => { + beforeEach(() => { + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + }); + + it('should render the listbox container in document.body', () => { + expect(document.body.querySelector('[role="listbox"]')).toBeTruthy(); + }); + + it('should render "Show all columns" option first', () => { + const firstOption = document.body.querySelector('[role="option"]'); + expect(firstOption?.textContent).toContain('Show all columns'); + }); + + it('should render one option per column plus the select-all', () => { + const options = document.body.querySelectorAll('[role="option"]'); + expect(options.length).toBe(COLUMNS.length + 1); + }); + + it('should display column headers using colHeaderName', () => { + const labels = document.body.querySelectorAll( + '.cps-table-coltoggle-menu-item-label' + ); + expect(labels[1].textContent?.trim()).toBe('Name'); + expect(labels[2].textContent?.trim()).toBe('Age'); + expect(labels[3].textContent?.trim()).toBe('City'); + }); + + it('should mark select-all as allselected when all columns are selected', () => { + const selectAll = document.body.querySelector('.select-all-option'); + expect(selectAll?.classList.contains('allselected')).toBe(true); + }); + + it('should not mark select-all as allselected when some columns are hidden', () => { + fixture.componentRef.setInput('selectedColumns', [COLUMNS[0]]); + fixture.detectChanges(); + const selectAll = document.body.querySelector('.select-all-option'); + expect(selectAll?.classList.contains('allselected')).toBe(false); + }); + + it('should mark selected column items with selected class', () => { + fixture.componentRef.setInput('selectedColumns', [COLUMNS[0]]); + fixture.detectChanges(); + const options = document.body.querySelectorAll( + '.cps-table-coltoggle-menu-item:not(.select-all-option)' + ); + expect(options[0].classList.contains('selected')).toBe(true); + expect(options[1].classList.contains('selected')).toBe(false); + }); + + it('should apply highlighten class to the highlighted item', () => { + component.highlightedIndex.set(1); + fixture.detectChanges(); + const options = document.body.querySelectorAll('[role="option"]'); + expect(options[1].classList.contains('highlighten')).toBe(true); + expect(options[0].classList.contains('highlighten')).toBe(false); + }); + }); + + describe('activeDescendantId', () => { + it('should return null when no item is highlighted', () => { + expect(component.activeDescendantId()).toBeNull(); + }); + + it('should return the id for the highlighted item', () => { + component.highlightedIndex.set(2); + expect(component.activeDescendantId()).toBe( + `${component.listboxId}-opt-2` + ); + }); + }); + + describe('isColumnSelected', () => { + it('should return true for a column in selectedColumns', () => { + expect(component.isColumnSelected(COLUMNS[0])).toBe(true); + }); + + it('should return false for a column not in selectedColumns', () => { + fixture.componentRef.setInput('selectedColumns', [COLUMNS[0]]); + expect(component.isColumnSelected(COLUMNS[1])).toBe(false); + }); + }); + + describe('toggleAllColumns', () => { + it('should emit all columns when currently partially selected', () => { + fixture.componentRef.setInput('selectedColumns', [COLUMNS[0]]); + jest.spyOn(component.selectedColumnsChange, 'emit'); + component.toggleAllColumns(); + expect(component.selectedColumnsChange.emit).toHaveBeenCalledWith( + COLUMNS + ); + }); + + it('should emit empty array when all columns are already selected', () => { + jest.spyOn(component.selectedColumnsChange, 'emit'); + component.toggleAllColumns(); + expect(component.selectedColumnsChange.emit).toHaveBeenCalledWith([]); + }); + }); + + describe('onSelectColumn', () => { + it('should emit selection with column added when not already selected', () => { + fixture.componentRef.setInput('selectedColumns', [COLUMNS[0]]); + jest.spyOn(component.selectedColumnsChange, 'emit'); + component.onSelectColumn(COLUMNS[1]); + expect(component.selectedColumnsChange.emit).toHaveBeenCalledWith([ + COLUMNS[0], + COLUMNS[1] + ]); + }); + + it('should emit selection with column removed when already selected', () => { + jest.spyOn(component.selectedColumnsChange, 'emit'); + component.onSelectColumn(COLUMNS[1]); + expect(component.selectedColumnsChange.emit).toHaveBeenCalledWith([ + COLUMNS[0], + COLUMNS[2] + ]); + }); + + it('should preserve column order from columns input', () => { + fixture.componentRef.setInput('selectedColumns', [COLUMNS[2]]); + jest.spyOn(component.selectedColumnsChange, 'emit'); + component.onSelectColumn(COLUMNS[0]); + expect(component.selectedColumnsChange.emit).toHaveBeenCalledWith([ + COLUMNS[0], + COLUMNS[2] + ]); + }); + }); + + describe('onMenuShown / onMenuHidden', () => { + it('should set isMenuOpen to true and reset highlightedIndex on menu shown', () => { + component.highlightedIndex.set(2); + component.onMenuShown(); + expect(component.isMenuOpen()).toBe(true); + expect(component.highlightedIndex()).toBe(-1); + }); + + it('should set isMenuOpen to false and reset highlightedIndex on menu hidden', () => { + component.onMenuShown(); + component.highlightedIndex.set(1); + component.onMenuHidden(); + expect(component.isMenuOpen()).toBe(false); + expect(component.highlightedIndex()).toBe(-1); + }); + }); + + describe('onKeydown - arrow navigation', () => { + const down = () => + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }); + const up = () => + new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }); + + it('should move to first item on ArrowDown from -1', () => { + component.onKeydown(down()); + expect(component.highlightedIndex()).toBe(0); + }); + + it('should increment highlightedIndex on ArrowDown', () => { + component.highlightedIndex.set(0); + component.onKeydown(down()); + expect(component.highlightedIndex()).toBe(1); + }); + + it('should wrap to first item on ArrowDown from last', () => { + component.highlightedIndex.set(COLUMNS.length); + component.onKeydown(down()); + expect(component.highlightedIndex()).toBe(0); + }); + + it('should move to last item on ArrowUp from -1', () => { + component.onKeydown(up()); + expect(component.highlightedIndex()).toBe(COLUMNS.length); + }); + + it('should decrement highlightedIndex on ArrowUp', () => { + component.highlightedIndex.set(2); + component.onKeydown(up()); + expect(component.highlightedIndex()).toBe(1); + }); + + it('should wrap to last item on ArrowUp from 0', () => { + component.highlightedIndex.set(0); + component.onKeydown(up()); + expect(component.highlightedIndex()).toBe(COLUMNS.length); + }); + }); + + describe('onKeydown - Enter / Space', () => { + it('should call toggleAllColumns when Enter is pressed at index 0', () => { + component.highlightedIndex.set(0); + jest.spyOn(component, 'toggleAllColumns'); + component.onKeydown( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + ); + expect(component.toggleAllColumns).toHaveBeenCalled(); + }); + + it('should call onSelectColumn for the correct column when Space is pressed', () => { + component.highlightedIndex.set(2); + jest.spyOn(component, 'onSelectColumn'); + component.onKeydown( + new KeyboardEvent('keydown', { key: ' ', bubbles: true }) + ); + expect(component.onSelectColumn).toHaveBeenCalledWith(COLUMNS[1]); + }); + + it('should do nothing when Enter is pressed with no item highlighted', () => { + jest.spyOn(component, 'toggleAllColumns'); + jest.spyOn(component, 'onSelectColumn'); + component.onKeydown( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + ); + expect(component.toggleAllColumns).not.toHaveBeenCalled(); + expect(component.onSelectColumn).not.toHaveBeenCalled(); + }); + }); + + describe('onToggle', () => { + it('should not open menu when disabled', () => { + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + component.onToggle(new MouseEvent('click')); + expect(component.isMenuOpen()).toBe(false); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component.ts b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component.ts new file mode 100644 index 000000000..3ae1a3231 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component.ts @@ -0,0 +1,140 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + computed, + input, + output, + signal, + viewChild +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { isEqual } from 'lodash-es'; +import { CpsMenuComponent } from '../../../../cps-menu/cps-menu.component'; +import { CpsIconComponent } from '../../../../cps-icon/cps-icon.component'; +import { generateUniqueId } from '../../../../../utils/internal/accessibility-utils'; + +/** + * TableColumnVisibilityToggleComponent is an internal component that renders + * the "Toggle column visibility" button and its listbox dropdown. + */ +@Component({ + selector: 'table-column-visibility-toggle', + imports: [CommonModule, CpsMenuComponent, CpsIconComponent], + templateUrl: './table-column-visibility-toggle.component.html', + styleUrls: ['./table-column-visibility-toggle.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'cps-table-tbar-coltoggle-btn' + } +}) +export class TableColumnVisibilityToggleComponent { + columns = input<{ [key: string]: any }[]>([]); + selectedColumns = input<{ [key: string]: any }[]>([]); + disabled = input(false); + colHeaderName = input('header'); + selectedColumnsChange = output<{ [key: string]: any }[]>(); + + private _menu = viewChild.required('menu'); + private _listbox = viewChild.required>('listbox'); + + isMenuOpen = signal(false); + highlightedIndex = signal(-1); + readonly listboxId = generateUniqueId('cps-table-coltoggle'); + + activeDescendantId = computed(() => { + const i = this.highlightedIndex(); + return i < 0 ? null : `${this.listboxId}-opt-${i}`; + }); + + onToggle(event: any): void { + if (this.disabled()) return; + this._menu().toggle(event); + } + + onMenuShown(): void { + this.isMenuOpen.set(true); + this.highlightedIndex.set(-1); + setTimeout(() => this._listbox().nativeElement?.focus()); + } + + onMenuHidden(): void { + this.isMenuOpen.set(false); + this.highlightedIndex.set(-1); + } + + onKeydown(event: KeyboardEvent): void { + const total = 1 + this.columns().length; + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.highlightedIndex.update((i) => + i === -1 || i === total - 1 ? 0 : i + 1 + ); + this._scrollIntoView(); + break; + case 'ArrowUp': + event.preventDefault(); + this.highlightedIndex.update((i) => (i < 1 ? total - 1 : i - 1)); + this._scrollIntoView(); + break; + case 'Enter': + case ' ': { + event.preventDefault(); + const idx = this.highlightedIndex(); + if (idx === 0) { + this.toggleAllColumns(); + } else if (idx > 0) { + this.onSelectColumn(this.columns()[idx - 1]); + } + break; + } + case 'Escape': + this._menu().hide(); + break; + } + } + + toggleAllColumns(): void { + const cols = + this.selectedColumns().length < this.columns().length + ? this.columns() + : []; + this.selectedColumnsChange.emit(cols); + } + + isColumnSelected(col: any): boolean { + return this.selectedColumns().some((item) => isEqual(item, col)); + } + + onSelectColumn(col: any): void { + let res: any[] = []; + if (this.isColumnSelected(col)) { + res = this.selectedColumns().filter((v: any) => !isEqual(v, col)); + } else { + this.columns().forEach((o) => { + if ( + this.selectedColumns().some((v: any) => isEqual(v, o)) || + isEqual(col, o) + ) { + res.push(o); + } + }); + } + this.selectedColumnsChange.emit(res); + } + + private _scrollIntoView(): void { + const activeId = this.activeDescendantId(); + if (!activeId) return; + const listbox = this._listbox().nativeElement; + const item = listbox?.querySelector(`#${activeId}`) as HTMLElement | null; + if (!listbox || !item) return; + const itemBottom = item.offsetTop + item.offsetHeight; + if (itemBottom > listbox.scrollTop + listbox.clientHeight) { + listbox.scrollTop = itemBottom - listbox.clientHeight; + } else if (item.offsetTop < listbox.scrollTop) { + listbox.scrollTop = item.offsetTop; + } + } +} diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-row-menu/table-row-menu.component.html b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-row-menu/table-row-menu.component.html index 4077a8cbd..f05facc77 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-row-menu/table-row-menu.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-row-menu/table-row-menu.component.html @@ -1,8 +1,17 @@ - + - - + diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-row-menu/table-row-menu.component.scss b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-row-menu/table-row-menu.component.scss index 2e4a382b3..c9ceb25c8 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-row-menu/table-row-menu.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-row-menu/table-row-menu.component.scss @@ -1,10 +1,32 @@ +@use '../../../../../../../styles/mixins' as *; + :host { - height: 22px; + height: 1.375rem; display: block; - .cps-table-row-menu-icon { + .cps-table-row-menu-btn { cursor: pointer; + background: none; + border: none; + padding: 0; + color: inherit; + font: inherit; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + min-height: 1.5rem; + position: relative; + + &:focus { + outline: none; + color: var(--cps-color-calm); + } + + &:focus-visible { + @include focus-ring(); + } } - .cps-table-row-menu-icon:hover { + .cps-table-row-menu-btn:hover { color: var(--cps-color-calm); } } diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-row-menu/table-row-menu.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-row-menu/table-row-menu.component.spec.ts new file mode 100644 index 000000000..b2a943bd3 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-row-menu/table-row-menu.component.spec.ts @@ -0,0 +1,147 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TableRowMenuComponent } from './table-row-menu.component'; +import { CpsMenuItem } from '../../../../cps-menu/cps-menu.component'; + +describe('TableRowMenuComponent', () => { + let component: TableRowMenuComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TableRowMenuComponent, NoopAnimationsModule] + }).compileComponents(); + + fixture = TestBed.createComponent(TableRowMenuComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + document.body + .querySelectorAll('.cps-menu-container') + .forEach((el) => el.remove()); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('default values', () => { + it('should default showRowEditButton to true', () => { + expect(component.showRowEditButton).toBe(true); + }); + + it('should default showRowRemoveButton to true', () => { + expect(component.showRowRemoveButton).toBe(true); + }); + + it('should default isMenuOpen to false', () => { + expect(component.isMenuOpen).toBe(false); + }); + + it('should build both Edit and Remove items by default', () => { + expect(component.items.length).toBe(2); + expect(component.items[0].title).toBe('Edit'); + expect(component.items[1].title).toBe('Remove'); + }); + }); + + describe('initializeItems', () => { + it('should include only Remove when showRowEditButton is false', () => { + fixture.componentRef.setInput('showRowEditButton', false); + expect(component.items.length).toBe(1); + expect(component.items[0].title).toBe('Remove'); + }); + + it('should include only Edit when showRowRemoveButton is false', () => { + fixture.componentRef.setInput('showRowRemoveButton', false); + expect(component.items.length).toBe(1); + expect(component.items[0].title).toBe('Edit'); + }); + + it('should produce empty items when both buttons are hidden', () => { + fixture.componentRef.setInput('showRowEditButton', false); + fixture.componentRef.setInput('showRowRemoveButton', false); + expect(component.items).toEqual([]); + }); + + it('should use customItems when provided, ignoring showRow flags', () => { + const custom: CpsMenuItem[] = [{ title: 'Custom', icon: 'star' }]; + fixture.componentRef.setInput('showRowEditButton', false); + fixture.componentRef.setInput('customItems', custom); + expect(component.items).toEqual(custom); + }); + + it('should fall back to default items when customItems is cleared', () => { + fixture.componentRef.setInput('customItems', [ + { title: 'Custom', icon: 'star' } + ]); + fixture.componentRef.setInput('customItems', undefined); + expect(component.items.length).toBe(2); + expect(component.items[0].title).toBe('Edit'); + expect(component.items[1].title).toBe('Remove'); + }); + }); + + describe('onMenuShown / onMenuHidden', () => { + it('should set isMenuOpen to true', () => { + component.onMenuShown(); + expect(component.isMenuOpen).toBe(true); + }); + + it('should set isMenuOpen to false', () => { + component.onMenuShown(); + component.onMenuHidden(); + expect(component.isMenuOpen).toBe(false); + }); + }); + + describe('editRowBtnClicked', () => { + it('should emit when the Edit item action is invoked', () => { + jest.spyOn(component.editRowBtnClicked, 'emit'); + const event = new MouseEvent('click'); + component.items.find((i) => i.title === 'Edit')!.action!(event); + expect(component.editRowBtnClicked.emit).toHaveBeenCalledWith(event); + }); + }); + + describe('removeRowBtnClicked', () => { + it('should emit when the Remove item action is invoked', () => { + jest.spyOn(component.removeRowBtnClicked, 'emit'); + const event = new MouseEvent('click'); + component.items.find((i) => i.title === 'Remove')!.action!(event); + expect(component.removeRowBtnClicked.emit).toHaveBeenCalledWith(event); + }); + }); + + describe('template', () => { + it('should render the action button', () => { + expect( + fixture.nativeElement.querySelector('button.cps-table-row-menu-btn') + ).toBeTruthy(); + }); + + it('should set aria-label to "Row actions"', () => { + const btn = fixture.nativeElement.querySelector('button'); + expect(btn.getAttribute('aria-label')).toBe('Row actions'); + }); + + it('should set aria-haspopup to "menu"', () => { + const btn = fixture.nativeElement.querySelector('button'); + expect(btn.getAttribute('aria-haspopup')).toBe('menu'); + }); + + it('should set aria-expanded to false when menu is closed', () => { + const btn = fixture.nativeElement.querySelector('button'); + expect(btn.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should set aria-expanded to true when menu is open', () => { + component.onMenuShown(); + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector('button'); + expect(btn.getAttribute('aria-expanded')).toBe('true'); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-row-menu/table-row-menu.component.ts b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-row-menu/table-row-menu.component.ts index ced86090d..2688373b7 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-row-menu/table-row-menu.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-row-menu/table-row-menu.component.ts @@ -76,6 +76,15 @@ export class TableRowMenuComponent implements OnInit { private _customRowMenuItems?: CpsMenuItem[]; items: CpsMenuItem[] = []; + isMenuOpen = false; + + onMenuShown(): void { + this.isMenuOpen = true; + } + + onMenuHidden(): void { + this.isMenuOpen = false; + } ngOnInit(): void { this.initializeItems(); diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.html b/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.html index 633cee51e..d3fdd3c9d 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.html @@ -2,7 +2,9 @@ #primengTable #tUnsortDirective="tWithUnsort" tWithUnsort - [ngClass]="{ 'cps-table-loading': loading }" + [pt]="tablePassthrough" + [attr.aria-busy]="loading || null" + [class.cps-table-loading]="loading" [styleClass]="styleClass" [tableStyle]="tableStyle" [tableStyleClass]="tableStyleClass" @@ -85,7 +87,7 @@ } @if (showColumnsToggleBtn && columns.length > 0) { -
- - -
-
- - - Show all columns - -
- @for (col of columns; track col) { -
- - - {{ - col[colHeaderName] - }} - -
- } -
-
-
+ + } @if (showExportBtn) { -
- +
+ + [withArrow]="false" + (menuShown)="isExportMenuOpen = true" + (menuHidden)="isExportMenuOpen = false">
} @if (showDataReloadBtn) { -
- +
}
@@ -218,22 +197,30 @@ @if (rowExpansionTemplate) { + [cpsTColResizableDisabled]="!resizableColumns"> + Row expansion + } @if (reorderableRows) { + [cpsTColResizableDisabled]="!resizableColumns"> + Row order + } @if (selectable) { - + } @if (headerTemplate) { @@ -250,6 +237,7 @@ @if (filterableByColumns) { @for (col of columns; track col) { @@ -278,6 +267,7 @@ @if (filterableByColumns) { @for (col of columns; track col) { {{ col[colHeaderName] }} @@ -303,10 +294,13 @@ } @if (showRowMenu && (showRowRemoveButton || showRowEditButton)) { + [cpsTColResizableDisabled]="!resizableColumns"> + Row actions + }
@@ -321,33 +315,48 @@ let-expanded="expanded"> + [class.cps-table-row-selected]=" + selectable && primengTable.isSelected(item) + " + [class.cps-table-row-keyboard-reordering]=" + reorderableRows && keyboardDragRowIndex === rowIndex + "> @if (rowExpansionTemplate) { -
- + [class.cps-table-row-chevron-expanded]="expanded" + [attr.aria-expanded]="expanded" + [attr.aria-label]="expanded ? 'Collapse row' : 'Expand row'" + [pRowToggler]="item"> + -
+ } @if (reorderableRows) { - + } @if (selectable) { - + } @if (bodyTemplate) { @@ -367,7 +376,9 @@ @if (columns.length > 0) { @if (renderDataAsHTML) { @for (col of columns; track col) { - + } } @else { @for (col of columns; track col) { @@ -397,7 +408,7 @@
@if (rowExpansionTemplate; as item) { - +
- tr > th { text-align: left; padding: 1rem 1rem; - border: 1px solid $table-borders-color; - border-width: 0 0 1px 1px; + border: 0.0625rem solid $table-borders-color; + border-width: 0 0 0.0625rem 0.0625rem; font-weight: normal; color: $header-text-color; background: white; @@ -190,11 +253,11 @@ $tbar-normal-height: 72px; } .p-datatable .p-datatable-thead > tr > th:last-child { - border-width: 1px; + border-width: 0.0625rem; } .p-datatable .p-datatable-thead > tr > th { - border-width: 1px 0 1px 1px; + border-width: 0.0625rem 0 0.0625rem 0.0625rem; } .p-datatable .p-datatable-thead { @@ -204,7 +267,11 @@ $tbar-normal-height: 72px; } .p-datatable - .p-datatable-sortable-column:not(.p-paginator-page-selected):hover + .p-sortable-column:not(.p-paginator-page-selected):hover + .cps-sortable-column-icon.sort-unsorted, + .p-datatable + .p-sortable-column + cps-sort-icon:focus-visible .cps-sortable-column-icon.sort-unsorted { .sort-unsorted-arrow-up { border-bottom-color: $sorticon-hover-color; @@ -228,7 +295,7 @@ $tbar-normal-height: 72px; display: inline-flex; align-items: center; justify-content: center; - font-size: 12px; + font-size: 0.75rem; vertical-align: top; color: $color-calm; margin-left: 0.25rem; @@ -249,11 +316,15 @@ $tbar-normal-height: 72px; cursor: pointer; -webkit-user-select: none; user-select: none; - } - .p-datatable .p-sortable-column:focus { - box-shadow: none; - outline: 0 none; + &:focus { + outline: none; + } + + &:focus-visible { + outline: 0.125rem solid var(--cps-color-calm); + outline-offset: -0.125rem; + } } .p-datatable.p-datatable-striped .p-datatable-tbody > tr:nth-child(odd) { @@ -272,7 +343,7 @@ $tbar-normal-height: 72px; .p-datatable .p-datatable-tbody .cps-table-row-expanded-content { td { - border-left: 4px solid $color-surprise !important; + border-left: 0.25rem solid $color-surprise !important; } } @@ -291,7 +362,7 @@ $tbar-normal-height: 72px; .p-datatable-tbody > tr > td:last-child { - border-width: 0 1px 1px 1px; + border-width: 0 0.0625rem 0.0625rem 0.0625rem; } .p-datatable.p-datatable-gridlines:has(.p-datatable-thead):has( @@ -300,33 +371,33 @@ $tbar-normal-height: 72px; .p-datatable-tbody > tr > td { - border-width: 0 0 1px 1px; + border-width: 0 0 0.0625rem 0.0625rem; } .p-datatable.p-datatable-gridlines .p-datatable-tbody > tr > td:last-child { - border-width: 1px 1px 0 1px; + border-width: 0.0625rem 0.0625rem 0 0.0625rem; } .p-datatable.p-datatable-gridlines .p-datatable-tbody > tr > td { - border-width: 1px 0 0 1px; + border-width: 0.0625rem 0 0 0.0625rem; } .p-datatable .p-datatable-tbody > tr > td { text-align: left; - border: 1px solid $table-borders-color; - border-width: 0 0 1px 0; + border: 0.0625rem solid $table-borders-color; + border-width: 0 0 0.0625rem 0; padding: 1rem 1rem; } .p-datatable .p-datatable-tbody > tr > td:first-child { - border-width: 0 0 1px 1px; + border-width: 0 0 0.0625rem 0.0625rem; } .p-datatable .p-datatable-tbody > tr > td:last-child { - border-width: 0 1px 1px 0; + border-width: 0 0.0625rem 0.0625rem 0; } .p-datatable .p-datatable-tbody > tr > td:only-child { - border-width: 0 1px 1px 1px; + border-width: 0 0.0625rem 0.0625rem 0.0625rem; } .p-datatable.p-datatable-sm .p-datatable-tbody > tr > td { @@ -353,19 +424,19 @@ $tbar-normal-height: 72px; user-select: none; vertical-align: bottom; position: relative; - width: 18px; - height: 18px; + width: 1.125rem; + height: 1.125rem; } .p-hidden-accessible { border: 0; clip: rect(0 0 0 0); - height: 1px; - margin: -1px; + height: 0.0625rem; + margin: -0.0625rem; overflow: hidden; padding: 0; position: absolute; - width: 1px; + width: 0.0625rem; } .p-hidden-accessible input, @@ -375,11 +446,11 @@ $tbar-normal-height: 72px; .p-checkbox .p-checkbox-box { background: #ffffff; - width: 18px; - height: 18px; + width: 1.125rem; + height: 1.125rem; color: $body-text-color; - border: 2px solid $checkbox-border-color; - border-radius: 2px; + border: 0.125rem solid $checkbox-border-color; + border-radius: 0.125rem; transition: background-color 0.2s, color 0.2s, @@ -397,30 +468,42 @@ $tbar-normal-height: 72px; border-color: $color-calm; background: $color-calm; } - .p-checkbox-checked:not(.p-disabled):has(.p-checkbox-input:hover) .p-checkbox-box { + .p-checkbox-checked:not(.p-disabled):has(.p-checkbox-input:hover) + .p-checkbox-box, + .p-checkbox-checked:not(.p-disabled):has(.p-checkbox-input:focus) + .p-checkbox-box { border-color: $color-calm; background: $color-calm; color: #ffffff; } - .p-checkbox:not(.p-disabled):has(.p-checkbox-input:hover) .p-checkbox-box { + .p-checkbox:not(.p-disabled):has(.p-checkbox-input:hover) .p-checkbox-box, + .p-checkbox:not(.p-disabled):has(.p-checkbox-input:focus) .p-checkbox-box { border-color: $color-calm; } .p-checkbox .p-checkbox-box .p-icon { - width: 14px; - height: 14px; + width: 0.875rem; + height: 0.875rem; } .p-checkbox .p-checkbox-box .p-checkbox-icon { transition-duration: 0.2s; color: #ffffff; - font-size: 14px; + font-size: 0.875rem; + } + + .p-checkbox-input:focus-visible { + outline: none; + } + + .p-checkbox:has(.p-checkbox-input:focus-visible) { + @include focus-ring(0.1875rem, 0.25rem, 0.25rem); } .p-datatable-reorderable-row-handle, [pReorderableColumn] { cursor: move; - font-size: 20px; + font-size: 1.25rem; line-height: 1; &:hover { color: $color-calm; @@ -428,14 +511,14 @@ $tbar-normal-height: 72px; } .p-datatable .p-datatable-tbody > tr.p-datatable-dragpoint-top > td { - box-shadow: inset 0 2px 0 0 $color-calm; + box-shadow: inset 0 0.125rem 0 0 $color-calm; } .p-datatable .p-datatable-tbody > tr.p-datatable-dragpoint-bottom > td { - box-shadow: inset 0 -2px 0 0 $color-calm; + box-shadow: inset 0 -0.125rem 0 0 $color-calm; } .p-datatable .p-paginator { - border-width: 0 1px 1px 1px; + border-width: 0 0.0625rem 0.0625rem 0.0625rem; border-radius: 0; } @@ -447,7 +530,7 @@ $tbar-normal-height: 72px; flex-wrap: wrap; color: $paginator-text-color; padding: 1rem; - border: 1px solid $table-borders-color; + border: 0.0625rem solid $table-borders-color; } .p-paginator-content-start { @@ -457,21 +540,22 @@ $tbar-normal-height: 72px; align-items: center; .cps-table-paginator-items-per-page-title { font-family: 'Source Sans Pro', sans-serif; - font-size: 14px; - margin-right: 12px; + font-size: 0.875rem; + margin-right: 0.75rem; cursor: default; } .cps-select-box { - min-height: 32px !important; + min-height: 2rem !important; background: transparent !important; .cps-select-box-items { - font-size: 14px !important; + font-size: 0.875rem !important; } .cps-select-box-chevron { + padding: 0.3125rem !important; .cps-icon { - width: 14px; - height: 14px; + width: 0.875rem; + height: 0.875rem; } } } @@ -486,7 +570,7 @@ $tbar-normal-height: 72px; margin: 0.143rem; padding: 0 0.5rem; font-family: 'Source Sans Pro', sans-serif; - font-size: 14px; + font-size: 0.875rem; height: unset; } @@ -503,7 +587,6 @@ $tbar-normal-height: 72px; line-height: 1; -webkit-user-select: none; user-select: none; - overflow: hidden; position: relative; } @@ -511,17 +594,29 @@ $tbar-normal-height: 72px; cursor: default; } - .p-paginator .p-paginator-first:not(.p-disabled):not(.p-paginator-page-selected):hover, - .p-paginator .p-paginator-prev:not(.p-disabled):not(.p-paginator-page-selected):hover, - .p-paginator .p-paginator-next:not(.p-disabled):not(.p-paginator-page-selected):hover, - .p-paginator .p-paginator-last:not(.p-disabled):not(.p-paginator-page-selected):hover { + .p-paginator + .p-paginator-first:not(.p-disabled):not(.p-paginator-page-selected):hover, + .p-paginator + .p-paginator-prev:not(.p-disabled):not(.p-paginator-page-selected):hover, + .p-paginator + .p-paginator-next:not(.p-disabled):not(.p-paginator-page-selected):hover, + .p-paginator + .p-paginator-last:not(.p-disabled):not(.p-paginator-page-selected):hover { background: $paginator-elem-hover-background; border-color: unset; } - .p-paginator .p-paginator-first:not(.p-disabled):not(.p-paginator-page-selected):active, - .p-paginator .p-paginator-prev:not(.p-disabled):not(.p-paginator-page-selected):active, - .p-paginator .p-paginator-next:not(.p-disabled):not(.p-paginator-page-selected):active, - .p-paginator .p-paginator-last:not(.p-disabled):not(.p-paginator-page-selected):active { + .p-paginator + .p-paginator-first:not(.p-disabled):not( + .p-paginator-page-selected + ):active, + .p-paginator + .p-paginator-prev:not(.p-disabled):not(.p-paginator-page-selected):active, + .p-paginator + .p-paginator-next:not(.p-disabled):not(.p-paginator-page-selected):active, + .p-paginator + .p-paginator-last:not(.p-disabled):not( + .p-paginator-page-selected + ):active { background: $paginator-elem-active-background; } @@ -530,11 +625,11 @@ $tbar-normal-height: 72px; .p-paginator .p-paginator-next, .p-paginator .p-paginator-last { background-color: transparent; - border: 1px solid $paginator-border-color; - border-radius: 4px; + border: 0.0625rem solid $paginator-border-color; + border-radius: 0.25rem; color: $paginator-text-color; - min-width: 32px; - height: 32px; + min-width: 2rem; + height: 2rem; margin: 0.143rem; transition: box-shadow 0.2s; } @@ -543,48 +638,78 @@ $tbar-normal-height: 72px; display: inline-flex; } + .p-paginator-first-icon, + .p-paginator-prev-icon, + .p-paginator-next-icon, + .p-paginator-last-icon { + width: 0.875rem; + height: 0.875rem; + } + .p-disabled, .p-disabled * { cursor: default !important; pointer-events: none; } - .p-paginator .p-paginator-pages .p-paginator-page.p-paginator-page-selected { + .p-paginator + .p-paginator-pages + .p-paginator-page.p-paginator-page-selected { background: $color-calm; border-color: $color-calm; color: white; } - .p-paginator .p-paginator-pages .p-paginator-page:not(.p-paginator-page-selected):hover { + .p-paginator + .p-paginator-pages + .p-paginator-page:not(.p-paginator-page-selected):hover { background: $paginator-elem-hover-background; border-color: unset; } - .p-paginator .p-paginator-pages .p-paginator-page:not(.p-paginator-page-selected):active { + .p-paginator + .p-paginator-pages + .p-paginator-page:not(.p-paginator-page-selected):active { background: $paginator-elem-active-background; } .p-paginator .p-paginator-pages .p-paginator-page { background-color: transparent; - border: 1px solid $paginator-border-color; - border-radius: 4px; + border: 0.0625rem solid $paginator-border-color; + border-radius: 0.25rem; color: $paginator-text-color; - min-width: 32px; - height: 32px; + min-width: 2rem; + height: 2rem; margin: 0.143rem; transition: box-shadow 0.2s; } - .p-paginator-element:focus { - z-index: 1; - position: relative; - } - + .p-paginator-first:focus, + .p-paginator-prev:focus, + .p-paginator-next:focus, + .p-paginator-last:focus, .p-paginator-page:focus { + z-index: 1; outline: 0 none; - outline-offset: 0; box-shadow: unset; } + .p-paginator-first:not(.p-disabled):focus-visible, + .p-paginator-prev:not(.p-disabled):focus-visible, + .p-paginator-next:not(.p-disabled):focus-visible, + .p-paginator-last:not(.p-disabled):focus-visible, + .p-paginator-page:focus-visible { + overflow: visible; + @include focus-ring(0.1875rem, 0.25rem, 0.25rem); + } + + .p-paginator-first:not(.p-disabled):focus-visible, + .p-paginator-prev:not(.p-disabled):focus-visible, + .p-paginator-next:not(.p-disabled):focus-visible, + .p-paginator-last:not(.p-disabled):focus-visible, + .p-paginator-page:not(.p-paginator-page-selected):focus-visible { + background: $paginator-elem-hover-background; + } + .p-paginator-page { text-align: left; background-color: transparent; @@ -594,7 +719,7 @@ $tbar-normal-height: 72px; cursor: pointer; -webkit-user-select: none; user-select: none; - font-size: 14px; + font-size: 0.875rem; font-family: 'Source Sans Pro', sans-serif; } @@ -616,6 +741,31 @@ $tbar-normal-height: 72px; -moz-user-select: none; -ms-user-select: none; user-select: none; + cursor: grab; + display: inline-block; + vertical-align: middle; + box-sizing: content-box; + padding: 0.25rem; + width: 0.875rem; + height: 0.75rem; + background-origin: content-box; + background-clip: content-box; + background-image: repeating-linear-gradient( + to bottom, + currentColor 0, + currentColor 2px, + transparent 2px, + transparent 5px + ); + + &:focus { + outline: none; + color: $color-calm; + } + + &:focus-visible { + @include focus-ring(); + } } } @@ -625,6 +775,22 @@ $tbar-normal-height: 72px; transition-duration: 0.2s; cursor: pointer; display: inline-flex; + background: none; + border: none; + padding: 0; + color: inherit; + position: relative; + + &:focus { + outline: none; + cps-icon .cps-icon { + color: $color-calm !important; + } + } + + &:focus-visible { + @include focus-ring(); + } } .cps-table-row-chevron:hover { cps-icon .cps-icon { @@ -636,6 +802,19 @@ $tbar-normal-height: 72px; } } + .cps-table-html-cell { + * { + &:focus { + outline: none; + } + + &:focus-visible { + position: relative; + @include focus-ring(); + } + } + } + .cps-table-row-menu-cell { border-left: none !important; } @@ -652,7 +831,7 @@ $tbar-normal-height: 72px; height: 100%; background-color: white; transition-duration: 0.2s; - border: 1px solid $table-borders-color; + border: 0.0625rem solid $table-borders-color; } .p-datatable.cps-tbar-small .p-datatable-mask.p-overlay-mask { top: $tbar-small-height; @@ -666,13 +845,13 @@ $tbar-normal-height: 72px; .cps-table-loading { .p-datatable { - min-height: 200px; + min-height: 12.5rem; } .p-datatable.cps-tbar-normal { - min-height: 272px; + min-height: 17rem; } .p-datatable.cps-tbar-small { - min-height: 243px; + min-height: 15.1875rem; } } @@ -681,96 +860,20 @@ $tbar-normal-height: 72px; color: $body-text-color; } } - } -} - -.cps-table-coltoggle-menu { - display: block; - max-height: 242px; - overflow-x: hidden; - background: white; - - .cps-table-coltoggle-menu-item { - padding: 12px; - justify-content: space-between; - display: flex; - cursor: pointer; - - &:hover { - background: $colitem-hover-background; - } - - &-label { - color: $colitem-value-color; - } - - &-left { - display: flex; - align-items: center; - margin-right: 8px; - } - &-check { - background-color: transparent; - border: 0; - width: 16px; - height: 16px; - cursor: pointer; - display: inline-block; - vertical-align: middle; - box-sizing: border-box; - position: relative; - flex-shrink: 0; - transition: - border-color 90ms cubic-bezier(0, 0, 0.2, 0.1), - background-color 90ms cubic-bezier(0, 0, 0.2, 0.1); - margin-right: 8px; - opacity: 0; - &::after { - color: $color-calm; - top: 4px; - left: 1px; - width: 8px; - height: 3px; - border-left: 2px solid currentColor; - transform: rotate(-45deg); - opacity: 1; - box-sizing: content-box; - position: absolute; - content: ''; - border-bottom: 2px solid currentColor; - transition: opacity 90ms cubic-bezier(0, 0, 0.2, 0.1); - } - } - &.selected, - &.allselected { - font-weight: 600; - .cps-table-coltoggle-menu-item-label { - color: $color-calm; - } - .cps-table-coltoggle-menu-item-check { - opacity: 1; - } - } - &.selected { - background: $selected-colitem-background; - } - &.highlighten { - background: $colitem-highlight-background; - } - &.selected.highlighten { - background: $colitem-highlight-selected-background; + .p-datatable + .p-datatable-tbody + > tr.cps-table-row-keyboard-reordering + > td { + box-shadow: + inset 0 0.125rem 0 0 $color-calm, + inset 0 -0.125rem 0 0 $color-calm; } } - - .select-all-option { - border-bottom: 1px solid lightgrey; - font-weight: 600; - } } ::ng-deep .cps-select-options-menu.cps-paginator-page-options { .cps-select-options-option { - font-size: 14px; + font-size: 0.875rem; } } diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.spec.ts new file mode 100644 index 000000000..de0776993 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.spec.ts @@ -0,0 +1,689 @@ +import { SimpleChange } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { BaseComponent } from 'primeng/basecomponent'; +import { CPS_LIVE_ANNOUNCER_SERVICE } from '../../services/cps-live-announcer/cps-live-announcer.service'; +import { CpsTableComponent } from './cps-table.component'; + +describe('CpsTableComponent', () => { + let fixture: ComponentFixture; + let component: CpsTableComponent; + let mockAnnouncer: { announce: jest.Mock }; + + beforeEach(async () => { + mockAnnouncer = { announce: jest.fn() }; + + jest + .spyOn(BaseComponent.prototype, 'ngOnInit') + .mockImplementation(() => {}); + + await TestBed.configureTestingModule({ + imports: [CpsTableComponent, NoopAnimationsModule], + providers: [ + { provide: CPS_LIVE_ANNOUNCER_SERVICE, useValue: mockAnnouncer } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(CpsTableComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have a primengTable ViewChild', () => { + expect(component.primengTable).toBeTruthy(); + }); + + describe('input defaults', () => { + it('should default striped to true', () => + expect(component.striped).toBe(true)); + it('should default bordered to true', () => + expect(component.bordered).toBe(true)); + it('should default size to "normal"', () => + expect(component.size).toBe('normal')); + it('should default selectable to false', () => + expect(component.selectable).toBe(false)); + it('should default scrollable to true', () => + expect(component.scrollable).toBe(true)); + it('should default paginator to false', () => + expect(component.paginator).toBe(false)); + it('should default sortable to false', () => + expect(component.sortable).toBe(false)); + it('should default hasToolbar to true', () => + expect(component.hasToolbar).toBe(true)); + it('should default toolbarSize to "normal"', () => + expect(component.toolbarSize).toBe('normal')); + it('should default reorderableRows to false', () => + expect(component.reorderableRows).toBe(false)); + it('should default emptyMessage to "No data"', () => + expect(component.emptyMessage).toBe('No data')); + it('should default data to empty array', () => + expect(component.data).toEqual([])); + it('should default selectedRows to empty array', () => + expect(component.selectedRows).toEqual([])); + }); + + describe('data setter / getter', () => { + it('should store a copy of the array', () => { + const arr = [{ id: 1 }, { id: 2 }]; + component.data = arr; + expect(component.data).toEqual(arr); + expect(component.data).not.toBe(arr); + }); + }); + + describe('ngOnInit', () => { + it('should convert emptyBodyHeight number to px string', () => { + component.emptyBodyHeight = 200; + component.ngOnInit(); + expect(component.emptyBodyHeight).toBe('200px'); + }); + + it('should convert emptyBodyHeight pixel string unchanged', () => { + component.emptyBodyHeight = '150px'; + component.ngOnInit(); + expect(component.emptyBodyHeight).toBe('150px'); + }); + + it('should disable virtualScroll when scrollable is false', () => { + component.scrollable = false; + component.virtualScroll = true; + component.ngOnInit(); + expect(component.virtualScroll).toBe(false); + }); + + it('should keep virtualScroll when scrollable is true', () => { + component.scrollable = true; + component.virtualScroll = true; + component.ngOnInit(); + expect(component.virtualScroll).toBe(true); + }); + + it('should set showRemoveBtnOnSelect to false when showAdditionalBtnOnSelect is true', () => { + component.showAdditionalBtnOnSelect = true; + component.showRemoveBtnOnSelect = true; + component.ngOnInit(); + expect(component.showRemoveBtnOnSelect).toBe(false); + }); + + it('should not change showRemoveBtnOnSelect when showAdditionalBtnOnSelect is false', () => { + component.showAdditionalBtnOnSelect = false; + component.showRemoveBtnOnSelect = true; + component.ngOnInit(); + expect(component.showRemoveBtnOnSelect).toBe(true); + }); + + describe('paginator initialization', () => { + it('should set default rowsPerPageOptions when none provided and paginator is true', () => { + component.paginator = true; + component.rowsPerPageOptions = []; + component.ngOnInit(); + expect(component.rowsPerPageOptions).toEqual([5, 10, 25, 50]); + }); + + it('should set rows to first rowsPerPageOption when rows is 0', () => { + component.paginator = true; + component.rowsPerPageOptions = [10, 20, 50]; + component.rows = 0; + component.ngOnInit(); + expect(component.rows).toBe(10); + }); + + it('should keep provided rows when it is in rowsPerPageOptions', () => { + component.paginator = true; + component.rowsPerPageOptions = [10, 20, 50]; + component.rows = 20; + component.ngOnInit(); + expect(component.rows).toBe(20); + }); + + it('should throw when provided rows is not in rowsPerPageOptions', () => { + component.paginator = true; + component.rowsPerPageOptions = [10, 20, 50]; + component.rows = 15; + expect(() => component.ngOnInit()).toThrow( + 'rowsPerPageOptions must include rows' + ); + }); + + it('should build rowOptions from rowsPerPageOptions', () => { + component.paginator = true; + component.rowsPerPageOptions = [5, 10]; + component.ngOnInit(); + expect(component.rowOptions).toEqual([ + { label: '5', value: 5 }, + { label: '10', value: 10 } + ]); + }); + }); + + describe('globalFilterFields auto-detection', () => { + it('should derive globalFilterFields from data keys when showGlobalFilter is true and fields are empty', () => { + component.showGlobalFilter = true; + component.globalFilterFields = []; + component.data = [{ name: 'Alice', age: 30 }]; + component.ngOnInit(); + expect(component.globalFilterFields).toEqual(['name', 'age']); + }); + + it('should not override existing globalFilterFields', () => { + component.showGlobalFilter = true; + component.globalFilterFields = ['name']; + component.data = [{ name: 'Alice', age: 30 }]; + component.ngOnInit(); + expect(component.globalFilterFields).toEqual(['name']); + }); + + it('should not set globalFilterFields when data is empty', () => { + component.showGlobalFilter = true; + component.globalFilterFields = []; + component.data = []; + component.ngOnInit(); + expect(component.globalFilterFields).toEqual([]); + }); + }); + + describe('selectedColumns initialization', () => { + it('should use initialColumns when provided', () => { + const cols = [{ header: 'Name', field: 'name' }]; + component.initialColumns = cols; + component.columns = [{ header: 'Age', field: 'age' }]; + component.ngOnInit(); + expect(component.selectedColumns).toEqual(cols); + }); + + it('should fall back to columns when initialColumns is empty', () => { + const cols = [{ header: 'Age', field: 'age' }]; + component.initialColumns = []; + component.columns = cols; + component.ngOnInit(); + expect(component.selectedColumns).toEqual(cols); + }); + }); + }); + + describe('styleClass getter', () => { + it('should return "cps-tbar-normal" by default', () => { + expect(component.styleClass).toBe('cps-tbar-normal'); + }); + + it('should return "cps-tbar-small" when toolbarSize is "small"', () => { + component.toolbarSize = 'small'; + expect(component.styleClass).toBe('cps-tbar-small'); + }); + + it('should return empty string when hasToolbar is false', () => { + component.hasToolbar = false; + expect(component.styleClass).toBe(''); + }); + }); + + describe('ngOnChanges', () => { + it('should clear selection when loading becomes true', () => { + component.selectedRows = [{ id: 1 }]; + component.loading = true; + component.ngOnChanges({ loading: new SimpleChange(false, true, false) }); + expect(component.selectedRows).toEqual([]); + }); + + it('should not clear selection when loading is false', () => { + component.selectedRows = [{ id: 1 }]; + component.loading = false; + component.ngOnChanges({ loading: new SimpleChange(true, false, false) }); + expect(component.selectedRows).toEqual([{ id: 1 }]); + }); + + it('should filter selectedRows to only keep rows still in data', () => { + const row1 = { id: 1 }; + const row2 = { id: 2 }; + component.data = [row1, row2]; + component.selectedRows = [row1, row2]; + + component.data = [row1]; + component.ngOnChanges({ + data: new SimpleChange([row1, row2], [row1], false) + }); + + expect(component.selectedRows).toEqual([row1]); + }); + + it('should call clearGlobalFilter when loading and clearGlobalFilterOnLoading are true', () => { + component.clearGlobalFilterOnLoading = true; + component.loading = true; + jest.spyOn(component, 'clearGlobalFilter'); + component.ngOnChanges({ loading: new SimpleChange(false, true, false) }); + expect(component.clearGlobalFilter).toHaveBeenCalled(); + }); + + it('should rebuild tablePassthrough when data changes', () => { + const before = component.tablePassthrough; + component.ngOnChanges({ data: new SimpleChange([], [{ id: 1 }], false) }); + expect(component.tablePassthrough).not.toBe(before); + }); + }); + + describe('clearSelection', () => { + it('should set selectedRows to empty array', () => { + component.selectedRows = [{ id: 1 }, { id: 2 }]; + component.clearSelection(); + expect(component.selectedRows).toEqual([]); + }); + }); + + describe('onSelectionChanged', () => { + it('should emit rowsSelected with the selection', () => { + jest.spyOn(component.rowsSelected, 'emit'); + const rows = [{ id: 1 }]; + component.primengTable.value = rows; + component.onSelectionChanged(rows); + expect(component.rowsSelected.emit).toHaveBeenCalledWith(rows); + }); + + it('should emit selectedRowIndexes with correct indexes', () => { + jest.spyOn(component.selectedRowIndexes, 'emit'); + const row = { id: 1 }; + component.primengTable.value = [{ id: 0 }, row, { id: 2 }]; + component.onSelectionChanged([row]); + expect(component.selectedRowIndexes.emit).toHaveBeenCalledWith([1]); + }); + }); + + describe('onSortFunction', () => { + it('should emit customSortFunction with the event', () => { + jest.spyOn(component.customSortFunction, 'emit'); + const event = { + data: [], + field: 'name', + order: 1, + mode: 'single' + } as unknown as Parameters[0]; + component.onSortFunction(event); + expect(component.customSortFunction.emit).toHaveBeenCalledWith(event); + }); + }); + + describe('onSort', () => { + it('should emit sorted with the event', () => { + jest.spyOn(component.sorted, 'emit'); + const event = { field: 'name', order: 1 }; + component.onSort(event); + expect(component.sorted.emit).toHaveBeenCalledWith(event); + }); + }); + + describe('onFilter', () => { + it('should emit filtered with the event', () => { + jest.spyOn(component.filtered, 'emit'); + const event = { filters: {} }; + component.onFilter(event); + expect(component.filtered.emit).toHaveBeenCalledWith(event); + }); + }); + + describe('onRowReorder', () => { + it('should emit rowsReordered with the event', () => { + jest.spyOn(component.rowsReordered, 'emit'); + const event = { dragIndex: 0, dropIndex: 1 }; + component.onRowReorder(event); + expect(component.rowsReordered.emit).toHaveBeenCalledWith(event); + }); + }); + + describe('onLazyLoaded', () => { + it('should emit lazyLoaded with the event', () => { + jest.spyOn(component.lazyLoaded, 'emit'); + const event = { first: 0, rows: 10 }; + component.onLazyLoaded(event); + expect(component.lazyLoaded.emit).toHaveBeenCalledWith(event); + }); + }); + + describe('onReloadData', () => { + it('should emit dataReloadBtnClicked when not disabled', () => { + jest.spyOn(component.dataReloadBtnClicked, 'emit'); + component.dataReloadBtnDisabled = false; + component.onReloadData(); + expect(component.dataReloadBtnClicked.emit).toHaveBeenCalled(); + }); + + it('should not emit when dataReloadBtnDisabled is true', () => { + jest.spyOn(component.dataReloadBtnClicked, 'emit'); + component.dataReloadBtnDisabled = true; + component.onReloadData(); + expect(component.dataReloadBtnClicked.emit).not.toHaveBeenCalled(); + }); + }); + + describe('removeSelected', () => { + it('should emit rowsToRemove with selected rows', () => { + jest.spyOn(component.rowsToRemove, 'emit'); + const row = { id: 1 }; + component.primengTable.value = [row]; + component.selectedRows = [row]; + component.removeSelected(); + expect(component.rowsToRemove.emit).toHaveBeenCalledWith([row]); + }); + + it('should emit rowIndexesToRemove with correct indexes', () => { + jest.spyOn(component.rowIndexesToRemove, 'emit'); + const row = { id: 1 }; + component.primengTable.value = [{ id: 0 }, row]; + component.selectedRows = [row]; + component.removeSelected(); + expect(component.rowIndexesToRemove.emit).toHaveBeenCalledWith([1]); + }); + }); + + describe('onClickActionBtn', () => { + it('should emit actionBtnClicked', () => { + jest.spyOn(component.actionBtnClicked, 'emit'); + component.onClickActionBtn(); + expect(component.actionBtnClicked.emit).toHaveBeenCalled(); + }); + }); + + describe('onClickAdditionalBtnOnSelect', () => { + it('should emit additionalBtnOnSelectClicked with selected rows', () => { + jest.spyOn(component.additionalBtnOnSelectClicked, 'emit'); + component.selectedRows = [{ id: 1 }]; + component.onClickAdditionalBtnOnSelect(); + expect(component.additionalBtnOnSelectClicked.emit).toHaveBeenCalledWith([ + { id: 1 } + ]); + }); + }); + + describe('onEditRowClicked', () => { + it('should emit editRowBtnClicked with row and its index in primengTable.value', () => { + jest.spyOn(component.editRowBtnClicked, 'emit'); + const row = { id: 2 }; + component.primengTable.value = [{ id: 1 }, row, { id: 3 }]; + component.onEditRowClicked(row); + expect(component.editRowBtnClicked.emit).toHaveBeenCalledWith({ + row, + index: 1 + }); + }); + }); + + describe('onRemoveRowClicked', () => { + it('should emit rowsToRemove with the item', () => { + jest.spyOn(component.rowsToRemove, 'emit'); + const item = { id: 5 }; + component.primengTable.value = [item]; + component.onRemoveRowClicked(item); + expect(component.rowsToRemove.emit).toHaveBeenCalledWith([item]); + }); + + it('should emit rowIndexesToRemove with the item index', () => { + jest.spyOn(component.rowIndexesToRemove, 'emit'); + const item = { id: 5 }; + component.primengTable.value = [{ id: 0 }, item]; + component.onRemoveRowClicked(item); + expect(component.rowIndexesToRemove.emit).toHaveBeenCalledWith([1]); + }); + }); + + describe('onColumnsSelectedChange', () => { + it('should set selectedColumns', () => { + const cols = [{ header: 'Name', field: 'name' }]; + component.onColumnsSelectedChange(cols); + expect(component.selectedColumns).toEqual(cols); + }); + + it('should emit columnsSelected', () => { + jest.spyOn(component.columnsSelected, 'emit'); + const cols = [{ header: 'Age', field: 'age' }]; + component.onColumnsSelectedChange(cols); + expect(component.columnsSelected.emit).toHaveBeenCalledWith(cols); + }); + }); + + describe('exportTable', () => { + it('should throw when columns is empty', () => { + component.columns = []; + expect(() => component.exportTable('csv')).toThrow( + 'Columns must be defined!' + ); + }); + + it('should throw when selectedColumns is empty', () => { + component.columns = [{ header: 'Name', field: 'name' }]; + component.selectedColumns = []; + expect(() => component.exportTable('csv')).toThrow('Nothing to export!'); + }); + + it('should call primengTable.exportCSV for csv format', () => { + component.columns = [{ header: 'Name', field: 'name' }]; + component.selectedColumns = component.columns; + jest + .spyOn(component.primengTable, 'exportCSV') + .mockImplementation(() => {}); + component.exportTable('csv'); + expect(component.primengTable.exportCSV).toHaveBeenCalled(); + }); + }); + + describe('pagination helpers', () => { + beforeEach(() => { + component.rows = 10; + component.primengTable.totalRecords = 30; + }); + + it('getPageCount should return total pages', () => { + expect(component.getPageCount()).toBe(3); + }); + + it('getPage should return 0 when primengTable.first is 0', () => { + component.primengTable.first = 0; + expect(component.getPage()).toBe(0); + }); + + it('getPage should return correct page from primengTable.first', () => { + component.primengTable.first = 20; + expect(component.getPage()).toBe(2); + }); + + it('changePage should call primengTable.onPageChange for valid page', () => { + jest + .spyOn(component.primengTable, 'onPageChange') + .mockImplementation(() => {}); + component.changePage(1); + expect(component.primengTable.onPageChange).toHaveBeenCalledWith({ + first: 10, + rows: 10 + }); + }); + + it('changePage should ignore out-of-bounds page', () => { + jest + .spyOn(component.primengTable, 'onPageChange') + .mockImplementation(() => {}); + component.changePage(5); + expect(component.primengTable.onPageChange).not.toHaveBeenCalled(); + }); + + it('changePage should ignore negative page', () => { + jest + .spyOn(component.primengTable, 'onPageChange') + .mockImplementation(() => {}); + component.changePage(-1); + expect(component.primengTable.onPageChange).not.toHaveBeenCalled(); + }); + }); + + describe('keyboard drag (_onDragHandleKeydown)', () => { + const data = [{ id: 0 }, { id: 1 }, { id: 2 }]; + + beforeEach(() => { + component.data = [...data]; + }); + + it('should activate drag on Enter when no drag is active', () => { + const event = new KeyboardEvent('keydown', { key: 'Enter' }); + jest.spyOn(event, 'preventDefault'); + component._onDragHandleKeydown(event, 1); + expect(component.keyboardDragRowIndex).toBe(1); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should activate drag on Space when no drag is active', () => { + const event = new KeyboardEvent('keydown', { key: ' ' }); + jest.spyOn(event, 'preventDefault'); + component._onDragHandleKeydown(event, 0); + expect(component.keyboardDragRowIndex).toBe(0); + }); + + it('should announce pickup on activation', () => { + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'Enter' }), + 2 + ); + expect(mockAnnouncer.announce).toHaveBeenCalledWith( + 'Row 3 picked up. Press arrow keys to move, Enter to confirm, Escape to cancel.' + ); + }); + + it('should confirm drag on Enter when drag is active and emit rowsReordered', () => { + jest.spyOn(component.rowsReordered, 'emit'); + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'Enter' }), + 0 + ); + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'ArrowDown' }), + 0 + ); + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'Enter' }), + 0 + ); + expect(component.keyboardDragRowIndex).toBeNull(); + expect(component.rowsReordered.emit).toHaveBeenCalledWith({ + dragIndex: 0, + dropIndex: 1 + }); + }); + + it('should cancel drag on Escape and restore data', () => { + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'Enter' }), + 0 + ); + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'ArrowDown' }), + 0 + ); + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'Escape' }), + 0 + ); + expect(component.keyboardDragRowIndex).toBeNull(); + expect(component.data).toEqual(data); + }); + + it('should move row up on ArrowUp when drag is active', () => { + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'Enter' }), + 1 + ); + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'ArrowUp' }), + 1 + ); + expect(component.keyboardDragRowIndex).toBe(0); + expect((component as unknown as { _data: unknown[] })._data[0]).toEqual({ + id: 1 + }); + }); + + it('should move row down on ArrowDown when drag is active', () => { + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'Enter' }), + 1 + ); + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'ArrowDown' }), + 1 + ); + expect(component.keyboardDragRowIndex).toBe(2); + expect((component as unknown as { _data: unknown[] })._data[2]).toEqual({ + id: 1 + }); + }); + + it('should not move above row 0', () => { + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'Enter' }), + 0 + ); + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'ArrowUp' }), + 0 + ); + expect(component.keyboardDragRowIndex).toBe(0); + }); + + it('should not move below last row', () => { + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'Enter' }), + 2 + ); + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'ArrowDown' }), + 2 + ); + expect(component.keyboardDragRowIndex).toBe(2); + }); + + it('should do nothing on unrecognized key when drag is not active', () => { + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'Tab' }), + 0 + ); + expect(component.keyboardDragRowIndex).toBeNull(); + }); + + it('should do nothing on arrow keys when drag is not active', () => { + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'ArrowUp' }), + 0 + ); + expect(component.keyboardDragRowIndex).toBeNull(); + }); + }); + + describe('_onDragHandleBlur', () => { + it('should cancel drag when keyboardDragRowIndex is set', () => { + component.data = [{ id: 0 }, { id: 1 }]; + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'Enter' }), + 0 + ); + expect(component.keyboardDragRowIndex).toBe(0); + component._onDragHandleBlur(); + expect(component.keyboardDragRowIndex).toBeNull(); + }); + + it('should not cancel drag when _movingFocus is true', () => { + component.data = [{ id: 0 }, { id: 1 }]; + component._onDragHandleKeydown( + new KeyboardEvent('keydown', { key: 'Enter' }), + 0 + ); + (component as unknown as { _movingFocus: boolean })._movingFocus = true; + component._onDragHandleBlur(); + expect(component.keyboardDragRowIndex).toBe(0); + }); + + it('should do nothing when drag is not active', () => { + expect(component.keyboardDragRowIndex).toBeNull(); + expect(() => component._onDragHandleBlur()).not.toThrow(); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.ts b/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.ts index 6132eb951..e240d82eb 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.ts @@ -4,19 +4,22 @@ import { ChangeDetectorRef, Component, ContentChild, + ElementRef, EventEmitter, - Inject, Input, OnChanges, OnInit, Output, - SimpleChanges, TemplateRef, - ViewChild + ViewChild, + inject, + type SimpleChanges } from '@angular/core'; import { CommonModule, DOCUMENT } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Table, TableService, TableModule } from 'primeng/table'; +import type { TablePassThrough } from 'primeng/types/table'; +import type { PaginatorPassThrough } from 'primeng/types/paginator'; import { SortEvent } from 'primeng/api'; import { CpsInputComponent } from '../cps-input/cps-input.component'; import { CpsButtonComponent } from '../cps-button/cps-button.component'; @@ -25,13 +28,14 @@ import { CpsIconComponent } from '../cps-icon/cps-icon.component'; import { CpsMenuComponent, CpsMenuItem } from '../cps-menu/cps-menu.component'; import { CpsLoaderComponent } from '../cps-loader/cps-loader.component'; import { TableRowMenuComponent } from './components/internal/table-row-menu/table-row-menu.component'; -import { CpsTableColumnSortableDirective } from './directives/cps-table-column-sortable.directive'; +import { TableColumnVisibilityToggleComponent } from './components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component'; +import { CpsTableColumnSortableDirective } from './directives/cps-table-column-sortable/cps-table-column-sortable.directive'; import { TableUnsortDirective } from './directives/internal/table-unsort.directive'; import { convertSize } from '../../utils/internal/size-utils'; -import { isEqual } from 'lodash-es'; -import { CpsTableColumnFilterDirective } from './directives/cps-table-column-filter.directive'; -import { CpsTableDetectFilterTypePipe } from './pipes/cps-table-detect-filter-type.pipe'; -import { CpsTableColumnResizableDirective } from './directives/cps-table-column-resizable.directive'; +import { CpsTableColumnFilterDirective } from './directives/cps-table-column-filter/cps-table-column-filter.directive'; +import { CpsTableDetectFilterTypePipe } from './pipes/cps-table-detect-filter-type/cps-table-detect-filter-type.pipe'; +import { CpsTableColumnResizableDirective } from './directives/cps-table-column-resizable/cps-table-column-resizable.directive'; +import { CPS_LIVE_ANNOUNCER_SERVICE } from '../../services/cps-live-announcer/cps-live-announcer.service'; // import jsPDF from 'jspdf'; // import 'jspdf-autotable'; @@ -71,6 +75,10 @@ export type CpsTableSortMode = 'single' | 'multiple'; @Component({ selector: 'cps-table', changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '[style.--cps-scroll-height]': 'scrollHeight || ""', + '(keydown)': 'onPaginatorKeydown($event)' + }, imports: [ FormsModule, CommonModule, @@ -83,6 +91,7 @@ export type CpsTableSortMode = 'single' | 'multiple'; CpsMenuComponent, CpsLoaderComponent, TableRowMenuComponent, + TableColumnVisibilityToggleComponent, CpsTableColumnSortableDirective, CpsTableColumnFilterDirective, CpsTableColumnResizableDirective, @@ -676,21 +685,24 @@ export class CpsTableComponent implements OnInit, AfterViewChecked, OnChanges { @ViewChild('exportMenu') exportMenu!: CpsMenuComponent; - @ViewChild('colToggleMenu') - colToggleMenu!: CpsMenuComponent; - @ViewChild('tUnsortDirective') tUnsortDirective!: TableUnsortDirective; _data: any[] = []; selectedRows: any[] = []; + isExportMenuOpen = false; + virtualScrollItemSize = 0; rowOptions: { label: string; value: number }[] = []; selectedColumns: { [key: string]: any }[] = []; + keyboardDragRowIndex: number | null = null; + + tablePassthrough: TablePassThrough = {}; + exportMenuItems: CpsMenuItem[] = [ { title: 'CSV', @@ -715,11 +727,14 @@ export class CpsTableComponent implements OnInit, AfterViewChecked, OnChanges { // } ]; - // eslint-disable-next-line no-useless-constructor - constructor( - private cdRef: ChangeDetectorRef, - @Inject(DOCUMENT) private document: Document - ) {} + private _keyboardDragOriginalIndex: number | null = null; + private _keyboardDragSnapshot: any[] | null = null; + private _movingFocus = false; + + private readonly _cdRef = inject(ChangeDetectorRef); + private readonly _document = inject(DOCUMENT); + private readonly _elementRef = inject(ElementRef); + private readonly _liveAnnouncer = inject(CPS_LIVE_ANNOUNCER_SERVICE); ngOnInit(): void { this.emptyBodyHeight = convertSize(this.emptyBodyHeight); @@ -754,6 +769,29 @@ export class CpsTableComponent implements OnInit, AfterViewChecked, OnChanges { this.selectedColumns = this.initialColumns.length > 0 ? this.initialColumns : this.columns; + + this.tablePassthrough = this._buildTablePassthrough(); + } + + private _buildTablePassthrough(): TablePassThrough { + const pt: TablePassThrough = {}; + if (!this.virtualScroll && this.scrollHeight) { + pt.tableContainer = { tabindex: 0 }; + } + if (this.paginator) { + const effectiveTotalRecords = this.lazy + ? this.totalRecords + : (this.data?.length ?? 0); + const firstDisabled = this.first === 0 || effectiveTotalRecords === 0; + const paginatorPt: PaginatorPassThrough = { + first: { + 'aria-disabled': firstDisabled ? 'true' : null, + tabindex: firstDisabled ? -1 : 0 + } + }; + pt.pcPaginator = paginatorPt; + } + return pt; } get styleClass() { @@ -778,7 +816,7 @@ export class CpsTableComponent implements OnInit, AfterViewChecked, OnChanges { this.primengTable?.el?.nativeElement ?.querySelector('.p-datatable-tbody') ?.querySelector('tr')?.clientHeight || 0; - this.cdRef.detectChanges(); + this._cdRef.detectChanges(); } ngOnChanges(changes: SimpleChanges): void { @@ -787,12 +825,23 @@ export class CpsTableComponent implements OnInit, AfterViewChecked, OnChanges { if (this.clearGlobalFilterOnLoading) this.clearGlobalFilter(); } - if (changes?.data) { + if (changes.data) { this.resetSortingState(); this.selectedRows = this.selectedRows.filter((sr) => this.data.includes(sr) ); } + + if ( + changes.data || + changes.virtualScroll || + changes.scrollHeight || + changes.paginator || + changes.first || + changes.totalRecords + ) { + this.tablePassthrough = this._buildTablePassthrough(); + } } resetSortingState() { @@ -863,9 +912,73 @@ export class CpsTableComponent implements OnInit, AfterViewChecked, OnChanges { } } + _onDragHandleKeydown(event: KeyboardEvent, rowIndex: number): void { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + if (this.keyboardDragRowIndex === null) { + this._activateKeyboardDrag(rowIndex); + } else { + this._confirmKeyboardDrag(); + } + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + if (this.keyboardDragRowIndex !== null) { + this._cancelKeyboardDrag(); + } + return; + } + + if (this.keyboardDragRowIndex === null) return; + + const maxIndex = this._data.length - 1; + if (event.key === 'ArrowUp' && this.keyboardDragRowIndex > 0) { + event.preventDefault(); + this._moveKeyboardRow(-1); + } else if ( + event.key === 'ArrowDown' && + this.keyboardDragRowIndex < maxIndex + ) { + event.preventDefault(); + this._moveKeyboardRow(1); + } + } + + onPaginatorKeydown(event: KeyboardEvent): void { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + const target = event.target as HTMLElement; + if (!target.classList.contains('p-paginator-page')) return; + + event.preventDefault(); + const pageButtons = this._getPaginatorPageButtons(); + const currentIndex = pageButtons.indexOf(target as HTMLButtonElement); + const delta = event.key === 'ArrowRight' ? 1 : -1; + const targetIndex = currentIndex + delta; + + if (targetIndex >= 0 && targetIndex < pageButtons.length) { + pageButtons[targetIndex].focus(); + pageButtons[targetIndex].click(); + } else { + const focusedPageNum = parseInt( + (target as HTMLButtonElement).textContent?.trim() || '1', + 10 + ); + const atBoundary = + delta > 0 ? focusedPageNum >= this.getPageCount() : focusedPageNum <= 1; + if (!atBoundary) { + this.changePage(focusedPageNum - 1 + delta); + setTimeout(() => this._focusPaginatorSelectedPage()); + } + } + } + onPageChange(event: any) { this.first = event.first; this.rows = event.rows; + this.tablePassthrough = this._buildTablePassthrough(); const state = { page: this.getPage(), @@ -875,34 +988,112 @@ export class CpsTableComponent implements OnInit, AfterViewChecked, OnChanges { }; this.pageChanged.emit(state); + + const activeEl = this._document.activeElement as HTMLElement | null; + const atFirst = this.first === 0; + const atLast = this.first + this.rows >= this.primengTable.totalRecords; + if ( + (atFirst && + (activeEl?.classList.contains('p-paginator-first') || + activeEl?.classList.contains('p-paginator-prev'))) || + (atLast && + (activeEl?.classList.contains('p-paginator-last') || + activeEl?.classList.contains('p-paginator-next'))) + ) { + setTimeout(() => this._focusPaginatorSelectedPage()); + } } - toggleAllColumns() { - this.selectedColumns = - this.selectedColumns.length < this.columns.length ? this.columns : []; - this.columnsSelected.emit(this.selectedColumns); + private _getPaginatorPageButtons(): HTMLButtonElement[] { + return Array.from( + this._elementRef.nativeElement.querySelectorAll('.p-paginator-page') + ) as HTMLButtonElement[]; } - isColumnSelected(col: any) { - return this.selectedColumns.some((item) => isEqual(item, col)); + private _focusPaginatorSelectedPage(): void { + const selected = this._elementRef.nativeElement.querySelector( + '.p-paginator-page[aria-current="page"]' + ) as HTMLButtonElement | null; + selected?.focus(); } - onSelectColumn(col: any) { - let res: any[] = []; - if (this.isColumnSelected(col)) { - res = this.selectedColumns.filter((v: any) => !isEqual(v, col)); - } else { - this.columns.forEach((o) => { - if ( - this.selectedColumns.some((v: any) => isEqual(v, o)) || - isEqual(col, o) - ) { - res.push(o); - } - }); + private _cleanupDrag(): void { + this.keyboardDragRowIndex = null; + this._keyboardDragOriginalIndex = null; + this._keyboardDragSnapshot = null; + this._cdRef.markForCheck(); + } + + private _activateKeyboardDrag(rowIndex: number): void { + this.keyboardDragRowIndex = rowIndex; + this._keyboardDragOriginalIndex = rowIndex; + this._keyboardDragSnapshot = [...this._data]; + this._cdRef.markForCheck(); + this._liveAnnouncer?.announce( + `Row ${rowIndex + 1} picked up. Press arrow keys to move, Enter to confirm, Escape to cancel.` + ); + } + + _onDragHandleBlur(): void { + if (this._movingFocus) return; + if (this.keyboardDragRowIndex !== null) { + this._cleanupDrag(); } - this.selectedColumns = res; - this.columnsSelected.emit(this.selectedColumns); + } + + private _moveKeyboardRow(direction: -1 | 1): void { + const from = this.keyboardDragRowIndex!; + const to = from + direction; + const arr = [...this._data]; + const [item] = arr.splice(from, 1); + arr.splice(to, 0, item); + this._data = arr; + this.keyboardDragRowIndex = to; + this._movingFocus = true; + this._cdRef.markForCheck(); + this._liveAnnouncer?.announce( + `Row moved to position ${to + 1} of ${arr.length}.` + ); + setTimeout(() => { + this._movingFocus = false; + this._focusDragHandle(to); + }); + } + + private _confirmKeyboardDrag(): void { + const dragIndex = this._keyboardDragOriginalIndex!; + const dropIndex = this.keyboardDragRowIndex!; + this._cleanupDrag(); + this.rowsReordered.emit({ dragIndex, dropIndex }); + this._liveAnnouncer?.announce(`Row dropped at position ${dropIndex + 1}.`); + this._focusDragHandle(dropIndex); + } + + private _cancelKeyboardDrag(): void { + const originalIdx = this._keyboardDragOriginalIndex!; + this._data = [...this._keyboardDragSnapshot!]; + this._cleanupDrag(); + this._liveAnnouncer?.announce( + 'Reorder cancelled. Row returned to original position.' + ); + this._focusDragHandle(originalIdx); + } + + private _focusDragHandle(rowIndex: number): void { + const handle = ( + this._elementRef.nativeElement as HTMLElement + ).querySelector( + `.cps-table-row-drag-handle[data-row-index="${rowIndex}"]` + ); + if (handle) { + handle.focus(); + handle.scrollIntoView({ block: 'center' }); + } + } + + onColumnsSelectedChange(cols: { [key: string]: any }[]): void { + this.selectedColumns = cols; + this.columnsSelected.emit(cols); } onEditRowClicked(row: any) { @@ -943,11 +1134,6 @@ export class CpsTableComponent implements OnInit, AfterViewChecked, OnChanges { this.exportMenu?.toggle(event); } - onColumnsToggle(event: any) { - if (this.columnsToggleBtnDisabled) return; - this.colToggleMenu?.toggle(event); - } - private _getIndexes(rows: any[]) { let indexes: number[] = rows.map((row) => this.primengTable.value.indexOf(row) @@ -1011,7 +1197,7 @@ export class CpsTableComponent implements OnInit, AfterViewChecked, OnChanges { type: EXCEL_TYPE }); - const downloadLink = this.document.createElement('a'); + const downloadLink = this._document.createElement('a'); downloadLink.href = URL.createObjectURL(blob); downloadLink.download = `${this.exportFilename}.xlsx`; downloadLink.click(); diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-filter/cps-table-column-filter.directive.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-filter/cps-table-column-filter.directive.spec.ts new file mode 100644 index 000000000..1714d7aed --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-filter/cps-table-column-filter.directive.spec.ts @@ -0,0 +1,384 @@ +import { Component, EventEmitter, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Table } from 'primeng/table'; +import { + CpsColumnFilterMatchMode, + CpsColumnFilterType +} from '../../cps-column-filter-types'; +import { CpsTableColumnFilterDirective } from './cps-table-column-filter.directive'; + +@Component({ + standalone: true, + template: ` + Header + `, + imports: [CpsTableColumnFilterDirective] +}) +class TestHostComponent { + @ViewChild(CpsTableColumnFilterDirective) + directive!: CpsTableColumnFilterDirective; + + field: string | undefined = 'name'; + filterType: CpsColumnFilterType = 'text'; + filterPersistent = false; + filterShowClearButton = true; + filterShowApplyButton = true; + filterShowCloseButton = false; + filterShowMatchModes = true; + filterMatchModes: CpsColumnFilterMatchMode[] = []; + filterShowOperator = true; + filterMaxConstraints = 2; + filterHeaderTitle = ''; + filterHideOnClear = false; + filterCategoryOptions: string[] = []; + filterAsButtonToggle = false; + filterSingleSelection = false; + filterPlaceholder = ''; +} + +describe('CpsTableColumnFilterDirective', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let directive: CpsTableColumnFilterDirective; + let mockTable: Table; + + beforeEach(async () => { + mockTable = Object.create(Table.prototype) as Table; + (mockTable as { ngOnDestroy: () => void }).ngOnDestroy = jest.fn(); + (mockTable as { filters: Record }).filters = {}; + (mockTable as { onFilter: EventEmitter }).onFilter = + new EventEmitter(); + jest.spyOn(mockTable, 'isFilterBlank').mockReturnValue(false); + jest.spyOn(mockTable, '_filter').mockImplementation(() => {}); + + await TestBed.configureTestingModule({ + imports: [TestHostComponent, NoopAnimationsModule], + providers: [{ provide: Table, useValue: mockTable }] + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + directive = host.directive; + }); + + afterEach(() => { + document.body + .querySelectorAll('.cps-menu-container, .cps-overlay-panel') + .forEach((el) => el.remove()); + }); + + it('should create', () => { + expect(directive).toBeTruthy(); + }); + + it('should create a TableColumnFilterComponent in filterCompRef', () => { + expect(directive.filterCompRef).toBeTruthy(); + expect(directive.filterCompRef.instance).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('should insert the filter component element into the host th', () => { + const th = fixture.debugElement.query(By.css('th')) + .nativeElement as HTMLElement; + const filterEl = directive.filterCompRef.location.nativeElement; + expect(th.contains(filterEl)).toBe(true); + }); + + it('should place the filter element after the first child', () => { + const th = fixture.debugElement.query(By.css('th')) + .nativeElement as HTMLElement; + const filterEl = directive.filterCompRef.location.nativeElement; + const firstChild = th.firstChild as HTMLElement; + expect(firstChild.nextSibling).toBe(filterEl); + }); + }); + + describe('ngOnChanges', () => { + it('should forward field to filter component', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.field = 'age'; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'field', + 'age' + ); + }); + + it('should forward filterType as type', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterType = 'number'; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'type', + 'number' + ); + }); + + it('should forward filterPersistent as persistent', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterPersistent = true; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'persistent', + true + ); + }); + + it('should forward filterShowClearButton as showClearButton', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterShowClearButton = false; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'showClearButton', + false + ); + }); + + it('should forward filterShowApplyButton as showApplyButton', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterShowApplyButton = false; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'showApplyButton', + false + ); + }); + + it('should forward filterShowCloseButton as showCloseButton', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterShowCloseButton = true; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'showCloseButton', + true + ); + }); + + it('should forward filterShowMatchModes as showMatchModes', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterShowMatchModes = false; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'showMatchModes', + false + ); + }); + + it('should forward filterMatchModes as matchModes', () => { + const modes: CpsColumnFilterMatchMode[] = [ + CpsColumnFilterMatchMode.CONTAINS, + CpsColumnFilterMatchMode.EQUALS + ]; + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterMatchModes = modes; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'matchModes', + modes + ); + }); + + it('should forward filterShowOperator as showOperator', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterShowOperator = false; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'showOperator', + false + ); + }); + + it('should forward filterMaxConstraints as maxConstraints', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterMaxConstraints = 5; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'maxConstraints', + 5 + ); + }); + + it('should forward filterHeaderTitle as headerTitle', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterHeaderTitle = 'My Filter'; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'headerTitle', + 'My Filter' + ); + }); + + it('should forward filterHideOnClear as hideOnClear', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterHideOnClear = true; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'hideOnClear', + true + ); + }); + + it('should forward filterCategoryOptions as categoryOptions', () => { + const opts = ['Active', 'Inactive']; + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterCategoryOptions = opts; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'categoryOptions', + opts + ); + }); + + it('should forward filterAsButtonToggle as asButtonToggle', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterAsButtonToggle = true; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'asButtonToggle', + true + ); + }); + + it('should forward filterSingleSelection as singleSelection', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterSingleSelection = true; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'singleSelection', + true + ); + }); + + it('should forward explicit filterPlaceholder as placeholder', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterPlaceholder = 'Search here'; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'placeholder', + 'Search here' + ); + }); + }); + + describe('placeholder defaults (_getDefaultPlaceholder)', () => { + it('should use "Please enter" for text type when filterPlaceholder is empty', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterType = 'text'; + host.filterPlaceholder = ''; + host.field = 'trigger'; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'placeholder', + 'Please enter' + ); + }); + + it('should use "Enter value" for number type', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterType = 'number'; + host.filterPlaceholder = ''; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'placeholder', + 'Enter value' + ); + }); + + it('should use "Select date" for date type', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterType = 'date'; + host.filterPlaceholder = ''; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'placeholder', + 'Select date' + ); + }); + + it('should use "Please select" for category type', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterType = 'category'; + host.filterPlaceholder = ''; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'placeholder', + 'Please select' + ); + }); + + it('should use empty string for boolean type (no default)', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterType = 'boolean'; + host.filterPlaceholder = ''; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'placeholder', + '' + ); + }); + + it('should prefer explicit filterPlaceholder over the default', () => { + jest.spyOn(directive.filterCompRef, 'setInput'); + host.filterType = 'text'; + host.filterPlaceholder = 'Custom'; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'placeholder', + 'Custom' + ); + }); + }); + + describe('hideFilter', () => { + it('should delegate to filterCompRef.instance.hide', () => { + jest.spyOn(directive.filterCompRef.instance, 'hide'); + directive.hideFilter(); + expect(directive.filterCompRef.instance.hide).toHaveBeenCalled(); + }); + }); + + describe('clearFilter', () => { + it('should delegate to filterCompRef.instance.clearFilter', () => { + jest.spyOn(directive.filterCompRef.instance, 'clearFilter'); + directive.clearFilter(); + expect(directive.filterCompRef.instance.clearFilter).toHaveBeenCalled(); + }); + }); + + describe('clearFilterValues', () => { + it('should delegate to filterCompRef.instance.clearFilterValues', () => { + jest.spyOn(directive.filterCompRef.instance, 'clearFilterValues'); + directive.clearFilterValues(); + expect( + directive.filterCompRef.instance.clearFilterValues + ).toHaveBeenCalled(); + }); + }); + + describe('ngOnDestroy', () => { + it('should call filterCompRef.destroy', () => { + jest.spyOn(directive.filterCompRef, 'destroy'); + directive.ngOnDestroy(); + expect(directive.filterCompRef.destroy).toHaveBeenCalled(); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-filter.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-filter/cps-table-column-filter.directive.ts similarity index 96% rename from projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-filter.directive.ts rename to projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-filter/cps-table-column-filter.directive.ts index d56750cbe..40b3ac6a7 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-filter.directive.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-filter/cps-table-column-filter.directive.ts @@ -13,15 +13,14 @@ import { CpsColumnFilterCategoryOption, CpsColumnFilterMatchMode, CpsColumnFilterType -} from '../cps-column-filter-types'; -import { TableColumnFilterComponent } from '../components/internal/table-column-filter/table-column-filter.component'; +} from '../../cps-column-filter-types'; +import { TableColumnFilterComponent } from '../../components/internal/table-column-filter/table-column-filter.component'; /** * CpsTableColumnFilterDirective is a filtering directive used to filter single or multiple columns in table. * @group Directives */ @Directive({ - standalone: true, selector: '[cpsTColFilter]', exportAs: 'cpsTColFilter' }) diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-resizable.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-resizable.directive.ts deleted file mode 100644 index 8d1d8d72d..000000000 --- a/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-resizable.directive.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Directive, Input } from '@angular/core'; -import { ResizableColumn } from 'primeng/table'; - -/** - * CpsTableColumnResizableDirective is a directive to enable column resizing in a table. - * @group Directives - */ -@Directive({ - standalone: true, - selector: '[cpsTColResizable]' -}) -export class CpsTableColumnResizableDirective extends ResizableColumn { - /** - * Whether the column resizing should be disabled. - * @group Props - */ - @Input('cpsTColResizableDisabled') override pResizableColumnDisabled: - | boolean - | undefined; -} diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-resizable/cps-table-column-resizable.directive.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-resizable/cps-table-column-resizable.directive.spec.ts new file mode 100644 index 000000000..1f915daa9 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-resizable/cps-table-column-resizable.directive.spec.ts @@ -0,0 +1,372 @@ +import { Component, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ResizableColumn, Table, TableStyle } from 'primeng/table'; +import { BaseComponent } from 'primeng/basecomponent'; +import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../../../services/cps-root-font-size/cps-root-font-size.service'; +import { CpsTableColumnResizableDirective } from './cps-table-column-resizable.directive'; + +@Component({ + standalone: true, + template: ` + + + + + +
+ Name +
`, + imports: [CpsTableColumnResizableDirective] +}) +class TestHostComponent { + @ViewChild(CpsTableColumnResizableDirective) + directive!: CpsTableColumnResizableDirective; + + disabled: boolean | undefined = undefined; +} + +function buildMockTable() { + return { + _cpsResizeCellsPatched: false, + resizeTableCells: jest.fn(), + columnResizeMode: 'expand', + tableViewChild: { nativeElement: document.createElement('table') }, + el: { nativeElement: document.createElement('div') }, + resizeColumnElement: null as HTMLElement | null, + _initialColWidths: null as number | null, + _totalTableWidth: jest.fn().mockReturnValue(1000), + setResizeTableWidth: jest.fn(), + onColResize: { emit: jest.fn() } + }; +} + +describe('CpsTableColumnResizableDirective', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let directive: CpsTableColumnResizableDirective; + let mockTable: ReturnType; + let mockResizer: HTMLSpanElement; + + beforeEach(async () => { + mockResizer = document.createElement('span'); + mockTable = buildMockTable(); + + jest + .spyOn(BaseComponent.prototype, 'ngOnInit') + .mockImplementation(() => {}); + + jest + .spyOn(ResizableColumn.prototype, 'onAfterViewInit') + .mockImplementation(function (this: ResizableColumn) { + (this as { resizer: HTMLSpanElement | undefined }).resizer = + mockResizer; + }); + + jest + .spyOn(ResizableColumn.prototype, 'onDestroy') + .mockImplementation(() => {}); + + await TestBed.configureTestingModule({ + imports: [TestHostComponent, NoopAnimationsModule], + providers: [ + { provide: Table, useValue: mockTable }, + { + provide: TableStyle, + useValue: { name: 'table', loadCSS: () => {}, getCSS: () => '' } + }, + { + provide: CPS_ROOT_FONT_SIZE_SERVICE, + useValue: { fontSize: () => 16 } + } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + directive = host.directive; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should create', () => { + expect(directive).toBeTruthy(); + }); + + describe('cpsTColResizableDisabled input', () => { + it('should default pResizableColumnDisabled to undefined', () => { + expect(directive.pResizableColumnDisabled).toBeUndefined(); + }); + + it('should set pResizableColumnDisabled via cpsTColResizableDisabled alias', () => { + host.disabled = true; + fixture.detectChanges(); + expect(directive.pResizableColumnDisabled).toBe(true); + }); + + it('should pass false through the alias', () => { + host.disabled = false; + fixture.detectChanges(); + expect(directive.pResizableColumnDisabled).toBe(false); + }); + }); + + describe('isEnabled', () => { + it('should return true when pResizableColumnDisabled is undefined', () => { + expect(directive.isEnabled()).toBe(true); + }); + + it('should return true when pResizableColumnDisabled is false', () => { + host.disabled = false; + fixture.detectChanges(); + expect(directive.isEnabled()).toBe(true); + }); + + it('should return false when pResizableColumnDisabled is true', () => { + host.disabled = true; + fixture.detectChanges(); + expect(directive.isEnabled()).toBe(false); + }); + }); + + describe('onAfterViewInit — resizer ARIA attributes (when enabled)', () => { + it('should set tabindex 0 on resizer', () => { + expect(mockResizer.getAttribute('tabindex')).toBe('0'); + }); + + it('should set role separator on resizer', () => { + expect(mockResizer.getAttribute('role')).toBe('separator'); + }); + + it('should set aria-orientation vertical on resizer', () => { + expect(mockResizer.getAttribute('aria-orientation')).toBe('vertical'); + }); + + it('should set aria-label on resizer', () => { + expect(mockResizer.getAttribute('aria-label')).toBe('Column resizer'); + }); + + it('should set aria-valuenow 0 on resizer', () => { + expect(mockResizer.getAttribute('aria-valuenow')).toBe('0'); + }); + + it('should set aria-valuetext on resizer', () => { + expect(mockResizer.getAttribute('aria-valuetext')).toContain( + 'arrow keys' + ); + }); + }); + + describe('onAfterViewInit — ARIA skipped when disabled', () => { + it('should not set ARIA on resizer when pResizableColumnDisabled is true', () => { + const freshResizer = document.createElement('span'); + jest + .spyOn(ResizableColumn.prototype, 'onAfterViewInit') + .mockImplementation(function (this: ResizableColumn) { + (this as { resizer: HTMLSpanElement | undefined }).resizer = + freshResizer; + }); + + const f = TestBed.createComponent(TestHostComponent); + f.componentInstance.disabled = true; + f.detectChanges(); + + expect(freshResizer.getAttribute('tabindex')).toBeNull(); + expect(freshResizer.getAttribute('role')).toBeNull(); + }); + }); + + describe('onAfterViewInit — resizeTableCells patch', () => { + it('should set _cpsResizeCellsPatched to true', () => { + expect(mockTable._cpsResizeCellsPatched).toBe(true); + }); + + it('should replace table.resizeTableCells with a patched function', () => { + expect(mockTable.resizeTableCells).not.toBeInstanceOf( + jest.fn().constructor + ); + expect(typeof mockTable.resizeTableCells).toBe('function'); + }); + + it('should not re-patch when a second directive is created on the same table', () => { + const patchedRef = mockTable.resizeTableCells; + const f2 = TestBed.createComponent(TestHostComponent); + f2.detectChanges(); + expect(mockTable.resizeTableCells).toBe(patchedRef); + }); + }); + + describe('_onResizerKeydown', () => { + let th: HTMLElement; + + beforeEach(() => { + th = fixture.debugElement.query(By.css('th')) + .nativeElement as HTMLElement; + Object.defineProperty(th, 'offsetWidth', { + value: 100, + configurable: true, + writable: true + }); + jest.spyOn(mockTable, 'resizeTableCells'); + }); + + it('should ignore non-arrow keys and not call preventDefault', () => { + const event = new KeyboardEvent('keydown', { key: 'Enter' }); + jest.spyOn(event, 'preventDefault'); + (directive as any)._onResizerKeydown(event); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(mockTable.resizeTableCells).not.toHaveBeenCalled(); + }); + + it('should call preventDefault on ArrowRight', () => { + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }); + jest.spyOn(event, 'preventDefault'); + (directive as any)._onResizerKeydown(event); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should call preventDefault on ArrowLeft', () => { + const event = new KeyboardEvent('keydown', { key: 'ArrowLeft' }); + jest.spyOn(event, 'preventDefault'); + (directive as any)._onResizerKeydown(event); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should use 10px step on ArrowRight (expand mode)', () => { + mockTable.columnResizeMode = 'expand'; + (directive as any)._onResizerKeydown( + new KeyboardEvent('keydown', { key: 'ArrowRight' }) + ); + expect(mockTable.resizeTableCells).toHaveBeenCalledWith(110, null); + }); + + it('should use 10px step on ArrowLeft (expand mode)', () => { + mockTable.columnResizeMode = 'expand'; + (directive as any)._onResizerKeydown( + new KeyboardEvent('keydown', { key: 'ArrowLeft' }) + ); + expect(mockTable.resizeTableCells).toHaveBeenCalledWith(90, null); + }); + + it('should use 50px step when Shift is held', () => { + mockTable.columnResizeMode = 'expand'; + (directive as any)._onResizerKeydown( + new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true }) + ); + expect(mockTable.resizeTableCells).toHaveBeenCalledWith(150, null); + }); + + it('should not resize if newColumnWidth < 15', () => { + mockTable.columnResizeMode = 'expand'; + Object.defineProperty(th, 'offsetWidth', { + value: 20, + configurable: true, + writable: true + }); + // ArrowLeft + Shift: delta = -50, newColumnWidth = 20 - 50 = -30 < 15 + (directive as any)._onResizerKeydown( + new KeyboardEvent('keydown', { key: 'ArrowLeft', shiftKey: true }) + ); + expect(mockTable.resizeTableCells).not.toHaveBeenCalled(); + }); + + it('should set table.resizeColumnElement to the th', () => { + mockTable.columnResizeMode = 'expand'; + (directive as any)._onResizerKeydown( + new KeyboardEvent('keydown', { key: 'ArrowRight' }) + ); + expect(mockTable.resizeColumnElement).toBe(th); + }); + + it('should call setResizeTableWidth with rem value in expand mode', () => { + mockTable.columnResizeMode = 'expand'; + Object.defineProperty( + mockTable.tableViewChild.nativeElement, + 'offsetWidth', + { + value: 800, + configurable: true + } + ); + (directive as any)._onResizerKeydown( + new KeyboardEvent('keydown', { key: 'ArrowRight' }) + ); + // tableWidth = 800 + 10 = 810; 810 / 16 = 50.625rem + expect(mockTable.setResizeTableWidth).toHaveBeenCalledWith('50.625rem'); + }); + + it('should emit onColResize with element and delta', () => { + mockTable.columnResizeMode = 'expand'; + (directive as any)._onResizerKeydown( + new KeyboardEvent('keydown', { key: 'ArrowRight' }) + ); + expect(mockTable.onColResize.emit).toHaveBeenCalledWith({ + element: th, + delta: 10 + }); + }); + + it('should not call resizeTableCells in fit mode when there is no next sibling', () => { + mockTable.columnResizeMode = 'fit'; + (directive as any)._onResizerKeydown( + new KeyboardEvent('keydown', { key: 'ArrowRight' }) + ); + expect(mockTable.resizeTableCells).not.toHaveBeenCalled(); + }); + + it('should call resizeTableCells with both widths in fit mode', () => { + mockTable.columnResizeMode = 'fit'; + const nextTh = document.createElement('th'); + Object.defineProperty(nextTh, 'offsetWidth', { + value: 200, + configurable: true + }); + th.after(nextTh); + + (directive as any)._onResizerKeydown( + new KeyboardEvent('keydown', { key: 'ArrowRight' }) + ); + // delta = 10; newColumnWidth = 110; nextColumnWidth = 200 - 10 = 190 + expect(mockTable.resizeTableCells).toHaveBeenCalledWith(110, 190); + + nextTh.remove(); + }); + + it('should not resize in fit mode when nextColumnWidth < 15', () => { + mockTable.columnResizeMode = 'fit'; + const nextTh = document.createElement('th'); + Object.defineProperty(nextTh, 'offsetWidth', { + value: 20, + configurable: true + }); + th.after(nextTh); + + // ArrowRight + Shift: delta = 50; nextColumnWidth = 20 - 50 = -30 < 15 + (directive as any)._onResizerKeydown( + new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true }) + ); + expect(mockTable.resizeTableCells).not.toHaveBeenCalled(); + + nextTh.remove(); + }); + }); + + describe('onDestroy', () => { + it('should call super.onDestroy', () => { + directive.onDestroy(); + expect(ResizableColumn.prototype.onDestroy).toHaveBeenCalled(); + }); + + it('should clear all listener references', () => { + directive.onDestroy(); + expect((directive as any)._keydownListener).toBeUndefined(); + expect((directive as any)._focusListener).toBeUndefined(); + expect((directive as any)._blurListener).toBeUndefined(); + expect((directive as any)._thScrollListener).toBeUndefined(); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-resizable/cps-table-column-resizable.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-resizable/cps-table-column-resizable.directive.ts new file mode 100644 index 000000000..67bd60dde --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-resizable/cps-table-column-resizable.directive.ts @@ -0,0 +1,155 @@ +import { Directive, Input, inject } from '@angular/core'; +import { ResizableColumn } from 'primeng/table'; +import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../../../services/cps-root-font-size/cps-root-font-size.service'; + +/** + * CpsTableColumnResizableDirective is a directive to enable column resizing in a table. + * @group Directives + */ +@Directive({ + selector: '[cpsTColResizable]' +}) +export class CpsTableColumnResizableDirective extends ResizableColumn { + /** + * Whether the column resizing should be disabled. + * @group Props + */ + @Input('cpsTColResizableDisabled') override pResizableColumnDisabled: + | boolean + | undefined; + + private readonly _cpsRootFontSizeService = inject(CPS_ROOT_FONT_SIZE_SERVICE); + + private get _rootFontSizePx(): number { + return this._cpsRootFontSizeService?.fontSize() || 16; + } + + private _keydownListener?: () => void; + private _focusListener?: () => void; + private _blurListener?: () => void; + private _thScrollListener?: () => void; + + override onAfterViewInit(): void { + super.onAfterViewInit(); + if (this.isEnabled() && this.resizer) { + this.renderer.setAttribute(this.resizer, 'tabindex', '0'); + this.renderer.setAttribute(this.resizer, 'role', 'separator'); + this.renderer.setAttribute(this.resizer, 'aria-orientation', 'vertical'); + this.renderer.setAttribute(this.resizer, 'aria-label', 'Column resizer'); + this.renderer.setAttribute(this.resizer, 'aria-valuenow', '0'); + this.renderer.setAttribute( + this.resizer, + 'aria-valuetext', + 'Use left or right arrow keys to resize the column. Hold Shift for larger steps.' + ); + this.zone.runOutsideAngular(() => { + this._keydownListener = this.renderer.listen( + this.resizer, + 'keydown', + this._onResizerKeydown.bind(this) + ); + this._focusListener = this.renderer.listen(this.resizer, 'focus', () => + this.renderer.addClass(this.resizer, 'cps-col-resizer-focused') + ); + this._blurListener = this.renderer.listen(this.resizer, 'blur', () => + this.renderer.removeClass(this.resizer, 'cps-col-resizer-focused') + ); + this._thScrollListener = this.renderer.listen( + this.el.nativeElement, + 'focusin', + (event: FocusEvent) => { + if (event.target !== this.resizer) return; + const th = this.el.nativeElement as HTMLElement; + requestAnimationFrame(() => { + if (th.scrollLeft !== 0) th.scrollLeft = 0; + }); + } + ); + }); + } + + const table = this.dataTable as any; + if (!table._cpsResizeCellsPatched) { + table._cpsResizeCellsPatched = true; + const original = table.resizeTableCells.bind(table) as ( + newColumnWidth: number, + nextColumnWidth: number | null + ) => void; + table.resizeTableCells = ( + newColumnWidth: number, + nextColumnWidth: number | null + ) => { + if (table.columnResizeMode !== 'fit') { + original(newColumnWidth, nextColumnWidth); + return; + } + const tableEl = table.tableViewChild + ?.nativeElement as HTMLElement | null; + const thead = (table.el.nativeElement as HTMLElement).querySelector( + '[data-pc-section="thead"]' + ) as HTMLElement | null; + if (!tableEl || !thead) { + original(newColumnWidth, nextColumnWidth); + return; + } + const headers = Array.from( + thead.querySelectorAll('tr > th') + ) as HTMLElement[]; + const resizeEl = table.resizeColumnElement as HTMLElement | null; + if (!resizeEl) return; + const colIndex = headers.indexOf(resizeEl); + if (colIndex === -1) return; + const widths = headers.map((h) => h.offsetWidth); + headers.forEach((h, i) => { + let w = widths[i]; + if (i === colIndex) w = newColumnWidth; + else if (nextColumnWidth !== null && i === colIndex + 1) + w = nextColumnWidth; + h.style.width = w / this._rootFontSizePx + 'rem'; + }); + tableEl.style.tableLayout = 'fixed'; + }; + } + } + + override onDestroy(): void { + this._keydownListener?.(); + this._focusListener?.(); + this._blurListener?.(); + this._thScrollListener?.(); + this._keydownListener = undefined; + this._focusListener = undefined; + this._blurListener = undefined; + this._thScrollListener = undefined; + super.onDestroy(); + } + + private _onResizerKeydown(event: KeyboardEvent): void { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + + const direction = event.key === 'ArrowRight' ? 1 : -1; + const delta = direction * (event.shiftKey ? 50 : 10); + const th = this.el.nativeElement as HTMLElement; + const newColumnWidth = th.offsetWidth + delta; + if (newColumnWidth < 15) return; + + const table = this.dataTable as any; + table.resizeColumnElement = th; + + if (table.columnResizeMode === 'expand') { + const tableWidth = table.tableViewChild.nativeElement.offsetWidth + delta; + table._initialColWidths = table._totalTableWidth(); + table.setResizeTableWidth(tableWidth / this._rootFontSizePx + 'rem'); + table.resizeTableCells(newColumnWidth, null); + } else { + const nextColumn = th.nextElementSibling as HTMLElement | null; + if (!nextColumn) return; + const nextColumnWidth = nextColumn.offsetWidth - delta; + if (nextColumnWidth < 15) return; + table.resizeTableCells(newColumnWidth, nextColumnWidth); + } + + table.onColResize.emit({ element: th, delta }); + } +} diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-sortable.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-sortable.directive.ts deleted file mode 100644 index 6282802cb..000000000 --- a/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-sortable.directive.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - ComponentRef, - Directive, - ElementRef, - Input, - OnDestroy, - OnInit, - ViewContainerRef -} from '@angular/core'; -import { SortableColumn, Table } from 'primeng/table'; -import { CpsSortIconComponent } from '../components/internal/cps-sort-icon/cps-sort-icon.component'; - -/** - * CpsTableColumnSortableDirective is a sorting directive used to sort single or multiple columns in table. - * @group Directives - */ -@Directive({ - standalone: true, - selector: '[cpsTColSortable]' -}) -export class CpsTableColumnSortableDirective - extends SortableColumn - implements OnInit, OnDestroy -{ - /** - * Name of the column to be sorted. - * @group Props - */ - @Input('cpsTColSortable') override field = ''; - - sortIconRef: ComponentRef; - - constructor( - private elementRef: ElementRef, - private viewContainerRef: ViewContainerRef, - public override dataTable: Table - ) { - super(dataTable); - this.sortIconRef = - this.viewContainerRef.createComponent(CpsSortIconComponent); - } - - override ngOnInit(): void { - super.ngOnInit(); - this.sortIconRef.setInput('field', this.field); - this.elementRef.nativeElement.appendChild( - this.sortIconRef.location.nativeElement - ); - } - - override ngOnDestroy(): void { - super.ngOnDestroy(); - this.sortIconRef.destroy(); - this.viewContainerRef.clear(); - } -} diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-sortable/cps-table-column-sortable.directive.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-sortable/cps-table-column-sortable.directive.spec.ts new file mode 100644 index 000000000..be2e8cf92 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-sortable/cps-table-column-sortable.directive.spec.ts @@ -0,0 +1,258 @@ +import { Component, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Subject } from 'rxjs'; +import { DomHandler } from 'primeng/dom'; +import { Table } from 'primeng/table'; +import { CpsTableColumnSortableDirective } from './cps-table-column-sortable.directive'; + +@Component({ + standalone: true, + template: `Name`, + imports: [CpsTableColumnSortableDirective] +}) +class TestHostComponent { + @ViewChild(CpsTableColumnSortableDirective) + directive!: CpsTableColumnSortableDirective; + + field = 'name'; +} + +describe('CpsTableColumnSortableDirective', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let directive: CpsTableColumnSortableDirective; + let mockTable: ReturnType; + let sortSourceSubject: Subject; + + function buildMockTable() { + sortSourceSubject = new Subject(); + return { + ngOnDestroy: jest.fn(), + sortMode: 'single', + sortOrder: 1 as number, + isSorted: jest + .fn() + .mockReturnValue(true), + getSortMeta: jest + .fn<{ field: string; order: number } | null, [string]>() + .mockReturnValue(null), + sort: jest.fn(), + tableService: { sortSource$: sortSourceSubject.asObservable() }, + _multiSortMeta: undefined as unknown, + showInitialSortBadge: false, + groupRowsBy: undefined as unknown + }; + } + + beforeEach(async () => { + mockTable = buildMockTable(); + + await TestBed.configureTestingModule({ + imports: [TestHostComponent, NoopAnimationsModule], + providers: [{ provide: Table, useValue: mockTable }] + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + directive = host.directive; + }); + + afterEach(() => { + jest.restoreAllMocks(); + document.body + .querySelectorAll('cps-sort-icon') + .forEach((el) => el.remove()); + }); + + it('should create', () => { + expect(directive).toBeTruthy(); + }); + + it('should create a CpsSortIconComponent in sortIconRef', () => { + expect(directive.sortIconRef).toBeTruthy(); + expect(directive.sortIconRef.instance).toBeTruthy(); + }); + + describe('field input', () => { + it('should set field via cpsTColSortable alias', () => { + expect(directive.field).toBe('name'); + }); + + it('should update field when input changes', () => { + host.field = 'age'; + fixture.detectChanges(); + expect(directive.field).toBe('age'); + }); + }); + + describe('ngOnInit', () => { + it('should append the sort icon element inside the host th', () => { + const th = fixture.debugElement.query(By.css('th')) + .nativeElement as HTMLElement; + expect(th.contains(directive.sortIconRef.location.nativeElement)).toBe( + true + ); + }); + + it('should set the field on the sort icon component', () => { + expect(directive.sortIconRef.instance.field).toBe('name'); + }); + + it('should set aria-sort on the host element after init', () => { + const th = fixture.debugElement.query(By.css('th')) + .nativeElement as HTMLElement; + expect(th.getAttribute('aria-sort')).not.toBeNull(); + }); + + it('should subscribe to sortSource$', () => { + expect((directive as any)._sortSub).toBeTruthy(); + }); + }); + + describe('host class', () => { + it('should add p-sortable-column class to the host element', () => { + const th = fixture.debugElement.query(By.css('th')) + .nativeElement as HTMLElement; + expect(th.classList.contains('p-sortable-column')).toBe(true); + }); + }); + + describe('_updateAriaSort (via sortSource$ or init)', () => { + let th: HTMLElement; + + beforeEach(() => { + th = fixture.debugElement.query(By.css('th')) + .nativeElement as HTMLElement; + }); + + it('should set aria-sort to "ascending" in single mode when sortOrder is 1', () => { + mockTable.sortMode = 'single'; + mockTable.sortOrder = 1; + mockTable.isSorted.mockReturnValue(true); + sortSourceSubject.next(null); + expect(th.getAttribute('aria-sort')).toBe('ascending'); + }); + + it('should set aria-sort to "descending" in single mode when sortOrder is -1', () => { + mockTable.sortMode = 'single'; + mockTable.sortOrder = -1; + mockTable.isSorted.mockReturnValue(true); + sortSourceSubject.next(null); + expect(th.getAttribute('aria-sort')).toBe('descending'); + }); + + it('should set aria-sort to "none" in single mode when field is not sorted', () => { + mockTable.sortMode = 'single'; + mockTable.isSorted.mockReturnValue(false); + sortSourceSubject.next(null); + expect(th.getAttribute('aria-sort')).toBe('none'); + }); + + it('should set aria-sort to "ascending" in multiple mode when meta order is 1', () => { + mockTable.sortMode = 'multiple'; + mockTable.getSortMeta.mockReturnValue({ field: 'name', order: 1 }); + sortSourceSubject.next(null); + expect(th.getAttribute('aria-sort')).toBe('ascending'); + }); + + it('should set aria-sort to "descending" in multiple mode when meta order is -1', () => { + mockTable.sortMode = 'multiple'; + mockTable.getSortMeta.mockReturnValue({ field: 'name', order: -1 }); + sortSourceSubject.next(null); + expect(th.getAttribute('aria-sort')).toBe('descending'); + }); + + it('should set aria-sort to "none" in multiple mode when getSortMeta returns null', () => { + mockTable.sortMode = 'multiple'; + mockTable.getSortMeta.mockReturnValue(null); + sortSourceSubject.next(null); + expect(th.getAttribute('aria-sort')).toBe('none'); + }); + + it('should re-evaluate aria-sort each time sortSource$ emits', () => { + mockTable.sortMode = 'single'; + mockTable.sortOrder = 1; + mockTable.isSorted.mockReturnValue(true); + sortSourceSubject.next(null); + expect(th.getAttribute('aria-sort')).toBe('ascending'); + + mockTable.isSorted.mockReturnValue(false); + sortSourceSubject.next(null); + expect(th.getAttribute('aria-sort')).toBe('none'); + }); + }); + + describe('onClick', () => { + beforeEach(() => { + jest.spyOn(DomHandler, 'clearSelection').mockImplementation(() => {}); + }); + + it('should call dataTable.sort with the current field', () => { + directive.onClick(new MouseEvent('click')); + expect(mockTable.sort).toHaveBeenCalledWith({ field: 'name' }); + }); + + it('should call DomHandler.clearSelection', () => { + directive.onClick(new MouseEvent('click')); + expect(DomHandler.clearSelection).toHaveBeenCalled(); + }); + + it('should not sort when event target is inside .cps-table-col-filter', () => { + const filter = document.createElement('div'); + filter.className = 'cps-table-col-filter'; + const inner = document.createElement('button'); + filter.appendChild(inner); + document.body.appendChild(filter); + + const event = new MouseEvent('click'); + Object.defineProperty(event, 'target', { value: inner }); + + directive.onClick(event); + + expect(mockTable.sort).not.toHaveBeenCalled(); + expect(DomHandler.clearSelection).not.toHaveBeenCalled(); + + filter.remove(); + }); + + it('should sort when event target is not inside .cps-table-col-filter', () => { + const outside = document.createElement('span'); + const event = new MouseEvent('click'); + Object.defineProperty(event, 'target', { value: outside }); + + directive.onClick(event); + + expect(mockTable.sort).toHaveBeenCalledWith({ field: 'name' }); + }); + }); + + describe('ngOnDestroy', () => { + it('should unsubscribe from sortSource$', () => { + const sub = (directive as any)._sortSub; + jest.spyOn(sub, 'unsubscribe'); + directive.ngOnDestroy(); + expect(sub.unsubscribe).toHaveBeenCalled(); + }); + + it('should call sortIconRef.destroy', () => { + jest.spyOn(directive.sortIconRef, 'destroy'); + directive.ngOnDestroy(); + expect(directive.sortIconRef.destroy).toHaveBeenCalled(); + }); + + it('should stop reacting to sortSource$ after destroy', () => { + const th = fixture.debugElement.query(By.css('th')) + .nativeElement as HTMLElement; + directive.ngOnDestroy(); + const valueBefore = th.getAttribute('aria-sort'); + + mockTable.isSorted.mockReturnValue(false); + sortSourceSubject.next(null); + + expect(th.getAttribute('aria-sort')).toBe(valueBefore); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-sortable/cps-table-column-sortable.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-sortable/cps-table-column-sortable.directive.ts new file mode 100644 index 000000000..09e939179 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-column-sortable/cps-table-column-sortable.directive.ts @@ -0,0 +1,80 @@ +import { + ComponentRef, + Directive, + ElementRef, + Input, + OnDestroy, + OnInit, + ViewContainerRef +} from '@angular/core'; +import { DomHandler } from 'primeng/dom'; +import { Table } from 'primeng/table'; +import { Subscription } from 'rxjs'; +import { CpsSortIconComponent } from '../../components/internal/cps-sort-icon/cps-sort-icon.component'; + +/** + * CpsTableColumnSortableDirective is a sorting directive used to sort single or multiple columns in table. + * @group Directives + */ +@Directive({ + selector: '[cpsTColSortable]', + host: { + class: 'p-sortable-column', + '(click)': 'onClick($event)' + } +}) +export class CpsTableColumnSortableDirective implements OnInit, OnDestroy { + /** + * Name of the column to be sorted. + * @group Props + */ + @Input('cpsTColSortable') field = ''; + + sortIconRef: ComponentRef; + private _sortSub: Subscription | undefined; + + constructor( + private elementRef: ElementRef, + private viewContainerRef: ViewContainerRef, + private dataTable: Table + ) { + this.sortIconRef = + this.viewContainerRef.createComponent(CpsSortIconComponent); + } + + ngOnInit(): void { + this.sortIconRef.setInput('field', this.field); + this.elementRef.nativeElement.appendChild( + this.sortIconRef.location.nativeElement + ); + this._updateAriaSort(); + this._sortSub = this.dataTable.tableService.sortSource$.subscribe(() => { + this._updateAriaSort(); + }); + } + + onClick(event: MouseEvent): void { + if ((event.target as Element)?.closest('.cps-table-col-filter')) return; + this.dataTable.sort({ field: this.field }); + DomHandler.clearSelection(); + } + + private _updateAriaSort(): void { + let value: 'ascending' | 'descending' | 'none' = 'none'; + if (this.dataTable.sortMode === 'single') { + if (this.dataTable.isSorted(this.field)) { + value = this.dataTable.sortOrder === 1 ? 'ascending' : 'descending'; + } + } else { + const meta = this.dataTable.getSortMeta(this.field); + if (meta) value = meta.order === 1 ? 'ascending' : 'descending'; + } + this.elementRef.nativeElement.setAttribute('aria-sort', value); + } + + ngOnDestroy(): void { + this._sortSub?.unsubscribe(); + this.sortIconRef.destroy(); + this.viewContainerRef.clear(); + } +} diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-header-selectable/cps-table-header-selectable.directive.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-header-selectable/cps-table-header-selectable.directive.spec.ts new file mode 100644 index 000000000..3d126a6b0 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-header-selectable/cps-table-header-selectable.directive.spec.ts @@ -0,0 +1,114 @@ +import { + Component, + ComponentRef, + ViewChild, + ViewContainerRef, + inject +} from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TableHeaderCheckbox } from 'primeng/table'; +import { CpsTableHeaderSelectableDirective } from './cps-table-header-selectable.directive'; + +@Component({ + standalone: true, + template: ``, + imports: [CpsTableHeaderSelectableDirective] +}) +class TestHostComponent { + @ViewChild(CpsTableHeaderSelectableDirective) + directive!: CpsTableHeaderSelectableDirective; +} + +@Component({ standalone: true, template: '' }) +class VcrProbeComponent { + readonly vcr = inject(ViewContainerRef); +} + +describe('CpsTableHeaderSelectableDirective', () => { + let fixture: ComponentFixture; + let directive: CpsTableHeaderSelectableDirective; + let mockCheckboxRef: { + setInput: jest.Mock; + destroy: jest.Mock; + location: { nativeElement: HTMLElement }; + }; + let createComponentSpy: jest.SpyInstance; + + beforeEach(async () => { + const checkboxEl = document.createElement('div'); + checkboxEl.className = 'p-tableheadercheckbox'; + + mockCheckboxRef = { + setInput: jest.fn(), + destroy: jest.fn(), + location: { nativeElement: checkboxEl } + }; + + await TestBed.configureTestingModule({ + imports: [TestHostComponent, VcrProbeComponent, NoopAnimationsModule] + }).compileComponents(); + + const probeFixture = TestBed.createComponent(VcrProbeComponent); + const vcrProto = Object.getPrototypeOf(probeFixture.componentInstance.vcr); + createComponentSpy = jest + .spyOn(vcrProto, 'createComponent') + .mockReturnValue( + mockCheckboxRef as unknown as ComponentRef + ); + + fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + directive = fixture.componentInstance.directive; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should create', () => { + expect(directive).toBeTruthy(); + }); + + describe('constructor', () => { + it('should call createComponent with TableHeaderCheckbox', () => { + expect(createComponentSpy).toHaveBeenCalledWith(TableHeaderCheckbox); + }); + + it('should store the ComponentRef in checkboxCompRef', () => { + expect(directive.checkboxCompRef).toBe(mockCheckboxRef); + }); + }); + + describe('ngOnInit', () => { + it('should set ariaLabel to "Select all rows" on the checkbox component', () => { + expect(mockCheckboxRef.setInput).toHaveBeenCalledWith( + 'ariaLabel', + 'Select all rows' + ); + }); + + it('should append the checkbox element inside the host th', () => { + const th = fixture.debugElement.query(By.css('th')) + .nativeElement as HTMLElement; + expect(th.contains(mockCheckboxRef.location.nativeElement)).toBe(true); + }); + }); + + describe('ngOnDestroy', () => { + it('should call checkboxCompRef.destroy', () => { + directive.ngOnDestroy(); + expect(mockCheckboxRef.destroy).toHaveBeenCalled(); + }); + + it('should call viewContainerRef.clear', () => { + const vcr = ( + directive as unknown as { viewContainerRef: ViewContainerRef } + ).viewContainerRef; + const clearSpy = jest.spyOn(vcr, 'clear'); + directive.ngOnDestroy(); + expect(clearSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-header-selectable.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-header-selectable/cps-table-header-selectable.directive.ts similarity index 93% rename from projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-header-selectable.directive.ts rename to projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-header-selectable/cps-table-header-selectable.directive.ts index 7f7f294e3..b3c50ffa8 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-header-selectable.directive.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-header-selectable/cps-table-header-selectable.directive.ts @@ -13,7 +13,6 @@ import { TableHeaderCheckbox } from 'primeng/table'; * @group Directives */ @Directive({ - standalone: true, selector: '[cpsTHdrSelectable]' }) export class CpsTableHeaderSelectableDirective implements OnInit, OnDestroy { @@ -28,6 +27,7 @@ export class CpsTableHeaderSelectableDirective implements OnInit, OnDestroy { } ngOnInit(): void { + this.checkboxCompRef.setInput('ariaLabel', 'Select all rows'); this.elementRef.nativeElement.appendChild( this.checkboxCompRef.location.nativeElement ); diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-row-selectable/cps-table-row-selectable.directive.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-row-selectable/cps-table-row-selectable.directive.spec.ts new file mode 100644 index 000000000..8e4a32777 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-row-selectable/cps-table-row-selectable.directive.spec.ts @@ -0,0 +1,127 @@ +import { + Component, + ComponentRef, + ViewChild, + ViewContainerRef, + inject +} from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TableCheckbox } from 'primeng/table'; +import { CpsTableRowSelectableDirective } from './cps-table-row-selectable.directive'; + +@Component({ + standalone: true, + template: ``, + imports: [CpsTableRowSelectableDirective] +}) +class TestHostComponent { + @ViewChild(CpsTableRowSelectableDirective) + directive!: CpsTableRowSelectableDirective; + + value: unknown = 'row-1'; +} + +@Component({ standalone: true, template: '' }) +class VcrProbeComponent { + readonly vcr = inject(ViewContainerRef); +} + +describe('CpsTableRowSelectableDirective', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let directive: CpsTableRowSelectableDirective; + let mockCheckboxRef: { + setInput: jest.Mock; + destroy: jest.Mock; + location: { nativeElement: HTMLElement }; + }; + let createComponentSpy: jest.SpyInstance; + + beforeEach(async () => { + const checkboxEl = document.createElement('div'); + checkboxEl.className = 'p-tablecheckbox'; + + mockCheckboxRef = { + setInput: jest.fn(), + destroy: jest.fn(), + location: { nativeElement: checkboxEl } + }; + + await TestBed.configureTestingModule({ + imports: [TestHostComponent, VcrProbeComponent, NoopAnimationsModule] + }).compileComponents(); + + const probeFixture = TestBed.createComponent(VcrProbeComponent); + const vcrProto = Object.getPrototypeOf(probeFixture.componentInstance.vcr); + createComponentSpy = jest + .spyOn(vcrProto, 'createComponent') + .mockReturnValue( + mockCheckboxRef as unknown as ComponentRef + ); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + directive = host.directive; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should create', () => { + expect(directive).toBeTruthy(); + }); + + describe('constructor', () => { + it('should call createComponent with TableCheckbox', () => { + expect(createComponentSpy).toHaveBeenCalledWith(TableCheckbox); + }); + + it('should store the ComponentRef in checkboxCompRef', () => { + expect(directive.checkboxCompRef).toBe(mockCheckboxRef); + }); + }); + + describe('value input', () => { + it('should accept value via cpsTRowSelectable alias', () => { + expect(directive.value).toBe('row-1'); + }); + + it('should update value when the input changes', () => { + host.value = { id: 42 }; + fixture.detectChanges(); + expect(directive.value).toEqual({ id: 42 }); + }); + }); + + describe('ngOnInit', () => { + it('should call setInput with the bound value', () => { + expect(mockCheckboxRef.setInput).toHaveBeenCalledWith('value', 'row-1'); + }); + + it('should append the checkbox element inside the host td', () => { + const td = fixture.debugElement.query(By.css('td')) + .nativeElement as HTMLElement; + expect(td.contains(mockCheckboxRef.location.nativeElement)).toBe(true); + }); + }); + + describe('ngOnDestroy', () => { + it('should call checkboxCompRef.destroy', () => { + directive.ngOnDestroy(); + expect(mockCheckboxRef.destroy).toHaveBeenCalled(); + }); + + it('should call viewContainerRef.clear', () => { + const vcr = ( + directive as unknown as { viewContainerRef: ViewContainerRef } + ).viewContainerRef; + const clearSpy = jest.spyOn(vcr, 'clear'); + directive.ngOnDestroy(); + expect(clearSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-row-selectable.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-row-selectable/cps-table-row-selectable.directive.ts similarity index 98% rename from projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-row-selectable.directive.ts rename to projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-row-selectable/cps-table-row-selectable.directive.ts index 8fad0deaa..f41581391 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-row-selectable.directive.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-table/directives/cps-table-row-selectable/cps-table-row-selectable.directive.ts @@ -14,7 +14,6 @@ import { TableCheckbox } from 'primeng/table'; * @group Directives */ @Directive({ - standalone: true, selector: '[cpsTRowSelectable]' }) export class CpsTableRowSelectableDirective implements OnInit, OnDestroy { diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/directives/internal/table-unsort.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-table/directives/internal/table-unsort.directive.ts index f7dca389b..a9778658d 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/directives/internal/table-unsort.directive.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-table/directives/internal/table-unsort.directive.ts @@ -4,7 +4,6 @@ import { Table } from 'primeng/table'; import { ObjectUtils } from 'primeng/utils'; @Directive({ - standalone: true, selector: '[tWithUnsort]', exportAs: 'tWithUnsort' }) diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/pipes/cps-table-detect-filter-type/cps-table-detect-filter-type.pipe.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-table/pipes/cps-table-detect-filter-type/cps-table-detect-filter-type.pipe.spec.ts new file mode 100644 index 000000000..d1b9e30cf --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/pipes/cps-table-detect-filter-type/cps-table-detect-filter-type.pipe.spec.ts @@ -0,0 +1,126 @@ +import { CpsTableDetectFilterTypePipe } from './cps-table-detect-filter-type.pipe'; + +describe('CpsTableDetectFilterTypePipe', () => { + let pipe: CpsTableDetectFilterTypePipe; + + beforeEach(() => { + pipe = new CpsTableDetectFilterTypePipe(); + }); + + it('should create', () => { + expect(pipe).toBeTruthy(); + }); + + describe('boolean', () => { + it('should return "boolean" when all values are booleans', () => { + const data = [{ active: true }, { active: false }, { active: true }]; + expect(pipe.transform(data, 'active')).toBe('boolean'); + }); + + it('should return "boolean" for an array of all-true values', () => { + const data = [{ flag: true }, { flag: true }]; + expect(pipe.transform(data, 'flag')).toBe('boolean'); + }); + }); + + describe('number', () => { + it('should return "number" when all values are numbers', () => { + const data = [{ age: 1 }, { age: 2 }, { age: 3 }]; + expect(pipe.transform(data, 'age')).toBe('number'); + }); + + it('should prefer "number" over "category" when all values are numbers (even fewer than 6)', () => { + const data = [{ score: 10 }, { score: 20 }, { score: 30 }]; + expect(pipe.transform(data, 'score')).toBe('number'); + }); + }); + + describe('date', () => { + it('should return "date" when all values are Date instances', () => { + const data = [ + { created: new Date('2024-01-01') }, + { created: new Date('2024-02-01') }, + { created: new Date('2024-03-01') } + ]; + expect(pipe.transform(data, 'created')).toBe('date'); + }); + + it('should prefer "date" over "category" when all values are Dates (even fewer than 6)', () => { + const data = [{ ts: new Date() }, { ts: new Date() }]; + expect(pipe.transform(data, 'ts')).toBe('date'); + }); + }); + + describe('category', () => { + it('should return "category" when there are fewer than 6 unique string values', () => { + const data = [ + { status: 'active' }, + { status: 'inactive' }, + { status: 'active' }, + { status: 'pending' } + ]; + expect(pipe.transform(data, 'status')).toBe('category'); + }); + + it('should return "category" at exactly 5 unique values', () => { + const data = ['a', 'b', 'c', 'd', 'e'].map((v) => ({ col: v })); + expect(pipe.transform(data, 'col')).toBe('category'); + }); + }); + + describe('text', () => { + it('should return "text" when there are 6 or more unique values', () => { + const data = ['a', 'b', 'c', 'd', 'e', 'f'].map((v) => ({ col: v })); + expect(pipe.transform(data, 'col')).toBe('text'); + }); + + it('should return "text" for many diverse string values', () => { + const data = Array.from({ length: 10 }, (_, i) => ({ + name: `name-${i}` + })); + expect(pipe.transform(data, 'name')).toBe('text'); + }); + + it('should return "text" when values are mixed types with 6+ unique values', () => { + const data = [ + { val: 1 }, + { val: 'two' }, + { val: true }, + { val: 'four' }, + { val: 5 }, + { val: 'six' } + ]; + expect(pipe.transform(data, 'val')).toBe('text'); + }); + }); + + describe('edge cases', () => { + it('should return "boolean" for empty data (vacuous truth)', () => { + expect(pipe.transform([], 'any')).toBe('boolean'); + }); + + it('should return "date" for 6+ unique Date values', () => { + const data = Array.from({ length: 6 }, (_, i) => ({ + ts: new Date(2024, i, 1) + })); + expect(pipe.transform(data, 'ts')).toBe('date'); + }); + + it('should return "text" when column values are 6+ unique mixed-type items', () => { + const data = [ + { val: 'a' }, + { val: 'b' }, + { val: 'c' }, + { val: 'd' }, + { val: 'e' }, + { val: 'f' } + ]; + expect(pipe.transform(data, 'val')).toBe('text'); + }); + + it('should use column identity: single-item unique values map to "category"', () => { + const data = [{ x: 'only' }, { x: 'only' }, { x: 'only' }]; + expect(pipe.transform(data, 'x')).toBe('category'); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/pipes/cps-table-detect-filter-type.pipe.ts b/projects/cps-ui-kit/src/lib/components/cps-table/pipes/cps-table-detect-filter-type/cps-table-detect-filter-type.pipe.ts similarity index 84% rename from projects/cps-ui-kit/src/lib/components/cps-table/pipes/cps-table-detect-filter-type.pipe.ts rename to projects/cps-ui-kit/src/lib/components/cps-table/pipes/cps-table-detect-filter-type/cps-table-detect-filter-type.pipe.ts index 5088554fd..de1950aae 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/pipes/cps-table-detect-filter-type.pipe.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-table/pipes/cps-table-detect-filter-type/cps-table-detect-filter-type.pipe.ts @@ -1,9 +1,8 @@ import { Pipe, PipeTransform } from '@angular/core'; -import { CpsColumnFilterType } from '../cps-column-filter-types'; +import { CpsColumnFilterType } from '../../cps-column-filter-types'; @Pipe({ - name: 'cpsTableDetectFilterType', - standalone: true + name: 'cpsTableDetectFilterType' }) export class CpsTableDetectFilterTypePipe implements PipeTransform { transform( diff --git a/projects/cps-ui-kit/src/public-api.ts b/projects/cps-ui-kit/src/public-api.ts index 30fb34bb7..6186d6c2d 100644 --- a/projects/cps-ui-kit/src/public-api.ts +++ b/projects/cps-ui-kit/src/public-api.ts @@ -30,12 +30,12 @@ export * from './lib/components/cps-tab-group/cps-tab-group.component'; export * from './lib/components/cps-tab-group/cps-tab/cps-tab.component'; export * from './lib/components/cps-table/cps-column-filter-types'; export * from './lib/components/cps-table/cps-table.component'; -export * from './lib/components/cps-table/directives/cps-table-column-filter.directive'; -export * from './lib/components/cps-table/directives/cps-table-column-resizable.directive'; -export * from './lib/components/cps-table/directives/cps-table-column-sortable.directive'; -export * from './lib/components/cps-table/directives/cps-table-header-selectable.directive'; -export * from './lib/components/cps-table/directives/cps-table-row-selectable.directive'; -export * from './lib/components/cps-table/pipes/cps-table-detect-filter-type.pipe'; +export * from './lib/components/cps-table/directives/cps-table-column-filter/cps-table-column-filter.directive'; +export * from './lib/components/cps-table/directives/cps-table-column-resizable/cps-table-column-resizable.directive'; +export * from './lib/components/cps-table/directives/cps-table-column-sortable/cps-table-column-sortable.directive'; +export * from './lib/components/cps-table/directives/cps-table-header-selectable/cps-table-header-selectable.directive'; +export * from './lib/components/cps-table/directives/cps-table-row-selectable/cps-table-row-selectable.directive'; +export * from './lib/components/cps-table/pipes/cps-table-detect-filter-type/cps-table-detect-filter-type.pipe'; export * from './lib/components/cps-tag/cps-tag.component'; export * from './lib/components/cps-textarea/cps-textarea.component'; export * from './lib/components/cps-timepicker/cps-timepicker.component';