[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
|
||||
// Attach the returned ref to the element you want to scroll to.
|
||||
export function useScrollOnRender() {
|
||||
const ref = React.useRef<HTMLElement>(null);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ref.current) {
|
||||
|
||||
@@ -8,6 +8,7 @@ exports[`components/drafts/draft_actions/action should match snapshot 1`] = `
|
||||
title=""
|
||||
>
|
||||
<button
|
||||
aria-label=""
|
||||
className="DraftAction__button"
|
||||
id="draft__"
|
||||
onClick={[MockFunction]}
|
||||
|
||||
@@ -35,6 +35,7 @@ function Action({
|
||||
)}
|
||||
id={`draft_${icon}_${id}`}
|
||||
onClick={onClick}
|
||||
aria-label={name}
|
||||
>
|
||||
<i
|
||||
className={classNames(
|
||||
|
||||
@@ -373,40 +373,46 @@ function DraftRow({
|
||||
innerRef={scrollIntoView ? alertRef : undefined}
|
||||
isHighlighted={scrollIntoView}
|
||||
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}
|
||||
hover={hover}
|
||||
actions={actions}
|
||||
title={title}
|
||||
timestamp={timestamp}
|
||||
remote={isRemote || false}
|
||||
error={postError || serverError?.message}
|
||||
/>
|
||||
{isEditing && (
|
||||
<EditScheduledPost
|
||||
scheduledPost={item as ScheduledPost}
|
||||
onCancel={handleCancelEdit}
|
||||
afterSave={handleCancelEdit}
|
||||
onDeleteScheduledPost={handleSchedulePostOnDelete}
|
||||
/>
|
||||
)}
|
||||
{!isEditing && (
|
||||
<PanelBody
|
||||
channelId={channel?.id}
|
||||
displayName={displayName}
|
||||
fileInfos={fileInfos}
|
||||
message={item.message}
|
||||
status={status}
|
||||
priority={rootId ? undefined : item.metadata?.priority}
|
||||
uploadsInProgress={uploadsInProgress}
|
||||
userId={user.id}
|
||||
username={user.username}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<Header
|
||||
kind={kind}
|
||||
actions={actions}
|
||||
title={title}
|
||||
timestamp={timestamp}
|
||||
remote={isRemote || false}
|
||||
error={postError || serverError?.message}
|
||||
/>
|
||||
{isEditing && (
|
||||
<EditScheduledPost
|
||||
scheduledPost={item as ScheduledPost}
|
||||
onCancel={handleCancelEdit}
|
||||
afterSave={handleCancelEdit}
|
||||
onDeleteScheduledPost={handleSchedulePostOnDelete}
|
||||
/>
|
||||
)}
|
||||
{!isEditing && (
|
||||
<PanelBody
|
||||
channelId={channel?.id}
|
||||
displayName={displayName}
|
||||
fileInfos={fileInfos}
|
||||
message={item.message}
|
||||
status={status}
|
||||
priority={rootId ? undefined : item.metadata?.priority}
|
||||
uploadsInProgress={uploadsInProgress}
|
||||
userId={user.id}
|
||||
username={user.username}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/drafts/panel/ should match snapshot 1`] = `
|
||||
<article
|
||||
<div
|
||||
className="Panel"
|
||||
onClick={[Function]}
|
||||
onMouseLeave={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
role="button"
|
||||
/>
|
||||
onKeyDown={[Function]}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Component />
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -55,61 +55,6 @@ exports[`components/drafts/panel/panel_header should match snapshot 1`] = `
|
||||
</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`] = `
|
||||
<header
|
||||
className="PanelHeader"
|
||||
|
||||
@@ -19,6 +19,16 @@
|
||||
transition-duration: 0.15s;
|
||||
}
|
||||
|
||||
&:hover, &:focus-within {
|
||||
.PanelHeader__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.PanelHeader__info {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.draftError {
|
||||
border-color: var(--error-text);
|
||||
}
|
||||
|
||||
@@ -2,22 +2,23 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React, {memo, useState} from 'react';
|
||||
import React, {memo} from 'react';
|
||||
|
||||
import {makeIsEligibleForClick} from 'utils/utils';
|
||||
|
||||
import './panel.scss';
|
||||
|
||||
type Props = {
|
||||
children: ({hover}: {hover: boolean}) => React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
hasError: boolean;
|
||||
innerRef?: React.Ref<HTMLElement>;
|
||||
innerRef?: React.Ref<HTMLDivElement>;
|
||||
isHighlighted?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
dataTestId?: string;
|
||||
dataPostId?: string;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
const isEligibleForClick = makeIsEligibleForClick('.hljs, code');
|
||||
@@ -32,25 +33,22 @@ function Panel({
|
||||
className,
|
||||
dataTestId,
|
||||
dataPostId,
|
||||
ariaLabel,
|
||||
}: Props) {
|
||||
const [hover, setHover] = useState(false);
|
||||
|
||||
const handleMouseOver = () => {
|
||||
setHover(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHover(false);
|
||||
};
|
||||
|
||||
const handleOnClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
if (isEligibleForClick(e)) {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
<div
|
||||
data-testid={dataTestId}
|
||||
data-postid={dataPostId}
|
||||
className={classNames(
|
||||
@@ -62,14 +60,15 @@ function Panel({
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
onMouseOver={handleMouseOver}
|
||||
onClick={handleOnClick}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
role='button'
|
||||
onKeyDown={handleOnKeyDown}
|
||||
role='link'
|
||||
tabIndex={0}
|
||||
ref={innerRef}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children({hover})}
|
||||
</article>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,19 +57,12 @@
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: none;
|
||||
|
||||
&.show {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
display: inline-flex;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&.hide {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ describe('components/drafts/panel/panel_header', () => {
|
||||
const baseProps: React.ComponentProps<typeof PanelHeader> = {
|
||||
kind: 'draft' as const,
|
||||
actions: <div>{'actions'}</div>,
|
||||
hover: false,
|
||||
timestamp: 12345,
|
||||
remote: false,
|
||||
title: <div>{'title'}</div>,
|
||||
@@ -46,20 +45,4 @@ describe('components/drafts/panel/panel_header', () => {
|
||||
expect(wrapper.find(WithTooltip).exists()).toBe(true);
|
||||
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.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import cn from 'classnames';
|
||||
import React, {useMemo} from 'react';
|
||||
import type {ComponentProps} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
@@ -33,7 +32,6 @@ export const scheduledPostTimeFormat: ComponentProps<typeof Timestamp>['useTime'
|
||||
type Props = {
|
||||
kind: 'draft' | 'scheduledPost';
|
||||
actions: React.ReactNode;
|
||||
hover: boolean;
|
||||
timestamp: number;
|
||||
remote: boolean;
|
||||
title: React.ReactNode;
|
||||
@@ -43,7 +41,6 @@ type Props = {
|
||||
function PanelHeader({
|
||||
kind,
|
||||
actions,
|
||||
hover,
|
||||
timestamp,
|
||||
remote,
|
||||
title,
|
||||
@@ -55,10 +52,10 @@ function PanelHeader({
|
||||
<header className='PanelHeader'>
|
||||
<div className='PanelHeader__left'>{title}</div>
|
||||
<div className='PanelHeader__right'>
|
||||
<div className={cn('PanelHeader__actions', {show: hover})}>
|
||||
<div className='PanelHeader__actions'>
|
||||
{actions}
|
||||
</div>
|
||||
<div className={cn('PanelHeader__info', {hide: hover})}>
|
||||
<div className='PanelHeader__info'>
|
||||
{remote && (
|
||||
<div className='PanelHeader__sync-icon'>
|
||||
<WithTooltip
|
||||
|
||||
@@ -3760,6 +3760,8 @@
|
||||
"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.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_thread": "Thread in: {icon} <span>{channelName}</span>",
|
||||
"drafts.draft_title.direct_channel": "To: {icon} <span>{channelName}</span>",
|
||||
|
||||
@@ -1537,6 +1537,7 @@ export function makeIsEligibleForClick(selector = '') {
|
||||
if (
|
||||
CLICKABLE_ELEMENTS.includes(node.tagName.toLowerCase()) ||
|
||||
node.getAttribute('role') === 'button' ||
|
||||
node.getAttribute('role') === 'link' ||
|
||||
(selector && node.matches(selector))
|
||||
) {
|
||||
return false;
|
||||
|
||||
Ссылка в новой задаче
Block a user