mirror of
https://github.com/grafana/grafana.git
synced 2024-11-30 04:34:23 -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 { 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>
|
||||||
);
|
);
|
||||||
|
@ -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';
|
||||||
|
|
||||||
|
@ -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: </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',
|
||||||
|
},
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
@ -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)};
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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