From c642b2a157be130dd35ccb934807a52092693275 Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Thu, 3 Oct 2024 01:16:55 -0300 Subject: [PATCH] [Discover] [Unified Doc Viewer] Fix doc viewer not receiving focus on open (#191039) ## Summary This PR fixes an issue where the new resizable doc viewer push flyout was not receiving focus on open, resulting in the flyout having to be manually focused in before users can navigate between docs using the arrow keys. It also re-adds support for closing the flyout when pressing the escape key, improves overall focus management, ensures the proper a11y attributes are used when the push flyout is shown, and introduces a new set of functional tests for doc viewer keyboard navigation and a11y. Fixes #190946. ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../doc_viewer_flyout/doc_viewer_flyout.tsx | 21 +- .../doc_viewer_flyout/use_flyout_a11y.tsx | 53 +++++ .../group2_data_grid1/_data_grid_doc_table.ts | 4 +- .../apps/discover/group3/_doc_viewer.ts | 211 ++++++++++++++++++ 4 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 src/plugins/unified_doc_viewer/public/components/doc_viewer_flyout/use_flyout_a11y.tsx 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 640dbdeb4abfa..bc635b9c41213 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 @@ -7,7 +7,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 ac77fe3f70714..498774a214579 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 @@ -185,9 +185,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 140129c6a251f..41375ab3e2776 100644 --- a/test/functional/apps/discover/group3/_doc_viewer.ts +++ b/test/functional/apps/discover/group3/_doc_viewer.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { @@ -16,6 +17,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const find = getService('find'); const retry = getService('retry'); const dataGrid = getService('dataGrid'); + const browser = getService('browser'); describe('discover doc viewer', function describeIndexTests() { before(async function () { @@ -97,5 +99,214 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); }); + + 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 PageObjects.discover.isShowingDocViewer(); + await dataGrid.closeFlyout(); + }); + + afterEach(async () => { + await restoreScreenWidth(); + }); + + describe('keyboard navigation', () => { + it('should navigate between documents with arrow keys', async () => { + await dataGrid.clickRowToggle(); + await PageObjects.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(); + await PageObjects.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(); + await PageObjects.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(); + expect(await PageObjects.discover.isShowingDocViewer()).to.be(true); + await browser.pressKeys(browser.keys.ESCAPE); + expect(await PageObjects.discover.isShowingDocViewer()).to.be(false); + }); + + it('should close the flyout with the escape key when the search input is focused', async () => { + await dataGrid.clickRowToggle(); + expect(await PageObjects.discover.isShowingDocViewer()).to.be(true); + await testSubjects.click('unifiedDocViewerFieldsSearchInput'); + await browser.pressKeys(browser.keys.ESCAPE); + expect(await PageObjects.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(); + expect(await PageObjects.discover.isShowingDocViewer()).to.be(true); + await testSubjects.click('dataGridHeaderCell-name'); + await browser.pressKeys(browser.keys.ESCAPE); + expect(await PageObjects.discover.isShowingDocViewer()).to.be(true); + await browser.pressKeys(browser.keys.TAB); + await browser.pressKeys(browser.keys.ESCAPE); + expect(await PageObjects.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(); + await PageObjects.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(); + await PageObjects.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(); + await PageObjects.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(); + await PageObjects.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(); + await PageObjects.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(); + await PageObjects.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(); + await PageObjects.discover.isShowingDocViewer(); + await testSubjects.existOrFail('unifiedDocViewerScreenReaderDescription', { + allowHidden: true, + }); + }); + + it('should not show custom screen reader description when overlay flyout active', async () => { + await dataGrid.clickRowToggle(); + await PageObjects.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(); + await PageObjects.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); + }); + }); + }); }); }