Commit 63e2e3af authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Remove jQuery dependency from BucketBar

Remove jQuery dependency from BucketBar, its tests
and other relevant modules' tests involving BucketBar.

Opportunistically add some type-checking, commenting and
other small improvements but leave general structure as-is.

Increase test coverage.
parent 0b6f8b10
import $ from 'jquery';
import Delegator from '../delegator';
import scrollIntoView from 'scroll-into-view';
import { setHighlightsFocused } from '../highlighter';
import {
findClosestOffscreenAnchor,
constructPositionPoints,
buildBuckets,
} from '../util/buckets';
/**
* @typedef {import('../util/buckets').Bucket} Bucket
* @typedef {import('../util/buckets').PositionPoints} PositionPoints
*/
const BUCKET_SIZE = 16; // Regular bucket size
const BUCKET_NAV_SIZE = BUCKET_SIZE + 6; // Bucket plus arrow (up/down)
const BUCKET_TOP_THRESHOLD = 115 + BUCKET_NAV_SIZE; // Toolbar
......@@ -22,186 +28,232 @@ function scrollToClosest(anchors, direction) {
export default class BucketBar extends Delegator {
constructor(element, options, annotator) {
const defaultOptions = {
// gapSize parameter is used by the clustering algorithm
// If an annotation is farther then this gapSize from the next bucket
// then that annotation will not be merged into the bucket
// TODO: This is not currently used; reassess
gapSize: 60,
html: '<div class="annotator-bucket-bar"></div>',
// Selectors for the scrollable elements on the page
scrollables: ['body'],
scrollables: [],
};
const opts = { ...defaultOptions, ...options };
super($(opts.html), opts);
const el = document.createElement('div');
el.className = 'annotator-bucket-bar';
super(el, opts);
this.annotator = annotator;
/** @type {Bucket[]} */
this.buckets = [];
this.tabs = $([]);
/** @type {HTMLElement[]} - Elements created in the bucket bar for each bucket */
this.tabs = [];
// The element to append this plugin's element to; defaults to the provided
// `element` unless a `container` option was provided
let container = /** @type {HTMLElement} */ (element);
if (this.options.container) {
$(this.options.container).append(this.element);
} else {
$(element).append(this.element);
// If a container element selector has been provided, and there is an
// element corresponding to that container — use it
const containerEl = /** @type {HTMLElement | null } */ (document.querySelector(
this.options.container
));
if (containerEl) {
container = containerEl;
} else {
// A container selector has been supplied, but it didn't pan out...
console.warn(
`Unable to find container element for selector '${this.options.container}'`
);
}
}
this.annotator = annotator;
container.appendChild(this.element);
this.updateFunc = () => this.update();
$(window).on('resize scroll', this.updateFunc);
window.addEventListener('resize', this.updateFunc);
window.addEventListener('scroll', this.updateFunc);
this.options.scrollables.forEach(scrollable => {
$(scrollable).on('scroll', this.updateFunc);
const scrollableElement = /** @type {HTMLElement | null} */ (document.querySelector(
scrollable
));
scrollableElement?.addEventListener('scroll', this.updateFunc);
});
}
destroy() {
$(window).off('resize scroll', this.updateFunc);
window.removeEventListener('resize', this.updateFunc);
window.removeEventListener('scroll', this.updateFunc);
this.options.scrollables.forEach(scrollable => {
$(scrollable).off('scroll', this.updateFunc);
const scrollableElement = /** @type {HTMLElement | null} */ (document.querySelector(
scrollable
));
scrollableElement?.removeEventListener('scroll', this.updateFunc);
});
}
/**
* Focus or unfocus the anchor highlights in the bucket indicated by `index`
*
* @param {number} index - The bucket's index in the `this.buckets` array
* @param {boolean} toggle - Should this set of highlights be focused (or
* un-focused)?
*/
updateHighlightFocus(index, toggle) {
if (index > 0 && this.buckets[index] && !this.isNavigationBucket(index)) {
const bucket = this.buckets[index];
bucket.anchors.forEach(anchor => {
setHighlightsFocused(anchor.highlights || [], toggle);
});
}
}
update() {
if (this._updatePending) {
return;
}
this._updatePending = true;
requestAnimationFrame(() => {
const updated = this._update();
this._update();
this._updatePending = false;
return updated;
});
}
_update() {
/** @type {PositionPoints} */
const { above, below, points } = constructPositionPoints(
this.annotator.anchors
);
this.buckets = buildBuckets(points);
// Scroll up
// Add a bucket to the top of the bar that, when clicked, will scroll up
// to the nearest bucket offscreen above, an upper navigation bucket
// TODO: This should be part of building the buckets
this.buckets.unshift(
{ anchors: [], position: 0 },
{ anchors: above, position: BUCKET_TOP_THRESHOLD - 1 },
{ anchors: [], position: BUCKET_TOP_THRESHOLD }
);
//this.index.unshift(0, BUCKET_TOP_THRESHOLD - 1, BUCKET_TOP_THRESHOLD);
// Scroll down,
// Add a bucket to the bottom of the bar that, when clicked, will scroll down
// to the nearest bucket offscreen below, a lower navigation bucket
// TODO: This should be part of building the buckets
this.buckets.push(
{ anchors: [], position: window.innerHeight - BUCKET_NAV_SIZE },
{ anchors: below, position: window.innerHeight - BUCKET_NAV_SIZE + 1 },
{ anchors: [], position: window.innerHeight }
);
// this.index.push(
// window.innerHeight - BUCKET_NAV_SIZE,
// window.innerHeight - BUCKET_NAV_SIZE + 1,
// window.innerHeight
// );
// Remove any extra tabs and update tabs.
this.tabs.slice(this.buckets.length).remove();
// The following affordances attempt to reuse existing DOM elements
// when reconstructing bucket "tabs" to cut down on the number of elements
// created and added to the DOM
// Only leave as many "tab" elements attached to the DOM as there are
// buckets
this.tabs.slice(this.buckets.length).forEach(tabEl => tabEl.remove());
// And cut the "tabs" collection down to the size of buckets, too
/** @type {HTMLElement[]} */
this.tabs = this.tabs.slice(0, this.buckets.length);
// Create any new tabs if needed.
$.each(this.buckets.slice(this.tabs.length), () => {
const div = $('<div/>').appendTo(this.element);
this.tabs.push(div[0]);
div
.addClass('annotator-bucket-indicator')
// Focus corresponding highlights bucket when mouse is hovered
// TODO: This should use event delegation on the container.
.on('mousemove', event => {
const bucketIndex = this.tabs.index(event.currentTarget);
for (let anchor of this.annotator.anchors) {
const toggle = this.buckets[bucketIndex].anchors.includes(anchor);
$(anchor.highlights).toggleClass(
'hypothesis-highlight-focused',
toggle
);
}
})
.on('mouseout', event => {
const bucket = this.tabs.index(event.currentTarget);
this.buckets[bucket].anchors.forEach(anchor =>
$(anchor.highlights).removeClass('hypothesis-highlight-focused')
// If the number of "tabs" currently in the DOM is too small (fewer than
// buckets), fill that gap by creating new elements (and adding event
// listeners to them)
this.buckets.slice(this.tabs.length).forEach(() => {
const tabEl = document.createElement('div');
this.tabs.push(tabEl);
// Note that these elements are reused as buckets change, meaning that
// any given tab element will correspond to a different bucket over time.
// However, we know that we have one "tab" per bucket, in order,
// so we can look up the correct bucket for a tab at event time.
// Focus and unfocus highlights on mouse events
tabEl.addEventListener('mousemove', () => {
this.updateHighlightFocus(this.tabs.indexOf(tabEl), true);
});
tabEl.addEventListener('mouseout', () => {
this.updateHighlightFocus(this.tabs.indexOf(tabEl), false);
});
// Select the annotations (in the sidebar)
// that have anchors within the clicked bucket
tabEl.addEventListener('click', event => {
event.stopPropagation();
const index = this.tabs.indexOf(tabEl);
const bucket = this.buckets[index];
if (!bucket) {
return;
}
if (this.isLower(index)) {
scrollToClosest(bucket.anchors, 'down');
} else if (this.isUpper(index)) {
scrollToClosest(bucket.anchors, 'up');
} else {
const annotations = bucket.anchors.map(anchor => anchor.annotation);
this.annotator.selectAnnotations(
annotations,
event.ctrlKey || event.metaKey
);
})
.on('click', event => {
const bucket = this.tabs.index(event.currentTarget);
event.stopPropagation();
// If it's the upper tab, scroll to next anchor above
if (this.isUpper(bucket)) {
scrollToClosest(this.buckets[bucket].anchors, 'up');
// If it's the lower tab, scroll to next anchor below
} else if (this.isLower(bucket)) {
scrollToClosest(this.buckets[bucket].anchors, 'down');
} else {
const annotations = this.buckets[bucket].anchors.map(
anchor => anchor.annotation
);
this.annotator.selectAnnotations(
annotations,
event.ctrlKey || event.metaKey
);
}
});
}
});
this.element.appendChild(tabEl);
});
this._buildTabs();
}
_buildTabs() {
this.tabs.each((index, el) => {
let bucketSize;
el = $(el);
const bucket = this.buckets[index];
const bucketLength = bucket?.anchors?.length;
const title = (() => {
if (bucketLength !== 1) {
return `Show ${bucketLength} annotations`;
} else if (bucketLength > 0) {
return 'Show one annotation';
this.tabs.forEach((tabEl, index) => {
let bucketHeight;
const anchorCount = this.buckets[index].anchors.length;
// Positioning logic currently _relies_ on their being interstitial
// buckets that have no anchors but do have positions. Positioning
// is averaged between this bucket's position and the _next_ bucket's
// position. For now. TODO: Fix this
const pos =
(this.buckets[index].position + this.buckets[index + 1]?.position) / 2;
tabEl.className = 'annotator-bucket-indicator';
tabEl.style.top = `${pos}px`;
tabEl.style.display = '';
if (anchorCount) {
tabEl.innerHTML = `<div class="label">${this.buckets[index].anchors.length}</div>`;
if (anchorCount === 1) {
tabEl.setAttribute('title', 'Show one annotation');
} else {
tabEl.setAttribute('title', `Show ${anchorCount} annotations`);
}
return '';
})();
el.attr('title', title);
el.toggleClass('upper', this.isUpper(index));
el.toggleClass('lower', this.isLower(index));
if (this.isUpper(index) || this.isLower(index)) {
bucketSize = BUCKET_NAV_SIZE;
} else {
bucketSize = BUCKET_SIZE;
tabEl.style.display = 'none';
}
el.css({
top: (bucket.position + this.buckets[index + 1]?.position) / 2,
marginTop: -bucketSize / 2,
display: !bucketLength ? 'none' : '',
});
if (bucket) {
el.html(`<div class='label'>${bucketLength}</div>`);
if (this.isNavigationBucket(index)) {
bucketHeight = BUCKET_NAV_SIZE;
tabEl.classList.toggle('upper', this.isUpper(index));
tabEl.classList.toggle('lower', this.isLower(index));
} else {
bucketHeight = BUCKET_SIZE;
tabEl.classList.remove('upper');
tabEl.classList.remove('lower');
}
tabEl.style.marginTop = (-1 * bucketHeight) / 2 + 'px';
});
}
isUpper(i) {
return i === 1;
}
isLower(i) {
return i === this.buckets.length - 2;
}
isNavigationBucket(i) {
return this.isUpper(i) || this.isLower(i);
}
}
// Export constants
......
import $ from 'jquery';
import BucketBar from '../bucket-bar';
import { $imports } from '../bucket-bar';
// Return DOM elements for non-empty bucket indicators in a `BucketBar`.
// Return DOM elements for non-empty bucket indicators in a `BucketBar`
// (i.e. bucket tab elements containing 1 or more anchors)
const nonEmptyBuckets = function (bucketBar) {
const buckets = bucketBar.element[0].querySelectorAll(
const buckets = bucketBar.element.querySelectorAll(
'.annotator-bucket-indicator'
);
return Array.from(buckets).filter(bucket => {
const label = bucket.querySelector('.label');
return parseInt(label.textContent) > 0;
return !!label;
});
};
......@@ -17,9 +17,27 @@ const createMouseEvent = function (type, { ctrlKey, metaKey } = {}) {
return new MouseEvent(type, { ctrlKey, metaKey });
};
// Create a fake anchor, which is a combination of annotation object and
// associated highlight elements.
const createAnchor = () => {
return {
annotation: { $tag: 'ann1' },
highlights: [document.createElement('span')],
};
};
describe('BucketBar', () => {
const sandbox = sinon.createSandbox();
let fakeAnnotator;
let fakeBucketUtil;
let fakeHighlighter;
let fakeScrollIntoView;
let bucketBar;
const createBucketBar = function (options) {
const element = document.createElement('div');
return new BucketBar(element, options || {}, fakeAnnotator);
};
beforeEach(() => {
fakeAnnotator = {
......@@ -35,32 +53,113 @@ describe('BucketBar', () => {
buildBuckets: sinon.stub().returns([]),
};
fakeHighlighter = {
setHighlightsFocused: sinon.stub(),
};
fakeScrollIntoView = sinon.stub();
$imports.$mock({
'scroll-into-view': fakeScrollIntoView,
'../highlighter': fakeHighlighter,
'../util/buckets': fakeBucketUtil,
});
sandbox.stub(window, 'requestAnimationFrame').yields();
});
afterEach(() => {
bucketBar?.destroy();
$imports.$restore();
sandbox.restore();
});
const createBucketBar = function (options) {
const element = document.createElement('div');
return new BucketBar(element, options || {}, fakeAnnotator);
};
describe('initializing and attaching to the DOM', () => {
let containerEl;
// Create a fake anchor, which is a combination of annotation object and
// associated highlight elements.
const createAnchor = () => {
return {
annotation: { $tag: 'ann1' },
highlights: [document.createElement('span')],
};
};
beforeEach(() => {
// Any element referenced by `options.container` selector needs to be
// present on the `document` before initialization
containerEl = document.createElement('div');
containerEl.className = 'bucket-bar-container';
document.body.appendChild(containerEl);
sandbox.stub(console, 'warn'); // Restored in test-global `afterEach`
});
context('when a bucket is clicked', () => {
let bucketBar;
afterEach(() => {
containerEl.remove();
});
it('will append its element to any supplied `options.container` selector', () => {
bucketBar = createBucketBar({ container: '.bucket-bar-container' });
assert.exists(containerEl.querySelector('.annotator-bucket-bar'));
});
it('will append itself to the element passed to constructor if `options.container` non-existent', () => {
bucketBar = createBucketBar({ container: '.bucket-bar-nope' });
assert.notExists(containerEl.querySelector('.annotator-bucket-bar'));
assert.calledOnce(console.warn);
});
});
describe('updating buckets', () => {
it('should update buckets when the window is resized', () => {
bucketBar = createBucketBar();
assert.notCalled(fakeBucketUtil.buildBuckets);
window.dispatchEvent(new Event('resize'));
assert.calledOnce(fakeBucketUtil.buildBuckets);
});
it('should update buckets when the window is scrolled', () => {
bucketBar = createBucketBar();
assert.notCalled(fakeBucketUtil.buildBuckets);
window.dispatchEvent(new Event('scroll'));
assert.calledOnce(fakeBucketUtil.buildBuckets);
});
context('when scrollables provided', () => {
let scrollableEls = [];
beforeEach(() => {
const scrollableEls1 = document.createElement('div');
scrollableEls1.className = 'scrollable-1';
const scrollableEls2 = document.createElement('div');
scrollableEls2.className = 'scrollable-2';
scrollableEls.push(scrollableEls1, scrollableEls2);
document.body.appendChild(scrollableEls1);
document.body.appendChild(scrollableEls2);
});
afterEach(() => {
// Explicitly call `destroy` before removing scrollable elements
// from document to test the scrollable-remove-events path of
// the `destroy` method. Otherwise, this afterEach will execute
// before the test-global one that calls `destroy`, and there will
// be no scrollable elements left in the document.
bucketBar.destroy();
scrollableEls.forEach(el => el.remove());
});
it('should update buckets when any scrollable scrolls', () => {
bucketBar = createBucketBar({
scrollables: ['.scrollable-1', '.scrollable-2'],
});
assert.notCalled(fakeBucketUtil.buildBuckets);
scrollableEls[0].dispatchEvent(new Event('scroll'));
assert.calledOnce(fakeBucketUtil.buildBuckets);
scrollableEls[1].dispatchEvent(new Event('scroll'));
assert.calledTwice(fakeBucketUtil.buildBuckets);
});
});
it('should not update if another update is pending', () => {
bucketBar._updatePending = true;
bucketBar.update();
assert.notCalled(window.requestAnimationFrame);
});
});
describe('user interactions with buckets', () => {
beforeEach(() => {
bucketBar = createBucketBar();
// Create fake anchors and render buckets.
......@@ -71,11 +170,34 @@ describe('BucketBar', () => {
]);
bucketBar.annotator.anchors = anchors;
bucketBar._update();
bucketBar.update();
});
it('highlights the bucket anchors when pointer device moved into bucket', () => {
const bucketEls = nonEmptyBuckets(bucketBar);
bucketEls[0].dispatchEvent(createMouseEvent('mousemove'));
assert.calledOnce(fakeHighlighter.setHighlightsFocused);
assert.calledWith(
fakeHighlighter.setHighlightsFocused,
bucketBar.annotator.anchors[0].highlights,
true
);
});
it('selects the annotations', () => {
// Click on the indicator for the non-empty bucket.
it('un-highlights the bucket anchors when pointer device moved out of bucket', () => {
const bucketEls = nonEmptyBuckets(bucketBar);
bucketEls[0].dispatchEvent(createMouseEvent('mousemove'));
bucketEls[0].dispatchEvent(createMouseEvent('mouseout'));
assert.calledTwice(fakeHighlighter.setHighlightsFocused);
const secondCall = fakeHighlighter.setHighlightsFocused.getCall(1);
assert.equal(
secondCall.args[0],
bucketBar.annotator.anchors[0].highlights
);
assert.equal(secondCall.args[1], false);
});
it('selects the annotations corresponding to the anchors in a bucket when bucket is clicked', () => {
const bucketEls = nonEmptyBuckets(bucketBar);
assert.equal(bucketEls.length, 1);
bucketEls[0].dispatchEvent(createMouseEvent('click'));
......@@ -84,12 +206,24 @@ describe('BucketBar', () => {
assert.calledWith(bucketBar.annotator.selectAnnotations, anns, false);
});
it('handles missing buckets gracefully on click', () => {
// FIXME - refactor and remove necessity for this test
// There is a coupling between `BucketBar.prototype.tabs` and
// `BucketBar.prototype.buckets` — they're "expected" to be the same
// length and correspond to each other. This very much should be the case,
// but, just in case...
const bucketEls = nonEmptyBuckets(bucketBar);
assert.equal(bucketEls.length, 1);
bucketBar.tabs = [];
bucketEls[0].dispatchEvent(createMouseEvent('click'));
assert.notCalled(bucketBar.annotator.selectAnnotations);
});
[
{ ctrlKey: true, metaKey: false },
{ ctrlKey: false, metaKey: true },
].forEach(({ ctrlKey, metaKey }) =>
it('toggles selection of the annotations if Ctrl or Alt is pressed', () => {
// Click on the indicator for the non-empty bucket.
const bucketEls = nonEmptyBuckets(bucketBar);
assert.equal(bucketEls.length, 1);
bucketEls[0].dispatchEvent(
......@@ -104,112 +238,154 @@ describe('BucketBar', () => {
);
});
// Yes this is testing a private method. Yes this is bad practice, but I'd
// rather test this functionality in a private method than not test it at all.
//
// Note: This could be tested using only the public APIs of the `BucketBar`
// class using the approach of the "when a bucket is clicked" tests above.
describe.skip('_buildTabs', () => {
const setup = function (tabs) {
const bucketBar = createBucketBar();
bucketBar.tabs = tabs;
bucketBar.buckets = [
{ anchors: [], position: 0 },
{
anchors: ['AN ANNOTATION?'],
position: BucketBar.BUCKET_TOP_THRESHOLD - 1,
},
{ anchors: [], position: BucketBar.BUCKET_TOP_THRESHOLD },
];
return bucketBar;
};
it('creates a tab with a title', () => {
const tab = $('<div />');
const bucketBar = setup(tab);
bucketBar._buildTabs();
assert.equal(tab.attr('title'), 'Show one annotation');
});
it('creates a tab with a pluralized title', () => {
const tab = $('<div />');
const bucketBar = setup(tab);
bucketBar.buckets[0].anchors.push('Another Annotation?');
bucketBar._buildTabs();
assert.equal(tab.attr('title'), 'Show 2 annotations');
});
it('sets the tab text to the number of annotations', () => {
const tab = $('<div />');
const bucketBar = setup(tab);
bucketBar.buckets[0].push('Another Annotation?');
bucketBar._buildTabs();
assert.equal(tab.text(), '2');
});
it('sets the tab text to the number of annotations', () => {
const tab = $('<div />');
const bucketBar = setup(tab);
bucketBar.buckets[0].push('Another Annotation?');
describe('rendered bucket "tabs"', () => {
let fakeAnchors;
let fakeAbove;
let fakeBelow;
let fakeBuckets;
bucketBar._buildTabs();
assert.equal(tab.text(), '2');
});
it('adds the class "upper" if the annotation is at the top', () => {
const tab = $('<div />');
const bucketBar = setup(tab);
sinon.stub(bucketBar, 'isUpper').returns(true);
bucketBar._buildTabs();
assert.equal(tab.hasClass('upper'), true);
});
it('removes the class "upper" if the annotation is not at the top', () => {
const tab = $('<div />').addClass('upper');
const bucketBar = setup(tab);
sinon.stub(bucketBar, 'isUpper').returns(false);
bucketBar._buildTabs();
assert.equal(tab.hasClass('upper'), false);
beforeEach(() => {
bucketBar = createBucketBar();
fakeAnchors = [
createAnchor(),
createAnchor(),
createAnchor(),
createAnchor(),
createAnchor(),
createAnchor(),
];
// These two anchors are considered to be offscreen upwards
fakeAbove = [fakeAnchors[0], fakeAnchors[1]];
// These buckets are on-screen
fakeBuckets = [
{ anchors: [fakeAnchors[2], fakeAnchors[3]], position: 350 },
{ anchors: [], position: 450 }, // This is an empty bucket
{ anchors: [fakeAnchors[4]], position: 550 },
];
// This anchor is offscreen below
fakeBelow = [fakeAnchors[5]];
fakeBucketUtil.constructPositionPoints.returns({
above: fakeAbove,
below: fakeBelow,
points: [],
});
fakeBucketUtil.buildBuckets.returns(fakeBuckets.slice());
});
it('adds the class "lower" if the annotation is at the top', () => {
const tab = $('<div />');
const bucketBar = setup(tab);
sinon.stub(bucketBar, 'isLower').returns(true);
describe('navigation bucket tabs', () => {
it('adds navigation tabs to scroll up and down to nearest anchors offscreen', () => {
bucketBar.update();
const validBuckets = nonEmptyBuckets(bucketBar);
assert.equal(
validBuckets[0].getAttribute('title'),
'Show 2 annotations'
);
assert.equal(
validBuckets[validBuckets.length - 1].getAttribute('title'),
'Show one annotation'
);
bucketBar._buildTabs();
assert.equal(tab.hasClass('lower'), true);
assert.isTrue(validBuckets[0].classList.contains('upper'));
assert.isTrue(
validBuckets[validBuckets.length - 1].classList.contains('lower')
);
});
it('removes unneeded tab elements from the document', () => {
bucketBar.update();
const extraEl = document.createElement('div');
extraEl.className = 'extraTab';
bucketBar.element.append(extraEl);
bucketBar.tabs.push(extraEl);
assert.equal(bucketBar.tabs.length, bucketBar.buckets.length + 1);
// Resetting this return is necessary to return a fresh array reference
// on next update
fakeBucketUtil.buildBuckets.returns(fakeBuckets.slice());
bucketBar.update();
assert.equal(bucketBar.tabs.length, bucketBar.buckets.length);
assert.notExists(bucketBar.element.querySelector('.extraTab'));
});
it('scrolls up to nearest anchor above when upper navigation tab clicked', () => {
fakeBucketUtil.findClosestOffscreenAnchor.returns(fakeAnchors[1]);
bucketBar.update();
const visibleBuckets = nonEmptyBuckets(bucketBar);
visibleBuckets[0].dispatchEvent(createMouseEvent('click'));
assert.calledOnce(fakeBucketUtil.findClosestOffscreenAnchor);
assert.calledWith(
fakeBucketUtil.findClosestOffscreenAnchor,
sinon.match([fakeAnchors[0], fakeAnchors[1]]),
'up'
);
assert.calledOnce(fakeScrollIntoView);
assert.calledWith(fakeScrollIntoView, fakeAnchors[1].highlights[0]);
});
it('scrolls down to nearest anchor below when lower navigation tab clicked', () => {
fakeBucketUtil.findClosestOffscreenAnchor.returns(fakeAnchors[5]);
bucketBar.update();
const visibleBuckets = nonEmptyBuckets(bucketBar);
visibleBuckets[visibleBuckets.length - 1].dispatchEvent(
createMouseEvent('click')
);
assert.calledOnce(fakeBucketUtil.findClosestOffscreenAnchor);
assert.calledWith(
fakeBucketUtil.findClosestOffscreenAnchor,
sinon.match([fakeAnchors[5]]),
'down'
);
assert.calledOnce(fakeScrollIntoView);
assert.calledWith(fakeScrollIntoView, fakeAnchors[5].highlights[0]);
});
});
it('removes the class "lower" if the annotation is not at the top', () => {
const tab = $('<div />').addClass('lower');
const bucketBar = setup(tab);
sinon.stub(bucketBar, 'isLower').returns(false);
bucketBar._buildTabs();
assert.equal(tab.hasClass('lower'), false);
it('displays bucket tabs that have at least one anchor', () => {
bucketBar.update();
const visibleBuckets = nonEmptyBuckets(bucketBar);
// Visible buckets include: upper navigation tab, two on-screen buckets,
// lower navigation tab = 4
assert.equal(visibleBuckets.length, 4);
visibleBuckets.forEach(visibleEl => {
assert.equal(visibleEl.style.display, '');
});
});
it('reveals the tab if there are annotations in the bucket', () => {
const tab = $('<div />');
const bucketBar = setup(tab);
bucketBar._buildTabs();
assert.equal(tab.css('display'), '');
it('sets bucket-tab label text and title based on number of anchors', () => {
bucketBar.update();
const visibleBuckets = nonEmptyBuckets(bucketBar);
// Upper navigation bucket tab
assert.equal(visibleBuckets[0].title, 'Show 2 annotations');
assert.equal(visibleBuckets[0].querySelector('.label').innerHTML, '2');
// First on-screen visible bucket
assert.equal(visibleBuckets[1].title, 'Show 2 annotations');
assert.equal(visibleBuckets[1].querySelector('.label').innerHTML, '2');
// Second on-screen visible bucket
assert.equal(visibleBuckets[2].title, 'Show one annotation');
assert.equal(visibleBuckets[2].querySelector('.label').innerHTML, '1');
// Lower navigation bucket tab
assert.equal(visibleBuckets[3].title, 'Show one annotation');
assert.equal(visibleBuckets[3].querySelector('.label').innerHTML, '1');
});
it('hides the tab if there are no annotations in the bucket', () => {
const tab = $('<div />');
const bucketBar = setup(tab);
bucketBar.buckets = [];
bucketBar._buildTabs();
assert.equal(tab.css('display'), 'none');
it('does not display empty bucket tabs', () => {
fakeBucketUtil.buildBuckets.returns([]);
fakeBucketUtil.constructPositionPoints.returns({
above: [],
below: [],
points: [],
});
bucketBar.update();
const allBuckets = bucketBar.element.querySelectorAll(
'.annotator-bucket-indicator'
);
// All of the buckets are empty...
allBuckets.forEach(bucketEl => {
assert.equal(bucketEl.style.display, 'none');
});
});
});
});
......@@ -42,7 +42,9 @@ export default class Sidebar extends Host {
}
if (this.plugins.BucketBar) {
this.plugins.BucketBar.element.on('click', () => this.show());
this.plugins.BucketBar.element.addEventListener('click', () =>
this.show()
);
}
// Set up the toolbar on the left edge of the sidebar.
......
import $ from 'jquery';
import PdfSidebar from '../pdf-sidebar';
import { $imports } from '../pdf-sidebar';
......@@ -46,7 +44,7 @@ describe('PdfSidebar', () => {
fakeCrossFrame.destroy = sandbox.stub();
const fakeBucketBar = {};
fakeBucketBar.element = $('<div></div>');
fakeBucketBar.element = document.createElement('div');
fakeBucketBar.destroy = sandbox.stub();
CrossFrame = sandbox.stub();
......
import $ from 'jquery';
import events from '../../shared/bridge-events';
import Sidebar from '../sidebar';
......@@ -68,7 +66,7 @@ describe('Sidebar', () => {
FakeToolbarController = sinon.stub().returns(fakeToolbar);
const fakeBucketBar = {};
fakeBucketBar.element = $('<div></div>');
fakeBucketBar.element = document.createElement('div');
fakeBucketBar.destroy = sandbox.stub();
CrossFrame = sandbox.stub();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment