mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
Merge pull request #4002 from spalger/fix/fixedScroll
Refactor Fixed Scrolling
This commit is contained in:
commit
ef8554b2f6
8 changed files with 294 additions and 120 deletions
|
@ -8,6 +8,7 @@
|
|||
"define": true,
|
||||
"require": true,
|
||||
"console": false,
|
||||
"-event": true
|
||||
"-event": true,
|
||||
"-name": true
|
||||
}
|
||||
}
|
||||
|
|
114
src/kibana/components/fixedScroll.js
Normal file
114
src/kibana/components/fixedScroll.js
Normal file
|
@ -0,0 +1,114 @@
|
|||
define(function (require) {
|
||||
var $ = require('jquery');
|
||||
var _ = require('lodash');
|
||||
|
||||
var SCROLLER_HEIGHT = 20;
|
||||
|
||||
require('modules')
|
||||
.get('kibana')
|
||||
.directive('fixedScroll', function ($timeout) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function ($scope, $el) {
|
||||
var $window = $(window);
|
||||
var $scroller = $('<div class="fixed-scroll-scroller">').height(SCROLLER_HEIGHT);
|
||||
|
||||
/**
|
||||
* Listen for scroll events on the $scroller and the $el, sets unlisten()
|
||||
*
|
||||
* unlisten must be called before calling or listen() will throw an Error
|
||||
*
|
||||
* Since the browser emits "scroll" events after setting scrollLeft
|
||||
* the listeners also prevent tug-of-war
|
||||
*
|
||||
* @throws {Error} If unlisten was not called first
|
||||
* @return {undefined}
|
||||
*/
|
||||
function listen() {
|
||||
if (unlisten !== _.noop) {
|
||||
throw new Error('fixedScroll listeners were not cleaned up properly before re-listening!');
|
||||
}
|
||||
|
||||
var blockTo;
|
||||
function bind($from, $to) {
|
||||
function handler() {
|
||||
if (blockTo === $to) return (blockTo = null);
|
||||
$to.scrollLeft((blockTo = $from).scrollLeft());
|
||||
}
|
||||
|
||||
$from.on('scroll', handler);
|
||||
return function () {
|
||||
$from.off('scroll', handler);
|
||||
};
|
||||
}
|
||||
|
||||
unlisten = _.compose(
|
||||
bind($el, $scroller),
|
||||
bind($scroller, $el),
|
||||
function () { unlisten = _.noop; }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the listeners bound in listen()
|
||||
* @type {function}
|
||||
*/
|
||||
var unlisten = _.noop;
|
||||
|
||||
/**
|
||||
* Revert DOM changes and event listeners
|
||||
* @return {undefined}
|
||||
*/
|
||||
function cleanUp() {
|
||||
unlisten();
|
||||
$scroller.detach();
|
||||
$el.css('padding-bottom', 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify the DOM and attach event listeners based on need.
|
||||
* Is called many times to re-setup, must be idempotent
|
||||
* @return {undefined}
|
||||
*/
|
||||
function setup() {
|
||||
cleanUp();
|
||||
|
||||
var containerWidth = $el.width();
|
||||
var contentWidth = $el.prop('scrollWidth');
|
||||
var containerHorizOverflow = contentWidth - containerWidth;
|
||||
|
||||
var elTop = $el.offset().top - $window.scrollTop();
|
||||
var elBottom = elTop + $el.height();
|
||||
var windowVertOverflow = elBottom - $window.height();
|
||||
|
||||
var requireScroller = containerHorizOverflow > 0 && windowVertOverflow > 0;
|
||||
if (!requireScroller) return;
|
||||
|
||||
// push the content away from the scroller
|
||||
$el.css('padding-bottom', SCROLLER_HEIGHT);
|
||||
|
||||
// fill the scroller with a dummy element that mimics the content
|
||||
$scroller
|
||||
.width(containerWidth)
|
||||
.html($('<div>').css({ width: contentWidth, height: SCROLLER_HEIGHT }))
|
||||
.insertAfter($el);
|
||||
|
||||
// listen for scroll events
|
||||
listen();
|
||||
}
|
||||
|
||||
// reset when the width or scrollWidth of the $el changes
|
||||
$scope.$watchMulti([
|
||||
function () { return $el.prop('scrollWidth'); },
|
||||
function () { return $el.width(); }
|
||||
], setup);
|
||||
|
||||
// cleanup when the scope is destroyed
|
||||
$scope.$on('$destroy', function () {
|
||||
cleanUp();
|
||||
$scroller = $window = null;
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
|
@ -1,111 +0,0 @@
|
|||
// Creates a fake scrollbar at the bottom of an element. Useful for infinite scrolling components
|
||||
define(function (require) {
|
||||
var module = require('modules').get('kibana');
|
||||
var $ = require('jquery');
|
||||
var _ = require('lodash');
|
||||
|
||||
module.directive('fixedScroll', function ($timeout) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: {
|
||||
fixedScrollTrigger: '=fixedScrollTrigger',
|
||||
anchor: '@fixedScroll',
|
||||
},
|
||||
link: function ($scope, $elem, attrs) {
|
||||
|
||||
var options = {
|
||||
fixedScrollMarkup: '<div class="fixedScroll-container" ' +
|
||||
'style="height: 20px;"><div class="fixedScroll-scroll" style="height: 20px;"></div></div>',
|
||||
fixedScrollInnerSelector: '.fixedScroll-scroll'
|
||||
};
|
||||
|
||||
var innerElem;
|
||||
|
||||
var fixedScroll = $($(options.fixedScrollMarkup));
|
||||
fixedScroll.css({position: 'fixed', bottom: 0});
|
||||
|
||||
|
||||
var addScroll = function ($elem, options) {
|
||||
|
||||
// Set the inner element that gives context to the scroll
|
||||
if ($scope.anchor !== undefined && $elem.find($scope.anchor).length !== 0) {
|
||||
innerElem = $elem.find($scope.anchor);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// If content isn't wide enough to scroll, abort
|
||||
if ($elem.get(0).scrollWidth <= $elem.width()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// add container for fake scrollbar
|
||||
$elem.after(fixedScroll);
|
||||
|
||||
// bind fixed scroll to real scroll
|
||||
fixedScroll.bind('scroll.fixedScroll', function () {
|
||||
$elem.scrollLeft(fixedScroll.scrollLeft());
|
||||
});
|
||||
|
||||
// and bind real scroll to fixed scroll
|
||||
var scrollHandler = function () {
|
||||
fixedScroll.scrollLeft($elem.scrollLeft());
|
||||
};
|
||||
$elem.bind('scroll.fixedScroll', scrollHandler);
|
||||
|
||||
fixedScroll.css({'overflow-x': 'auto', 'overflow-y': 'hidden'});
|
||||
$elem.css({'overflow-x': 'auto', 'overflow-y': 'hidden'});
|
||||
|
||||
// Check the width until it stops changing
|
||||
var setWidth = function (innerElemWidth) {
|
||||
$timeout(function () {
|
||||
if (innerElemWidth !== innerElem.outerWidth()) {
|
||||
setWidth(innerElem.outerWidth());
|
||||
} else {
|
||||
$(options.fixedScrollInnerSelector, fixedScroll).width(innerElem.outerWidth());
|
||||
fixedScroll.width($elem.width());
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
setWidth(innerElem.outerWidth());
|
||||
|
||||
};
|
||||
|
||||
addScroll($elem, options);
|
||||
|
||||
var recompute = function () {
|
||||
$elem.unbind('scroll.fixedScroll');
|
||||
$elem.prev('div.fixedScroll-container').remove();
|
||||
addScroll($elem, options);
|
||||
};
|
||||
|
||||
// Create a watchable for the content element
|
||||
$scope.innerElemWidth = function () {
|
||||
if (!innerElem) return;
|
||||
return innerElem.outerWidth();
|
||||
};
|
||||
|
||||
// Watch window size
|
||||
$(window).resize(recompute);
|
||||
|
||||
// Watch the trigger if there is one
|
||||
$scope.$watchCollection('fixedScrollTrigger', function () {
|
||||
recompute();
|
||||
});
|
||||
|
||||
// And watch the element width
|
||||
$scope.$watch('innerElemWidth()', function (width) {
|
||||
recompute();
|
||||
});
|
||||
|
||||
// Clean up listeners
|
||||
$scope.$on('$destroy', function () {
|
||||
$elem.unbind('scroll.fixedScroll');
|
||||
fixedScroll.unbind('scroll.fixedScroll');
|
||||
});
|
||||
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
|
@ -10,7 +10,7 @@ define(function (require) {
|
|||
|
||||
require('components/notify/notify');
|
||||
require('components/timepicker/timepicker');
|
||||
require('directives/fixed_scroll');
|
||||
require('components/fixedScroll');
|
||||
require('directives/validate_json');
|
||||
require('components/validate_query/validate_query');
|
||||
require('filters/moment');
|
||||
|
|
|
@ -179,10 +179,7 @@
|
|||
<visualize ng-if="vis && rows.length != 0" vis="vis" es-resp="mergedEsResp" search-source="searchSource"></visualize>
|
||||
</div>
|
||||
|
||||
<div class="discover-table"
|
||||
fixed-scroll='table'
|
||||
fixed-scroll-trigger="state.columns">
|
||||
|
||||
<div class="discover-table" fixed-scroll>
|
||||
<doc-table
|
||||
hits="rows"
|
||||
index-pattern="indexPattern"
|
||||
|
|
|
@ -512,3 +512,15 @@ fieldset {
|
|||
border: 1px solid @input-border;
|
||||
border-radius: @input-border-radius;
|
||||
}
|
||||
|
||||
[fixed-scroll] {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0px;
|
||||
|
||||
+ .fixed-scroll-scroller {
|
||||
position: fixed;
|
||||
bottom: 0px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
}
|
||||
|
|
159
test/unit/specs/directives/fixedScroll.js
Normal file
159
test/unit/specs/directives/fixedScroll.js
Normal file
|
@ -0,0 +1,159 @@
|
|||
define(function (require) {
|
||||
require('components/fixedScroll');
|
||||
|
||||
describe('FixedScroll directive', function () {
|
||||
var $ = require('jquery');
|
||||
var sinon = require('test_utils/auto_release_sinon');
|
||||
var Promise = require('bluebird');
|
||||
|
||||
var compile;
|
||||
var trash = [];
|
||||
|
||||
beforeEach(module('kibana'));
|
||||
beforeEach(inject(function ($compile, $rootScope) {
|
||||
|
||||
compile = function (ratioY, ratioX) {
|
||||
if (ratioX == null) ratioX = ratioY;
|
||||
|
||||
// since the directive works at the sibling level we create a
|
||||
// parent for everything to happen in
|
||||
var $parent = $('<div>').css({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0
|
||||
});
|
||||
|
||||
$parent.appendTo(document.body);
|
||||
trash.push($parent);
|
||||
|
||||
var $el = $('<div fixed-scroll></div>').css({
|
||||
'overflow-x': 'auto',
|
||||
'width': $parent.width()
|
||||
}).appendTo($parent);
|
||||
|
||||
var $content = $('<div>').css({
|
||||
width: $parent.width() * ratioX,
|
||||
height: $parent.height() * ratioY
|
||||
}).appendTo($el);
|
||||
|
||||
$compile($parent)($rootScope);
|
||||
$rootScope.$digest();
|
||||
|
||||
return {
|
||||
$container: $el,
|
||||
$content: $content,
|
||||
$scroller: $parent.find('.fixed-scroll-scroller')
|
||||
};
|
||||
};
|
||||
|
||||
}));
|
||||
|
||||
afterEach(function () {
|
||||
trash.splice(0).forEach(function ($el) {
|
||||
$el.remove();
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing when not needed', function () {
|
||||
var els = compile(0.5, 1.5);
|
||||
expect(els.$scroller).to.have.length(0);
|
||||
|
||||
els = compile(1.5, 0.5);
|
||||
expect(els.$scroller).to.have.length(0);
|
||||
});
|
||||
|
||||
it('attaches a scroller below the element when the content is larger then the container', function () {
|
||||
var els = compile(1.5);
|
||||
expect(els.$scroller).to.have.length(1);
|
||||
});
|
||||
|
||||
it('copies the width of the container', function () {
|
||||
var els = compile(1.5);
|
||||
expect(els.$scroller.width()).to.be(els.$container.width());
|
||||
});
|
||||
|
||||
it('mimics the scrollWidth of the element', function () {
|
||||
var els = compile(1.5);
|
||||
expect(els.$scroller.prop('scrollWidth')).to.be(els.$container.prop('scrollWidth'));
|
||||
});
|
||||
|
||||
describe('scroll event handling / tug of war prevention', function () {
|
||||
it('listens when needed, unlistens when not needed', function () {
|
||||
var on = sinon.spy($.fn, 'on');
|
||||
var off = sinon.spy($.fn, 'off');
|
||||
|
||||
function checkThisVals(name, spy) {
|
||||
// the this values should be different
|
||||
expect(spy.thisValues[0].is(spy.thisValues[1])).to.be(false);
|
||||
// but they should be either $scroller or $container
|
||||
spy.thisValues.forEach(function ($this) {
|
||||
if ($this.is(els.$scroller) || $this.is(els.$container)) return;
|
||||
expect.fail('expected ' + name + ' to be called with $scroller or $container');
|
||||
});
|
||||
}
|
||||
|
||||
var els = compile(1.5);
|
||||
expect(on.callCount).to.be(2);
|
||||
checkThisVals('$.fn.on', on);
|
||||
|
||||
expect(off.callCount).to.be(0);
|
||||
els.$container.width(els.$container.prop('scrollWidth'));
|
||||
els.$container.scope().$digest();
|
||||
expect(off.callCount).to.be(2);
|
||||
|
||||
checkThisVals('$.fn.off', off);
|
||||
});
|
||||
|
||||
[
|
||||
{ from: '$container', to: '$scroller' },
|
||||
{ from: '$scroller', to: '$container' }
|
||||
].forEach(function (names) {
|
||||
describe('scroll events ' + JSON.stringify(names), function () {
|
||||
var spy;
|
||||
var els;
|
||||
var $from;
|
||||
var $to;
|
||||
|
||||
beforeEach(function () {
|
||||
spy = sinon.spy($.fn, 'scrollLeft');
|
||||
els = compile(1.5);
|
||||
$from = els[names.from];
|
||||
$to = els[names.to];
|
||||
});
|
||||
|
||||
it('transfers the scrollLeft', function () {
|
||||
expect(spy.callCount).to.be(0);
|
||||
$from.scroll();
|
||||
expect(spy.callCount).to.be(2);
|
||||
|
||||
// first call should read the scrollLeft from the $container
|
||||
var firstCall = spy.getCall(0);
|
||||
expect(firstCall.thisValue.is($from)).to.be(true);
|
||||
expect(firstCall.args).to.eql([]);
|
||||
|
||||
// second call should be setting the scrollLeft on the $scroller
|
||||
var secondCall = spy.getCall(1);
|
||||
expect(secondCall.thisValue.is($to)).to.be(true);
|
||||
expect(secondCall.args).to.eql([firstCall.returnValue]);
|
||||
});
|
||||
|
||||
/**
|
||||
* In practice, calling $el.scrollLeft() causes the "scroll" event to trigger,
|
||||
* but the browser seems to be very careful about triggering the event too much
|
||||
* and I can't reliably recreate the browsers behavior in a test. So... faking it!
|
||||
*/
|
||||
it('prevents tug of war by ignoring echo scroll events', function () {
|
||||
$from.scroll();
|
||||
expect(spy.callCount).to.be(2);
|
||||
|
||||
spy.reset();
|
||||
$to.scroll();
|
||||
expect(spy.callCount).to.be(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -326,9 +326,11 @@ define(function (require) {
|
|||
chart.tooltipFormatter = function (str) {
|
||||
return '<div class="popup-stub"></div>';
|
||||
};
|
||||
var layerIds = _.keys(map._layers);
|
||||
var id = layerIds[_.random(1, layerIds.length - 1)]; // layer 0 is tileLayer
|
||||
map._layers[id].fire('mouseover');
|
||||
|
||||
var featureLayer = _.sample(_.filter(map._layers, 'feature'));
|
||||
|
||||
expect($('.popup-stub', vis.el).length).to.be(0);
|
||||
featureLayer.fire('mouseover');
|
||||
expect($('.popup-stub', vis.el).length).to.be(1);
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue