Skip to content

Accessibility improvements: Keyboard interactions#2003

Open
cnathe wants to merge 73 commits into
developfrom
fb_keyboardInteraction
Open

Accessibility improvements: Keyboard interactions#2003
cnathe wants to merge 73 commits into
developfrom
fb_keyboardInteraction

Conversation

@cnathe
Copy link
Copy Markdown
Contributor

@cnathe cnathe commented May 14, 2026

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 and
Escape-to-close behavior to modals, makes editable grid cells tab-focusable, and extends useEnterEscape to pass the keyboard event to callbacks. Several class components are refactored to functional components along the way.

Related Pull Requests

Changes

  • Make ActionButton a button so it can be tabbed to
  • Allow tab to app main menu folder items
  • Modals to have focus on open, allow tab only within modal elements, and ESCAPE to close
  • Use buttons with clickable-text styling instead of spans and divs with onClick properties
  • Update useEnterEscape to allow optional event argument to callbacks and to allow for multi-select behavior
  • EditableGrid Cell to allow tab focus with tabIndex 0
  • Update styling for file inputs on AttachmentCard so input field is not hidden
  • Add tabIndex and onKeyDown callback for thread components

cnathe and others added 30 commits May 7, 2026 16:07
@labkey-nicka labkey-nicka self-requested a review May 15, 2026 17:28
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}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conflating event types. Here I think it is also safe to not hand the event back through props (after examining the usages).

Image

const onToggle = useCallback(() => {
setCollapsed(!collapsed);
}, [collapsed]);
const onKeyDown = useEnterEscape(onToggle);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" />}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add type="button"

<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}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add type="button"

<>
&nbsp;Try <a onClick={() => window.location.reload()}>refreshing the page</a>.
&nbsp;Try{' '}
<button className="clickable-text" onClick={() => window.location.reload()}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add type="button"

const className = classNames('lk-dropdown', 'dropdown', props.className, { open });
const menuClassName = classNames(DROPDOWN_MENU_CLASS, { 'dropdown-menu-right': pullRight });

const onKeyDown = useEnterEscape(onClick);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conflating event types

};
const onMouseEnter = useCallback((): void => setIsActive(true), []);
const onMouseLeave = useCallback((): void => setIsActive(false), []);
const onKeyDown = useEnterEscape(onValueClick);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
        }
    };
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants