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", "elkjs": "^0.9.3",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"fast-json-patch": "^3.1.1", "fast-json-patch": "^3.1.1",
"focus-trap-react": "^11.0.4",
"html-react-parser": "^1.4.14", "html-react-parser": "^1.4.14",
"html-to-image": "1.11.11", "html-to-image": "1.11.11",
"https-browserify": "^1.0.0", "https-browserify": "^1.0.0",

View File

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

View File

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

View File

@ -22,9 +22,11 @@ import { getEntityName } from '../../utils/EntityUtils';
import { stringToHTML } from '../../utils/StringsUtils'; import { stringToHTML } from '../../utils/StringsUtils';
import { getTagImageSrc } from '../../utils/TagsUtils'; import { getTagImageSrc } from '../../utils/TagsUtils';
import { showErrorToast } from '../../utils/ToastUtils'; import { showErrorToast } from '../../utils/ToastUtils';
import { FocusTrapWithContainer } from '../common/FocusTrap/FocusTrapWithContainer';
import Loader from '../common/Loader/Loader'; import Loader from '../common/Loader/Loader';
import { CertificationProps } from './Certification.interface'; import { CertificationProps } from './Certification.interface';
import './certification.less'; import './certification.less';
const Certification = ({ const Certification = ({
currentCertificate = '', currentCertificate = '',
children, children,
@ -158,48 +160,59 @@ const Certification = ({
<Popover <Popover
className="p-0" className="p-0"
content={ content={
<Card <FocusTrapWithContainer active={popoverProps?.open || false}>
bordered={false} <Card
className="certification-card" bordered={false}
data-testid="certification-cards" className="certification-card"
title={ data-testid="certification-cards"
<Space className="w-full justify-between"> title={
<div className="flex gap-2 items-center w-full"> <Space className="w-full justify-between">
<CertificationIcon height={18} width={18} /> <div className="flex gap-2 items-center w-full">
<Typography.Text className="m-b-0 font-semibold text-sm"> <CertificationIcon height={18} width={18} />
{t('label.edit-entity', { <Typography.Text className="m-b-0 font-semibold text-sm">
entity: t('label.certification'), {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> </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> </div>
<Typography.Text </Spin>
className="m-b-0 font-semibold text-primary text-sm cursor-pointer" </Card>
data-testid="clear-certification" </FocusTrapWithContainer>
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>
} }
overlayClassName="certification-card-popover" overlayClassName="certification-card-popover"
placement="bottomRight" placement="bottomRight"

View File

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

View File

@ -354,6 +354,38 @@ const TreeAsyncSelectList: FC<TreeAsyncSelectListProps> = ({
); );
}, [glossaries, searchOptions, expandableKeys.current, isParentSelectable]); }, [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 ( return (
<TreeSelect <TreeSelect
showSearch showSearch
@ -408,6 +440,7 @@ const TreeAsyncSelectList: FC<TreeAsyncSelectListProps> = ({
onSearch={onSearch} onSearch={onSearch}
onTreeExpand={setExpandedRowKeys} onTreeExpand={setExpandedRowKeys}
{...props} {...props}
onKeyDown={handleKeyDown}
/> />
); );
}; };

View File

@ -22,6 +22,7 @@ import { getEntityName } from '../../../utils/EntityUtils';
import Fqn from '../../../utils/Fqn'; import Fqn from '../../../utils/Fqn';
import { useGenericContext } from '../../Customization/GenericProvider/GenericProvider'; import { useGenericContext } from '../../Customization/GenericProvider/GenericProvider';
import DomainSelectablTree from '../DomainSelectableTree/DomainSelectableTree'; import DomainSelectablTree from '../DomainSelectableTree/DomainSelectableTree';
import { FocusTrapWithContainer } from '../FocusTrap/FocusTrapWithContainer';
import { EditIconButton } from '../IconButtons/EditIconButton'; import { EditIconButton } from '../IconButtons/EditIconButton';
import './domain-select-dropdown.less'; import './domain-select-dropdown.less';
import { DomainSelectableListProps } from './DomainSelectableList.interface'; import { DomainSelectableListProps } from './DomainSelectableList.interface';
@ -109,15 +110,17 @@ const DomainSelectableList = ({
<Popover <Popover
destroyTooltipOnHide destroyTooltipOnHide
content={ content={
<DomainSelectablTree <FocusTrapWithContainer active={popoverProps?.open || false}>
initialDomains={initialDomains} <DomainSelectablTree
isMultiple={multiple} initialDomains={initialDomains}
showAllDomains={showAllDomains} isMultiple={multiple}
value={selectedDomainsList as string[]} showAllDomains={showAllDomains}
visible={popupVisible || Boolean(popoverProps?.open)} value={selectedDomainsList as string[]}
onCancel={handleCancel} visible={popupVisible || Boolean(popoverProps?.open)}
onSubmit={handleUpdate} onCancel={handleCancel}
/> onSubmit={handleUpdate}
/>
</FocusTrapWithContainer>
} }
open={popupVisible} open={popupVisible}
overlayClassName="domain-select-popover w-400" 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, cancelButtonProps,
saveButtonProps, saveButtonProps,
}: InlineEditProps) => { }: InlineEditProps) => {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel?.();
}
};
return ( return (
<Space <Space
className={classNames(className, 'inline-edit-container')} className={classNames(className, 'inline-edit-container')}
@ -33,7 +40,8 @@ const InlineEdit = ({
direction={direction} direction={direction}
// Used onClick to stop click propagation event anywhere in the component to parent // Used onClick to stop click propagation event anywhere in the component to parent
// TeamDetailsV1 and User.component collapsible panel. // TeamDetailsV1 and User.component collapsible panel.
onClick={(e) => e.stopPropagation()}> onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}>
{children} {children}
<Space className="w-full justify-end" data-testid="buttons" size={4}> <Space className="w-full justify-end" data-testid="buttons" size={4}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -94,6 +94,21 @@
border-radius: 12px; border-radius: 12px;
padding: 2px 6px; 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 { .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; isTreeSelect?: boolean;
hasNoActionButtons?: boolean; hasNoActionButtons?: boolean;
open?: boolean; open?: boolean;
autoFocus?: boolean;
} }
const TagSuggestion: React.FC<TagSuggestionProps> = ({ const TagSuggestion: React.FC<TagSuggestionProps> = ({
@ -47,6 +48,7 @@ const TagSuggestion: React.FC<TagSuggestionProps> = ({
isTreeSelect = false, isTreeSelect = false,
hasNoActionButtons = false, hasNoActionButtons = false,
open = true, open = true,
autoFocus = false,
}) => { }) => {
const isGlossaryType = useMemo( const isGlossaryType = useMemo(
() => tagType === TagSource.Glossary, () => tagType === TagSource.Glossary,
@ -103,6 +105,7 @@ const TagSuggestion: React.FC<TagSuggestionProps> = ({
}), }),
value: value?.map((item) => item.tagFQN) ?? [], value: value?.map((item) => item.tagFQN) ?? [],
onChange: handleTagSelection, onChange: handleTagSelection,
autoFocus,
}; };
return isTreeSelect ? ( return isTreeSelect ? (

View File

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

View File

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

View File

@ -8221,6 +8221,21 @@ flatted@^3.1.0:
resolved "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz" resolved "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz"
integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== 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: follow-redirects@^1.0.0, follow-redirects@^1.15.6:
version "1.15.6" version "1.15.6"
resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz" resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz"
@ -14141,6 +14156,11 @@ sync-i18n@^0.0.20:
dependencies: dependencies:
xml2js "0.5.0" 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: table@^6.0.9:
version "6.8.2" version "6.8.2"
resolved "https://registry.npmjs.org/table/-/table-6.8.2.tgz" resolved "https://registry.npmjs.org/table/-/table-6.8.2.tgz"