Accessibility: Fix text selection when using FocusScope (#44770)

* Add tabIndex={-1} to places using focusScope to allow for text highlighting

* use useDialog

* don't need explicit tabIndex anymore

* remove duplicate spreading of props
This commit is contained in:
Ashley Harrison
2022-02-04 11:20:18 +00:00
committed by GitHub
parent e7a0e69153
commit 9e52361c1e
14 changed files with 120 additions and 54 deletions

View File

@@ -248,6 +248,7 @@
"@opentelemetry/semantic-conventions": "1.0.1",
"@popperjs/core": "2.11.2",
"@react-aria/button": "3.3.4",
"@react-aria/dialog": "3.1.4",
"@react-aria/focus": "3.5.0",
"@react-aria/interactions": "3.7.0",
"@react-aria/menu": "3.3.0",

View File

@@ -40,6 +40,7 @@
"@monaco-editor/react": "4.3.1",
"@popperjs/core": "2.11.2",
"@react-aria/button": "3.3.4",
"@react-aria/dialog": "3.1.4",
"@react-aria/focus": "3.5.0",
"@react-aria/menu": "3.3.0",
"@react-aria/overlays": "3.7.3",

View File

@@ -104,9 +104,9 @@ class UnThemedColorPickerPopover<T extends CustomPickersDescriptor> extends Reac
<>
{Object.keys(customPickers).map((key) => {
return (
<div className={this.getTabClassName(key)} onClick={this.onTabChange(key)} key={key}>
<button className={this.getTabClassName(key)} onClick={this.onTabChange(key)} key={key}>
{customPickers[key].name}
</div>
</button>
);
})}
</>
@@ -118,7 +118,11 @@ class UnThemedColorPickerPopover<T extends CustomPickersDescriptor> extends Reac
const styles = getStyles(theme);
return (
<FocusScope contain restoreFocus autoFocus>
<div className={styles.colorPickerPopover}>
{/*
tabIndex=-1 is needed here to support highlighting text within the picker when using FocusScope
see https://github.com/adobe/react-spectrum/issues/1604#issuecomment-781574668
*/}
<div tabIndex={-1} className={styles.colorPickerPopover}>
<div className={styles.colorPickerPopoverTabs}>
<button className={this.getTabClassName('palette')} onClick={this.onTabChange('palette')}>
Colors

View File

@@ -25,6 +25,7 @@ import { Themeable } from '../../types';
import { quickOptions } from './options';
import { ButtonGroup, ToolbarButton } from '../Button';
import { selectors } from '@grafana/e2e-selectors';
import { useDialog } from '@react-aria/dialog';
import { useOverlay } from '@react-aria/overlays';
import { FocusScope } from '@react-aria/focus';
@@ -86,6 +87,7 @@ export function UnthemedTimeRangePicker(props: TimeRangePickerProps): ReactEleme
const ref = createRef<HTMLElement>();
const { overlayProps } = useOverlay({ onClose, isDismissable: true, isOpen }, ref);
const { dialogProps } = useDialog({}, ref);
const styles = getStyles(theme);
const hasAbsolute = isDateTime(value.raw.from) || isDateTime(value.raw.to);
@@ -118,7 +120,7 @@ export function UnthemedTimeRangePicker(props: TimeRangePickerProps): ReactEleme
</Tooltip>
{isOpen && (
<FocusScope contain autoFocus restoreFocus>
<section ref={ref} {...overlayProps}>
<section ref={ref} {...overlayProps} {...dialogProps}>
<TimePickerContent
timeZone={timeZone}
fiscalYearStartMonth={fiscalYearStartMonth}

View File

@@ -78,6 +78,10 @@ export const getBodyStyles = (theme: GrafanaTheme2) => {
background-color: ${theme.colors.background.primary};
width: 268px;
.react-calendar__navigation {
display: flex;
}
.react-calendar__navigation__label,
.react-calendar__navigation__arrow,
.react-calendar__navigation {

View File

@@ -4,6 +4,7 @@ import { DateTime, GrafanaTheme2, TimeZone } from '@grafana/data';
import { useTheme2 } from '../../../themes';
import { Header } from './CalendarHeader';
import { selectors } from '@grafana/e2e-selectors';
import { useDialog } from '@react-aria/dialog';
import { FocusScope } from '@react-aria/focus';
import { OverlayContainer, useOverlay } from '@react-aria/overlays';
import { Body } from './CalendarBody';
@@ -77,6 +78,12 @@ function TimePickerCalendar(props: TimePickerCalendarProps) {
const styles = getStyles(theme, props.isReversed);
const { isOpen, isFullscreen, onClose } = props;
const ref = React.createRef<HTMLElement>();
const { dialogProps } = useDialog(
{
'aria-label': selectors.components.TimePicker.calendar.label,
},
ref
);
const { overlayProps } = useOverlay(
{
isDismissable: true,
@@ -93,13 +100,7 @@ function TimePickerCalendar(props: TimePickerCalendarProps) {
if (isFullscreen) {
return (
<FocusScope contain restoreFocus autoFocus>
<section
className={styles.container}
onClick={stopPropagation}
aria-label={selectors.components.TimePicker.calendar.label}
ref={ref}
{...overlayProps}
>
<section className={styles.container} onClick={stopPropagation} ref={ref} {...overlayProps} {...dialogProps}>
<Header {...props} />
<Body {...props} />
</section>
@@ -110,7 +111,7 @@ function TimePickerCalendar(props: TimePickerCalendarProps) {
return (
<OverlayContainer>
<FocusScope contain autoFocus restoreFocus>
<section className={styles.modal} onClick={stopPropagation} ref={ref} {...overlayProps}>
<section className={styles.modal} onClick={stopPropagation} ref={ref} {...overlayProps} {...dialogProps}>
<div className={styles.content} aria-label={selectors.components.TimePicker.calendar.label}>
<Header {...props} />
<Body {...props} />

View File

@@ -8,6 +8,7 @@ import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { IconButton } from '../IconButton/IconButton';
import { stylesFactory, useTheme2 } from '../../themes';
import { FocusScope } from '@react-aria/focus';
import { useDialog } from '@react-aria/dialog';
import { useOverlay } from '@react-aria/overlays';
export interface Props {
@@ -34,6 +35,11 @@ export interface Props {
const getStyles = stylesFactory((theme: GrafanaTheme2, scrollableContent: boolean) => {
return {
container: css`
display: flex;
flex-direction: column;
height: 100%;
`,
drawer: css`
.drawer-content {
background-color: ${theme.colors.background.primary};
@@ -76,10 +82,9 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, scrollableContent: boolea
`,
content: css`
padding: ${theme.spacing(2)};
flex-grow: 1;
flex: 1;
overflow: ${!scrollableContent ? 'hidden' : 'auto'};
z-index: 0;
height: 100%;
`,
};
});
@@ -101,6 +106,7 @@ export const Drawer: FC<Props> = ({
const [isOpen, setIsOpen] = useState(false);
const currentWidth = isExpanded ? '100%' : width;
const overlayRef = React.useRef(null);
const { dialogProps, titleProps } = useDialog({}, overlayRef);
const { overlayProps } = useOverlay(
{
isDismissable: true,
@@ -132,45 +138,47 @@ export const Drawer: FC<Props> = ({
}
>
<FocusScope restoreFocus contain autoFocus>
{typeof title === 'string' && (
<div className={drawerStyles.header} {...overlayProps} ref={overlayRef}>
<div className={drawerStyles.actions}>
{expandable && !isExpanded && (
<div className={drawerStyles.container} {...overlayProps} {...dialogProps} ref={overlayRef}>
{typeof title === 'string' && (
<div className={drawerStyles.header}>
<div className={drawerStyles.actions}>
{expandable && !isExpanded && (
<IconButton
name="angle-left"
size="xl"
onClick={() => setIsExpanded(true)}
surface="header"
aria-label={selectors.components.Drawer.General.expand}
/>
)}
{expandable && isExpanded && (
<IconButton
name="angle-right"
size="xl"
onClick={() => setIsExpanded(false)}
surface="header"
aria-label={selectors.components.Drawer.General.contract}
/>
)}
<IconButton
name="angle-left"
name="times"
size="xl"
onClick={() => setIsExpanded(true)}
onClick={onClose}
surface="header"
aria-label={selectors.components.Drawer.General.expand}
aria-label={selectors.components.Drawer.General.close}
/>
)}
{expandable && isExpanded && (
<IconButton
name="angle-right"
size="xl"
onClick={() => setIsExpanded(false)}
surface="header"
aria-label={selectors.components.Drawer.General.contract}
/>
)}
<IconButton
name="times"
size="xl"
onClick={onClose}
surface="header"
aria-label={selectors.components.Drawer.General.close}
/>
</div>
<div className={drawerStyles.titleWrapper}>
<h3>{title}</h3>
{typeof subtitle === 'string' && <div className="muted">{subtitle}</div>}
{typeof subtitle !== 'string' && subtitle}
</div>
<div className={drawerStyles.titleWrapper}>
<h3 {...titleProps}>{title}</h3>
{typeof subtitle === 'string' && <div className="muted">{subtitle}</div>}
{typeof subtitle !== 'string' && subtitle}
</div>
</div>
)}
{typeof title !== 'string' && title}
<div className={drawerStyles.content}>
{!scrollableContent ? children : <CustomScrollbar>{children}</CustomScrollbar>}
</div>
)}
{typeof title !== 'string' && title}
<div className={drawerStyles.content} {...overlayProps} ref={overlayRef}>
{!scrollableContent ? children : <CustomScrollbar>{children}</CustomScrollbar>}
</div>
</FocusScope>
</RcDrawer>

View File

@@ -57,7 +57,11 @@ const ButtonSelectComponent = <T,>(props: Props<T>) => {
<div className={styles.menuWrapper}>
<ClickOutsideWrapper onClick={state.close} parent={document} includeButtonPress={false}>
<FocusScope contain autoFocus restoreFocus>
<Menu onClose={state.close} {...menuProps}>
{/*
tabIndex=-1 is needed here to support highlighting text within the menu when using FocusScope
see https://github.com/adobe/react-spectrum/issues/1604#issuecomment-781574668
*/}
<Menu tabIndex={-1} onClose={state.close} {...menuProps}>
{options.map((item) => (
<MenuItem
key={`${item.value}`}

View File

@@ -80,7 +80,11 @@ export function Modal(props: PropsWithChildren<Props>) {
onClick={onClickBackdrop || (closeOnBackdropClick ? onDismiss : undefined)}
/>
<FocusScope contain={trapFocus} autoFocus restoreFocus>
<div className={cx(styles.modal, className)}>
{/*
tabIndex=-1 is needed here to support highlighting text within the modal when using FocusScope
see https://github.com/adobe/react-spectrum/issues/1604#issuecomment-781574668
*/}
<div tabIndex={-1} className={cx(styles.modal, className)}>
<div className={headerClass}>
{typeof title === 'string' && <DefaultModalHeader {...props} title={title} />}
{typeof title !== 'string' && title}

View File

@@ -7,6 +7,7 @@ import { useMenuTriggerState } from '@react-stately/menu';
import { useMenuTrigger } from '@react-aria/menu';
import { useFocusWithin, useHover, useKeyboard } from '@react-aria/interactions';
import { useButton } from '@react-aria/button';
import { useDialog } from '@react-aria/dialog';
import { DismissButton, useOverlay } from '@react-aria/overlays';
import { FocusScope } from '@react-aria/focus';
@@ -130,6 +131,7 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
}
const overlayRef = React.useRef(null);
const { dialogProps } = useDialog({}, overlayRef);
const { overlayProps } = useOverlay(
{
onClose: () => state.close(),
@@ -155,7 +157,7 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
}}
>
<FocusScope restoreFocus>
<div {...overlayProps} ref={overlayRef}>
<div {...overlayProps} {...dialogProps} ref={overlayRef}>
<DismissButton onDismiss={() => state.close()} />
{menu}
<DismissButton onDismiss={() => state.close()} />

View File

@@ -2,6 +2,7 @@ import React, { useRef } from 'react';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { CustomScrollbar, Icon, IconButton, IconName, useTheme2 } from '@grafana/ui';
import { FocusScope } from '@react-aria/focus';
import { useDialog } from '@react-aria/dialog';
import { useOverlay } from '@react-aria/overlays';
import { css } from '@emotion/css';
import { NavBarMenuItem } from './NavBarMenuItem';
@@ -16,6 +17,7 @@ export function NavBarMenu({ activeItem, navItems, onClose }: Props) {
const theme = useTheme2();
const styles = getStyles(theme);
const ref = useRef(null);
const { dialogProps } = useDialog({}, ref);
const { overlayProps } = useOverlay(
{
isDismissable: true,
@@ -27,7 +29,7 @@ export function NavBarMenu({ activeItem, navItems, onClose }: Props) {
return (
<FocusScope contain restoreFocus autoFocus>
<div data-testid="navbarmenu" className={styles.container} ref={ref} {...overlayProps}>
<div data-testid="navbarmenu" className={styles.container} ref={ref} {...overlayProps} {...dialogProps}>
<div className={styles.header}>
<Icon name="bars" size="xl" />
<IconButton aria-label="Close navigation menu" name="times" onClick={onClose} size="xl" variant="secondary" />

View File

@@ -3,6 +3,7 @@ import { css } from '@emotion/css';
import { Button, ButtonGroup, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { FocusScope } from '@react-aria/focus';
import { useDialog } from '@react-aria/dialog';
import { useOverlay } from '@react-aria/overlays';
import { MediaType, PickerTabType, ResourceFolderName } from '../types';
@@ -25,6 +26,7 @@ export const ResourcePickerPopover = (props: Props) => {
};
const ref = createRef<HTMLElement>();
const { dialogProps } = useDialog({}, ref);
const { overlayProps } = useOverlay({ onClose, isDismissable: true, isOpen: true }, ref);
const [newValue, setNewValue] = useState<string>(value ?? '');
@@ -59,7 +61,7 @@ export const ResourcePickerPopover = (props: Props) => {
return (
<FocusScope contain autoFocus restoreFocus>
<section ref={ref} {...overlayProps}>
<section ref={ref} {...overlayProps} {...dialogProps}>
<div className={styles.resourcePickerPopover}>
<div className={styles.resourcePickerPopoverTabs}>
<button

View File

@@ -1,5 +1,6 @@
import React, { createRef } from 'react';
import { VizTooltipContainer } from '@grafana/ui';
import { useDialog } from '@react-aria/dialog';
import { useOverlay } from '@react-aria/overlays';
import { ComplexDataHoverView } from './components/ComplexDataHoverView';
@@ -14,12 +15,13 @@ interface Props {
export const GeomapTooltip = ({ ttip, onClose, isOpen }: Props) => {
const ref = createRef<HTMLElement>();
const { overlayProps } = useOverlay({ onClose, isDismissable: true, isOpen }, ref);
const { dialogProps } = useDialog({}, ref);
return (
<>
{ttip && ttip.layers && (
<VizTooltipContainer position={{ x: ttip.pageX, y: ttip.pageY }} offset={{ x: 10, y: 10 }} allowPointerEvents>
<section ref={ref} {...overlayProps}>
<section ref={ref} {...overlayProps} {...dialogProps}>
<ComplexDataHoverView {...ttip} isOpen={isOpen} onClose={onClose} />
</section>
</VizTooltipContainer>

View File

@@ -4042,6 +4042,7 @@ __metadata:
"@monaco-editor/react": 4.3.1
"@popperjs/core": 2.11.2
"@react-aria/button": 3.3.4
"@react-aria/dialog": 3.1.4
"@react-aria/focus": 3.5.0
"@react-aria/menu": 3.3.0
"@react-aria/overlays": 3.7.3
@@ -6520,7 +6521,22 @@ __metadata:
languageName: node
linkType: hard
"@react-aria/focus@npm:3.5.0, @react-aria/focus@npm:^3.5.0":
"@react-aria/dialog@npm:3.1.4":
version: 3.1.4
resolution: "@react-aria/dialog@npm:3.1.4"
dependencies:
"@babel/runtime": ^7.6.2
"@react-aria/focus": ^3.4.1
"@react-aria/utils": ^3.8.2
"@react-stately/overlays": ^3.1.3
"@react-types/dialog": ^3.3.1
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1
checksum: 968328a9b22e545ea79084cca2714f3bc7954c02eb0dc7cab78a1094d4c5dbd19520a0cc28e3dcbb7cb8dd7c505d6b9cb77fbb0fb4735fdf2e99908d481af3ab
languageName: node
linkType: hard
"@react-aria/focus@npm:3.5.0, @react-aria/focus@npm:^3.4.1, @react-aria/focus@npm:^3.5.0":
version: 3.5.0
resolution: "@react-aria/focus@npm:3.5.0"
dependencies:
@@ -6835,6 +6851,18 @@ __metadata:
languageName: node
linkType: hard
"@react-types/dialog@npm:^3.3.1":
version: 3.3.1
resolution: "@react-types/dialog@npm:3.3.1"
dependencies:
"@react-types/overlays": ^3.5.1
"@react-types/shared": ^3.8.0
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1
checksum: 6b0260e7018f26387c1d16585a048af5617f8b146d42d28439f0d4fd0fb041b1f004ca1a0a021a4e07a200a0045e34da8b2e0a3ff235272426b6815994e1c291
languageName: node
linkType: hard
"@react-types/menu@npm:3.4.1, @react-types/menu@npm:^3.3.0":
version: 3.4.1
resolution: "@react-types/menu@npm:3.4.1"
@@ -19664,6 +19692,7 @@ __metadata:
"@pmmmwh/react-refresh-webpack-plugin": 0.5.4
"@popperjs/core": 2.11.2
"@react-aria/button": 3.3.4
"@react-aria/dialog": 3.1.4
"@react-aria/focus": 3.5.0
"@react-aria/interactions": 3.7.0
"@react-aria/menu": 3.3.0