Skip to content

Commit 71bc737

Browse files
authored
Accessibility improvements: Keyboard interactions (#2003)
### version 7.38.0 *Released*: 21 May 2026 - Accessibility improvements for app pages: Keyboard Interactions - 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
1 parent 6ae3bc6 commit 71bc737

76 files changed

Lines changed: 1319 additions & 731 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/components/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/components/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@labkey/components",
3-
"version": "7.37.1",
3+
"version": "7.38.0",
44
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
55
"sideEffects": false,
66
"files": [

packages/components/releaseNotes/components.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
# @labkey/components
22
Components, models, actions, and utility functions for LabKey applications and pages
33

4+
### version 7.38.0
5+
*Released*: 21 May 2026
6+
- Accessibility improvements for app pages: Keyboard Interactions
7+
- Make ActionButton a button so it can be tabbed to
8+
- Allow tab to app main menu folder items
9+
- Modals to have focus on open, allow tab only within modal elements, and ESCAPE to close
10+
- Use buttons with clickable-text styling instead of spans and divs with onClick properties
11+
- Update `useEnterEscape` to allow optional event argument to callbacks and to allow for multi-select behavior
12+
- EditableGrid Cell to allow tab focus with tabIndex 0
13+
- Update styling for file inputs on `AttachmentCard` so input field is not hidden
14+
- Add `tabIndex` and `onKeyDown` callback for thread components
15+
416
### version 7.37.1
517
*Released*: 20 May 2026
618
- Molecule and PS bulk import by file
@@ -28,7 +40,7 @@ Components, models, actions, and utility functions for LabKey applications and p
2840
- Package updates
2941

3042
### version 7.35.1
31-
*Released*: 12 May 2026
43+
*Released*: 7 May 2026
3244
- Package updates
3345

3446
### version 7.35.0

packages/components/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -850,7 +850,7 @@ import {
850850
WORKFLOW_HOME_HREF,
851851
WORKFLOW_KEY,
852852
} from './internal/app/constants';
853-
import { Key, useEnterEscape } from './public/useEnterEscape';
853+
import { Key, onEnterKeyDown, useEnterEscape } from './public/useEnterEscape';
854854
import { DateInput } from './internal/components/DateInput';
855855
import { EditInlineField } from './internal/components/EditInlineField';
856856
import { FileAttachmentArea } from './internal/components/files/FileAttachmentArea';
@@ -1575,6 +1575,7 @@ export {
15751575
NOT_IN_EXP_DESCENDANTS_OF_FILTER_TYPE,
15761576
Notifications,
15771577
NotificationsContextProvider,
1578+
onEnterKeyDown,
15781579
OntologyBrowserFilterPanel,
15791580
OntologyBrowserPage,
15801581
OntologyConceptOverviewPanel,

packages/components/src/internal/Modal.tsx

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,29 @@
1-
import React, { FC, memo, PropsWithChildren, ReactNode, useEffect } from 'react';
1+
import React, { FC, memo, PropsWithChildren, ReactNode, useEffect, useRef } from 'react';
22
import { createPortal } from 'react-dom';
33

44
import classNames from 'classnames';
55

66
import { usePortalRef } from './hooks';
77
import { ModalButtons, ModalButtonsProps } from './ModalButtons';
8+
import { Key } from '../public/useEnterEscape';
9+
10+
const FOCUSABLE_SELECTORS =
11+
'a, button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
812

913
interface BaseModalProps extends PropsWithChildren {
1014
bsSize?: 'lg' | 'sm';
1115
className?: string;
16+
onCancel?: () => void;
1217
}
1318

1419
/**
1520
* This component renders the absolute basic elements needed to render a modal. You probably shouldn't use this
1621
* component, instead you should probably be using Modal, which has a bunch of props to make it easier to render a
1722
* typical modal with save/close buttons and the appropriate logic for those buttons.
1823
*/
19-
export const BaseModal: FC<BaseModalProps> = ({ bsSize, children, className }) => {
24+
export const BaseModal: FC<BaseModalProps> = ({ bsSize, children, className, onCancel }) => {
2025
const portalRef = usePortalRef('modal');
26+
const modalRef = useRef<HTMLDivElement>(null);
2127
const className_ = classNames('modal-dialog', className, {
2228
'modal-sm': bsSize === 'sm',
2329
'modal-lg': bsSize === 'lg',
@@ -31,13 +37,46 @@ export const BaseModal: FC<BaseModalProps> = ({ bsSize, children, className }) =
3137
};
3238
}, []);
3339

40+
useEffect(() => {
41+
// Focus the modal on open so keyboard navigation starts within it rather than behind it
42+
modalRef.current?.focus();
43+
}, []);
44+
45+
useEffect(() => {
46+
// Trap focus within the modal so Tab/Shift+Tab cycle only through modal elements,
47+
// and close the modal on Escape
48+
const handleKeyDown = (e: KeyboardEvent) => {
49+
if (e.key === Key.ESCAPE) {
50+
onCancel?.();
51+
} else if (e.key === Key.TAB) {
52+
const focusable = Array.from(
53+
modalRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS) ?? []
54+
);
55+
if (!focusable.length) return;
56+
const first = focusable[0];
57+
const last = focusable[focusable.length - 1];
58+
if (e.shiftKey && document.activeElement === first) {
59+
e.preventDefault();
60+
last.focus();
61+
} else if (!e.shiftKey && document.activeElement === last) {
62+
e.preventDefault();
63+
first.focus();
64+
}
65+
}
66+
};
67+
document.addEventListener('keydown', handleKeyDown);
68+
return () => document.removeEventListener('keydown', handleKeyDown);
69+
}, [onCancel]);
70+
3471
const modal = (
3572
<div className="modal-wrapper">
3673
<div className="fade in modal-backdrop" />
3774

3875
<div className="lk-modal modal">
3976
<div className={className_}>
40-
<div className="modal-content">{children}</div>
77+
<div className="modal-content" ref={modalRef} tabIndex={-1}>
78+
{children}
79+
</div>
4180
</div>
4281
</div>
4382
</div>
@@ -111,7 +150,7 @@ export const Modal: FC<ModalProps> = memo(props => {
111150
} = props;
112151
const showHeader = !!(onCancel || title);
113152
return (
114-
<BaseModal bsSize={bsSize} className={className}>
153+
<BaseModal bsSize={bsSize} className={className} onCancel={onCancel}>
115154
{showHeader && !header && <ModalHeader onCancel={onCancel} title={title} />}
116155
{header}
117156

packages/components/src/internal/announcements/Discussions.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { AnnouncementsAPIWrapper, getDefaultAnnouncementsAPIWrapper } from './AP
99
import { AnnouncementModel } from './model';
1010
import { Thread } from './Thread';
1111
import { ThreadEditor } from './ThreadEditor';
12+
import { useEnterEscape } from '../../public/useEnterEscape';
1213

1314
interface Props {
1415
api?: AnnouncementsAPIWrapper;
@@ -67,6 +68,7 @@ export const Discussions: FC<Props> = memo(props => {
6768
const onShow = useCallback(() => {
6869
setShowEditor(true);
6970
}, []);
71+
const onShowKeyDown = useEnterEscape(onShow);
7072

7173
const updatePendingThread = useCallback(
7274
(threadId: number, hasPendingChange: boolean) => {
@@ -101,23 +103,23 @@ export const Discussions: FC<Props> = memo(props => {
101103
<Thread
102104
api={api}
103105
containerPath={containerPath}
104-
discussionSrcIdentifier={discussionSrcIdentifier}
105106
discussionSrcEntityType={discussionSrcEntityType}
107+
discussionSrcIdentifier={discussionSrcIdentifier}
106108
key={thread.rowId}
107109
nounPlural={nounPlural}
108110
nounSingular={nounSingular}
109111
onCreate={loadDiscussions}
110112
onDelete={loadDiscussions}
111113
onUpdate={loadDiscussions}
112114
readOnly={readOnly}
115+
setPendingChange={updatePendingThread}
113116
thread={thread}
114117
user={user}
115-
setPendingChange={updatePendingThread}
116118
/>
117119
))}
118120

119121
{allowCreateThread && !showEditor && (
120-
<span className="clickable-text" onClick={onShow}>
122+
<span className="clickable-text" onClick={onShow} onKeyDown={onShowKeyDown} tabIndex={0}>
121123
<i className="fa fa-comments" />
122124
Start a thread
123125
</span>
@@ -127,8 +129,8 @@ export const Discussions: FC<Props> = memo(props => {
127129
<ThreadEditor
128130
api={api}
129131
containerPath={containerPath}
130-
discussionSrcIdentifier={discussionSrcIdentifier}
131132
discussionSrcEntityType={discussionSrcEntityType}
133+
discussionSrcIdentifier={discussionSrcIdentifier}
132134
nounPlural={nounPlural}
133135
nounSingular={nounSingular}
134136
onCancel={onCancel}

packages/components/src/internal/announcements/ThreadAttachments.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Alert } from '../components/base/Alert';
44
import { Modal } from '../Modal';
55

66
import { Attachment, getAttachmentURL } from './model';
7+
import { useEnterEscape } from '../../public/useEnterEscape';
78

89
interface ThreadAttachmentProps {
910
attachment: Attachment;
@@ -13,6 +14,7 @@ interface ThreadAttachmentProps {
1314

1415
const ThreadAttachment: FC<ThreadAttachmentProps> = memo(({ attachment, containerPath, onRemove }) => {
1516
const _onRemove = useCallback(() => onRemove(attachment.name), [attachment, onRemove]);
17+
const onRemoveKeyDown = useEnterEscape(_onRemove);
1618
// Only generate a URL if the file has been uploaded.
1719
const url = attachment.created !== undefined ? getAttachmentURL(attachment, containerPath) : undefined;
1820

@@ -22,6 +24,8 @@ const ThreadAttachment: FC<ThreadAttachmentProps> = memo(({ attachment, containe
2224
<span
2325
className="fa fa-times-circle thread-attachment-icon thread-attachment-icon--remove"
2426
onClick={_onRemove}
27+
onKeyDown={onRemoveKeyDown}
28+
tabIndex={0}
2529
/>
2630
)}
2731

@@ -30,7 +34,7 @@ const ThreadAttachment: FC<ThreadAttachmentProps> = memo(({ attachment, containe
3034
{url === undefined && <span>{attachment.name}</span>}
3135

3236
{url !== undefined && (
33-
<a href={url} target="_blank" rel="noopener noreferrer">
37+
<a href={url} rel="noopener noreferrer" target="_blank">
3438
{attachment.name}
3539
</a>
3640
)}
@@ -54,10 +58,10 @@ export const ThreadAttachments: FC<ThreadAttachmentsProps> = memo(({ attachments
5458
<div className="thread-editor-attachments__list">
5559
{attachments.map(attachment => (
5660
<ThreadAttachment
57-
key={attachment.name}
5861
attachment={attachment}
59-
onRemove={onRemove}
6062
containerPath={containerPath}
63+
key={attachment.name}
64+
onRemove={onRemove}
6165
/>
6266
))}
6367
</div>

packages/components/src/internal/announcements/ThreadBlock.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { fromNow, parseDate } from '../util/Date';
1717
import { AnnouncementModel } from './model';
1818
import { ThreadEditor, ThreadEditorProps } from './ThreadEditor';
1919
import { ThreadAttachments } from './ThreadAttachments';
20+
import { useEnterEscape } from '../../public/useEnterEscape';
2021

2122
interface DeleteThreadBSModalProps {
2223
cancel: () => void;
@@ -25,8 +26,8 @@ interface DeleteThreadBSModalProps {
2526

2627
const DeleteThreadModal: FC<DeleteThreadBSModalProps> = ({ cancel, onDelete }) => (
2728
<Modal
28-
confirmText="Yes, Delete Thread"
2929
confirmClass="btn-danger"
30+
confirmText="Yes, Delete Thread"
3031
onCancel={cancel}
3132
onConfirm={onDelete}
3233
title="Delete this comment thread?"
@@ -39,8 +40,8 @@ DeleteThreadModal.displayName = 'DeleteThreadModal';
3940

4041
const DeleteReplyModal: FC<DeleteThreadBSModalProps> = ({ cancel, onDelete }) => (
4142
<Modal
42-
confirmText="Yes, Delete Reply"
4343
confirmClass="btn-danger"
44+
confirmText="Yes, Delete Reply"
4445
onCancel={cancel}
4546
onConfirm={onDelete}
4647
title="Delete this reply?"
@@ -83,7 +84,7 @@ const ThreadBlockHeader: FC<ThreadBlockHeaderProps> = props => {
8384
return (
8485
<div className="thread-block-header">
8586
<span className="thread-block-header__user">
86-
<UserLink userId={author.id} userDisplayValue={author.displayName} />
87+
<UserLink userDisplayValue={author.displayName} userId={author.id} />
8788
</span>
8889
<div className="pull-right">
8990
<span className="thread-block-header__date">
@@ -93,9 +94,9 @@ const ThreadBlockHeader: FC<ThreadBlockHeaderProps> = props => {
9394
{(onDelete || onEdit) && (
9495
<DropdownMenu
9596
className="thread-block-header__menu"
96-
label={"Manage thread block"}
97-
title={<i className="fa fa-ellipsis-v" />}
97+
label={'Manage thread block'}
9898
pullRight
99+
title={<i className="fa fa-ellipsis-v" />}
99100
>
100101
{onEdit !== undefined && (
101102
<MenuItem className="thread-block-header__menu-edit" onClick={onEdit}>
@@ -184,6 +185,7 @@ export const ThreadBlock: FC<ThreadBlockProps> = props => {
184185
const onReply = useCallback(() => {
185186
setReplying(true);
186187
}, []);
188+
const onReplyKeyDown = useEnterEscape(onReply);
187189

188190
const onReplied = useCallback((thread: AnnouncementModel) => {
189191
clearTimeout(recentTimeout);
@@ -206,20 +208,25 @@ export const ThreadBlock: FC<ThreadBlockProps> = props => {
206208
{!editing && (
207209
<div className="thread-block-body">
208210
<ThreadBlockHeader
211+
author={thread.author}
209212
created={thread.created}
213+
isThread={!thread.parent}
210214
modified={thread.modified}
211215
onDelete={allowDelete ? onDeleteThread : undefined}
212216
onEdit={allowUpdate ? onEdit : undefined}
213-
author={thread.author}
214-
isThread={!thread.parent}
215217
/>
216218
{error !== undefined && <Alert>{error}</Alert>}
217219
<div className="thread-block-body__content" dangerouslySetInnerHTML={threadBody} />
218220

219221
<ThreadAttachments attachments={thread.attachments ?? []} containerPath={containerPath} />
220222

221223
{allowReply && (
222-
<span className="clickable-text thread-block__reply" onClick={onReply}>
224+
<span
225+
className="clickable-text thread-block__reply"
226+
onClick={onReply}
227+
onKeyDown={onReplyKeyDown}
228+
tabIndex={0}
229+
>
223230
Reply
224231
</span>
225232
)}
@@ -245,8 +252,8 @@ export const ThreadBlock: FC<ThreadBlockProps> = props => {
245252
onCancel={onCancel}
246253
onCreate={onReplied}
247254
parent={thread.parent ?? thread.entityId}
248-
thread={undefined}
249255
setPendingChange={setPendingChange}
256+
thread={undefined}
250257
/>
251258
</div>
252259
)}

0 commit comments

Comments
 (0)