Accessibility improvements: Keyboard interactions#2003
Conversation
…nd ESCAPE to close
…able-text styling for actionable elements
…bkey-ui-components into fb_keyboardInteraction
| export const DeleteIcon: FC<Props> = memo(({ id, title, className = 'field-icon', onDelete, iconCls }) => { | ||
| const onKeyDown = useEnterEscape(onDelete); | ||
| return ( | ||
| <span className={className} id={id} onClick={onDelete} onKeyDown={onKeyDown} tabIndex={0} title={title}> |
There was a problem hiding this comment.
With this change we're now conflating mouse and keyboard events. In general, we should not return the DOM event if we do not intend for its use. This will not be applicable everywhere but a good "default" policy. In this particular case I recommend separating the onDelete call.
import React, { FC, memo, useCallback } from 'react';
import { useEnterEscape } from '../../../public/useEnterEscape';
interface Props {
className?: string;
iconCls: string;
id?: string;
onDelete: () => void;
title: string;
}
export const DeleteIcon: FC<Props> = memo(({ id, title, className = 'field-icon', onDelete, iconCls }) => {
const callOnDelete = useCallback(() => {
onDelete();
}, [onDelete]);
const onKeyDown = useEnterEscape(callOnDelete);
return (
<span className={className} id={id} onClick={callOnDelete} onKeyDown={onKeyDown} tabIndex={0} title={title}>
<span className={`fa fa-times-circle ${iconCls}`} />
</span>
);
});
DeleteIcon.displayName = 'DeleteIcon';| 'fa-chevron-down': expanded, | ||
| 'fa-chevron-right': !expanded, | ||
| }); | ||
| const onKeyDown = useEnterEscape(onClick); |
| const onToggle = useCallback(() => { | ||
| setCollapsed(!collapsed); | ||
| }, [collapsed]); | ||
| const onKeyDown = useEnterEscape(onToggle); |
There was a problem hiding this comment.
nit: (unrelated) Simplify onToggle to:
const onToggle = useCallback(() => {
setCollapsed(c => !c);
}, []);| return ( | ||
| <div className="attached-file__container"> | ||
| {onDelete && <span className={deleteIconClassName} onClick={onClick} title="Remove file" />} | ||
| {onDelete && <button className={deleteIconClassName} onClick={onClick} title="Remove file" />} |
| <div className="run-step-node-detail"> | ||
| <DetailHeader header={`Run Step: ${stepName}`} iconSrc="default"> | ||
| <a className="lineage-link" onClick={onBack}> | ||
| <button className="lineage-link clickable-text" onClick={onBack}> |
| <> | ||
| Try <a onClick={() => window.location.reload()}>refreshing the page</a>. | ||
| Try{' '} | ||
| <button className="clickable-text" onClick={() => window.location.reload()}> |
| const className = classNames('lk-dropdown', 'dropdown', props.className, { open }); | ||
| const menuClassName = classNames(DROPDOWN_MENU_CLASS, { 'dropdown-menu-right': pullRight }); | ||
|
|
||
| const onKeyDown = useEnterEscape(onClick); |
There was a problem hiding this comment.
Conflating event types
| }; | ||
| const onMouseEnter = useCallback((): void => setIsActive(true), []); | ||
| const onMouseLeave = useCallback((): void => setIsActive(false), []); | ||
| const onKeyDown = useEnterEscape(onValueClick); |
There was a problem hiding this comment.
Conflating event types
| * @param onEscape: function to call when the escape key is pressed. | ||
| */ | ||
| export const useEnterEscape = (onEnter?: () => void, onEscape?: () => void): any => { | ||
| export const useEnterEscape = (onEnter?: (evt?: any) => void, onEscape?: (evt?: any) => void, allowMultiSelect?: boolean): any => { |
There was a problem hiding this comment.
The typings should be strengthened here. We expect keyboard event handling. I believe this should work (updated docs as well):
/**
* React hook for when you want to intercept Enter and Escape keys (e.g., for a text input where Enter is to save
* and Escape is to cancel). Pass the result of this hook to the onKeyDown prop of an <input /> element.
* @param onEnter function to call when the Enter key is pressed.
* @param onEscape function to call when the Escape key is pressed.
* @param allowMultiSelect When false, if the shift-key or meta-key are pressed skip processing key event. Default is false.
*/
export function useEnterEscape<E = Element>(
onEnter?: KeyboardEventHandler<E>,
onEscape?: KeyboardEventHandler<E>,
allowMultiSelect?: boolean
) {
return useCallback<KeyboardEventHandler<E>>(
evt => {
if (!allowMultiSelect && (evt.shiftKey || evt.metaKey)) return;
switch (evt.key) {
case Key.ENTER:
evt.stopPropagation();
evt.preventDefault();
onEnter?.(evt);
break;
case Key.ESCAPE:
evt.stopPropagation();
evt.preventDefault();
onEscape?.(evt);
break;
default:
break;
}
},
[allowMultiSelect, onEnter, onEscape]
);
}| }; | ||
|
|
||
| // For use with PureComponents that can't use the above hook | ||
| export const onEnterKeyDown = (onClick: () => any): ((evt: any) => void) => { |
There was a problem hiding this comment.
Same note regarding stronger typings. Additionally, I would follow the same logic for applying the onEnter defensively.
export function onEnterKeyDown<E = Element>(onEnter: KeyboardEventHandler<E>): KeyboardEventHandler<E> {
return evt => {
if (evt.shiftKey || evt.metaKey) return;
switch (evt.key) {
case Key.ENTER:
evt.stopPropagation();
evt.preventDefault();
onEnter?.(evt);
break;
}
};
}
Rationale
This PR adds keyboard accessibility in several parts of the @labkey/components package. It converts non-interactive elements (
<span>,<div>,<a>) with onClick handlers into proper<button>elements, adds focus trapping andEscape-to-close behavior to modals, makes editable grid cells tab-focusable, and extends
useEnterEscapeto pass the keyboard event to callbacks. Several class components are refactored to functional components along the way.Related Pull Requests
Changes
useEnterEscapeto allow optional event argument to callbacks and to allow for multi-select behaviorAttachmentCardso input field is not hiddentabIndexandonKeyDowncallback for thread components