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 Delegator from '../delegator';
import scrollIntoView from 'scroll-into-view'; import scrollIntoView from 'scroll-into-view';
import { setHighlightsFocused } from '../highlighter';
import { import {
findClosestOffscreenAnchor, findClosestOffscreenAnchor,
constructPositionPoints, constructPositionPoints,
buildBuckets, buildBuckets,
} from '../util/buckets'; } from '../util/buckets';
/**
* @typedef {import('../util/buckets').Bucket} Bucket
* @typedef {import('../util/buckets').PositionPoints} PositionPoints
*/
const BUCKET_SIZE = 16; // Regular bucket size const BUCKET_SIZE = 16; // Regular bucket size
const BUCKET_NAV_SIZE = BUCKET_SIZE + 6; // Bucket plus arrow (up/down) const BUCKET_NAV_SIZE = BUCKET_SIZE + 6; // Bucket plus arrow (up/down)
const BUCKET_TOP_THRESHOLD = 115 + BUCKET_NAV_SIZE; // Toolbar const BUCKET_TOP_THRESHOLD = 115 + BUCKET_NAV_SIZE; // Toolbar
...@@ -22,186 +28,232 @@ function scrollToClosest(anchors, direction) { ...@@ -22,186 +28,232 @@ function scrollToClosest(anchors, direction) {
export default class BucketBar extends Delegator { export default class BucketBar extends Delegator {
constructor(element, options, annotator) { constructor(element, options, annotator) {
const defaultOptions = { 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 // Selectors for the scrollable elements on the page
scrollables: ['body'], scrollables: [],
}; };
const opts = { ...defaultOptions, ...options }; 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.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) { if (this.options.container) {
$(this.options.container).append(this.element); // If a container element selector has been provided, and there is an
} else { // element corresponding to that container — use it
$(element).append(this.element); 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}'`
);
}
} }
container.appendChild(this.element);
this.annotator = annotator;
this.updateFunc = () => this.update(); 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 => { this.options.scrollables.forEach(scrollable => {
$(scrollable).on('scroll', this.updateFunc); const scrollableElement = /** @type {HTMLElement | null} */ (document.querySelector(
scrollable
));
scrollableElement?.addEventListener('scroll', this.updateFunc);
}); });
} }
destroy() { destroy() {
$(window).off('resize scroll', this.updateFunc); window.removeEventListener('resize', this.updateFunc);
window.removeEventListener('scroll', this.updateFunc);
this.options.scrollables.forEach(scrollable => { 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() { update() {
if (this._updatePending) { if (this._updatePending) {
return; return;
} }
this._updatePending = true; this._updatePending = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
const updated = this._update(); this._update();
this._updatePending = false; this._updatePending = false;
return updated;
}); });
} }
_update() { _update() {
/** @type {PositionPoints} */
const { above, below, points } = constructPositionPoints( const { above, below, points } = constructPositionPoints(
this.annotator.anchors this.annotator.anchors
); );
this.buckets = buildBuckets(points); 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( this.buckets.unshift(
{ anchors: [], position: 0 }, { anchors: [], position: 0 },
{ anchors: above, position: BUCKET_TOP_THRESHOLD - 1 }, { anchors: above, position: BUCKET_TOP_THRESHOLD - 1 },
{ anchors: [], position: BUCKET_TOP_THRESHOLD } { 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( this.buckets.push(
{ anchors: [], position: window.innerHeight - BUCKET_NAV_SIZE }, { anchors: [], position: window.innerHeight - BUCKET_NAV_SIZE },
{ anchors: below, position: window.innerHeight - BUCKET_NAV_SIZE + 1 }, { anchors: below, position: window.innerHeight - BUCKET_NAV_SIZE + 1 },
{ anchors: [], position: window.innerHeight } { anchors: [], position: window.innerHeight }
); );
// this.index.push(
// window.innerHeight - BUCKET_NAV_SIZE, // The following affordances attempt to reuse existing DOM elements
// window.innerHeight - BUCKET_NAV_SIZE + 1, // when reconstructing bucket "tabs" to cut down on the number of elements
// window.innerHeight // created and added to the DOM
// );
// Only leave as many "tab" elements attached to the DOM as there are
// Remove any extra tabs and update tabs. // buckets
this.tabs.slice(this.buckets.length).remove(); 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); this.tabs = this.tabs.slice(0, this.buckets.length);
// Create any new tabs if needed. // If the number of "tabs" currently in the DOM is too small (fewer than
$.each(this.buckets.slice(this.tabs.length), () => { // buckets), fill that gap by creating new elements (and adding event
const div = $('<div/>').appendTo(this.element); // listeners to them)
this.buckets.slice(this.tabs.length).forEach(() => {
this.tabs.push(div[0]); const tabEl = document.createElement('div');
this.tabs.push(tabEl);
div
.addClass('annotator-bucket-indicator') // Note that these elements are reused as buckets change, meaning that
// any given tab element will correspond to a different bucket over time.
// Focus corresponding highlights bucket when mouse is hovered // However, we know that we have one "tab" per bucket, in order,
// TODO: This should use event delegation on the container. // so we can look up the correct bucket for a tab at event time.
.on('mousemove', event => {
const bucketIndex = this.tabs.index(event.currentTarget); // Focus and unfocus highlights on mouse events
for (let anchor of this.annotator.anchors) { tabEl.addEventListener('mousemove', () => {
const toggle = this.buckets[bucketIndex].anchors.includes(anchor); this.updateHighlightFocus(this.tabs.indexOf(tabEl), true);
$(anchor.highlights).toggleClass( });
'hypothesis-highlight-focused',
toggle tabEl.addEventListener('mouseout', () => {
); this.updateHighlightFocus(this.tabs.indexOf(tabEl), false);
} });
})
// Select the annotations (in the sidebar)
.on('mouseout', event => { // that have anchors within the clicked bucket
const bucket = this.tabs.index(event.currentTarget); tabEl.addEventListener('click', event => {
this.buckets[bucket].anchors.forEach(anchor => event.stopPropagation();
$(anchor.highlights).removeClass('hypothesis-highlight-focused') 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(); this.element.appendChild(tabEl);
// 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._buildTabs(); this._buildTabs();
} }
_buildTabs() { _buildTabs() {
this.tabs.each((index, el) => { this.tabs.forEach((tabEl, index) => {
let bucketSize; let bucketHeight;
el = $(el); const anchorCount = this.buckets[index].anchors.length;
const bucket = this.buckets[index]; // Positioning logic currently _relies_ on their being interstitial
const bucketLength = bucket?.anchors?.length; // buckets that have no anchors but do have positions. Positioning
// is averaged between this bucket's position and the _next_ bucket's
const title = (() => { // position. For now. TODO: Fix this
if (bucketLength !== 1) { const pos =
return `Show ${bucketLength} annotations`; (this.buckets[index].position + this.buckets[index + 1]?.position) / 2;
} else if (bucketLength > 0) {
return 'Show one annotation'; 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 { } else {
bucketSize = BUCKET_SIZE; tabEl.style.display = 'none';
} }
el.css({ if (this.isNavigationBucket(index)) {
top: (bucket.position + this.buckets[index + 1]?.position) / 2, bucketHeight = BUCKET_NAV_SIZE;
marginTop: -bucketSize / 2, tabEl.classList.toggle('upper', this.isUpper(index));
display: !bucketLength ? 'none' : '', tabEl.classList.toggle('lower', this.isLower(index));
}); } else {
bucketHeight = BUCKET_SIZE;
if (bucket) { tabEl.classList.remove('upper');
el.html(`<div class='label'>${bucketLength}</div>`); tabEl.classList.remove('lower');
} }
tabEl.style.marginTop = (-1 * bucketHeight) / 2 + 'px';
}); });
} }
isUpper(i) { isUpper(i) {
return i === 1; return i === 1;
} }
isLower(i) { isLower(i) {
return i === this.buckets.length - 2; return i === this.buckets.length - 2;
} }
isNavigationBucket(i) {
return this.isUpper(i) || this.isLower(i);
}
} }
// Export constants // Export constants
......
import $ from 'jquery';
import BucketBar from '../bucket-bar'; import BucketBar from '../bucket-bar';
import { $imports } 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 nonEmptyBuckets = function (bucketBar) {
const buckets = bucketBar.element[0].querySelectorAll( const buckets = bucketBar.element.querySelectorAll(
'.annotator-bucket-indicator' '.annotator-bucket-indicator'
); );
return Array.from(buckets).filter(bucket => { return Array.from(buckets).filter(bucket => {
const label = bucket.querySelector('.label'); const label = bucket.querySelector('.label');
return parseInt(label.textContent) > 0; return !!label;
}); });
}; };
...@@ -17,9 +17,27 @@ const createMouseEvent = function (type, { ctrlKey, metaKey } = {}) { ...@@ -17,9 +17,27 @@ const createMouseEvent = function (type, { ctrlKey, metaKey } = {}) {
return new MouseEvent(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', () => { describe('BucketBar', () => {
const sandbox = sinon.createSandbox();
let fakeAnnotator; let fakeAnnotator;
let fakeBucketUtil; let fakeBucketUtil;
let fakeHighlighter;
let fakeScrollIntoView;
let bucketBar;
const createBucketBar = function (options) {
const element = document.createElement('div');
return new BucketBar(element, options || {}, fakeAnnotator);
};
beforeEach(() => { beforeEach(() => {
fakeAnnotator = { fakeAnnotator = {
...@@ -35,32 +53,113 @@ describe('BucketBar', () => { ...@@ -35,32 +53,113 @@ describe('BucketBar', () => {
buildBuckets: sinon.stub().returns([]), buildBuckets: sinon.stub().returns([]),
}; };
fakeHighlighter = {
setHighlightsFocused: sinon.stub(),
};
fakeScrollIntoView = sinon.stub();
$imports.$mock({ $imports.$mock({
'scroll-into-view': fakeScrollIntoView,
'../highlighter': fakeHighlighter,
'../util/buckets': fakeBucketUtil, '../util/buckets': fakeBucketUtil,
}); });
sandbox.stub(window, 'requestAnimationFrame').yields();
}); });
afterEach(() => { afterEach(() => {
bucketBar?.destroy();
$imports.$restore(); $imports.$restore();
sandbox.restore();
}); });
const createBucketBar = function (options) { describe('initializing and attaching to the DOM', () => {
const element = document.createElement('div'); let containerEl;
return new BucketBar(element, options || {}, fakeAnnotator);
};
// Create a fake anchor, which is a combination of annotation object and beforeEach(() => {
// associated highlight elements. // Any element referenced by `options.container` selector needs to be
const createAnchor = () => { // present on the `document` before initialization
return { containerEl = document.createElement('div');
annotation: { $tag: 'ann1' }, containerEl.className = 'bucket-bar-container';
highlights: [document.createElement('span')], document.body.appendChild(containerEl);
}; sandbox.stub(console, 'warn'); // Restored in test-global `afterEach`
}; });
context('when a bucket is clicked', () => { afterEach(() => {
let bucketBar; 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(() => { beforeEach(() => {
bucketBar = createBucketBar(); bucketBar = createBucketBar();
// Create fake anchors and render buckets. // Create fake anchors and render buckets.
...@@ -71,11 +170,34 @@ describe('BucketBar', () => { ...@@ -71,11 +170,34 @@ describe('BucketBar', () => {
]); ]);
bucketBar.annotator.anchors = anchors; 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', () => { it('un-highlights the bucket anchors when pointer device moved out of bucket', () => {
// Click on the indicator for the non-empty 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); const bucketEls = nonEmptyBuckets(bucketBar);
assert.equal(bucketEls.length, 1); assert.equal(bucketEls.length, 1);
bucketEls[0].dispatchEvent(createMouseEvent('click')); bucketEls[0].dispatchEvent(createMouseEvent('click'));
...@@ -84,12 +206,24 @@ describe('BucketBar', () => { ...@@ -84,12 +206,24 @@ describe('BucketBar', () => {
assert.calledWith(bucketBar.annotator.selectAnnotations, anns, false); 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: true, metaKey: false },
{ ctrlKey: false, metaKey: true }, { ctrlKey: false, metaKey: true },
].forEach(({ ctrlKey, metaKey }) => ].forEach(({ ctrlKey, metaKey }) =>
it('toggles selection of the annotations if Ctrl or Alt is pressed', () => { 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); const bucketEls = nonEmptyBuckets(bucketBar);
assert.equal(bucketEls.length, 1); assert.equal(bucketEls.length, 1);
bucketEls[0].dispatchEvent( bucketEls[0].dispatchEvent(
...@@ -104,112 +238,154 @@ describe('BucketBar', () => { ...@@ -104,112 +238,154 @@ describe('BucketBar', () => {
); );
}); });
// Yes this is testing a private method. Yes this is bad practice, but I'd describe('rendered bucket "tabs"', () => {
// rather test this functionality in a private method than not test it at all. let fakeAnchors;
// let fakeAbove;
// Note: This could be tested using only the public APIs of the `BucketBar` let fakeBelow;
// class using the approach of the "when a bucket is clicked" tests above. let fakeBuckets;
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?');
bucketBar._buildTabs(); beforeEach(() => {
assert.equal(tab.text(), '2'); bucketBar = createBucketBar();
}); fakeAnchors = [
createAnchor(),
it('adds the class "upper" if the annotation is at the top', () => { createAnchor(),
const tab = $('<div />'); createAnchor(),
const bucketBar = setup(tab); createAnchor(),
sinon.stub(bucketBar, 'isUpper').returns(true); createAnchor(),
createAnchor(),
bucketBar._buildTabs(); ];
assert.equal(tab.hasClass('upper'), true); // These two anchors are considered to be offscreen upwards
}); fakeAbove = [fakeAnchors[0], fakeAnchors[1]];
// These buckets are on-screen
it('removes the class "upper" if the annotation is not at the top', () => { fakeBuckets = [
const tab = $('<div />').addClass('upper'); { anchors: [fakeAnchors[2], fakeAnchors[3]], position: 350 },
const bucketBar = setup(tab); { anchors: [], position: 450 }, // This is an empty bucket
sinon.stub(bucketBar, 'isUpper').returns(false); { anchors: [fakeAnchors[4]], position: 550 },
];
bucketBar._buildTabs(); // This anchor is offscreen below
assert.equal(tab.hasClass('upper'), false); 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', () => { describe('navigation bucket tabs', () => {
const tab = $('<div />'); it('adds navigation tabs to scroll up and down to nearest anchors offscreen', () => {
const bucketBar = setup(tab); bucketBar.update();
sinon.stub(bucketBar, 'isLower').returns(true); 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.isTrue(validBuckets[0].classList.contains('upper'));
assert.equal(tab.hasClass('lower'), true); 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', () => { it('displays bucket tabs that have at least one anchor', () => {
const tab = $('<div />').addClass('lower'); bucketBar.update();
const bucketBar = setup(tab); const visibleBuckets = nonEmptyBuckets(bucketBar);
sinon.stub(bucketBar, 'isLower').returns(false); // Visible buckets include: upper navigation tab, two on-screen buckets,
// lower navigation tab = 4
bucketBar._buildTabs(); assert.equal(visibleBuckets.length, 4);
assert.equal(tab.hasClass('lower'), false); visibleBuckets.forEach(visibleEl => {
assert.equal(visibleEl.style.display, '');
});
}); });
it('reveals the tab if there are annotations in the bucket', () => { it('sets bucket-tab label text and title based on number of anchors', () => {
const tab = $('<div />'); bucketBar.update();
const bucketBar = setup(tab); const visibleBuckets = nonEmptyBuckets(bucketBar);
// Upper navigation bucket tab
bucketBar._buildTabs(); assert.equal(visibleBuckets[0].title, 'Show 2 annotations');
assert.equal(tab.css('display'), ''); 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', () => { it('does not display empty bucket tabs', () => {
const tab = $('<div />'); fakeBucketUtil.buildBuckets.returns([]);
const bucketBar = setup(tab); fakeBucketUtil.constructPositionPoints.returns({
bucketBar.buckets = []; above: [],
below: [],
bucketBar._buildTabs(); points: [],
assert.equal(tab.css('display'), 'none'); });
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 { ...@@ -42,7 +42,9 @@ export default class Sidebar extends Host {
} }
if (this.plugins.BucketBar) { 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. // Set up the toolbar on the left edge of the sidebar.
......
import $ from 'jquery';
import PdfSidebar from '../pdf-sidebar'; import PdfSidebar from '../pdf-sidebar';
import { $imports } from '../pdf-sidebar'; import { $imports } from '../pdf-sidebar';
...@@ -46,7 +44,7 @@ describe('PdfSidebar', () => { ...@@ -46,7 +44,7 @@ describe('PdfSidebar', () => {
fakeCrossFrame.destroy = sandbox.stub(); fakeCrossFrame.destroy = sandbox.stub();
const fakeBucketBar = {}; const fakeBucketBar = {};
fakeBucketBar.element = $('<div></div>'); fakeBucketBar.element = document.createElement('div');
fakeBucketBar.destroy = sandbox.stub(); fakeBucketBar.destroy = sandbox.stub();
CrossFrame = sandbox.stub(); CrossFrame = sandbox.stub();
......
import $ from 'jquery';
import events from '../../shared/bridge-events'; import events from '../../shared/bridge-events';
import Sidebar from '../sidebar'; import Sidebar from '../sidebar';
...@@ -68,7 +66,7 @@ describe('Sidebar', () => { ...@@ -68,7 +66,7 @@ describe('Sidebar', () => {
FakeToolbarController = sinon.stub().returns(fakeToolbar); FakeToolbarController = sinon.stub().returns(fakeToolbar);
const fakeBucketBar = {}; const fakeBucketBar = {};
fakeBucketBar.element = $('<div></div>'); fakeBucketBar.element = document.createElement('div');
fakeBucketBar.destroy = sandbox.stub(); fakeBucketBar.destroy = sandbox.stub();
CrossFrame = 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