Commit 9e69dcb6 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Create preact components for Buckets

Create a preact component `Buckets`, and sub-components `BucketButton`
and `NavigationBucketButton`. This refactors buckets to add some
HTML semantics and accessibility.
parent 4adc389c
import { createElement } from 'preact';
import classnames from 'classnames';
import propTypes from 'prop-types';
import scrollIntoView from 'scroll-into-view';
import { setHighlightsFocused } from '../highlighter';
import { findClosestOffscreenAnchor } from '../util/buckets';
/**
* @typedef {import('../../types/annotator').AnnotationData} AnnotationData
* @typedef {import('../util/buckets').Bucket} Bucket
*/
/**
* A left-pointing indicator button that, when hovered or clicked, highlights
* or selects associated annotations.
*
* @param {Object} props
* @param {Bucket} props.bucket
* @param {(annotations: AnnotationData[], toggle: boolean) => any} props.onSelectAnnotations
*/
function BucketButton({ bucket, onSelectAnnotations }) {
const annotations = bucket.anchors.map(anchor => anchor.annotation);
const buttonTitle = `Select nearby annotations (${bucket.anchors.length})`;
function selectAnnotations(event) {
event.stopPropagation();
onSelectAnnotations(annotations, event.metaKey || event.ctrlKey);
}
function setFocus(focusState) {
bucket.anchors.forEach(anchor => {
setHighlightsFocused(anchor.highlights || [], focusState);
});
}
return (
<button
className="bucket-button bucket-button--left"
onClick={event => selectAnnotations(event)}
onMouseMove={() => setFocus(true)}
onMouseOut={() => setFocus(false)}
onBlur={() => setFocus(false)}
title={buttonTitle}
aria-label={buttonTitle}
>
{bucket.anchors.length}
</button>
);
}
BucketButton.propTypes = {
bucket: propTypes.object.isRequired,
onSelectAnnotations: propTypes.func.isRequired,
};
/**
* An up- or down-pointing button that will scroll to the next closest bucket
* of annotations in the given direction.
*
* @param {Object} props
* @param {Bucket} props.bucket
* @param {'up'|'down'} props.direction
*/
function NavigationBucketButton({ bucket, direction }) {
const buttonTitle = `Go ${direction} to next annotations (${bucket.anchors.length})`;
function scrollToClosest(event) {
event.stopPropagation();
const closest = findClosestOffscreenAnchor(bucket.anchors, direction);
if (closest?.highlights?.length) {
scrollIntoView(closest.highlights[0]);
}
}
return (
<button
className={classnames('bucket-button', `bucket-button--${direction}`)}
onClick={event => scrollToClosest(event)}
title={buttonTitle}
aria-label={buttonTitle}
>
{bucket.anchors.length}
</button>
);
}
NavigationBucketButton.propTypes = {
bucket: propTypes.object.isRequired,
direction: propTypes.string,
};
/**
* A list of buckets, including up and down navigation (when applicable) and
* on-screen buckets
*
* @param {Object} props
* @param {Bucket} props.above
* @param {Bucket} props.below
* @param {Bucket[]} props.buckets
* @param {(annotations: AnnotationData[], toggle: boolean) => any} props.onSelectAnnotations
*/
export default function Buckets({
above,
below,
buckets,
onSelectAnnotations,
}) {
const showUpNavigation = above.anchors.length > 0;
const showDownNavigation = below.anchors.length > 0;
return (
<ul className="buckets">
{showUpNavigation && (
<li className="bucket" style={{ top: above.position }}>
<NavigationBucketButton bucket={above} direction="up" />
</li>
)}
{buckets.map((bucket, index) => (
<li className="bucket" style={{ top: bucket.position }} key={index}>
<BucketButton
bucket={bucket}
onSelectAnnotations={onSelectAnnotations}
/>
</li>
))}
{showDownNavigation && (
<li className="bucket" style={{ top: below.position }}>
<NavigationBucketButton bucket={below} direction="down" />
</li>
)}
</ul>
);
}
Buckets.propTypes = {
above: propTypes.object.isRequired,
below: propTypes.object.isRequired,
buckets: propTypes.array.isRequired,
onSelectAnnotations: propTypes.func.isRequired,
};
import { mount } from 'enzyme';
import { createElement } from 'preact';
import { checkAccessibility } from '../../../test-util/accessibility';
import Buckets from '../buckets';
import { $imports } from '../buckets';
describe('Buckets', () => {
let fakeBucketsUtil;
let fakeHighlighter;
let fakeScrollIntoView;
let fakeAbove;
let fakeBelow;
let fakeBuckets;
const createComponent = props =>
mount(
<Buckets
above={fakeAbove}
below={fakeBelow}
buckets={fakeBuckets}
onSelectAnnotations={() => null}
{...props}
/>
);
beforeEach(() => {
fakeAbove = { anchors: ['hi', 'there'], position: 150 };
fakeBelow = { anchors: ['ho', 'there'], position: 550 };
fakeBuckets = [
{
anchors: [
{ annotation: { $tag: 't1' }, highlights: ['hi'] },
{ annotation: { $tag: 't2' }, highlights: ['yay'] },
],
position: 250,
},
{ anchors: ['you', 'also', 'are', 'welcome'], position: 350 },
];
fakeBucketsUtil = {
findClosestOffscreenAnchor: sinon.stub().returns({}),
};
fakeHighlighter = {
setHighlightsFocused: sinon.stub(),
};
fakeScrollIntoView = sinon.stub();
$imports.$mock({
'scroll-into-view': fakeScrollIntoView,
'../highlighter': fakeHighlighter,
'../util/buckets': fakeBucketsUtil,
});
});
afterEach(() => {
$imports.$restore();
});
describe('up and down navigation', () => {
it('renders an up navigation button if there are above-screen anchors', () => {
const wrapper = createComponent();
const upButton = wrapper.find('.bucket-button--up');
// The list item element wrapping the button
const bucketItem = wrapper.find('.bucket').first();
assert.isTrue(upButton.exists());
assert.equal(
bucketItem.getDOMNode().style.top,
`${fakeAbove.position}px`
);
});
it('does not render an up navigation button if there are no above-screen anchors', () => {
fakeAbove = { anchors: [], position: 150 };
const wrapper = createComponent();
assert.isFalse(wrapper.find('.bucket-button--up').exists());
});
it('renders a down navigation button if there are below-screen anchors', () => {
const wrapper = createComponent();
const downButton = wrapper.find('.bucket-button--down');
// The list item element wrapping the button
const bucketItem = wrapper.find('.bucket').last();
assert.isTrue(downButton.exists());
assert.equal(
bucketItem.getDOMNode().style.top,
`${fakeBelow.position}px`
);
});
it('does not render a down navigation button if there are no below-screen anchors', () => {
fakeBelow = { anchors: [], position: 550 };
const wrapper = createComponent();
assert.isFalse(wrapper.find('.bucket-button--down').exists());
});
it('scrolls to anchors above when up navigation button is pressed', () => {
const fakeAnchor = { highlights: ['hi'] };
fakeBucketsUtil.findClosestOffscreenAnchor.returns(fakeAnchor);
const wrapper = createComponent();
const upButton = wrapper.find('.bucket-button--up');
upButton.simulate('click');
assert.calledWith(
fakeBucketsUtil.findClosestOffscreenAnchor,
fakeAbove.anchors,
'up'
);
assert.calledWith(fakeScrollIntoView, fakeAnchor.highlights[0]);
});
it('scrolls to anchors below when down navigation button is pressed', () => {
const fakeAnchor = { highlights: ['hi'] };
fakeBucketsUtil.findClosestOffscreenAnchor.returns(fakeAnchor);
const wrapper = createComponent();
const downButton = wrapper.find('.bucket-button--down');
downButton.simulate('click');
assert.calledWith(
fakeBucketsUtil.findClosestOffscreenAnchor,
fakeBelow.anchors,
'down'
);
assert.calledWith(fakeScrollIntoView, fakeAnchor.highlights[0]);
});
});
describe('on-screen buckets', () => {
it('renders a bucket button for each bucket', () => {
const wrapper = createComponent();
assert.equal(wrapper.find('.bucket-button--left').length, 2);
});
it('focuses associated anchors when mouse enters the element', () => {
const wrapper = createComponent();
wrapper.find('.bucket-button--left').first().simulate('mousemove');
assert.calledTwice(fakeHighlighter.setHighlightsFocused);
assert.calledWith(
fakeHighlighter.setHighlightsFocused,
fakeBuckets[0].anchors[0].highlights,
true
);
assert.calledWith(
fakeHighlighter.setHighlightsFocused,
fakeBuckets[0].anchors[1].highlights,
true
);
});
it('removes focus on associated anchors when element is blurred', () => {
const wrapper = createComponent();
wrapper.find('.bucket-button--left').first().simulate('blur');
assert.calledTwice(fakeHighlighter.setHighlightsFocused);
assert.calledWith(
fakeHighlighter.setHighlightsFocused,
fakeBuckets[0].anchors[0].highlights,
false
);
assert.calledWith(
fakeHighlighter.setHighlightsFocused,
fakeBuckets[0].anchors[1].highlights,
false
);
});
it('removes focus on associated anchors when mouse leaves the element', () => {
const wrapper = createComponent();
wrapper.find('.bucket-button--left').first().simulate('mouseout');
assert.calledTwice(fakeHighlighter.setHighlightsFocused);
assert.calledWith(
fakeHighlighter.setHighlightsFocused,
fakeBuckets[0].anchors[0].highlights,
false
);
});
it('selects associated annotations when bucket button pressed', () => {
const fakeOnSelectAnnotations = sinon.stub();
const wrapper = createComponent({
onSelectAnnotations: fakeOnSelectAnnotations,
});
wrapper
.find('.bucket-button--left')
.first()
.simulate('click', { metaKey: false, ctrlKey: false });
assert.calledOnce(fakeOnSelectAnnotations);
const call = fakeOnSelectAnnotations.getCall(0);
assert.deepEqual(call.args[0], [
fakeBuckets[0].anchors[0].annotation,
fakeBuckets[0].anchors[1].annotation,
]);
assert.equal(call.args[1], false);
});
it('toggles annotation selection if metakey pressed', () => {
const fakeOnSelectAnnotations = sinon.stub();
const wrapper = createComponent({
onSelectAnnotations: fakeOnSelectAnnotations,
});
wrapper
.find('.bucket-button--left')
.first()
.simulate('click', { metaKey: true, ctrlKey: false });
const call = fakeOnSelectAnnotations.getCall(0);
assert.equal(call.args[1], true);
});
});
it(
'should pass a11y checks',
checkAccessibility([
{
content: () => createComponent(),
},
])
);
});
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