[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 // 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;