Re-enable `jsx-a11y` recommended rules (#104637)

* Re-enable `jsx-a11y` recommended rules

* apply rule in correct place, couple of fixes

* fix up some a11y issues

* add ignore for keyboard a11y for now

* readd testid

* close carousel on backdrop click

* use type="button"

---------

Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
This commit is contained in:
Tom Ratcliffe 2025-05-07 15:43:48 +01:00 committed by GitHub
parent 60ea65ca69
commit 6f3200d4f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 114 additions and 99 deletions

View File

@ -173,9 +173,7 @@ module.exports = [
files: ['**/*.tsx'], files: ['**/*.tsx'],
ignores: ['**/*.{spec,test}.tsx'], ignores: ['**/*.{spec,test}.tsx'],
rules: { rules: {
// rules marked "off" are those left in the recommended preset we need to fix ...jsxA11yPlugin.configs.recommended.rules,
// we should remove the corresponding line and fix them one by one
// any marked "error" contain specific overrides we'll need to keep
'jsx-a11y/no-autofocus': [ 'jsx-a11y/no-autofocus': [
'error', 'error',
{ {

View File

@ -1,11 +1,15 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { useState, useEffect } from 'react'; import { useDialog } from '@react-aria/dialog';
import { FocusScope } from '@react-aria/focus';
import { OverlayContainer, useOverlay } from '@react-aria/overlays';
import { useState, useEffect, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes'; import { useStyles2 } from '../../themes';
import { t } from '../../utils/i18n'; import { t } from '../../utils/i18n';
import { Alert } from '../Alert/Alert'; import { Alert } from '../Alert/Alert';
import { clearButtonStyles } from '../Button';
import { IconButton } from '../IconButton/IconButton'; import { IconButton } from '../IconButton/IconButton';
// Define the image item interface // Define the image item interface
@ -23,7 +27,8 @@ export const Carousel: React.FC<CarouselProps> = ({ images }) => {
const [imageErrors, setImageErrors] = useState<Record<string, boolean>>({}); const [imageErrors, setImageErrors] = useState<Record<string, boolean>>({});
const [validImages, setValidImages] = useState<CarouselImage[]>(images); const [validImages, setValidImages] = useState<CarouselImage[]>(images);
const styles = useStyles2(getStyles()); const styles = useStyles2(getStyles);
const resetButtonStyles = useStyles2(clearButtonStyles);
const handleImageError = (path: string) => { const handleImageError = (path: string) => {
setImageErrors((prev) => ({ setImageErrors((prev) => ({
@ -77,6 +82,11 @@ export const Carousel: React.FC<CarouselProps> = ({ images }) => {
} }
}; };
const ref = useRef<HTMLDivElement>(null);
const { overlayProps, underlayProps } = useOverlay({ isOpen: selectedIndex !== null, onClose: closePreview }, ref);
const { dialogProps } = useDialog({}, ref);
if (validImages.length === 0) { if (validImages.length === 0) {
return ( return (
<Alert <Alert
@ -88,72 +98,88 @@ export const Carousel: React.FC<CarouselProps> = ({ images }) => {
} }
return ( return (
<div onKeyDown={handleKeyDown} tabIndex={0}> <>
<div className={cx(styles.imageGrid)}> <div className={cx(styles.imageGrid)}>
{validImages.map((image, index) => ( {validImages.map((image, index) => (
<div key={image.path} onClick={() => openPreview(index)} style={{ cursor: 'pointer' }}> <button
type="button"
key={image.path}
onClick={() => openPreview(index)}
className={cx(resetButtonStyles, styles.imageButton)}
>
<img src={image.path} alt={image.name} onError={() => handleImageError(image.path)} /> <img src={image.path} alt={image.name} onError={() => handleImageError(image.path)} />
<p>{image.name}</p> <p>{image.name}</p>
</div> </button>
))} ))}
</div> </div>
{selectedIndex !== null && ( {selectedIndex !== null && (
<div className={cx(styles.fullScreenDiv)} onClick={closePreview} data-testid="carousel-full-screen"> <OverlayContainer>
<IconButton <div role="presentation" className={styles.underlay} onClick={closePreview} {...underlayProps} />
name="times" <FocusScope contain autoFocus restoreFocus>
aria-label={t('carousel.close', 'Close')} {/* convenience method for keyboard users */}
size="xl" {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
onClick={closePreview} <div
className={cx(styles.closeButton)} data-testid="carousel-full-screen"
/> ref={ref}
{...overlayProps}
{...dialogProps}
onKeyDown={handleKeyDown}
className={styles.overlay}
>
<IconButton
name="times"
aria-label={t('carousel.close', 'Close')}
size="xl"
onClick={closePreview}
className={cx(styles.closeButton)}
/>
<IconButton <IconButton
size="xl" size="xl"
name="angle-left" name="angle-left"
aria-label={t('carousel.previous', 'Previous')} aria-label={t('carousel.previous', 'Previous')}
onClick={(e) => { onClick={goToPrevious}
e.stopPropagation(); data-testid="previous-button"
goToPrevious(); />
}}
className={cx(styles.navigationButton, styles.previousButton)}
data-testid="previous-button"
/>
<div <div data-testid="carousel-full-image">
style={{ position: 'relative', maxWidth: '90%', maxHeight: '90%' }} <img
onClick={(e) => e.stopPropagation()} className={styles.imagePreview}
data-testid="carousel-full-image" src={validImages[selectedIndex].path}
> alt={validImages[selectedIndex].name}
<img onError={() => handleImageError(validImages[selectedIndex].path)}
src={validImages[selectedIndex].path} />
alt={validImages[selectedIndex].name} </div>
onError={() => handleImageError(validImages[selectedIndex].path)}
/>
</div>
<IconButton <IconButton
size="xl" size="xl"
name="angle-right" name="angle-right"
aria-label={t('carousel.next', 'Next')} aria-label={t('carousel.next', 'Next')}
onClick={(e) => { onClick={goToNext}
e.stopPropagation(); data-testid="next-button"
goToNext(); />
}} </div>
className={cx(styles.navigationButton, styles.nextButton)} </FocusScope>
data-testid="next-button" </OverlayContainer>
/>
</div>
)} )}
</div> </>
); );
}; };
const getStyles = () => (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
imageButton: css({
textAlign: 'left',
}),
imagePreview: css({
maxWidth: '100%',
maxHeight: '80vh',
objectFit: 'contain',
}),
imageGrid: css({ imageGrid: css({
display: 'grid', display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(200px, 1fr))`, gridTemplateColumns: `repeat(auto-fill, minmax(200px, 1fr))`,
gap: '16px', gap: theme.spacing(2),
marginBottom: '20px', marginBottom: '20px',
'& img': { '& img': {
@ -165,49 +191,33 @@ const getStyles = () => (theme: GrafanaTheme2) => ({
boxShadow: theme.shadows.z1, boxShadow: theme.shadows.z1,
}, },
'& p': { '& p': {
margin: '4px 0', margin: theme.spacing(0.5, 0),
fontWeight: theme.typography.fontWeightMedium, fontWeight: theme.typography.fontWeightMedium,
color: theme.colors.text.primary, color: theme.colors.text.primary,
}, },
}), }),
fullScreenDiv: css({ underlay: css({
position: 'fixed', position: 'fixed',
zIndex: theme.zIndex.modalBackdrop, zIndex: theme.zIndex.modalBackdrop,
top: 0, inset: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: theme.components.overlay.background, backgroundColor: theme.components.overlay.background,
}),
overlay: css({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center',
display: 'flex', display: 'flex',
gap: theme.spacing(1),
'& img': { height: 'fit-content',
maxWidth: '100%', marginBottom: 'auto',
maxHeight: '80vh', marginTop: 'auto',
objectFit: 'contain', padding: theme.spacing(2),
}, position: 'fixed',
inset: 0,
zIndex: theme.zIndex.modal,
}), }),
closeButton: css({ closeButton: css({
position: 'absolute',
top: '20px',
right: '20px',
backgroundColor: 'transparent',
color: theme.colors.text.primary, color: theme.colors.text.primary,
}), position: 'fixed',
navigationButton: css({ top: theme.spacing(2),
position: 'absolute', right: theme.spacing(2),
backgroundColor: 'transparent',
color: theme.colors.text.primary,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}),
nextButton: css({
right: '20px',
}),
previousButton: css({
left: '20px',
}), }),
}); });

View File

@ -99,6 +99,7 @@ const HeaderCell: React.FC<HeaderCellProps> = ({
}, [column]); // eslint-disable-line react-hooks/exhaustive-deps }, [column]); // eslint-disable-line react-hooks/exhaustive-deps
return ( return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div <div
ref={headerRef} ref={headerRef}
className={styles.headerCell} className={styles.headerCell}

View File

@ -16,7 +16,7 @@ export function RowExpander({ height, onCellExpand, isExpanded }: RowExpanderNGP
} }
} }
return ( return (
<div className={styles.expanderCell} onClick={onCellExpand} onKeyDown={handleKeyDown}> <div role="button" tabIndex={0} className={styles.expanderCell} onClick={onCellExpand} onKeyDown={handleKeyDown}>
<Icon <Icon
aria-label={ aria-label={
isExpanded isExpanded

View File

@ -75,6 +75,8 @@ function ThemeCard({ themeOption, isExperimental, isSelected, onSelect }: ThemeC
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
return ( return (
// this is a convenience for mouse users. keyboard/screen reader users will use the radio button
// eslint-disable-next-line jsx-a11y/no-static-element-interactions,jsx-a11y/click-events-have-key-events
<div className={styles.card} onClick={onSelect}> <div className={styles.card} onClick={onSelect}>
<div className={styles.header}> <div className={styles.header}>
<RadioButtonDot <RadioButtonDot

View File

@ -13,6 +13,7 @@ import {
VizPanel, VizPanel,
} from '@grafana/scenes'; } from '@grafana/scenes';
import { import {
clearButtonStyles,
ElementSelectionContextItem, ElementSelectionContextItem,
ElementSelectionContextState, ElementSelectionContextState,
ElementSelectionOnSelectOptions, ElementSelectionOnSelectOptions,
@ -199,6 +200,7 @@ export interface Props {
export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleCollapse, openOverlay }: Props) { export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleCollapse, openOverlay }: Props) {
const { selection } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true }); const { selection } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true });
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const clearButton = useStyles2(clearButtonStyles);
const editableElement = useEditableElement(selection, editPane); const editableElement = useEditableElement(selection, editPane);
const selectedObject = selection?.getFirstObject(); const selectedObject = selection?.getFirstObject();
const isNewElement = selection?.isNewElement() ?? false; const isNewElement = selection?.isNewElement() ?? false;
@ -280,17 +282,17 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla
data-edit-pane-splitter={true} data-edit-pane-splitter={true}
/> />
<div {...splitter.secondaryProps} className={cx(splitter.primaryProps.className, styles.paneContent)}> <div {...splitter.secondaryProps} className={cx(splitter.primaryProps.className, styles.paneContent)}>
<div <button
role="button" type="button"
onClick={() => setOutlineCollapsed(!outlineCollapsed)} onClick={() => setOutlineCollapsed(!outlineCollapsed)}
className={styles.outlineCollapseButton} className={cx(clearButton, styles.outlineCollapseButton)}
data-testid={selectors.components.PanelEditor.Outline.section} data-testid={selectors.components.PanelEditor.Outline.section}
> >
<Text weight="medium"> <Text weight="medium">
<Trans i18nKey="dashboard-scene.dashboard-edit-pane-renderer.outline">Outline</Trans> <Trans i18nKey="dashboard-scene.dashboard-edit-pane-renderer.outline">Outline</Trans>
</Text> </Text>
<Icon name={outlineCollapsed ? 'angle-up' : 'angle-down'} /> <Icon name={outlineCollapsed ? 'angle-up' : 'angle-down'} />
</div> </button>
{!outlineCollapsed && ( {!outlineCollapsed && (
<div className={styles.outlineContainer}> <div className={styles.outlineContainer}>
<ScrollContainer showScrollIndicators={true}> <ScrollContainer showScrollIndicators={true}>

View File

@ -90,6 +90,8 @@ function DashboardOutlineNode({
> >
{elementInfo.isContainer && ( {elementInfo.isContainer && (
<button <button
// TODO fix keyboard a11y here
// eslint-disable-next-line jsx-a11y/role-has-required-aria-props
role="treeitem" role="treeitem"
className={styles.angleButton} className={styles.angleButton}
onPointerDown={onToggleCollapse} onPointerDown={onToggleCollapse}
@ -99,7 +101,6 @@ function DashboardOutlineNode({
</button> </button>
)} )}
<button <button
role="button"
className={cx(styles.nodeName, isCloned && styles.nodeNameClone)} className={cx(styles.nodeName, isCloned && styles.nodeNameClone)}
onDoubleClick={outlineRename.onNameDoubleClicked} onDoubleClick={outlineRename.onNameDoubleClicked}
data-testid={selectors.components.PanelEditor.Outline.item(instanceName)} data-testid={selectors.components.PanelEditor.Outline.item(instanceName)}

View File

@ -112,7 +112,6 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
!isTopLevel && styles.rowTitleNested, !isTopLevel && styles.rowTitleNested,
isCollapsed && styles.rowTitleCollapsed isCollapsed && styles.rowTitleCollapsed
)} )}
role="heading"
> >
{!model.hasUniqueTitle() && ( {!model.hasUniqueTitle() && (
<Tooltip <Tooltip

View File

@ -65,7 +65,6 @@ export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
isDropTarget && 'dashboard-drop-target' isDropTarget && 'dashboard-drop-target'
)} )}
active={isActive} active={isActive}
role="presentation"
title={titleInterpolated} title={titleInterpolated}
href={href} href={href}
aria-selected={isActive} aria-selected={isActive}

View File

@ -12,7 +12,7 @@ interface Props {
checkedLabel?: string; checkedLabel?: string;
disabled?: boolean; disabled?: boolean;
'data-testid'?: string; 'data-testid'?: string;
onClick: (evt: MouseEvent<HTMLDivElement>) => void; onClick: (evt: MouseEvent<HTMLButtonElement>) => void;
} }
export const ToolbarSwitch = ({ export const ToolbarSwitch = ({
@ -32,9 +32,8 @@ export const ToolbarSwitch = ({
return ( return (
<Tooltip content={labelText}> <Tooltip content={labelText}>
<div <button
aria-label={labelText} aria-label={labelText}
role="button"
className={cx({ className={cx({
[styles.container]: true, [styles.container]: true,
[styles.containerChecked]: checked, [styles.containerChecked]: checked,
@ -46,7 +45,7 @@ export const ToolbarSwitch = ({
<div className={cx(styles.box, checked && styles.boxChecked)}> <div className={cx(styles.box, checked && styles.boxChecked)}>
<Icon name={iconName} size="xs" /> <Icon name={iconName} size="xs" />
</div> </div>
</div> </button>
</Tooltip> </Tooltip>
); );
}; };

View File

@ -76,6 +76,8 @@ function VariableList({ set }: { set: SceneVariableSet }) {
return ( return (
<Stack direction="column" gap={0}> <Stack direction="column" gap={0}>
{variables.map((variable) => ( {variables.map((variable) => (
// TODO fix keyboard a11y here
// eslint-disable-next-line jsx-a11y/no-static-element-interactions,jsx-a11y/click-events-have-key-events
<div className={styles.variableItem} key={variable.state.name} onClick={() => onEditVariable(variable)}> <div className={styles.variableItem} key={variable.state.name} onClick={() => onEditVariable(variable)}>
{/* eslint-disable-next-line @grafana/no-untranslated-strings */} {/* eslint-disable-next-line @grafana/no-untranslated-strings */}
<Text>${variable.state.name}</Text> <Text>${variable.state.name}</Text>

View File

@ -88,15 +88,16 @@ const Ellipsis = ({ toggle, diff }: EllipsisProps) => {
return ( return (
<> <>
<Trans i18nKey="logs.log-row-message.ellipsis"> </Trans> <Trans i18nKey="logs.log-row-message.ellipsis"> </Trans>
<span className={styles.showMore} onClick={handleClick}> <button className={styles.showMore} onClick={handleClick}>
{diff} <Trans i18nKey="logs.log-row-message.more">more</Trans> {diff} <Trans i18nKey="logs.log-row-message.more">more</Trans>
</span> </button>
</> </>
); );
}; };
const getEllipsisStyles = (theme: GrafanaTheme2) => ({ const getEllipsisStyles = (theme: GrafanaTheme2) => ({
showMore: css({ showMore: css({
backgroundColor: 'transparent',
display: 'inline-flex', display: 'inline-flex',
fontWeight: theme.typography.fontWeightMedium, fontWeight: theme.typography.fontWeightMedium,
fontSize: theme.typography.size.sm, fontSize: theme.typography.size.sm,

View File

@ -76,6 +76,7 @@ export const LogLine = ({
className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''}`} className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''}`}
ref={onOverflow ? logLineRef : undefined} ref={onOverflow ? logLineRef : undefined}
onMouseEnter={handleMouseOver} onMouseEnter={handleMouseOver}
onFocus={handleMouseOver}
> >
<LogLineMenu styles={styles} log={log} /> <LogLineMenu styles={styles} log={log} />
<div <div