Addepar / ember-table

https://opensource.addepar.com/ember-table/
Other
1.69k stars 352 forks source link

Sticky header (and footer) for auto height tables? #701

Open billdami opened 5 years ago

billdami commented 5 years ago

Is there any plans (or is it possible) to allow for sticky column headers (and footers) for non-fixed height tables, i.e. tables that do not have their own scroll container, but who has some ancestor element that will scroll to fit the content (which may be the <body>)?

I have a page layout where having an independently scrolling fixed-height table is not feasible (or at least would not result in a good UX). There is quite a bit of content above the table, such that a fixed height table would be too short on most screen resolutions. The content above it needs to be able to scroll out of view and let the table occupy the entire viewport when scrolled down. But we still want the table header to be sticky and always in view.

I was able to partially get this working by just removing the overflow: auto; style from the .ember-table container, however, as expected, this breaks other things..most noticeably the table's horizontal scrolling.

gwak commented 2 years ago

Hey @billdami,

I've managed to do just that after a lot of trial and error, but I now have a satisfying solution, here's how I've done it:

  <div class="table-header" {{did-insert this.handleTableHeaderInsert}}>
    {{#unless @fill}}
      <EmberTable class="table" ...attributes as |et|>
        <et.head
          @columns={{@columns}}
          @scrollIndicators="horizontal"
          class="et-header"
          {{create-ref "thead"}} as |h|
        >
          <h.row as |r|>
            <r.cell as |columnValue columnMeta|>
              {{columnValue}}
            </r.cell>
          </h.row>
        </et.head>
      </EmberTable>
    {{/unless}}
  </div>
  <EmberTable
    class="table"
    ...attributes
    {{did-resize this.handleTableResize}} as |et|
  >
    <et.head
      @columns={{@columns}}
      @sorts={{@sorts}}
      @scrollIndicators="all"
      @columnKeyPath="valuePath"
      class={{concat "et-header" (unless @fill " u-d-none")}} as |h|
    >
      <h.row as |r|>
        <r.cell as |columnValue columnMeta|>
          {{columnValue}}
        </r.cell>
      </h.row>
    </et.head>
    <et.body
      @rows={{@rows}}
      @key={{@key}}
      @staticHeight={{or-else @staticHeight true}}
      @containerSelector={{this.containerSelector}}
      @bufferSize={{@bufferSize}}
      @renderAll={{this.renderAll}}
      {{did-insert this.handleTBodyInsert}} as |b|
    >
      <b.row as |r|>
        {{#if
          (or
            (or (not @hideLeaves) (not @enableTree))
            (and @hideLeaves @enableTree r.api.rowValue.children.length)
          )
        }}
          <r.cell as |cellValue columnValue|>
            {{cellValue}}
          </r.cell>
        {{/if}}
      </b.row>
    </et.body>
    {{#if @footerRows.length}}
      <et.foot @rows={{@footerRows}} as |f|>
        <f.row as |r|>
          <r.cell as |cellValue columnValue|>
            {{cellValue}}
          </r.cell>
        </f.row>
      </et.foot>
    {{/if}}
  </EmberTable>
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { ref } from 'ember-ref-bucket';

import { closest, getScrollParent } from 'app/utils/dom-utils';

export default class Table extends Component<IArgs> {
  @tracked scrollContainer!: Element;
  @tracked containerSelector?: string;
  @ref('thead') thead!: HTMLElement;
  xScrollContainer?: HTMLElement;
  headerScrollContainer: HTMLElement | null = null;

  willDestroy() {
    this.xScrollContainer?.removeEventListener('scroll', this.scrollEventHandler);
  }

  /**
   * When the body is ready, we setup the scroll sync event handler
   *
   * @param tbody `table tbody` element
   */
  @action
  handleTBodyInsert(tbody: HTMLElement) {
    const { thead } = this;
    this.headerScrollContainer = thead ? closest(thead, '.ember-table-overflow') : null;
    this.xScrollContainer = closest(tbody, '.ember-table-overflow') as HTMLElement;

    if (this.headerScrollContainer && this.xScrollContainer && !this.args.fill) {
      this.xScrollContainer.addEventListener('scroll', this.scrollEventHandler);
    }
  }

 /**
   * When the .table-header element is inserted into the DOM,
   * we initialise the container selector by trying to find the nearest scroll parent element
   * The container selector will be used by vertical-collection to calculate the visibility of items
   * in relation to the visible scrolling view.
   *
   * @param tableHeader `.table-header` element
   */
  @action
  handleTableHeaderInsert(tableHeader: HTMLElement) {
    const scrollContainer =
      getScrollParent(tableHeader)! ?? document.querySelector('body');
    this.containerSelector =
      this.args.fill || !scrollContainer
        ? undefined
        : '.' + scrollContainer?.className.split(' ').join('.');
  }

  /**
   * This function syncs the horizontal scroll of the table sticky header
   * with the body horizontal scroll
   */
  scrollEventHandler = () => {
    const { headerScrollContainer, xScrollContainer } = this;
    if (headerScrollContainer && xScrollContainer) {
      headerScrollContainer.scrollLeft = xScrollContainer.scrollLeft;
    }
  };
}
.table-header {
    position: sticky;
    top: var(--table-header-top, 0);
    z-index: 4;
    width: 100%;
    height: 48px;
    background: white;
    overflow: hidden;

    .ember-table-overflow {
      overflow: hidden;
    }
}

And here's the result

https://user-images.githubusercontent.com/176766/181385041-4f368a52-6016-4207-9ccf-a94c35bcbc02.mov

Of course it would be so much easier if css had something like position: sticky-vertical; overflow-x: hidden;

I hope it helps anyway.

Cheers