diff --git a/apps/table-test/src/app/app.component.html b/apps/table-test/src/app/app.component.html index a7d0aaca..5a115e12 100644 --- a/apps/table-test/src/app/app.component.html +++ b/apps/table-test/src/app/app.component.html @@ -18,6 +18,7 @@ [data]="data | async" [columns]="columns" [selectable]="true" + [currentSorting]="currentSort" selectableKey="id" [resetFormOnNewData]="false" (rowClicked)="rowEmitted($event)" @@ -38,14 +39,26 @@ } - + First name - + Amount + + + + + + + Loading diff --git a/apps/table-test/src/app/app.component.ts b/apps/table-test/src/app/app.component.ts index 3002d6d1..3cf8d276 100644 --- a/apps/table-test/src/app/app.component.ts +++ b/apps/table-test/src/app/app.component.ts @@ -3,7 +3,7 @@ import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { BehaviorSubject } from 'rxjs'; import { AsyncPipe, JsonPipe } from '@angular/common'; import { WrapperComponent } from './wrapper/wrapper.component'; -import { NgxCurrencyTableCellComponent, NgxTable } from '@ngx/table'; +import { NgxCurrencyTableCellComponent, NgxTable, NgxTableSortEvent } from '@ngx/table'; @Component({ selector: 'app-root', @@ -21,6 +21,7 @@ import { NgxCurrencyTableCellComponent, NgxTable } from '@ngx/table'; }) export class AppComponent { private currentSet = 'dataSet1'; + public currentSort: NgxTableSortEvent; public dataSet1 = [ { @@ -39,6 +40,14 @@ export class AppComponent { hello: 'world', amount: 5000, }, + { + name: 'Hyperdrive', + firstName: 'Studio', + active: true, + id: 'SHD2', + hello: 'world', + amount: 5000, + }, ]; public dataSet2 = [ @@ -54,7 +63,7 @@ export class AppComponent { public data = new BehaviorSubject(this.dataSet1); - public readonly columns = ['firstName', 'name', 'amount', 'active']; + public readonly columns = ['firstName', 'name', 'button', 'amount', 'active']; public showDetail = true; @@ -85,4 +94,8 @@ export class AppComponent { public rowEmitted(data: any) { console.log(data); } + + public sort(event: NgxTableSortEvent) { + this.currentSort = event; + } } diff --git a/libs/table/README.md b/libs/table/README.md index 2b326cc6..c7dd6b8b 100644 --- a/libs/table/README.md +++ b/libs/table/README.md @@ -385,6 +385,10 @@ If you wish to provide a custom class to the row of your tables, you can provide Cells can also have a default class we want to provide to all cells of that kind. All `ngx-date-table-cell` cells have the `ngx-date-table-cell` class and the same applies for the `ngx-currency-table-cell`. +### Accessibility + +`ngx-table` provides a WCAG and ARIA compliant implementation to tables. When a table has a detail row or is selectable, the role of the table switches from `table` to `treegrid`. Because of that, `ngx-table` follows the [WAI ARIA Treegrid Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/). + ## Acknowledgements A big thanks goes out to [Sam Verschueren](https://github.com/SamVerschueren) for his help with the initial implementation of this table. Without his help, this table would not have existed. diff --git a/libs/table/package.json b/libs/table/package.json index e068c5e5..96912506 100644 --- a/libs/table/package.json +++ b/libs/table/package.json @@ -9,7 +9,11 @@ "angular cdk", "cdk", "table", - "detail row" + "detail row", + "wai", + "aria", + "wcag", + "treegrid" ], "homepage": "https://github.com/studiohyperdrive/ngx-tools/tree/master/libs/table", "author": { diff --git a/libs/table/src/lib/cell/cell.directive.ts b/libs/table/src/lib/cell/cell.directive.ts index 535d8d06..e1d33355 100644 --- a/libs/table/src/lib/cell/cell.directive.ts +++ b/libs/table/src/lib/cell/cell.directive.ts @@ -8,6 +8,23 @@ import { NgxTableCypressDataTags, NgxTableSortEvent } from '../interfaces'; standalone: true, }) export class NgxAbstractTableCellDirective { + /** + * The current sortDirection of the cell + */ + public sortDirection: NgxTableSortDirection | null = null; + + /** + * The templates used to set in the table + */ + public footerTemplate: TemplateRef; + public headerTemplate: TemplateRef; + public cellTemplate: TemplateRef; + + /** + * An optional class that can be set for the cell + */ + public cellClass: string; + /** * The name of the column we want this cell to represent */ @@ -24,24 +41,15 @@ export class NgxAbstractTableCellDirective { */ @Input() public cypressDataTags: NgxTableCypressDataTags; - @Output() sort = new EventEmitter(); - /** - * The current sortDirection of the cell + * Whether the content of a cell is editable. By default, this is set to false */ - public sortDirection: NgxTableSortDirection | null = null; + @Input() public editable: boolean = false; /** - * The templates used to set in the table + * Emits the sortable event if a column is sortable */ - public footerTemplate: TemplateRef; - public headerTemplate: TemplateRef; - public cellTemplate: TemplateRef; - - /** - * An optional class that can be set for the cell - */ - public cellClass: string; + @Output() sort = new EventEmitter(); /** * Handles the sorting click events diff --git a/libs/table/src/lib/directives/has-focus-action/has-focus.directive.ts b/libs/table/src/lib/directives/has-focus-action/has-focus.directive.ts new file mode 100644 index 00000000..ff1b3f03 --- /dev/null +++ b/libs/table/src/lib/directives/has-focus-action/has-focus.directive.ts @@ -0,0 +1,39 @@ +import { Directive, HostListener } from '@angular/core'; + +/** + * An abstract directive used as a base to handle focussed base actions + */ +@Directive({ + standalone: true, +}) +export abstract class NgxHasFocusDirective { + /** + * Whether the current element is focussed + */ + protected hasFocus: boolean = false; + + /** + * Set the hasFocus flag + */ + @HostListener('focus') setFocus() { + this.hasFocus = true; + } + + /** + * Remove the hasFocus flag + */ + @HostListener('blur') removeFocus() { + this.hasFocus = false; + } + + /** + * Execute an action when the element has focussed + * + * @param action - The provided action + */ + public handleWhenFocussed(action: () => void): void { + if (this.hasFocus) { + action(); + } + } +} diff --git a/libs/table/src/lib/directives/has-focus-action/index.ts b/libs/table/src/lib/directives/has-focus-action/index.ts new file mode 100644 index 00000000..a35c3847 --- /dev/null +++ b/libs/table/src/lib/directives/has-focus-action/index.ts @@ -0,0 +1 @@ +export * from './has-focus.directive'; diff --git a/libs/table/src/lib/directives/index.ts b/libs/table/src/lib/directives/index.ts new file mode 100644 index 00000000..a4becb24 --- /dev/null +++ b/libs/table/src/lib/directives/index.ts @@ -0,0 +1,2 @@ +export * from './has-focus-action'; +export * from './tree-grid'; diff --git a/libs/table/src/lib/directives/tree-grid/index.ts b/libs/table/src/lib/directives/tree-grid/index.ts new file mode 100644 index 00000000..42720763 --- /dev/null +++ b/libs/table/src/lib/directives/tree-grid/index.ts @@ -0,0 +1,9 @@ +import { NgxTreeGridRowDirective } from './tree-grid-row.directive'; +import { NgxTreeGridCellDirective } from './tree-grid.cell.directive'; +import { NgxTreeGridDirective } from './tree-grid.directive'; + +export const NgxTreeGrid = [ + NgxTreeGridDirective, + NgxTreeGridRowDirective, + NgxTreeGridCellDirective, +]; diff --git a/libs/table/src/lib/directives/tree-grid/tree-grid-row.directive.ts b/libs/table/src/lib/directives/tree-grid/tree-grid-row.directive.ts new file mode 100644 index 00000000..f376f6a2 --- /dev/null +++ b/libs/table/src/lib/directives/tree-grid/tree-grid-row.directive.ts @@ -0,0 +1,272 @@ +import { + Directive, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + OnDestroy, + Output, +} from '@angular/core'; + +import { NgxTreeGridCellTarget, NgxTreeGridRowTarget } from '../../interfaces'; +import { NgxHasFocusDirective } from '../has-focus-action'; +import { NgxTreeGridDirective } from './tree-grid.directive'; +import { NgxTreeGridCellDirective } from './tree-grid.cell.directive'; + +/** + * A row directive to handle navigation according to the WCAG treegrid pattern + * + * See https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/ + */ +@Directive({ + selector: '[ngxTreeGridRow]', + standalone: true, +}) +export class NgxTreeGridRowDirective extends NgxHasFocusDirective implements OnDestroy { + /** + * An array of all cells in the row + */ + private readonly cells: NgxTreeGridCellDirective[] = []; + + /** + * Sets focus on to the previous row + */ + @HostListener('keydown.ArrowUp') private moveUp(): void { + this.handleWhenFocussed(() => { + this.parent.getRow(this.ngxTreeGridRow - 1)?.focus(); + }); + } + + /** + * Sets focus to the next row + */ + @HostListener('keydown.ArrowDown') private moveDown(): void { + this.handleWhenFocussed(() => { + this.parent.getRow(this.ngxTreeGridRow + 1)?.focus(); + }); + } + + /** + * Sets the focus on the first cell in the row or closes the row when expanded + */ + @HostListener('keydown.ArrowRight') private arrowRight(): void { + this.handleWhenFocussed(() => { + // Iben: Only when the row is not expanded we can move to the next cell, as by WCAG design + if (this.parent.ngxTreeGridExpandable && this.ngxTreeGridRowExpanded) { + this.ngxTreeGridRowExpandRow.emit(false); + return; + } + + // Iben: Focus on the first cell + this.cells[0]?.focus(); + }); + } + + /** + * Expand the row when closed + */ + @HostListener('keydown.ArrowLeft') private arrowLeft(): void { + this.handleWhenFocussed(() => { + this.ngxTreeGridRowExpandRow.emit(true); + }); + } + + /** + * Sets focus on the first row of the grid + */ + @HostListener('keydown.PageUp') private moveToTopPageUp(): void { + this.moveToTop(); + } + + /** + * Sets focus on the first row of the grid + */ + @HostListener('keydown.Home') private moveToTopHome(): void { + this.moveToTop(); + } + + /** + * Sets focus on the first row of the grid + */ + @HostListener('keydown.control.Home') private moveToTopControlHome(): void { + this.moveToTop(); + } + + /** + * Sets focus on the last row of the grid + */ + @HostListener('keydown.PageDown') private moveToBottomPageDown(): void { + this.moveToBottom(); + } + + /** + * Sets focus on the last row of the grid + */ + @HostListener('keydown.End') private moveToBottomEnd(): void { + this.moveToBottom(); + } + + /** + * Sets focus on the last row of the grid + */ + @HostListener('keydown.control.End') private moveToBottomControlEnd(): void { + this.moveToBottom(); + } + + /** + * Emits a select event to select a row + */ + @HostListener('keydown.shift.space') private selectRowShift(): void { + this.handleWhenFocussed(() => { + this.selectRow(); + }); + } + + /** + * Emits a select event to select a row + */ + @HostListener('keydown.control.space') private selectRowControl(): void { + this.handleWhenFocussed(() => { + this.selectRow(); + }); + } + + /** + * Emits a select event to select a row below the current row and move focus to that row + */ + @HostListener('keydown.shift.ArrowDown') private selectNextRow(): void { + this.handleWhenFocussed(() => { + this.parent.getRow(this.ngxTreeGridRow + 1)?.selectRow(); + this.parent.getRow(this.ngxTreeGridRow + 1)?.focus(); + }); + } + + /** + * Emits a select event to select a row above the current row and move focus to that row + */ + @HostListener('keydown.shift.ArrowUp') private selectPreviousRow(): void { + this.handleWhenFocussed(() => { + this.parent.getRow(this.ngxTreeGridRow - 1)?.selectRow(); + this.parent.getRow(this.ngxTreeGridRow - 1)?.focus(); + }); + } + + /** + * Marks the row as expanded + */ + @HostBinding('attr.aria-expanded') + @Input() + public ngxTreeGridRowExpanded: boolean = false; + + /** + * The index of the row + */ + @Input({ required: true }) public ngxTreeGridRow: number; + + /** + * Whether the row is selectable + */ + @Input() public ngxTreeGridRowSelectable: boolean = false; + + /** + * Emits a select row event + */ + @Output() public ngxTreeGridRowSelectRow: EventEmitter = new EventEmitter(); + + /** + * Emits an expand row event + */ + @Output() public ngxTreeGridRowExpandRow: EventEmitter = new EventEmitter(); + + constructor( + private readonly parent: NgxTreeGridDirective, + private readonly elementRef: ElementRef + ) { + super(); + + this.parent.registerRow(this); + } + + /** + * Registers a cell to the row + * + * @param cell - The provided cell + */ + public registerCell(cell: NgxTreeGridCellDirective): void { + this.cells.push(cell); + } + + /** + * Focus on the row + */ + public focus(): void { + this.elementRef.nativeElement.focus(); + } + + /** + * Get a cell from either the current row, or one of the other rows in the grid + * + * @param cell - The cell we wish to target + * @param row - The row we wish to target + */ + public getCell( + cell: NgxTreeGridCellTarget, + row: NgxTreeGridRowTarget + ): NgxTreeGridCellDirective { + // Iben: Get the index of the cell + const index = + typeof cell === 'string' ? (cell === 'first' ? 0 : this.cells.length - 1) : cell; + + // Iben: If the cell is in the current row, we return the cell + if (row === 'current') { + return this.cells[index]; + } + + // Iben: If the row is either the above or below one, we get the cell from those rows + if (row === 'above' || row === 'below') { + return this.parent + .getRow(row === 'below' ? this.ngxTreeGridRow + 1 : this.ngxTreeGridRow - 1) + ?.getCell(index, 'current'); + } + + // Iben: If the row is the first or last, we get those rows + return row === 'first' + ? this.parent.getFirstRow()?.getCell(index, 'current') + : this.parent.getLastRow()?.getCell(index, 'current'); + } + + /** + * Emit the row event only if the row is selectable + */ + public selectRow(): void { + if (this.ngxTreeGridRowSelectable) { + this.ngxTreeGridRowSelectRow.emit(); + } + } + + /** + * Move to the top of the grid + */ + public moveToTop(): void { + this.handleWhenFocussed(() => { + this.parent.moveTo('top'); + }); + } + + /** + * Move to the bottom of the grid + * + * @memberof NgxTreeGridRowDirective + */ + public moveToBottom(): void { + this.handleWhenFocussed(() => { + this.parent.moveTo('bottom'); + }); + } + + public ngOnDestroy(): void { + // Iben: Remove the row from the grid + this.parent.removeRow(this.ngxTreeGridRow); + } +} diff --git a/libs/table/src/lib/directives/tree-grid/tree-grid.cell.directive.ts b/libs/table/src/lib/directives/tree-grid/tree-grid.cell.directive.ts new file mode 100644 index 00000000..13b2a1ab --- /dev/null +++ b/libs/table/src/lib/directives/tree-grid/tree-grid.cell.directive.ts @@ -0,0 +1,178 @@ +import { Directive, ElementRef, HostListener, Input, Optional } from '@angular/core'; + +import { NgxHasFocusDirective } from '../has-focus-action'; +import { NgxTreeGridCellTarget, NgxTreeGridRowTarget } from '../../interfaces'; +import { NgxTreeGridRowDirective } from './tree-grid-row.directive'; +import { NgxTreeGridDirective } from './tree-grid.directive'; + +/** + * A cell directive to handle navigation according to the WCAG treegrid pattern + * + * See https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/ + */ +@Directive({ + selector: '[ngxTreeGridCell]', + standalone: true, + host: { + // Iben: Marks the cell as focusable, but only by setting its focus programmatically, not by the tab key + '[attr.tabIndex]': '-1', + }, +}) +export class NgxTreeGridCellDirective extends NgxHasFocusDirective { + /** + * The parent row of the cell + */ + private row: NgxTreeGridRowDirective; + + /** + * Set focus on the previous cell to the left + */ + @HostListener('keydown.ArrowLeft', ['$event']) private moveLeft(event: Event): void { + this.handleWhenFocussed(() => { + // Iben: Stop the event from bubbling so that the row does not open when navigating through the row (see arrowLeft in the NgxTreeGridRowDirective ) + if (this.ngxTreeGridCell === 0) { + event.stopPropagation(); + return; + } + + this.moveToCell(this.ngxTreeGridCell - 1, 'current'); + }); + } + + /** + * Set focus on the next cell to the right + */ + @HostListener('keydown.ArrowRight') private moveRight(): void { + this.moveToCell(this.ngxTreeGridCell + 1, 'current'); + } + + /** + * Set focus on the cell above + */ + @HostListener('keydown.ArrowUp') private moveUp(): void { + this.moveToCell(this.ngxTreeGridCell, 'above'); + } + + /** + * Set focus on the cell below + */ + @HostListener('keydown.ArrowDown') private moveDown(): void { + this.moveToCell(this.ngxTreeGridCell, 'below'); + } + + /** + * Set focus on the first cell of the grid + */ + @HostListener('keydown.PageUp') private moveToFirstCellOfGrid(): void { + this.moveToCell('first', 'first'); + } + + /** + * Set focus on the first cell of the row + */ + @HostListener('keydown.Home') private moveToFirstOfRow(): void { + this.moveToCell('first', 'current'); + } + + /** + * Set focus on the first cell of the same column of the grid + */ + @HostListener('keydown.control.Home') private moveToFirstColumnOfGrid(): void { + this.moveToCell(this.ngxTreeGridCell, 'first'); + } + + /** + * Set focus on the last cell of the grid + */ + @HostListener('keydown.PageDown') moveToLastCellOfGrid(): void { + this.moveToCell('last', 'last'); + } + + /** + * Set focus on the last cell of the row + */ + @HostListener('keydown.End') private moveToBottomEnd(): void { + this.moveToCell('last', 'current'); + } + + /** + * Set focus on the last cell of the same column of the grid + */ + @HostListener('keydown.control.End') private moveToBottomControlEnd(): void { + this.moveToCell(this.ngxTreeGridCell, 'last'); + } + + /** + * The index of the cell in the row + */ + @Input({ required: true }) public ngxTreeGridCell: number; + + /** + * The index of the row + */ + @Input({ required: true }) public ngxTreeGridCellRow: number; + + constructor( + @Optional() private readonly parent: NgxTreeGridDirective, + private readonly elementRef: ElementRef + ) { + super(); + } + + /** + * Sets focus on the cell or on the first focusable item in the cell + */ + public focus(): void { + // Iben: Check if any of the child elements are focusable + const focusableElement = this.findFocusableElement(); + + // Iben: If no element was focusable, focus on the current element + if (!focusableElement) { + this.elementRef.nativeElement.focus(); + } + } + + /** + * Moves focus to a provided cell in a provided row + * + * @private + * @param cell - The cell we wish to put focus on + * @param row - The row in which the cell is + */ + private moveToCell(cell: NgxTreeGridCellTarget, row: NgxTreeGridRowTarget): void { + this.handleWhenFocussed(() => { + this.row.getCell(cell, row)?.focus(); + }); + } + + /** + * Searches for a focusable element in the cell + */ + private findFocusableElement(): HTMLElement | undefined { + let result: HTMLElement; + + // Iben: Loop over each first-level element of the children + for (let element of [...this.elementRef.nativeElement.children]) { + if (!result) { + // Iben: Check if we can focus on the element + element.focus(); + + // Iben: If the current active element is the same as the element we focussed, on, we break + if (this.elementRef.nativeElement !== document?.activeElement) { + result = element; + this.hasFocus = true; + + break; + } + } + } + + return result; + } + + public ngAfterViewInit(): void { + // Iben: We register the cell and the row through the parent, as the td elements are not rendered within the row initially. + this.parent?.registerCell(this.ngxTreeGridCellRow, this); + this.row = this.parent.getRow(this.ngxTreeGridCellRow); + } +} diff --git a/libs/table/src/lib/directives/tree-grid/tree-grid.directive.ts b/libs/table/src/lib/directives/tree-grid/tree-grid.directive.ts new file mode 100644 index 00000000..fb39c929 --- /dev/null +++ b/libs/table/src/lib/directives/tree-grid/tree-grid.directive.ts @@ -0,0 +1,102 @@ +import { Directive, HostBinding, Input } from '@angular/core'; + +import { NgxTreeGridRowDirective } from './tree-grid-row.directive'; +import { NgxTreeGridCellDirective } from './tree-grid.cell.directive'; + +/** + * An overarching directive to handle navigation according to the WCAG treegrid pattern + * + * See https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/ + */ +@Directive({ + selector: '[ngxTreeGrid]', + standalone: true, +}) +export class NgxTreeGridDirective { + /** + * An array of all rows in the treegrid + */ + private rows: NgxTreeGridRowDirective[] = []; + + @HostBinding('attr.role') private role: 'table' | 'treegrid' = 'table'; + + /** + * Whether the current item is a treegrid + */ + @Input({ required: true }) public ngxTreeGrid: boolean; + + /** + * Whether the treegrid has expandableRows + */ + @Input({ required: true }) public ngxTreeGridExpandable: boolean; + + /** + * Registers a row to the rows array + * + * @param row - The provided row + */ + public registerRow(row: NgxTreeGridRowDirective): void { + this.rows.push(row); + } + + /** + * Removes a registered row from the grid + * + * @param index - The index of the provided row + */ + public removeRow(index: number): void { + this.rows = this.rows.slice(0, index).concat(this.rows.slice(index + 1)); + } + + /** + * Returns a row from the grid + * + * @param index - The index of the row + */ + public getRow(index: number): NgxTreeGridRowDirective { + // Iben: Early exit if the row is not found + if (!this.ngxTreeGrid) { + return null; + } + + // Iben: Return the row + return this.rows[index]; + } + + /** + * Returns the first row of the grid + */ + public getFirstRow(): NgxTreeGridRowDirective { + return this.getRow(0); + } + + /** + * Returns the last row of the grid + */ + public getLastRow(): NgxTreeGridRowDirective { + return this.getRow(this.rows.length - 1); + } + + /** + * Moves the row focus to either the top or the bottom row of the grid + * + * @param direction - Whether we want to go to the top or the bottom of the grid + */ + public moveTo(direction: 'top' | 'bottom') { + this.rows[direction === 'top' ? 0 : this.rows.length - 1]?.focus(); + } + + /** + * Registers a a cell to a row of the grid + * + * @param cell - The provided cell + */ + public registerCell(index: number, cell: NgxTreeGridCellDirective) { + this.getRow(index)?.registerCell(cell); + } + + ngOnChanges() { + // Iben: Set the role based on the tree grid + this.role = this.ngxTreeGrid ? 'treegrid' : 'table'; + } +} diff --git a/libs/table/src/lib/interfaces/index.ts b/libs/table/src/lib/interfaces/index.ts index 8cfe5b1a..ae25caf8 100644 --- a/libs/table/src/lib/interfaces/index.ts +++ b/libs/table/src/lib/interfaces/index.ts @@ -1,2 +1,3 @@ export * from './sort-event'; export * from './show-header-requirements'; +export * from './tree-grid.types'; diff --git a/libs/table/src/lib/interfaces/tree-grid.types.ts b/libs/table/src/lib/interfaces/tree-grid.types.ts new file mode 100644 index 00000000..b232937c --- /dev/null +++ b/libs/table/src/lib/interfaces/tree-grid.types.ts @@ -0,0 +1,2 @@ +export type NgxTreeGridRowTarget = 'current' | 'above' | 'below' | 'first' | 'last'; +export type NgxTreeGridCellTarget = number | 'first' | 'last'; diff --git a/libs/table/src/lib/pipes/aria-sort/aria-sort.pipe.ts b/libs/table/src/lib/pipes/aria-sort/aria-sort.pipe.ts new file mode 100644 index 00000000..22811242 --- /dev/null +++ b/libs/table/src/lib/pipes/aria-sort/aria-sort.pipe.ts @@ -0,0 +1,23 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { NgxAbstractTableCellDirective } from '../../cell'; +import { NgxTableSortEvent } from '../../interfaces'; + +@Pipe({ + name: 'ngxAriaSort', + standalone: true, +}) +export class NgxAriaSortPipe implements PipeTransform { + transform(value: { + currentSorting: NgxTableSortEvent; + cell: NgxAbstractTableCellDirective; + }): 'none' | 'ascending' | 'descending' { + const { cell } = value; + + if (!cell || !cell.sortDirection) { + return 'none'; + } + + return cell.sortDirection.toLocaleLowerCase() as 'ascending' | 'descending'; + } +} diff --git a/libs/table/src/lib/pipes/index.ts b/libs/table/src/lib/pipes/index.ts index 46f10227..2f06195b 100644 --- a/libs/table/src/lib/pipes/index.ts +++ b/libs/table/src/lib/pipes/index.ts @@ -2,15 +2,18 @@ import { NgxTableGetPipe } from './get-pipe/get.pipe'; import { NgxTableHasObserversPipe } from './has-observers.pipe'; import { NgxTableSortIconPipe } from './sort-icon.pipe'; import { NgxTableShowHeaderPipe } from './show-header/show-header.pipe'; +import { NgxAriaSortPipe } from './aria-sort/aria-sort.pipe'; export * from './has-observers.pipe'; export * from './sort-icon.pipe'; export * from './get-pipe/get.pipe'; export * from './show-header/show-header.pipe'; +export * from './aria-sort/aria-sort.pipe'; export const Pipes = [ NgxTableHasObserversPipe, NgxTableSortIconPipe, NgxTableGetPipe, NgxTableShowHeaderPipe, + NgxAriaSortPipe, ]; diff --git a/libs/table/src/lib/table/ngx-table.component.html b/libs/table/src/lib/table/ngx-table.component.html index ebdcdc46..cdfd8a05 100644 --- a/libs/table/src/lib/table/ngx-table.component.html +++ b/libs/table/src/lib/table/ngx-table.component.html @@ -1,298 +1,290 @@ - @if (selectable) { - - - - - - } - - @if (showOpenRowState) { - - - + + + } @if (showOpenRowState) { + + + - - - } - - @for (column of tableColumns(); track column) { - - - - - - } - - @if (detailRowTemplate) { - - - - } - - @if ( - { isLoading: loading, isEmpty: data?.length === 0 } - | ngxTableShowHeader : hideHeaderWhen - ) { - - } - @if (data) { - + + + + + } @for (column of tableColumns(); track column; let columnIndex = $index) { + + - @if (detailRowTemplate) { - - } - @if (hasFooterTemplates()) { - - } - } + [attr.aria-readonly]="!sortableTableCellRecord()[column]" + (click)="handleSort(column)" + > + @if ( tableCellTemplateRecord()[column]?.headerTemplate) { + + + } @else { + {{ column }} + } @if (sortableTableCellRecord()[column]; as sortCell) { @if (sortTemplate) { + + + } @else { + + } } + + + + + } @if (detailRowTemplate) { + + + + } @if ( { isLoading: loading, isEmpty: data?.length === 0 } | ngxTableShowHeader : + hideHeaderWhen ) { + + } @if (data) { + + @if (detailRowTemplate) { + + } @if (hasFooterTemplates()) { + + } }
- @if (selectableType === 'checkbox') { - + @if (selectable) { + + + @if (selectableType === 'checkbox') { + - - } - - @if ( - selectableType === 'radio' || - (selectableType === 'checkbox' && - rowsFormGroup.controls[ - this.selectableKey ? row[selectableKey] : index - ]); as control - ) { - + + } + + + @if ( selectableType === 'radio' || (selectableType === 'checkbox' && + rowsFormGroup.controls[ this.selectableKey ? row[selectableKey] : index ]); as control ) + { + - - } - - + + } + + - - - @if ( - tableCellTemplateRecord()[column]?.headerTemplate) { - - - } @else { - {{ column }} - } - @if (sortableTableCellRecord()[column]; as sortCell) { - @if (sortTemplate) { - - - } @else { - - } - } - - @if (tableCellTemplateRecord()[column]?.cellTemplate) { - - - } @else { - {{ row[column] }} - } - - - -
+ @if (tableCellTemplateRecord()[column]?.cellTemplate) { + + + } @else { + {{ row[column] }} + } + + + +
@if (!loading && (!data || data.length === 0)) { -
- -
-} - -@if (loading) { -
- -
+
+ +
+} @if (loading) { +
+ +
} - @if ( - showDetailRow === 'always' || - (showDetailRow === 'on-single-item' && data?.length === 1) || - openRows.has(index) - ) { - - - } + @if ( showDetailRow === 'always' || (showDetailRow === 'on-single-item' && data?.length === 1) + || openRows.has(index) ) { + + + } - @if (checkboxTemplate) { - - - } @else { - - } - + @if (checkboxTemplate) { + + + } @else { + + } - @if (radioTemplate) { - - - } @else { - - } - + @if (radioTemplate) { + + + } @else { + + } - + + diff --git a/libs/table/src/lib/table/ngx-table.component.ts b/libs/table/src/lib/table/ngx-table.component.ts index ca7a72d8..95f7e2f1 100644 --- a/libs/table/src/lib/table/ngx-table.component.ts +++ b/libs/table/src/lib/table/ngx-table.component.ts @@ -23,7 +23,7 @@ import { import { ControlValueAccessor, FormControl, - FormGroup, + FormRecord, NG_VALUE_ACCESSOR, ReactiveFormsModule, } from '@angular/forms'; @@ -51,6 +51,8 @@ import { import { NgxTableShowHeaderPipe } from '../pipes/show-header/show-header.pipe'; import { NgxTableSortIconPipe } from '../pipes/sort-icon.pipe'; import { NgxTableHasObserversPipe } from '../pipes/has-observers.pipe'; +import { NgxAriaSortPipe } from '../pipes'; +import { NgxTreeGrid } from '../directives'; interface TableCellTemplate { headerTemplate?: TemplateRef; @@ -80,6 +82,8 @@ interface TableCellTemplate { NgxTableHasObserversPipe, NgxTableSortIconPipe, NgxTableShowHeaderPipe, + NgxAriaSortPipe, + NgxTreeGrid, ], }) export class NgxTableComponent @@ -111,14 +115,14 @@ export class NgxTableComponent private onChanged: Function = (_: any) => {}; /** - * The current sorting event + * Whether or not the form was generated */ - private currentSortingEvent: WritableSignal = signal(undefined); + private formGenerated: WritableSignal = signal(false); /** - * Whether or not the form was generated + * The current sorting event */ - private formGenerated: WritableSignal = signal(false); + public currentSortingEvent: WritableSignal = signal(undefined); /** * Keeps a record with the column and it's templates @@ -133,6 +137,11 @@ export class NgxTableComponent * Keeps a record of which cells have a cypress tag */ public tableCypressRecord: WritableSignal> = signal({}); + /** + * Keeps a record of which cells are editable + */ + public editableTableCellRecord: WritableSignal> = + signal({}); /** * A set with all the open rows @@ -143,7 +152,7 @@ export class NgxTableComponent /** * A FormGroup that adds a control for each row */ - public readonly rowsFormGroup = new FormGroup({}); + public readonly rowsFormGroup = new FormRecord>({}); /** * A control for the select all option in the header of the table @@ -175,6 +184,16 @@ export class NgxTableComponent */ public tableColumns: WritableSignal = signal([]); + /** + * The currently focussed row + */ + public focussedRow: string; + + /** + * The currently focussed cell + */ + public focussedCell: string; + /** * A QueryList of all the table cell templates */ @@ -432,6 +451,7 @@ export class NgxTableComponent // Iben: Emit a row click event this.rowClicked.emit(row); + // Iben: Handle the selected open row if needed if (this.showSelectedOpenRow) { if (this.selectedRow() === index) { // Benoit: If you close the selected row, unselect that row @@ -441,6 +461,17 @@ export class NgxTableComponent } } + // Iben: Handle the row state + this.handleRowState(index, !this.openRows.has(index) ? 'open' : 'close'); + } + + /** + * Handle the expanded state of a row + * + * @param index - The index of the row + * @param action - Whether the row needs to be opened or closed + */ + public handleRowState(index: number, action: 'open' | 'close'): void { // Iben: If there's no detail row we early exit if (!this.detailRowTemplate) { return; @@ -448,9 +479,9 @@ export class NgxTableComponent // Iben: Depending on whether we allow multiple rows to be open at the same time, we toggle the open rows accordingly if (this.allowMultipleOpenRows) { - !this.openRows.has(index) ? this.openRows.add(index) : this.openRows.delete(index); + action === 'open' ? this.openRows.add(index) : this.openRows.delete(index); } else { - this.openRows = !this.openRows.has(index) ? new Set([index]) : new Set(); + this.openRows = action === 'open' ? new Set([index]) : new Set(); } } @@ -462,6 +493,7 @@ export class NgxTableComponent this.tableCellTemplateRecord.set({}); this.sortableTableCellRecord.set({}); this.tableCypressRecord.set({}); + this.editableTableCellRecord.set({}); // Iben: Loop over all provided table cell templates Array.from(this.tableCellTemplates).forEach((tableCellTemplate) => { @@ -479,6 +511,7 @@ export class NgxTableComponent sortable, cellClass, cypressDataTags, + editable, } = tableCellTemplate; this.tableCellTemplateRecord.update((value) => { @@ -512,6 +545,16 @@ export class NgxTableComponent }; }); } + + // Iben: If the cell is editable, we add it to the record + if (editable) { + this.editableTableCellRecord.update((value) => { + return { + ...value, + [column]: tableCellTemplate, + }; + }); + } }); // Iben: Check if at least one template has a footer template, so that we know whether or not we have to render the footer row @@ -549,6 +592,12 @@ export class NgxTableComponent }); } + public selectRow(index: number): void { + this.rowsFormGroup + .get(this.selectableKey ? `${this.data[index][this.selectableKey]}` : `${index}`) + .patchValue(true); + } + /** * Handle the changes in sort events *