Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vertical scroll indicators #854

Merged
merged 11 commits into from
Jan 22, 2021
7 changes: 7 additions & 0 deletions addon-test-support/pages/-private/ember-table-body.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ import { click } from 'ember-native-dom-helpers';
export default PageObject.extend({
scope: 'tbody',

/**
Returns the height of the entire tbody element.
*/
get height() {
return findElement(this).offsetHeight;
},

/**
Returns the number of rows in the body.
*/
Expand Down
9 changes: 8 additions & 1 deletion addon-test-support/pages/-private/ember-table-header.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,14 @@ export default {
headers: collection('th', Header),

/**
Returns the number of rows in the footer.
Returns the height of the entire thead element.
*/
get height() {
return findElement(this).offsetHeight;
},

/**
Returns the number of rows in the header.
*/
get rowCount() {
return Number(findElement(this).getAttribute('data-test-row-count'));
Expand Down
23 changes: 23 additions & 0 deletions addon-test-support/pages/ember-table.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,29 @@ export default PageObject.extend({
return !!this.scrollIndicator(side);
},

/**
* Returns the scrollable overflow element
*/
overflow() {
return findElement(this, '[data-test-ember-table-overflow]');
},

/**
* Returns the height of the horizontal scrollbar on the overflow element
*/
horizontalScrollbarHeight() {
let overflow = this.overflow();
return overflow.offsetHeight - overflow.clientHeight;
},

/**
* Returns the width of the vertical scrollbar on the overflow element
*/
verticalScrollbarWidth() {
let overflow = this.overflow();
return overflow.offsetWidth - overflow.clientWidth;
},

/**
* Selects a row in the body
*
Expand Down
227 changes: 172 additions & 55 deletions addon/components/-private/scroll-indicators/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,111 @@ import Component from '@ember/component';
import { computed } from '@ember/object';
import { readOnly } from '@ember/object/computed';
import { bind } from '@ember/runloop';
import { capitalize } from '@ember/string';
import { htmlSafe } from '@ember/template';
import { isEmpty } from '@ember/utils';
import { isEmpty, isNone } from '@ember/utils';
import { addObserver } from 'ember-table/-private/utils/observer';
import layout from './template';

/**
Computed property macro that builds the CSS styles (position, height)
for each scroll indicator element.
for each horizontal scroll indicator element.

@param {string} side - which side we are computing styles for: `left` or `right`
*/
const indicatorStyle = side => {
const horizontalIndicatorStyle = side => {
return computed(
`columnTree.${side}FixedNodes.@each.width`,
'scrollRect',
'tableRect',
'overflowHeight',
'scrollbarWidth',
function() {
let style = [];

// left/right position
let offset = 0;

let fixedNodes = this.get(`columnTree.${side}FixedNodes`);
if (!isEmpty(fixedNodes)) {
let fixedWidth = fixedNodes.reduce((acc, node) => acc + node.get('width'), 0);
style.push(`${side}:${fixedWidth}px;`);
offset += fixedWidth;
}

if (side === 'right') {
let scrollbarWidth = this.get('scrollbarWidth') || 0;
offset += scrollbarWidth;
}

style.push(`${side}:${offset}px;`);

// height
let scrollRect = this.get('scrollRect');
let tableRect = this.get('tableRect');
if (scrollRect && tableRect) {
style.push(`height:${Math.min(scrollRect.height, tableRect.height)}px;`);
let overflowHeight = this.get('overflowHeight');
if (!isNone(overflowHeight)) {
style.push(`height:${overflowHeight}px;`);
}

return htmlSafe(style.join(''));
}
);
};

/**
Computed property macro that builds the CSS styles (position, width)
for each vertical scroll indicator element.

@param {string} location - which location we are computing styles for: `top` or `bottom`
*/
const verticalIndicatorStyle = location => {
return computed(
`columnTree.${location}FixedNodes.@each.width`,
'overflowWidth',
'tableWidth',
'headerHeight',
'footerHeight',
'scrollbarHeight',
function() {
let style = [];
let offset = 0;

// top/bottom offset
if (location === 'top') {
let headerHeight = this.get('headerHeight') || 0;
offset += headerHeight;
}

if (location === 'bottom') {
let footerHeight = this.get('footerHeight') || 0;
let scrollbarHeight = this.get('scrollbarHeight') || 0;
offset += footerHeight + scrollbarHeight;
}

style.push(`${location}:${offset}px;`);

// width
let tableWidth = this.get('tableWidth');
if (!isNone(tableWidth)) {
let overflowWidth = this.get('overflowWidth');
let width = Math.min(tableWidth, overflowWidth);
style.push(`width:${width}px;`);
}

return htmlSafe(style.join(''));
}
);
};

/**
Computed property macro that builds a boolean to determine whether or not
to show a scroll indicator in the given position.

@param {string} location - `left`, `right`, `top`, or `bottom`
*/
const showIndicator = location => {
let scrollProp = `scroll${capitalize(location)}`;
return computed('enabledIndicators', scrollProp, function() {
return this.get('enabledIndicators').includes(location) && this.get(scrollProp) > 0;
});
};

export default Component.extend({
layout,
tagName: '',
Expand All @@ -54,77 +121,127 @@ export default Component.extend({
*/
api: null,

showLeft: true,
showRight: false,
scrollLeft: null,
scrollRight: null,
scrollTop: null,
scrollBottom: null,

scrollbarWidth: null,
scrollbarHeight: null,

overflowHeight: null,
overflowWidth: null,
tableWidth: null,
headerHeight: null,
footerHeight: null,

columnTree: readOnly('api.columnTree'),
enableScrollIndicators: readOnly('api.enableScrollIndicators'),
scrollIndicators: readOnly('api.scrollIndicators'),
tableScrollId: readOnly('api.tableId'),

leftStyle: indicatorStyle('left'),
rightStyle: indicatorStyle('right'),
showLeft: showIndicator('left'),
showRight: showIndicator('right'),
showTop: showIndicator('top'),
showBottom: showIndicator('bottom'),

leftStyle: horizontalIndicatorStyle('left'),
rightStyle: horizontalIndicatorStyle('right'),
topStyle: verticalIndicatorStyle('top'),
bottomStyle: verticalIndicatorStyle('bottom'),

enabledIndicators: computed('scrollIndicators', function() {
switch (this.get('scrollIndicators')) {
case true:
case 'all':
return ['left', 'right', 'top', 'bottom'];
case 'horizontal':
return ['left', 'right'];
case 'vertical':
return ['top', 'bottom'];
case false:
case 'none':
default:
return [];
}
}),

_addListeners() {
this._scrollElement = this._getScrollElement();
this._onScroll = bind(this, this._updateIndicatorShow);
this._scrollElement.addEventListener('scroll', this._onScroll);
this._isListening = true;

// cache static elements for performance
this._scrollElement = document.getElementById(this.get('tableScrollId'));
this._tableElement = this._scrollElement.querySelector('table');
this._resizeSensor = new ResizeSensor(
this._tableElement,
bind(this, this._updateIndicatorShow)
);
},
this._headerElement = this._tableElement.querySelector('thead');

_getScrollElement() {
return document.getElementById(this.get('tableScrollId'));
this._onScroll = bind(this, this._updateIndicators);
this._scrollElement.addEventListener('scroll', this._onScroll);
this._resizeSensor = new ResizeSensor(this._tableElement, bind(this, this._updateIndicators));
},

_removeListeners() {
if (this._scrollElement) {
this._scrollElement.removeEventListener('scroll', this._onScroll);
}
if (this._resizeSensor) {
this._resizeSensor.detach(this._tableElement);
}
this._isListening = false;
this._scrollElement.removeEventListener('scroll', this._onScroll);
this._resizeSensor.detach();
},

_setRects() {
let scrollElement = this._getScrollElement();
let scrollRect = scrollElement.getBoundingClientRect();
let tableRect = scrollElement.querySelector('table').getBoundingClientRect();
this.set('scrollRect', scrollRect);
this.set('tableRect', tableRect);
},

_updateIndicatorShow() {
this._setRects();
let scrollRect = this.get('scrollRect');
let tableRect = this.get('tableRect');
let xDiff = scrollRect.x - tableRect.x;
let widthDiff = tableRect.width - scrollRect.width;
this.set('showLeft', xDiff !== 0);
this.set('showRight', widthDiff > 0 && xDiff !== widthDiff);
_updateIndicators() {
let el = this._scrollElement;
let table = this._tableElement;
let header = this._headerElement;

// could appear/disappear over lifetime of component
let footer = table.querySelector('tfoot');

let scrollLeft = el.scrollLeft;
let scrollRight = el.scrollWidth - el.clientWidth - scrollLeft;
let scrollTop = el.scrollTop;
let scrollBottom = el.scrollHeight - el.clientHeight - scrollTop;

let scrollbarWidth = el.offsetWidth - el.clientWidth;
let scrollbarHeight = el.offsetHeight - el.clientHeight;

let overflowHeight = el.clientHeight;
let overflowWidth = el.clientWidth;
let tableWidth = table ? table.clientWidth : null;
let headerHeight = header ? header.offsetHeight : null;
let footerHeight = footer ? footer.offsetHeight : null;

this.setProperties({
scrollLeft,
scrollRight,
scrollTop,
scrollBottom,

scrollbarHeight,
scrollbarWidth,

overflowHeight,
overflowWidth,
tableWidth,
headerHeight,
footerHeight,
});
},

_updateListeners() {
if (this.get('enableScrollIndicators')) {
let hasIndicators = !isEmpty(this.get('enabledIndicators'));

if (hasIndicators && !this._isListening) {
this._addListeners();
} else {
this._updateIndicators();
} else if (!hasIndicators && this._isListening) {
this._removeListeners();
}
},

didInsertElement() {
this._super(...arguments);
this._updateIndicatorShow();
if (this.get('enableScrollIndicators')) {
this._addListeners();
}
addObserver(this, 'enableScrollIndicators', this._updateListeners);
this._updateListeners();
addObserver(this, 'enabledIndicators', this._updateListeners);
},

willDestroy() {
if (this.get('enableScrollIndicators')) {
if (this._isListening) {
this._removeListeners();
}
},
Expand Down
42 changes: 27 additions & 15 deletions addon/components/-private/scroll-indicators/template.hbs
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
{{#if enableScrollIndicators}}
{{#if showLeft}}
<div
data-test-ember-table-scroll-indicator="left"
class="scroll-indicator scroll-indicator__left"
style={{leftStyle}}
></div>
{{/if}}
{{#if showRight}}
<div
data-test-ember-table-scroll-indicator="right"
class="scroll-indicator scroll-indicator__right"
style={{rightStyle}}
></div>
{{/if}}
{{#if showLeft}}
<div
data-test-ember-table-scroll-indicator="left"
class="scroll-indicator scroll-indicator__left"
style={{leftStyle}}
></div>
{{/if}}
{{#if showRight}}
<div
data-test-ember-table-scroll-indicator="right"
class="scroll-indicator scroll-indicator__right"
style={{rightStyle}}
></div>
{{/if}}
{{#if showTop}}
<div
data-test-ember-table-scroll-indicator="top"
class="scroll-indicator scroll-indicator__top"
style={{topStyle}}
></div>
{{/if}}
{{#if showBottom}}
<div
data-test-ember-table-scroll-indicator="bottom"
class="scroll-indicator scroll-indicator__bottom"
style={{bottomStyle}}
></div>
{{/if}}
Loading