diff --git a/src/annotator/plugin/bucket-bar.js b/src/annotator/plugin/bucket-bar.js
index 3622fd288cb..e17e626013d 100644
--- a/src/annotator/plugin/bucket-bar.js
+++ b/src/annotator/plugin/bucket-bar.js
@@ -1,12 +1,18 @@
-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: '
',
// 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 = $('').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 = `${this.buckets[index].anchors.length}
`;
+ 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(`${bucketLength}
`);
+ 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
diff --git a/src/annotator/plugin/test/bucket-bar-test.js b/src/annotator/plugin/test/bucket-bar-test.js
index 3681eb57e58..de41998d23a 100644
--- a/src/annotator/plugin/test/bucket-bar-test.js
+++ b/src/annotator/plugin/test/bucket-bar-test.js
@@ -1,15 +1,15 @@
-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 = $('');
- const bucketBar = setup(tab);
-
- bucketBar._buildTabs();
- assert.equal(tab.attr('title'), 'Show one annotation');
- });
-
- it('creates a tab with a pluralized title', () => {
- const tab = $('');
- 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 = $('');
- 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 = $('');
- 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 = $('');
- 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 = $('').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 = $('');
- 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 = $('').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 = $('');
- 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 = $('');
- 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');
+ });
});
});
});
diff --git a/src/annotator/sidebar.js b/src/annotator/sidebar.js
index 95cbea7ea5d..56d4968c53e 100644
--- a/src/annotator/sidebar.js
+++ b/src/annotator/sidebar.js
@@ -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.
diff --git a/src/annotator/test/pdf-sidebar-test.js b/src/annotator/test/pdf-sidebar-test.js
index 74206795b0f..c6faa35ed0c 100644
--- a/src/annotator/test/pdf-sidebar-test.js
+++ b/src/annotator/test/pdf-sidebar-test.js
@@ -1,5 +1,3 @@
-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 = $('');
+ fakeBucketBar.element = document.createElement('div');
fakeBucketBar.destroy = sandbox.stub();
CrossFrame = sandbox.stub();
diff --git a/src/annotator/test/sidebar-test.js b/src/annotator/test/sidebar-test.js
index 59b048dcac6..eac4b1bf3e4 100644
--- a/src/annotator/test/sidebar-test.js
+++ b/src/annotator/test/sidebar-test.js
@@ -1,5 +1,3 @@
-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 = $('');
+ fakeBucketBar.element = document.createElement('div');
fakeBucketBar.destroy = sandbox.stub();
CrossFrame = sandbox.stub();