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 { useTheme } from '../../themes';
import { getTagColor, getTagColorsFromName } from '../../utils';
import { IconName } from '../../types/icon';
import { Icon } from '../Icon/Icon';
/**
* @public
@ -12,12 +14,13 @@ export type OnTagClick = (name: string, event: React.MouseEvent<HTMLElement>) =>
export interface Props extends Omit<HTMLAttributes<HTMLElement>, 'onClick'> {
/** Name of the tag to display */
name: string;
icon?: IconName;
/** Use constant color from TAG_COLORS. Using index instead of color directly so we can match other styling. */
colorIndex?: number;
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 styles = getTagStyles(theme, name, colorIndex);
@ -32,10 +35,12 @@ export const Tag = forwardRef<HTMLElement, Props>(({ name, onClick, className, c
return onClick ? (
<button {...rest} className={classes} onClick={onTagClick} ref={ref as React.ForwardedRef<HTMLButtonElement>}>
{icon && <Icon name={icon} />}
{name}
</button>
) : (
<span {...rest} className={classes} ref={ref}>
{icon && <Icon name={icon} />}
{name}
</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 { OnTagClick, Tag } from './Tag';
import { useTheme2 } from '../../themes';
import { GrafanaTheme2 } from '@grafana/data';
import { IconName } from '../../types/icon';
export interface Props {
displayMax?: number;
@ -12,24 +13,30 @@ export interface Props {
className?: string;
/** aria-label for the `i`-th Tag component */
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 }) => {
const theme = useTheme2();
const styles = getStyles(theme, Boolean(displayMax && displayMax > 0));
const numTags = tags.length;
const tagsToDisplay = displayMax ? tags.slice(0, displayMax) : tags;
return (
<ul className={cx(styles.wrapper, className)} aria-label="Tags">
{tagsToDisplay.map((tag, i) => (
<li className={styles.li} key={tag}>
<Tag name={tag} onClick={onClick} aria-label={getAriaLabel?.(tag, i)} />
</li>
))}
{displayMax && displayMax > 0 && numTags - 1 > 0 && <span className={styles.moreTagsLabel}>+ {numTags - 1}</span>}
</ul>
);
});
export const TagList = memo(
forwardRef<HTMLUListElement, Props>(({ displayMax, tags, icon, onClick, className, getAriaLabel }, ref) => {
const theme = useTheme2();
const styles = getStyles(theme, Boolean(displayMax && displayMax > 0));
const numTags = tags.length;
const tagsToDisplay = displayMax ? tags.slice(0, displayMax) : tags;
return (
<ul className={cx(styles.wrapper, className)} aria-label="Tags" ref={ref}>
{tagsToDisplay.map((tag, i) => (
<li className={styles.li} key={tag}>
<Tag name={tag} icon={icon} onClick={onClick} aria-label={getAriaLabel?.(tag, i)} data-tag-id={i} />
</li>
))}
{displayMax && displayMax > 0 && numTags - 1 > 0 && (
<span className={styles.moreTagsLabel}>+ {numTags - 1}</span>
)}
</ul>
);
})
);
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 appEvents from 'app/core/app_events';
import { AnnotationListItem } from './AnnotationListItem';
import { AnnotationListItemTags } from './AnnotationListItemTags';
import { CustomScrollbar, stylesFactory } from '@grafana/ui';
import { CustomScrollbar, stylesFactory, TagList } from '@grafana/ui';
import { css } from '@emotion/css';
import { Subscription } from 'rxjs';
import { FocusScope } from '@react-aria/focus';
interface UserInfo {
id?: number;
@ -39,6 +39,7 @@ interface State {
export class AnnoListPanel extends PureComponent<Props, State> {
style = getStyles(config.theme);
subs = new Subscription();
tagListRef = React.createRef<HTMLUListElement>();
constructor(props: Props) {
super(props);
@ -181,9 +182,30 @@ export class AnnoListPanel extends PureComponent<Props, State> {
}
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];
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) => {
@ -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 => {
const { options } = this.props;
const dashboard = getDashboardSrv().getCurrent();
@ -242,14 +260,25 @@ export class AnnoListPanel extends PureComponent<Props, State> {
return (
<CustomScrollbar autoHeightMin="100%">
{hasFilter && (
<div>
<b>Filter: &nbsp; </b>
<div className={this.style.filter}>
<b>Filter:</b>
{queryUser && (
<span onClick={this.onClearUser} className="pointer">
{queryUser.email}
</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>
)}
@ -269,4 +298,17 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
width: 100%;
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 { css, cx } from '@emotion/css';
import { css } from '@emotion/css';
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 { AnnotationListItemTags } from './AnnotationListItemTags';
interface Props extends Pick<PanelProps<AnnoOptions>, 'options'> {
annotation: AnnotationEvent;
@ -24,8 +23,7 @@ export const AnnotationListItem: FC<Props> = ({
const styles = useStyles2(getStyles);
const { showUser, showTags, showTime } = options;
const { text, login, email, avatarUrl, tags, time, timeEnd } = annotation;
const onItemClick = (e: MouseEvent) => {
e.stopPropagation();
const onItemClick = () => {
onClick(annotation);
};
const onLoginClick = () => {
@ -36,20 +34,32 @@ export const AnnotationListItem: FC<Props> = ({
const showTimeStampEnd = timeEnd && timeEnd !== time && showTime;
return (
<div>
<span className={cx(styles.item, styles.link, styles.pointer)} onClick={onItemClick}>
<div className={styles.title}>
<span>{text}</span>
{showTimeStamp ? <TimeStamp formatDate={formatDate} time={time!} /> : null}
{showTimeStampEnd ? <span className={styles.time}>-</span> : null}
{showTimeStampEnd ? <TimeStamp formatDate={formatDate} time={timeEnd!} /> : null}
</div>
<div className={styles.login}>
{showAvatar ? <Avatar email={email} login={login!} avatarUrl={avatarUrl} onClick={onLoginClick} /> : null}
{showTags ? <AnnotationListItemTags tags={tags} remove={false} onClick={onTagClick} /> : null}
</div>
</span>
</div>
<Card className={styles.card} onClick={onItemClick}>
<Card.Heading>
<span>{text}</span>
</Card.Heading>
{showTimeStamp && (
<Card.Description className={styles.timestamp}>
<TimeStamp formatDate={formatDate} time={time!} />
{showTimeStampEnd && (
<>
<span className={styles.time}>-</span>
<TimeStamp formatDate={formatDate} time={timeEnd!} />{' '}
</>
)}
</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 (
<div>
<Tooltip content={tooltipContent} theme="info" placement="top">
<span onClick={onAvatarClick} className={styles.avatar}>
<img src={avatarUrl} alt="avatar icon" />
</span>
</Tooltip>
</div>
<Tooltip content={tooltipContent} theme="info" placement="top">
<button onClick={onAvatarClick} className={styles.avatar} aria-label={`Created by ${email}`}>
<img src={avatarUrl} alt="avatar icon" />
</button>
</Tooltip>
);
};
@ -101,48 +109,38 @@ const TimeStamp: FC<TimeStampProps> = ({ time, formatDate }) => {
function getStyles(theme: GrafanaTheme2) {
return {
pointer: css`
cursor: pointer;
`,
item: css`
margin: ${theme.spacing(0.5)};
padding: ${theme.spacing(1)};
${styleMixins.listItem(theme)}// display: flex;
`,
title: css`
flex-basis: 80%;
`,
link: css`
display: flex;
.fa {
padding-top: ${theme.spacing(0.5)};
}
.fa-star {
color: ${theme.v1.palette.orange};
}
`,
login: css`
align-self: center;
flex: auto;
display: flex;
justify-content: flex-end;
font-size: ${theme.typography.bodySmall.fontSize};
`,
time: css`
margin-left: ${theme.spacing(1)};
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)};
}
`,
card: css({
gridTemplateAreas: `"Heading Description Meta Tags"`,
gridTemplateColumns: 'auto 1fr auto auto',
padding: theme.spacing(1),
margin: theme.spacing(0.5),
width: 'inherit',
}),
meta: css({
margin: 0,
position: 'relative',
justifyContent: 'end',
}),
timestamp: css({
margin: 0,
alignSelf: 'center',
}),
time: css({
marginLeft: theme.spacing(1),
marginRight: theme.spacing(1),
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.secondary,
}),
avatar: css({
border: 'none',
background: 'inherit',
margin: 0,
padding: theme.spacing(0.5),
img: {
borderRadius: '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};
`,
};
}