From 63e2e3afc63d384dd5d4bb6a4675168be216b603 Mon Sep 17 00:00:00 2001 From: Lyza Danger Gardner Date: Wed, 14 Oct 2020 13:42:52 -0400 Subject: [PATCH] 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. --- src/annotator/plugin/bucket-bar.js | 270 +++++++----- src/annotator/plugin/test/bucket-bar-test.js | 412 +++++++++++++------ src/annotator/sidebar.js | 4 +- src/annotator/test/pdf-sidebar-test.js | 4 +- src/annotator/test/sidebar-test.js | 4 +- 5 files changed, 460 insertions(+), 234 deletions(-) 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();