diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 27462fc631..416931ae6e 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.37.1", + "version": "7.38.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.37.1", + "version": "7.38.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index c9b665efe4..23faf43b45 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.37.1", + "version": "7.38.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index e686b4604d..1c04d7cf6f 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,18 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### 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 + ### version 7.37.1 *Released*: 20 May 2026 - Molecule and PS bulk import by file @@ -28,7 +40,7 @@ Components, models, actions, and utility functions for LabKey applications and p - Package updates ### version 7.35.1 -*Released*: 12 May 2026 +*Released*: 7 May 2026 - Package updates ### version 7.35.0 diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 736e0b3b0b..de584bb19a 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -850,7 +850,7 @@ import { WORKFLOW_HOME_HREF, WORKFLOW_KEY, } from './internal/app/constants'; -import { Key, useEnterEscape } from './public/useEnterEscape'; +import { Key, onEnterKeyDown, useEnterEscape } from './public/useEnterEscape'; import { DateInput } from './internal/components/DateInput'; import { EditInlineField } from './internal/components/EditInlineField'; import { FileAttachmentArea } from './internal/components/files/FileAttachmentArea'; @@ -1575,6 +1575,7 @@ export { NOT_IN_EXP_DESCENDANTS_OF_FILTER_TYPE, Notifications, NotificationsContextProvider, + onEnterKeyDown, OntologyBrowserFilterPanel, OntologyBrowserPage, OntologyConceptOverviewPanel, diff --git a/packages/components/src/internal/Modal.tsx b/packages/components/src/internal/Modal.tsx index 622d39341e..d97b86a57e 100644 --- a/packages/components/src/internal/Modal.tsx +++ b/packages/components/src/internal/Modal.tsx @@ -1,14 +1,19 @@ -import React, { FC, memo, PropsWithChildren, ReactNode, useEffect } from 'react'; +import React, { FC, memo, PropsWithChildren, ReactNode, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import classNames from 'classnames'; import { usePortalRef } from './hooks'; import { ModalButtons, ModalButtonsProps } from './ModalButtons'; +import { Key } from '../public/useEnterEscape'; + +const FOCUSABLE_SELECTORS = + 'a, button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; interface BaseModalProps extends PropsWithChildren { bsSize?: 'lg' | 'sm'; className?: string; + onCancel?: () => void; } /** @@ -16,8 +21,9 @@ interface BaseModalProps extends PropsWithChildren { * component, instead you should probably be using Modal, which has a bunch of props to make it easier to render a * typical modal with save/close buttons and the appropriate logic for those buttons. */ -export const BaseModal: FC = ({ bsSize, children, className }) => { +export const BaseModal: FC = ({ bsSize, children, className, onCancel }) => { const portalRef = usePortalRef('modal'); + const modalRef = useRef(null); const className_ = classNames('modal-dialog', className, { 'modal-sm': bsSize === 'sm', 'modal-lg': bsSize === 'lg', @@ -31,13 +37,46 @@ export const BaseModal: FC = ({ bsSize, children, className }) = }; }, []); + useEffect(() => { + // Focus the modal on open so keyboard navigation starts within it rather than behind it + modalRef.current?.focus(); + }, []); + + useEffect(() => { + // Trap focus within the modal so Tab/Shift+Tab cycle only through modal elements, + // and close the modal on Escape + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === Key.ESCAPE) { + onCancel?.(); + } else if (e.key === Key.TAB) { + const focusable = Array.from( + modalRef.current?.querySelectorAll(FOCUSABLE_SELECTORS) ?? [] + ); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onCancel]); + const modal = (
-
{children}
+
+ {children} +
@@ -111,7 +150,7 @@ export const Modal: FC = memo(props => { } = props; const showHeader = !!(onCancel || title); return ( - + {showHeader && !header && } {header} diff --git a/packages/components/src/internal/announcements/Discussions.tsx b/packages/components/src/internal/announcements/Discussions.tsx index 3f69b544c2..4bac4d46c3 100644 --- a/packages/components/src/internal/announcements/Discussions.tsx +++ b/packages/components/src/internal/announcements/Discussions.tsx @@ -9,6 +9,7 @@ import { AnnouncementsAPIWrapper, getDefaultAnnouncementsAPIWrapper } from './AP import { AnnouncementModel } from './model'; import { Thread } from './Thread'; import { ThreadEditor } from './ThreadEditor'; +import { useEnterEscape } from '../../public/useEnterEscape'; interface Props { api?: AnnouncementsAPIWrapper; @@ -67,6 +68,7 @@ export const Discussions: FC = memo(props => { const onShow = useCallback(() => { setShowEditor(true); }, []); + const onShowKeyDown = useEnterEscape(onShow); const updatePendingThread = useCallback( (threadId: number, hasPendingChange: boolean) => { @@ -101,8 +103,8 @@ export const Discussions: FC = memo(props => { = memo(props => { onDelete={loadDiscussions} onUpdate={loadDiscussions} readOnly={readOnly} + setPendingChange={updatePendingThread} thread={thread} user={user} - setPendingChange={updatePendingThread} /> ))} {allowCreateThread && !showEditor && ( - + Start a thread @@ -127,8 +129,8 @@ export const Discussions: FC = memo(props => { = memo(({ attachment, containerPath, onRemove }) => { const _onRemove = useCallback(() => onRemove(attachment.name), [attachment, onRemove]); + const onRemoveKeyDown = useEnterEscape(_onRemove); // Only generate a URL if the file has been uploaded. const url = attachment.created !== undefined ? getAttachmentURL(attachment, containerPath) : undefined; @@ -22,6 +24,8 @@ const ThreadAttachment: FC = memo(({ attachment, containe )} @@ -30,7 +34,7 @@ const ThreadAttachment: FC = memo(({ attachment, containe {url === undefined && {attachment.name}} {url !== undefined && ( - + {attachment.name} )} @@ -54,10 +58,10 @@ export const ThreadAttachments: FC = memo(({ attachments
{attachments.map(attachment => ( ))}
diff --git a/packages/components/src/internal/announcements/ThreadBlock.tsx b/packages/components/src/internal/announcements/ThreadBlock.tsx index 69a8f881ff..aef614cb34 100644 --- a/packages/components/src/internal/announcements/ThreadBlock.tsx +++ b/packages/components/src/internal/announcements/ThreadBlock.tsx @@ -17,6 +17,7 @@ import { fromNow, parseDate } from '../util/Date'; import { AnnouncementModel } from './model'; import { ThreadEditor, ThreadEditorProps } from './ThreadEditor'; import { ThreadAttachments } from './ThreadAttachments'; +import { useEnterEscape } from '../../public/useEnterEscape'; interface DeleteThreadBSModalProps { cancel: () => void; @@ -25,8 +26,8 @@ interface DeleteThreadBSModalProps { const DeleteThreadModal: FC = ({ cancel, onDelete }) => ( = ({ cancel, onDelete }) => ( = props => { return (
- +
@@ -93,9 +94,9 @@ const ThreadBlockHeader: FC = props => { {(onDelete || onEdit) && ( } + label={'Manage thread block'} pullRight + title={} > {onEdit !== undefined && ( @@ -184,6 +185,7 @@ export const ThreadBlock: FC = props => { const onReply = useCallback(() => { setReplying(true); }, []); + const onReplyKeyDown = useEnterEscape(onReply); const onReplied = useCallback((thread: AnnouncementModel) => { clearTimeout(recentTimeout); @@ -206,12 +208,12 @@ export const ThreadBlock: FC = props => { {!editing && (
{error !== undefined && {error}}
@@ -219,7 +221,12 @@ export const ThreadBlock: FC = props => { {allowReply && ( - + Reply )} @@ -245,8 +252,8 @@ export const ThreadBlock: FC = props => { onCancel={onCancel} onCreate={onReplied} parent={thread.parent ?? thread.entityId} - thread={undefined} setPendingChange={setPendingChange} + thread={undefined} />
)} diff --git a/packages/components/src/internal/announcements/ThreadEditor.tsx b/packages/components/src/internal/announcements/ThreadEditor.tsx index 94cfd857c3..f118f642bc 100644 --- a/packages/components/src/internal/announcements/ThreadEditor.tsx +++ b/packages/components/src/internal/announcements/ThreadEditor.tsx @@ -16,14 +16,14 @@ import { handleFileInputChange } from '../util/utils'; import { isLoading, LoadingState } from '../../public/LoadingState'; import { resolveErrorMessage } from '../util/messaging'; import { LoadingSpinner } from '../components/base/LoadingSpinner'; -import { Key } from '../../public/useEnterEscape'; +import { Key, useEnterEscape } from '../../public/useEnterEscape'; import { DropdownMenu, MenuItem } from '../dropdowns'; import { AnnouncementsAPIWrapper } from './APIWrapper'; import { RemoveAttachmentModal, ThreadAttachments } from './ThreadAttachments'; -import { Attachment, AnnouncementModel } from './model'; +import { AnnouncementModel, Attachment } from './model'; // Check if a line starts with any spaces, a number, followed by a period and a space. const orderedBulletRe = /^\s*\d+. /; @@ -439,6 +439,7 @@ export const ThreadEditor: FC = props => { onCancel?.(); handlePendingChange(); }, [thread?.rowId, onCancel, handlePendingChange]); + const onCancelKeyDown = useEnterEscape(handleCancel); const onSubmit = useCallback(() => { if (submitting) return; @@ -501,16 +502,16 @@ export const ThreadEditor: FC = props => { @@ -518,10 +519,15 @@ export const ThreadEditor: FC = props => { - + Cancel diff --git a/packages/components/src/internal/components/ColumnSelectionModal.test.tsx b/packages/components/src/internal/components/ColumnSelectionModal.test.tsx index 2390c646e6..3b2ef54822 100644 --- a/packages/components/src/internal/components/ColumnSelectionModal.test.tsx +++ b/packages/components/src/internal/components/ColumnSelectionModal.test.tsx @@ -123,7 +123,7 @@ describe('ColumnSelectionModal', () => { } else { expect(removeIcon).toBeTruthy(); const iconParent = removeIcon.parentElement; - expect(iconParent.className).toContain('view-field__action clickable'); + expect(iconParent.className).toContain('clickable-text view-field__action'); expect(iconParent.onclick).toBeDefined(); } } diff --git a/packages/components/src/internal/components/ColumnSelectionModal.tsx b/packages/components/src/internal/components/ColumnSelectionModal.tsx index 7e03435c26..d682593089 100644 --- a/packages/components/src/internal/components/ColumnSelectionModal.tsx +++ b/packages/components/src/internal/components/ColumnSelectionModal.tsx @@ -101,7 +101,7 @@ export interface ColumnChoiceProps { // exported for jest tests export const ColumnChoice: FC = memo(props => { const { column, disabledMsg, isExpanded, isInView, onAddColumn, onExpandColumn, onCollapseColumn } = props; - const { onMouseEnter, onMouseLeave, portalEl, show, targetRef } = useOverlayTriggerState( + const { onMouseEnter, onMouseLeave, portalEl, show, targetRef } = useOverlayTriggerState( 'disabled-button-overlay', disabledMsg !== undefined, false @@ -145,10 +145,18 @@ export const ColumnChoice: FC = memo(props => { ))}
{column.isLookup() && !isExpanded && ( - +
@@ -160,17 +168,18 @@ export const ColumnChoice: FC = memo(props => {
)} {!isInView && column.selectable && ( -
{show && createPortal(popover, portalEl)} -
+ )}
); @@ -315,22 +324,24 @@ export const ColumnInView: FC = memo(props => { {!editing && ( {allowEditLabel && ( - - + )} {!disableDelete && ( - - + )} {disableDelete && } diff --git a/packages/components/src/internal/components/EditInlineField.tsx b/packages/components/src/internal/components/EditInlineField.tsx index 32afb584ea..1c1f03b69f 100644 --- a/packages/components/src/internal/components/EditInlineField.tsx +++ b/packages/components/src/internal/components/EditInlineField.tsx @@ -315,7 +315,7 @@ export const EditInlineField: FC = memo(props => { className={classNames({ 'edit-inline-field__toggle': allowEdit, 'ws-pre-wrap': isTextArea })} onClick={toggleEdit} onKeyDown={toggleKeyDown} - tabIndex={1} + tabIndex={0} > {allowEdit && pullRight && } {!isUser && displayValue} diff --git a/packages/components/src/internal/components/ExpandableContainer.tsx b/packages/components/src/internal/components/ExpandableContainer.tsx index 77072f4927..c3c8fe5c79 100644 --- a/packages/components/src/internal/components/ExpandableContainer.tsx +++ b/packages/components/src/internal/components/ExpandableContainer.tsx @@ -2,7 +2,7 @@ * Copyright (c) 2016-2019 LabKey Corporation. All rights reserved. No portion of this work may be reproduced in * any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ -import React, { FC, memo, useCallback, PropsWithChildren, useState, useEffect, useMemo } from 'react'; +import React, { FC, memo, PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; @@ -75,9 +75,6 @@ export const ExpandableContainer: FC = memo(props => { return (
= memo(props => { { 'container-expandable__active': isHover || visible }, { 'container-expandable__inactive': !isHover && !visible } )} + onClick={hasOnClick || isExpandable ? handleClick : undefined} + onMouseEnter={isExpandable ? handleMouseEnter : undefined} + onMouseLeave={isExpandable ? handleMouseLeave : undefined} > {!noIcon && ( {iconFaCls ? ( - + ) : ( - + )} )}
-
diff --git a/packages/components/src/internal/components/__snapshots__/ExpandableContainer.test.tsx.snap b/packages/components/src/internal/components/__snapshots__/ExpandableContainer.test.tsx.snap index 6986c0ff47..9d81e72a07 100644 --- a/packages/components/src/internal/components/__snapshots__/ExpandableContainer.test.tsx.snap +++ b/packages/components/src/internal/components/__snapshots__/ExpandableContainer.test.tsx.snap @@ -19,8 +19,9 @@ exports[` custom props 1`] = `
-
default props 1`] = `
-
void; +} + +const TimelineTimestamp: FC = ({ event, onSelect }) => { + const onClick = () => { + if (event.rowId) onSelect(event); + }; + const onKeyDown = useEnterEscape(onClick); + return ( + + {getEventDataValueDisplay(event.timestamp)} + + ); +}; interface Props { events: TimelineEventModel[]; @@ -57,19 +80,20 @@ export class TimelineView extends React.Component { } else if (info.firstEvent && event.getRowKey() === info.firstEvent.getRowKey()) isFirstEvent = true; }); } + const onClick = () => { + if (event.rowId) this.selectEvent(event); + }; return ( { - if (event.rowId) this.selectEvent(event); - }} className={classNames({ 'timeline-event-row': event.rowId !== 0, 'timeline-row-selected': eventSelected, })} + key={event.getRowKey()} + onClick={onClick} > - {this.renderTimestampCol(event.timestamp)} + {this.renderIconCol( event.getIcon(), eventSelected, @@ -83,14 +107,6 @@ export class TimelineView extends React.Component { ); } - renderTimestampCol(timestamp) { - return ( - - {getEventDataValueDisplay(timestamp)} - - ); - } - renderIconCol( iconSrc: string, isSelected?: boolean, @@ -103,9 +119,9 @@ export class TimelineView extends React.Component { const icon = ( ); @@ -147,7 +163,7 @@ export class TimelineView extends React.Component { line = longVLine; } return ( - +
{line}
{icon} @@ -160,10 +176,7 @@ export class TimelineView extends React.Component { if (!comment) return null; return ( - } - placement="bottom" - > + } placement="bottom">
{comment}
); @@ -194,7 +207,7 @@ export class TimelineView extends React.Component { const { summary, user, entity, entitySeparator } = event; const comment = event.getComment(); return ( - +
{getEventDataValueDisplay(summary)} {entity != null && {entitySeparator ? entitySeparator : ' - '}} @@ -203,9 +216,9 @@ export class TimelineView extends React.Component {
{' '} {this.renderComment(comment)} diff --git a/packages/components/src/internal/components/auditlog/__snapshots__/TimelineView.test.tsx.snap b/packages/components/src/internal/components/auditlog/__snapshots__/TimelineView.test.tsx.snap index 729df9fbc6..0b069b6d15 100644 --- a/packages/components/src/internal/components/auditlog/__snapshots__/TimelineView.test.tsx.snap +++ b/packages/components/src/internal/components/auditlog/__snapshots__/TimelineView.test.tsx.snap @@ -11,6 +11,7 @@ exports[` Disable selection 1`] = ` > 2020-04-04 21:57 @@ -49,11 +50,12 @@ exports[` Disable selection 1`] = `
@@ -64,6 +66,7 @@ exports[` Disable selection 1`] = ` > 2020-04-04 22:57 @@ -110,11 +113,12 @@ exports[` Disable selection 1`] = `
@@ -125,6 +129,7 @@ exports[` Disable selection 1`] = ` > 2020-04-05 22:58 @@ -171,11 +176,12 @@ exports[` Disable selection 1`] = `
@@ -186,6 +192,7 @@ exports[` Disable selection 1`] = ` > 2020-04-08 22:57 @@ -232,11 +239,12 @@ exports[` Disable selection 1`] = `
@@ -247,6 +255,7 @@ exports[` Disable selection 1`] = ` > 2020-04-09 18:57 @@ -293,11 +302,12 @@ exports[` Disable selection 1`] = `
@@ -308,6 +318,7 @@ exports[` Disable selection 1`] = ` > 2020-04-09 19:57 @@ -354,11 +365,12 @@ exports[` Disable selection 1`] = `
@@ -369,6 +381,7 @@ exports[` Disable selection 1`] = ` > 2020-04-09 20:57 @@ -415,11 +428,12 @@ exports[` Disable selection 1`] = `
@@ -430,6 +444,7 @@ exports[` Disable selection 1`] = ` > 2020-05-04 23:00 @@ -514,6 +529,7 @@ exports[` Hide user link 1`] = ` > 2020-04-04 21:57 @@ -552,11 +568,12 @@ exports[` Hide user link 1`] = `
@@ -567,6 +584,7 @@ exports[` Hide user link 1`] = ` > 2020-04-04 22:57 @@ -613,11 +631,12 @@ exports[` Hide user link 1`] = `
@@ -628,6 +647,7 @@ exports[` Hide user link 1`] = ` > 2020-04-05 22:58 @@ -674,11 +694,12 @@ exports[` Hide user link 1`] = `
@@ -689,6 +710,7 @@ exports[` Hide user link 1`] = ` > 2020-04-08 22:57 @@ -735,11 +757,12 @@ exports[` Hide user link 1`] = ` @@ -750,6 +773,7 @@ exports[` Hide user link 1`] = ` > 2020-04-09 18:57 @@ -796,11 +820,12 @@ exports[` Hide user link 1`] = ` @@ -811,6 +836,7 @@ exports[` Hide user link 1`] = ` > 2020-04-09 19:57 @@ -857,11 +883,12 @@ exports[` Hide user link 1`] = ` @@ -872,6 +899,7 @@ exports[` Hide user link 1`] = ` > 2020-04-09 20:57 @@ -918,11 +946,12 @@ exports[` Hide user link 1`] = ` @@ -933,6 +962,7 @@ exports[` Hide user link 1`] = ` > 2020-05-04 23:00 @@ -1017,6 +1047,7 @@ exports[` with selection, completed entity 1`] = ` > 2020-04-04 21:57 @@ -1055,11 +1086,12 @@ exports[` with selection, completed entity 1`] = ` @@ -1070,6 +1102,7 @@ exports[` with selection, completed entity 1`] = ` > 2020-04-04 22:57 @@ -1122,11 +1155,12 @@ exports[` with selection, completed entity 1`] = ` @@ -1137,6 +1171,7 @@ exports[` with selection, completed entity 1`] = ` > 2020-04-05 22:58 @@ -1183,11 +1218,12 @@ exports[` with selection, completed entity 1`] = ` @@ -1198,6 +1234,7 @@ exports[` with selection, completed entity 1`] = ` > 2020-04-08 22:57 @@ -1244,11 +1281,12 @@ exports[` with selection, completed entity 1`] = ` @@ -1259,6 +1297,7 @@ exports[` with selection, completed entity 1`] = ` > 2020-04-09 18:57 @@ -1305,11 +1344,12 @@ exports[` with selection, completed entity 1`] = ` @@ -1320,6 +1360,7 @@ exports[` with selection, completed entity 1`] = ` > 2020-04-09 19:57 @@ -1372,11 +1413,12 @@ exports[` with selection, completed entity 1`] = ` @@ -1387,6 +1429,7 @@ exports[` with selection, completed entity 1`] = ` > 2020-04-09 20:57 @@ -1433,11 +1476,12 @@ exports[` with selection, completed entity 1`] = ` @@ -1448,6 +1492,7 @@ exports[` with selection, completed entity 1`] = ` > 2020-05-04 23:00 @@ -1532,6 +1577,7 @@ exports[` with selection, open entity 1`] = ` > 2020-04-04 21:57 @@ -1570,11 +1616,12 @@ exports[` with selection, open entity 1`] = ` @@ -1585,6 +1632,7 @@ exports[` with selection, open entity 1`] = ` > 2020-04-04 22:57 @@ -1631,11 +1679,12 @@ exports[` with selection, open entity 1`] = ` @@ -1646,6 +1695,7 @@ exports[` with selection, open entity 1`] = ` > 2020-04-05 22:58 @@ -1698,11 +1748,12 @@ exports[` with selection, open entity 1`] = ` @@ -1713,6 +1764,7 @@ exports[` with selection, open entity 1`] = ` > 2020-04-08 22:57 @@ -1759,11 +1811,12 @@ exports[` with selection, open entity 1`] = ` @@ -1774,6 +1827,7 @@ exports[` with selection, open entity 1`] = ` > 2020-04-09 18:57 @@ -1820,11 +1874,12 @@ exports[` with selection, open entity 1`] = ` @@ -1835,6 +1890,7 @@ exports[` with selection, open entity 1`] = ` > 2020-04-09 19:57 @@ -1881,11 +1937,12 @@ exports[` with selection, open entity 1`] = ` @@ -1896,6 +1953,7 @@ exports[` with selection, open entity 1`] = ` > 2020-04-09 20:57 @@ -1942,11 +2000,12 @@ exports[` with selection, open entity 1`] = ` @@ -1957,6 +2016,7 @@ exports[` with selection, open entity 1`] = ` > 2020-05-04 23:00 diff --git a/packages/components/src/internal/components/base/DeleteIcon.tsx b/packages/components/src/internal/components/base/DeleteIcon.tsx index e49054de1a..53d82eff63 100644 --- a/packages/components/src/internal/components/base/DeleteIcon.tsx +++ b/packages/components/src/internal/components/base/DeleteIcon.tsx @@ -1,16 +1,24 @@ -import React, { FC, memo } from 'react'; +import React, { FC, memo, useCallback } from 'react'; +import { useEnterEscape } from '../../../public/useEnterEscape'; interface Props { className?: string; iconCls: string; id?: string; - onDelete: (event) => void; + onDelete: () => void; title: string; } -export const DeleteIcon: FC = memo(({ id, title, className = 'field-icon', onDelete, iconCls }) => ( - - - -)); +export const DeleteIcon: FC = memo(({ id, title, className = 'field-icon', onDelete, iconCls }) => { + const callOnDelete = useCallback(() => { + onDelete(); + }, [onDelete]); + const onKeyDown = useEnterEscape(callOnDelete); + + return ( + + + + ); +}); DeleteIcon.displayName = 'DeleteIcon'; diff --git a/packages/components/src/internal/components/base/ExpandableFilterToggle.tsx b/packages/components/src/internal/components/base/ExpandableFilterToggle.tsx index 02466d8d2e..fa9f3771f2 100644 --- a/packages/components/src/internal/components/base/ExpandableFilterToggle.tsx +++ b/packages/components/src/internal/components/base/ExpandableFilterToggle.tsx @@ -19,7 +19,7 @@ export class ExpandableFilterToggle extends PureComponent { return ( <> -
+
+ {hasFilter && ( - + )} ); diff --git a/packages/components/src/internal/components/base/FieldExpansionToggle.tsx b/packages/components/src/internal/components/base/FieldExpansionToggle.tsx index e19a75ad4f..d96195e989 100644 --- a/packages/components/src/internal/components/base/FieldExpansionToggle.tsx +++ b/packages/components/src/internal/components/base/FieldExpansionToggle.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { FC, useCallback } from 'react'; import classNames from 'classnames'; +import { useEnterEscape } from '../../../public/useEnterEscape'; interface Props { cls?: string; @@ -8,27 +9,32 @@ interface Props { expandedTitle: string; highlighted?: boolean; id: string; - onClick: (event) => void; + onClick: () => void; } -export class FieldExpansionToggle extends React.Component { - render() { - const { expanded, expandedTitle, collapsedTitle, cls, highlighted, id, onClick } = this.props; - const className = classNames('fa fa-lg', { - 'field-highlighted': highlighted && !expanded, - 'fa-chevron-down': expanded, - 'fa-chevron-right': !expanded, - }); +export const FieldExpansionToggle: FC = props => { + const { expanded, expandedTitle, collapsedTitle, cls, highlighted, id, onClick } = props; + const className = classNames('fa fa-lg', { + 'field-highlighted': highlighted && !expanded, + 'fa-chevron-down': expanded, + 'fa-chevron-right': !expanded, + }); + const onClickHandler = useCallback(() => { + onClick(); + }, [onClick]); + const onKeyDown = useEnterEscape(onClickHandler); - return ( -
- -
- ); - } -} + return ( +
+ +
+ ); +}; +FieldExpansionToggle.displayName = 'FieldExpansionToggle'; diff --git a/packages/components/src/internal/components/buttons/ActionButton.test.tsx b/packages/components/src/internal/components/buttons/ActionButton.test.tsx index 50f7b433d8..899e74aaf7 100644 --- a/packages/components/src/internal/components/buttons/ActionButton.test.tsx +++ b/packages/components/src/internal/components/buttons/ActionButton.test.tsx @@ -19,11 +19,11 @@ import { userEvent } from '@testing-library/user-event'; import { ActionButton } from './ActionButton'; -describe('', () => { +describe('ActionButton', () => { test('Default properties', async () => { const onClick = jest.fn(); render(); - await userEvent.click(document.querySelector('span')); + await userEvent.click(document.querySelector('button')); expect(onClick).toHaveBeenCalledTimes(1); }); @@ -35,16 +35,16 @@ describe('', () => { buttonClass="test-button-class" containerClass="test-container-class" disabled={false} - title="test-title" onClick={onClick} + title="test-title" /> ); // Customized attributes should all be valid click targets - await userEvent.click(document.querySelector('span')); - await userEvent.click(document.querySelector('.test-button-class span')); - await userEvent.click(document.querySelector('.test-container-class span')); - await userEvent.click(document.querySelector('[title="test-title"] span')); + await userEvent.click(document.querySelector('button')); + await userEvent.click(document.querySelector('.test-button-class button')); + await userEvent.click(document.querySelector('.test-container-class button')); + await userEvent.click(document.querySelector('[title="test-title"] button')); expect(onClick).toHaveBeenCalledTimes(4); }); @@ -52,7 +52,7 @@ describe('', () => { const onClick = jest.fn(); render( - Test Body Contents

} /> + Test Body Contents

} helperTitle="test-helperTitle" onClick={onClick} /> ); // content not visible @@ -71,7 +71,7 @@ describe('', () => { test('Disabled', async () => { const onClick = jest.fn(); render(); - await userEvent.click(document.querySelector('span')); + await userEvent.click(document.querySelector('button')); expect(onClick).toHaveBeenCalledTimes(0); }); }); diff --git a/packages/components/src/internal/components/buttons/ActionButton.tsx b/packages/components/src/internal/components/buttons/ActionButton.tsx index 763e323386..b405002dae 100644 --- a/packages/components/src/internal/components/buttons/ActionButton.tsx +++ b/packages/components/src/internal/components/buttons/ActionButton.tsx @@ -42,9 +42,9 @@ export class ActionButton extends React.PureComponent { return (
- + {helperBody && {helperBody}}
diff --git a/packages/components/src/internal/components/buttons/RemoveEntityButton.tsx b/packages/components/src/internal/components/buttons/RemoveEntityButton.tsx index 80eca736fa..9e20395bbe 100644 --- a/packages/components/src/internal/components/buttons/RemoveEntityButton.tsx +++ b/packages/components/src/internal/components/buttons/RemoveEntityButton.tsx @@ -32,10 +32,10 @@ export class RemoveEntityButton extends React.Component - + ); } diff --git a/packages/components/src/internal/components/buttons/ToggleButtons.test.tsx b/packages/components/src/internal/components/buttons/ToggleButtons.test.tsx index 37215e4adc..5c98d1df8e 100644 --- a/packages/components/src/internal/components/buttons/ToggleButtons.test.tsx +++ b/packages/components/src/internal/components/buttons/ToggleButtons.test.tsx @@ -29,7 +29,7 @@ describe('ToggleButtons', () => { test('alternate button labels and set second to active', () => { const onClickFn = jest.fn(); - render(); + render(); expect(document.getElementsByClassName('toggle-on').length).toBe(0); expect(document.getElementsByClassName('toggle-off').length).toBe(1); @@ -103,7 +103,7 @@ describe('ToggleIcon', () => { expect(document.getElementsByClassName('toggle-off').length).toBe(1); expect(document.getElementsByClassName('fa-toggle-off').length).toBe(1); - await userEvent.click(document.getElementsByTagName('i')[0]); + await userEvent.click(document.getElementsByTagName('button')[0]); expect(onClickFn).toHaveBeenCalledTimes(1); expect(onClickFn).toHaveBeenCalledWith('on'); }); @@ -137,11 +137,11 @@ describe('ToggleIcon', () => { test('tooltip', async () => { const onClickFn = jest.fn(); - render(); + render(); expect(document.getElementsByClassName('overlay-trigger').length).toBe(1); - await userEvent.click(document.getElementsByTagName('i')[0]); + await userEvent.click(document.getElementsByTagName('button')[0]); expect(onClickFn).toHaveBeenCalledTimes(1); expect(onClickFn).toHaveBeenCalledWith('on'); }); diff --git a/packages/components/src/internal/components/buttons/ToggleButtons.tsx b/packages/components/src/internal/components/buttons/ToggleButtons.tsx index 5687c6d107..fd3cb7aeee 100644 --- a/packages/components/src/internal/components/buttons/ToggleButtons.tsx +++ b/packages/components/src/internal/components/buttons/ToggleButtons.tsx @@ -67,10 +67,10 @@ export const ToggleButtons: FC = memo(props => { })} id={id} > - - @@ -104,8 +104,12 @@ export const ToggleIcon: FC = memo(props => { const body = ( <> - {firstActive && } - {secondActive && } + {firstActive && ( + )} @@ -374,9 +374,9 @@ export const DataTypeSelector: FC = memo(props => { dataTypes={activeDataTypes} disabled={disabled} getUncheckedEntityWarning={_getUncheckedEntityWarning} - uncheckedEntities={uncheckedEntities} onChange={onChange} showUncheckedWarning={showUncheckedWarning} + uncheckedEntities={uncheckedEntities} /> )} {!loading && activeDataTypes?.length === 0 && ( @@ -386,8 +386,8 @@ export const DataTypeSelector: FC = memo(props => { {inactiveDataTypes?.length > 0 && (
= memo(props => { dataTypes={inactiveDataTypes} disabled={disabled} getUncheckedEntityWarning={_getUncheckedEntityWarning} - uncheckedEntities={uncheckedEntities} onChange={onChange} showUncheckedWarning={showUncheckedWarning} + uncheckedEntities={uncheckedEntities} />
diff --git a/packages/components/src/internal/components/files/FileAttachmentEntry.test.tsx b/packages/components/src/internal/components/files/FileAttachmentEntry.test.tsx index bce9da177b..a76739fb5a 100644 --- a/packages/components/src/internal/components/files/FileAttachmentEntry.test.tsx +++ b/packages/components/src/internal/components/files/FileAttachmentEntry.test.tsx @@ -6,13 +6,13 @@ import { FileAttachmentEntry } from './FileAttachmentEntry'; describe('', () => { test('with onDelete', () => { - const { container } = render(); - expect(document.querySelectorAll('span.fa-times-circle')).toHaveLength(1); + const { container } = render(); + expect(document.querySelectorAll('.fa-times-circle')).toHaveLength(1); expect(container.textContent).toBe('Test files'); }); test('no deletion', () => { render(); - expect(document.querySelectorAll('span.fa-times-circle')).toHaveLength(0); + expect(document.querySelectorAll('.fa-times-circle')).toHaveLength(0); }); test('with downloadUrl', () => { render(); diff --git a/packages/components/src/internal/components/files/FileAttachmentEntry.tsx b/packages/components/src/internal/components/files/FileAttachmentEntry.tsx index f609946db5..22ad98b277 100644 --- a/packages/components/src/internal/components/files/FileAttachmentEntry.tsx +++ b/packages/components/src/internal/components/files/FileAttachmentEntry.tsx @@ -9,10 +9,10 @@ interface Props { export const FileAttachmentEntry: FC = memo(props => { const { downloadUrl, onDelete, name } = props; const onClick = useCallback(() => onDelete(name), [onDelete, name]); - const deleteIconClassName = 'fa fa-times-circle attached-file__remove-icon'; + const deleteIconClassName = 'fa fa-times-circle clickable-text attached-file__remove-icon'; return (
- {onDelete && } + {onDelete &&
+
)} diff --git a/packages/components/src/internal/components/forms/input/__snapshots__/ColorPickerInput.test.tsx.snap b/packages/components/src/internal/components/forms/input/__snapshots__/ColorPickerInput.test.tsx.snap index 6c4974edc2..3aa4e45fac 100644 --- a/packages/components/src/internal/components/forms/input/__snapshots__/ColorPickerInput.test.tsx.snap +++ b/packages/components/src/internal/components/forms/input/__snapshots__/ColorPickerInput.test.tsx.snap @@ -21,13 +21,14 @@ exports[`ColorPickerInput allowRemove 1`] = `
- - +
{ expect(document.querySelectorAll('.label-printing--help-link')).toHaveLength(1); } - function validateButtons(canTest?: boolean, canSave?: boolean): void { + function validateButtons(canTest?: boolean, canSave?: boolean, canAdd = true): void { const buttons = document.querySelectorAll('button'); - expect(buttons).toHaveLength(1); + expect(buttons).toHaveLength(canTest || canSave || canAdd ? 2 : 1); const button = buttons.item(0); if (canTest) { expect(button).toHaveTextContent('Test Connection'); @@ -56,6 +56,9 @@ describe('BarTenderSettingsForm', () => { expect(button).toBeDisabled(); } } + if (canAdd) { + expect(buttons.item(1).textContent).toBe(' Add New Label Template'); + } } test('default props, home project', async () => { @@ -85,7 +88,7 @@ describe('BarTenderSettingsForm', () => { expect(document.querySelectorAll('.label-templates-container')).toHaveLength(0); expect(document.querySelector('input').getAttribute('type')).toBe('url'); validate(true); - validateButtons(false, false); + validateButtons(false, false, false); }); test('default props, subfolder without folders', async () => { diff --git a/packages/components/src/internal/components/lineage/node/DetailsList.tsx b/packages/components/src/internal/components/lineage/node/DetailsList.tsx index 0dfeab4123..a3b0dad87d 100644 --- a/packages/components/src/internal/components/lineage/node/DetailsList.tsx +++ b/packages/components/src/internal/components/lineage/node/DetailsList.tsx @@ -3,10 +3,10 @@ import React, { FC, Fragment, memo, PropsWithChildren, ReactNode, useCallback, u import { naturalSortByProperty } from '../../../../public/sort'; import { getLineageNodeTitle, - LineageNodeCollection, - LineageItemWithMetadata, LineageIOWithMetadata, + LineageItemWithMetadata, LineageNode, + LineageNodeCollection, } from '../models'; import { DEFAULT_ICON_URL } from '../utils'; import { NodeInteractionConsumer } from '../actions'; @@ -55,10 +55,14 @@ export const DetailsList: FC = memo(props => {
  • - +
  • {showChild ?
  • {child}
  • : null}
    @@ -86,25 +90,27 @@ export const DetailsListSteps: FC = memo(({ node, onSelect return ( - {node.steps.map((step, i) => ( -
    - - - {step.protocol?.name || step.name} - - { - onSelect(i); - }} - > - Details - -
    - )).toArray()} + {node.steps + .map((step, i) => ( +
    + + + {step.protocol?.name || step.name} + + { + onSelect(i); + }} + > + Details + +
    + )) + .toArray()}
    ); }); @@ -131,14 +137,15 @@ const DetailsListLineageItem: FC = memo(({ highligh {context => { if (context.isNodeInGraph(item)) { return ( - context.onNodeClick(item)} - onMouseOver={e => context.onNodeMouseOver(item)} onMouseOut={e => context.onNodeMouseOut(item)} + onMouseOver={e => context.onNodeMouseOver(item)} + type="button" > {item.name} - + ); } @@ -208,7 +215,7 @@ interface DetailsListNodesProps { export const DetailsListNodes: FC = memo(({ highlightNode, nodes, title }) => ( + View in grid , ]} diff --git a/packages/components/src/internal/components/lineage/node/LineageNodeDetail.tsx b/packages/components/src/internal/components/lineage/node/LineageNodeDetail.tsx index 9151396cfb..a776b86f30 100644 --- a/packages/components/src/internal/components/lineage/node/LineageNodeDetail.tsx +++ b/packages/components/src/internal/components/lineage/node/LineageNodeDetail.tsx @@ -180,20 +180,16 @@ const RunStepNodeDetail: FC = memo(props => { const stepName = step.protocol?.name || step.name; const hasProvenanceModule = useMemo(() => hasModule('provenance'), []); - const changeTab = useCallback((newTabKey: string) => { - setTabKey(newTabKey); - }, []); - return (
    - + > {stepName} - + diff --git a/packages/components/src/internal/components/navigation/FolderMenu.tsx b/packages/components/src/internal/components/navigation/FolderMenu.tsx index c81a5eeb68..203daf4a23 100644 --- a/packages/components/src/internal/components/navigation/FolderMenu.tsx +++ b/packages/components/src/internal/components/navigation/FolderMenu.tsx @@ -54,9 +54,9 @@ export const FolderMenuItems: FC = memo(props => { 'col-xs-10': !user.isAdmin, })} > - onClick(item)}> +
    = memo(props => { 'col-xs-2': !user.isAdmin, })} > - + @@ -121,12 +121,12 @@ export const FolderMenu: FC = memo(props => { {archivedItems?.length > 0 && (
    = ({ item }) => { const { dismissNotifications } = useNotificationsContext(); const { data, id, message, isDismissible } = item; const onClick = useCallback(() => dismissNotifications(id), [dismissNotifications, id]); + const onKeyDown = useEnterEscape(onClick); return (
    {typeof message === 'function' ? message(item, data) : message} - {isDismissible && } + {isDismissible && ( + + )}
    ); }; diff --git a/packages/components/src/internal/components/picklist/ChoosePicklistModal.tsx b/packages/components/src/internal/components/picklist/ChoosePicklistModal.tsx index 897558f0e1..09181992f0 100644 --- a/packages/components/src/internal/components/picklist/ChoosePicklistModal.tsx +++ b/packages/components/src/internal/components/picklist/ChoosePicklistModal.tsx @@ -291,7 +291,11 @@ export const ChoosePicklistModalDisplay: FC - Do you want to create a new one? + Do you want to{' '} + + ? ); diff --git a/packages/components/src/internal/components/samples/ManageSampleStatusesPanel.test.tsx b/packages/components/src/internal/components/samples/ManageSampleStatusesPanel.test.tsx index 3b6ddae48c..bb8035eadb 100644 --- a/packages/components/src/internal/components/samples/ManageSampleStatusesPanel.test.tsx +++ b/packages/components/src/internal/components/samples/ManageSampleStatusesPanel.test.tsx @@ -264,11 +264,11 @@ describe('SampleStatusDetail', () => { expect(selectInput).toHaveTextContent(STATE.stateType); expect(selectInput.getAttribute('class')).not.toContain('select-input__single-value--is-disabled'); const buttons = document.querySelectorAll('button'); - expect(buttons).toHaveLength(3); - expect(buttons.item(1).textContent).toContain('Delete'); - expect(buttons.item(1).getAttribute('disabled')).toBeFalsy(); - expect(buttons.item(2)).toHaveTextContent('Save'); - expect(buttons.item(2).getAttribute('disabled')).not.toBeNull(); // save initially disabled + expect(buttons).toHaveLength(4); + expect(buttons.item(2).textContent).toContain('Delete'); + expect(buttons.item(2).getAttribute('disabled')).toBeFalsy(); + expect(buttons.item(3)).toHaveTextContent('Save'); + expect(buttons.item(3).getAttribute('disabled')).not.toBeNull(); // save initially disabled }); test('in use disabled', async () => { @@ -295,11 +295,11 @@ describe('SampleStatusDetail', () => { const selectInput = document.querySelector('.select-input__single-value'); expect(selectInput.getAttribute('class')).toContain('select-input__single-value--is-disabled'); const buttons = document.querySelectorAll('button'); - expect(buttons).toHaveLength(3); - expect(buttons.item(1).textContent).toContain('Delete'); - expect(buttons.item(1).getAttribute('disabled')).not.toBeNull(); // delete disabled - expect(buttons.item(2)).toHaveTextContent('Save'); - expect(buttons.item(2).getAttribute('disabled')).not.toBeNull(); // save initially disabled + expect(buttons).toHaveLength(4); + expect(buttons.item(2).textContent).toContain('Delete'); + expect(buttons.item(2).getAttribute('disabled')).not.toBeNull(); // delete disabled + expect(buttons.item(3)).toHaveTextContent('Save'); + expect(buttons.item(3).getAttribute('disabled')).not.toBeNull(); // save initially disabled }); test('not local, disabled', async () => { diff --git a/packages/components/src/internal/components/user/UserLink.test.tsx b/packages/components/src/internal/components/user/UserLink.test.tsx index e8c480b2ff..1fcd968030 100644 --- a/packages/components/src/internal/components/user/UserLink.test.tsx +++ b/packages/components/src/internal/components/user/UserLink.test.tsx @@ -8,7 +8,9 @@ import { UserLink, UserLinkList } from './UserLink'; describe('UserLink', () => { test('unknown', () => { - const { container } = renderWithAppContext(, { serverContext: { user: TEST_USER_APP_ADMIN } }); + const { container } = renderWithAppContext(, { + serverContext: { user: TEST_USER_APP_ADMIN }, + }); expect(container.querySelectorAll('a')).toHaveLength(0); expect(container.querySelectorAll('span')).toHaveLength(1); expect(container.querySelectorAll('.gray-text')).toHaveLength(1); @@ -16,10 +18,9 @@ describe('UserLink', () => { }); test('displayValue without userId', async () => { - const { container } = renderWithAppContext( - , - { serverContext: { user: TEST_USER_APP_ADMIN } } - ); + const { container } = renderWithAppContext(, { + serverContext: { user: TEST_USER_APP_ADMIN }, + }); await waitFor(() => { expect(container.querySelectorAll('span')).toHaveLength(1); }); @@ -30,7 +31,9 @@ describe('UserLink', () => { }); test('userId without displayValue', () => { - const { container } = renderWithAppContext(, { serverContext: { user: TEST_USER_APP_ADMIN } }); + const { container } = renderWithAppContext(, { + serverContext: { user: TEST_USER_APP_ADMIN }, + }); expect(container.querySelectorAll('a')).toHaveLength(0); expect(container.querySelectorAll('span')).toHaveLength(1); expect(container.querySelectorAll('.gray-text')).toHaveLength(1); @@ -38,30 +41,27 @@ describe('UserLink', () => { }); test('userId with displayValue', async () => { - const { container } = renderWithAppContext( - , - { serverContext: { user: TEST_USER_APP_ADMIN } } - ); + const { container } = renderWithAppContext(, { + serverContext: { user: TEST_USER_APP_ADMIN }, + }); await waitFor(() => { - expect(container.querySelectorAll('a')).toHaveLength(1); + expect(container.querySelectorAll('button')).toHaveLength(1); }); - expect(container.querySelectorAll('a')).toHaveLength(1); - expect(container.querySelectorAll('.clickable')).toHaveLength(1); + expect(container.querySelectorAll('.clickable-text')).toHaveLength(1); expect(container.querySelectorAll('span')).toHaveLength(0); expect(container.querySelectorAll('.gray-text')).toHaveLength(0); - expect(container.querySelector('a').textContent).toBe('Test display'); + expect(container.querySelector('button').textContent).toBe('Test display'); }); test('user cannot ReadUserDetails, not self', async () => { - const { container } = renderWithAppContext( - , - { serverContext: { user: TEST_USER_READER } } - ); + const { container } = renderWithAppContext(, { + serverContext: { user: TEST_USER_READER }, + }); await waitFor(() => { expect(container.querySelectorAll('span')).toHaveLength(1); }); expect(container.querySelectorAll('a')).toHaveLength(0); - expect(container.querySelectorAll('.clickable')).toHaveLength(0); + expect(container.querySelectorAll('.clickable-text')).toHaveLength(0); expect(container.querySelectorAll('span')).toHaveLength(1); expect(container.querySelectorAll('.gray-text')).toHaveLength(0); expect(container.querySelector('span').textContent).toBe('Test display'); @@ -69,17 +69,16 @@ describe('UserLink', () => { test('user cannot ReadUserDetails, self', async () => { const { container } = renderWithAppContext( - , + , { serverContext: { user: TEST_USER_READER } } ); await waitFor(() => { - expect(container.querySelectorAll('a')).toHaveLength(1); + expect(container.querySelectorAll('button')).toHaveLength(1); }); - expect(container.querySelectorAll('a')).toHaveLength(1); - expect(container.querySelectorAll('.clickable')).toHaveLength(1); + expect(container.querySelectorAll('.clickable-text')).toHaveLength(1); expect(container.querySelectorAll('span')).toHaveLength(0); expect(container.querySelectorAll('.gray-text')).toHaveLength(0); - expect(container.querySelector('a').textContent).toBe('Test display'); + expect(container.querySelector('button').textContent).toBe('Test display'); }); }); diff --git a/packages/components/src/internal/components/user/UserLink.tsx b/packages/components/src/internal/components/user/UserLink.tsx index bffa4a312b..2ce0d7a3e8 100644 --- a/packages/components/src/internal/components/user/UserLink.tsx +++ b/packages/components/src/internal/components/user/UserLink.tsx @@ -72,9 +72,9 @@ export const UserLink: FC = props => { return ( <> - + {showDetails && ( ): void { } } -interface ToggleState { - onClick: (event: MouseEvent) => void; +interface ToggleState { + onClick: (event: SyntheticEvent) => void; open: boolean; setOpen: (show: boolean) => void; - toggleRef: MutableRefObject; + toggleRef: RefCallback; } -function useToggleState(): ToggleState { - const toggleRef = useRef(undefined); +function useToggleState(): ToggleState { + const nodeRef = useRef(null); const [open, setOpen] = useState(false); - const onClick = useCallback(event => { + + const toggleRef = useCallback>(node => { + nodeRef.current = node; + }, []); + + const onClick = useCallback((event: SyntheticEvent) => { event.preventDefault(); // Needed so DropdownMenu doesn't navigate to home page on click setOpen(o => !o); }, []); // onDocumentClick closes the menu if the user clicks on a MenuItem or outside the menu - const onDocumentClick = useCallback(event => { + const onDocumentClick = useCallback((event: Event) => { // Don't take action if we're clicking the toggle, as that handles open/close on its own - const isToggle = event.target === toggleRef.current; - const insideToggle = toggleRef.current?.contains(event.target); - if (isToggle || insideToggle) return; + const node = nodeRef.current; + if (!node) return; + const target = event.target as Node; + if (target === node || node.contains(target)) return; setOpen(false); }, []); useEffect(() => { // We only want to listen for clicks on the document if the menu is open if (open) { - // Note: capture: true is very important here. It's needed so that we always handle the event + // Note: capture: true is very important here. It's necessary so that we always handle the event document.addEventListener('click', onDocumentClick); } @@ -90,9 +97,10 @@ interface DropdownMenuProps extends PropsWithChildren { export const DropdownMenu: FC = props => { const { children, label, pullRight, title, asAnchor = true } = props; const id = useMemo(() => generateId('dropdown-anchor-'), []); - const { onClick, open, toggleRef } = useToggleState(); + const { onClick, open, toggleRef } = useToggleState(); const className = classNames('lk-dropdown', 'dropdown', props.className, { open }); const menuClassName = classNames(DROPDOWN_MENU_CLASS, { 'dropdown-menu-right': pullRight }); + const onKeyDown = useEnterEscape(onClick); const elemProps = { 'aria-haspopup': true, @@ -100,8 +108,10 @@ export const DropdownMenu: FC = props => { className: 'dropdown-toggle', id, onClick, + onKeyDown, ref: toggleRef, role: 'button', + tabIndex: 0, title: label, }; @@ -177,7 +187,7 @@ export const DropdownButton = forwardRef((p ); return ( -
    +
    ); @@ -263,8 +273,8 @@ export const SplitButton: FC = memo(props => {
    {button} -
      +
        {children}
    @@ -292,7 +302,7 @@ interface MenuHeaderProps { * See docs in docs/dropdowns.md */ export const MenuHeader: FC = ({ className, text }) => ( -
  • +
  • {text}
  • ); @@ -301,15 +311,16 @@ MenuHeader.displayName = 'MenuHeader'; /** * See docs in docs/dropdowns.md */ -export const MenuDivider = (): ReactElement =>
  • ; +export const MenuDivider = (): ReactElement =>
  • ; export interface MenuItemProps { active?: boolean; children: ReactNode; className?: string; disabled?: boolean; - href?: string | AppURL; + href?: AppURL | string; onClick?: () => void; + onKeyDown?: (e: React.KeyboardEvent) => void; onMouseEnter?: () => void; onMouseLeave?: () => void; rel?: string; @@ -327,6 +338,7 @@ export const MenuItem = forwardRef((props, ref) => disabled, href = '#', onClick, + onKeyDown, onMouseEnter, onMouseLeave, rel, @@ -352,10 +364,11 @@ export const MenuItem = forwardRef((props, ref) => ); return ( -
  • +
  • new Date().valueOf(); @@ -52,29 +54,32 @@ export const AttachmentCard: FC = memo(props => { titleStyle, } = props; const titleClass = titleStyle?.backgroundColor ? 'attachment-card__name status-pill' : 'attachment-card__name '; - const [showModal, setShowModal] = useState(); - - const _showModal = useCallback(() => { - setShowModal(true); - }, [setShowModal]); - - const _hideModal = useCallback(() => { - setShowModal(false); - }, [setShowModal]); + const { close, open, show } = useModalState(); const _onCopyLink = useCallback((): void => onCopyLink(attachment), [attachment, onCopyLink]); + const onCopyKeyDown = useEnterEscape(_onCopyLink); const _onDownload = useCallback((): void => { if (allowDownload) { onDownload?.(attachment); } }, [allowDownload, attachment, onDownload]); + const onDownloadKeyDown = useEnterEscape(_onDownload); const _onRemove = useCallback(() => { if (allowRemove) { onRemove?.(attachment); } }, [allowRemove, attachment, onRemove]); + const onRemoveKeyDown = useEnterEscape(_onRemove); + + const _onBodyAction = useCallback(() => { + if (!attachment || attachment.unavailable || isLoading(attachment.loadingState)) return; + if (isImage(attachment.name)) open(); + else _onDownload(); + }, [attachment, open, _onDownload]); + + const onBodyKeyDown = useEnterEscape(_onBodyAction); const showMenu = useMemo(() => { return ((onCopyLink || allowDownload) && !attachment?.unavailable) || allowRemove; @@ -90,7 +95,7 @@ export const AttachmentCard: FC = memo(props => { const recentlyCreated = attachment.created ? attachment.created > now() - 30000 : false; const _isImage = isImage(attachment.name); const modalTitle = ( - + {title ?? name} ); @@ -104,14 +109,11 @@ export const AttachmentCard: FC = memo(props => { })} title={name + (unavailable ? ' (unavailable)' : '')} > -
    +
    {_isImage && !isLoaded && } {_isImage && isLoaded && !unavailable && ( - {name} + {name} )} {(!_isImage || unavailable) && }
    @@ -138,18 +140,31 @@ export const AttachmentCard: FC = memo(props => { pullRight title={} > - {onCopyLink && !unavailable && Copy {copyNoun}} - {allowDownload && !unavailable && Download} - {allowRemove && Remove {noun}} + {onCopyLink && !unavailable && ( + + Copy {copyNoun} + + )} + {allowDownload && !unavailable && ( + + Download + + )} + {allowRemove && ( + + Remove {noun} + + )} )}
    - {showModal && ( - - {`${name} + {show && ( + + {`${name} )} ); }); +AttachmentCard.displayName = 'AttachmentCard'; diff --git a/packages/components/src/internal/util/messaging.tsx b/packages/components/src/internal/util/messaging.tsx index a97c1a6228..34eb3b83b6 100644 --- a/packages/components/src/internal/util/messaging.tsx +++ b/packages/components/src/internal/util/messaging.tsx @@ -12,7 +12,11 @@ export function getActionErrorMessage(problemStatement: string, noun: string, sh  Your session may have expired or the {noun} may no longer be valid. {showRefresh && ( <> -  Try window.location.reload()}>refreshing the page. +  Try{' '} + + . )} @@ -212,7 +216,8 @@ export function resolveErrorMessage( return noun + ' cannot be blank.'; } else if (noun === 'job' && errorMsg.indexOf('when it contains rows with blank values') > -1) { return errorMsg.replace('it contains rows with blank values', 'there are already jobs using this template'); - } else if (errorMsg.indexOf('found for field SampleState')) { // GH Issue 613 + } else if (errorMsg.indexOf('found for field SampleState')) { + // GH Issue 613 return errorMsg.replace('SampleState', 'Status'); } } diff --git a/packages/components/src/public/QueryModel/ExportMenu.tsx b/packages/components/src/public/QueryModel/ExportMenu.tsx index 77c40a91d4..634dbb6d53 100644 --- a/packages/components/src/public/QueryModel/ExportMenu.tsx +++ b/packages/components/src/public/QueryModel/ExportMenu.tsx @@ -68,7 +68,9 @@ const ExportMenuItem: FC = ({ model, onExport, option, supp {option.type === EXPORT_TYPES.LABEL && ( @@ -76,8 +78,6 @@ const ExportMenuItem: FC = ({ model, onExport, option, supp {option.label} } - maxSelection={MAX_SELECTION_ACTION_ROWS} - onClick={onClick} /> )} {option.type !== EXPORT_TYPES.LABEL && ( @@ -141,16 +141,16 @@ const ExportMenuImpl: FC = memo(props => { hasData && (
    - }> + }> {exportOptions.map(option => ( ))} @@ -181,11 +181,11 @@ export class ExportMenu extends PureComponent { return ( ); } diff --git a/packages/components/src/public/QueryModel/FilterStatus.tsx b/packages/components/src/public/QueryModel/FilterStatus.tsx index f855a1efa4..4e91e5bf42 100644 --- a/packages/components/src/public/QueryModel/FilterStatus.tsx +++ b/packages/components/src/public/QueryModel/FilterStatus.tsx @@ -73,9 +73,9 @@ export const FilterStatus: FC = memo(props => { )} {onRemoveAll && showRemoveAll && ( - + )}
    ); diff --git a/packages/components/src/public/QueryModel/ManageViewsModal.test.tsx b/packages/components/src/public/QueryModel/ManageViewsModal.test.tsx index e28b785a30..339903700f 100644 --- a/packages/components/src/public/QueryModel/ManageViewsModal.test.tsx +++ b/packages/components/src/public/QueryModel/ManageViewsModal.test.tsx @@ -161,13 +161,10 @@ describe('ViewLabel', () => { describe('ManageViewsModal', () => { test('no views', async () => { - renderWithAppContext( - , - { - appContext: { api: getQueryAPI([]) }, - serverContext: { user: TEST_USER_READER }, - } - ); + renderWithAppContext(, { + appContext: { api: getQueryAPI([]) }, + serverContext: { user: TEST_USER_READER }, + }); expect(document.querySelector('.fa-spinner')).not.toBeNull(); await waitFor(() => { @@ -179,13 +176,10 @@ describe('ManageViewsModal', () => { }); test('multiple saved views: default, named, shared and session view', async () => { - renderWithAppContext( - , - { - appContext: { api: getQueryAPI([SHARED_DEFAULT_VIEW, VIEW_1, SESSION_VIEW, SHARED_VIEW]) }, - serverContext: { user: TEST_USER_PROJECT_ADMIN }, - } - ); + renderWithAppContext(, { + appContext: { api: getQueryAPI([SHARED_DEFAULT_VIEW, VIEW_1, SESSION_VIEW, SHARED_VIEW]) }, + serverContext: { user: TEST_USER_PROJECT_ADMIN }, + }); expect(document.querySelector('.fa-spinner')).not.toBeNull(); await waitFor(() => { @@ -206,7 +200,7 @@ describe('ManageViewsModal', () => { expect(rows[1].querySelector('.col-xs-8').textContent.trim()).toBe('View 1'); expect(rows[1].querySelectorAll('.fa-pencil')).toHaveLength(1); expect(rows[1].querySelectorAll('.fa-trash-o')).toHaveLength(1); - expect(rows[1].querySelectorAll('.clickable-text')).toHaveLength(1); + expect(rows[1].querySelectorAll('.clickable-text')).toHaveLength(3); expect(rows[1].querySelector('.clickable-text').textContent).toBe('Make default'); expect(rows[2].querySelector('.col-xs-8').textContent.trim()).toBe('View 2 (edited)'); @@ -219,22 +213,19 @@ describe('ManageViewsModal', () => { expect(rows[3].querySelectorAll('.fa-pencil')).toHaveLength(1); expect(rows[3].querySelectorAll('.fa-trash-o')).toHaveLength(1); expect(rows[0].querySelectorAll('.gray-text')).toHaveLength(0); - expect(rows[3].querySelectorAll('.clickable-text')).toHaveLength(1); + expect(rows[3].querySelectorAll('.clickable-text')).toHaveLength(3); expect(rows[3].querySelector('.clickable-text').textContent).toBe('Make default'); expect(document.querySelector('button.btn-default').textContent).toEqual('Done'); }); test('system default view', async () => { - renderWithAppContext( - , - { - appContext: { - api: getQueryAPI([SYSTEM_DEFAULT_VIEW, SYSTEM_DETAIL_VIEW, VIEW_1, SESSION_VIEW, SHARED_VIEW]), - }, - serverContext: { user: TEST_USER_PROJECT_ADMIN }, - } - ); + renderWithAppContext(, { + appContext: { + api: getQueryAPI([SYSTEM_DEFAULT_VIEW, SYSTEM_DETAIL_VIEW, VIEW_1, SESSION_VIEW, SHARED_VIEW]), + }, + serverContext: { user: TEST_USER_PROJECT_ADMIN }, + }); await waitFor(() => { expect(document.querySelectorAll('.row.small-margin-bottom')).toHaveLength(4); }); @@ -251,13 +242,10 @@ describe('ManageViewsModal', () => { }); test('multiple saved views: no admin permission', async () => { - renderWithAppContext( - , - { - appContext: { api: getQueryAPI([MY_DEFAULT_VIEW, VIEW_1, SESSION_VIEW, SHARED_VIEW]) }, - serverContext: { user: TEST_USER_READER }, - } - ); + renderWithAppContext(, { + appContext: { api: getQueryAPI([MY_DEFAULT_VIEW, VIEW_1, SESSION_VIEW, SHARED_VIEW]) }, + serverContext: { user: TEST_USER_READER }, + }); expect(document.querySelector('.fa-spinner')).not.toBeNull(); await waitFor(() => { @@ -277,7 +265,7 @@ describe('ManageViewsModal', () => { expect(rows[1].querySelector('.col-xs-8').textContent.trim()).toBe('View 1'); expect(rows[1].querySelectorAll('.fa-pencil')).toHaveLength(1); expect(rows[1].querySelectorAll('.fa-trash-o')).toHaveLength(1); - expect(rows[1].querySelectorAll('.clickable-text')).toHaveLength(0); + expect(rows[1].querySelectorAll('.clickable-text')).toHaveLength(2); expect(rows[2].querySelector('.col-xs-8').textContent.trim()).toBe('View 2 (edited)'); expect(rows[2].querySelectorAll('.fa-pencil')).toHaveLength(0); diff --git a/packages/components/src/public/QueryModel/ManageViewsModal.tsx b/packages/components/src/public/QueryModel/ManageViewsModal.tsx index c14a9f686a..b7c280be59 100644 --- a/packages/components/src/public/QueryModel/ManageViewsModal.tsx +++ b/packages/components/src/public/QueryModel/ManageViewsModal.tsx @@ -91,7 +91,7 @@ export const ManageViewsModal: FC = memo(props => { const getActionView = useCallback( event => { - const targetId = event.target.id; + const targetId = event.currentTarget.id; const viewInd = parseInt(targetId.split('-')[1]); return views[viewInd]; }, @@ -211,10 +211,10 @@ export const ManageViewsModal: FC = memo(props => { {selectedView && selectedView?.name === view.name ? ( ) : ( @@ -231,35 +231,47 @@ export const ManageViewsModal: FC = memo(props => { } > {view.isSaved ? ( - + ) : ( Revert )} )} {!isDefault && !isRenaming && ( - Make default - + )} {canEdit && ( - + + + )}
    @@ -299,3 +311,4 @@ export const ManageViewsModal: FC = memo(props => { ); }); +ManageViewsModal.displayName = 'ManageViewsModal'; diff --git a/packages/components/src/public/QueryModel/TabbedGridPanel.tsx b/packages/components/src/public/QueryModel/TabbedGridPanel.tsx index 11975d1fdc..616b6b8508 100644 --- a/packages/components/src/public/QueryModel/TabbedGridPanel.tsx +++ b/packages/components/src/public/QueryModel/TabbedGridPanel.tsx @@ -26,6 +26,7 @@ import { GridPanel, GridPanelProps } from './GridPanel'; import { InjectedQueryModels } from './withQueryModels'; import { QueryModel } from './QueryModel'; import { getQueryModelExportParams } from './utils'; +import { useEnterEscape } from '../useEnterEscape'; interface GridTabProps { isActive: boolean; @@ -43,6 +44,7 @@ const GridTab: FC = memo(({ isActive, model, onSelect, pullRight, 'pull-right': pullRight, }); const onClick = useCallback(() => onSelect(id), [id, onSelect]); + const onKeyDown = useEnterEscape(onClick); const rowCountDisplay = useMemo(() => { if (rowCount === undefined && !model.isActivelyLoadingTotalCount) return tabRowCount?.toLocaleString(); @@ -51,7 +53,7 @@ const GridTab: FC = memo(({ isActive, model, onSelect, pullRight, return (
  • - + {title || queryInfo?.queryLabel || queryInfo?.name} {showRowCount && rowCountDisplay !== undefined && ({rowCountDisplay})} diff --git a/packages/components/src/public/QueryModel/grid/Value.tsx b/packages/components/src/public/QueryModel/grid/Value.tsx index 2a5f45911b..b911462dc9 100644 --- a/packages/components/src/public/QueryModel/grid/Value.tsx +++ b/packages/components/src/public/QueryModel/grid/Value.tsx @@ -13,9 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { ReactNode } from 'react'; +import React, { FC, memo, useCallback, useState } from 'react'; import classNames from 'classnames'; +import { useEnterEscape } from '../../useEnterEscape'; + import { ActionValue } from './actions/Action'; interface ValueProps { @@ -26,89 +28,82 @@ interface ValueProps { onRemove?: (actionValueIndex: number, event: any) => void; } -interface ValueState { - isActive?: boolean; - isDisabled?: boolean; -} - export const valueClassName = 'filter-status-value'; -export class Value extends React.Component { - constructor(props: ValueProps) { - super(props); +export const Value: FC = memo(({ actionValue, index, lockReadOnlyForDelete, onClick, onRemove }) => { + const [isActive, setIsActive] = useState(false); + const { action, value, displayValue, isReadOnly, isRemovable } = actionValue; - this.state = { - isActive: false, - isDisabled: false, - }; - } + const onIconClick = useCallback( + (event: React.MouseEvent): void => { + event.stopPropagation(); + event.preventDefault(); + if (onRemove && isRemovable !== false) { + onRemove(index, event); + } + }, + [index, isRemovable, onRemove] + ); - onClick = (event): void => { - // Issue 50449: Expand icon click area to remove filter value - const filterBoundBoxClick = event.target.className?.indexOf('filter-status-value') > -1; - const boxLeftEdge = event.target.getBoundingClientRect().left; - const isIconClick = event.clientX - boxLeftEdge < 30; - if (filterBoundBoxClick && isIconClick) { - this.onIconClick(event); - return; - } + const onValueClick = useCallback( + (event: React.MouseEvent): void => { + // Issue 50449: Expand icon click area to remove filter value + const target = event.target as HTMLElement; + const filterBoundBoxClick = target.className?.indexOf('filter-status-value') > -1; + const boxLeftEdge = target.getBoundingClientRect().left; + const isIconClick = event.clientX - boxLeftEdge < 30; + if (filterBoundBoxClick && isIconClick) { + onIconClick(event); + return; + } - event.stopPropagation(); - event.preventDefault(); - if (this.props.onClick && this.props.actionValue.isReadOnly === undefined) { - this.props.onClick(this.props.actionValue, event); - } - }; + event.stopPropagation(); + event.preventDefault(); + if (onClick && isReadOnly === undefined) { + onClick(actionValue, event); + } + }, + [actionValue, isReadOnly, onClick, onIconClick] + ); - onIconClick = (event): void => { - event.stopPropagation(); - event.preventDefault(); - if (this.props.onRemove && this.props.actionValue.isRemovable !== false) { - this.props.onRemove(this.props.index, event); + const onValueEnter = useCallback((): void => { + if (onClick && isReadOnly === undefined) { + onClick(actionValue, undefined); } - }; - - onMouseEnter = (): void => { - this.setState({ - isActive: true, - }); - }; + }, [actionValue, isReadOnly, onClick]); - onMouseLeave = (): void => { - this.setState({ - isActive: false, - }); - }; + const onMouseEnter = useCallback((): void => setIsActive(true), []); + const onMouseLeave = useCallback((): void => setIsActive(false), []); + const onKeyDown = useEnterEscape(onValueEnter); - render(): ReactNode { - const { actionValue, lockReadOnlyForDelete } = this.props; - const { action, value, displayValue, isReadOnly, isRemovable } = actionValue; - const showRemoveIcon = this.state.isActive && isRemovable !== false && actionValue.action.keyword !== 'view'; + const showRemoveIcon = isActive && isRemovable !== false && action.keyword !== 'view'; - const className = classNames(valueClassName, { - 'is-active': this.state.isActive, - 'is-disabled': this.state.isDisabled || (lockReadOnlyForDelete && isReadOnly), - 'is-readonly': isReadOnly !== undefined, - }); + const className = classNames(valueClassName, { + 'is-active': isActive, + 'is-disabled': lockReadOnlyForDelete && isReadOnly, + 'is-readonly': isReadOnly !== undefined, + }); - const iconClassNames = classNames( - 'symbol', - 'fa', - showRemoveIcon ? 'fa-close' : action.iconCls ? 'fa-' + action.iconCls : '' - ); + const iconClassNames = classNames( + 'symbol', + 'fa', + showRemoveIcon ? 'fa-close' : action.iconCls ? 'fa-' + action.iconCls : '' + ); - return ( -
    - {(!lockReadOnlyForDelete || !isReadOnly) && } - {isReadOnly ? : null} - {displayValue ?? value} -
    - ); - } -} + return ( +
    + {(!lockReadOnlyForDelete || !isReadOnly) && } + {isReadOnly ? : null} + {displayValue ?? value} +
    + ); +}); +Value.displayName = 'Value'; diff --git a/packages/components/src/public/QueryModel/grid/actions/Filter.test.ts b/packages/components/src/public/QueryModel/grid/actions/Filter.test.ts index 8ebad19503..a257c9e9c6 100644 --- a/packages/components/src/public/QueryModel/grid/actions/Filter.test.ts +++ b/packages/components/src/public/QueryModel/grid/actions/Filter.test.ts @@ -27,7 +27,41 @@ import { FilterProps } from '../../../../internal/components/entities/models'; describe('FilterAction::actionValueFromFilter', () => { const action = new FilterAction(); - // TODO add tests for various value options + test('no-value filter (ISBLANK)', () => { + const filter = Filter.create('col', null, Filter.Types.ISBLANK); + const value: ActionValue = action.actionValueFromFilter(filter); + expect(value.displayValue).toBe('col Is Blank'); + expect(value.value).toBe('"col" Is Blank null'); + }); + + test('multi-value IN filter with 3 or fewer values shows comma-joined list', () => { + const filter1 = Filter.create('col', ['a'], Filter.Types.IN); + expect(action.actionValueFromFilter(filter1).displayValue).toBe('col Equals One Of a'); + + const filter3 = Filter.create('col', ['a', 'b', 'c'], Filter.Types.IN); + expect(action.actionValueFromFilter(filter3).displayValue).toBe('col Equals One Of a, b, c'); + }); + + test('multi-value IN filter with more than 3 values shows count', () => { + const filter = Filter.create('col', ['a', 'b', 'c', 'd'], Filter.Types.IN); + const value: ActionValue = action.actionValueFromFilter(filter); + expect(value.displayValue).toBe('col Equals One Of (4 values)'); + }); + + test('custom getFilterDisplayValue callback overrides display', () => { + const actionWithCb = new FilterAction((_colName, rawValue) => `DISPLAY(${rawValue})`); + const filter = Filter.create('myCol', 'rawVal', Filter.Types.EQUAL); + const value: ActionValue = actionWithCb.actionValueFromFilter(filter); + expect(value.displayValue).toBe('myCol = DISPLAY(rawVal)'); + expect(value.value).toBe('"myCol" = DISPLAY(rawVal)'); + }); + + test('isReadOnly is propagated to ActionValue', () => { + const filter = Filter.create('col', 'val', Filter.Types.EQUAL); + const value: ActionValue = action.actionValueFromFilter(filter, undefined, 'readonly'); + expect(value.isReadOnly).toBe('readonly'); + }); + test('no label, unencoded column', () => { const filter = Filter.create('colName', '10', Filter.Types.EQUAL); const value: ActionValue = action.actionValueFromFilter(filter); diff --git a/packages/components/src/public/useEnterEscape.ts b/packages/components/src/public/useEnterEscape.ts index edfd02c890..1610ca8b97 100644 --- a/packages/components/src/public/useEnterEscape.ts +++ b/packages/components/src/public/useEnterEscape.ts @@ -1,4 +1,4 @@ -import { useCallback, KeyboardEvent } from 'react'; +import { KeyboardEventHandler, useCallback } from 'react'; /** * Enumeration of values for the KeyboardEvent.key @@ -17,34 +17,52 @@ export enum Key { TAB = 'Tab', } -type KeyHandler = (evt: KeyboardEvent) => void; - /** - * React hook useful for when you want to intercept enter and escape keys (e.g. for a text input where enter is save - * and escape is cancel). Pass the result of this hook to the onKeyDown prop of an element. - * @param onEnter: function to call when the enter key is pressed. - * @param onEscape: function to call when the escape key is pressed. + * 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 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 const useEnterEscape = (onEnter?: () => void, onEscape?: () => void): any => { - return useCallback( - (evt: KeyboardEvent) => { - if (evt.shiftKey || evt.metaKey) return; +export function useEnterEscape( + onEnter?: KeyboardEventHandler, + onEscape?: KeyboardEventHandler, + allowMultiSelect?: boolean +) { + return useCallback>( + evt => { + if (!allowMultiSelect && (evt.shiftKey || evt.metaKey)) return; switch (evt.key) { case Key.ENTER: evt.stopPropagation(); evt.preventDefault(); - onEnter?.(); + onEnter?.(evt); break; case Key.ESCAPE: evt.stopPropagation(); evt.preventDefault(); - onEscape?.(); + onEscape?.(evt); break; default: break; } }, - [onEnter, onEscape] + [allowMultiSelect, onEnter, onEscape] ); -}; +} + +// For use with PureComponents that can't use the above hook +export function onEnterKeyDown(onEnter: KeyboardEventHandler): KeyboardEventHandler { + return evt => { + if (evt.shiftKey || evt.metaKey) return; + + switch (evt.key) { + case Key.ENTER: + evt.stopPropagation(); + evt.preventDefault(); + onEnter?.(evt); + break; + } + }; +} diff --git a/packages/components/src/theme/announcements.scss b/packages/components/src/theme/announcements.scss index ca4291089f..e0d2e647c7 100644 --- a/packages/components/src/theme/announcements.scss +++ b/packages/components/src/theme/announcements.scss @@ -52,11 +52,18 @@ $editor-padding-horizontal: 15px; .insert-menu__attachment_input { cursor: pointer; font-weight: normal; + position: relative; margin: 0; width: 100%; input[type=file] { - display: none; + cursor: pointer; + height: 100%; + left: 0; + opacity: 0; + position: absolute; + top: 0; + width: 100%; } } } @@ -148,9 +155,21 @@ $editor-padding-horizontal: 15px; .thread-editor__attachment-input { cursor: pointer; margin-left: 15px; + position: relative; input[type=file] { - display: none; + cursor: pointer; + height: 100%; + left: 0; + opacity: 0; + position: absolute; + top: 0; + width: 100%; + } + + &:has(input[type=file]:focus-visible) { + outline: 5px auto -webkit-focus-ring-color; + outline-offset: 2px; } } .thread-editor-attachments { diff --git a/packages/components/src/theme/app/overrides.scss b/packages/components/src/theme/app/overrides.scss index 34a7df56b0..606b75159c 100644 --- a/packages/components/src/theme/app/overrides.scss +++ b/packages/components/src/theme/app/overrides.scss @@ -28,10 +28,13 @@ body { margin: 20px 0; } -// Remove :focus browser outline for nav-tabs -.nav-tabs li a:focus { +// Remove :focus browser outline for nav-tabs when active +.nav-tabs li.active a:focus { outline: none; } +.nav-tabs li.active a:focus-visible { + outline: 5px auto -webkit-focus-ring-color; +} // resolve issue 32093 // bootstrap attempts to make room to the right of an input for an error icon, diff --git a/packages/components/src/theme/attachment-card.scss b/packages/components/src/theme/attachment-card.scss index 991a684ca3..c781db786a 100644 --- a/packages/components/src/theme/attachment-card.scss +++ b/packages/components/src/theme/attachment-card.scss @@ -83,6 +83,9 @@ &:focus, &:hover { color: $gray-dark; } + &:focus-visible { + outline: 1px auto -webkit-focus-ring-color; + } } } .attachment-card__name { diff --git a/packages/components/src/theme/cards.scss b/packages/components/src/theme/cards.scss index 9eab175c66..b1d28188c4 100644 --- a/packages/components/src/theme/cards.scss +++ b/packages/components/src/theme/cards.scss @@ -269,7 +269,12 @@ $circle-size: 12 * $scale; } & .action-icon { - padding-right: 10px; + padding-right: 4px; + color: $white; + + &:hover { + color: $white; + } } } diff --git a/packages/components/src/theme/detail.scss b/packages/components/src/theme/detail.scss index 9fd6fb364f..8ae2c413b3 100644 --- a/packages/components/src/theme/detail.scss +++ b/packages/components/src/theme/detail.scss @@ -102,8 +102,8 @@ line-height: 80px; padding-left: 1.5%; background: white; - color: black; - border-right: 1px solid lightgray; + color: black !important; // override button.clickable-text + border-right: 1px solid lightgray !important; // override button.clickable-text } .component-detail--child--inactive { @@ -160,11 +160,10 @@ float: right; } -.detail__edit-button { - cursor: pointer; - +.detail__edit-button .fa { + color: $text-color-light; &:hover { - color: #adabab; + color: $gray-dark; } } diff --git a/packages/components/src/theme/dropdowns.scss b/packages/components/src/theme/dropdowns.scss index ef12f9c2a5..8e8277025c 100644 --- a/packages/components/src/theme/dropdowns.scss +++ b/packages/components/src/theme/dropdowns.scss @@ -34,3 +34,10 @@ color: color.adjust(lightgray, $lightness: -10%); } } + +.lk-dropdown .dropdown-toggle { + &:focus-visible { + outline: 5px auto -webkit-focus-ring-color; + outline-offset: 2px; + } +} diff --git a/packages/components/src/theme/fields.scss b/packages/components/src/theme/fields.scss index 3979284492..762352f9d8 100644 --- a/packages/components/src/theme/fields.scss +++ b/packages/components/src/theme/fields.scss @@ -17,7 +17,7 @@ display: inline-block; &:hover { - color: $gray-base; + color: $gray-dark;; cursor: pointer; } } @@ -120,6 +120,10 @@ } } +button.view-field__action .fa { + margin-right: 4px; +} + .toggle-group-icon { i { font-size: 1.5em; @@ -146,6 +150,9 @@ .control-label-toggle-input { float: right; + .fa { + font-size: 21px; + } .btn { padding: 4px 8px; } diff --git a/packages/components/src/theme/fileupload.scss b/packages/components/src/theme/fileupload.scss index dc56065846..08d01e6b53 100644 --- a/packages/components/src/theme/fileupload.scss +++ b/packages/components/src/theme/fileupload.scss @@ -9,6 +9,11 @@ flex-grow: 2; } +.file-upload__container:has(:focus-visible) { + outline: 2px solid $brand-primary; + outline-offset: -1px; +} + .file-upload__label { cursor: pointer; color: #116596; @@ -56,13 +61,6 @@ margin-right: 5px; } -.attached-file__remove-icon { - cursor: pointer; - font-size: 20px; - margin-right: 8px; - color: $brand-danger; -} - .file-upload__is-hover { outline: 2px dashed $brand-success !important; } @@ -124,7 +122,7 @@ } .attached-file__remove-icon { - color: $brand-danger; + color: $brand-danger !important; display: inline-block; margin-right: 10px; font-size: 20px; diff --git a/packages/components/src/theme/grid.scss b/packages/components/src/theme/grid.scss index 4c2239d645..a27e9be596 100644 --- a/packages/components/src/theme/grid.scss +++ b/packages/components/src/theme/grid.scss @@ -274,7 +274,7 @@ $table-cell-padding: 4px 2px; background-color: $table-cell-selection-bg-color; } - &.cell-selected { + &.cell-selected, &:focus { border: $table-cell-selected-border; cursor: default; outline: none; diff --git a/packages/components/src/theme/lineage.scss b/packages/components/src/theme/lineage.scss index cbcad4e432..1ee6de497c 100644 --- a/packages/components/src/theme/lineage.scss +++ b/packages/components/src/theme/lineage.scss @@ -7,7 +7,6 @@ } .lineage-name { - outline: none; h6 { display: inline-block; diff --git a/packages/components/src/theme/navbar.scss b/packages/components/src/theme/navbar.scss index 3eb0724354..2937583108 100644 --- a/packages/components/src/theme/navbar.scss +++ b/packages/components/src/theme/navbar.scss @@ -131,14 +131,23 @@ border: none; background-color: transparent; box-shadow: none; + border-radius: 2px; + + &:focus { + background-color: transparent !important; + } + &:focus-visible { + outline: 1px solid $white; + outline-offset: 2px; + } - &:focus, &:active, &:hover, &:hover:active { box-shadow: none; border: none; background-color: transparent; + outline: none; } } @@ -169,6 +178,19 @@ border-radius: 4px 0 0 4px; padding-left: 8px; + .menu-folder-item { + background: none; + border: none; + color: $link-color; + padding: 0; + text-align: left; + width: 100%; + + &:hover { + color: $link-hover-color; + } + } + li:hover { background-color: $gray-lighter; border-radius: 4px 0 0 4px; @@ -182,7 +204,7 @@ background-color: $white; } - a { + a, .menu-folder-item { color: $text-color; &:focus, &:hover { @@ -291,7 +313,9 @@ } .menu-section-header:hover .menu-folder-icons, - .menu-section-item:hover .menu-folder-icons { + .menu-section-header.active .menu-folder-icons, + .menu-section-item:hover .menu-folder-icons, + .menu-section-item.active .menu-folder-icons { display: inline-block; } } @@ -529,6 +553,10 @@ &.active > a { border-bottom: 3px solid $brand-secondary; + + &:focus:not(:focus-visible) { + outline: none; + } } } } @@ -574,6 +602,11 @@ & .caret { color: $white; } + + a:focus-visible { + outline: 1px solid $white; + outline-offset: 2px; + } } .user-name { diff --git a/packages/components/src/theme/notification.scss b/packages/components/src/theme/notification.scss index baabfb887f..4d4b4b0510 100644 --- a/packages/components/src/theme/notification.scss +++ b/packages/components/src/theme/notification.scss @@ -128,6 +128,16 @@ button.clickable-text { padding: 0; border: none; background-color: transparent; + + &.fa { + color: $text-color-light; + padding-left: 5px; + text-decoration: none; + + &:hover { + color: $gray-dark; + } + } } .page-detail-header-title-padding { diff --git a/packages/components/src/theme/product-navigation.scss b/packages/components/src/theme/product-navigation.scss index 4722db0cd9..7655ed8499 100644 --- a/packages/components/src/theme/product-navigation.scss +++ b/packages/components/src/theme/product-navigation.scss @@ -213,5 +213,8 @@ cursor: default; } } + a:focus:not(:focus-visible) { + outline: none; + } } } diff --git a/packages/components/src/theme/samples.scss b/packages/components/src/theme/samples.scss index 7a9c728413..07f187bed7 100644 --- a/packages/components/src/theme/samples.scss +++ b/packages/components/src/theme/samples.scss @@ -59,6 +59,10 @@ .entity-insert--remove-parent { padding-top: 7px; + + button { + color: $text-color-light; + } } .container--removal-icon:hover { diff --git a/packages/components/src/theme/tabs.scss b/packages/components/src/theme/tabs.scss index 1f52bf9abe..8d16fac1b8 100644 --- a/packages/components/src/theme/tabs.scss +++ b/packages/components/src/theme/tabs.scss @@ -18,6 +18,9 @@ text-align: center; text-transform: uppercase; white-space: nowrap; + &:focus { + background-color: $gray-shadow; + } } }