[MM-63000][MM-63055][MM-63046] Various accessibility fixes around Drafts (#30924)
* [MM-63000] Use CSS hover state instead of component state, add focus-within to ensure buttons are focusable * [MM-63046] Allow draft panel to be correctly focusable and navigable using correct roles * [MM-63046] Use `name` as `aria-label` for `Action` * update locator for scheduled drafts and fix failing playwright tests * fix lint * PR feedback * PR feedback --------- Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
4803892492
Коммит
dfe6478fd7
@@ -6,7 +6,7 @@ import React from 'react';
|
|||||||
// useScrollOnRender hook is used to scroll to the element when it is rendered
|
// useScrollOnRender hook is used to scroll to the element when it is rendered
|
||||||
// Attach the returned ref to the element you want to scroll to.
|
// Attach the returned ref to the element you want to scroll to.
|
||||||
export function useScrollOnRender() {
|
export function useScrollOnRender() {
|
||||||
const ref = React.useRef<HTMLElement>(null);
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ exports[`components/drafts/draft_actions/action should match snapshot 1`] = `
|
|||||||
title=""
|
title=""
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
aria-label=""
|
||||||
className="DraftAction__button"
|
className="DraftAction__button"
|
||||||
id="draft__"
|
id="draft__"
|
||||||
onClick={[MockFunction]}
|
onClick={[MockFunction]}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ function Action({
|
|||||||
)}
|
)}
|
||||||
id={`draft_${icon}_${id}`}
|
id={`draft_${icon}_${id}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
aria-label={name}
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
|||||||
@@ -373,40 +373,46 @@ function DraftRow({
|
|||||||
innerRef={scrollIntoView ? alertRef : undefined}
|
innerRef={scrollIntoView ? alertRef : undefined}
|
||||||
isHighlighted={scrollIntoView}
|
isHighlighted={scrollIntoView}
|
||||||
className={containerClassName}
|
className={containerClassName}
|
||||||
|
ariaLabel={isScheduledPost ? intl.formatMessage({
|
||||||
|
id: 'drafts.draft_row.aria_label.scheduled_post',
|
||||||
|
defaultMessage: 'scheduled post in {channelName}',
|
||||||
|
}, {
|
||||||
|
channelName: channel?.display_name,
|
||||||
|
}) : intl.formatMessage({
|
||||||
|
id: 'drafts.draft_row.aria_label.draft',
|
||||||
|
defaultMessage: 'draft in {channelName}',
|
||||||
|
}, {
|
||||||
|
channelName: channel?.display_name,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{({hover}) => (
|
<Header
|
||||||
<>
|
kind={kind}
|
||||||
<Header
|
actions={actions}
|
||||||
kind={kind}
|
title={title}
|
||||||
hover={hover}
|
timestamp={timestamp}
|
||||||
actions={actions}
|
remote={isRemote || false}
|
||||||
title={title}
|
error={postError || serverError?.message}
|
||||||
timestamp={timestamp}
|
/>
|
||||||
remote={isRemote || false}
|
{isEditing && (
|
||||||
error={postError || serverError?.message}
|
<EditScheduledPost
|
||||||
/>
|
scheduledPost={item as ScheduledPost}
|
||||||
{isEditing && (
|
onCancel={handleCancelEdit}
|
||||||
<EditScheduledPost
|
afterSave={handleCancelEdit}
|
||||||
scheduledPost={item as ScheduledPost}
|
onDeleteScheduledPost={handleSchedulePostOnDelete}
|
||||||
onCancel={handleCancelEdit}
|
/>
|
||||||
afterSave={handleCancelEdit}
|
)}
|
||||||
onDeleteScheduledPost={handleSchedulePostOnDelete}
|
{!isEditing && (
|
||||||
/>
|
<PanelBody
|
||||||
)}
|
channelId={channel?.id}
|
||||||
{!isEditing && (
|
displayName={displayName}
|
||||||
<PanelBody
|
fileInfos={fileInfos}
|
||||||
channelId={channel?.id}
|
message={item.message}
|
||||||
displayName={displayName}
|
status={status}
|
||||||
fileInfos={fileInfos}
|
priority={rootId ? undefined : item.metadata?.priority}
|
||||||
message={item.message}
|
uploadsInProgress={uploadsInProgress}
|
||||||
status={status}
|
userId={user.id}
|
||||||
priority={rootId ? undefined : item.metadata?.priority}
|
username={user.username}
|
||||||
uploadsInProgress={uploadsInProgress}
|
/>
|
||||||
userId={user.id}
|
|
||||||
username={user.username}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`components/drafts/panel/ should match snapshot 1`] = `
|
exports[`components/drafts/panel/ should match snapshot 1`] = `
|
||||||
<article
|
<div
|
||||||
className="Panel"
|
className="Panel"
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
onMouseLeave={[Function]}
|
onKeyDown={[Function]}
|
||||||
onMouseOver={[Function]}
|
role="link"
|
||||||
role="button"
|
tabIndex={0}
|
||||||
/>
|
>
|
||||||
|
<Component />
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -55,61 +55,6 @@ exports[`components/drafts/panel/panel_header should match snapshot 1`] = `
|
|||||||
</header>
|
</header>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`components/drafts/panel/panel_header should show draft actions when hovered 1`] = `
|
|
||||||
<header
|
|
||||||
className="PanelHeader"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="PanelHeader__left"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
title
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="PanelHeader__right"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="PanelHeader__actions show"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
actions
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="PanelHeader__info hide"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="PanelHeader__timestamp"
|
|
||||||
>
|
|
||||||
<Connect(injectIntl(Timestamp))
|
|
||||||
day="numeric"
|
|
||||||
units={
|
|
||||||
Array [
|
|
||||||
"now",
|
|
||||||
"minute",
|
|
||||||
"hour",
|
|
||||||
"day",
|
|
||||||
"week",
|
|
||||||
"month",
|
|
||||||
"year",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
useSemanticOutput={false}
|
|
||||||
useTime={false}
|
|
||||||
value={1970-01-01T00:00:12.345Z}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Memo(Tag)
|
|
||||||
text="draft"
|
|
||||||
uppercase={true}
|
|
||||||
variant="danger"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`components/drafts/panel/panel_header should show sync icon when draft is from server 1`] = `
|
exports[`components/drafts/panel/panel_header should show sync icon when draft is from server 1`] = `
|
||||||
<header
|
<header
|
||||||
className="PanelHeader"
|
className="PanelHeader"
|
||||||
|
|||||||
@@ -19,6 +19,16 @@
|
|||||||
transition-duration: 0.15s;
|
transition-duration: 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover, &:focus-within {
|
||||||
|
.PanelHeader__actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.PanelHeader__info {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.draftError {
|
&.draftError {
|
||||||
border-color: var(--error-text);
|
border-color: var(--error-text);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,23 @@
|
|||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, {memo, useState} from 'react';
|
import React, {memo} from 'react';
|
||||||
|
|
||||||
import {makeIsEligibleForClick} from 'utils/utils';
|
import {makeIsEligibleForClick} from 'utils/utils';
|
||||||
|
|
||||||
import './panel.scss';
|
import './panel.scss';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ({hover}: {hover: boolean}) => React.ReactNode;
|
children: React.ReactNode;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
innerRef?: React.Ref<HTMLElement>;
|
innerRef?: React.Ref<HTMLDivElement>;
|
||||||
isHighlighted?: boolean;
|
isHighlighted?: boolean;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
className?: string;
|
className?: string;
|
||||||
dataTestId?: string;
|
dataTestId?: string;
|
||||||
dataPostId?: string;
|
dataPostId?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEligibleForClick = makeIsEligibleForClick('.hljs, code');
|
const isEligibleForClick = makeIsEligibleForClick('.hljs, code');
|
||||||
@@ -32,25 +33,22 @@ function Panel({
|
|||||||
className,
|
className,
|
||||||
dataTestId,
|
dataTestId,
|
||||||
dataPostId,
|
dataPostId,
|
||||||
|
ariaLabel,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [hover, setHover] = useState(false);
|
|
||||||
|
|
||||||
const handleMouseOver = () => {
|
|
||||||
setHover(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
setHover(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOnClick = (e: React.MouseEvent<HTMLElement>) => {
|
const handleOnClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||||
if (isEligibleForClick(e)) {
|
if (isEligibleForClick(e)) {
|
||||||
onClick();
|
onClick();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOnKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<div
|
||||||
data-testid={dataTestId}
|
data-testid={dataTestId}
|
||||||
data-postid={dataPostId}
|
data-postid={dataPostId}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
@@ -62,14 +60,15 @@ function Panel({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={style}
|
style={style}
|
||||||
onMouseOver={handleMouseOver}
|
|
||||||
onClick={handleOnClick}
|
onClick={handleOnClick}
|
||||||
onMouseLeave={handleMouseLeave}
|
onKeyDown={handleOnKeyDown}
|
||||||
role='button'
|
role='link'
|
||||||
|
tabIndex={0}
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
|
aria-label={ariaLabel}
|
||||||
>
|
>
|
||||||
{children({hover})}
|
{children}
|
||||||
</article>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,19 +57,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
display: none;
|
display: inline-flex;
|
||||||
|
opacity: 0;
|
||||||
&.show {
|
|
||||||
display: inline-flex !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__info {
|
&__info {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
&.hide {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ describe('components/drafts/panel/panel_header', () => {
|
|||||||
const baseProps: React.ComponentProps<typeof PanelHeader> = {
|
const baseProps: React.ComponentProps<typeof PanelHeader> = {
|
||||||
kind: 'draft' as const,
|
kind: 'draft' as const,
|
||||||
actions: <div>{'actions'}</div>,
|
actions: <div>{'actions'}</div>,
|
||||||
hover: false,
|
|
||||||
timestamp: 12345,
|
timestamp: 12345,
|
||||||
remote: false,
|
remote: false,
|
||||||
title: <div>{'title'}</div>,
|
title: <div>{'title'}</div>,
|
||||||
@@ -46,20 +45,4 @@ describe('components/drafts/panel/panel_header', () => {
|
|||||||
expect(wrapper.find(WithTooltip).exists()).toBe(true);
|
expect(wrapper.find(WithTooltip).exists()).toBe(true);
|
||||||
expect(wrapper).toMatchSnapshot();
|
expect(wrapper).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show draft actions when hovered', () => {
|
|
||||||
const props = {
|
|
||||||
...baseProps,
|
|
||||||
hover: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapper = shallow(
|
|
||||||
<PanelHeader
|
|
||||||
{...props}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(wrapper.find('div.PanelHeader__actions').hasClass('PanelHeader__actions show')).toBe(true);
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import cn from 'classnames';
|
|
||||||
import React, {useMemo} from 'react';
|
import React, {useMemo} from 'react';
|
||||||
import type {ComponentProps} from 'react';
|
import type {ComponentProps} from 'react';
|
||||||
import {FormattedMessage} from 'react-intl';
|
import {FormattedMessage} from 'react-intl';
|
||||||
@@ -33,7 +32,6 @@ export const scheduledPostTimeFormat: ComponentProps<typeof Timestamp>['useTime'
|
|||||||
type Props = {
|
type Props = {
|
||||||
kind: 'draft' | 'scheduledPost';
|
kind: 'draft' | 'scheduledPost';
|
||||||
actions: React.ReactNode;
|
actions: React.ReactNode;
|
||||||
hover: boolean;
|
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
remote: boolean;
|
remote: boolean;
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
@@ -43,7 +41,6 @@ type Props = {
|
|||||||
function PanelHeader({
|
function PanelHeader({
|
||||||
kind,
|
kind,
|
||||||
actions,
|
actions,
|
||||||
hover,
|
|
||||||
timestamp,
|
timestamp,
|
||||||
remote,
|
remote,
|
||||||
title,
|
title,
|
||||||
@@ -55,10 +52,10 @@ function PanelHeader({
|
|||||||
<header className='PanelHeader'>
|
<header className='PanelHeader'>
|
||||||
<div className='PanelHeader__left'>{title}</div>
|
<div className='PanelHeader__left'>{title}</div>
|
||||||
<div className='PanelHeader__right'>
|
<div className='PanelHeader__right'>
|
||||||
<div className={cn('PanelHeader__actions', {show: hover})}>
|
<div className='PanelHeader__actions'>
|
||||||
{actions}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn('PanelHeader__info', {hide: hover})}>
|
<div className='PanelHeader__info'>
|
||||||
{remote && (
|
{remote && (
|
||||||
<div className='PanelHeader__sync-icon'>
|
<div className='PanelHeader__sync-icon'>
|
||||||
<WithTooltip
|
<WithTooltip
|
||||||
|
|||||||
@@ -3760,6 +3760,8 @@
|
|||||||
"drafts.confirm.send.button": "Yes, send now",
|
"drafts.confirm.send.button": "Yes, send now",
|
||||||
"drafts.confirm.send.text": "Are you sure you want to send this message to <strong>{displayName}</strong>?",
|
"drafts.confirm.send.text": "Are you sure you want to send this message to <strong>{displayName}</strong>?",
|
||||||
"drafts.confirm.send.title": "Send message now",
|
"drafts.confirm.send.title": "Send message now",
|
||||||
|
"drafts.draft_row.aria_label.draft": "draft in {channelName}",
|
||||||
|
"drafts.draft_row.aria_label.scheduled_post": "scheduled post in {channelName}",
|
||||||
"drafts.draft_title.channel": "In: {icon} <span>{channelName}</span>",
|
"drafts.draft_title.channel": "In: {icon} <span>{channelName}</span>",
|
||||||
"drafts.draft_title.channel_thread": "Thread in: {icon} <span>{channelName}</span>",
|
"drafts.draft_title.channel_thread": "Thread in: {icon} <span>{channelName}</span>",
|
||||||
"drafts.draft_title.direct_channel": "To: {icon} <span>{channelName}</span>",
|
"drafts.draft_title.direct_channel": "To: {icon} <span>{channelName}</span>",
|
||||||
|
|||||||
@@ -1537,6 +1537,7 @@ export function makeIsEligibleForClick(selector = '') {
|
|||||||
if (
|
if (
|
||||||
CLICKABLE_ELEMENTS.includes(node.tagName.toLowerCase()) ||
|
CLICKABLE_ELEMENTS.includes(node.tagName.toLowerCase()) ||
|
||||||
node.getAttribute('role') === 'button' ||
|
node.getAttribute('role') === 'button' ||
|
||||||
|
node.getAttribute('role') === 'link' ||
|
||||||
(selector && node.matches(selector))
|
(selector && node.matches(selector))
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user