Table bulk edit keyboard support (#22113)

* Keyboard support for tags, glossary terms and related terms

* Keyboard support for Certification, Domain, Tier, User team select components

* FocusTrap common and first element focus

* Unit tests for useRovingFocus and FocusTrapWithContainer

* Deactivate focus trap on popover close

* Update Playwrite tests

* Address review comments

---------

Co-authored-by: Satish <satish@Satishs-MacBook-Pro.local>
This commit is contained in:
satish 2025-07-09 12:12:39 +05:30 committed by GitHub
parent e3ad25abc9
commit f61ddbe41f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 753 additions and 164 deletions

View File

@ -89,6 +89,7 @@
"elkjs": "^0.9.3",
"eventemitter3": "^5.0.1",
"fast-json-patch": "^3.1.1",
"focus-trap-react": "^11.0.4",
"html-react-parser": "^1.4.14",
"html-to-image": "1.11.11",
"https-browserify": "^1.0.0",

View File

@ -343,6 +343,8 @@ export const assignTier = async (
await page.waitForSelector('[data-testid="loader"]', { state: 'detached' });
const patchRequest = page.waitForResponse(`/api/v1/${endpoint}/*`);
await page.getByTestId(`radio-btn-${tier}`).click();
await page.click(`[data-testid="update-tier-card"]`);
await patchRequest;
await clickOutside(page);
@ -358,6 +360,7 @@ export const removeTier = async (page: Page, endpoint: string) => {
response.request().method() === 'PATCH'
);
await page.getByTestId('clear-tier').click();
await patchRequest;
await clickOutside(page);

View File

@ -626,6 +626,7 @@ export const fillRowDetails = async (
.press('Enter', { delay: 100 });
await page.click(`[data-testid="radio-btn-${row.tier}"]`);
await page.click(`[data-testid="update-tier-card"]`);
await page
.locator('.InovuaReactDataGrid__cell--cell-active')

View File

@ -22,9 +22,11 @@ import { getEntityName } from '../../utils/EntityUtils';
import { stringToHTML } from '../../utils/StringsUtils';
import { getTagImageSrc } from '../../utils/TagsUtils';
import { showErrorToast } from '../../utils/ToastUtils';
import { FocusTrapWithContainer } from '../common/FocusTrap/FocusTrapWithContainer';
import Loader from '../common/Loader/Loader';
import { CertificationProps } from './Certification.interface';
import './certification.less';
const Certification = ({
currentCertificate = '',
children,
@ -158,48 +160,59 @@ const Certification = ({
<Popover
className="p-0"
content={
<Card
bordered={false}
className="certification-card"
data-testid="certification-cards"
title={
<Space className="w-full justify-between">
<div className="flex gap-2 items-center w-full">
<CertificationIcon height={18} width={18} />
<Typography.Text className="m-b-0 font-semibold text-sm">
{t('label.edit-entity', {
entity: t('label.certification'),
})}
<FocusTrapWithContainer active={popoverProps?.open || false}>
<Card
bordered={false}
className="certification-card"
data-testid="certification-cards"
title={
<Space className="w-full justify-between">
<div className="flex gap-2 items-center w-full">
<CertificationIcon height={18} width={18} />
<Typography.Text className="m-b-0 font-semibold text-sm">
{t('label.edit-entity', {
entity: t('label.certification'),
})}
</Typography.Text>
</div>
<Typography.Text
className="m-b-0 font-semibold text-primary text-sm cursor-pointer"
data-testid="clear-certification"
tabIndex={0}
onClick={() => updateCertificationData()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
updateCertificationData();
}
}}>
{t('label.clear')}
</Typography.Text>
</Space>
}>
<Spin
indicator={<Loader size="small" />}
spinning={isLoadingCertificationData}>
{certificationCardData}
<div className="flex justify-end text-lg gap-2 mt-4">
<Button
data-testid="close-certification"
type="default"
onClick={handleCloseCertification}>
<CloseOutlined />
</Button>
<Button
data-testid="update-certification"
type="primary"
onClick={() =>
updateCertificationData(selectedCertification)
}>
<CheckOutlined />
</Button>
</div>
<Typography.Text
className="m-b-0 font-semibold text-primary text-sm cursor-pointer"
data-testid="clear-certification"
onClick={() => updateCertificationData()}>
{t('label.clear')}
</Typography.Text>
</Space>
}>
<Spin
indicator={<Loader size="small" />}
spinning={isLoadingCertificationData}>
{certificationCardData}
<div className="flex justify-end text-lg gap-2 mt-4">
<Button
data-testid="close-certification"
type="default"
onClick={handleCloseCertification}>
<CloseOutlined />
</Button>
<Button
data-testid="update-certification"
type="primary"
onClick={() => updateCertificationData(selectedCertification)}>
<CheckOutlined />
</Button>
</div>
</Spin>
</Card>
</Spin>
</Card>
</FocusTrapWithContainer>
}
overlayClassName="certification-card-popover"
placement="bottomRight"

View File

@ -59,6 +59,7 @@ const AsyncSelectList: FC<AsyncSelectListProps & SelectProps> = ({
}) => {
const [isLoading, setIsLoading] = useState(false);
const [hasContentLoading, setHasContentLoading] = useState(false);
const [open, setOpen] = useState(props.autoFocus ?? false);
const [options, setOptions] = useState<SelectOption[]>([]);
const [searchValue, setSearchValue] = useState<string>('');
const [paging, setPaging] = useState<Paging>({} as Paging);
@ -305,12 +306,13 @@ const AsyncSelectList: FC<AsyncSelectListProps & SelectProps> = ({
/>
)
}
open={open}
optionLabelProp="label"
// this popupClassName class is used to identify the dropdown in the playwright tests
popupClassName="async-select-list-dropdown"
popupClassName="async-select-list-dropdown" // this popupClassName class is used to identify the dropdown in the playwright tests
style={{ width: '100%' }}
tagRender={customTagRender}
onChange={handleChange}
onDropdownVisibleChange={setOpen}
onInputKeyDown={(event) => {
if (event.key === 'Backspace') {
return event.stopPropagation();

View File

@ -354,6 +354,38 @@ const TreeAsyncSelectList: FC<TreeAsyncSelectListProps> = ({
);
}, [glossaries, searchOptions, expandableKeys.current, isParentSelectable]);
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'Escape':
e.preventDefault();
onCancel?.();
break;
case 'Tab':
e.preventDefault();
e.stopPropagation();
form.submit();
break;
case 'Enter': {
e.preventDefault();
e.stopPropagation();
const active = document.querySelector(
'.ant-select-tree .ant-select-tree-treenode-active .ant-select-tree-checkbox'
);
if (active) {
(active as HTMLElement).click();
} else {
form.submit();
}
break;
}
default:
break;
}
};
return (
<TreeSelect
showSearch
@ -408,6 +440,7 @@ const TreeAsyncSelectList: FC<TreeAsyncSelectListProps> = ({
onSearch={onSearch}
onTreeExpand={setExpandedRowKeys}
{...props}
onKeyDown={handleKeyDown}
/>
);
};

View File

@ -22,6 +22,7 @@ import { getEntityName } from '../../../utils/EntityUtils';
import Fqn from '../../../utils/Fqn';
import { useGenericContext } from '../../Customization/GenericProvider/GenericProvider';
import DomainSelectablTree from '../DomainSelectableTree/DomainSelectableTree';
import { FocusTrapWithContainer } from '../FocusTrap/FocusTrapWithContainer';
import { EditIconButton } from '../IconButtons/EditIconButton';
import './domain-select-dropdown.less';
import { DomainSelectableListProps } from './DomainSelectableList.interface';
@ -109,15 +110,17 @@ const DomainSelectableList = ({
<Popover
destroyTooltipOnHide
content={
<DomainSelectablTree
initialDomains={initialDomains}
isMultiple={multiple}
showAllDomains={showAllDomains}
value={selectedDomainsList as string[]}
visible={popupVisible || Boolean(popoverProps?.open)}
onCancel={handleCancel}
onSubmit={handleUpdate}
/>
<FocusTrapWithContainer active={popoverProps?.open || false}>
<DomainSelectablTree
initialDomains={initialDomains}
isMultiple={multiple}
showAllDomains={showAllDomains}
value={selectedDomainsList as string[]}
visible={popupVisible || Boolean(popoverProps?.open)}
onCancel={handleCancel}
onSubmit={handleUpdate}
/>
</FocusTrapWithContainer>
}
open={popupVisible}
overlayClassName="domain-select-popover w-400"

View File

@ -0,0 +1,62 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*
* Copyright 2024 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* You may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { render } from '@testing-library/react';
import { FocusTrapWithContainer } from './FocusTrapWithContainer';
jest.mock('focus-trap-react', () => ({
FocusTrap: ({ children, focusTrapOptions }: any) => (
<div
data-options={JSON.stringify(!!focusTrapOptions)}
data-testid="focus-trap-mock">
{children}
</div>
),
}));
describe('FocusTrapWithContainer', () => {
it('renders children inside FocusTrap', () => {
const { getByText, getByTestId } = render(
<FocusTrapWithContainer>
<button>Test Button</button>
</FocusTrapWithContainer>
);
expect(getByTestId('focus-trap-mock')).toBeInTheDocument();
expect(getByText('Test Button')).toBeInTheDocument();
});
it('passes focusTrapOptions to FocusTrap', () => {
const { getByTestId } = render(
<FocusTrapWithContainer>
<span>Child</span>
</FocusTrapWithContainer>
);
// The mock sets data-options to true if focusTrapOptions is present
expect(getByTestId('focus-trap-mock').getAttribute('data-options')).toBe(
'true'
);
});
});

View File

@ -0,0 +1,39 @@
/*
* Copyright 2023 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FocusTrap } from 'focus-trap-react';
import { useRef } from 'react';
export const FocusTrapWithContainer = ({
children,
active = true,
}: {
children: React.ReactNode;
active?: boolean;
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
return (
<FocusTrap
active={active}
focusTrapOptions={{
fallbackFocus: () => containerRef.current || document.body,
initialFocus: () =>
(containerRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
) as HTMLElement) || containerRef.current,
}}>
<div ref={containerRef}>{children}</div>
</FocusTrap>
);
};

View File

@ -26,6 +26,13 @@ const InlineEdit = ({
cancelButtonProps,
saveButtonProps,
}: InlineEditProps) => {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel?.();
}
};
return (
<Space
className={classNames(className, 'inline-edit-container')}
@ -33,7 +40,8 @@ const InlineEdit = ({
direction={direction}
// Used onClick to stop click propagation event anywhere in the component to parent
// TeamDetailsV1 and User.component collapsible panel.
onClick={(e) => e.stopPropagation()}>
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}>
{children}
<Space className="w-full justify-end" data-testid="buttons" size={4}>

View File

@ -25,6 +25,7 @@ import {
} from '../../../constants/constants';
import { EntityReference } from '../../../generated/entity/data/table';
import { Paging } from '../../../generated/type/paging';
import { useRovingFocus } from '../../../hooks/useRovingFocus';
import { getEntityName } from '../../../utils/EntityUtils';
import Loader from '../Loader/Loader';
import Searchbar from '../SearchBarComponent/SearchBar.component';
@ -225,6 +226,11 @@ export const SelectableList = ({
}
};
const { containerRef, getItemProps } = useRovingFocus({
totalItems: uniqueOptions.length,
onSelect: (index) => selectionHandler(uniqueOptions[index]),
});
const handleUpdateClick = async () => {
handleUpdate([...selectedItemsInternal.values()]);
};
@ -293,54 +299,61 @@ export const SelectableList = ({
}}
size="small">
{uniqueOptions.length > 0 && (
<VirtualList
className="selectable-list-virtual-list"
data={uniqueOptions}
height={height}
itemHeight={40}
itemKey="id"
onScroll={onScroll}>
{(item) => (
<List.Item
className={classNames('selectable-list-item', 'cursor-pointer', {
active: checkActiveSelectedItem(item),
})}
extra={
multiSelect ? (
<CheckOutlined
className={classNames('selectable-list-item-checkmark', {
active: checkActiveSelectedItem(item),
})}
/>
) : (
checkActiveSelectedItem(item) && (
<RemoveIcon
removeIconTooltipLabel={removeIconTooltipLabel}
removeOwner={handleRemoveClick}
<div ref={containerRef} role="listbox" tabIndex={0}>
<VirtualList
className="selectable-list-virtual-list"
data={uniqueOptions}
height={height}
itemHeight={40}
itemKey="id"
onScroll={onScroll}>
{(item, index) => (
<List.Item
className={classNames(
'selectable-list-item',
'cursor-pointer',
{
active: checkActiveSelectedItem(item),
}
)}
extra={
multiSelect ? (
<CheckOutlined
className={classNames('selectable-list-item-checkmark', {
active: checkActiveSelectedItem(item),
})}
/>
) : (
checkActiveSelectedItem(item) && (
<RemoveIcon
removeIconTooltipLabel={removeIconTooltipLabel}
removeOwner={handleRemoveClick}
/>
)
)
)
}
key={item.id}
title={getEntityName(item)}
onClick={(e) => {
// Used to stop click propagation event anywhere in the component to parent
// TeamDetailsV1 collapsible panel
e.stopPropagation();
selectionHandler(item);
}}>
{customTagRenderer ? (
customTagRenderer(item)
) : (
<UserTag
avatarType="outlined"
id={item.name ?? ''}
name={getEntityName(item)}
/>
)}
</List.Item>
)}
</VirtualList>
}
key={item.id}
{...getItemProps(index)}
title={getEntityName(item)}
onClick={(e) => {
// Used to stop click propagation event anywhere in the component to parent
// TeamDetailsV1 collapsible panel
e.stopPropagation();
selectionHandler(item);
}}>
{customTagRenderer ? (
customTagRenderer(item)
) : (
<UserTag
avatarType="outlined"
id={item.name ?? ''}
name={getEntityName(item)}
/>
)}
</List.Item>
)}
</VirtualList>
</div>
)}
</List>
);

View File

@ -27,4 +27,5 @@ export interface TierCardProps {
updateTier?: (value?: Tag) => Promise<void>;
children?: ReactNode;
popoverProps?: PopoverProps;
onClose?: () => void;
}

View File

@ -99,6 +99,14 @@ describe('Test TierCard Component', () => {
fireEvent.click(radioButton);
});
const updateTierCard = await screen.findByTestId('update-tier-card');
expect(updateTierCard).toBeInTheDocument();
await act(async () => {
fireEvent.click(updateTierCard);
});
expect(mockOnUpdate).toHaveBeenCalled();
});

View File

@ -12,6 +12,7 @@
*/
import {
Button,
Card,
Collapse,
Popover,
@ -23,13 +24,15 @@ import {
} from 'antd';
import { AxiosError } from 'axios';
import { useEffect, useState } from 'react';
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants';
import { Tag } from '../../../generated/entity/classification/tag';
import { getTags } from '../../../rest/tagAPI';
import { getEntityName } from '../../../utils/EntityUtils';
import { showErrorToast } from '../../../utils/ToastUtils';
import { FocusTrapWithContainer } from '../FocusTrap/FocusTrapWithContainer';
import Loader from '../Loader/Loader';
import RichTextEditorPreviewerV1 from '../RichTextEditor/RichTextEditorPreviewerV1';
import './tier-card.style.less';
@ -41,13 +44,17 @@ const TierCard = ({
updateTier,
children,
popoverProps,
onClose,
}: TierCardProps) => {
const popoverRef = useRef<any>(null);
const [tiers, setTiers] = useState<Array<Tag>>([]);
const [tierCardData, setTierCardData] = useState<Array<CardWithListItems>>(
[]
);
const [selectedTier, setSelectedTier] = useState<string>(currentTier ?? '');
const [isLoadingTierData, setIsLoadingTierData] = useState<boolean>(false);
const { t } = useTranslation();
const getTierData = async () => {
setIsLoadingTierData(true);
try {
@ -92,12 +99,18 @@ const TierCard = ({
const tier = tiers.find((tier) => tier.fullyQualifiedName === value);
await updateTier?.(tier);
setIsLoadingTierData(false);
popoverRef.current?.close();
};
const handleTierSelection = async ({
target: { value },
}: RadioChangeEvent) => {
updateTierData(value);
setSelectedTier(value);
};
const handleCloseTier = async () => {
popoverRef.current?.close();
onClose?.();
};
useEffect(() => {
@ -110,70 +123,94 @@ const TierCard = ({
<Popover
className="p-0"
content={
<Card
className="tier-card"
data-testid="cards"
title={
<Space className="w-full p-xs justify-between">
<Typography.Text className="m-b-0 font-medium text-md">
{t('label.edit-entity', { entity: t('label.tier') })}
</Typography.Text>
<Typography.Text
className="m-b-0 font-normal text-primary cursor-pointer"
data-testid="clear-tier"
// we need to pass undefined to clear the tier
onClick={() => updateTierData()}>
{t('label.clear')}
</Typography.Text>
</Space>
}>
<Spin
indicator={<Loader size="small" />}
spinning={isLoadingTierData}>
<Radio.Group value={currentTier} onChange={handleTierSelection}>
<Collapse
accordion
className="bg-white border-none tier-card-content"
collapsible="icon"
defaultActiveKey={currentTier}
expandIconPosition="end">
{tierCardData.map((card) => (
<Panel
data-testid="card-list"
header={
<Radio
className="radio-input"
data-testid={`radio-btn-${card.title}`}
value={card.id}>
<Space direction="vertical" size={0}>
<Typography.Paragraph
className="m-b-0 font-regular text-grey-body"
style={{ color: card.style?.color }}>
{card.title}
</Typography.Paragraph>
<Typography.Paragraph className="m-b-0 font-regular text-xs text-grey-muted">
{card.description.replace(/\*/g, '')}
</Typography.Paragraph>
</Space>
</Radio>
<FocusTrapWithContainer active={popoverProps?.open || false}>
<Card
className="tier-card"
data-testid="cards"
title={
<Space className="w-full p-xs justify-between">
<Typography.Text className="m-b-0 font-medium text-md">
{t('label.edit-entity', { entity: t('label.tier') })}
</Typography.Text>
<Typography.Text
className="m-b-0 font-normal text-primary cursor-pointer"
data-testid="clear-tier"
tabIndex={0}
// we need to pass undefined to clear the tier
onClick={() => updateTierData()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
updateTierData();
}
key={card.id}>
<div className="m-l-md">
<RichTextEditorPreviewerV1
className="tier-card-description"
enableSeeMoreVariant={false}
markdown={card.data}
/>
</div>
</Panel>
))}
</Collapse>
</Radio.Group>
</Spin>
</Card>
}}>
{t('label.clear')}
</Typography.Text>
</Space>
}>
<Spin
indicator={<Loader size="small" />}
spinning={isLoadingTierData}>
<Radio.Group value={selectedTier} onChange={handleTierSelection}>
<Collapse
accordion
className="bg-white border-none tier-card-content"
collapsible="icon"
defaultActiveKey={selectedTier}
expandIconPosition="end">
{tierCardData.map((card) => (
<Panel
data-testid="card-list"
header={
<Radio
className="radio-input"
data-testid={`radio-btn-${card.title}`}
value={card.id}>
<Space direction="vertical" size={0}>
<Typography.Paragraph
className="m-b-0 font-regular text-grey-body"
style={{ color: card.style?.color }}>
{card.title}
</Typography.Paragraph>
<Typography.Paragraph className="m-b-0 font-regular text-xs text-grey-muted">
{card.description.replace(/\*/g, '')}
</Typography.Paragraph>
</Space>
</Radio>
}
key={card.id}>
<div className="m-l-md">
<RichTextEditorPreviewerV1
className="tier-card-description"
enableSeeMoreVariant={false}
markdown={card.data}
/>
</div>
</Panel>
))}
</Collapse>
</Radio.Group>
<div className="flex justify-end text-lg gap-2 mt-4">
<Button
data-testid="close-tier-card"
type="default"
onClick={handleCloseTier}>
<CloseOutlined />
</Button>
<Button
data-testid="update-tier-card"
type="primary"
onClick={() => updateTierData(selectedTier)}>
<CheckOutlined />
</Button>
</div>
</Spin>
</Card>
</FocusTrapWithContainer>
}
overlayClassName="tier-card-popover"
placement="bottomRight"
ref={popoverRef}
showArrow={false}
trigger="click"
onOpenChange={(visible) =>

View File

@ -78,3 +78,9 @@
max-height: 460px;
overflow-y: auto;
}
/* Outline on focus */
.ant-radio-wrapper:focus-within .ant-radio-inner {
outline: 2px solid @focus-outline-color;
outline-offset: 2px;
}

View File

@ -35,6 +35,7 @@ import {
getEntityName,
getEntityReferenceListFromEntities,
} from '../../../utils/EntityUtils';
import { FocusTrapWithContainer } from '../FocusTrap/FocusTrapWithContainer';
import { EditIconButton } from '../IconButtons/EditIconButton';
import { SelectableList } from '../SelectableList/SelectableList.component';
import { UserTag } from '../UserTag/UserTag.component';
@ -255,7 +256,7 @@ export const UserTeamSelectableList = ({
<Popover
destroyTooltipOnHide
content={
<>
<FocusTrapWithContainer active={popoverProps?.open || false}>
{previewSelected && (
<Space
className="user-team-popover-header w-full p-x-sm p-y-md"
@ -285,7 +286,6 @@ export const UserTeamSelectableList = ({
</div>
</Space>
)}
<Tabs
centered
activeKey={activeTab}
@ -349,7 +349,7 @@ export const UserTeamSelectableList = ({
// Users.component collapsible panel
onClick={(e) => e.stopPropagation()}
/>
</>
</FocusTrapWithContainer>
}
open={popupVisible}
overlayClassName="user-team-select-popover card-shadow"

View File

@ -94,6 +94,21 @@
border-radius: 12px;
padding: 2px 6px;
}
.selectable-list-item:focus-visible {
outline: 2px solid @focus-outline-color;
background-color: @focus-outline-color;
}
[role='tab']:focus-visible {
outline: 2px solid @focus-outline-color;
background-color: @focus-outline-color;
}
[role='tabpanel']:focus {
outline: 2px solid @focus-outline-color;
background-color: @focus-outline-color;
}
}
.ant-btn.ant-btn-icon-only.edit-owner-button {

View File

@ -0,0 +1,182 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*
* Copyright 2024 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* You may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { act, fireEvent, render } from '@testing-library/react';
import { useRovingFocus } from './useRovingFocus';
function TestRovingFocus({
totalItems = 3,
initialIndex = 0,
vertical = true,
onSelect,
}: {
totalItems?: number;
initialIndex?: number;
vertical?: boolean;
onSelect?: (index: number) => void;
}) {
const { containerRef, focusedIndex, getItemProps } = useRovingFocus({
totalItems,
initialIndex,
vertical,
onSelect,
});
return (
<div data-testid="container" ref={containerRef} tabIndex={-1}>
{Array.from({ length: totalItems }).map((_, i) => (
<button data-testid={`item-${i}`} key={i} {...getItemProps(i)}>
Item {i}
</button>
))}
<div data-testid="focused-index">{focusedIndex}</div>
</div>
);
}
describe('useRovingFocus', () => {
it('should set initial focus index correctly', () => {
const { getByTestId } = render(
<TestRovingFocus initialIndex={2} totalItems={5} />
);
expect(getByTestId('focused-index').textContent).toBe('2');
expect(getByTestId('item-2')).toHaveAttribute('tabindex', '0');
});
it('should move focus with ArrowDown/ArrowUp (vertical)', () => {
const { getByTestId } = render(
<TestRovingFocus vertical initialIndex={1} totalItems={3} />
);
let focused = getByTestId('item-1');
act(() => {
fireEvent.keyDown(focused, { key: 'ArrowDown' });
});
expect(getByTestId('focused-index').textContent).toBe('2');
focused = getByTestId('item-2');
act(() => {
fireEvent.keyDown(focused, { key: 'ArrowUp' });
});
expect(getByTestId('focused-index').textContent).toBe('1');
});
it('should move focus with ArrowRight/ArrowLeft (horizontal)', () => {
const { getByTestId } = render(
<TestRovingFocus initialIndex={1} totalItems={3} vertical={false} />
);
let focused = getByTestId('item-1');
act(() => {
fireEvent.keyDown(focused, { key: 'ArrowRight' });
});
expect(getByTestId('focused-index').textContent).toBe('2');
focused = getByTestId('item-2');
act(() => {
fireEvent.keyDown(focused, { key: 'ArrowLeft' });
});
expect(getByTestId('focused-index').textContent).toBe('1');
});
it('should not move focus out of bounds', () => {
const { getByTestId } = render(
<TestRovingFocus vertical initialIndex={0} totalItems={3} />
);
let focused = getByTestId('item-0');
act(() => {
fireEvent.keyDown(focused, { key: 'ArrowUp' });
});
expect(getByTestId('focused-index').textContent).toBe('0');
focused = getByTestId('item-0');
act(() => {
fireEvent.keyDown(focused, { key: 'ArrowDown' });
});
expect(getByTestId('focused-index').textContent).toBe('1');
focused = getByTestId('item-1');
act(() => {
fireEvent.keyDown(focused, { key: 'ArrowDown' });
});
expect(getByTestId('focused-index').textContent).toBe('2');
focused = getByTestId('item-2');
act(() => {
fireEvent.keyDown(focused, { key: 'ArrowDown' });
});
expect(getByTestId('focused-index').textContent).toBe('2');
});
it('should call onSelect with correct index on Enter or Space', () => {
const onSelect = jest.fn();
const { getByTestId } = render(
<TestRovingFocus initialIndex={1} totalItems={3} onSelect={onSelect} />
);
const focused = getByTestId('item-1');
act(() => {
fireEvent.keyDown(focused, { key: 'Enter' });
});
expect(onSelect).toHaveBeenCalledWith(1);
act(() => {
fireEvent.keyDown(focused, { key: ' ' });
});
expect(onSelect).toHaveBeenCalledWith(1);
});
it('should update focus if totalItems changes', () => {
const { getByTestId, rerender } = render(
<TestRovingFocus initialIndex={2} totalItems={3} />
);
expect(getByTestId('focused-index').textContent).toBe('2');
rerender(<TestRovingFocus initialIndex={2} totalItems={2} />);
expect(getByTestId('focused-index').textContent).toBe('1');
});
it('should set focus when item receives focus', () => {
const { getByTestId } = render(
<TestRovingFocus initialIndex={0} totalItems={3} />
);
const item2 = getByTestId('item-2');
act(() => {
item2.focus();
fireEvent.focus(item2);
});
expect(getByTestId('focused-index').textContent).toBe('2');
});
});

View File

@ -0,0 +1,131 @@
/*
* Copyright 2023 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
interface UseRovingFocusOptions {
initialIndex?: number;
vertical?: boolean;
onSelect?: (index: number) => void;
totalItems: number;
}
export function useRovingFocus({
initialIndex = 0,
vertical = true,
onSelect,
totalItems,
}: UseRovingFocusOptions) {
const [focusedIndex, setFocusedIndex] = useState(() =>
Math.min(Math.max(initialIndex, 0), totalItems - 1)
);
const containerRef = useRef<HTMLDivElement | null>(null);
const moveFocus = (delta: number) => {
setFocusedIndex((prev) => {
const next = prev + delta;
if (next < 0) {
return 0;
}
if (next >= totalItems) {
return totalItems - 1;
}
return next;
});
};
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const container = containerRef.current;
if (!container || totalItems === 0) {
return;
}
const target = e.target as HTMLElement;
if (!target.dataset.rovingItem) {
return;
}
switch (e.key) {
case 'ArrowDown':
case 'ArrowRight':
if (vertical ? e.key === 'ArrowDown' : e.key === 'ArrowRight') {
e.preventDefault();
moveFocus(1);
}
break;
case 'ArrowUp':
case 'ArrowLeft':
if (vertical ? e.key === 'ArrowUp' : e.key === 'ArrowLeft') {
e.preventDefault();
moveFocus(-1);
}
break;
case 'Enter':
case ' ':
e.preventDefault();
onSelect?.(focusedIndex);
break;
}
},
[focusedIndex, totalItems, vertical, onSelect]
);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const current = container.querySelector<HTMLElement>(
`[data-roving-index="${focusedIndex}"]`
);
current?.focus();
}, [focusedIndex, totalItems]);
useEffect(() => {
// Reset if current focus index is invalid
if (focusedIndex >= totalItems && totalItems > 0) {
setFocusedIndex(totalItems - 1);
} else if (focusedIndex < 0 && totalItems > 0) {
setFocusedIndex(0);
}
}, [totalItems]);
const getItemProps = (index: number) => ({
tabIndex: focusedIndex === index ? 0 : -1,
'data-roving-item': 'true',
'data-roving-index': index,
onFocus: () => setFocusedIndex(index),
});
return {
containerRef,
focusedIndex,
setFocusedIndex,
getItemProps,
};
}

View File

@ -35,6 +35,7 @@ export interface TagSuggestionProps {
isTreeSelect?: boolean;
hasNoActionButtons?: boolean;
open?: boolean;
autoFocus?: boolean;
}
const TagSuggestion: React.FC<TagSuggestionProps> = ({
@ -47,6 +48,7 @@ const TagSuggestion: React.FC<TagSuggestionProps> = ({
isTreeSelect = false,
hasNoActionButtons = false,
open = true,
autoFocus = false,
}) => {
const isGlossaryType = useMemo(
() => tagType === TagSource.Glossary,
@ -103,6 +105,7 @@ const TagSuggestion: React.FC<TagSuggestionProps> = ({
}),
value: value?.map((item) => item.tagFQN) ?? [],
onChange: handleTagSelection,
autoFocus,
};
return isTreeSelect ? (

View File

@ -385,3 +385,5 @@
@btn-height-sm: 36px;
@btn-height-base: 40px;
@btn-height-lg: 44px;
@focus-outline-color: #e6f4ff;

View File

@ -149,6 +149,7 @@ class CSVUtilsClassBase {
return (
<InlineEdit onCancel={props.onCancel} onSave={props.onComplete}>
<TagSuggestion
autoFocus
selectProps={{
className: 'react-grid-select-dropdown',
size: 'small',
@ -198,11 +199,16 @@ class CSVUtilsClassBase {
}, 1);
};
const onClose = () => {
props.onCancel();
};
return (
<TierCard
currentTier={value}
popoverProps={{ open: true }}
updateTier={handleChange}>
updateTier={handleChange}
onClose={onClose}>
{' '}
</TierCard>
);

View File

@ -8221,6 +8221,21 @@ flatted@^3.1.0:
resolved "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz"
integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==
focus-trap-react@^11.0.4:
version "11.0.4"
resolved "https://registry.yarnpkg.com/focus-trap-react/-/focus-trap-react-11.0.4.tgz#889315c28b86ca7f3e9978710eb73819c0bb9b2c"
integrity sha512-tC7jC/yqeAqhe4irNIzdyDf9XCtGSeECHiBSYJBO/vIN0asizbKZCt8TarB6/XqIceu42ajQ/U4lQJ9pZlWjrg==
dependencies:
focus-trap "^7.6.5"
tabbable "^6.2.0"
focus-trap@^7.6.5:
version "7.6.5"
resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-7.6.5.tgz#56f0814286d43c1a2688e9bc4f31f17ae047fb76"
integrity sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg==
dependencies:
tabbable "^6.2.0"
follow-redirects@^1.0.0, follow-redirects@^1.15.6:
version "1.15.6"
resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz"
@ -14141,6 +14156,11 @@ sync-i18n@^0.0.20:
dependencies:
xml2js "0.5.0"
tabbable@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97"
integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==
table@^6.0.9:
version "6.8.2"
resolved "https://registry.npmjs.org/table/-/table-6.8.2.tgz"