mirror of
https://github.com/grafana/grafana.git
synced 2024-11-29 20:24:18 -06:00
AnnoListPanel: Add keyboard accessibility (#44280)
This commit is contained in:
parent
e93e1bdd2b
commit
8218d81f0e
@ -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>
|
||||
);
|
||||
|
@ -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 }) => {
|
||||
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">
|
||||
<ul className={cx(styles.wrapper, className)} aria-label="Tags" ref={ref}>
|
||||
{tagsToDisplay.map((tag, i) => (
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
TagList.displayName = 'TagList';
|
||||
|
||||
|
@ -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: </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',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
@ -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}>
|
||||
<Card className={styles.card} onClick={onItemClick}>
|
||||
<Card.Heading>
|
||||
<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.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}>
|
||||
<button onClick={onAvatarClick} className={styles.avatar} aria-label={`Created by ${email}`}>
|
||||
<img src={avatarUrl} alt="avatar icon" />
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
@ -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};
|
||||
`,
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue
Block a user