Search code examples
angulartypescriptprimengprimeng-datatableprimeng-turbotable

Selecting Rows On PrimeNG Scroll Table With Keypress Doesn't Change Scroll Location


Current Stackblitz Here

A few weeks ago, I asked and had answered a question for using arrow keys for a PrimeNG table.

As of now, the code allows the user, when the "search" input is in focus, to navigate up and down on the table rows and click "enter" to select the table row. However, when using a scroll table, as seen in this stackblitz, the selected table row doesn't appear because it is hiding below the scroll bars. If I use tab to navigate into the table cells, then it will also scroll, but not with the arrow keys as in this use case.

Is it possible to shift the scrollbar location based on the selectedProduct so the table rows aren't hidden below?

Table setup in HTML:

<p-table
  #dl
  [columns]="cols"
  [value]="products"
  selectionMode="single"
  [(selection)]="selectedProduct"
  (onFilter)="onFilter($event, dt)"
  [scrollable]="true"
  scrollHeight="270px"
> 
...
<!-- table columns and code here -->
</p-table>

Component TS (some unnecessary info removed -- can be found in StackBlitz)

// imports

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent {
  @ViewChild('dl') table: Table;

  cpt = 0;
  products: Product[];
  visibleProducts: Product[];
  selectedProduct: Product;

  cols: any[];

  constructor( ...) {}

  ngOnInit() {
    this.productService.getProductsSmall().then((data) => {
      this.products = data.slice();
      this.visibleProducts = this.products;
      this.selectedProduct = this.visibleProducts[0];
    });

    this.cols = [...];
  }

  onFilter(event, dt) {
    this.cpt = 0;
    if (event.filteredValue.length > 0) {
      this.selectedProduct = event.filteredValue[0];
      this.visibleProducts = event.filteredValue;
    }
  }

  @HostListener('keydown.ArrowUp', ['$event']) ArrowUp($event: KeyboardEvent) {
    if (this.cpt > 0) {
      this.cpt--;
    }
    this.selectedProduct = this.visibleProducts[this.cpt];
  }

  @HostListener('keydown.ArrowDown', ['$event']) ArrowDown(
    $event: KeyboardEvent
  ) {
    if (this.cpt < this.visibleProducts.length - 1) {
      this.cpt++;
    }
    this.selectedProduct = this.visibleProducts[this.cpt];
  }

  @HostListener('keydown.Enter', ['$event']) Enter($event: KeyboardEvent) {
    alert('opening product: ' + this.selectedProduct.name);
  }
}



