mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-11-02 11:39:12 +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",
|
||||
"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",
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
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}>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -27,4 +27,5 @@ export interface TierCardProps {
|
||||
updateTier?: (value?: Tag) => Promise<void>;
|
||||
children?: ReactNode;
|
||||
popoverProps?: PopoverProps;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
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 ? (
|
||||
|
||||
@ -385,3 +385,5 @@
|
||||
@btn-height-sm: 36px;
|
||||
@btn-height-base: 40px;
|
||||
@btn-height-lg: 44px;
|
||||
|
||||
@focus-outline-color: #e6f4ff;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user