ember-table
ember-table copied to clipboard
Sticky header (and footer) for auto height tables?
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.
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:
- I render 2 ember-table component, one with only the thead and one with both the thead and tbody but with the thead hidden.
- Then I've added a scroll event handler to sync horizontal scrolling between the two tables.
- the
@fill
prop is used to control whether the table should fill its container (default behaviour of ember-table) or go with the document flow. I don't create the table with the sticky header if@fill={{true}}
<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