mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-11-07 05:53:46 +00:00
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:
parent
e3ad25abc9
commit
f61ddbe41f
@ -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",
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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}>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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) =>
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user