diff --git a/src/plugins/unified_doc_viewer/public/components/doc_viewer_flyout/doc_viewer_flyout.tsx b/src/plugins/unified_doc_viewer/public/components/doc_viewer_flyout/doc_viewer_flyout.tsx index 540c04a206c618..be0ab585b15a81 100644 --- a/src/plugins/unified_doc_viewer/public/components/doc_viewer_flyout/doc_viewer_flyout.tsx +++ b/src/plugins/unified_doc_viewer/public/components/doc_viewer_flyout/doc_viewer_flyout.tsx @@ -8,7 +8,6 @@ */ import React, { useMemo, useCallback, type ComponentType } from 'react'; -import { get } from 'lodash'; import { i18n } from '@kbn/i18n'; import { css } from '@emotion/react'; import type { DataView } from '@kbn/data-views-plugin/public'; @@ -34,6 +33,7 @@ import useLocalStorage from 'react-use/lib/useLocalStorage'; import type { ToastsStart } from '@kbn/core-notifications-browser'; import type { DocViewFilterFn, DocViewRenderProps } from '@kbn/unified-doc-viewer/types'; import { UnifiedDocViewer } from '../lazy_doc_viewer'; +import { useFlyoutA11y } from './use_flyout_a11y'; export interface UnifiedDocViewerFlyoutProps { 'data-test-subj'?: string; @@ -71,6 +71,7 @@ function getIndexByDocId(hits: DataTableRecord[], id: string) { } export const FLYOUT_WIDTH_KEY = 'unifiedDocViewer:flyoutWidth'; + /** * Flyout displaying an expanded row details */ @@ -129,24 +130,29 @@ export function UnifiedDocViewerFlyout({ const onKeyDown = useCallback( (ev: React.KeyboardEvent) => { - const nodeClasses = get(ev, 'target.className', ''); - if (typeof nodeClasses === 'string' && nodeClasses.includes('euiDataGrid')) { + if (ev.target instanceof HTMLElement && ev.target.closest('.euiDataGrid__content')) { // ignore events triggered from the data grid return; } - const nodeName = get(ev, 'target.nodeName', null); - if (typeof nodeName === 'string' && nodeName.toLowerCase() === 'input') { + if (ev.key === keys.ESCAPE) { + ev.preventDefault(); + ev.stopPropagation(); + onClose(); + } + + if (ev.target instanceof HTMLInputElement) { // ignore events triggered from the search input return; } + if (ev.key === keys.ARROW_LEFT || ev.key === keys.ARROW_RIGHT) { ev.preventDefault(); ev.stopPropagation(); setPage(activePage + (ev.key === keys.ARROW_RIGHT ? 1 : -1)); } }, - [activePage, setPage] + [activePage, onClose, setPage] ); const addColumn = useCallback( @@ -231,6 +237,7 @@ export function UnifiedDocViewerFlyout({ defaultMessage: 'Document', }); const currentFlyoutTitle = flyoutTitle ?? defaultFlyoutTitle; + const { a11yProps, screenReaderDescription } = useFlyoutA11y({ isXlScreen }); return ( @@ -250,7 +257,9 @@ export function UnifiedDocViewerFlyout({ maxWidth: `${isXlScreen ? `calc(100vw - ${DEFAULT_WIDTH}px)` : '90vw'} !important`, }} paddingSize="m" + {...a11yProps} > + {screenReaderDescription} { + const descriptionId = useGeneratedHtmlId(); + const [triggerEl] = useState(document.activeElement); + const [flyoutEl, setFlyoutEl] = useState(); + + // Auto-focus push flyout on open or when switching to XL screen + useEffect(() => { + if (isXlScreen && flyoutEl && document.contains(flyoutEl)) { + // Wait a tick before focusing or focus will be stolen by the trigger element when + // switching from an overlay flyout to a push flyout (due to EUI focus lock) + setTimeout(() => flyoutEl.focus()); + } + }, [flyoutEl, isXlScreen]); + + // Return focus to the trigger element when the flyout is closed + useUnmount(() => { + if (triggerEl instanceof HTMLElement && document.contains(triggerEl)) { + triggerEl.focus(); + } + }); + + return { + a11yProps: { + ref: setFlyoutEl, + role: isXlScreen ? 'dialog' : undefined, + tabindex: isXlScreen ? 0 : undefined, + 'aria-describedby': isXlScreen ? descriptionId : undefined, + 'data-no-focus-lock': isXlScreen || undefined, + }, + screenReaderDescription: isXlScreen && ( + +

+ {i18n.translate('unifiedDocViewer.flyout.screenReaderDescription', { + defaultMessage: 'You are in a non-modal dialog. To close the dialog, press Escape.', + })} +

+
+ ), + }; +}; diff --git a/test/functional/apps/discover/group2_data_grid1/_data_grid_doc_table.ts b/test/functional/apps/discover/group2_data_grid1/_data_grid_doc_table.ts index c2ae5c30bf86f5..dc3cfb8c23bf96 100644 --- a/test/functional/apps/discover/group2_data_grid1/_data_grid_doc_table.ts +++ b/test/functional/apps/discover/group2_data_grid1/_data_grid_doc_table.ts @@ -179,9 +179,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should allow paginating docs in the flyout by clicking in the doc table', async function () { await retry.try(async function () { await dataGrid.clickRowToggle({ rowIndex: rowToInspect - 1 }); - await testSubjects.exists(`docViewerFlyoutNavigationPage0`); + await testSubjects.existOrFail('docViewerFlyoutNavigationPage-0'); await dataGrid.clickRowToggle({ rowIndex: rowToInspect }); - await testSubjects.exists(`docViewerFlyoutNavigationPage1`); + await testSubjects.existOrFail('docViewerFlyoutNavigationPage-1'); await dataGrid.closeFlyout(); }); }); diff --git a/test/functional/apps/discover/group3/_doc_viewer.ts b/test/functional/apps/discover/group3/_doc_viewer.ts index bc71c822890710..542cdab9543ec8 100644 --- a/test/functional/apps/discover/group3/_doc_viewer.ts +++ b/test/functional/apps/discover/group3/_doc_viewer.ts @@ -431,5 +431,214 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await dataGrid.isFieldPinnedInFlyout('@message')).to.be(false); }); }); + + describe('flyout', () => { + let originalScreenSize = { width: 0, height: 0 }; + + const reduceScreenWidth = async () => { + await browser.setWindowSize(800, originalScreenSize.height); + }; + + const restoreScreenWidth = async () => { + await browser.setWindowSize(originalScreenSize.width, originalScreenSize.height); + }; + + before(async () => { + originalScreenSize = await browser.getWindowSize(); + }); + + beforeEach(async () => { + // open the flyout once initially to ensure table is the default tab + await dataGrid.clickRowToggle(); + await discover.isShowingDocViewer(); + await dataGrid.closeFlyout(); + }); + + afterEach(async () => { + await restoreScreenWidth(); + }); + + describe('keyboard navigation', () => { + it('should navigate between documents with arrow keys', async () => { + await dataGrid.clickRowToggle({ defaultTabId: false }); + await discover.isShowingDocViewer(); + await testSubjects.existOrFail(`docViewerFlyoutNavigationPage-0`); + await browser.pressKeys(browser.keys.ARROW_RIGHT); + await testSubjects.existOrFail(`docViewerFlyoutNavigationPage-1`); + await browser.pressKeys(browser.keys.ARROW_RIGHT); + await testSubjects.existOrFail(`docViewerFlyoutNavigationPage-2`); + await browser.pressKeys(browser.keys.ARROW_LEFT); + await testSubjects.existOrFail(`docViewerFlyoutNavigationPage-1`); + await browser.pressKeys(browser.keys.ARROW_LEFT); + await testSubjects.existOrFail(`docViewerFlyoutNavigationPage-0`); + }); + + it('should not navigate between documents with arrow keys when the search input is focused', async () => { + await dataGrid.clickRowToggle({ defaultTabId: false }); + await discover.isShowingDocViewer(); + await testSubjects.existOrFail(`docViewerFlyoutNavigationPage-0`); + await browser.pressKeys(browser.keys.ARROW_RIGHT); + await testSubjects.existOrFail(`docViewerFlyoutNavigationPage-1`); + await testSubjects.click('unifiedDocViewerFieldsSearchInput'); + await browser.pressKeys(browser.keys.ARROW_RIGHT); + await testSubjects.existOrFail(`docViewerFlyoutNavigationPage-1`); + await browser.pressKeys(browser.keys.TAB); + await browser.pressKeys(browser.keys.ARROW_RIGHT); + await testSubjects.existOrFail(`docViewerFlyoutNavigationPage-2`); + }); + + it('should not navigate between documents with arrow keys when the data grid is focused', async () => { + await dataGrid.clickRowToggle({ defaultTabId: false }); + await discover.isShowingDocViewer(); + await testSubjects.existOrFail(`docViewerFlyoutNavigationPage-0`); + await browser.pressKeys(browser.keys.ARROW_RIGHT); + await testSubjects.existOrFail(`docViewerFlyoutNavigationPage-1`); + await testSubjects.click('dataGridHeaderCell-name'); + await browser.pressKeys(browser.keys.ARROW_RIGHT); + await testSubjects.existOrFail(`docViewerFlyoutNavigationPage-1`); + await browser.pressKeys(browser.keys.TAB); + await browser.pressKeys(browser.keys.ARROW_RIGHT); + await testSubjects.existOrFail(`docViewerFlyoutNavigationPage-2`); + }); + + it('should close the flyout with the escape key', async () => { + await dataGrid.clickRowToggle({ defaultTabId: false }); + expect(await discover.isShowingDocViewer()).to.be(true); + await browser.pressKeys(browser.keys.ESCAPE); + expect(await discover.isShowingDocViewer()).to.be(false); + }); + + it('should close the flyout with the escape key when the search input is focused', async () => { + await dataGrid.clickRowToggle({ defaultTabId: false }); + expect(await discover.isShowingDocViewer()).to.be(true); + await testSubjects.click('unifiedDocViewerFieldsSearchInput'); + await browser.pressKeys(browser.keys.ESCAPE); + expect(await discover.isShowingDocViewer()).to.be(false); + }); + + it('should not close the flyout with the escape key when the data grid is focused', async () => { + await dataGrid.clickRowToggle({ defaultTabId: false }); + expect(await discover.isShowingDocViewer()).to.be(true); + await testSubjects.click('dataGridHeaderCell-name'); + await browser.pressKeys(browser.keys.ESCAPE); + expect(await discover.isShowingDocViewer()).to.be(true); + await browser.pressKeys(browser.keys.TAB); + await browser.pressKeys(browser.keys.ESCAPE); + expect(await discover.isShowingDocViewer()).to.be(false); + }); + }); + + describe('accessibility', () => { + it('should focus the flyout on open, and retain focus when resizing between push and overlay flyouts', async () => { + // push -> overlay -> push + await dataGrid.clickRowToggle({ defaultTabId: false }); + await discover.isShowingDocViewer(); + let activeElement = await find.activeElement(); + expect(await activeElement.getAttribute('data-test-subj')).to.be('docViewerFlyout'); + await reduceScreenWidth(); + activeElement = await find.activeElement(); + expect(await activeElement.getAttribute('data-test-subj')).to.be('docViewerFlyout'); + await restoreScreenWidth(); + activeElement = await find.activeElement(); + expect(await activeElement.getAttribute('data-test-subj')).to.be('docViewerFlyout'); + // overlay -> push -> overlay + await browser.pressKeys(browser.keys.ESCAPE); + await reduceScreenWidth(); + await dataGrid.clickRowToggle({ defaultTabId: false }); + await discover.isShowingDocViewer(); + activeElement = await find.activeElement(); + expect(await activeElement.getAttribute('data-test-subj')).to.be('docViewerFlyout'); + await restoreScreenWidth(); + activeElement = await find.activeElement(); + expect(await activeElement.getAttribute('data-test-subj')).to.be('docViewerFlyout'); + await reduceScreenWidth(); + activeElement = await find.activeElement(); + expect(await activeElement.getAttribute('data-test-subj')).to.be('docViewerFlyout'); + }); + + it('should return focus to the trigger element when the flyout is closed', async () => { + // push + await dataGrid.clickRowToggle({ defaultTabId: false }); + await discover.isShowingDocViewer(); + await browser.pressKeys(browser.keys.ESCAPE); + let activeElement = await find.activeElement(); + expect(await activeElement.getAttribute('data-test-subj')).to.be( + 'docTableExpandToggleColumn' + ); + // push -> overlay + await dataGrid.clickRowToggle({ defaultTabId: false }); + await discover.isShowingDocViewer(); + await reduceScreenWidth(); + await browser.pressKeys(browser.keys.ESCAPE); + activeElement = await find.activeElement(); + expect(await activeElement.getAttribute('data-test-subj')).to.be( + 'docTableExpandToggleColumn' + ); + // overlay + await dataGrid.clickRowToggle({ defaultTabId: false }); + await discover.isShowingDocViewer(); + await browser.pressKeys(browser.keys.ESCAPE); + activeElement = await find.activeElement(); + expect(await activeElement.getAttribute('data-test-subj')).to.be( + 'docTableExpandToggleColumn' + ); + // overlay -> push + await dataGrid.clickRowToggle({ defaultTabId: false }); + await discover.isShowingDocViewer(); + await restoreScreenWidth(); + await browser.pressKeys(browser.keys.ESCAPE); + activeElement = await find.activeElement(); + expect(await activeElement.getAttribute('data-test-subj')).to.be( + 'docTableExpandToggleColumn' + ); + }); + + it('should show custom screen reader description push flyout is active', async () => { + await dataGrid.clickRowToggle({ defaultTabId: false }); + await discover.isShowingDocViewer(); + await testSubjects.existOrFail('unifiedDocViewerScreenReaderDescription', { + allowHidden: true, + }); + }); + + it('should not show custom screen reader description when overlay flyout active', async () => { + await dataGrid.clickRowToggle({ defaultTabId: false }); + await discover.isShowingDocViewer(); + await reduceScreenWidth(); + expect( + await testSubjects.exists('unifiedDocViewerScreenReaderDescription', { + allowHidden: true, + }) + ).to.be(false); + }); + + it('should use expected a11y attributes', async () => { + // push flyout + await dataGrid.clickRowToggle({ defaultTabId: false }); + await discover.isShowingDocViewer(); + let role = await testSubjects.getAttribute('docViewerFlyout', 'role'); + let tabindex = await testSubjects.getAttribute('docViewerFlyout', 'tabindex'); + let describedBy = await testSubjects.getAttribute('docViewerFlyout', 'aria-describedby'); + let noFocusLock = await testSubjects.getAttribute( + 'docViewerFlyout', + 'data-no-focus-lock' + ); + expect(role).to.be('dialog'); + expect(tabindex).to.be('0'); + expect(await find.existsByCssSelector(`#${describedBy}`)).to.be(true); + expect(noFocusLock).to.be('true'); + // overlay flyout + await reduceScreenWidth(); + role = await testSubjects.getAttribute('docViewerFlyout', 'role'); + tabindex = await testSubjects.getAttribute('docViewerFlyout', 'tabindex'); + describedBy = await testSubjects.getAttribute('docViewerFlyout', 'aria-describedby'); + noFocusLock = await testSubjects.getAttribute('docViewerFlyout', 'data-no-focus-lock'); + expect(role).to.be('dialog'); + expect(tabindex).to.be('0'); + expect(await find.existsByCssSelector(`#${describedBy}`)).to.be(true); + expect(noFocusLock).to.be(null); + }); + }); + }); }); } diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index efdaeb49933f21..903e38427181b6 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -357,7 +357,7 @@ export class DataGridService extends FtrService { } public async clickRowToggle( - { defaultTabId, ...options }: SelectOptions & { defaultTabId?: string } = { + { defaultTabId, ...options }: SelectOptions & { defaultTabId?: string | false } = { isAnchorRow: false, rowIndex: 0, } @@ -389,7 +389,9 @@ export class DataGridService extends FtrService { throw new Error('Unable to find row toggle element'); } - await this.clickDocViewerTab(defaultTabId ?? 'doc_view_table'); + if (defaultTabId !== false) { + await this.clickDocViewerTab(defaultTabId ?? 'doc_view_table'); + } } public async isShowingDocViewer() {