Commit ad20c5f2 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Migrate annotator `Buckets` to modern styles, components, tailwind

parent 3a5aa936
import { LabeledButton } from '@hypothesis/frontend-shared';
import classnames from 'classnames';
/**
* @typedef {import('../util/buckets').Bucket} Bucket
* @typedef {import("preact").ComponentChildren} Children
*/
/**
* A left-pointing indicator button that, when hovered or clicked, highlights
* or selects associated annotations.
* Render a set of buckets in a vertical channel positioned along the edge of
* the sidebar.
*
* @param {object} props
* @param {Bucket} props.bucket
* @param {(tags: string[]) => void} props.onFocusAnnotations
* @param {(tags: string[], toggle: boolean) => void} props.onSelectAnnotations
* @prop {Children} props.children
*/
function BucketButton({ bucket, onFocusAnnotations, onSelectAnnotations }) {
const buttonTitle = `Select nearby annotations (${bucket.tags.size})`;
function selectAnnotations(event) {
onSelectAnnotations([...bucket.tags], event.metaKey || event.ctrlKey);
}
/** @param {boolean} hasFocus */
function setFocus(hasFocus) {
if (hasFocus) {
onFocusAnnotations([...bucket.tags]);
} else {
onFocusAnnotations([]);
}
}
function BucketList({ children }) {
return (
<button
className="Buckets__button Buckets__button--left"
onClick={event => selectAnnotations(event)}
onBlur={() => setFocus(false)}
onFocus={() => setFocus(true)}
onMouseEnter={() => setFocus(true)}
onMouseOut={() => setFocus(false)}
title={buttonTitle}
aria-label={buttonTitle}
<ul
className={classnames(
// 2020-11-20: Making bucket bar one pixel wider (23px vs 22px) is an
// interim and pragmatic solution for an apparent glitch on
// Safari and Chrome. Adding one pixel resolves this issue:
// https://github.com/hypothesis/client/pull/2750
'absolute w-[23px] left-[-22px] h-full',
// The background is set to low opacity when the sidebar is collapsed.
'bg-grey-2 annotator-collapsed:bg-black/[.08]',
// Disable pointer events along the sidebar itself; re-enable them in
// bucket indicator buttons
'pointer-events-none'
)}
>
{bucket.tags.size}
</button>
{children}
</ul>
);
}
/**
* An up- or down-pointing button that will scroll to the next closest bucket
* of annotations in the given direction.
* Render a vertically-positioned bucket-list item.
*
* @param {object} props
* @param {Bucket} props.bucket
* @param {'down'|'up'} props.direction
* @param {(tags: string[]) => void} props.onFocusAnnotations
* @param {(tags: string[], direction: 'down'|'up') => void} props.onScrollToClosestOffScreenAnchor
* @prop {Children} props.children
* @prop {number} props.topPosition - The vertical top position, in pixels,
* for this bucket item relative to the top of the containing BucketList
*/
function NavigationBucketButton({
bucket,
direction,
onFocusAnnotations,
onScrollToClosestOffScreenAnchor,
}) {
const buttonTitle = `Go ${direction} to next annotations (${bucket.tags.size})`;
/** @param {boolean} hasFocus */
function setFocus(hasFocus) {
if (hasFocus) {
onFocusAnnotations([...bucket.tags]);
} else {
onFocusAnnotations([]);
}
}
function BucketItem({ children, topPosition }) {
return (
<button
className={classnames('Buckets__button', `Buckets__button--${direction}`)}
onClick={() =>
onScrollToClosestOffScreenAnchor([...bucket.tags], direction)
}
onBlur={() => setFocus(false)}
onFocus={() => setFocus(true)}
onMouseEnter={() => setFocus(true)}
onMouseOut={() => setFocus(false)}
title={buttonTitle}
aria-label={buttonTitle}
<li
className={classnames(
'absolute right-0',
// Re-enable pointer events, which are disabled on the containing list
'pointer-events-auto'
)}
style={{ top: topPosition }}
>
{bucket.tags.size}
</button>
{children}
</li>
);
}
......@@ -114,40 +81,75 @@ export default function Buckets({
const showDownNavigation = below.tags.size > 0;
return (
<ul className="Buckets__list">
<BucketList>
{showUpNavigation && (
<li className="Buckets__bucket" style={{ top: above.position }}>
<NavigationBucketButton
bucket={above}
direction="up"
onFocusAnnotations={onFocusAnnotations}
onScrollToClosestOffScreenAnchor={onScrollToClosestOffScreenAnchor}
/>
</li>
<BucketItem topPosition={above.position}>
<LabeledButton
className={classnames(
'BucketButton UpBucketButton',
// Center the button vertically at `above.position` by pulling
// its top margin up by about half the button's height.
// This puts it nearer the toolbar's other buttons above the
// bucket list.
'right-0 mt-[-11px]'
)}
data-testid="up-navigation-button"
onClick={() =>
onScrollToClosestOffScreenAnchor([...above.tags], 'up')
}
onBlur={() => onFocusAnnotations([])}
onFocus={() => onFocusAnnotations([...above.tags])}
onMouseEnter={() => onFocusAnnotations([...above.tags])}
onMouseOut={() => onFocusAnnotations([])}
title={`Go up to next annotations (${above.tags.size})`}
>
{above.tags.size}
</LabeledButton>
</BucketItem>
)}
{buckets.map((bucket, index) => (
<li
className="Buckets__bucket"
style={{ top: bucket.position }}
key={index}
<BucketItem topPosition={bucket.position} key={index}>
<LabeledButton
className={classnames(
'BucketButton LeftBucketButton',
// Center the bucket indicator button vertically on `bucket.position`
// by pulling it by half the height of the button
'right-0 mt-[-8px]'
)}
onClick={event =>
onSelectAnnotations(
[...bucket.tags],
event.metaKey || event.ctrlKey
)
}
onBlur={() => onFocusAnnotations([])}
onFocus={() => onFocusAnnotations([...bucket.tags])}
onMouseEnter={() => onFocusAnnotations([...bucket.tags])}
onMouseOut={() => onFocusAnnotations([])}
title={`Select nearby annotations (${bucket.tags.size})`}
>
<BucketButton
bucket={bucket}
onFocusAnnotations={onFocusAnnotations}
onSelectAnnotations={onSelectAnnotations}
/>
</li>
{bucket.tags.size}
</LabeledButton>
</BucketItem>
))}
{showDownNavigation && (
<li className="Buckets__bucket" style={{ top: below.position }}>
<NavigationBucketButton
bucket={below}
direction="down"
onFocusAnnotations={onFocusAnnotations}
onScrollToClosestOffScreenAnchor={onScrollToClosestOffScreenAnchor}
/>
</li>
<BucketItem topPosition={below.position}>
<LabeledButton
className="BucketButton DownBucketButton right-0"
data-testid="down-navigation-button"
onClick={() =>
onScrollToClosestOffScreenAnchor([...below.tags], 'down')
}
onBlur={() => onFocusAnnotations([])}
onFocus={() => onFocusAnnotations([...below.tags])}
onMouseEnter={() => onFocusAnnotations([...below.tags])}
onMouseOut={() => onFocusAnnotations([])}
title={`Go up to next annotations (${below.tags.size})`}
>
{below.tags.size}
</LabeledButton>
</BucketItem>
)}
</ul>
</BucketList>
);
}
......@@ -45,37 +45,76 @@ describe('Buckets', () => {
);
describe('up and down navigation', () => {
it('focuses associated anchors when mouse enters the element', () => {
const upButtonSelector = '[data-testid="up-navigation-button"] button';
const downButtonSelector = '[data-testid="down-navigation-button"] button';
it('focuses associated anchors above the screen when mouse enters the element', () => {
const wrapper = createComponent();
wrapper.find('.Buckets__button--up').first().simulate('mouseenter');
wrapper.find(upButtonSelector).simulate('mouseenter');
assert.calledOnce(fakeOnFocusAnnotations);
assert.calledWith(fakeOnFocusAnnotations, ['a1', 'a2']);
});
it('removes focus on associated anchors when mouse leaves the element', () => {
it('focuses associated anchors below the screen when mouse enters the element', () => {
const wrapper = createComponent();
wrapper.find(downButtonSelector).simulate('mouseenter');
assert.calledOnce(fakeOnFocusAnnotations);
assert.calledWith(fakeOnFocusAnnotations, ['b1', 'b2']);
});
it('removes focus on associated anchors above screen when mouse leaves the element', () => {
const wrapper = createComponent();
wrapper.find('.Buckets__button--up').first().simulate('mouseout');
wrapper.find(upButtonSelector).simulate('mouseout');
assert.calledOnce(fakeOnFocusAnnotations);
assert.calledWith(fakeOnFocusAnnotations, []);
});
it('focuses associated anchors when the element is focused', () => {
it('removes focus on associated anchors below screen when mouse leaves the element', () => {
const wrapper = createComponent();
wrapper.find('.Buckets__button--up').first().simulate('focus');
wrapper.find(downButtonSelector).simulate('mouseout');
assert.calledOnce(fakeOnFocusAnnotations);
assert.calledWith(fakeOnFocusAnnotations, []);
});
it('focuses associated anchors above screen when the element is focused', () => {
const wrapper = createComponent();
wrapper.find(upButtonSelector).simulate('focus');
assert.calledOnce(fakeOnFocusAnnotations);
assert.calledWith(fakeOnFocusAnnotations, ['a1', 'a2']);
});
it('removes focus on associated anchors when element is blurred', () => {
it('focuses associated anchors below screen when the element is focused', () => {
const wrapper = createComponent();
wrapper.find(downButtonSelector).simulate('focus');
assert.calledOnce(fakeOnFocusAnnotations);
assert.calledWith(fakeOnFocusAnnotations, ['b1', 'b2']);
});
it('removes focus on associated anchors above screen when element is blurred', () => {
const wrapper = createComponent();
wrapper.find(upButtonSelector).simulate('blur');
assert.calledOnce(fakeOnFocusAnnotations);
assert.calledWith(fakeOnFocusAnnotations, []);
});
it('removes focus on associated anchors below screen when element is blurred', () => {
const wrapper = createComponent();
wrapper.find('.Buckets__button--up').first().simulate('blur');
wrapper.find(downButtonSelector).simulate('blur');
assert.calledOnce(fakeOnFocusAnnotations);
assert.calledWith(fakeOnFocusAnnotations, []);
......@@ -83,9 +122,9 @@ describe('Buckets', () => {
it('renders an up navigation button if there are above-screen anchors', () => {
const wrapper = createComponent();
const upButton = wrapper.find('.Buckets__button--up');
const upButton = wrapper.find(upButtonSelector);
// The list item element wrapping the button
const bucketItem = wrapper.find('.Buckets__bucket').first();
const bucketItem = wrapper.find('BucketItem').first();
assert.isTrue(upButton.exists());
assert.equal(
......@@ -97,15 +136,15 @@ describe('Buckets', () => {
it('does not render an up navigation button if there are no above-screen anchors', () => {
fakeAbove = { tags: new Set(), position: 150 };
const wrapper = createComponent();
assert.isFalse(wrapper.find('.Buckets__button--up').exists());
assert.isFalse(wrapper.find(upButtonSelector).exists());
});
it('renders a down navigation button if there are below-screen anchors', () => {
const wrapper = createComponent();
const downButton = wrapper.find('.Buckets__button--down');
const downButton = wrapper.find(downButtonSelector);
// The list item element wrapping the button
const bucketItem = wrapper.find('.Buckets__bucket').last();
const bucketItem = wrapper.find('BucketItem').last();
assert.isTrue(downButton.exists());
assert.equal(
......@@ -117,12 +156,12 @@ describe('Buckets', () => {
it('does not render a down navigation button if there are no below-screen anchors', () => {
fakeBelow = { tags: new Set(), position: 550 };
const wrapper = createComponent();
assert.isFalse(wrapper.find('.Buckets__button--down').exists());
assert.isFalse(wrapper.find(downButtonSelector).exists());
});
it('scrolls to anchors above when up navigation button is pressed', () => {
const wrapper = createComponent();
const upButton = wrapper.find('.Buckets__button--up');
const upButton = wrapper.find(upButtonSelector);
upButton.simulate('click');
......@@ -135,7 +174,7 @@ describe('Buckets', () => {
it('scrolls to anchors below when down navigation button is pressed', () => {
const wrapper = createComponent();
const downButton = wrapper.find('.Buckets__button--down');
const downButton = wrapper.find(downButtonSelector);
downButton.simulate('click');
......@@ -148,16 +187,17 @@ describe('Buckets', () => {
});
describe('on-screen buckets', () => {
const bucketButtonSelector = 'button[title^="Select nearby annotations"]';
it('renders a bucket button for each bucket', () => {
const wrapper = createComponent();
assert.equal(wrapper.find('.Buckets__button--left').length, 2);
assert.equal(wrapper.find(bucketButtonSelector).length, 2);
});
it('focuses on associated annotations when mouse enters the element', () => {
const wrapper = createComponent();
wrapper.find('.Buckets__button--left').first().simulate('mouseenter');
wrapper.find(bucketButtonSelector).first().simulate('mouseenter');
assert.calledOnce(fakeOnFocusAnnotations);
assert.calledWith(fakeOnFocusAnnotations, ['t1', 't2']);
......@@ -166,7 +206,7 @@ describe('Buckets', () => {
it('removes focus on associated anchors when mouse leaves the element', () => {
const wrapper = createComponent();
wrapper.find('.Buckets__button--left').first().simulate('mouseout');
wrapper.find(bucketButtonSelector).first().simulate('mouseout');
assert.calledOnce(fakeOnFocusAnnotations);
assert.calledWith(fakeOnFocusAnnotations, []);
......@@ -175,7 +215,7 @@ describe('Buckets', () => {
it('focuses associated anchors when the element is focused', () => {
const wrapper = createComponent();
wrapper.find('.Buckets__button--left').first().simulate('focus');
wrapper.find(bucketButtonSelector).first().simulate('focus');
assert.calledOnce(fakeOnFocusAnnotations);
assert.calledWith(fakeOnFocusAnnotations, ['t1', 't2']);
......@@ -184,7 +224,7 @@ describe('Buckets', () => {
it('removes focus on associated annotations when element is blurred', () => {
const wrapper = createComponent();
wrapper.find('.Buckets__button--left').first().simulate('blur');
wrapper.find(bucketButtonSelector).first().simulate('blur');
assert.calledOnce(fakeOnFocusAnnotations);
assert.calledWith(fakeOnFocusAnnotations, []);
......@@ -194,7 +234,7 @@ describe('Buckets', () => {
const wrapper = createComponent();
wrapper
.find('.Buckets__button--left')
.find(bucketButtonSelector)
.first()
.simulate('click', { metaKey: false, ctrlKey: false });
......@@ -208,7 +248,7 @@ describe('Buckets', () => {
const wrapper = createComponent();
wrapper
.find('.Buckets__button--left')
.find(bucketButtonSelector)
.first()
.simulate('click', { metaKey: true, ctrlKey: false });
......
@use '../mixins/buttons';
@use '../variables' as var;
$bucket-bar-width: 22px;
.Buckets__list {
background: var.$grey-2; // When sidebar is unfolded, remove the background transparency
pointer-events: none;
position: absolute;
height: 100%;
// 2020-11-20: interim and pragmatic solution for an apparent glitch on Safari and Chrome.
// Adding one pixel resolve this issue: https://github.com/hypothesis/client/pull/2750
width: $bucket-bar-width + 1;
left: -($bucket-bar-width);
}
/**
* These classes style buttons to appear as lozenges with an integrated arrow
* pointing left, up or down (right doesn't exist yet but could easily be added).
* *
* The arrow-points are created by the combination of borders and positioning.
* See https://css-tricks.com/snippets/css/css-triangle/ for a few examples
*
*/
// When the sidebar is collapsed, make the background semi-transparent so the
// text is visible throughout (useful for pages with tight margins)
.annotator-collapsed .Buckets__list {
background: rgba(0, 0, 0, 0.08);
}
@layer components {
// Base styling for a button with a numerical badge that points either up,
// down or left. On its own, it will be lozenge-shaped. Compose with one
// of the directional classes below to style a button fully, e.g.
// `classname="BucketButton LeftBucketButton`
.BucketButton {
// A lozenge-shaped element with very small text
@apply w-[26px] h-[16px] absolute border bg-white shadow rounded;
@apply font-sans text-center text-tiny font-bold text-color-text-light leading-none;
.Buckets__bucket {
position: absolute;
right: 0;
}
// Establish :before and :after content for later manipulation into
// different pointer shapes and directions
&::before,
&::after {
@apply content-[""] absolute border-transparent;
}
}
.Buckets__button {
// Need pointer events again. Necessary because of `pointer-events` rule
// in `.Buckets__list`
pointer-events: all;
}
// Style a `BucketButton` to point left
.LeftBucketButton {
@apply rounded-r rounded-l-sm;
.Buckets__button--left {
// Center the indicator vertically (the element is 16px tall)
margin-top: -8px;
@include buttons.indicator--left;
}
// Position to the left of the button and centered vertically
&::before,
&::after {
@apply right-full top-1/2;
}
.Buckets__button--up {
@include buttons.indicator--up;
// Vertically center the element (which is 22px high)
margin-top: -11px;
}
// Create a grey wedge to the left of the button
// This will appear as the border around the pointy side
&::before {
@apply mt-[-8px] border-8;
@apply border-r-[5px] border-r-grey-3;
}
// Create a white wedge to the left of the button, 1px smaller than
// grey wedge. This will composite on top of the grey wedge and serve as
// the white fill of the pointy side
&::after {
@apply mt-[-7px] border-[7px];
@apply border-r-[4px] border-r-white;
}
}
// Style a `BucketButton` to point up
.UpBucketButton {
// Z-index assures that left-pointing buttons will scroll behind this
@apply z-1 rounded-t-sm rounded-b;
// Position above the button and horizontally centered
&::before,
&::after {
@apply top-auto left-1/2 bottom-full;
}
// Create a grey wedge at the top of the button
// This will appear as the border around the pointy side
&::before {
@apply ml-[-13px] border-[13px];
@apply border-b-[6px] border-b-grey-3;
}
// Create a white wedge 1px smaller than the grey wedge, as "fill"
&::after {
@apply ml-[-12px] border-[12px];
@apply border-b-[5px] border-b-white;
}
}
// Style a `BucketButton` to point down
.DownBucketButton {
// Z-index assures that left-pointing buttons will scroll behind this
@apply z-1 rounded-t rounded-b-sm;
// Position below the button and horizontally centered
&::before,
&::after {
@apply top-full left-1/2;
}
// Create a grey wedge at the bottom of the button
// This will appear as the border around the pointy side
&::before {
@apply ml-[-13px] border-[13px];
@apply border-t-[6px] border-t-grey-3;
}
.Buckets__button--down {
@include buttons.indicator--down;
// Create a white wedge 1px smaller than the grey wedge, as "fill"
&::after {
@apply ml-[-12px] border-[12px];
@apply border-t-[5px] border-t-white;
}
}
}
......@@ -17,7 +17,6 @@
*
* And pass 'my-component-button' as the `className` prop to `Button`.
*/
@use 'sass:math';
@use '@hypothesis/frontend-shared/styles/mixins/focus';
......@@ -139,142 +138,3 @@
color: var.$grey-semi;
}
}
/**
* Mixins that style buttons to appear as lozenges with an integrated arrow
* pointing left, up or down (right doesn't exist yet but could easily be added).
* These indicators are used, e.g., in the bucket bar.
*
* These button or button-like elements consist of styles applied to the
* element itself, which create a rounded-rectangle lozenge with small-sized
* label text, as well as composited ::before and ::after pseudo-elements to
* create an arrow-pointer effect.
*
* The arrow-points are created by the combination of borders and positioning.
* See https://css-tricks.com/snippets/css/css-triangle/ for a few examples
*
*/
$indicator-width: 26px;
$indicator-height: 16px;
// How far the arrow-pointer "sticks out" from the main body of the lozenge
$indicator-horizontal-offset: 5px;
$indicator-vertical-offset: 6px;
@mixin indicator-base {
@include reset-native-btn-styles;
@include utils.border;
position: absolute;
right: 0;
background-color: var.$color-background;
width: $indicator-width;
height: $indicator-height;
// Font/text
text-align: center;
color: var.$color-text--light;
font-weight: bold;
font-family: var.$sans-font-family;
font-size: var.$annotator-bucket-bar-font-size;
line-height: 1;
&::before,
&::after {
content: '';
position: absolute;
// NB: use of 'inset' here fixes jagged diagonals in FF
// https://github.com/zurb/foundation/issues/2230
border: inset transparent;
}
}
@mixin indicator-vertical-base {
@include indicator-base;
@include utils.shadow;
z-index: 1;
&::before,
&::after {
left: 50%;
}
}
@mixin indicator--left {
@include indicator-base;
border-radius: 2px 4px 4px 2px;
&::before,
&::after {
right: 100%;
top: 50%;
}
// This creates a left-pointing "wedge" in grey
// offset to the left of the element
&::before {
border-width: math.div($indicator-height, 2);
border-right: $indicator-horizontal-offset solid var.$grey-3;
margin-top: -1 * math.div($indicator-height, 2);
}
// This creates a left-pointing "wedge" in white, on top
// of the grey wedge and one pixel narrower so that the
// grey wedge appears as a border around it
&::after {
border-width: math.div($indicator-height, 2) - 1;
border-right: ($indicator-horizontal-offset - 1) solid var.$color-background;
margin-top: -1 * (math.div($indicator-height, 2) - 1);
}
}
@mixin indicator--up {
@include indicator-vertical-base;
border-radius: 2px 2px 4px 4px;
&::before,
&::after {
top: auto;
bottom: 100%;
}
// Grey (border) arrow pointing up
&::before {
border-width: math.div($indicator-width, 2);
border-bottom: $indicator-vertical-offset solid var.$grey-3;
margin-left: -1 * math.div($indicator-width, 2);
}
// White (fill) arrow pointing up
&::after {
border-width: math.div($indicator-width, 2) - 1;
border-bottom: ($indicator-vertical-offset - 1) solid var.$color-background;
margin-left: -1 * (math.div($indicator-width, 2) - 1);
}
}
@mixin indicator--down {
@include indicator-vertical-base;
margin-top: 0;
border-radius: 4px 4px 2px 2px;
&::before,
&::after {
top: 100%;
}
// Grey (border) arrow, pointing down
&::before {
border-width: math.div($indicator-width, 2);
border-top: $indicator-vertical-offset solid var.$grey-3;
margin-left: -1 * math.div($indicator-width, 2);
}
// White (fill) arrow, pointing down
&::after {
border-width: math.div($indicator-width, 2) - 1;
border-top: ($indicator-vertical-offset - 1) solid var.$color-background;
margin-left: -1 * (math.div($indicator-width, 2) - 1);
}
}
import tailwindConfig from '@hypothesis/frontend-shared/lib/tailwind.preset.js';
import plugin from 'tailwindcss/plugin.js';
export default {
presets: [tailwindConfig],
......@@ -81,8 +82,18 @@ export default {
},
},
zIndex: {
1: '1',
2: '2',
},
},
},
plugins: [
plugin(({ addVariant }) => {
// Add a custom variant such that the `annotator-collapsed:` modifier
// is available. The `Sidebar` logic adds the `.annotator-collapsed`
// class to the sidebar frame when it's collapsed. This modifier allows
// sub-components to select for that state.
addVariant('annotator-collapsed', '.annotator-collapsed &');
}),
],
};
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