Skip to content

Commit

Permalink
Improve keyboard-based navigation
Browse files Browse the repository at this point in the history
  • Loading branch information
tnajdek committed Oct 25, 2024
1 parent ac637a6 commit 4c44b06
Show file tree
Hide file tree
Showing 17 changed files with 476 additions and 117 deletions.
2 changes: 1 addition & 1 deletion modules/web-common
78 changes: 69 additions & 9 deletions src/js/components/bibliography.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import cx from 'classnames';
import PropTypes from 'prop-types';
import { Fragment, useCallback, useRef, useState, memo } from 'react';
import { Fragment, useCallback, useRef, useState, memo, useEffect } from 'react';
import { useIntl, FormattedMessage } from 'react-intl';
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownToggle, Icon } from 'web-common/components';
import { isTriggerEvent, pick } from 'web-common/utils';
import { useFocusManager } from 'web-common/hooks';

import { useDnd } from '../hooks';

Expand All @@ -15,7 +16,11 @@ const BibliographyItem = memo(props => {
onDelayedCloseDropdown, onEditCitationClick, onReorderCitations, onSelectCitation, onToggleDropdown,
rawItem
} = props;
const listItemRef = useRef(null);
const containerRef = useRef(null);
const { focusNext, focusPrev, receiveBlur, receiveFocus } = useFocusManager(
listItemRef, { targetTabIndex: -3, isFocusable: true, isCarousel: false }
);
const intl = useIntl();
const isCopied = copySingleState.copied && copySingleState.citationKey === rawItem.key;

Expand All @@ -31,12 +36,14 @@ const BibliographyItem = memo(props => {
const topNode = srcNode.parentNode.querySelector('[data-key]:first-child');
onReorderCitations(srcNode.dataset.key, topNode.dataset.key, true);
}, [onReorderCitations]);

const handleMoveUp = useCallback(ev => {
ev.stopPropagation();
const srcNode = ev.currentTarget.closest('[data-key]');
const prevNode = srcNode.previousElementSibling;
onReorderCitations(srcNode.dataset.key, prevNode.dataset.key, true);
}, [onReorderCitations]);

const handleMovedown = useCallback(ev => {
ev.stopPropagation();
const srcNode = ev.currentTarget.closest('[data-key]');
Expand All @@ -45,12 +52,44 @@ const BibliographyItem = memo(props => {
}, [onReorderCitations]);

const handleCopySingleClick = useCallback(ev => {
listItemRef.current?.focus();
ev.stopPropagation();
ev.preventDefault();
onDelayedCloseDropdown();
onCopySingle(ev.currentTarget.closest('[data-key]')?.dataset.key);
}, [onCopySingle, onDelayedCloseDropdown])

const handleCopyCitationClick = useCallback(ev => {
ev.currentTarget.closest('[data-key]')?.focus();
onCopyCitationDialogOpen(ev);
}, [onCopyCitationDialogOpen]);

const handleEditCitationClick = useCallback(ev => {
ev.currentTarget.closest('[data-key]')?.focus();
onEditCitationClick(ev);
}, [onEditCitationClick]);

const handleDeleteCitationClick = useCallback(ev => {
const bibItemEl = ev.currentTarget.closest('[data-key]');
const otherBibItemEl = bibItemEl.previousElementSibling || bibItemEl.nextElementSibling;
onDeleteCitation(ev);
if(otherBibItemEl) {
otherBibItemEl.focus();
}
}, [onDeleteCitation]);

const handleKeyDown = useCallback(ev => {
if(isTriggerEvent(ev)) {
onSelectCitation(ev);
} else if(ev.key === 'ArrowRight') {
focusNext(ev, { useCurrentTarget: false });
ev.stopPropagation();
} else if(ev.key === 'ArrowLeft') {
focusPrev(ev, { useCurrentTarget: false });
ev.stopPropagation();
}
}, [focusNext, focusPrev, onSelectCitation]);

const getData = useCallback(
ev => ({ key: ev.currentTarget.closest('[data-key]').dataset.key }), []
);
Expand All @@ -76,12 +115,15 @@ const BibliographyItem = memo(props => {
data-key={rawItem.key}
className="citation-container"
onClick={onSelectCitation}
tabIndex={0}
onKeyDown={onSelectCitation}
tabIndex={-2}
onKeyDown={handleKeyDown}
onMouseOver={onHover}
onMouseOut={onHover}
onMouseMove={onHover}
onMouseUp={onDrop}
ref={listItemRef}
onFocus={receiveFocus}
onBlur={receiveBlur}
>
<div className="citation" ref={containerRef}>
{allowReorder && (
Expand All @@ -100,7 +142,8 @@ const BibliographyItem = memo(props => {
icon
title={copyText}
className={cx('d-xs-none d-md-block btn-outline-secondary btn-copy')}
onClick={onCopyCitationDialogOpen}
onClick={handleCopyCitationClick}
tabIndex={ -3 }
>
<Icon type={'16/quote'} role="presentation" width="16" height="16" />
</Button>
Expand All @@ -112,6 +155,7 @@ const BibliographyItem = memo(props => {
{ 'success': isCopied }
)}
onClick={handleCopySingleClick}
tabIndex={-3}
>
<Icon type={isCopied ? '16/tick' : '16/copy'} role="presentation" width="16" height="16" />
</Button>
Expand All @@ -126,21 +170,22 @@ const BibliographyItem = memo(props => {
color={null}
className="btn-icon dropdown-toggle"
title="Options"
tabIndex={-3}
>
<Icon type={'28/dots'} role="presentation" width="28" height="28" />
</DropdownToggle>
<DropdownMenu aria-label="Options" right>
{!isNumericStyle && (
<Fragment>
<DropdownItem
onClick={onCopyCitationDialogOpen}
className="btn"
onClick={handleCopyCitationClick}
className="btn d-xs-block d-md-none"
>
{copyText}
</DropdownItem>
<DropdownItem
onClick={handleCopySingleClick}
className={cx('btn clipboard-trigger', { success: isCopied })}
className={cx('btn clipboard-trigger d-xs-block d-md-none', { success: isCopied })}
>
<span className={cx('inline-feedback', { 'active': isCopied })}>
<span className="default-text" aria-hidden={isCopied}>
Expand All @@ -154,13 +199,13 @@ const BibliographyItem = memo(props => {
</Fragment>
)}
<DropdownItem
onClick={onEditCitationClick}
onClick={handleEditCitationClick}
className="btn"
>
<FormattedMessage id="zbib.general.edit" defaultMessage="Edit" />
</DropdownItem>
<DropdownItem
onClick={onDeleteCitation}
onClick={handleDeleteCitationClick}
className="btn"
>
<FormattedMessage id="zbib.general.delete" defaultMessage="Delete" />
Expand Down Expand Up @@ -189,6 +234,7 @@ const BibliographyItem = memo(props => {
title={ intl.formatMessage({ id: 'zbib.citation.deleteEntry', defaultMessage: 'Delete Entry' }) }
className="btn-outline-secondary btn-remove"
onClick={onDeleteCitation}
tabIndex={-3}
>
<Icon type={'16/remove-sm'} role="presentation" width="16" height="16" />
</Button>
Expand Down Expand Up @@ -226,6 +272,8 @@ BibliographyItem.propTypes = {
const Bibliography = props => {
const dropdownTimer = useRef(null);
const [dropdownOpen, setDropdownOpen] = useState(null);
const listRef = useRef(null);
const { focusNext, focusPrev, receiveBlur, receiveFocus } = useFocusManager(listRef);

const {
bibliography, bibliographyRendered, bibliographyRenderedNodes,
Expand Down Expand Up @@ -297,6 +345,13 @@ const Bibliography = props => {
onCitationCopyDialogOpen(ev.currentTarget.closest('[data-key]').dataset.key);
}, [onCitationCopyDialogOpen]);

const handleListKeyDown = useCallback(ev => {
if (ev.key === 'ArrowDown') {
focusNext(ev, { useCurrentTarget: false });
} else if (ev.key === 'ArrowUp') {
focusPrev(ev, { useCurrentTarget: false });
}
}, [focusNext, focusPrev]);

if (bibliography.items.length === 0) {
return null;
Expand Down Expand Up @@ -325,6 +380,11 @@ const Bibliography = props => {
aria-label="Bibliography"
className="bibliography"
key="bibliography"
ref={ listRef }
onFocus={ receiveFocus }
onBlur={ receiveBlur }
onKeyDown={ handleListKeyDown }
tabIndex={0}
>
{bibliography.items.map((renderedItem, index) => (
<BibliographyItem
Expand Down
19 changes: 18 additions & 1 deletion src/js/components/export-tools.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const ExportTools = props => {
const dropdownTimer = useRef(null);
const whenReadyData = useRef(false);
const wasReady = usePrevious(isReady);
const mainButtonRef = useRef(null);
const dropdownToggleRef = useRef(null);

const handleClipoardSuccess = useCallback(format => {
if(clipboardConfirmations[format]) {
Expand Down Expand Up @@ -122,6 +124,17 @@ const ExportTools = props => {
}
}, [copyToClipboard, isDropdownOpen, isHydrated, isReady]);

const handleKeyDown = useCallback(ev => {
if (['ArrowRight', 'ArrowDown'].includes(ev.key) && ev.currentTarget === mainButtonRef.current) {
dropdownToggleRef.current?.focus();
ev.preventDefault();
} else if(['ArrowLeft', 'ArrowUp'].includes(ev.key) && ev.currentTarget === dropdownToggleRef.current) {
mainButtonRef.current?.focus();
} else if (isTriggerEvent(ev) && ev.currentTarget === mainButtonRef.current) {
handleCopyClick(ev);
}
}, [handleCopyClick]);

const isCopied = clipboardConfirmations['plain'];

useEffect(() => {
Expand All @@ -145,13 +158,14 @@ const ExportTools = props => {
className={ cx('btn-group', { 'success': isCopied }) }
>
<Button
ref={mainButtonRef}
aria-labelledby="export-tools-copy-to-clipboard"
data-format="plain"
data-main
disabled={ itemCount === 0 }
className='btn btn-secondary btn-xl copy-to-clipboard'
onClick={ handleCopyClick }
onKeyDown={ handleCopyClick }
onKeyDown={handleKeyDown }
>
<span id="export-tools-copy-to-clipboard" className={ cx('inline-feedback', { 'active': isCopied }) }>
<span className="default-text" aria-hidden={ isCopied }>{ exportFormats['plain'].label }</span>
Expand All @@ -161,9 +175,12 @@ const ExportTools = props => {
</span>
</Button>
<DropdownToggle
ref={ dropdownToggleRef }
aria-label="Export Options"
disabled={ itemCount === 0 }
className="btn btn-secondary btn-xl dropdown-toggle"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<span className="dropdown-caret" />
</DropdownToggle>
Expand Down
1 change: 0 additions & 1 deletion src/js/components/form/creator-field.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,6 @@ const CreatorField = forwardRef((props, ref) => {
onReorderCommit={ onReorderCommit }
onDragStatusChange={ onDragStatusChange }
raw={ raw }
tabIndex = { 0 }
>
{ shouldUseModalCreatorField ?
<div className="truncate">{ creatorLabel }</div> :
Expand Down
33 changes: 30 additions & 3 deletions src/js/components/multiple-choice-dialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useCallback, useId, memo, useRef } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { Button, Icon, Spinner } from 'web-common/components';
import { isTriggerEvent } from 'web-common/utils';
import { useFocusManager } from 'web-common/hooks';

import Modal from './modal';

Expand Down Expand Up @@ -39,7 +40,7 @@ const ChoiceItem = memo(({ item, onItemSelect }) => {
data-signature={ item.signature }
onKeyDown={ onItemSelect }
onClick={ onItemSelect }
tabIndex={ 0 }
tabIndex={ -2 }
>
{/* { badge && <span className="badge badge-light d-sm-none">{ badge }</span> } */}
<h5 id={ id } className="title">
Expand Down Expand Up @@ -67,6 +68,8 @@ const getItem = (ev, items) => items.find(item => item.signature === ev.currentT
const MultipleChoiceDialog = props => {
const { activeDialog, isTranslatingMore, moreItemsLink, multipleChoiceItems,
onMultipleChoiceCancel, onMultipleChoiceMore, onMultipleChoiceSelect } = props;
const listRef = useRef(null);
const { focusNext, focusPrev, receiveBlur, receiveFocus } = useFocusManager(listRef);
const persistBtnWidth = useRef(null); // To avoid button size change when spinner is shown, store the button width in a ref when it is firts rendered
const intl = useIntl();
const useDescriptionColumn = !!multipleChoiceItems?.some(item => item.value.description);
Expand All @@ -75,9 +78,27 @@ const MultipleChoiceDialog = props => {
if(isTriggerEvent(ev)) {
const item = getItem(ev, multipleChoiceItems);
onMultipleChoiceSelect(item);
ev.stopPropagation();
}
}, [multipleChoiceItems, onMultipleChoiceSelect]);

const handleListKeyDown = useCallback(ev => {
if (ev.key === 'ArrowDown') {
focusNext(ev, { useCurrentTarget: false });
} else if (ev.key === 'ArrowUp') {
focusPrev(ev, { useCurrentTarget: false });
}
}, [focusNext, focusPrev]);

const handleModalAfterOpen = useCallback(() => {
listRef.current.focus();
}, []);

const handleMoreButtonClick = useCallback(() => {
listRef.current.focus(); // move focus back to the list, because the button will be disabled
onMultipleChoiceMore();
}, [onMultipleChoiceMore]);

const title = intl.formatMessage({ id: 'zbib.multipleChoice.prompt', defaultMessage: 'Please select a citation from the list' });

return (multipleChoiceItems && activeDialog === 'MULTIPLE_CHOICE_DIALOG') ? (
Expand All @@ -86,6 +107,7 @@ const MultipleChoiceDialog = props => {
contentLabel={ title }
className={cx("multiple-choice-dialog modal modal-lg", { 'modal-with-footer': moreItemsLink })}
onRequestClose={ onMultipleChoiceCancel }
onAfterOpen={ handleModalAfterOpen }
>
<div className="modal-content" tabIndex={ -1 }>
<div className="modal-header">
Expand All @@ -101,8 +123,13 @@ const MultipleChoiceDialog = props => {
<Icon type={'24/remove'} role="presentation" width="24" height="24" />
</Button>
</div>
<div className="modal-body">
<div className="modal-body" tabIndex={-1}>
<ul
tabIndex={0}
ref={listRef}
onFocus={receiveFocus}
onBlur={receiveBlur}
onKeyDown={handleListKeyDown}
aria-label="Results"
className={ cx("results", { 'single-column': !useDescriptionColumn }) }
>
Expand All @@ -129,7 +156,7 @@ const MultipleChoiceDialog = props => {
style={ isTranslatingMore ? { width: persistBtnWidth.current } : {} }
disabled={ isTranslatingMore }
className="btn-outline-secondary btn-min-width btn-flex"
onClick={onMultipleChoiceMore}
onClick={handleMoreButtonClick}
>
{ isTranslatingMore ? <Spinner /> : <FormattedMessage id="zbib.multipleChoice.more" defaultMessage="More…" /> }
</Button>
Expand Down
Loading

0 comments on commit 4c44b06

Please sign in to comment.