From 486c4f1008f5deafe9a2c40cd8290e09748f6aa0 Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Tue, 30 Jun 2026 18:46:17 +0200 Subject: [PATCH 1/5] base changes --- playwright/cps-accessibility.spec.ts | 24 +- .../tree-table-page.component.html | 10 +- .../tree-table-page.component.scss | 2 +- ...le-column-visibility-toggle.component.html | 2 +- .../cps-table/cps-table.component.html | 6 +- .../cps-table/cps-table.component.scss | 11 +- .../cps-table/cps-table.component.ts | 15 +- .../cps-table-column-resizable.directive.ts | 42 +- .../cps-tree-table.component.html | 112 ++--- .../cps-tree-table.component.scss | 426 +++++++++--------- .../cps-tree-table.component.ts | 169 ++++--- ...s-tree-table-column-resizable.directive.ts | 270 ++++++++++- ...ps-tree-table-column-sortable.directive.ts | 51 ++- ...-tree-table-header-selectable.directive.ts | 3 + ...cps-tree-table-row-selectable.directive.ts | 3 + 15 files changed, 780 insertions(+), 366 deletions(-) diff --git a/playwright/cps-accessibility.spec.ts b/playwright/cps-accessibility.spec.ts index 2d2c0c1e8..dc39b72cc 100644 --- a/playwright/cps-accessibility.spec.ts +++ b/playwright/cps-accessibility.spec.ts @@ -282,8 +282,30 @@ const components: ComponentEntry[] = [ await page.waitForSelector('cps-tree-select'); await page.locator('cps-tree-select').first().click(); } + }, + { + route: '/tree-table', + name: 'Tree table', + selector: 'cps-tree-table', + states: [ + 'Tree table 1', + 'Tree table 2', + 'Tree table 3', + 'Tree table 4', + 'Tree table 5', + 'Tree table 6', + 'Tree table 7', + 'Tree table 8', + 'Tree table 9', + 'Tree table 10' + ].map((tab) => ({ + label: tab, + setup: async (page: Page) => { + await page.getByRole('tab', { name: tab, exact: true }).click(); + await page.waitForSelector('cps-tree-table'); + } + })) } - // { route: '/tree-table', name: 'Tree table', selector: 'cps-tree-table' } ]; type Violations = Awaited>['violations']; diff --git a/projects/composition/src/app/pages/tree-table-page/tree-table-page.component.html b/projects/composition/src/app/pages/tree-table-page/tree-table-page.component.html index f17084062..15a5c23d8 100644 --- a/projects/composition/src/app/pages/tree-table-page/tree-table-page.component.html +++ b/projects/composition/src/app/pages/tree-table-page/tree-table-page.component.html @@ -78,7 +78,7 @@ [showColumnsToggleBtn]="true" [filterableByColumns]="true" [resizableColumns]="true" - scrollHeight="500px" + scrollHeight="31.25rem" toolbarTitle="Sortable tree table with resizable columns, virtual scroller, global filter, internal columns toggle and with column filtering enabled"> @@ -190,7 +190,7 @@ (rowsToRemove)="onRowsToRemove($event)" [selectable]="true" toolbarSize="small" - scrollHeight="calc(100vh - 370px)" + scrollHeight="calc(100vh - 23.125rem)" toolbarTitle="Tree table with nested header and small toolbar"> @@ -326,7 +326,7 @@ + emptyBodyHeight="25rem"> Name Size @@ -340,7 +340,7 @@ @@ -348,7 +348,7 @@ diff --git a/projects/composition/src/app/pages/tree-table-page/tree-table-page.component.scss b/projects/composition/src/app/pages/tree-table-page/tree-table-page.component.scss index 1f6c84b83..646a7d3b6 100644 --- a/projects/composition/src/app/pages/tree-table-page/tree-table-page.component.scss +++ b/projects/composition/src/app/pages/tree-table-page/tree-table-page.component.scss @@ -2,6 +2,6 @@ overflow-x: hidden; ::ng-deep .treetable-page--sub-tabs { - padding-top: 10px; + padding-top: 0.625rem; } } 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 index 31031ed68..44217fddd 100644 --- 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 @@ -9,7 +9,7 @@ diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.scss b/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.scss index 6e62a24aa..4d44bf4b2 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.scss @@ -1,7 +1,6 @@ @use '../../../../styles/mixins' as *; $color-calm: var(--cps-color-calm); -$color-prepared: var(--cps-color-prepared); $color-surprise: var(--cps-color-surprise); $checkbox-border-color: var(--cps-color-text-mild); $stripe-color: var(--cps-color-bg-light); @@ -131,7 +130,7 @@ $tbar-normal-height: 4.5rem; &:hover .cps-icon, &:focus-visible .cps-icon { - color: $color-prepared !important; + color: $color-calm !important; } &:disabled { @@ -144,11 +143,11 @@ $tbar-normal-height: 4.5rem; } .cps-tbar-small.p-datatable .p-datatable-header { - height: $tbar-small-height; + min-height: $tbar-small-height; } .cps-tbar-normal.p-datatable .p-datatable-header { - height: $tbar-normal-height; + min-height: $tbar-normal-height; } .p-datatable-flex-scrollable > .p-datatable-wrapper { @@ -176,10 +175,10 @@ $tbar-normal-height: 4.5rem; &:has(.p-datatable-table-container:focus-visible)::before { content: ''; position: absolute; + top: calc(var(--cps-header-height, 0px) + 0.0625rem); bottom: 0.0625rem; left: 0.0625rem; right: 0.0625rem; - height: calc(var(--cps-scroll-height) - 0.125rem); border: 0.0625rem solid var(--cps-color-calm-highlighten); pointer-events: none; z-index: 9999; @@ -189,10 +188,10 @@ $tbar-normal-height: 4.5rem; &:has(.p-datatable-table-container:focus-visible)::after { content: ''; position: absolute; + top: var(--cps-header-height, 0px); bottom: 0; left: 0; right: 0; - height: var(--cps-scroll-height); border: 0.0625rem solid var(--cps-color-calm); pointer-events: none; z-index: 9998; 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 e240d82eb..7187fcddd 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 @@ -76,7 +76,6 @@ export type CpsTableSortMode = 'single' | 'multiple'; selector: 'cps-table', changeDetection: ChangeDetectionStrategy.OnPush, host: { - '[style.--cps-scroll-height]': 'scrollHeight || ""', '(keydown)': 'onPaginatorKeydown($event)' }, imports: [ @@ -773,6 +772,8 @@ export class CpsTableComponent implements OnInit, AfterViewChecked, OnChanges { this.tablePassthrough = this._buildTablePassthrough(); } + private _headerHeightSet = false; + private _buildTablePassthrough(): TablePassThrough { const pt: TablePassThrough = {}; if (!this.virtualScroll && this.scrollHeight) { @@ -811,6 +812,18 @@ export class CpsTableComponent implements OnInit, AfterViewChecked, OnChanges { } ngAfterViewChecked() { + if (this.scrollHeight && !this._headerHeightSet) { + const ptEl = this.primengTable?.el?.nativeElement as HTMLElement | null; + const headerEl = ptEl?.querySelector( + '.p-datatable-header' + ) as HTMLElement | null; + const h = headerEl?.offsetHeight ?? 0; + if (h > 0) { + ptEl?.style.setProperty('--cps-header-height', h + 'px'); + this._headerHeightSet = true; + } + } + if (!this.virtualScroll || this.virtualScrollItemSize) return; this.virtualScrollItemSize = this.primengTable?.el?.nativeElement 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 index 67bd60dde..29d301995 100644 --- 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 @@ -79,10 +79,6 @@ export class CpsTableColumnResizableDirective extends ResizableColumn { 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( @@ -105,9 +101,39 @@ export class CpsTableColumnResizableDirective extends ResizableColumn { if (i === colIndex) w = newColumnWidth; else if (nextColumnWidth !== null && i === colIndex + 1) w = nextColumnWidth; - h.style.width = w / this._rootFontSizePx + 'rem'; + h.style.width = w + 'px'; }); tableEl.style.tableLayout = 'fixed'; + if ( + nextColumnWidth !== null && + headers[colIndex].offsetWidth !== newColumnWidth + ) { + headers[colIndex].style.width = widths[colIndex] + 'px'; + if (colIndex + 1 < headers.length) { + headers[colIndex + 1].style.width = widths[colIndex + 1] + 'px'; + } + } + }; + } + + if (!table._cpsResizeIndicatorPatched) { + table._cpsResizeIndicatorPatched = true; + const originalColumnResize = table.onColumnResize.bind(table); + table.onColumnResize = (event: Event) => { + originalColumnResize(event); + const indicator = table.resizeHelperViewChild + ?.nativeElement as HTMLElement | null; + if (!indicator) return; + const tableEl = table.tableViewChild + ?.nativeElement as HTMLElement | null; + if (!tableEl) return; + const positionedAncestor = indicator.offsetParent as HTMLElement | null; + if (!positionedAncestor) return; + const tableRect = tableEl.getBoundingClientRect(); + const ancestorRect = positionedAncestor.getBoundingClientRect(); + indicator.style.top = + Math.max(0, tableRect.top - ancestorRect.top) + 'px'; + indicator.style.height = tableEl.offsetHeight + 'px'; }; } } @@ -129,7 +155,9 @@ export class CpsTableColumnResizableDirective extends ResizableColumn { event.preventDefault(); const direction = event.key === 'ArrowRight' ? 1 : -1; - const delta = direction * (event.shiftKey ? 50 : 10); + const delta = Math.round( + direction * (event.shiftKey ? 3.125 : 0.625) * this._rootFontSizePx + ); const th = this.el.nativeElement as HTMLElement; const newColumnWidth = th.offsetWidth + delta; if (newColumnWidth < 15) return; @@ -140,7 +168,7 @@ export class CpsTableColumnResizableDirective extends ResizableColumn { if (table.columnResizeMode === 'expand') { const tableWidth = table.tableViewChild.nativeElement.offsetWidth + delta; table._initialColWidths = table._totalTableWidth(); - table.setResizeTableWidth(tableWidth / this._rootFontSizePx + 'rem'); + table.setResizeTableWidth(tableWidth + 'px'); table.resizeTableCells(newColumnWidth, null); } else { const nextColumn = th.nextElementSibling as HTMLElement | null; diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.html b/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.html index 93ebc8204..f72779622 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.html @@ -1,6 +1,7 @@ } @if (showColumnsToggleBtn && columns.length > 0) { -
- - -
-
- - - - Show all columns - -
- @for (col of columns; track col) { -
- - - {{ - col[colHeaderName] - }} - -
- } -
-
-
+ + } @if (showDataReloadBtn) {
- - + + +
} @@ -226,6 +196,7 @@ @if (selectable) { @@ -242,6 +213,7 @@ @if (filterableByColumns) { @for (col of columns; track col) { @@ -270,6 +243,7 @@ @if (filterableByColumns) { @for (col of columns; track col) { {{ col[colHeaderName] }} @@ -295,9 +270,12 @@ } @if (showRowMenu && (showRowRemoveButton || showRowEditButton)) { + [cpsTTColResizableDisabled]="!resizableColumns"> + Row actions + }
@@ -340,11 +318,14 @@ @for (col of columns; track col; let i = $index) { @if (i === 0) { } @if (i > 0) { - + } } } @else { @@ -390,6 +371,7 @@ {{ emptyMessage }} @@ -406,6 +388,7 @@
- - - - - - - - - - - - - diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.scss b/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.scss index 009941ac4..4271a23b2 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.scss @@ -1,5 +1,6 @@ +@use '../../../../styles/mixins' as *; + $color-calm: var(--cps-color-calm); -$color-prepared: var(--cps-color-prepared); $color-surprise: var(--cps-color-surprise); $toolbar-text-color: var(--cps-color-text-darkest); $treetable-borders-color: var(--cps-color-line-mid); @@ -8,13 +9,6 @@ $row-hover-background: var(--cps-color-highlight-hover); $row-selected-background: var(--cps-color-highlight-active); $header-text-color: var(--cps-color-text-mild); $body-text-color: var(--cps-color-text-dark); -$colitem-hover-background: var(--cps-color-highlight-hover); -$colitem-value-color: var(--cps-color-text-dark); -$selected-colitem-background: var(--cps-color-highlight-selected); -$colitem-highlight-background: var(--cps-color-highlight-active); -$colitem-highlight-selected-background: var( - --cps-color-highlight-selected-dark -); $paginator-text-color: var(--cps-color-text-dark); $paginator-border-color: var(--cps-color-text-dark); $paginator-elem-hover-background: var(--cps-color-highlight-hover); @@ -22,8 +16,8 @@ $paginator-elem-active-background: var(--cps-color-highlight-active); $sorticon-hover-color: var(--cps-color-text-mild); $checkbox-border-color: var(--cps-color-text-mild); -$tbar-small-height: 43px; -$tbar-normal-height: 72px; +$tbar-small-height: 2.6875rem; +$tbar-normal-height: 4.5rem; :host { ::ng-deep { @@ -36,7 +30,7 @@ $tbar-normal-height: 72px; } .p-component { font-family: 'Source Sans Pro', sans-serif; - font-size: 14px; + font-size: 0.875rem; font-weight: normal; } .p-treetable-scrollable-wrapper { @@ -83,8 +77,8 @@ $tbar-normal-height: 72px; .p-treetable .p-treetable-thead > tr > th { text-align: left; padding: 1rem 1rem; - border: 1px solid $treetable-borders-color; - border-width: 0 0 1px 1px; + border: 0.0625rem solid $treetable-borders-color; + border-width: 0 0 0.0625rem 0.0625rem; font-weight: 400; color: $header-text-color; background: white; @@ -118,8 +112,8 @@ $tbar-normal-height: 72px; .p-treetable .p-treetable-tbody > tr > td { text-align: left; - border: 1px solid $treetable-borders-color; - border-width: 0 0 1px 0; + border: 0.0625rem solid $treetable-borders-color; + border-width: 0 0 0.0625rem 0; padding: 1rem 1rem; } @@ -159,6 +153,22 @@ $tbar-normal-height: 72px; .p-treetable-scrollable-body .p-treetable-tbody > tr:hover + > td:not(.cps-treetable-row-toggler-cell):not( + .cps-treetable-selectable-cell + ):not(.cps-treetable-row-menu-cell):not( + .cps-treetable-empty-message-td + ), + .p-treetable-scrollable-body + .p-treetable-tbody + > tr:focus-visible + > td:not(.cps-treetable-row-toggler-cell):not( + .cps-treetable-selectable-cell + ):not(.cps-treetable-row-menu-cell):not( + .cps-treetable-empty-message-td + ), + .p-treetable-scrollable-body + .p-treetable-tbody + > tr:has(*:focus-visible) > td:not(.cps-treetable-row-toggler-cell):not( .cps-treetable-selectable-cell ):not(.cps-treetable-row-menu-cell):not( @@ -170,10 +180,10 @@ $tbar-normal-height: 72px; } .p-treetable .p-treetable-tbody > tr > td:first-child { - border-width: 0 0 1px 1px; + border-width: 0 0 0.0625rem 0.0625rem; } .p-treetable .p-treetable-tbody > tr > td:last-child { - border-width: 0 1px 1px 0; + border-width: 0 0.0625rem 0.0625rem 0; } .p-treetable-auto-layout table { @@ -181,7 +191,7 @@ $tbar-normal-height: 72px; } .p-treetable .p-treetable-tbody > tr > td:only-child { - border-width: 0 1px 1px 1px; + border-width: 0 0.0625rem 0.0625rem 0.0625rem; } .p-treetable .p-treetable-tbody > tr > td .p-treetable-toggler { @@ -225,9 +235,9 @@ $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; - border-radius: 6px; + border-radius: 0.375rem; } input[type='button'], @@ -247,8 +257,13 @@ $tbar-normal-height: 72px; } .p-treetable .p-treetable-tbody > tr:focus { - outline: 0 none; - outline-offset: -0.15rem; + outline: none; + } + + .p-treetable .p-treetable-tbody > tr:focus-visible, + .p-treetable .p-treetable-tbody > tr:has(*:focus-visible) { + outline: 0.0625rem solid var(--cps-color-calm); + outline-offset: -0.0625rem; } .p-treetable .p-treetable-tbody @@ -266,7 +281,7 @@ $tbar-normal-height: 72px; } .p-treetable .p-paginator { - border-width: 0 1px 1px 1px; + border-width: 0 0.0625rem 0.0625rem 0.0625rem; border-radius: 0; } @@ -278,7 +293,7 @@ $tbar-normal-height: 72px; flex-wrap: wrap; color: $paginator-text-color; padding: 1rem; - border: 1px solid $treetable-borders-color; + border: 0.0625rem solid $treetable-borders-color; } .p-paginator-content-start { @@ -288,21 +303,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; } } } @@ -317,7 +333,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; } @@ -334,7 +350,6 @@ $tbar-normal-height: 72px; line-height: 1; -webkit-user-select: none; user-select: none; - overflow: hidden; position: relative; } @@ -342,6 +357,33 @@ $tbar-normal-height: 72px; cursor: default; } + .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; + 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 .p-paginator-first:not(.p-disabled):not(.p-paginator-page-selected):hover, .p-paginator @@ -373,11 +415,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; } @@ -414,28 +456,15 @@ $tbar-normal-height: 72px; .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; - } - - span.p-paginator-icon, - span.p-paginator-first-icon, - span.p-paginator-prev-icon, - span.p-paginator-last-icon, - span.p-paginator-next-icon { - display: contents; - } - .p-disabled, .p-component:disabled { opacity: 0.4; @@ -448,7 +477,7 @@ $tbar-normal-height: 72px; } .p-treetable.p-treetable-gridlines .p-treetable-header { - border-width: 1px 1px 0 1px; + border-width: 0.0625rem 0.0625rem 0 0.0625rem; } .p-treetable .p-treetable-header { @@ -456,14 +485,14 @@ $tbar-normal-height: 72px; display: flex; justify-content: space-between; align-items: center; - padding: 0 10px !important; + padding: 0 0.625rem !important; border: unset; background: unset; - border-top: 1px solid $treetable-borders-color; - border-right: 1px solid $treetable-borders-color; - border-left: 4px solid $color-surprise !important; - border-radius: 4px 4px 0 0; - font-size: 16px; + border-top: 0.0625rem solid $treetable-borders-color; + border-right: 0.0625rem solid $treetable-borders-color; + border-left: 0.25rem solid $color-surprise !important; + border-radius: 0.25rem 0.25rem 0 0; + font-size: 1rem; line-height: 150%; background-color: white; color: $toolbar-text-color; @@ -472,11 +501,11 @@ $tbar-normal-height: 72px; display: flex; align-items: center; .cps-treetable-tbar-global-filter { - margin-left: 12px; + margin-left: 0.75rem; } .cps-treetable-tbar-icon { display: flex; - margin-right: 8px; + margin-right: 0.5rem; } .cps-treetable-tbar-title { cursor: default; @@ -486,54 +515,70 @@ $tbar-normal-height: 72px; display: flex; align-items: center; .cps-treetable-tbar-btn-on-select { - margin-right: 4px; + margin-right: 0.25rem; } .cps-treetable-tbar-action-btn { - margin-right: 4px; - margin-left: 8px; + margin-right: 0.25rem; + margin-left: 0.5rem; } - .cps-treetable-tbar-coltoggle-btn, + .cps-table-tbar-coltoggle-btn, .cps-treetable-tbar-reload-btn { display: contents; - cps-icon { - margin-left: 12px; - margin-right: 4px; + } + + .cps-table-tbar-coltoggle-btn .cps-table-tbar-icon-btn, + .cps-treetable-tbar-reload-btn .cps-treetable-tbar-icon-btn { + background: none; + border: none; + padding: 0; + color: inherit; + font: inherit; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + margin-left: 0.75rem; + margin-right: 0.25rem; + overflow: visible; + &:hover .cps-icon, + &:focus-visible .cps-icon { + color: $color-calm !important; } - &:not(.btn-disabled) { - cps-icon { - cursor: pointer; - } - cps-icon:hover { - .cps-icon { - color: $color-prepared !important; - } - } + &:disabled { + cursor: default; + pointer-events: none; + } + &:focus { + outline: none; + } + &:focus-visible { + @include focus-ring(0.1875rem, 0.25rem, 0.25rem); } } } } .cps-tbar-small.p-treetable .p-treetable-header { - height: $tbar-small-height; + min-height: $tbar-small-height; } .cps-tbar-normal.p-treetable .p-treetable-header { - height: $tbar-normal-height; + min-height: $tbar-normal-height; } .p-treetable .p-treetable-footer { background: white; color: #343a40; - border: 1px solid $treetable-borders-color; - border-width: 0 0 1px 0; + border: 0.0625rem solid $treetable-borders-color; + border-width: 0 0 0.0625rem 0; padding: 1rem 1rem; font-weight: 700; } .p-treetable .p-treetable-tfoot > tr > td { text-align: left; padding: 1rem 1rem; - border: 1px solid $treetable-borders-color; - border-width: 0 0 1px 0; + border: 0.0625rem solid $treetable-borders-color; + border-width: 0 0 0.0625rem 0; font-weight: 700; color: #343a40; background: white; @@ -541,6 +586,10 @@ $tbar-normal-height: 72px; .p-treetable .p-sortable-column:not(.p-highlight):hover + .cps-sortable-column-icon.sort-unsorted, + .p-treetable + .p-sortable-column + cps-sort-icon:focus-visible .cps-sortable-column-icon.sort-unsorted { .sort-unsorted-arrow-up { border-bottom-color: $sorticon-hover-color; @@ -560,11 +609,19 @@ $tbar-normal-height: 72px; height: 1rem; } + .p-paginator .p-paginator-first-icon, + .p-paginator .p-paginator-prev-icon, + .p-paginator .p-paginator-next-icon, + .p-paginator .p-paginator-last-icon { + width: 0.875rem; + height: 0.875rem; + } + .p-treetable .p-sortable-column .cps-sortable-column-badge { 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; @@ -592,6 +649,11 @@ $tbar-normal-height: 72px; outline: 0 none; } + .p-treetable .p-sortable-column:focus-visible { + overflow: visible; + @include focus-ring(0.1875rem, 0.25rem, 0.25rem); + } + .p-treetable .p-treetable-tbody > tr > td .p-treetable-toggler.p-icon { width: 2rem; height: 2rem; @@ -604,7 +666,13 @@ $tbar-normal-height: 72px; .p-treetable.p-treetable-hoverable-rows .p-treetable-tbody - > tr:not(.p-highlight):hover { + > tr:not(.p-highlight):hover, + .p-treetable.p-treetable-hoverable-rows + .p-treetable-tbody + > tr:not(.p-highlight):focus-visible, + .p-treetable.p-treetable-hoverable-rows + .p-treetable-tbody + > tr:not(.p-highlight):has(*:focus-visible) { background: $row-hover-background; } @@ -613,7 +681,18 @@ $tbar-normal-height: 72px; } .p-treetable .p-column-resizer-helper { background: $color-calm; - width: 1px; + width: 0.0625rem; + } + + .p-treetable .p-column-resizer { + &:focus { + outline: none; + } + + &.cps-col-resizer-focused { + background: $color-calm !important; + opacity: 1 !important; + } } .p-treetable .p-treetable-mask.p-overlay-mask { @@ -628,7 +707,7 @@ $tbar-normal-height: 72px; height: 100%; background-color: white; transition-duration: 0.2s; - border: 1px solid $treetable-borders-color; + border: 0.0625rem solid $treetable-borders-color; } .p-treetable.cps-tbar-small .p-treetable-mask.p-overlay-mask { top: $tbar-small-height; @@ -642,13 +721,13 @@ $tbar-normal-height: 72px; .cps-treetable-loading { .p-treetable { - min-height: 200px; + min-height: 12.5rem; } .p-treetable.cps-tbar-normal { - min-height: 272px; + min-height: 17rem; } .p-treetable.cps-tbar-small { - min-height: 243px; + min-height: 15.1875rem; } } @@ -659,36 +738,36 @@ $tbar-normal-height: 72px; } .p-treetable.p-treetable-gridlines .p-treetable-footer { - border-width: 0 1px 1px 1px; + border-width: 0 0.0625rem 0.0625rem 0.0625rem; } .p-treetable.p-treetable-gridlines .p-treetable-top { - border-width: 0 1px 0 1px; + border-width: 0 0.0625rem 0 0.0625rem; } .p-treetable.p-treetable-gridlines .p-treetable-bottom { - border-width: 0 1px 1px 1px; + border-width: 0 0.0625rem 0.0625rem 0.0625rem; } .p-treetable .p-treetable-thead > tr > th, .p-treetable.p-treetable-gridlines .p-treetable-thead > tr > th { - border-width: 1px 0 1px 1px; + border-width: 0.0625rem 0 0.0625rem 0.0625rem; } .p-treetable .p-treetable-thead > tr > th:last-child { - border-width: 1px; + border-width: 0.0625rem; } .p-treetable.p-treetable-gridlines .p-treetable-tbody > tr > td { - border-width: 0 0 1px 1px; + border-width: 0 0 0.0625rem 0.0625rem; } .p-treetable.p-treetable-gridlines .p-treetable-tfoot > tr > td { - border-width: 1px; + border-width: 0.0625rem; } .p-treetable.p-treetable-gridlines .p-treetable-tbody > tr > td:last-child { - border-width: 0 1px 1px 1px; + border-width: 0 0.0625rem 0.0625rem 0.0625rem; } .p-treetable .p-treetable-tbody > tr > td:only-child { - border-width: 0 1px 1px 1px; + border-width: 0 0.0625rem 0.0625rem 0.0625rem; } .p-treetable.p-treetable-sm .p-treetable-thead > tr > th { @@ -726,12 +805,12 @@ $tbar-normal-height: 72px; } .cps-treetable-row-menu-cell { - width: 55px; + width: 3.4375rem; border-left: none !important; } .cps-treetable-selectable-cell { - width: 55px; + width: 3.4375rem; text-align: center !important; } @@ -742,19 +821,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, @@ -764,11 +843,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, @@ -787,132 +866,71 @@ $tbar-normal-height: 72px; 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: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-input:focus-visible { + outline: none; + } + + .p-checkbox:has(.p-checkbox-input:focus-visible) { + @include focus-ring(0.1875rem, 0.25rem, 0.25rem); + } + .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 .p-checkbox-box minusicon .p-checkbox-icon { color: $color-calm; - width: 12px; + width: 0.75rem; } .p-treetable .p-treetable-tbody > tr > td p-treetablecheckbox .p-checkbox { margin-right: 0; } + + .cps-treetable-html-cell { + * { + &:focus { + outline: none; + } + + &:focus-visible { + position: relative; + @include focus-ring(); + } + } + } } } ::ng-deep .p-scrollbar-measure { - width: 100px; - height: 100px; + width: 6.25rem; + height: 6.25rem; overflow: scroll; position: absolute; top: -9999px; } -.cps-treetable-coltoggle-menu { - display: block; - max-height: 242px; - overflow-x: hidden; - background: white; - - .cps-treetable-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-treetable-coltoggle-menu-item-label { - color: $color-calm; - } - .cps-treetable-coltoggle-menu-item-check { - opacity: 1; - } - } - &.selected { - background: $selected-colitem-background; - } - &.highlighten { - background: $colitem-highlight-background; - } - &.selected.highlighten { - background: $colitem-highlight-selected-background; - } - } - - .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-tree-table/cps-tree-table.component.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.ts index b066751e8..8dbc9bc37 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.ts @@ -1,4 +1,6 @@ import { CommonModule, DOCUMENT } from '@angular/common'; +import type { PaginatorPassThrough } from 'primeng/types/paginator'; +import type { TreeTablePassThrough } from 'primeng/types/treetable'; import { AfterViewChecked, AfterViewInit, @@ -6,7 +8,9 @@ import { ChangeDetectorRef, Component, ContentChild, + ElementRef, EventEmitter, + inject, Inject, Input, NgZone, @@ -21,7 +25,7 @@ import { ViewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { cloneDeep, isEqual } from 'lodash-es'; +import { cloneDeep } from 'lodash-es'; import { DomHandler } from 'primeng/dom'; import { TreeTable, @@ -36,8 +40,9 @@ import { CpsButtonComponent } from '../cps-button/cps-button.component'; import { CpsIconComponent } from '../cps-icon/cps-icon.component'; import { CpsInputComponent } from '../cps-input/cps-input.component'; import { CpsLoaderComponent } from '../cps-loader/cps-loader.component'; -import { CpsMenuComponent, CpsMenuItem } from '../cps-menu/cps-menu.component'; +import { CpsMenuItem } from '../cps-menu/cps-menu.component'; import { CpsSelectComponent } from '../cps-select/cps-select.component'; +import { TableColumnVisibilityToggleComponent } from '../cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component'; import { TableRowMenuComponent } from '../cps-table/components/internal/table-row-menu/table-row-menu.component'; import { CpsTreeTableColumnFilterDirective } from './directives/cps-tree-table-column-filter.directive'; import { CpsTreeTableColumnResizableDirective } from './directives/cps-tree-table-column-resizable.directive'; @@ -47,6 +52,7 @@ import { CpsTreeTableRowSelectableDirective } from './directives/cps-tree-table- import { CpsTreetableRowTogglerDirective } from './directives/cps-tree-table-row-toggler.directive'; import { TreeTableUnsortDirective } from './directives/internal/tree-table-unsort.directive'; import { CpsTreeTableDetectFilterTypePipe } from './pipes/cps-tree-table-detect-filter-type.pipe'; +import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../services/cps-root-font-size/cps-root-font-size.service'; export function treeTableFactory(tableComponent: CpsTreeTableComponent) { return tableComponent.primengTreeTable; @@ -77,14 +83,17 @@ export type CpsTreeTableSortMode = 'single' | 'multiple'; @Component({ selector: 'cps-tree-table', changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '(keydown)': 'onPaginatorKeydown($event)' + }, imports: [ FormsModule, CommonModule, TreeTableModule, CpsInputComponent, CpsButtonComponent, - CpsMenuComponent, CpsIconComponent, + TableColumnVisibilityToggleComponent, CpsSelectComponent, CpsLoaderComponent, CpsTreeTableColumnSortableDirective, @@ -711,9 +720,6 @@ export class CpsTreeTableComponent @ViewChild('globalFilterComp') globalFilterComp!: CpsInputComponent; - @ViewChild('colToggleMenu') - colToggleMenu!: CpsMenuComponent; - selectedColumns: { [key: string]: any }[] = []; rowOptions: { label: string; value: number }[] = []; @@ -722,6 +728,7 @@ export class CpsTreeTableComponent virtualScrollItemSize = 0; defScrollHeight = ''; + treeTablePassthrough: TreeTablePassThrough = {}; private _defScrollHeightPx = 0; private _defScrollHeightPxInitial = 0; @@ -741,10 +748,17 @@ export class CpsTreeTableComponent private window: Window; + private readonly _cpsRootFontSizeService = inject(CPS_ROOT_FONT_SIZE_SERVICE); + + private get _rootFontSizePx(): number { + return this._cpsRootFontSizeService?.fontSize() || 16; + } + // eslint-disable-next-line no-useless-constructor constructor( private cdRef: ChangeDetectorRef, @Inject(DOCUMENT) private document: Document, + private _elementRef: ElementRef, private renderer: Renderer2, private ngZone: NgZone ) { @@ -773,7 +787,7 @@ export class CpsTreeTableComponent this.renderer.setStyle( this._headerBox, 'border-right', - wScroll > 0 ? '1px solid #d7d5d5' : 'unset' + wScroll > 0 ? '0.0625rem solid #d7d5d5' : 'unset' ); this._calcAutoLayoutHeaderWidths(); @@ -791,10 +805,6 @@ export class CpsTreeTableComponent this.defScrollHeight = this.scrollHeight; if (this.virtualScroll) { this.window.addEventListener('resize', this._onWindowResize.bind(this)); - - if (this.defScrollHeight && this.defScrollHeight !== 'flex') { - this._defScrollHeightPx = parseInt(this.scrollHeight, 10); - } } if (this.paginator) { @@ -824,6 +834,8 @@ export class CpsTreeTableComponent this.selectedColumns = this.initialColumns.length > 0 ? this.initialColumns : this.columns; + + this.treeTablePassthrough = this._buildTreeTablePassthrough(); } ngAfterViewInit(): void { @@ -839,6 +851,7 @@ export class CpsTreeTableComponent } if (this.virtualScroll) { + this._scrollableBody.tabIndex = -1; if (this.defScrollHeight === 'flex') { this._defScrollHeightPx = this._scrollableBody.clientHeight; this._defScrollHeightPxInitial = this._defScrollHeightPx; @@ -928,6 +941,10 @@ export class CpsTreeTableComponent this._calcAutoLayoutHeaderWidths(true); this._recalcVirtualHeight(); + + if (changes.first || changes.totalRecords || changes.paginator) { + this.treeTablePassthrough = this._buildTreeTablePassthrough(); + } } ngOnDestroy(): void { @@ -1040,7 +1057,7 @@ export class CpsTreeTableComponent (hasSelectableCell && idx === 0) || (hasRowMenuCell && idx === headerCells.length - 1) ) { - this.renderer.setStyle(th, 'width', '55px'); + this.renderer.setStyle(th, 'width', '3.4375rem'); } else this.renderer.setStyle( th, @@ -1056,7 +1073,7 @@ export class CpsTreeTableComponent (hasSelectableCell && idx === 0) || (hasRowMenuCell && idx === tds.length - 1) ) { - this.renderer.setStyle(td, 'width', '55px'); + this.renderer.setStyle(td, 'width', '3.4375rem'); } else { this.renderer.setStyle( td, @@ -1203,10 +1220,12 @@ export class CpsTreeTableComponent const itemsLen = this.primengTreeTable.serializedValue?.length || 0; if (itemsLen < 1) { this.scrollHeight = this.emptyBodyHeight - ? (`calc(${this.emptyBodyHeight} + 1px)` as string) - : this.virtualScrollItemSize + 1 + 'px'; + ? (`calc(${this.emptyBodyHeight} + ${0.0625 * this._rootFontSizePx}px)` as string) + : this.virtualScrollItemSize + 0.0625 * this._rootFontSizePx + 'px'; } else { - const curHeight = this.virtualScrollItemSize * itemsLen + 2; + const curHeight = + this.virtualScrollItemSize * itemsLen + + 0.125 * this._rootFontSizePx; if (this.defScrollHeight === 'flex') { if (curHeight >= this._defScrollHeightPxInitial) { this.scrollHeight = 'flex'; @@ -1214,9 +1233,11 @@ export class CpsTreeTableComponent this.cdRef.markForCheck(); return; } + this.scrollHeight = + Math.min(this._defScrollHeightPx, curHeight) + 'px'; + } else { + this.scrollHeight = `min(${this.defScrollHeight}, ${curHeight}px)`; } - this.scrollHeight = - Math.min(this._defScrollHeightPx, curHeight) + 'px'; } this.cdRef.markForCheck(); } @@ -1248,11 +1269,6 @@ export class CpsTreeTableComponent this.dataReloadBtnClicked.emit(); } - onColumnsToggle(event: any) { - if (this.columnsToggleBtnDisabled) return; - this.colToggleMenu?.toggle(event); - } - removeSelected() { this.rowsToRemove.emit(this.selectedRows); } @@ -1269,19 +1285,14 @@ export class CpsTreeTableComponent this.rowsToRemove.emit([node]); } - toggleAllColumns() { - this.selectedColumns = - this.selectedColumns.length < this.columns.length ? this.columns : []; - this.columnsSelected.emit(this.selectedColumns); + onSelectedColumnsChange(cols: { [key: string]: any }[]) { + this.selectedColumns = cols; + this.columnsSelected.emit(cols); setTimeout(() => { this._calcAutoLayoutHeaderWidths(true); }); } - isColumnSelected(col: any) { - return this.selectedColumns.some((item) => isEqual(item, col)); - } - onRowsPerPageChanged() { if (this.resetPageOnRowsChange) { this.primengTreeTable.first = 0; @@ -1309,9 +1320,56 @@ export class CpsTreeTableComponent } } + private _buildTreeTablePassthrough(): TreeTablePassThrough { + const pt: TreeTablePassThrough = {}; + 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; + } + + 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.treeTablePassthrough = this._buildTreeTablePassthrough(); const state = { page: this.getPage(), @@ -1321,11 +1379,39 @@ export class CpsTreeTableComponent }; this.pageChanged.emit(state); + + const activeEl = this.document.activeElement as HTMLElement | null; + const atFirst = this.first === 0; + const atLast = this.first + this.rows >= this.primengTreeTable.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()); + } + setTimeout(() => { this._calcAutoLayoutHeaderWidths(true); }); } + private _getPaginatorPageButtons(): HTMLButtonElement[] { + return Array.from( + this._elementRef.nativeElement.querySelectorAll('.p-paginator-page') + ) as HTMLButtonElement[]; + } + + private _focusPaginatorSelectedPage(): void { + const selected = this._elementRef.nativeElement.querySelector( + '.p-paginator-page[aria-current="page"]' + ) as HTMLButtonElement | null; + selected?.focus(); + } + onLazyLoaded(event: any) { this.lazyLoaded.emit(event); } @@ -1370,27 +1456,6 @@ export class CpsTreeTableComponent this._recalcVirtualHeight(); } - onSelectColumn(col: any) { - let res = [] as 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.selectedColumns = res; - this.columnsSelected.emit(this.selectedColumns); - setTimeout(() => { - this._calcAutoLayoutHeaderWidths(true); - }); - } - onSelectionChanged(selection: any) { if (selection && !Array.isArray(selection)) selection = [selection]; this.rowsSelected.emit(selection); diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-resizable.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-resizable.directive.ts index 3f3c7a4e3..f6584cc68 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-resizable.directive.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-resizable.directive.ts @@ -1,5 +1,6 @@ -import { Directive, Input } from '@angular/core'; +import { Directive, Input, inject } from '@angular/core'; import { TTResizableColumn } from 'primeng/treetable'; +import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../../services/cps-root-font-size/cps-root-font-size.service'; /** * CpsTreeTableColumnResizableDirective is a directive to enable column resizing in a treetable. @@ -17,4 +18,271 @@ export class CpsTreeTableColumnResizableDirective extends TTResizableColumn { @Input('cpsTTColResizableDisabled') override ttResizableColumnDisabled: | 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 tt = this.tt as typeof this.tt & { + _cpsResizeColGroupPatched?: boolean; + _cpsResizeIndicatorPatched?: boolean; + _cpsResizeBeginPatched?: boolean; + }; + if (!tt._cpsResizeColGroupPatched) { + tt._cpsResizeColGroupPatched = true; + tt.resizeColGroup = ( + table: HTMLElement, + resizeColumnIndex: number, + newColumnWidth: number, + nextColumnWidth: number | null + ) => { + if (!table || resizeColumnIndex == null || newColumnWidth == null) + return; + const colGroup = + table.children[0]?.nodeName === 'COLGROUP' ? table.children[0] : null; + if (!colGroup) return; + const cols = Array.from(colGroup.children) as HTMLElement[]; + if (cols.some((c) => !c.style.width)) { + const ths = Array.from( + table.querySelectorAll('thead > tr:first-child > th') + ) as HTMLElement[]; + cols.forEach((col, i) => { + if (!col.style.width && ths[i]) { + col.style.width = ths[i].offsetWidth + 'px'; + } + }); + } + const col = cols[resizeColumnIndex] ?? null; + const nextCol = col?.nextElementSibling as HTMLElement | null; + if (col) col.style.width = newColumnWidth + 'px'; + if (nextCol && nextColumnWidth != null) + nextCol.style.width = nextColumnWidth + 'px'; + table.style.tableLayout = 'fixed'; + }; + } + + if (!tt._cpsResizeIndicatorPatched) { + tt._cpsResizeIndicatorPatched = true; + const originalColumnResize = tt.onColumnResize.bind(tt); + tt.onColumnResize = (event: MouseEvent) => { + originalColumnResize(event); + const indicator = tt.resizeHelperViewChild + ?.nativeElement as HTMLElement | null; + if (!indicator) return; + const positionedAncestor = indicator.offsetParent as HTMLElement | null; + if (!positionedAncestor) return; + const hostEl = tt.el?.nativeElement as HTMLElement | null; + const tableEl = + (tt.tableViewChild?.nativeElement as HTMLElement | null) ?? + (hostEl?.querySelector( + '.p-treetable-scrollable-wrapper' + ) as HTMLElement | null); + if (!tableEl) return; + const tableRect = tableEl.getBoundingClientRect(); + const ancestorRect = positionedAncestor.getBoundingClientRect(); + indicator.style.top = + Math.max(0, tableRect.top - ancestorRect.top) + 'px'; + indicator.style.height = tableEl.offsetHeight + 'px'; + }; + } + + if (!tt._cpsResizeBeginPatched) { + tt._cpsResizeBeginPatched = true; + const originalResizeBegin = tt.onColumnResizeBegin.bind(tt); + tt.onColumnResizeBegin = (event: MouseEvent) => { + originalResizeBegin(event); + tt.onColumnResize(event); + }; + } + } + + 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 = Math.round( + direction * (event.shiftKey ? 3.125 : 0.625) * this._rootFontSizePx + ); + const th = this.el.nativeElement as HTMLElement; + const newColumnWidth = th.offsetWidth + delta; + if (newColumnWidth < 15) return; + + if (this.tt.columnResizeMode === 'expand') { + this._resizeExpand(th, newColumnWidth, delta); + } else { + const nextColumn = th.nextElementSibling as HTMLElement | null; + if (!nextColumn) return; + const nextColumnWidth = nextColumn.offsetWidth - delta; + if (nextColumnWidth < 15) return; + this._resizeFit(th, newColumnWidth, nextColumn, nextColumnWidth); + } + + this.tt.onColResize.emit({ element: th, delta }); + } + + private _resizeFit( + th: HTMLElement, + newColumnWidth: number, + nextColumn: HTMLElement, + nextColumnWidth: number + ): void { + if (this.tt.scrollable) { + const scrollableView = this.tt.findParentScrollableView(th); + if (!scrollableView) return; + const colIndex = Array.from(th.parentElement!.children).indexOf(th); + this.tt.resizeColGroup( + scrollableView.querySelector( + '[data-pc-section="scrollableheadertable"]' + ), + colIndex, + newColumnWidth, + nextColumnWidth + ); + this.tt.resizeColGroup( + scrollableView.querySelector( + '[data-pc-section="scrollablebody"] table' + ) || + scrollableView.querySelector( + '[data-pc-name="virtualscroller"] table' + ), + colIndex, + newColumnWidth, + nextColumnWidth + ); + this.tt.resizeColGroup( + scrollableView.querySelector( + '[data-pc-section="scrollablefootertable"]' + ), + colIndex, + newColumnWidth, + nextColumnWidth + ); + } else { + const tableEl = this.tt.tableViewChild + ?.nativeElement as HTMLElement | null; + if (tableEl) { + const ths = Array.from( + tableEl.querySelectorAll('thead > tr:first-child > th') + ) as HTMLElement[]; + const thWidths = ths.map((h) => h.offsetWidth); + ths.forEach((h, i) => { + if (!h.style.width) h.style.width = thWidths[i] + 'px'; + }); + tableEl.style.tableLayout = 'fixed'; + } + th.style.width = newColumnWidth + 'px'; + nextColumn.style.width = nextColumnWidth + 'px'; + } + } + + private _resizeExpand( + th: HTMLElement, + newColumnWidth: number, + delta: number + ): void { + if (this.tt.scrollable) { + const scrollableView = this.tt.findParentScrollableView(th); + if (!scrollableView) return; + const headerTable = scrollableView.querySelector( + '[data-pc-section="scrollableheadertable"]' + ) as HTMLElement | null; + const bodyTable = (scrollableView.querySelector( + '[data-pc-section="scrollablebody"] table' + ) || + scrollableView.querySelector( + '[data-pc-name="virtualscroller"] table' + )) as HTMLElement | null; + const footerTable = scrollableView.querySelector( + '[data-pc-section="scrollablefootertable"]' + ) as HTMLElement | null; + if (headerTable) { + headerTable.style.width = headerTable.offsetWidth + delta + 'px'; + } + if (bodyTable) { + bodyTable.style.width = bodyTable.offsetWidth + delta + 'px'; + } + if (footerTable) { + footerTable.style.width = footerTable.offsetWidth + delta + 'px'; + } + const colIndex = Array.from(th.parentElement!.children).indexOf(th); + this.tt.resizeColGroup(headerTable, colIndex, newColumnWidth, null); + this.tt.resizeColGroup(bodyTable, colIndex, newColumnWidth, null); + this.tt.resizeColGroup(footerTable, colIndex, newColumnWidth, null); + } else { + const tableEl = this.tt.tableViewChild + ?.nativeElement as HTMLElement | null; + if (tableEl) { + const ths = Array.from( + tableEl.querySelectorAll('thead > tr:first-child > th') + ) as HTMLElement[]; + const thWidths = ths.map((h) => h.offsetWidth); + const tableWidth = tableEl.offsetWidth; + ths.forEach((h, i) => { + if (!h.style.width) h.style.width = thWidths[i] + 'px'; + }); + tableEl.style.width = tableWidth + delta + 'px'; + tableEl.style.tableLayout = 'fixed'; + } + th.style.width = newColumnWidth + 'px'; + } + } } diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-sortable.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-sortable.directive.ts index 24b4e8c4e..422d86f46 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-sortable.directive.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-sortable.directive.ts @@ -7,7 +7,9 @@ import { OnInit, ViewContainerRef } from '@angular/core'; -import { TTSortableColumn, TreeTable } from 'primeng/treetable'; +import { DomHandler } from 'primeng/dom'; +import { TreeTable } from 'primeng/treetable'; +import { Subscription } from 'rxjs'; import { CpsSortIconComponent } from '../../cps-table/components/internal/cps-sort-icon/cps-sort-icon.component'; /** @@ -16,40 +18,63 @@ import { CpsSortIconComponent } from '../../cps-table/components/internal/cps-so */ @Directive({ standalone: true, - selector: '[cpsTTColSortable]' + selector: '[cpsTTColSortable]', + host: { + class: 'p-sortable-column', + '(click)': 'onClick($event)' + } }) -export class CpsTreeTableColumnSortableDirective - extends TTSortableColumn - implements OnInit, OnDestroy -{ +export class CpsTreeTableColumnSortableDirective implements OnInit, OnDestroy { /** * Name of the column to be sorted. * @group Props */ - @Input('cpsTTColSortable') override field = ''; + @Input('cpsTTColSortable') field = ''; sortIconRef: ComponentRef; + private _sortSub: Subscription | undefined; constructor( private elementRef: ElementRef, private viewContainerRef: ViewContainerRef, - public override tt: TreeTable + private tt: TreeTable ) { - super(tt); this.sortIconRef = this.viewContainerRef.createComponent(CpsSortIconComponent); } - override ngOnInit(): void { - super.ngOnInit(); + ngOnInit(): void { this.sortIconRef.setInput('field', this.field); this.elementRef.nativeElement.appendChild( this.sortIconRef.location.nativeElement ); + this._updateAriaSort(); + this._sortSub = this.tt.tableService.sortSource$.subscribe(() => { + this._updateAriaSort(); + }); + } + + onClick(event: MouseEvent): void { + if ((event.target as Element)?.closest('.cps-table-col-filter')) return; + this.tt.sort({ field: this.field }); + DomHandler.clearSelection(); + } + + private _updateAriaSort(): void { + let value: 'ascending' | 'descending' | 'none' = 'none'; + if (this.tt.sortMode === 'single') { + if (this.tt.isSorted(this.field)) { + value = this.tt.sortOrder === 1 ? 'ascending' : 'descending'; + } + } else { + const meta = this.tt.getSortMeta(this.field); + if (meta) value = meta.order === 1 ? 'ascending' : 'descending'; + } + this.elementRef.nativeElement.setAttribute('aria-sort', value); } - override ngOnDestroy(): void { - super.ngOnDestroy(); + ngOnDestroy(): void { + this._sortSub?.unsubscribe(); this.sortIconRef.destroy(); this.viewContainerRef.clear(); } diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-header-selectable.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-header-selectable.directive.ts index 0ec7cc154..b5de35c80 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-header-selectable.directive.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-header-selectable.directive.ts @@ -33,6 +33,9 @@ export class CpsTreeTableHeaderSelectableDirective this.elementRef.nativeElement.classList.add( 'cps-treetable-selectable-cell' ); + this.checkboxCompRef.setInput('pt', { + pcHeaderCheckbox: { input: { 'aria-label': 'Select all rows' } } + }); this.elementRef.nativeElement.appendChild( this.checkboxCompRef.location.nativeElement ); diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-selectable.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-selectable.directive.ts index d6eebd6dc..27cd7d250 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-selectable.directive.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-selectable.directive.ts @@ -38,6 +38,9 @@ export class CpsTreeTableRowSelectableDirective implements OnInit, OnDestroy { 'cps-treetable-selectable-cell' ); this.checkboxCompRef.setInput('value', this.value); + this.checkboxCompRef.setInput('pt', { + pcRowCheckbox: { input: { 'aria-label': 'Select row', tabindex: '0' } } + }); this.elementRef.nativeElement.appendChild( this.checkboxCompRef.location.nativeElement From 9dbb36d7a31d04db84c9aa9f655ff0e11ffe8a59 Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 1 Jul 2026 09:17:55 +0200 Subject: [PATCH 2/5] add UTs --- ...s-table-column-resizable.directive.spec.ts | 10 +- .../cps-tree-table.component.spec.ts | 726 ++++++++++++++++++ .../cps-tree-table.component.ts | 14 +- ...tree-table-column-filter.directive.spec.ts | 425 ++++++++++ .../cps-tree-table-column-filter.directive.ts | 5 +- ...e-table-column-resizable.directive.spec.ts | 499 ++++++++++++ ...s-tree-table-column-resizable.directive.ts | 3 +- ...ee-table-column-sortable.directive.spec.ts | 258 +++++++ ...ps-tree-table-column-sortable.directive.ts | 3 +- ...-table-header-selectable.directive.spec.ts | 118 +++ ...-tree-table-header-selectable.directive.ts | 1 - ...ree-table-row-selectable.directive.spec.ts | 136 ++++ ...cps-tree-table-row-selectable.directive.ts | 1 - ...s-tree-table-row-toggler.directive.spec.ts | 175 +++++ .../cps-tree-table-row-toggler.directive.ts | 1 - ...tree-table-detect-filter-type.pipe.spec.ts | 136 ++++ .../cps-tree-table-detect-filter-type.pipe.ts | 5 +- projects/cps-ui-kit/src/public-api.ts | 14 +- 18 files changed, 2499 insertions(+), 31 deletions(-) create mode 100644 projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.spec.ts create mode 100644 projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-filter/cps-tree-table-column-filter.directive.spec.ts rename projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/{ => cps-tree-table-column-filter}/cps-tree-table-column-filter.directive.ts (95%) create mode 100644 projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-resizable/cps-tree-table-column-resizable.directive.spec.ts rename projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/{ => cps-tree-table-column-resizable}/cps-tree-table-column-resizable.directive.ts (98%) create mode 100644 projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-sortable/cps-tree-table-column-sortable.directive.spec.ts rename projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/{ => cps-tree-table-column-sortable}/cps-tree-table-column-sortable.directive.ts (94%) create mode 100644 projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-header-selectable/cps-tree-table-header-selectable.directive.spec.ts rename projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/{ => cps-tree-table-header-selectable}/cps-tree-table-header-selectable.directive.ts (98%) create mode 100644 projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-selectable/cps-tree-table-row-selectable.directive.spec.ts rename projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/{ => cps-tree-table-row-selectable}/cps-tree-table-row-selectable.directive.ts (98%) create mode 100644 projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-toggler/cps-tree-table-row-toggler.directive.spec.ts rename projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/{ => cps-tree-table-row-toggler}/cps-tree-table-row-toggler.directive.ts (98%) create mode 100644 projects/cps-ui-kit/src/lib/components/cps-tree-table/pipes/cps-tree-table-detect-filter-type/cps-tree-table-detect-filter-type.pipe.spec.ts rename projects/cps-ui-kit/src/lib/components/cps-tree-table/pipes/{ => cps-tree-table-detect-filter-type}/cps-tree-table-detect-filter-type.pipe.ts (84%) 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 index 1f915daa9..e111de13c 100644 --- 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 @@ -8,7 +8,6 @@ import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../../../services/cps-root-font-s import { CpsTableColumnResizableDirective } from './cps-table-column-resizable.directive'; @Component({ - standalone: true, template: ` @@ -30,11 +29,14 @@ class TestHostComponent { function buildMockTable() { return { _cpsResizeCellsPatched: false, + _cpsResizeIndicatorPatched: false, resizeTableCells: jest.fn(), + onColumnResize: jest.fn(), columnResizeMode: 'expand', tableViewChild: { nativeElement: document.createElement('table') }, el: { nativeElement: document.createElement('div') }, resizeColumnElement: null as HTMLElement | null, + resizeHelperViewChild: null as null | { nativeElement: HTMLElement }, _initialColWidths: null as number | null, _totalTableWidth: jest.fn().mockReturnValue(1000), setResizeTableWidth: jest.fn(), @@ -282,7 +284,7 @@ describe('CpsTableColumnResizableDirective', () => { expect(mockTable.resizeColumnElement).toBe(th); }); - it('should call setResizeTableWidth with rem value in expand mode', () => { + it('should call setResizeTableWidth with px value in expand mode', () => { mockTable.columnResizeMode = 'expand'; Object.defineProperty( mockTable.tableViewChild.nativeElement, @@ -295,8 +297,8 @@ describe('CpsTableColumnResizableDirective', () => { (directive as any)._onResizerKeydown( new KeyboardEvent('keydown', { key: 'ArrowRight' }) ); - // tableWidth = 800 + 10 = 810; 810 / 16 = 50.625rem - expect(mockTable.setResizeTableWidth).toHaveBeenCalledWith('50.625rem'); + // tableWidth = 800 + 10 = 810px + expect(mockTable.setResizeTableWidth).toHaveBeenCalledWith('810px'); }); it('should emit onColResize with element and delta', () => { diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.spec.ts new file mode 100644 index 000000000..e8b1d118e --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.spec.ts @@ -0,0 +1,726 @@ +import { signal, 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_ROOT_FONT_SIZE_SERVICE } from '../../services/cps-root-font-size/cps-root-font-size.service'; +import { CpsTreeTableComponent } from './cps-tree-table.component'; + +const mockFontSize = signal(16); +const mockRootFontSizeService = { + fontSize: mockFontSize.asReadonly() +}; + +describe('CpsTreeTableComponent', () => { + let fixture: ComponentFixture; + let component: CpsTreeTableComponent; + + beforeEach(async () => { + jest + .spyOn(BaseComponent.prototype, 'ngOnInit') + .mockImplementation(() => {}); + + await TestBed.configureTestingModule({ + imports: [CpsTreeTableComponent, NoopAnimationsModule], + providers: [ + { + provide: CPS_ROOT_FONT_SIZE_SERVICE, + useValue: mockRootFontSizeService + } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(CpsTreeTableComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have a primengTreeTable ViewChild', () => { + expect(component.primengTreeTable).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 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([])); + it('should default resizableColumns to false', () => + expect(component.resizableColumns).toBe(false)); + it('should default columnResizeMode to "fit"', () => + expect(component.columnResizeMode).toBe('fit')); + it('should default sortMode to "single"', () => + expect(component.sortMode).toBe('single')); + }); + + describe('data setter / getter', () => { + it('should store a deep clone of the array', () => { + const arr = [{ data: { id: 1 } }, { data: { id: 2 } }]; + component.data = arr; + expect(component.data).not.toBe(arr); + expect( + (arr[0] as { _defaultSortOrder?: number })._defaultSortOrder + ).toBeUndefined(); + }); + + it('should add _defaultSortOrder to each item', () => { + component.data = [{ data: { id: 1 } }, { data: { id: 2 } }]; + expect(component.data[0]._defaultSortOrder).toBe(0); + expect(component.data[1]._defaultSortOrder).toBe(1); + }); + + it('should handle empty array', () => { + component.data = []; + expect(component.data).toEqual([]); + }); + }); + + 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); + }); + + it('should set defScrollHeight from scrollHeight', () => { + component.scrollHeight = '400px'; + component.ngOnInit(); + expect(component.defScrollHeight).toBe('400px'); + }); + + 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 node keys when showGlobalFilter is true and fields are empty', () => { + component.showGlobalFilter = true; + component.globalFilterFields = []; + component.data = [{ 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 = [{ 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 p-treetable-striped" by default', () => { + expect(component.styleClass).toBe('cps-tbar-normal p-treetable-striped'); + }); + + it('should include "cps-tbar-small" when toolbarSize is "small"', () => { + component.toolbarSize = 'small'; + expect(component.styleClass).toContain('cps-tbar-small'); + }); + + it('should not include toolbar class when hasToolbar is false', () => { + component.hasToolbar = false; + component.striped = false; + expect(component.styleClass).toBe(''); + }); + + it('should include "p-treetable-sm" when size is "small"', () => { + component.size = 'small'; + expect(component.styleClass).toContain('p-treetable-sm'); + }); + + it('should include "p-treetable-lg" when size is "large"', () => { + component.size = 'large'; + expect(component.styleClass).toContain('p-treetable-lg'); + }); + + it('should not include stripe class when striped is false', () => { + component.striped = false; + expect(component.styleClass).not.toContain('p-treetable-striped'); + }); + }); + + describe('ngOnChanges', () => { + it('should clear selection when loading becomes true', () => { + component.selectedRows = [{ data: { 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 = [{ data: { id: 1 } }]; + component.loading = false; + component.ngOnChanges({ loading: new SimpleChange(true, false, false) }); + expect(component.selectedRows).toEqual([{ data: { id: 1 } }]); + }); + + 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 clear selection when data changes', () => { + const node = { data: { id: 1 } }; + component.selectedRows = [node]; + component.ngOnChanges({ + data: new SimpleChange([], [node], false) + }); + expect(component.selectedRows).toEqual([]); + }); + + it('should set virtualScrollItemSize from virtualScrollItemHeight when provided', () => { + component.virtualScrollItemHeight = 48; + component.ngOnChanges({ + virtualScrollItemHeight: new SimpleChange(0, 48, false) + }); + expect(component.virtualScrollItemSize).toBe(48); + }); + + it('should rebuild treeTablePassthrough when paginator changes', () => { + const before = component.treeTablePassthrough; + component.paginator = true; + component.ngOnChanges({ + paginator: new SimpleChange(false, true, false) + }); + expect(component.treeTablePassthrough).not.toBe(before); + }); + + it('should rebuild treeTablePassthrough when totalRecords changes', () => { + const before = component.treeTablePassthrough; + component.ngOnChanges({ + totalRecords: new SimpleChange(0, 100, false) + }); + expect(component.treeTablePassthrough).not.toBe(before); + }); + }); + + describe('clearSelection', () => { + it('should set selectedRows to empty array', () => { + component.selectedRows = [{ data: { id: 1 } }, { data: { id: 2 } }]; + component.clearSelection(); + expect(component.selectedRows).toEqual([]); + }); + }); + + describe('onSelectionChanged', () => { + it('should emit rowsSelected with an array selection', () => { + jest.spyOn(component.rowsSelected, 'emit'); + const rows = [{ data: { id: 1 } }]; + component.onSelectionChanged(rows); + expect(component.rowsSelected.emit).toHaveBeenCalledWith(rows); + }); + + it('should wrap non-array selection in an array', () => { + jest.spyOn(component.rowsSelected, 'emit'); + const row = { data: { id: 1 } }; + component.onSelectionChanged(row); + expect(component.rowsSelected.emit).toHaveBeenCalledWith([row]); + }); + + it('should emit rowsSelected with null/undefined as-is', () => { + jest.spyOn(component.rowsSelected, 'emit'); + component.onSelectionChanged(null); + expect(component.rowsSelected.emit).toHaveBeenCalledWith(null); + }); + }); + + describe('onSortFunction', () => { + it('should emit customSortFunction with the event', () => { + jest.spyOn(component.customSortFunction, 'emit'); + const event = { + data: [], + field: 'name', + order: 1 + } 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('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 = { data: { id: 1 } }; + component.selectedRows = [row]; + component.removeSelected(); + expect(component.rowsToRemove.emit).toHaveBeenCalledWith([row]); + }); + }); + + 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 = [{ data: { id: 1 } }]; + component.onClickAdditionalBtnOnSelect(); + expect(component.additionalBtnOnSelectClicked.emit).toHaveBeenCalledWith([ + { data: { id: 1 } } + ]); + }); + }); + + describe('onEditRowClicked', () => { + it('should emit editRowBtnClicked with the node', () => { + jest.spyOn(component.editRowBtnClicked, 'emit'); + const node = { data: { id: 2 } }; + component.onEditRowClicked(node); + expect(component.editRowBtnClicked.emit).toHaveBeenCalledWith(node); + }); + }); + + describe('onRemoveRowClicked', () => { + it('should emit rowsToRemove with the node wrapped in an array', () => { + jest.spyOn(component.rowsToRemove, 'emit'); + const node = { data: { id: 5 } }; + component.onRemoveRowClicked(node); + expect(component.rowsToRemove.emit).toHaveBeenCalledWith([node]); + }); + }); + + describe('onSelectedColumnsChange', () => { + it('should update selectedColumns', () => { + const cols = [{ header: 'Name', field: 'name' }]; + component.onSelectedColumnsChange(cols); + expect(component.selectedColumns).toEqual(cols); + }); + + it('should emit columnsSelected', () => { + jest.spyOn(component.columnsSelected, 'emit'); + const cols = [{ header: 'Age', field: 'age' }]; + component.onSelectedColumnsChange(cols); + expect(component.columnsSelected.emit).toHaveBeenCalledWith(cols); + }); + }); + + describe('node events', () => { + it('onNodeExpanded should emit nodeExpanded with the event', () => { + jest.spyOn(component.nodeExpanded, 'emit'); + const event = { node: { data: { id: 1 } } }; + component.onNodeExpanded(event); + expect(component.nodeExpanded.emit).toHaveBeenCalledWith(event); + }); + + it('onNodeCollapsed should emit nodeCollapsed with the event', () => { + jest.spyOn(component.nodeCollapsed, 'emit'); + const event = { node: { data: { id: 2 } } }; + component.onNodeCollapsed(event); + expect(component.nodeCollapsed.emit).toHaveBeenCalledWith(event); + }); + + it('onNodeSelected should emit nodeSelected with the event', () => { + jest.spyOn(component.nodeSelected, 'emit'); + const event = { node: { data: { id: 3 } } }; + component.onNodeSelected(event); + expect(component.nodeSelected.emit).toHaveBeenCalledWith(event); + }); + + it('onNodeUnselected should emit nodeUnselected with the event', () => { + jest.spyOn(component.nodeUnselected, 'emit'); + const event = { node: { data: { id: 4 } } }; + component.onNodeUnselected(event); + expect(component.nodeUnselected.emit).toHaveBeenCalledWith(event); + }); + }); + + describe('onFilterGlobal', () => { + it('should call primengTreeTable.filterGlobal with value and "contains"', () => { + jest + .spyOn(component.primengTreeTable, 'filterGlobal') + .mockImplementation(() => {}); + component.onFilterGlobal('search term'); + expect(component.primengTreeTable.filterGlobal).toHaveBeenCalledWith( + 'search term', + 'contains' + ); + }); + }); + + describe('pagination helpers', () => { + beforeEach(() => { + component.rows = 10; + component.primengTreeTable.totalRecords = 30; + }); + + it('getPageCount should return total pages', () => { + expect(component.getPageCount()).toBe(3); + }); + + it('getPage should return 0 when primengTreeTable.first is 0', () => { + component.primengTreeTable.first = 0; + expect(component.getPage()).toBe(0); + }); + + it('getPage should return correct page from primengTreeTable.first', () => { + component.primengTreeTable.first = 20; + expect(component.getPage()).toBe(2); + }); + + it('changePage should call primengTreeTable.onPageChange for valid page', () => { + jest + .spyOn(component.primengTreeTable, 'onPageChange') + .mockImplementation(() => {}); + component.changePage(1); + expect(component.primengTreeTable.onPageChange).toHaveBeenCalledWith({ + first: 10, + rows: 10 + }); + }); + + it('changePage should ignore out-of-bounds page', () => { + jest + .spyOn(component.primengTreeTable, 'onPageChange') + .mockImplementation(() => {}); + component.changePage(5); + expect(component.primengTreeTable.onPageChange).not.toHaveBeenCalled(); + }); + + it('changePage should ignore negative page', () => { + jest + .spyOn(component.primengTreeTable, 'onPageChange') + .mockImplementation(() => {}); + component.changePage(-1); + expect(component.primengTreeTable.onPageChange).not.toHaveBeenCalled(); + }); + }); + + describe('onPageChange', () => { + it('should update first and rows', () => { + component.rows = 10; + component.primengTreeTable.totalRecords = 50; + jest + .spyOn(component.primengTreeTable, 'onPageChange') + .mockImplementation(() => {}); + component.onPageChange({ first: 10, rows: 10 }); + expect(component.first).toBe(10); + expect(component.rows).toBe(10); + }); + + it('should emit pageChanged with page state', () => { + jest.spyOn(component.pageChanged, 'emit'); + jest + .spyOn(component.primengTreeTable, 'onPageChange') + .mockImplementation(() => {}); + component.rows = 10; + component.primengTreeTable.totalRecords = 30; + component.primengTreeTable.first = 10; + component.onPageChange({ first: 10, rows: 10 }); + expect(component.pageChanged.emit).toHaveBeenCalledWith({ + page: 1, + first: 10, + rows: 10, + pageCount: 3 + }); + }); + }); + + describe('getExportData', () => { + it('should return exportOriginalData when it is set', () => { + const orig = [{ id: 1 }, { id: 2 }]; + component.exportOriginalData = orig; + expect(component.getExportData()).toBe(orig); + }); + + it('should return primengTreeTable.value without parent references when exportOriginalData is empty', () => { + component.exportOriginalData = []; + const parent = { data: { id: 1 } }; + const child = { data: { id: 2 }, parent }; + component.primengTreeTable.value = [ + { data: { id: 1 }, children: [child] } + ]; + const result = component.getExportData(); + expect(result[0].children[0].data).toEqual({ id: 2 }); + expect(result[0].children[0].parent).toBeUndefined(); + }); + + it('should remove parent key from top-level nodes', () => { + component.exportOriginalData = []; + component.primengTreeTable.value = [ + { data: { id: 1 }, parent: { data: { id: 0 } } } + ]; + const result = component.getExportData(); + expect(result[0].parent).toBeUndefined(); + expect(result[0].data).toEqual({ id: 1 }); + }); + + it('should return empty array when primengTreeTable.value is empty', () => { + component.exportOriginalData = []; + component.primengTreeTable.value = []; + expect(component.getExportData()).toEqual([]); + }); + }); + + describe('exportJSON', () => { + it('should create a download link and trigger download', () => { + (URL as unknown as { createObjectURL: jest.Mock }).createObjectURL = jest + .fn() + .mockReturnValue('blob:mock-url'); + const mockClick = jest.fn(); + const mockAnchor = { href: '', download: '', click: mockClick }; + const origCreate = document.createElement.bind(document); + jest + .spyOn(document, 'createElement') + .mockImplementation((tag: string) => + tag === 'a' ? (mockAnchor as unknown as HTMLElement) : origCreate(tag) + ); + + component.exportOriginalData = [{ id: 1 }]; + component.exportFilename = 'my-export'; + component.exportJSON(); + + expect(mockClick).toHaveBeenCalled(); + expect(mockAnchor.download).toBe('my-export.json'); + }); + + it('should not throw on error (catches internally)', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + (URL as unknown as { createObjectURL: jest.Mock }).createObjectURL = jest + .fn() + .mockImplementation(() => { + throw new Error('mock error'); + }); + expect(() => component.exportJSON()).not.toThrow(); + }); + }); + + describe('treeTablePassthrough', () => { + it('should return empty object when paginator is false', () => { + component.paginator = false; + component.ngOnInit(); + expect(component.treeTablePassthrough).toEqual({}); + }); + + it('should set first button aria-disabled to true when first is 0', () => { + component.paginator = true; + component.rowsPerPageOptions = [10]; + component.first = 0; + component.data = [{ data: { id: 1 } }]; + component.ngOnInit(); + const pt = component.treeTablePassthrough as unknown as { + pcPaginator?: { first?: { 'aria-disabled'?: string | null } }; + }; + expect(pt.pcPaginator?.first?.['aria-disabled']).toBe('true'); + }); + + it('should set first button aria-disabled to null when first > 0', () => { + component.paginator = true; + component.rowsPerPageOptions = [10]; + component.rows = 10; + component.first = 10; + component.data = [{ data: { id: 1 } }, { data: { id: 2 } }]; + component.ngOnInit(); + const pt = component.treeTablePassthrough as unknown as { + pcPaginator?: { first?: { 'aria-disabled'?: string | null } }; + }; + expect(pt.pcPaginator?.first?.['aria-disabled']).toBeNull(); + }); + }); + + describe('onPaginatorKeydown', () => { + it('should do nothing for non-arrow keys', () => { + const event = new KeyboardEvent('keydown', { key: 'Enter' }); + jest.spyOn(event, 'preventDefault'); + component.onPaginatorKeydown(event); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('should do nothing when target does not have p-paginator-page class', () => { + const btn = document.createElement('button'); + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }); + Object.defineProperty(event, 'target', { value: btn }); + jest.spyOn(event, 'preventDefault'); + component.onPaginatorKeydown(event); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('should call preventDefault when target is a paginator page button', () => { + const btn = document.createElement('button'); + btn.classList.add('p-paginator-page'); + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }); + Object.defineProperty(event, 'target', { value: btn }); + jest.spyOn(event, 'preventDefault'); + component.onPaginatorKeydown(event); + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.ts index 8dbc9bc37..d1efac84e 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.ts @@ -44,14 +44,14 @@ import { CpsMenuItem } from '../cps-menu/cps-menu.component'; import { CpsSelectComponent } from '../cps-select/cps-select.component'; import { TableColumnVisibilityToggleComponent } from '../cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component'; import { TableRowMenuComponent } from '../cps-table/components/internal/table-row-menu/table-row-menu.component'; -import { CpsTreeTableColumnFilterDirective } from './directives/cps-tree-table-column-filter.directive'; -import { CpsTreeTableColumnResizableDirective } from './directives/cps-tree-table-column-resizable.directive'; -import { CpsTreeTableColumnSortableDirective } from './directives/cps-tree-table-column-sortable.directive'; -import { CpsTreeTableHeaderSelectableDirective } from './directives/cps-tree-table-header-selectable.directive'; -import { CpsTreeTableRowSelectableDirective } from './directives/cps-tree-table-row-selectable.directive'; -import { CpsTreetableRowTogglerDirective } from './directives/cps-tree-table-row-toggler.directive'; +import { CpsTreeTableColumnFilterDirective } from './directives/cps-tree-table-column-filter/cps-tree-table-column-filter.directive'; +import { CpsTreeTableColumnResizableDirective } from './directives/cps-tree-table-column-resizable/cps-tree-table-column-resizable.directive'; +import { CpsTreeTableColumnSortableDirective } from './directives/cps-tree-table-column-sortable/cps-tree-table-column-sortable.directive'; +import { CpsTreeTableHeaderSelectableDirective } from './directives/cps-tree-table-header-selectable/cps-tree-table-header-selectable.directive'; +import { CpsTreeTableRowSelectableDirective } from './directives/cps-tree-table-row-selectable/cps-tree-table-row-selectable.directive'; +import { CpsTreetableRowTogglerDirective } from './directives/cps-tree-table-row-toggler/cps-tree-table-row-toggler.directive'; import { TreeTableUnsortDirective } from './directives/internal/tree-table-unsort.directive'; -import { CpsTreeTableDetectFilterTypePipe } from './pipes/cps-tree-table-detect-filter-type.pipe'; +import { CpsTreeTableDetectFilterTypePipe } from './pipes/cps-tree-table-detect-filter-type/cps-tree-table-detect-filter-type.pipe'; import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../services/cps-root-font-size/cps-root-font-size.service'; export function treeTableFactory(tableComponent: CpsTreeTableComponent) { diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-filter/cps-tree-table-column-filter.directive.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-filter/cps-tree-table-column-filter.directive.spec.ts new file mode 100644 index 000000000..7ae854ad4 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-filter/cps-tree-table-column-filter.directive.spec.ts @@ -0,0 +1,425 @@ +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 { TreeTable } from 'primeng/treetable'; +import { + CpsColumnFilterMatchMode, + CpsColumnFilterType +} from '../../../cps-table/cps-column-filter-types'; +import { CpsTreeTableColumnFilterDirective } from './cps-tree-table-column-filter.directive'; + +@Component({ + template: ``, + imports: [CpsTreeTableColumnFilterDirective] +}) +class TestHostComponent { + @ViewChild(CpsTreeTableColumnFilterDirective) + directive!: CpsTreeTableColumnFilterDirective; + + field: string | undefined = 'name'; + filterType: CpsColumnFilterType = 'text'; + filterPersistent = false; + filterShowClearButton = true; + filterShowApplyButton = true; + filterShowCloseButton = false; + filterShowMatchModes = true; + filterMatchModes: CpsColumnFilterMatchMode[] = []; + filterShowOperator = true; + filterHeaderTitle = ''; + filterHideOnClear = false; + filterCategoryOptions: string[] = []; + filterAsButtonToggle = false; + filterSingleSelection = false; + filterPlaceholder = ''; +} + +describe('CpsTreeTableColumnFilterDirective', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let directive: CpsTreeTableColumnFilterDirective; + let mockTreeTable: TreeTable; + + beforeEach(async () => { + mockTreeTable = Object.create(TreeTable.prototype) as TreeTable; + (mockTreeTable as { ngOnDestroy: () => void }).ngOnDestroy = jest.fn(); + (mockTreeTable as { filters: Record }).filters = {}; + (mockTreeTable as { onFilter: EventEmitter }).onFilter = + new EventEmitter(); + jest.spyOn(mockTreeTable, 'isFilterBlank').mockReturnValue(false); + jest.spyOn(mockTreeTable, '_filter').mockImplementation(() => {}); + + await TestBed.configureTestingModule({ + imports: [TestHostComponent, NoopAnimationsModule], + providers: [{ provide: TreeTable, useValue: mockTreeTable }] + }).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') + .mockImplementation(() => {}); + host.field = 'age'; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'field', + 'age' + ); + }); + + it('should forward filterType as type', () => { + jest + .spyOn(directive.filterCompRef, 'setInput') + .mockImplementation(() => {}); + host.filterType = 'number'; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'type', + 'number' + ); + }); + + it('should forward filterPersistent as persistent', () => { + jest + .spyOn(directive.filterCompRef, 'setInput') + .mockImplementation(() => {}); + host.filterPersistent = true; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'persistent', + true + ); + }); + + it('should forward filterShowClearButton as showClearButton', () => { + jest + .spyOn(directive.filterCompRef, 'setInput') + .mockImplementation(() => {}); + host.filterShowClearButton = false; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'showClearButton', + false + ); + }); + + it('should forward filterShowApplyButton as showApplyButton', () => { + jest + .spyOn(directive.filterCompRef, 'setInput') + .mockImplementation(() => {}); + host.filterShowApplyButton = false; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'showApplyButton', + false + ); + }); + + it('should forward filterShowCloseButton as showCloseButton', () => { + jest + .spyOn(directive.filterCompRef, 'setInput') + .mockImplementation(() => {}); + host.filterShowCloseButton = true; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'showCloseButton', + true + ); + }); + + it('should forward filterShowMatchModes as showMatchModes', () => { + jest + .spyOn(directive.filterCompRef, 'setInput') + .mockImplementation(() => {}); + 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') + .mockImplementation(() => {}); + host.filterMatchModes = modes; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'matchModes', + modes + ); + }); + + it('should forward filterShowOperator as showOperator', () => { + jest + .spyOn(directive.filterCompRef, 'setInput') + .mockImplementation(() => {}); + host.filterShowOperator = false; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'showOperator', + false + ); + }); + + it('should always forward maxConstraints as 1', () => { + jest + .spyOn(directive.filterCompRef, 'setInput') + .mockImplementation(() => {}); + host.field = 'trigger'; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'maxConstraints', + 1 + ); + }); + + it('should forward filterHeaderTitle as headerTitle', () => { + jest + .spyOn(directive.filterCompRef, 'setInput') + .mockImplementation(() => {}); + 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') + .mockImplementation(() => {}); + 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') + .mockImplementation(() => {}); + host.filterCategoryOptions = opts; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'categoryOptions', + opts + ); + }); + + it('should forward filterAsButtonToggle as asButtonToggle', () => { + jest + .spyOn(directive.filterCompRef, 'setInput') + .mockImplementation(() => {}); + host.filterAsButtonToggle = true; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'asButtonToggle', + true + ); + }); + + it('should forward filterSingleSelection as singleSelection', () => { + jest + .spyOn(directive.filterCompRef, 'setInput') + .mockImplementation(() => {}); + host.filterSingleSelection = true; + fixture.detectChanges(); + expect(directive.filterCompRef.setInput).toHaveBeenCalledWith( + 'singleSelection', + true + ); + }); + + it('should forward explicit filterPlaceholder as placeholder', () => { + jest + .spyOn(directive.filterCompRef, 'setInput') + .mockImplementation(() => {}); + 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') + .mockImplementation(() => {}); + 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') + .mockImplementation(() => {}); + 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') + .mockImplementation(() => {}); + 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') + .mockImplementation(() => {}); + 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') + .mockImplementation(() => {}); + 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') + .mockImplementation(() => {}); + 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-tree-table/directives/cps-tree-table-column-filter.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-filter/cps-tree-table-column-filter.directive.ts similarity index 95% rename from projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-filter.directive.ts rename to projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-filter/cps-tree-table-column-filter.directive.ts index ac6d6fa81..2d6a010bf 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-filter.directive.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-filter/cps-tree-table-column-filter.directive.ts @@ -8,19 +8,18 @@ import { OnChanges, ViewContainerRef } from '@angular/core'; -import { TableColumnFilterComponent } from '../../cps-table/components/internal/table-column-filter/table-column-filter.component'; +import { TableColumnFilterComponent } from '../../../cps-table/components/internal/table-column-filter/table-column-filter.component'; import { CpsColumnFilterType, CpsColumnFilterMatchMode, CpsColumnFilterCategoryOption -} from '../../cps-table/cps-column-filter-types'; +} from '../../../cps-table/cps-column-filter-types'; /** * CpsTreeTableColumnFilterDirective is a filtering directive used to filter single or multiple columns in treetable. * @group Directives */ @Directive({ - standalone: true, selector: '[cpsTTColFilter]', exportAs: 'cpsTTColFilter' }) diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-resizable/cps-tree-table-column-resizable.directive.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-resizable/cps-tree-table-column-resizable.directive.spec.ts new file mode 100644 index 000000000..d1d9ff285 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-resizable/cps-tree-table-column-resizable.directive.spec.ts @@ -0,0 +1,499 @@ +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 { + TTResizableColumn, + TreeTable, + TreeTableStyle +} from 'primeng/treetable'; +import { BaseComponent } from 'primeng/basecomponent'; +import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../../../services/cps-root-font-size/cps-root-font-size.service'; +import { CpsTreeTableColumnResizableDirective } from './cps-tree-table-column-resizable.directive'; + +@Component({ + template: `
+ Header +
+ + + + + +
+ Name +
`, + imports: [CpsTreeTableColumnResizableDirective] +}) +class TestHostComponent { + @ViewChild(CpsTreeTableColumnResizableDirective) + directive!: CpsTreeTableColumnResizableDirective; + + disabled: boolean | undefined = undefined; +} + +function buildMockTreeTable() { + return { + _cpsResizeColGroupPatched: false, + _cpsResizeIndicatorPatched: false, + _cpsResizeBeginPatched: false, + resizeColGroup: jest.fn(), + onColumnResize: jest.fn(), + onColumnResizeBegin: jest.fn(), + columnResizeMode: 'expand' as string, + scrollable: false, + tableViewChild: { + nativeElement: document.createElement('table') + } as { nativeElement: HTMLElement }, + el: { nativeElement: document.createElement('div') }, + resizeHelperViewChild: null as null | { nativeElement: HTMLElement }, + findParentScrollableView: jest.fn(), + onColResize: { emit: jest.fn() } + }; +} + +describe('CpsTreeTableColumnResizableDirective', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let directive: CpsTreeTableColumnResizableDirective; + let mockTreeTable: ReturnType; + let mockResizer: HTMLSpanElement; + + beforeEach(async () => { + mockResizer = document.createElement('span'); + mockTreeTable = buildMockTreeTable(); + + jest + .spyOn(BaseComponent.prototype, 'ngOnInit') + .mockImplementation(() => {}); + + jest + .spyOn(TTResizableColumn.prototype, 'onAfterViewInit') + .mockImplementation(function (this: TTResizableColumn) { + (this as { resizer: HTMLSpanElement | undefined }).resizer = + mockResizer; + }); + + jest + .spyOn(TTResizableColumn.prototype, 'onDestroy') + .mockImplementation(() => {}); + + await TestBed.configureTestingModule({ + imports: [TestHostComponent, NoopAnimationsModule], + providers: [ + { provide: TreeTable, useValue: mockTreeTable }, + { + provide: TreeTableStyle, + useValue: { name: 'treetable', 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('cpsTTColResizableDisabled input', () => { + it('should default ttResizableColumnDisabled to undefined', () => { + expect(directive.ttResizableColumnDisabled).toBeUndefined(); + }); + + it('should set ttResizableColumnDisabled via cpsTTColResizableDisabled alias', () => { + host.disabled = true; + fixture.detectChanges(); + expect(directive.ttResizableColumnDisabled).toBe(true); + }); + + it('should pass false through the alias', () => { + host.disabled = false; + fixture.detectChanges(); + expect(directive.ttResizableColumnDisabled).toBe(false); + }); + }); + + describe('isEnabled', () => { + it('should return true when ttResizableColumnDisabled is undefined', () => { + expect(directive.isEnabled()).toBe(true); + }); + + it('should return true when ttResizableColumnDisabled is false', () => { + host.disabled = false; + fixture.detectChanges(); + expect(directive.isEnabled()).toBe(true); + }); + + it('should return false when ttResizableColumnDisabled 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 ttResizableColumnDisabled is true', () => { + const freshResizer = document.createElement('span'); + jest + .spyOn(TTResizableColumn.prototype, 'onAfterViewInit') + .mockImplementation(function (this: TTResizableColumn) { + (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 — resizeColGroup patch', () => { + it('should set _cpsResizeColGroupPatched to true', () => { + expect(mockTreeTable._cpsResizeColGroupPatched).toBe(true); + }); + + it('should replace tt.resizeColGroup with a custom function', () => { + expect(typeof mockTreeTable.resizeColGroup).toBe('function'); + expect(mockTreeTable.resizeColGroup).not.toBeInstanceOf( + jest.fn().constructor + ); + }); + + it('should not re-patch when a second directive is created on the same tree table', () => { + const patchedRef = mockTreeTable.resizeColGroup; + const f2 = TestBed.createComponent(TestHostComponent); + f2.detectChanges(); + expect(mockTreeTable.resizeColGroup).toBe(patchedRef); + }); + + it('should return early if table argument is null', () => { + expect(() => + mockTreeTable.resizeColGroup(null as any, 0, 100, null) + ).not.toThrow(); + }); + + it('should return early if table has no COLGROUP first child', () => { + const table = document.createElement('table'); + table.appendChild(document.createElement('thead')); + expect(() => + mockTreeTable.resizeColGroup(table, 0, 100, null) + ).not.toThrow(); + }); + + it('should set col width and tableLayout when table has COLGROUP', () => { + const table = document.createElement('table'); + const colGroup = document.createElement('colgroup'); + const col0 = document.createElement('col'); + const col1 = document.createElement('col'); + colGroup.appendChild(col0); + colGroup.appendChild(col1); + table.appendChild(colGroup); + + mockTreeTable.resizeColGroup(table, 0, 120, null); + + expect(col0.style.width).toBe('120px'); + expect(table.style.tableLayout).toBe('fixed'); + }); + + it('should set next col width when nextColumnWidth is provided', () => { + const table = document.createElement('table'); + const colGroup = document.createElement('colgroup'); + const col0 = document.createElement('col'); + const col1 = document.createElement('col'); + colGroup.appendChild(col0); + colGroup.appendChild(col1); + table.appendChild(colGroup); + + mockTreeTable.resizeColGroup(table, 0, 120, 80); + + expect(col0.style.width).toBe('120px'); + expect(col1.style.width).toBe('80px'); + }); + }); + + describe('onAfterViewInit — onColumnResize patch', () => { + it('should set _cpsResizeIndicatorPatched to true', () => { + expect(mockTreeTable._cpsResizeIndicatorPatched).toBe(true); + }); + + it('should call the original onColumnResize when invoked', () => { + const wrapped = mockTreeTable.onColumnResize as (e: MouseEvent) => void; + const event = new MouseEvent('mousemove'); + expect(() => wrapped.call(mockTreeTable, event)).not.toThrow(); + }); + + it('should not throw when resizeHelperViewChild is null', () => { + mockTreeTable.resizeHelperViewChild = null; + expect(() => + (mockTreeTable.onColumnResize as (e: MouseEvent) => void).call( + mockTreeTable, + new MouseEvent('mousemove') + ) + ).not.toThrow(); + }); + }); + + describe('onAfterViewInit — onColumnResizeBegin patch', () => { + it('should set _cpsResizeBeginPatched to true', () => { + expect(mockTreeTable._cpsResizeBeginPatched).toBe(true); + }); + + it('should call both onColumnResizeBegin original and the patched onColumnResize', () => { + const originalResizeBeginSpy = jest.fn(); + const originalResizeSpy = jest.fn(); + + const freshMock = buildMockTreeTable(); + freshMock.onColumnResizeBegin = originalResizeBeginSpy; + freshMock.onColumnResize = originalResizeSpy; + + const originalBegin = freshMock.onColumnResizeBegin.bind(freshMock); + ( + freshMock as { onColumnResizeBegin: (e: MouseEvent) => void } + ).onColumnResizeBegin = (event: MouseEvent) => { + originalBegin(event); + freshMock.onColumnResize(event); + }; + freshMock.onColumnResize = originalResizeSpy; + + const event = new MouseEvent('mousedown'); + freshMock.onColumnResizeBegin(event); + + expect(originalResizeBeginSpy).toHaveBeenCalledWith(event); + expect(originalResizeSpy).toHaveBeenCalledWith(event); + }); + }); + + 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 + }); + }); + + 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(); + }); + + 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 set th.style.width in expand mode (ArrowRight, small step)', () => { + mockTreeTable.columnResizeMode = 'expand'; + mockTreeTable.scrollable = false; + (directive as any)._onResizerKeydown( + new KeyboardEvent('keydown', { key: 'ArrowRight' }) + ); + // delta = Math.round(1 * 0.625 * 16) = 10; newColumnWidth = 100 + 10 = 110 + expect(th.style.width).toBe('110px'); + }); + + it('should set th.style.width in expand mode (ArrowLeft, small step)', () => { + mockTreeTable.columnResizeMode = 'expand'; + mockTreeTable.scrollable = false; + (directive as any)._onResizerKeydown( + new KeyboardEvent('keydown', { key: 'ArrowLeft' }) + ); + // delta = -10; newColumnWidth = 90 + expect(th.style.width).toBe('90px'); + }); + + it('should use large step when Shift is held', () => { + mockTreeTable.columnResizeMode = 'expand'; + mockTreeTable.scrollable = false; + (directive as any)._onResizerKeydown( + new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true }) + ); + // delta = Math.round(1 * 3.125 * 16) = 50; newColumnWidth = 150 + expect(th.style.width).toBe('150px'); + }); + + it('should adjust tableEl.style.width in expand mode', () => { + mockTreeTable.columnResizeMode = 'expand'; + mockTreeTable.scrollable = false; + const tableEl = mockTreeTable.tableViewChild.nativeElement; + Object.defineProperty(tableEl, 'offsetWidth', { + value: 800, + configurable: true + }); + (directive as any)._onResizerKeydown( + new KeyboardEvent('keydown', { key: 'ArrowRight' }) + ); + // tableWidth = 800 + 10 = 810 + expect(tableEl.style.width).toBe('810px'); + }); + + it('should not resize when newColumnWidth < 15', () => { + mockTreeTable.columnResizeMode = 'expand'; + mockTreeTable.scrollable = false; + 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(th.style.width).toBe(''); + }); + + it('should emit onColResize with element and delta in expand mode', () => { + mockTreeTable.columnResizeMode = 'expand'; + mockTreeTable.scrollable = false; + (directive as any)._onResizerKeydown( + new KeyboardEvent('keydown', { key: 'ArrowRight' }) + ); + expect(mockTreeTable.onColResize.emit).toHaveBeenCalledWith({ + element: th, + delta: 10 + }); + }); + + it('should not resize in fit mode when there is no next sibling', () => { + mockTreeTable.columnResizeMode = 'fit'; + mockTreeTable.scrollable = false; + (directive as any)._onResizerKeydown( + new KeyboardEvent('keydown', { key: 'ArrowRight' }) + ); + expect(th.style.width).toBe(''); + expect(mockTreeTable.onColResize.emit).not.toHaveBeenCalled(); + }); + + it('should set both th and nextTh widths in fit mode', () => { + mockTreeTable.columnResizeMode = 'fit'; + mockTreeTable.scrollable = false; + 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(th.style.width).toBe('110px'); + expect(nextTh.style.width).toBe('190px'); + + nextTh.remove(); + }); + + it('should not resize in fit mode when nextColumnWidth < 15', () => { + mockTreeTable.columnResizeMode = 'fit'; + mockTreeTable.scrollable = false; + 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(th.style.width).toBe(''); + expect(nextTh.style.width).toBe(''); + + nextTh.remove(); + }); + + it('should emit onColResize in fit mode', () => { + mockTreeTable.columnResizeMode = 'fit'; + mockTreeTable.scrollable = false; + 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' }) + ); + expect(mockTreeTable.onColResize.emit).toHaveBeenCalledWith({ + element: th, + delta: 10 + }); + + nextTh.remove(); + }); + }); + + describe('onDestroy', () => { + it('should call super.onDestroy', () => { + directive.onDestroy(); + expect(TTResizableColumn.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-tree-table/directives/cps-tree-table-column-resizable.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-resizable/cps-tree-table-column-resizable.directive.ts similarity index 98% rename from projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-resizable.directive.ts rename to projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-resizable/cps-tree-table-column-resizable.directive.ts index f6584cc68..a975cbfd9 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-resizable.directive.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-resizable/cps-tree-table-column-resizable.directive.ts @@ -1,13 +1,12 @@ import { Directive, Input, inject } from '@angular/core'; import { TTResizableColumn } from 'primeng/treetable'; -import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../../services/cps-root-font-size/cps-root-font-size.service'; +import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../../../services/cps-root-font-size/cps-root-font-size.service'; /** * CpsTreeTableColumnResizableDirective is a directive to enable column resizing in a treetable. * @group Directives */ @Directive({ - standalone: true, selector: '[cpsTTColResizable]' }) export class CpsTreeTableColumnResizableDirective extends TTResizableColumn { diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-sortable/cps-tree-table-column-sortable.directive.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-sortable/cps-tree-table-column-sortable.directive.spec.ts new file mode 100644 index 000000000..b5c822ec6 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-sortable/cps-tree-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 { TreeTable } from 'primeng/treetable'; +import { CpsTreeTableColumnSortableDirective } from './cps-tree-table-column-sortable.directive'; + +@Component({ + template: `Name`, + imports: [CpsTreeTableColumnSortableDirective] +}) +class TestHostComponent { + @ViewChild(CpsTreeTableColumnSortableDirective) + directive!: CpsTreeTableColumnSortableDirective; + + field = 'name'; +} + +describe('CpsTreeTableColumnSortableDirective', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let directive: CpsTreeTableColumnSortableDirective; + let mockTreeTable: ReturnType; + let sortSourceSubject: Subject; + + function buildMockTreeTable() { + 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() } + }; + } + + beforeEach(async () => { + mockTreeTable = buildMockTreeTable(); + + await TestBed.configureTestingModule({ + imports: [TestHostComponent, NoopAnimationsModule], + providers: [{ provide: TreeTable, useValue: mockTreeTable }] + }).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 cpsTTColSortable 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 unknown as { _sortSub: unknown })._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', () => { + mockTreeTable.sortMode = 'single'; + mockTreeTable.sortOrder = 1; + mockTreeTable.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', () => { + mockTreeTable.sortMode = 'single'; + mockTreeTable.sortOrder = -1; + mockTreeTable.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', () => { + mockTreeTable.sortMode = 'single'; + mockTreeTable.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', () => { + mockTreeTable.sortMode = 'multiple'; + mockTreeTable.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', () => { + mockTreeTable.sortMode = 'multiple'; + mockTreeTable.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', () => { + mockTreeTable.sortMode = 'multiple'; + mockTreeTable.getSortMeta.mockReturnValue(null); + sortSourceSubject.next(null); + expect(th.getAttribute('aria-sort')).toBe('none'); + }); + + it('should re-evaluate aria-sort each time sortSource$ emits', () => { + mockTreeTable.sortMode = 'single'; + mockTreeTable.sortOrder = 1; + mockTreeTable.isSorted.mockReturnValue(true); + sortSourceSubject.next(null); + expect(th.getAttribute('aria-sort')).toBe('ascending'); + + mockTreeTable.isSorted.mockReturnValue(false); + sortSourceSubject.next(null); + expect(th.getAttribute('aria-sort')).toBe('none'); + }); + }); + + describe('onClick', () => { + beforeEach(() => { + jest.spyOn(DomHandler, 'clearSelection').mockImplementation(() => {}); + }); + + it('should call tt.sort with the current field', () => { + directive.onClick(new MouseEvent('click')); + expect(mockTreeTable.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(mockTreeTable.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(mockTreeTable.sort).toHaveBeenCalledWith({ field: 'name' }); + }); + }); + + describe('ngOnDestroy', () => { + it('should unsubscribe from sortSource$', () => { + const sub = ( + directive as unknown as { _sortSub: { unsubscribe: () => void } } + )._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'); + + mockTreeTable.isSorted.mockReturnValue(false); + sortSourceSubject.next(null); + + expect(th.getAttribute('aria-sort')).toBe(valueBefore); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-sortable.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-sortable/cps-tree-table-column-sortable.directive.ts similarity index 94% rename from projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-sortable.directive.ts rename to projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-sortable/cps-tree-table-column-sortable.directive.ts index 422d86f46..b8b3a1f5f 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-sortable.directive.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-column-sortable/cps-tree-table-column-sortable.directive.ts @@ -10,14 +10,13 @@ import { import { DomHandler } from 'primeng/dom'; import { TreeTable } from 'primeng/treetable'; import { Subscription } from 'rxjs'; -import { CpsSortIconComponent } from '../../cps-table/components/internal/cps-sort-icon/cps-sort-icon.component'; +import { CpsSortIconComponent } from '../../../cps-table/components/internal/cps-sort-icon/cps-sort-icon.component'; /** * CpsTreeTableColumnSortableDirective is a sorting directive used to sort single or multiple columns in treetable. * @group Directives */ @Directive({ - standalone: true, selector: '[cpsTTColSortable]', host: { class: 'p-sortable-column', diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-header-selectable/cps-tree-table-header-selectable.directive.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-header-selectable/cps-tree-table-header-selectable.directive.spec.ts new file mode 100644 index 000000000..5cbc741f6 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-header-selectable/cps-tree-table-header-selectable.directive.spec.ts @@ -0,0 +1,118 @@ +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 { TTHeaderCheckbox } from 'primeng/treetable'; +import { CpsTreeTableHeaderSelectableDirective } from './cps-tree-table-header-selectable.directive'; + +@Component({ + template: ``, + imports: [CpsTreeTableHeaderSelectableDirective] +}) +class TestHostComponent { + @ViewChild(CpsTreeTableHeaderSelectableDirective) + directive!: CpsTreeTableHeaderSelectableDirective; +} + +@Component({ template: '' }) +class VcrProbeComponent { + readonly vcr = inject(ViewContainerRef); +} + +describe('CpsTreeTableHeaderSelectableDirective', () => { + let fixture: ComponentFixture; + let directive: CpsTreeTableHeaderSelectableDirective; + 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-treetableheadercheckbox'; + + 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 TTHeaderCheckbox', () => { + expect(createComponentSpy).toHaveBeenCalledWith(TTHeaderCheckbox); + }); + + it('should store the ComponentRef in checkboxCompRef', () => { + expect(directive.checkboxCompRef).toBe(mockCheckboxRef); + }); + }); + + describe('ngOnInit', () => { + it('should add cps-treetable-selectable-cell class to the host element', () => { + const th = fixture.debugElement.query(By.css('th')) + .nativeElement as HTMLElement; + expect(th.classList.contains('cps-treetable-selectable-cell')).toBe(true); + }); + + it('should set pt input with aria-label on the checkbox component', () => { + expect(mockCheckboxRef.setInput).toHaveBeenCalledWith('pt', { + pcHeaderCheckbox: { input: { 'aria-label': '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-tree-table/directives/cps-tree-table-header-selectable.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-header-selectable/cps-tree-table-header-selectable.directive.ts similarity index 98% rename from projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-header-selectable.directive.ts rename to projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-header-selectable/cps-tree-table-header-selectable.directive.ts index b5de35c80..f3dbbb407 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-header-selectable.directive.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-header-selectable/cps-tree-table-header-selectable.directive.ts @@ -13,7 +13,6 @@ import { TTHeaderCheckbox } from 'primeng/treetable'; * @group Directives */ @Directive({ - standalone: true, selector: '[cpsTTHdrSelectable]' }) export class CpsTreeTableHeaderSelectableDirective diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-selectable/cps-tree-table-row-selectable.directive.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-selectable/cps-tree-table-row-selectable.directive.spec.ts new file mode 100644 index 000000000..ea4031863 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-selectable/cps-tree-table-row-selectable.directive.spec.ts @@ -0,0 +1,136 @@ +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 { TTCheckbox } from 'primeng/treetable'; +import { CpsTreeTableRowSelectableDirective } from './cps-tree-table-row-selectable.directive'; + +@Component({ + template: ``, + imports: [CpsTreeTableRowSelectableDirective] +}) +class TestHostComponent { + @ViewChild(CpsTreeTableRowSelectableDirective) + directive!: CpsTreeTableRowSelectableDirective; + + value: unknown = 'row-1'; +} + +@Component({ template: '' }) +class VcrProbeComponent { + readonly vcr = inject(ViewContainerRef); +} + +describe('CpsTreeTableRowSelectableDirective', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let directive: CpsTreeTableRowSelectableDirective; + 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-treetablecheckbox'; + + 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 TTCheckbox', () => { + expect(createComponentSpy).toHaveBeenCalledWith(TTCheckbox); + }); + + it('should store the ComponentRef in checkboxCompRef', () => { + expect(directive.checkboxCompRef).toBe(mockCheckboxRef); + }); + }); + + describe('value input', () => { + it('should accept value via cpsTTRowSelectable 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 add cps-treetable-selectable-cell class to the host element', () => { + const td = fixture.debugElement.query(By.css('td')) + .nativeElement as HTMLElement; + expect(td.classList.contains('cps-treetable-selectable-cell')).toBe(true); + }); + + it('should set value input on the checkbox component', () => { + expect(mockCheckboxRef.setInput).toHaveBeenCalledWith('value', 'row-1'); + }); + + it('should set pt input with aria-label and tabindex on the checkbox component', () => { + expect(mockCheckboxRef.setInput).toHaveBeenCalledWith('pt', { + pcRowCheckbox: { input: { 'aria-label': 'Select row', tabindex: '0' } } + }); + }); + + 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-tree-table/directives/cps-tree-table-row-selectable.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-selectable/cps-tree-table-row-selectable.directive.ts similarity index 98% rename from projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-selectable.directive.ts rename to projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-selectable/cps-tree-table-row-selectable.directive.ts index 27cd7d250..3282d31d5 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-selectable.directive.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-selectable/cps-tree-table-row-selectable.directive.ts @@ -14,7 +14,6 @@ import { TTCheckbox } from 'primeng/treetable'; * @group Directives */ @Directive({ - standalone: true, selector: '[cpsTTRowSelectable]' }) export class CpsTreeTableRowSelectableDirective implements OnInit, OnDestroy { diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-toggler/cps-tree-table-row-toggler.directive.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-toggler/cps-tree-table-row-toggler.directive.spec.ts new file mode 100644 index 000000000..a9ebdb3f9 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-toggler/cps-tree-table-row-toggler.directive.spec.ts @@ -0,0 +1,175 @@ +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 { TreeTableToggler } from 'primeng/treetable'; +import { CpsTreetableRowTogglerDirective } from './cps-tree-table-row-toggler.directive'; + +@Component({ + template: ` + Content + `, + imports: [CpsTreetableRowTogglerDirective] +}) +class TestHostComponent { + @ViewChild(CpsTreetableRowTogglerDirective) + directive!: CpsTreetableRowTogglerDirective; + + rowNode: unknown = { node: { data: 'row-1' } }; +} + +@Component({ template: '' }) +class VcrProbeComponent { + readonly vcr = inject(ViewContainerRef); +} + +describe('CpsTreetableRowTogglerDirective', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let directive: CpsTreetableRowTogglerDirective; + let mockTogglerRef: { + setInput: jest.Mock; + destroy: jest.Mock; + location: { nativeElement: HTMLElement }; + }; + let createComponentSpy: jest.SpyInstance; + + beforeEach(async () => { + const togglerEl = document.createElement('button'); + togglerEl.className = 'p-treetable-toggler'; + + mockTogglerRef = { + setInput: jest.fn(), + destroy: jest.fn(), + location: { nativeElement: togglerEl } + }; + + 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( + mockTogglerRef 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 TreeTableToggler', () => { + expect(createComponentSpy).toHaveBeenCalledWith(TreeTableToggler); + }); + + it('should store the ComponentRef in togglerCompRef', () => { + expect(directive.togglerCompRef).toBe(mockTogglerRef); + }); + }); + + describe('rowNode input', () => { + it('should accept rowNode via cpsTTRowToggler alias', () => { + expect(directive.rowNode).toEqual({ node: { data: 'row-1' } }); + }); + + it('should update rowNode when the input changes', () => { + host.rowNode = { node: { data: 'row-2' } }; + fixture.detectChanges(); + expect(directive.rowNode).toEqual({ node: { data: 'row-2' } }); + }); + }); + + describe('ngOnInit', () => { + it('should add cps-treetable-row-toggler-cell class to the host element', () => { + const td = fixture.debugElement.query(By.css('td')) + .nativeElement as HTMLElement; + expect(td.classList.contains('cps-treetable-row-toggler-cell')).toBe( + true + ); + }); + + it('should set rowNode input on the toggler component', () => { + expect(mockTogglerRef.setInput).toHaveBeenCalledWith('rowNode', { + node: { data: 'row-1' } + }); + }); + + it('should create a wrapper span with display flex inside the host td', () => { + const td = fixture.debugElement.query(By.css('td')) + .nativeElement as HTMLElement; + const spans = Array.from(td.children).filter( + (el) => + el.tagName === 'SPAN' && (el as HTMLElement).style.display === 'flex' + ) as HTMLElement[]; + expect(spans.length).toBeGreaterThan(0); + }); + + it('should move existing cell content into the wrapper span', () => { + const td = fixture.debugElement.query(By.css('td')) + .nativeElement as HTMLElement; + const wrapperSpan = Array.from(td.children).find( + (el) => + el.tagName === 'SPAN' && (el as HTMLElement).style.display === 'flex' + ) as HTMLElement; + const cellContent = wrapperSpan.querySelector('.cell-content'); + expect(cellContent).not.toBeNull(); + }); + + it('should prepend the toggler element as the first child of the wrapper span', () => { + const td = fixture.debugElement.query(By.css('td')) + .nativeElement as HTMLElement; + const wrapperSpan = Array.from(td.children).find( + (el) => + el.tagName === 'SPAN' && (el as HTMLElement).style.display === 'flex' + ) as HTMLElement; + expect(wrapperSpan.firstChild).toBe( + mockTogglerRef.location.nativeElement + ); + }); + + it('should append the wrapper span as a child of the host td', () => { + const td = fixture.debugElement.query(By.css('td')) + .nativeElement as HTMLElement; + const wrapperSpan = Array.from(td.children).find( + (el) => + el.tagName === 'SPAN' && (el as HTMLElement).style.display === 'flex' + ); + expect(wrapperSpan).toBeTruthy(); + }); + }); + + describe('ngOnDestroy', () => { + it('should call togglerCompRef.destroy', () => { + directive.ngOnDestroy(); + expect(mockTogglerRef.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-tree-table/directives/cps-tree-table-row-toggler.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-toggler/cps-tree-table-row-toggler.directive.ts similarity index 98% rename from projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-toggler.directive.ts rename to projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-toggler/cps-tree-table-row-toggler.directive.ts index 3e2cfa6c0..550482f69 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-toggler.directive.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/cps-tree-table-row-toggler/cps-tree-table-row-toggler.directive.ts @@ -16,7 +16,6 @@ import { TreeTableToggler } from 'primeng/treetable'; * @group Directives */ @Directive({ - standalone: true, selector: '[cpsTTRowToggler]' }) export class CpsTreetableRowTogglerDirective implements OnInit, OnDestroy { diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/pipes/cps-tree-table-detect-filter-type/cps-tree-table-detect-filter-type.pipe.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/pipes/cps-tree-table-detect-filter-type/cps-tree-table-detect-filter-type.pipe.spec.ts new file mode 100644 index 000000000..040a5ca78 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/pipes/cps-tree-table-detect-filter-type/cps-tree-table-detect-filter-type.pipe.spec.ts @@ -0,0 +1,136 @@ +import { CpsTreeTableDetectFilterTypePipe } from './cps-tree-table-detect-filter-type.pipe'; + +describe('CpsTreeTableDetectFilterTypePipe', () => { + let pipe: CpsTreeTableDetectFilterTypePipe; + + beforeEach(() => { + pipe = new CpsTreeTableDetectFilterTypePipe(); + }); + + it('should create', () => { + expect(pipe).toBeTruthy(); + }); + + describe('boolean', () => { + it('should return "boolean" when all values are booleans', () => { + const nodes = [ + { data: { active: true } }, + { data: { active: false } }, + { data: { active: true } } + ]; + expect(pipe.transform(nodes, 'active')).toBe('boolean'); + }); + + it('should return "boolean" for an array of all-true values', () => { + const nodes = [{ data: { flag: true } }, { data: { flag: true } }]; + expect(pipe.transform(nodes, 'flag')).toBe('boolean'); + }); + }); + + describe('number', () => { + it('should return "number" when all values are numbers', () => { + const nodes = [ + { data: { age: 1 } }, + { data: { age: 2 } }, + { data: { age: 3 } } + ]; + expect(pipe.transform(nodes, 'age')).toBe('number'); + }); + + it('should prefer "number" over "category" when all values are numbers (even fewer than 6)', () => { + const nodes = [{ data: { score: 10 } }, { data: { score: 20 } }]; + expect(pipe.transform(nodes, 'score')).toBe('number'); + }); + }); + + describe('date', () => { + it('should return "date" when all values are Date instances', () => { + const nodes = [ + { data: { created: new Date('2024-01-01') } }, + { data: { created: new Date('2024-02-01') } }, + { data: { created: new Date('2024-03-01') } } + ]; + expect(pipe.transform(nodes, 'created')).toBe('date'); + }); + + it('should prefer "date" over "category" when all values are Dates (even fewer than 6)', () => { + const nodes = [ + { data: { ts: new Date() } }, + { data: { ts: new Date() } } + ]; + expect(pipe.transform(nodes, 'ts')).toBe('date'); + }); + + it('should return "date" for 6+ unique Date values', () => { + const nodes = Array.from({ length: 6 }, (_, i) => ({ + data: { ts: new Date(2024, i, 1) } + })); + expect(pipe.transform(nodes, 'ts')).toBe('date'); + }); + }); + + describe('category', () => { + it('should return "category" when there are fewer than 6 unique string values', () => { + const nodes = [ + { data: { status: 'active' } }, + { data: { status: 'inactive' } }, + { data: { status: 'active' } }, + { data: { status: 'pending' } } + ]; + expect(pipe.transform(nodes, 'status')).toBe('category'); + }); + + it('should return "category" at exactly 5 unique values', () => { + const nodes = ['a', 'b', 'c', 'd', 'e'].map((v) => ({ + data: { col: v } + })); + expect(pipe.transform(nodes, 'col')).toBe('category'); + }); + }); + + describe('text', () => { + it('should return "text" when there are 6 or more unique string values', () => { + const nodes = ['a', 'b', 'c', 'd', 'e', 'f'].map((v) => ({ + data: { col: v } + })); + expect(pipe.transform(nodes, 'col')).toBe('text'); + }); + + it('should return "text" for many diverse string values', () => { + const nodes = Array.from({ length: 10 }, (_, i) => ({ + data: { name: `name-${i}` } + })); + expect(pipe.transform(nodes, 'name')).toBe('text'); + }); + + it('should return "text" when values are mixed types with 6+ unique values', () => { + const nodes = [ + { data: { val: 1 } }, + { data: { val: 'two' } }, + { data: { val: true } }, + { data: { val: 'four' } }, + { data: { val: 5 } }, + { data: { val: 'six' } } + ]; + expect(pipe.transform(nodes, 'val')).toBe('text'); + }); + }); + + describe('edge cases', () => { + it('should return "boolean" for empty nodes array', () => { + expect(pipe.transform([], 'any')).toBe('boolean'); + }); + + it('should return "text" when column values include 6+ unique mixed-type items', () => { + const nodes = [ + { data: { val: 'alpha' } }, + { data: { val: 'beta' } }, + { data: { val: 'gamma' } }, + { data: { val: 'delta' } }, + { data: { val: 'epsilon' } }, + { data: { val: 'zeta' } } + ]; + expect(pipe.transform(nodes, 'val')).toBe('text'); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/pipes/cps-tree-table-detect-filter-type.pipe.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/pipes/cps-tree-table-detect-filter-type/cps-tree-table-detect-filter-type.pipe.ts similarity index 84% rename from projects/cps-ui-kit/src/lib/components/cps-tree-table/pipes/cps-tree-table-detect-filter-type.pipe.ts rename to projects/cps-ui-kit/src/lib/components/cps-tree-table/pipes/cps-tree-table-detect-filter-type/cps-tree-table-detect-filter-type.pipe.ts index c7235984a..c193f5de6 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/pipes/cps-tree-table-detect-filter-type.pipe.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/pipes/cps-tree-table-detect-filter-type/cps-tree-table-detect-filter-type.pipe.ts @@ -1,9 +1,8 @@ import { Pipe, PipeTransform } from '@angular/core'; -import { CpsColumnFilterType } from '../../cps-table/cps-column-filter-types'; +import { CpsColumnFilterType } from '../../../cps-table/cps-column-filter-types'; @Pipe({ - name: 'cpsTTDetectFilterType', - standalone: true + name: 'cpsTTDetectFilterType' }) export class CpsTreeTableDetectFilterTypePipe implements PipeTransform { transform( diff --git a/projects/cps-ui-kit/src/public-api.ts b/projects/cps-ui-kit/src/public-api.ts index 6186d6c2d..9b50661b1 100644 --- a/projects/cps-ui-kit/src/public-api.ts +++ b/projects/cps-ui-kit/src/public-api.ts @@ -42,13 +42,13 @@ export * from './lib/components/cps-timepicker/cps-timepicker.component'; export * from './lib/components/cps-tree-autocomplete/cps-tree-autocomplete.component'; export * from './lib/components/cps-tree-select/cps-tree-select.component'; export * from './lib/components/cps-tree-table/cps-tree-table.component'; -export * from './lib/components/cps-tree-table/directives/cps-tree-table-column-filter.directive'; -export * from './lib/components/cps-tree-table/directives/cps-tree-table-column-resizable.directive'; -export * from './lib/components/cps-tree-table/directives/cps-tree-table-column-sortable.directive'; -export * from './lib/components/cps-tree-table/directives/cps-tree-table-header-selectable.directive'; -export * from './lib/components/cps-tree-table/directives/cps-tree-table-row-selectable.directive'; -export * from './lib/components/cps-tree-table/directives/cps-tree-table-row-toggler.directive'; -export * from './lib/components/cps-tree-table/pipes/cps-tree-table-detect-filter-type.pipe'; +export * from './lib/components/cps-tree-table/directives/cps-tree-table-column-filter/cps-tree-table-column-filter.directive'; +export * from './lib/components/cps-tree-table/directives/cps-tree-table-column-resizable/cps-tree-table-column-resizable.directive'; +export * from './lib/components/cps-tree-table/directives/cps-tree-table-column-sortable/cps-tree-table-column-sortable.directive'; +export * from './lib/components/cps-tree-table/directives/cps-tree-table-header-selectable/cps-tree-table-header-selectable.directive'; +export * from './lib/components/cps-tree-table/directives/cps-tree-table-row-selectable/cps-tree-table-row-selectable.directive'; +export * from './lib/components/cps-tree-table/directives/cps-tree-table-row-toggler/cps-tree-table-row-toggler.directive'; +export * from './lib/components/cps-tree-table/pipes/cps-tree-table-detect-filter-type/cps-tree-table-detect-filter-type.pipe'; export * from './lib/directives/cps-tooltip/cps-tooltip.directive'; From 13ea258aa353e38c334d9db9766e9b36fa7dc0fa Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Wed, 1 Jul 2026 14:06:32 +0200 Subject: [PATCH 3/5] update toolbar buttons --- .../src/app/api-data/cps-button.json | 16 ++++++ .../cps-button/cps-button.component.html | 2 + .../cps-button/cps-button.component.ts | 12 +++++ ...le-column-visibility-toggle.component.html | 24 ++++----- ...le-column-visibility-toggle.component.scss | 10 ++++ ...column-visibility-toggle.component.spec.ts | 8 ++- ...able-column-visibility-toggle.component.ts | 9 ++-- .../cps-table/cps-table.component.html | 49 ++++++++---------- .../cps-table/cps-table.component.scss | 48 ++++-------------- .../cps-table/cps-table.component.ts | 9 ++++ .../cps-tree-table.component.html | 50 +++++++------------ .../cps-tree-table.component.scss | 46 ++++------------- 12 files changed, 123 insertions(+), 160 deletions(-) diff --git a/projects/composition/src/app/api-data/cps-button.json b/projects/composition/src/app/api-data/cps-button.json index ccfd635f5..33086327d 100644 --- a/projects/composition/src/app/api-data/cps-button.json +++ b/projects/composition/src/app/api-data/cps-button.json @@ -61,6 +61,22 @@ "default": "", "description": "Aria label for the button, used for accessibility, it takes precedence over label." }, + { + "name": "ariaHaspopup", + "optional": false, + "readonly": false, + "type": "string | null", + "default": "null", + "description": "Indicates that the button controls a popup element." + }, + { + "name": "ariaExpanded", + "optional": false, + "readonly": false, + "type": "boolean | null", + "default": "null", + "description": "Indicates whether the controlled popup element is expanded or collapsed." + }, { "name": "icon", "optional": false, diff --git a/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.html b/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.html index 3837a9dae..2cf58fbef 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.html @@ -6,6 +6,8 @@ (click)="onClick($event)" [attr.aria-busy]="loading ? true : null" [attr.aria-label]="ariaLabel || label || null" + [attr.aria-haspopup]="ariaHaspopup" + [attr.aria-expanded]="ariaExpanded" [class.loading]="loading" [class.cps-button--key-active]="enterActive" (keydown.enter)="onEnterKeydown()" diff --git a/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.ts b/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.ts index 148158aca..f4a78e824 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.ts @@ -69,6 +69,18 @@ export class CpsButtonComponent implements OnInit, OnChanges { */ @Input() ariaLabel = ''; + /** + * Indicates that the button controls a popup element. + * @group Props + */ + @Input() ariaHaspopup: string | null = null; + + /** + * Indicates whether the controlled popup element is expanded or collapsed. + * @group Props + */ + @Input() ariaExpanded: boolean | null = null; + /** * Name of the icon on the button. * @group Props 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 index 44217fddd..8afc862b7 100644 --- 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 @@ -1,17 +1,15 @@ - + type="borderless" + color="calm" + (clicked)="onToggle($event)"> + + { 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); + fixture.nativeElement.querySelector('.cps-table-tbar-coltoggle-btn') + ).toBeTruthy(); }); }); describe('toggle button', () => { it('should render the toggle button', () => { - const btn = fixture.nativeElement.querySelector( - 'button.cps-table-tbar-icon-btn' - ); + const btn = fixture.nativeElement.querySelector('cps-button'); expect(btn).toBeTruthy(); }); 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 index 3ae1a3231..1c90a144e 100644 --- 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 @@ -11,7 +11,7 @@ import { 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 { CpsButtonComponent } from '../../../../cps-button/cps-button.component'; import { generateUniqueId } from '../../../../../utils/internal/accessibility-utils'; /** @@ -20,13 +20,10 @@ import { generateUniqueId } from '../../../../../utils/internal/accessibility-ut */ @Component({ selector: 'table-column-visibility-toggle', - imports: [CommonModule, CpsMenuComponent, CpsIconComponent], + imports: [CommonModule, CpsMenuComponent, CpsButtonComponent], templateUrl: './table-column-visibility-toggle.component.html', styleUrls: ['./table-column-visibility-toggle.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - host: { - class: 'cps-table-tbar-coltoggle-btn' - } + changeDetection: ChangeDetectionStrategy.OnPush }) export class TableColumnVisibilityToggleComponent { columns = input<{ [key: string]: any }[]>([]); 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 763c29af9..472bee6fa 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 @@ -132,47 +132,38 @@ } @if (showExportBtn) {
- + color="calm" + type="solid" + icon="export" + [size]="toolbarSize" + ariaHaspopup="menu" + [ariaExpanded]="isExportMenuOpen" + (clicked)="onExportData($event)"> +
} @if (showDataReloadBtn) {
- + (clicked)="onReloadData()"> +
} @@ -427,7 +418,7 @@ + [style.height]="emptyBodyHeight"> {{ emptyMessage }} diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.scss b/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.scss index 4d44bf4b2..381756892 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.scss @@ -47,6 +47,8 @@ $tbar-normal-height: 4.5rem; } .p-datatable-column-resizer { + border-width: 0.0625rem; + &:focus { outline: none; } @@ -96,48 +98,16 @@ $tbar-normal-height: 4.5rem; .cps-table-tbar-right { display: flex; align-items: center; - .cps-table-tbar-btn-on-select { - margin-right: 0.25rem; - } - .cps-table-tbar-action-btn { + + .cps-table-tbar-action-btn, + .cps-table-tbar-export-btn { margin-right: 0.25rem; - margin-left: 0.5rem; + margin-left: 0.25rem; } - .cps-table-tbar-coltoggle-btn, - .cps-table-tbar-export-btn, + .cps-table-tbar-btn-on-select, .cps-table-tbar-reload-btn { - display: contents; - .cps-table-tbar-icon-btn { - background: none; - border: none; - padding: 0; - color: inherit; - font: inherit; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - margin-left: 0.75rem; - margin-right: 0.25rem; - - &:focus { - outline: none; - } - - &:focus-visible { - @include focus-ring(0.1875rem, 0.25rem, 0.25rem); - } - - &:hover .cps-icon, - &:focus-visible .cps-icon { - color: $color-calm !important; - } - - &:disabled { - cursor: default; - pointer-events: none; - } - } + margin-right: 0.125rem; + margin-left: 0.125rem; } } } 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 7187fcddd..85e6f5057 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 @@ -1147,6 +1147,15 @@ export class CpsTableComponent implements OnInit, AfterViewChecked, OnChanges { this.exportMenu?.toggle(event); } + onExportMenuShown() { + this.isExportMenuOpen = true; + const container = this.exportMenu?.container; + const target = this.exportMenu?.target as HTMLElement | null; + if (container && target) { + container.style.width = target.offsetWidth + 'px'; + } + } + private _getIndexes(rows: any[]) { let indexes: number[] = rows.map((row) => this.primengTable.value.indexOf(row) diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.html b/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.html index f72779622..817fd15e1 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.html @@ -2,12 +2,10 @@ #primengTreeTable ttWithUnsort [pt]="treeTablePassthrough" - [ngClass]="{ - 'cps-treetable-loading': loading, - 'cps-treetable-flex': defScrollHeight === 'flex', - 'cps-treetable-nonvirtual': !virtualScroll, - 'cps-treetable-autolayout-calced': autoLayout && scrollable - }" + [class.cps-treetable-loading]="loading" + [class.cps-treetable-flex]="defScrollHeight === 'flex'" + [class.cps-treetable-nonvirtual]="!virtualScroll" + [class.cps-treetable-autolayout-calced]="autoLayout && scrollable" [value]="data" [autoLayout]="autoLayout && !scrollable" [scrollable]="scrollable" @@ -132,9 +130,7 @@ } @if (showExportBtn) { -
+
} @if (showDataReloadBtn) { -
- +
+ +
}
@@ -289,10 +278,9 @@ let-rowIndex="rowIndex"> + [class.cps-treetable-row-selected]=" + selectable && primengTreeTable.isSelected(rowNode.node) + "> @@ -373,7 +361,7 @@ colspan="100" role="status" class="cps-treetable-empty-message-td" - [ngStyle]="{ height: emptyBodyHeight }"> + [style.height]="emptyBodyHeight"> {{ emptyMessage }} diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.scss b/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.scss index 4271a23b2..32ebabe87 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.scss @@ -514,46 +514,16 @@ $tbar-normal-height: 4.5rem; .cps-treetable-tbar-right { display: flex; align-items: center; - .cps-treetable-tbar-btn-on-select { - margin-right: 0.25rem; - } - .cps-treetable-tbar-action-btn { + + .cps-treetable-tbar-action-btn, + .cps-treetable-tbar-export-btn { margin-right: 0.25rem; - margin-left: 0.5rem; + margin-left: 0.25rem; } - .cps-table-tbar-coltoggle-btn, + .cps-treetable-tbar-btn-on-select, .cps-treetable-tbar-reload-btn { - display: contents; - } - - .cps-table-tbar-coltoggle-btn .cps-table-tbar-icon-btn, - .cps-treetable-tbar-reload-btn .cps-treetable-tbar-icon-btn { - background: none; - border: none; - padding: 0; - color: inherit; - font: inherit; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - margin-left: 0.75rem; - margin-right: 0.25rem; - overflow: visible; - &:hover .cps-icon, - &:focus-visible .cps-icon { - color: $color-calm !important; - } - &:disabled { - cursor: default; - pointer-events: none; - } - &:focus { - outline: none; - } - &:focus-visible { - @include focus-ring(0.1875rem, 0.25rem, 0.25rem); - } + margin-right: 0.125rem; + margin-left: 0.125rem; } } } @@ -685,6 +655,8 @@ $tbar-normal-height: 4.5rem; } .p-treetable .p-column-resizer { + border-width: 0.0625rem; + &:focus { outline: none; } From 787025f8a3757502798c3a912a9aa08e4aab97b8 Mon Sep 17 00:00:00 2001 From: Andrei Fateev Date: Wed, 1 Jul 2026 14:13:30 +0200 Subject: [PATCH 4/5] update menu page --- .../pages/menu-page/menu-page.component.html | 53 ++++++++++++++++--- .../pages/menu-page/menu-page.component.ts | 8 +++ 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/projects/composition/src/app/pages/menu-page/menu-page.component.html b/projects/composition/src/app/pages/menu-page/menu-page.component.html index 343e13f5c..02877be5b 100644 --- a/projects/composition/src/app/pages/menu-page/menu-page.component.html +++ b/projects/composition/src/app/pages/menu-page/menu-page.component.html @@ -1,17 +1,31 @@