AnnoListPanel: Add keyboard accessibility (#44280)

This commit is contained in:
kay delaney 2022-02-01 15:33:21 +00:00 committed by GitHub
parent e93e1bdd2b
commit 8218d81f0e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 149 additions and 146 deletions

View File

@ -3,6 +3,8 @@ import { cx, css } from '@emotion/css';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { useTheme } from '../../themes'; import { useTheme } from '../../themes';
import { getTagColor, getTagColorsFromName } from '../../utils'; import { getTagColor, getTagColorsFromName } from '../../utils';
import { IconName } from '../../types/icon';
import { Icon } from '../Icon/Icon';
/** /**
* @public * @public
@ -12,12 +14,13 @@ export type OnTagClick = (name: string, event: React.MouseEvent<HTMLElement>) =>
export interface Props extends Omit<HTMLAttributes<HTMLElement>, 'onClick'> { export interface Props extends Omit<HTMLAttributes<HTMLElement>, 'onClick'> {
/** Name of the tag to display */ /** Name of the tag to display */
name: string; name: string;
icon?: IconName;
/** Use constant color from TAG_COLORS. Using index instead of color directly so we can match other styling. */ /** Use constant color from TAG_COLORS. Using index instead of color directly so we can match other styling. */
colorIndex?: number; colorIndex?: number;
onClick?: OnTagClick; onClick?: OnTagClick;
} }
export const Tag = forwardRef<HTMLElement, Props>(({ name, onClick, className, colorIndex, ...rest }, ref) => { export const Tag = forwardRef<HTMLElement, Props>(({ name, onClick, icon, className, colorIndex, ...rest }, ref) => {
const theme = useTheme(); const theme = useTheme();
const styles = getTagStyles(theme, name, colorIndex); const styles = getTagStyles(theme, name, colorIndex);
@ -32,10 +35,12 @@ export const Tag = forwardRef<HTMLElement, Props>(({ name, onClick, className, c
return onClick ? ( return onClick ? (
<button {...rest} className={classes} onClick={onTagClick} ref={ref as React.ForwardedRef<HTMLButtonElement>}> <button {...rest} className={classes} onClick={onTagClick} ref={ref as React.ForwardedRef<HTMLButtonElement>}>
{icon && <Icon name={icon} />}
{name} {name}
</button> </button>
) : ( ) : (
<span {...rest} className={classes} ref={ref}> <span {...rest} className={classes} ref={ref}>
{icon && <Icon name={icon} />}
{name} {name}
</span> </span>
); );

View File

@ -1,8 +1,9 @@
import React, { FC, memo } from 'react'; import React, { forwardRef, memo } from 'react';
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { OnTagClick, Tag } from './Tag'; import { OnTagClick, Tag } from './Tag';
import { useTheme2 } from '../../themes'; import { useTheme2 } from '../../themes';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { IconName } from '../../types/icon';
export interface Props { export interface Props {
displayMax?: number; displayMax?: number;
@ -12,24 +13,30 @@ export interface Props {
className?: string; className?: string;
/** aria-label for the `i`-th Tag component */ /** aria-label for the `i`-th Tag component */
getAriaLabel?: (name: string, i: number) => string; getAriaLabel?: (name: string, i: number) => string;
/** Icon to show next to tag label */
icon?: IconName;
} }
export const TagList: FC<Props> = memo(({ displayMax, tags, onClick, className, getAriaLabel }) => { export const TagList = memo(
forwardRef<HTMLUListElement, Props>(({ displayMax, tags, icon, onClick, className, getAriaLabel }, ref) => {
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme, Boolean(displayMax && displayMax > 0)); const styles = getStyles(theme, Boolean(displayMax && displayMax > 0));
const numTags = tags.length; const numTags = tags.length;
const tagsToDisplay = displayMax ? tags.slice(0, displayMax) : tags; const tagsToDisplay = displayMax ? tags.slice(0, displayMax) : tags;
return ( return (
<ul className={cx(styles.wrapper, className)} aria-label="Tags"> <ul className={cx(styles.wrapper, className)} aria-label="Tags" ref={ref}>
{tagsToDisplay.map((tag, i) => ( {tagsToDisplay.map((tag, i) => (
<li className={styles.li} key={tag}> <li className={styles.li} key={tag}>
<Tag name={tag} onClick={onClick} aria-label={getAriaLabel?.(tag, i)} /> <Tag name={tag} icon={icon} onClick={onClick} aria-label={getAriaLabel?.(tag, i)} data-tag-id={i} />
</li> </li>
))} ))}
{displayMax && displayMax > 0 && numTags - 1 > 0 && <span className={styles.moreTagsLabel}>+ {numTags - 1}</span>} {displayMax && displayMax > 0 && numTags - 1 > 0 && (
<span className={styles.moreTagsLabel}>+ {numTags - 1}</span>
)}
</ul> </ul>
); );
}); })
);
TagList.displayName = 'TagList'; TagList.displayName = 'TagList';

View File

@ -17,10 +17,10 @@ import { AbstractList } from '@grafana/ui/src/components/List/AbstractList';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { AnnotationListItem } from './AnnotationListItem'; import { AnnotationListItem } from './AnnotationListItem';
import { AnnotationListItemTags } from './AnnotationListItemTags'; import { CustomScrollbar, stylesFactory, TagList } from '@grafana/ui';
import { CustomScrollbar, stylesFactory } from '@grafana/ui';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { FocusScope } from '@react-aria/focus';
interface UserInfo { interface UserInfo {
id?: number; id?: number;
@ -39,6 +39,7 @@ interface State {
export class AnnoListPanel extends PureComponent<Props, State> { export class AnnoListPanel extends PureComponent<Props, State> {
style = getStyles(config.theme); style = getStyles(config.theme);
subs = new Subscription(); subs = new Subscription();
tagListRef = React.createRef<HTMLUListElement>();
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
@ -181,9 +182,30 @@ export class AnnoListPanel extends PureComponent<Props, State> {
} }
onTagClick = (tag: string, remove?: boolean) => { onTagClick = (tag: string, remove?: boolean) => {
if (!remove && this.state.queryTags.includes(tag)) {
return;
}
const queryTags = remove ? this.state.queryTags.filter((item) => item !== tag) : [...this.state.queryTags, tag]; const queryTags = remove ? this.state.queryTags.filter((item) => item !== tag) : [...this.state.queryTags, tag];
this.setState({ queryTags }); // Logic to ensure keyboard focus isn't lost when the currently
// focused tag is removed
let nextTag: HTMLElement | undefined = undefined;
if (remove) {
const focusedTag = document.activeElement;
const dataTagId = focusedTag?.getAttribute('data-tag-id');
if (this.tagListRef.current?.contains(focusedTag) && dataTagId) {
const parsedTagId = Number.parseInt(dataTagId, 10);
const possibleNextTag =
this.tagListRef.current.querySelector(`[data-tag-id="${parsedTagId + 1}"]`) ??
this.tagListRef.current.querySelector(`[data-tag-id="${parsedTagId - 1}"]`);
if (possibleNextTag instanceof HTMLElement) {
nextTag = possibleNextTag;
}
}
}
this.setState({ queryTags }, () => nextTag?.focus());
}; };
onUserClick = (anno: AnnotationEvent) => { onUserClick = (anno: AnnotationEvent) => {
@ -202,10 +224,6 @@ export class AnnoListPanel extends PureComponent<Props, State> {
}); });
}; };
renderTags = (tags?: string[], remove?: boolean): JSX.Element | null => {
return <AnnotationListItemTags tags={tags} remove={remove} onClick={this.onTagClick} />;
};
renderItem = (anno: AnnotationEvent, index: number): JSX.Element => { renderItem = (anno: AnnotationEvent, index: number): JSX.Element => {
const { options } = this.props; const { options } = this.props;
const dashboard = getDashboardSrv().getCurrent(); const dashboard = getDashboardSrv().getCurrent();
@ -242,14 +260,25 @@ export class AnnoListPanel extends PureComponent<Props, State> {
return ( return (
<CustomScrollbar autoHeightMin="100%"> <CustomScrollbar autoHeightMin="100%">
{hasFilter && ( {hasFilter && (
<div> <div className={this.style.filter}>
<b>Filter: &nbsp; </b> <b>Filter:</b>
{queryUser && ( {queryUser && (
<span onClick={this.onClearUser} className="pointer"> <span onClick={this.onClearUser} className="pointer">
{queryUser.email} {queryUser.email}
</span> </span>
)} )}
{queryTags.length > 0 && this.renderTags(queryTags, true)} {queryTags.length > 0 && (
<FocusScope restoreFocus>
<TagList
icon="times"
tags={queryTags}
onClick={(tag) => this.onTagClick(tag, true)}
getAriaLabel={(name) => `Remove ${name} tag`}
className={this.style.tagList}
ref={this.tagListRef}
/>
</FocusScope>
)}
</div> </div>
)} )}
@ -269,4 +298,17 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
width: 100%; width: 100%;
height: calc(100% - 30px); height: calc(100% - 30px);
`, `,
filter: css({
display: 'flex',
padding: `0px ${theme.spacing.xs}`,
b: {
paddingRight: theme.spacing.sm,
},
}),
tagList: css({
justifyContent: 'flex-start',
'li > button': {
paddingLeft: '3px',
},
}),
})); }));

View File

@ -1,9 +1,8 @@
import React, { FC, MouseEvent } from 'react'; import React, { FC, MouseEvent } from 'react';
import { css, cx } from '@emotion/css'; import { css } from '@emotion/css';
import { AnnotationEvent, DateTimeInput, GrafanaTheme2, PanelProps } from '@grafana/data'; import { AnnotationEvent, DateTimeInput, GrafanaTheme2, PanelProps } from '@grafana/data';
import { styleMixins, Tooltip, useStyles2 } from '@grafana/ui'; import { Card, TagList, Tooltip, useStyles2 } from '@grafana/ui';
import { AnnoOptions } from './types'; import { AnnoOptions } from './types';
import { AnnotationListItemTags } from './AnnotationListItemTags';
interface Props extends Pick<PanelProps<AnnoOptions>, 'options'> { interface Props extends Pick<PanelProps<AnnoOptions>, 'options'> {
annotation: AnnotationEvent; annotation: AnnotationEvent;
@ -24,8 +23,7 @@ export const AnnotationListItem: FC<Props> = ({
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { showUser, showTags, showTime } = options; const { showUser, showTags, showTime } = options;
const { text, login, email, avatarUrl, tags, time, timeEnd } = annotation; const { text, login, email, avatarUrl, tags, time, timeEnd } = annotation;
const onItemClick = (e: MouseEvent) => { const onItemClick = () => {
e.stopPropagation();
onClick(annotation); onClick(annotation);
}; };
const onLoginClick = () => { const onLoginClick = () => {
@ -36,20 +34,32 @@ export const AnnotationListItem: FC<Props> = ({
const showTimeStampEnd = timeEnd && timeEnd !== time && showTime; const showTimeStampEnd = timeEnd && timeEnd !== time && showTime;
return ( return (
<div> <Card className={styles.card} onClick={onItemClick}>
<span className={cx(styles.item, styles.link, styles.pointer)} onClick={onItemClick}> <Card.Heading>
<div className={styles.title}>
<span>{text}</span> <span>{text}</span>
{showTimeStamp ? <TimeStamp formatDate={formatDate} time={time!} /> : null} </Card.Heading>
{showTimeStampEnd ? <span className={styles.time}>-</span> : null} {showTimeStamp && (
{showTimeStampEnd ? <TimeStamp formatDate={formatDate} time={timeEnd!} /> : null} <Card.Description className={styles.timestamp}>
</div> <TimeStamp formatDate={formatDate} time={time!} />
<div className={styles.login}> {showTimeStampEnd && (
{showAvatar ? <Avatar email={email} login={login!} avatarUrl={avatarUrl} onClick={onLoginClick} /> : null} <>
{showTags ? <AnnotationListItemTags tags={tags} remove={false} onClick={onTagClick} /> : null} <span className={styles.time}>-</span>
</div> <TimeStamp formatDate={formatDate} time={timeEnd!} />{' '}
</span> </>
</div> )}
</Card.Description>
)}
{showAvatar && (
<Card.Meta className={styles.meta}>
<Avatar email={email} login={login!} avatarUrl={avatarUrl} onClick={onLoginClick} />
</Card.Meta>
)}
{showTags && tags && (
<Card.Tags>
<TagList tags={tags} onClick={(tag) => onTagClick(tag, false)} />
</Card.Tags>
)}
</Card>
); );
}; };
@ -74,13 +84,11 @@ const Avatar: FC<AvatarProps> = ({ onClick, avatarUrl, login, email }) => {
); );
return ( return (
<div>
<Tooltip content={tooltipContent} theme="info" placement="top"> <Tooltip content={tooltipContent} theme="info" placement="top">
<span onClick={onAvatarClick} className={styles.avatar}> <button onClick={onAvatarClick} className={styles.avatar} aria-label={`Created by ${email}`}>
<img src={avatarUrl} alt="avatar icon" /> <img src={avatarUrl} alt="avatar icon" />
</span> </button>
</Tooltip> </Tooltip>
</div>
); );
}; };
@ -101,48 +109,38 @@ const TimeStamp: FC<TimeStampProps> = ({ time, formatDate }) => {
function getStyles(theme: GrafanaTheme2) { function getStyles(theme: GrafanaTheme2) {
return { return {
pointer: css` card: css({
cursor: pointer; gridTemplateAreas: `"Heading Description Meta Tags"`,
`, gridTemplateColumns: 'auto 1fr auto auto',
item: css` padding: theme.spacing(1),
margin: ${theme.spacing(0.5)}; margin: theme.spacing(0.5),
padding: ${theme.spacing(1)}; width: 'inherit',
${styleMixins.listItem(theme)}// display: flex; }),
`, meta: css({
title: css` margin: 0,
flex-basis: 80%; position: 'relative',
`, justifyContent: 'end',
link: css` }),
display: flex; timestamp: css({
margin: 0,
.fa { alignSelf: 'center',
padding-top: ${theme.spacing(0.5)}; }),
} time: css({
marginLeft: theme.spacing(1),
.fa-star { marginRight: theme.spacing(1),
color: ${theme.v1.palette.orange}; fontSize: theme.typography.bodySmall.fontSize,
} color: theme.colors.text.secondary,
`, }),
login: css` avatar: css({
align-self: center; border: 'none',
flex: auto; background: 'inherit',
display: flex; margin: 0,
justify-content: flex-end; padding: theme.spacing(0.5),
font-size: ${theme.typography.bodySmall.fontSize}; img: {
`, borderRadius: '50%',
time: css` width: theme.spacing(2),
margin-left: ${theme.spacing(1)}; height: theme.spacing(2),
margin-right: ${theme.spacing(1)} },
font-size: ${theme.typography.bodySmall.fontSize}; }),
color: ${theme.colors.text.secondary};
`,
avatar: css`
padding: ${theme.spacing(0.5)};
img {
border-radius: 50%;
width: ${theme.spacing(2)};
height: ${theme.spacing(2)};
}
`,
}; };
} }

View File

@ -1,49 +0,0 @@
import React, { FC, MouseEvent, useCallback } from 'react';
import { GrafanaTheme } from '@grafana/data';
import { css } from '@emotion/css';
import { useStyles } from '@grafana/ui';
import { TagBadge } from '../../../core/components/TagFilter/TagBadge';
interface Props {
tags?: string[];
remove?: boolean;
onClick: (tag: string, remove?: boolean) => void;
}
export const AnnotationListItemTags: FC<Props> = ({ tags, remove, onClick }) => {
const styles = useStyles(getStyles);
const onTagClicked = useCallback(
(e: MouseEvent, tag: string) => {
e.stopPropagation();
onClick(tag, remove);
},
[onClick, remove]
);
if (!tags || !tags.length) {
return null;
}
return (
<>
{tags.map((tag) => {
return (
<span key={tag} onClick={(e) => onTagClicked(e, tag)} className={styles.pointer}>
<TagBadge label={tag} removeIcon={Boolean(remove)} count={0} />
</span>
);
})}
</>
);
};
function getStyles(theme: GrafanaTheme) {
return {
pointer: css`
cursor: pointer;
padding: ${theme.spacing.xxs};
`,
};
}