From f61ddbe41f27a2c729cfe84339ff689eb7969931 Mon Sep 17 00:00:00 2001 From: satish Date: Wed, 9 Jul 2025 12:12:39 +0530 Subject: [PATCH] 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 --- .../src/main/resources/ui/package.json | 1 + .../resources/ui/playwright/utils/entity.ts | 3 + .../ui/playwright/utils/importUtils.ts | 1 + .../Certification/Certification.component.tsx | 93 +++++---- .../AsyncSelectList/AsyncSelectList.tsx | 6 +- .../AsyncSelectList/TreeAsyncSelectList.tsx | 33 ++++ .../DomainSelectableList.component.tsx | 21 +- .../FocusTrap/FocusTrapWithContainer.test.tsx | 62 ++++++ .../FocusTrap/FocusTrapWithContainer.tsx | 39 ++++ .../InlineEdit/InlineEdit.component.tsx | 10 +- .../SelectableList.component.tsx | 105 +++++----- .../common/TierCard/TierCard.interface.ts | 1 + .../common/TierCard/TierCard.test.tsx | 8 + .../components/common/TierCard/TierCard.tsx | 161 ++++++++++------ .../common/TierCard/tier-card.style.less | 6 + .../UserTeamSelectableList.component.tsx | 6 +- .../user-team-selectable-list.less | 15 ++ .../ui/src/hooks/useRovingFocus.test.tsx | 182 ++++++++++++++++++ .../resources/ui/src/hooks/useRovingFocus.ts | 131 +++++++++++++ .../pages/TasksPage/shared/TagSuggestion.tsx | 3 + .../resources/ui/src/styles/variables.less | 2 + .../ui/src/utils/CSV/CSVUtilsClassBase.tsx | 8 +- .../src/main/resources/ui/yarn.lock | 20 ++ 23 files changed, 753 insertions(+), 164 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/FocusTrap/FocusTrapWithContainer.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/FocusTrap/FocusTrapWithContainer.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/hooks/useRovingFocus.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/hooks/useRovingFocus.ts diff --git a/openmetadata-ui/src/main/resources/ui/package.json b/openmetadata-ui/src/main/resources/ui/package.json index fdefb182f32..ec1e780d8bb 100644 --- a/openmetadata-ui/src/main/resources/ui/package.json +++ b/openmetadata-ui/src/main/resources/ui/package.json @@ -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", diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts index 9aa1b5f00c3..28a0656fb06 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -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); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts index de5b48da823..73d4b8bb6ba 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts @@ -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') diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.component.tsx index 255568e68d8..3e49bb6bd4c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.component.tsx @@ -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 = ({ -
- - - {t('label.edit-entity', { - entity: t('label.certification'), - })} + + +
+ + + {t('label.edit-entity', { + entity: t('label.certification'), + })} + +
+ updateCertificationData()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + updateCertificationData(); + } + }}> + {t('label.clear')} + + }> + } + spinning={isLoadingCertificationData}> + {certificationCardData} +
+ +
- updateCertificationData()}> - {t('label.clear')} - - - }> - } - spinning={isLoadingCertificationData}> - {certificationCardData} -
- - -
-
-
+ + +
} overlayClassName="certification-card-popover" placement="bottomRight" diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/AsyncSelectList.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/AsyncSelectList.tsx index 9347034d7be..209baaa9a1b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/AsyncSelectList.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/AsyncSelectList.tsx @@ -59,6 +59,7 @@ const AsyncSelectList: FC = ({ }) => { const [isLoading, setIsLoading] = useState(false); const [hasContentLoading, setHasContentLoading] = useState(false); + const [open, setOpen] = useState(props.autoFocus ?? false); const [options, setOptions] = useState([]); const [searchValue, setSearchValue] = useState(''); const [paging, setPaging] = useState({} as Paging); @@ -305,12 +306,13 @@ const AsyncSelectList: FC = ({ /> ) } + 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(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx index 706444dbdde..304fe7dcf50 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelectList/TreeAsyncSelectList.tsx @@ -354,6 +354,38 @@ const TreeAsyncSelectList: FC = ({ ); }, [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 ( = ({ onSearch={onSearch} onTreeExpand={setExpandedRowKeys} {...props} + onKeyDown={handleKeyDown} /> ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/DomainSelectableList.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/DomainSelectableList.component.tsx index a96fef78afa..9f8352bc933 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/DomainSelectableList.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DomainSelectableList/DomainSelectableList.component.tsx @@ -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 = ({ + + + } open={popupVisible} overlayClassName="domain-select-popover w-400" diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/FocusTrap/FocusTrapWithContainer.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/FocusTrap/FocusTrapWithContainer.test.tsx new file mode 100644 index 00000000000..3a1d0dcba1e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/FocusTrap/FocusTrapWithContainer.test.tsx @@ -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) => ( +
+ {children} +
+ ), +})); + +describe('FocusTrapWithContainer', () => { + it('renders children inside FocusTrap', () => { + const { getByText, getByTestId } = render( + + + + ); + + expect(getByTestId('focus-trap-mock')).toBeInTheDocument(); + expect(getByText('Test Button')).toBeInTheDocument(); + }); + + it('passes focusTrapOptions to FocusTrap', () => { + const { getByTestId } = render( + + Child + + ); + + // The mock sets data-options to true if focusTrapOptions is present + expect(getByTestId('focus-trap-mock').getAttribute('data-options')).toBe( + 'true' + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/FocusTrap/FocusTrapWithContainer.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/FocusTrap/FocusTrapWithContainer.tsx new file mode 100644 index 00000000000..11db0dae08a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/FocusTrap/FocusTrapWithContainer.tsx @@ -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(null); + + return ( + containerRef.current || document.body, + initialFocus: () => + (containerRef.current?.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) as HTMLElement) || containerRef.current, + }}> +
{children}
+
+ ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/InlineEdit/InlineEdit.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/InlineEdit/InlineEdit.component.tsx index 14d0130b8f2..b4ba6da420b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/InlineEdit/InlineEdit.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/InlineEdit/InlineEdit.component.tsx @@ -26,6 +26,13 @@ const InlineEdit = ({ cancelButtonProps, saveButtonProps, }: InlineEditProps) => { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCancel?.(); + } + }; + return ( e.stopPropagation()}> + onClick={(e) => e.stopPropagation()} + onKeyDown={handleKeyDown}> {children} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/SelectableList/SelectableList.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/SelectableList/SelectableList.component.tsx index fb367fc392b..9a36492547c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/SelectableList/SelectableList.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/SelectableList/SelectableList.component.tsx @@ -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 && ( - - {(item) => ( - - ) : ( - checkActiveSelectedItem(item) && ( - + + {(item, index) => ( + + ) : ( + checkActiveSelectedItem(item) && ( + + ) ) - ) - } - 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) - ) : ( - - )} - - )} - + } + 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) + ) : ( + + )} + + )} + +
)} ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.interface.ts index 669b6d624f6..46750f60057 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.interface.ts @@ -27,4 +27,5 @@ export interface TierCardProps { updateTier?: (value?: Tag) => Promise; children?: ReactNode; popoverProps?: PopoverProps; + onClose?: () => void; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.test.tsx index 1c26812072f..eaf341e2ec9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.test.tsx @@ -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(); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.tsx index 3a2ea4efce0..e1a9edb9717 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/TierCard.tsx @@ -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(null); const [tiers, setTiers] = useState>([]); const [tierCardData, setTierCardData] = useState>( [] ); + const [selectedTier, setSelectedTier] = useState(currentTier ?? ''); const [isLoadingTierData, setIsLoadingTierData] = useState(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 = ({ - - {t('label.edit-entity', { entity: t('label.tier') })} - - updateTierData()}> - {t('label.clear')} - - - }> - } - spinning={isLoadingTierData}> - - - {tierCardData.map((card) => ( - - - - {card.title} - - - {card.description.replace(/\*/g, '')} - - - + + + + {t('label.edit-entity', { entity: t('label.tier') })} + + updateTierData()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + updateTierData(); } - key={card.id}> -
- -
-
- ))} -
-
-
- + }}> + {t('label.clear')} + + + }> + } + spinning={isLoadingTierData}> + + + {tierCardData.map((card) => ( + + + + {card.title} + + + {card.description.replace(/\*/g, '')} + + + + } + key={card.id}> +
+ +
+
+ ))} +
+
+
+ + +
+
+ + } overlayClassName="tier-card-popover" placement="bottomRight" + ref={popoverRef} showArrow={false} trigger="click" onOpenChange={(visible) => diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/tier-card.style.less b/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/tier-card.style.less index 61fd6b1a002..c7e76a3e858 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/tier-card.style.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TierCard/tier-card.style.less @@ -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; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/UserTeamSelectableList/UserTeamSelectableList.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/UserTeamSelectableList/UserTeamSelectableList.component.tsx index 1cbab180498..fa9ad1cef3b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/UserTeamSelectableList/UserTeamSelectableList.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/UserTeamSelectableList/UserTeamSelectableList.component.tsx @@ -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 = ({ + {previewSelected && ( )} - e.stopPropagation()} /> - + } open={popupVisible} overlayClassName="user-team-select-popover card-shadow" diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/UserTeamSelectableList/user-team-selectable-list.less b/openmetadata-ui/src/main/resources/ui/src/components/common/UserTeamSelectableList/user-team-selectable-list.less index 1be8f0a8112..73c44f97d7c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/UserTeamSelectableList/user-team-selectable-list.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/UserTeamSelectableList/user-team-selectable-list.less @@ -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 { diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useRovingFocus.test.tsx b/openmetadata-ui/src/main/resources/ui/src/hooks/useRovingFocus.test.tsx new file mode 100644 index 00000000000..29c0ec19d5a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useRovingFocus.test.tsx @@ -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 ( +
+ {Array.from({ length: totalItems }).map((_, i) => ( + + ))} +
{focusedIndex}
+
+ ); +} + +describe('useRovingFocus', () => { + it('should set initial focus index correctly', () => { + const { getByTestId } = render( + + ); + + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + + expect(getByTestId('focused-index').textContent).toBe('2'); + + rerender(); + + expect(getByTestId('focused-index').textContent).toBe('1'); + }); + + it('should set focus when item receives focus', () => { + const { getByTestId } = render( + + ); + const item2 = getByTestId('item-2'); + act(() => { + item2.focus(); + fireEvent.focus(item2); + }); + + expect(getByTestId('focused-index').textContent).toBe('2'); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useRovingFocus.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useRovingFocus.ts new file mode 100644 index 00000000000..1f91f730b53 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useRovingFocus.ts @@ -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(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( + `[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, + }; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/shared/TagSuggestion.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/shared/TagSuggestion.tsx index cc37d280175..ad5e7261c8d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/shared/TagSuggestion.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TasksPage/shared/TagSuggestion.tsx @@ -35,6 +35,7 @@ export interface TagSuggestionProps { isTreeSelect?: boolean; hasNoActionButtons?: boolean; open?: boolean; + autoFocus?: boolean; } const TagSuggestion: React.FC = ({ @@ -47,6 +48,7 @@ const TagSuggestion: React.FC = ({ isTreeSelect = false, hasNoActionButtons = false, open = true, + autoFocus = false, }) => { const isGlossaryType = useMemo( () => tagType === TagSource.Glossary, @@ -103,6 +105,7 @@ const TagSuggestion: React.FC = ({ }), value: value?.map((item) => item.tagFQN) ?? [], onChange: handleTagSelection, + autoFocus, }; return isTreeSelect ? ( diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/variables.less b/openmetadata-ui/src/main/resources/ui/src/styles/variables.less index de27b8f75c1..9f922f721fa 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/variables.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/variables.less @@ -385,3 +385,5 @@ @btn-height-sm: 36px; @btn-height-base: 40px; @btn-height-lg: 44px; + +@focus-outline-color: #e6f4ff; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSVUtilsClassBase.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSVUtilsClassBase.tsx index 3e4b935030d..d7d0adedb45 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSVUtilsClassBase.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSVUtilsClassBase.tsx @@ -149,6 +149,7 @@ class CSVUtilsClassBase { return ( { + props.onCancel(); + }; + return ( + updateTier={handleChange} + onClose={onClose}> {' '} ); diff --git a/openmetadata-ui/src/main/resources/ui/yarn.lock b/openmetadata-ui/src/main/resources/ui/yarn.lock index c6744553a24..0dbd5a0bd95 100644 --- a/openmetadata-ui/src/main/resources/ui/yarn.lock +++ b/openmetadata-ui/src/main/resources/ui/yarn.lock @@ -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"