Solution

  • Okay, I ended up figuring out a potential solution. I also created an issue for PrimeNG to hopefully include something like this in the future, as previously the feature requests for features like this have not been completed.

    I added a function to my component called scrollTable().

    What this function does is first take the datatable wrapper element, then the rows of this wrapper. Next, I set a rowEl equal to the current active row based on the cpt global variable from previously. If the row item is the first in the array, the wrapper scrolls to the top. If it's not the first item, then a small check is run to see if the offset is greater than the total body size (from the wrapper.offsetHeight which appears to be the same as the p-table's scrollHeight property). Then the wrapper's scrolltop position is calculated and changed with a small offset to adjust for the size of the table rows:

    app.component.ts

    ...
    
      scrollTable() {
        // No need to scroll if there aren't any products
        if (!this.products.length) {
          return;
        }
    
        let wrapper = this.table.el.nativeElement.children[0].getElementsByClassName(
          'p-datatable-wrapper'
        )[0];
        let rows = wrapper.getElementsByClassName('p-selectable-row');
    
        console.log(wrapper);
        let rowEl = rows[this.cpt];
    
        // Scroll to top if first item
        if (this.cpt === 0) {
          wrapper.scrollTop = 0;
        }
        // Change scroll position if not first item and at bottom of scrollbar
        if (rowEl.offsetTop + rowEl.offsetHeight > wrapper.offsetHeight) {
          wrapper.scrollTop +=
            rowEl.offsetTop +
            rowEl.offsetHeight -
            wrapper.scrollTop -
            wrapper.offsetHeight +
            18;
        }
      }
    ...
    

    Here's an updated StackBlitz with a working solution in the component.

    shout-out to the comments in this GitHub issue that gave me the initial steps to get through the scroll portion (as I already had the arrow key functionality)

    Update

    I made a directive to handle not only the navigation of the PrimeNG table rows with arrow keys, but also the scrolling. This works on any single selectable table including when a filter is being used. The filter is passed in with the @Input and the highlighted row through the @Output. It might not be perfect, but solves a lot of use cases for when users want more keyboard functionality. When enter is pressed, the onRowSelect(...) triggers from within the table and you can use any function from within the component. Hope it helps anyone else...

    StackBlitz with Directive.

    import {
      Directive,
      HostListener,
      Output,
      EventEmitter,
      Input,
    } from '@angular/core';
    import { Table } from 'primeng/table';
    
    @Directive({
      selector: '[tableNavigation]',
    })
    export class TableNavigationDirective {
      /////////
      // (onRowSelect)="doSomething()"
      // (highlightProduct)="selectedProduct = $event" for the highlighting.
      // 'selectedProduct' will be equal to the [(selection)] value in your component
      // [visibleItems]="visibleItems" from component for filtering
      /////////
    
      @Output() highlightProduct: EventEmitter<any> = new EventEmitter();
    
      // If you want to include filtering, you'll want to use this input.
      _visibleItems: any;
      @Input('visibleItems') set visibleItems(items: any) {
        this._visibleItems = items;
        if (this._visibleItems) {
          this.rowIndex = 0;
          this.highlightProduct.emit(this._visibleItems[0]);
          if (this.table.scrollable) {
            this.scrollTable();
          }
        }
      }
    
      wrapper: any;
      rowIndex = 0;
    
      constructor(private table: Table) {
        this._visibleItems = this.table.value;
      }
    
      @HostListener('keydown.ArrowUp', ['$event']) ArrowUp($event: KeyboardEvent) {
        $event.preventDefault();
    
        if (this.rowIndex > 0) {
          this.rowIndex--;
        }
        this.highlightProduct.emit(this._visibleItems[this.rowIndex]);
        if (this.table.scrollable) {
          this.scrollTable();
        }
      }
    
      @HostListener('keydown.ArrowDown', ['$event']) ArrowDown(
        $event: KeyboardEvent
      ) {
        $event.preventDefault();
    
        if (this.rowIndex < this._visibleItems.length - 1) {
          this.rowIndex++;
        }
        this.highlightProduct.emit(this._visibleItems[this.rowIndex]);
        if (this.table.scrollable) {
          this.scrollTable();
        }
      }
    
      @HostListener('keydown.Enter', ['$event']) Enter($event: KeyboardEvent) {
        this.table.onRowSelect.emit({
          originalEvent: $event,
          index: this.rowIndex,
          data: this.table.selection,
          type: 'row',
        });
      }
    
      scrollTable() {
        if (this.table.el.nativeElement.children[0].getElementsByClassName('p-datatable-scrollable-body')[0]) {
          this.wrapper = this.table.el.nativeElement.children[0].getElementsByClassName('p-datatable-scrollable-body')[0];
        } else if (this.table.el.nativeElement.children[0].getElementsByClassName('p-datatable-wrapper')[0]) {
          this.wrapper = this.table.el.nativeElement.children[0].getElementsByClassName('p-datatable-wrapper')[0];
        } else {
          this.wrapper = undefined;
          console.error("wrapper is undefined, scroll won't work");
        }
    
        let rows = this.wrapper.getElementsByClassName('p-selectable-row');
    
        // No need to scroll if there aren't any products
        if (!rows.length) {
          return;
        }
        let rowEl = rows[this.rowIndex];
    
        // Scroll all the way to top if first item
        if (rowEl === rows[0]) {
          this.wrapper.scrollTop = 0;
        }
    
        // Change scroll position if not first item and at bottom of scrollbar
        if (rowEl.offsetTop + rowEl.offsetHeight > this.wrapper.offsetHeight) {
          this.wrapper.scrollTop +=
            rowEl.offsetTop +
            rowEl.offsetHeight -
            this.wrapper.scrollTop -
            this.wrapper.offsetHeight;
        }
      }
    }