[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>
Этот коммит содержится в:
Devin Binnie
2025-05-12 12:38:06 -04:00
коммит произвёл GitHub
родитель 4803892492
Коммит dfe6478fd7
13 изменённых файлов: 84 добавлений и 144 удалений

Просмотреть файл

@@ -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;