From cae68b955bfdf9997a5ce072df19ec4d30b2298c Mon Sep 17 00:00:00 2001 From: Polina Boneva <13227501+polibb@users.noreply.github.com> Date: Wed, 26 Jul 2023 16:02:04 +0300 Subject: [PATCH] Dashboard: New Datasource picker link is keyboard accessible (#72134) * WIP * fixes for readability * fix * WIP * Keep tab index working with portal * Use callback and clean up * Fix linting errors * Ignore clickable element --------- Co-authored-by: Ivan Ortega --- .../components/picker/DataSourceDropdown.tsx | 291 +++++++++++------- 1 file changed, 182 insertions(+), 109 deletions(-) diff --git a/public/app/features/datasources/components/picker/DataSourceDropdown.tsx b/public/app/features/datasources/components/picker/DataSourceDropdown.tsx index 78d7fe9c16e..7d0e99d3caa 100644 --- a/public/app/features/datasources/components/picker/DataSourceDropdown.tsx +++ b/public/app/features/datasources/components/picker/DataSourceDropdown.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/css'; import { useDialog } from '@react-aria/dialog'; +import { FocusScope } from '@react-aria/focus'; import { useOverlay } from '@react-aria/overlays'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { usePopper } from 'react-popper'; @@ -70,55 +71,25 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { ...restProps } = props; + const styles = useStyles2((theme: GrafanaTheme2) => getStylesDropdown(theme, props)); const [isOpen, setOpen] = useState(false); const [inputHasFocus, setInputHasFocus] = useState(false); - const [markerElement, setMarkerElement] = useState(); - const [selectorElement, setSelectorElement] = useState(); const [filterTerm, setFilterTerm] = useState(''); - const openDropdown = () => { - reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_DROPDOWN }); - setOpen(true); - markerElement?.focus(); - }; + const { onKeyDown, keyboardEvents } = useKeyNavigationListener(); + const ref = useRef(null); + + // Used to position the popper correctly and to bring back the focus when navigating from footer to input + const [markerElement, setMarkerElement] = useState(); + // Used to position the popper correctly + const [selectorElement, setSelectorElement] = useState(); + // Used to move the focus to the footer when tabbing from the input + const [footerRef, setFooterRef] = useState(); const currentDataSourceInstanceSettings = useDatasource(current); + const grafanaDS = useDatasource('-- Grafana --'); const currentValue = Boolean(!current && noDefault) ? undefined : currentDataSourceInstanceSettings; const prefixIcon = filterTerm && isOpen ? : ; - const { onKeyDown, keyboardEvents } = useKeyNavigationListener(); - - useEffect(() => { - const sub = keyboardEvents.subscribe({ - next: (keyEvent) => { - switch (keyEvent?.code) { - case 'ArrowDown': { - openDropdown(); - keyEvent.preventDefault(); - break; - } - case 'ArrowUp': - openDropdown(); - keyEvent.preventDefault(); - break; - case 'Escape': - onClose(); - markerElement?.focus(); - keyEvent.preventDefault(); - } - }, - }); - return () => sub.unsubscribe(); - }); - const grafanaDS = useDatasource('-- Grafana --'); - - const onClickAddCSV = () => { - if (!grafanaDS) { - return; - } - - onChange(grafanaDS, [defaultFileUploadQuery]); - }; - const popper = usePopper(markerElement, selectorElement, { placement: 'bottom-start', modifiers: [ @@ -136,9 +107,9 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { const onClose = useCallback(() => { setFilterTerm(''); setOpen(false); - }, [setOpen]); + markerElement?.focus(); + }, [setOpen, markerElement]); - const ref = useRef(null); const { overlayProps, underlayProps } = useOverlay( { onClose: onClose, @@ -150,9 +121,72 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { }, ref ); - const { dialogProps } = useDialog({}, ref); + const { dialogProps } = useDialog( + { + 'aria-label': 'Opened data source picker list', + }, + ref + ); - const styles = useStyles2((theme: GrafanaTheme2) => getStylesDropdown(theme, props)); + function openDropdown() { + reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_DROPDOWN }); + setOpen(true); + markerElement?.focus(); + } + + function onClickAddCSV() { + if (!grafanaDS) { + return; + } + + onChange(grafanaDS, [defaultFileUploadQuery]); + } + + function onKeyDownInput(keyEvent: React.KeyboardEvent) { + // From the input, it navigates to the footer + if (keyEvent.key === 'Tab' && !keyEvent.shiftKey && isOpen) { + keyEvent.preventDefault(); + footerRef?.focus(); + } + // From the input, if we navigate back, it closes the dropdown + if (keyEvent.key === 'Tab' && keyEvent.shiftKey && isOpen) { + onClose(); + } + onKeyDown(keyEvent); + } + + function onNavigateOutsiteFooter(e: React.KeyboardEvent) { + // When navigating back, the dropdown keeps open and the input element is focused. + if (e.shiftKey) { + e.preventDefault(); + markerElement?.focus(); + // When navigating forward, the dropdown closes and and the element next to the input element is focused. + } else { + onClose(); + } + } + + useEffect(() => { + const sub = keyboardEvents.subscribe({ + next: (keyEvent) => { + switch (keyEvent?.code) { + case 'ArrowDown': + openDropdown(); + keyEvent.preventDefault(); + break; + case 'ArrowUp': + openDropdown(); + keyEvent.preventDefault(); + break; + case 'Escape': + onClose(); + keyEvent.preventDefault(); + break; + } + }, + }); + return () => sub.unsubscribe(); + }); return (
@@ -166,15 +200,13 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { prefix={currentValue ? prefixIcon : undefined} suffix={} placeholder={hideTextValue ? '' : dataSourceLabel(currentValue) || placeholder} - onClick={openDropdown} onFocus={() => { setInputHasFocus(true); }} onBlur={() => { setInputHasFocus(false); - onClose(); }} - onKeyDown={onKeyDown} + onKeyDown={onKeyDownInput} value={filterTerm} onChange={(e) => { openDropdown(); @@ -187,19 +219,16 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { {isOpen ? (
- {/* TODO: fix keyboard a11y */} - {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} -
{ - e.preventDefault(); /** Need to prevent default here to stop onMouseDown to trigger onBlur of the input element */ - }} - > +
{ onClose(); if (ds.uid !== currentValue?.uid) { @@ -208,13 +237,9 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { } }} onClose={onClose} - current={currentValue} - style={popper.styles.popper} - ref={setSelectorElement} onClickAddCSV={onClickAddCSV} - {...restProps} onDismiss={onClose} - {...popper.attributes.popper} + onNavigateOutsiteFooter={onNavigateOutsiteFooter} />
@@ -249,10 +274,13 @@ export interface PickerContentProps extends DataSourceDropdownProps { filterTerm?: string; onClose: () => void; onDismiss: () => void; + footerRef: (element: HTMLElement | null) => void; + onNavigateOutsiteFooter: (e: React.KeyboardEvent) => void; } const PickerContent = React.forwardRef((props, ref) => { - const { filterTerm, onChange, onClose, onClickAddCSV, current, filter, uploadFile } = props; + const { filterTerm, onChange, onClose, onClickAddCSV, current, filter } = props; + const changeCallback = useCallback( (ds: DataSourceInstanceSettings) => { onChange(ds); @@ -285,50 +313,14 @@ const PickerContent = React.forwardRef((prop } > -
- - {({ showModal, hideModal }) => ( - - )} - - {uploadFile && config.featureToggles.editPanelCSVDragAndDrop && ( - - )} -
+ +
+
); }); @@ -359,3 +351,84 @@ function getStylesPickerContent(theme: GrafanaTheme2) { `, }; } + +export interface FooterProps extends PickerContentProps {} + +function Footer({ onClose, onChange, onClickAddCSV, ...props }: FooterProps) { + const styles = useStyles2(getStylesFooter); + const isUploadFileEnabled = props.uploadFile && config.featureToggles.editPanelCSVDragAndDrop; + + const onKeyDownLastButton = (e: React.KeyboardEvent) => { + if (e.key === 'Tab') { + props.onNavigateOutsiteFooter(e); + } + }; + const onKeyDownFirstButton = (e: React.KeyboardEvent) => { + if (e.key === 'Tab' && e.shiftKey) { + props.onNavigateOutsiteFooter(e); + } + }; + + return ( +
+ + {({ showModal, hideModal }) => ( + + )} + + {isUploadFileEnabled && ( + + )} +
+ ); +} + +function getStylesFooter(theme: GrafanaTheme2) { + return { + footer: css` + flex: 0; + display: flex; + flex-direction: row-reverse; + justify-content: space-between; + padding: ${theme.spacing(1.5)}; + border-top: 1px solid ${theme.colors.border.weak}; + background-color: ${theme.colors.background.secondary}; + `, + }; +}