Commit 15a628cd authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Update SelectionTabs component for a11y, shared components

* Use `LabeledButton` instead of custom `<button>`
* Use `Icon`, `Frame` components where useful
* Reduce local/custom CSS
* Modernize tests
* Add title to `Icon` for a11y

Fixes https://github.com/hypothesis/support/issues/241

Part of https://github.com/hypothesis/client/issues/3876

Part of https://github.com/hypothesis/frontend-shared/issues/232
parent afca5e8f
import { LabeledButton, SvgIcon } from '@hypothesis/frontend-shared'; import { Frame, Icon, LabeledButton } from '@hypothesis/frontend-shared';
import classnames from 'classnames'; import classnames from 'classnames';
import { applyTheme } from '../helpers/theme'; import { applyTheme } from '../helpers/theme';
...@@ -11,17 +11,19 @@ import { withServices } from '../service-context'; ...@@ -11,17 +11,19 @@ import { withServices } from '../service-context';
*/ */
/** /**
* @typedef {import('preact').ComponentChildren} Children
*
* @typedef TabProps * @typedef TabProps
* @prop {object} children - Child components. * @prop {Children} children
* @prop {number} count - The total annotations for this tab. * @prop {number} count - The total annotations for this tab
* @prop {boolean} isSelected - Is this tab currently selected? * @prop {boolean} isSelected - Is this tab currently selected?
* @prop {boolean} isWaitingToAnchor - Are there any annotations still waiting to anchor? * @prop {boolean} isWaitingToAnchor - Are there any annotations still waiting to anchor?
* @prop {string} label - A string label to use for `aria-label` and `title` * @prop {string} label - A string label to use for a11y
* @prop {() => any} onSelect - Callback to invoke when this tab is selected. * @prop {() => any} onSelect - Callback to invoke when this tab is selected
*/ */
/** /**
* Display name of the tab and annotation count. * Display name of the tab and annotation count
* *
* @param {TabProps} props * @param {TabProps} props
*/ */
...@@ -42,9 +44,8 @@ function Tab({ ...@@ -42,9 +44,8 @@ function Tab({
const title = count > 0 ? `${label} (${count} available)` : label; const title = count > 0 ? `${label} (${count} available)` : label;
return ( return (
<div> <LabeledButton
<button classes={classnames('u-color-text', 'SelectionTab', {
className={classnames('SelectionTabs__type', {
'is-selected': isSelected, 'is-selected': isSelected,
})} })}
// Listen for `onMouseDown` so that the tab is selected when _pressed_ // Listen for `onMouseDown` so that the tab is selected when _pressed_
...@@ -52,18 +53,23 @@ function Tab({ ...@@ -52,18 +53,23 @@ function Tab({
// to enable selecting the tab via other input methods. // to enable selecting the tab via other input methods.
onClick={selectTab} onClick={selectTab}
onMouseDown={selectTab} onMouseDown={selectTab}
pressed={!!isSelected}
role="tab" role="tab"
tabIndex={0} tabIndex={0}
title={title} title={title}
aria-label={title}
aria-selected={isSelected.toString()}
> >
<>
{children} {children}
{count > 0 && !isWaitingToAnchor && ( {count > 0 && !isWaitingToAnchor && (
<span className="SelectionTabs__count"> {count}</span> <span
className="u-font--xsmall"
style="position:relative;bottom:3px;left:2px"
>
{count}
</span>
)} )}
</button> </>
</div> </LabeledButton>
); );
} }
...@@ -75,7 +81,7 @@ function Tab({ ...@@ -75,7 +81,7 @@ function Tab({
*/ */
/** /**
* Tabbed display of annotations and notes. * Tabbed display of annotations and notes
* *
* @param {SelectionTabsProps} props * @param {SelectionTabsProps} props
*/ */
...@@ -103,8 +109,11 @@ function SelectionTabs({ annotationsService, isLoading, settings }) { ...@@ -103,8 +109,11 @@ function SelectionTabs({ annotationsService, isLoading, settings }) {
const showNotesUnavailableMessage = selectedTab === 'note' && noteCount === 0; const showNotesUnavailableMessage = selectedTab === 'note' && noteCount === 0;
return ( return (
<div className="SelectionTabs-container"> <div className="hyp-u-vertical-spacing--4 SelectionTabs__container">
<div className="SelectionTabs" role="tablist"> <div
className="hyp-u-layout-row hyp-u-horizontal-spacing--6 SelectionTabs"
role="tablist"
>
<Tab <Tab
count={annotationCount} count={annotationCount}
isWaitingToAnchor={isWaitingToAnchorAnnotations} isWaitingToAnchor={isWaitingToAnchorAnnotations}
...@@ -138,6 +147,7 @@ function SelectionTabs({ annotationsService, isLoading, settings }) { ...@@ -138,6 +147,7 @@ function SelectionTabs({ annotationsService, isLoading, settings }) {
{selectedTab === 'note' && settings.enableExperimentalNewNoteButton && ( {selectedTab === 'note' && settings.enableExperimentalNewNoteButton && (
<div className="hyp-u-layout-row--justify-right"> <div className="hyp-u-layout-row--justify-right">
<LabeledButton <LabeledButton
data-testid="new-note-button"
icon="add" icon="add"
onClick={() => annotationsService.createPageNote()} onClick={() => annotationsService.createPageNote()}
variant="primary" variant="primary"
...@@ -148,22 +158,26 @@ function SelectionTabs({ annotationsService, isLoading, settings }) { ...@@ -148,22 +158,26 @@ function SelectionTabs({ annotationsService, isLoading, settings }) {
</div> </div>
)} )}
{!isLoading && showNotesUnavailableMessage && ( {!isLoading && showNotesUnavailableMessage && (
<div className="SelectionTabs__message"> <Frame classes="u-text--centered">
<span data-testid="notes-unavailable-message">
There are no page notes in this group. There are no page notes in this group.
</div> </span>
</Frame>
)} )}
{!isLoading && showAnnotationsUnavailableMessage && ( {!isLoading && showAnnotationsUnavailableMessage && (
<div className="SelectionTabs__message"> <Frame classes="u-text--centered">
<span data-testid="annotations-unavailable-message">
There are no annotations in this group. There are no annotations in this group.
<br /> <br />
Create one by selecting some text and clicking the{' '} Create one by selecting some text and clicking the{' '}
<SvgIcon <Icon
classes="hyp-u-margin--1 u-inline"
name="annotate" name="annotate"
inline={true} title="Annotate"
className="SelectionTabs__icon"
/>{' '} />{' '}
button. button.
</div> </span>
</Frame>
)} )}
</div> </div>
); );
......
...@@ -55,35 +55,35 @@ describe('SelectionTabs', () => { ...@@ -55,35 +55,35 @@ describe('SelectionTabs', () => {
$imports.$restore(); $imports.$restore();
}); });
const unavailableMessage = wrapper =>
wrapper.find('.SelectionTabs__message').text();
it('should display the tabs and counts of annotations and notes', () => { it('should display the tabs and counts of annotations and notes', () => {
const wrapper = createComponent(); const wrapper = createComponent();
const tabs = wrapper.find('button'); const annotationTab = wrapper.find('Tab[label="Annotations"]');
const noteTab = wrapper.find('Tab[label="Page notes"]');
assert.include(tabs.at(0).text(), 'Annotations'); assert.include(annotationTab.text(), 'Annotations');
assert.equal(tabs.at(0).find('.SelectionTabs__count').text(), 123); assert.include(annotationTab.text(), '123');
assert.include(tabs.at(1).text(), 'Page Notes'); assert.include(noteTab.text(), 'Page Notes');
assert.equal(tabs.at(1).find('.SelectionTabs__count').text(), 456); assert.include(noteTab.text(), '456');
}); });
describe('Annotations tab', () => { describe('Annotations tab', () => {
it('should display annotations tab as selected when it is active', () => { it('should display annotations tab as selected when it is active', () => {
const wrapper = createComponent(); const wrapper = createComponent();
const annotationTab = wrapper.find('Tab[label="Annotations"]');
const noteTab = wrapper.find('Tab[label="Page notes"]');
const tabs = wrapper.find('button'); assert.isTrue(annotationTab.find('LabeledButton').props().pressed);
assert.isFalse(noteTab.find('LabeledButton').props().pressed);
assert.isTrue(tabs.at(0).hasClass('is-selected'));
assert.equal(tabs.at(0).prop('aria-selected'), 'true');
assert.equal(tabs.at(1).prop('aria-selected'), 'false');
}); });
it('should not display the add-page-note button when the annotations tab is active', () => { it('should not display the add-page-note button when the annotations tab is active', () => {
fakeSettings.enableExperimentalNewNoteButton = true; fakeSettings.enableExperimentalNewNoteButton = true;
const wrapper = createComponent(); const wrapper = createComponent();
assert.equal(wrapper.find('LabeledButton').length, 0); assert.equal(
wrapper.find('LabeledButton[data-testid="new-note-button"]').length,
0
);
}); });
}); });
...@@ -92,11 +92,16 @@ describe('SelectionTabs', () => { ...@@ -92,11 +92,16 @@ describe('SelectionTabs', () => {
fakeStore.selectedTab.returns('note'); fakeStore.selectedTab.returns('note');
const wrapper = createComponent(); const wrapper = createComponent();
const tabs = wrapper.find('button'); const annotationTabButton = wrapper
.find('Tab[label="Annotations"]')
.find('LabeledButton');
const noteTabButton = wrapper
.find('Tab[label="Page notes"]')
.find('LabeledButton');
assert.isTrue(tabs.at(1).hasClass('is-selected')); assert.isTrue(noteTabButton.find('button').hasClass('is-selected'));
assert.equal(tabs.at(1).prop('aria-selected'), 'true'); assert.isTrue(noteTabButton.prop('pressed'));
assert.equal(tabs.at(0).prop('aria-selected'), 'false'); assert.isFalse(annotationTabButton.prop('pressed'));
}); });
describe('Add Page Note button', () => { describe('Add Page Note button', () => {
...@@ -106,7 +111,9 @@ describe('SelectionTabs', () => { ...@@ -106,7 +111,9 @@ describe('SelectionTabs', () => {
const wrapper = createComponent(); const wrapper = createComponent();
assert.isFalse(wrapper.find('LabeledButton').exists()); assert.isFalse(
wrapper.find('LabeledButton[data-testid="new-note-button"]').exists()
);
}); });
it('should display the add-page-note button when the associated setting is enabled', () => { it('should display the add-page-note button when the associated setting is enabled', () => {
...@@ -115,7 +122,9 @@ describe('SelectionTabs', () => { ...@@ -115,7 +122,9 @@ describe('SelectionTabs', () => {
const wrapper = createComponent(); const wrapper = createComponent();
assert.isTrue(wrapper.find('LabeledButton').exists()); assert.isTrue(
wrapper.find('LabeledButton[data-testid="new-note-button"]').exists()
);
}); });
it('should apply background-color styling from settings', () => { it('should apply background-color styling from settings', () => {
...@@ -129,7 +138,9 @@ describe('SelectionTabs', () => { ...@@ -129,7 +138,9 @@ describe('SelectionTabs', () => {
const wrapper = createComponent(); const wrapper = createComponent();
const button = wrapper.find('LabeledButton'); const button = wrapper.find(
'LabeledButton[data-testid="new-note-button"]'
);
assert.deepEqual(button.prop('style'), { backgroundColor: '#00f' }); assert.deepEqual(button.prop('style'), { backgroundColor: '#00f' });
}); });
...@@ -138,7 +149,10 @@ describe('SelectionTabs', () => { ...@@ -138,7 +149,10 @@ describe('SelectionTabs', () => {
fakeStore.selectedTab.returns('note'); fakeStore.selectedTab.returns('note');
const wrapper = createComponent(); const wrapper = createComponent();
wrapper.find('LabeledButton').props().onClick(); wrapper
.find('LabeledButton[data-testid="new-note-button"]')
.props()
.onClick();
assert.calledOnce(fakeAnnotationsService.createPageNote); assert.calledOnce(fakeAnnotationsService.createPageNote);
}); });
...@@ -151,8 +165,8 @@ describe('SelectionTabs', () => { ...@@ -151,8 +165,8 @@ describe('SelectionTabs', () => {
const wrapper = createComponent(); const wrapper = createComponent();
const tabs = wrapper.find('button'); const orphanTab = wrapper.find('Tab[label="Orphans"]');
assert.equal(tabs.length, 3); assert.isTrue(orphanTab.exists());
}); });
it('should display orphans tab as selected when it is active', () => { it('should display orphans tab as selected when it is active', () => {
...@@ -161,11 +175,8 @@ describe('SelectionTabs', () => { ...@@ -161,11 +175,8 @@ describe('SelectionTabs', () => {
const wrapper = createComponent(); const wrapper = createComponent();
const tabs = wrapper.find('button'); const orphanTab = wrapper.find('Tab[label="Orphans"]');
assert.isTrue(tabs.at(2).hasClass('is-selected')); assert.isTrue(orphanTab.find('LabeledButton').prop('pressed'));
assert.equal(tabs.at(2).prop('aria-selected'), 'true');
assert.equal(tabs.at(1).prop('aria-selected'), 'false');
assert.equal(tabs.at(0).prop('aria-selected'), 'false');
}); });
it('should not display orphans tab if there are 0 orphans', () => { it('should not display orphans tab if there are 0 orphans', () => {
...@@ -173,38 +184,21 @@ describe('SelectionTabs', () => { ...@@ -173,38 +184,21 @@ describe('SelectionTabs', () => {
const wrapper = createComponent(); const wrapper = createComponent();
const tabs = wrapper.find('button'); const orphanTab = wrapper.find('Tab[label="Orphans"]');
assert.equal(tabs.length, 2);
assert.isFalse(orphanTab.exists());
}); });
}); });
describe('tab display and counts', () => { describe('tab display and counts', () => {
it('should render `title` and `aria-label` attributes for tab buttons, with counts', () => { it('should not render count if there are no page notes', () => {
fakeStore.orphanCount.returns(1);
const wrapper = createComponent();
const tabs = wrapper.find('button');
assert.equal(
tabs.at(0).prop('aria-label'),
'Annotations (123 available)'
);
assert.equal(tabs.at(0).prop('title'), 'Annotations (123 available)');
assert.equal(tabs.at(1).prop('aria-label'), 'Page notes (456 available)');
assert.equal(tabs.at(1).prop('title'), 'Page notes (456 available)');
assert.equal(tabs.at(2).prop('aria-label'), 'Orphans (1 available)');
assert.equal(tabs.at(2).prop('title'), 'Orphans (1 available)');
});
it('should not render count in `title` and `aria-label` for page notes tab if there are no page notes', () => {
fakeStore.noteCount.returns(0); fakeStore.noteCount.returns(0);
const wrapper = createComponent({}); const wrapper = createComponent({});
const tabs = wrapper.find('button'); const noteTab = wrapper.find('Tab[label="Page notes"]');
assert.equal(tabs.at(1).prop('aria-label'), 'Page notes'); assert.equal(noteTab.text(), 'Page Notes');
assert.equal(tabs.at(1).prop('title'), 'Page notes');
}); });
it('should not display a message when its loading annotation count is 0', () => { it('should not display a message when its loading annotation count is 0', () => {
...@@ -212,7 +206,9 @@ describe('SelectionTabs', () => { ...@@ -212,7 +206,9 @@ describe('SelectionTabs', () => {
const wrapper = createComponent({ const wrapper = createComponent({
isLoading: true, isLoading: true,
}); });
assert.isFalse(wrapper.exists('.annotation-unavailable-message__label')); assert.isFalse(
wrapper.exists('[data-testid="annotations-unavailable-message"]')
);
}); });
it('should not display a message when its loading notes count is 0', () => { it('should not display a message when its loading notes count is 0', () => {
...@@ -221,7 +217,9 @@ describe('SelectionTabs', () => { ...@@ -221,7 +217,9 @@ describe('SelectionTabs', () => {
const wrapper = createComponent({ const wrapper = createComponent({
isLoading: true, isLoading: true,
}); });
assert.isFalse(wrapper.exists('.SelectionTabs__message')); assert.isFalse(
wrapper.exists('[data-testid="notes-unavailable-message"]')
);
}); });
it('should not display the longer version of the no annotations message when there are no annotations and isWaitingToAnchorAnnotations is true', () => { it('should not display the longer version of the no annotations message when there are no annotations and isWaitingToAnchorAnnotations is true', () => {
...@@ -230,16 +228,19 @@ describe('SelectionTabs', () => { ...@@ -230,16 +228,19 @@ describe('SelectionTabs', () => {
const wrapper = createComponent({ const wrapper = createComponent({
isLoading: false, isLoading: false,
}); });
assert.isFalse(wrapper.exists('.SelectionTabs__message')); assert.isFalse(
wrapper.exists('[data-testid="annotations-unavailable-message"]')
);
}); });
it('should display the longer version of the no notes message when there are no notes', () => { it('should display the longer version of the no notes message when there are no notes', () => {
fakeStore.selectedTab.returns('note'); fakeStore.selectedTab.returns('note');
fakeStore.noteCount.returns(0); fakeStore.noteCount.returns(0);
const wrapper = createComponent({}); const wrapper = createComponent({});
assert.include( assert.include(
unavailableMessage(wrapper), wrapper.find('[data-testid="notes-unavailable-message"]').text(),
'There are no page notes in this group.' 'There are no page notes in this group'
); );
}); });
...@@ -247,12 +248,8 @@ describe('SelectionTabs', () => { ...@@ -247,12 +248,8 @@ describe('SelectionTabs', () => {
fakeStore.annotationCount.returns(0); fakeStore.annotationCount.returns(0);
const wrapper = createComponent({}); const wrapper = createComponent({});
assert.include( assert.include(
unavailableMessage(wrapper), wrapper.find('[data-testid="annotations-unavailable-message"]').text(),
'There are no annotations in this group.' 'There are no annotations in this group'
);
assert.include(
unavailableMessage(wrapper),
'Create one by selecting some text and clicking the'
); );
}); });
}); });
......
@use '@hypothesis/frontend-shared/styles/components/buttons';
@use '../../mixins/layout';
@use '../../mixins/utils';
@use '../../variables' as var; @use '../../variables' as var;
.SelectionTabs-container { .SelectionTabs__container {
@include layout.vertical-rhythm;
// FIXME: This should be a margin, and it should be handled by the parent, // FIXME: This should be a margin, and it should be handled by the parent,
// but needs to be considered carefully because applying vertical rhythm to // but needs to be considered carefully because applying vertical rhythm to
// this component's parent messes with the calculations in the virtualized // this component's parent messes with the calculations in the virtualized
// thread list. Needs another pass. Note also that it is `10px` (and looks // thread list. Needs another pass. Note also that it is `10px` (and looks
// unbalanced at the standard vertical rhythm size of `1em`) // unbalanced at the standard vertical rhythm size of `1rem`)
padding-bottom: 10px; padding-bottom: 10px;
} }
.SelectionTabs { .SelectionTab {
@include layout.row; margin: 0;
@include layout.horizontal-rhythm(20px); padding: 0;
} font-weight: normal;
background-color: transparent;
.SelectionTabs__icon {
color: var.$grey-mid;
margin: 0 var.$layout-space--xxsmall;
}
.SelectionTabs__type {
@include buttons.reset;
color: var.$color-text;
cursor: pointer;
min-width: 85px;
min-height: 18px;
// Give the tab a radius to allow :focus styling to appear similar to that of buttons
border-radius: var.$border-radius;
user-select: none; min-width: 5.25rem;
&:hover { &:hover:not([disabled]) {
color: var.$color-link-hover; color: var.$color-link-hover;
} }
}
.SelectionTabs__type.is-selected { &.is-selected {
font-weight: bold; font-weight: bold;
} }
.SelectionTabs__count {
@include utils.font--xsmall;
position: relative;
bottom: 3px;
}
.SelectionTabs__empty-message {
position: relative;
top: 10px;
}
.SelectionTabs__type--orphan {
margin-left: -5px;
}
.SelectionTabs__message {
@include utils.border;
color: var.$color-text;
padding: 2em;
text-align: center;
} }
/** Clean theme affordances */ /** Clean theme affordances */
......
...@@ -4,6 +4,9 @@ ...@@ -4,6 +4,9 @@
// Utility classes // Utility classes
// These will be extracted and considered when developing typography patterns // These will be extracted and considered when developing typography patterns
.u-font--xsmall {
@include utils.font--xsmall;
}
.u-font--small { .u-font--small {
@include utils.font--small; @include utils.font--small;
...@@ -66,6 +69,16 @@ ...@@ -66,6 +69,16 @@
color: var.$color-text; color: var.$color-text;
} }
// Layout
.u-inline {
display: inline;
}
.u-text--centered {
text-align: center;
}
// TODO: This is a temporary utility class to allow elements in the sidebar // TODO: This is a temporary utility class to allow elements in the sidebar
// (e.g. panels, thread cards, etc.) // (e.g. panels, thread cards, etc.)
// to apply margins such that they are evenly spaced. In the future, the // to apply margins such that they are evenly spaced. In the future, the
......
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