[MM-62989][MM-63039][MM-63033][MM-64174][MM-63040][MM-63032][MM-63001][MM-63006][MM-63037] Various accessibility fixes for the Create Channel modal (#30888)

* [MM-62989] Replace channel purpose input with Input component

* [MM-63039] Fix styles so that a11y--focused box shadow is applied to the private/public channel buttons

* [MM-63033] Add aria-describedby and role=alert to the URL input on create channel modal

* Fix lint

* [MM-63035] Change legend to label, fieldset to div in the Input component

* Fix i18n

* [MM-64174] Stop propagation of enter event while editing the new channel

* [MM-63037] Add role=img and an aria label to the error img icon

* Fix snap

* Revert "[MM-63035] Change legend to label, fieldset to div in the Input component"

This reverts commit e8516f3e6a266c77db2b1695a036db717041a9ef.

* [MM-63040] Remove tabindex=0 from GenericModal wrapper, fix issue with URLInput that I caused D:

* Fix snap

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Devin Binnie
2025-06-18 16:29:04 -04:00
коммит произвёл GitHub
родитель 1efc3dacaf
Коммит bd16f4f9bf
12 изменённых файлов: 43 добавлений и 56 удалений

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

@@ -35,7 +35,6 @@ Object {
> >
<div <div
class="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher" class="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
tabindex="0"
> >
<div <div
class="modal-header" class="modal-header"
@@ -519,7 +518,6 @@ Object {
> >
<div <div
class="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher" class="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
tabindex="0"
> >
<div <div
class="modal-header" class="modal-header"
@@ -1163,7 +1161,6 @@ Object {
> >
<div <div
class="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher" class="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
tabindex="0"
> >
<div <div
class="modal-header" class="modal-header"
@@ -1526,7 +1523,6 @@ Object {
> >
<div <div
class="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher" class="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
tabindex="0"
> >
<div <div
class="modal-header" class="modal-header"
@@ -2108,7 +2104,6 @@ Object {
> >
<div <div
class="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher" class="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
tabindex="0"
> >
<div <div
class="modal-header" class="modal-header"
@@ -2690,7 +2685,6 @@ Object {
> >
<div <div
class="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher" class="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
tabindex="0"
> >
<div <div
class="modal-header" class="modal-header"

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

@@ -9,15 +9,17 @@
.new-channel-modal-purpose-textarea { .new-channel-modal-purpose-textarea {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 12px 16px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
background: var(--center-channel-bg); background: var(--center-channel-bg);
color: var(--center-channel-color); color: var(--center-channel-color);
font-size: 14px; font-size: 14px;
line-height: 20px; line-height: 20px;
resize: none; resize: none;
.Input_wrapper textarea.form-control {
height: auto;
border: none;
}
&:hover { &:hover {
border-color: rgba(var(--center-channel-color-rgb), 0.48); border-color: rgba(var(--center-channel-color-rgb), 0.48);
} }
@@ -43,26 +45,6 @@
line-height: 16px; line-height: 16px;
text-align: left; text-align: left;
} }
.new-channel-modal-purpose-error {
display: flex;
margin-top: 5px;
color: var(--error-text);
font-size: 12px;
line-height: 16px;
text-align: left;
i {
height: 14px;
align-self: baseline;
margin-right: 7px;
font-size: 14px;
&::before {
margin: 0;
}
}
}
} }
} }

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

@@ -145,9 +145,9 @@ describe('components/new_channel_modal', () => {
expect(privateChannelHeading).toBeInTheDocument(); expect(privateChannelHeading).toBeInTheDocument();
expect(privateChannelHeading.nextSibling).toHaveTextContent('Only invited members'); expect(privateChannelHeading.nextSibling).toHaveTextContent('Only invited members');
const purposeTextArea = screen.getByPlaceholderText('Enter a purpose for this channel (optional)'); const purposeTextArea = screen.getByLabelText('Channel Purpose');
expect(purposeTextArea).toBeInTheDocument(); expect(purposeTextArea).toBeInTheDocument();
expect(purposeTextArea).toHaveClass('new-channel-modal-purpose-textarea'); expect(purposeTextArea).toHaveClass('Input form-control medium');
const purposeDesc = screen.getByText('This will be displayed when browsing for channels.'); const purposeDesc = screen.getByText('This will be displayed when browsing for channels.');
expect(purposeDesc).toBeInTheDocument(); expect(purposeDesc).toBeInTheDocument();
@@ -263,7 +263,7 @@ describe('components/new_channel_modal', () => {
); );
// Change purpose // Change purpose
const ChannelPurposeTextArea = screen.getByPlaceholderText('Enter a purpose for this channel (optional)'); const ChannelPurposeTextArea = screen.getByLabelText('Channel Purpose');
expect(ChannelPurposeTextArea).toBeInTheDocument(); expect(ChannelPurposeTextArea).toBeInTheDocument();
userEvent.click(ChannelPurposeTextArea); userEvent.click(ChannelPurposeTextArea);

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

@@ -23,6 +23,7 @@ import {switchToChannel} from 'actions/views/channel';
import {closeModal} from 'actions/views/modals'; import {closeModal} from 'actions/views/modals';
import ChannelNameFormField from 'components/channel_name_form_field/channel_name_form_field'; import ChannelNameFormField from 'components/channel_name_form_field/channel_name_form_field';
import Input from 'components/widgets/inputs/input/input';
import PublicPrivateSelector from 'components/widgets/public-private-selector/public-private-selector'; import PublicPrivateSelector from 'components/widgets/public-private-selector/public-private-selector';
import WithTooltip from 'components/with_tooltip'; import WithTooltip from 'components/with_tooltip';
@@ -283,16 +284,18 @@ const NewChannelModal = () => {
onChange={handleOnTypeChange} onChange={handleOnTypeChange}
/> />
<div className='new-channel-modal-purpose-container'> <div className='new-channel-modal-purpose-container'>
<textarea <Input
id='new-channel-modal-purpose' id='new-channel-modal-purpose'
className={classNames('new-channel-modal-purpose-textarea', {'with-error': purposeError})} type='textarea'
placeholder={formatMessage({id: 'channel_modal.purpose.placeholder', defaultMessage: 'Enter a purpose for this channel (optional)'})}
rows={4}
maxLength={Constants.MAX_CHANNELPURPOSE_LENGTH}
autoComplete='off'
value={purpose} value={purpose}
onChange={handleOnPurposeChange} onChange={handleOnPurposeChange}
onKeyDown={handleOnPurposeKeyDown} onKeyDown={handleOnPurposeKeyDown}
label={formatMessage({id: 'channel_modal.purpose.label', defaultMessage: 'Channel Purpose'})}
placeholder={formatMessage({id: 'channel_modal.purpose.placeholder', defaultMessage: 'Enter a purpose for this channel (optional)'})}
maxLength={Constants.MAX_CHANNELPURPOSE_LENGTH}
autoComplete='off'
className={classNames('new-channel-modal-purpose-textarea', {'with-error': purposeError})}
rows={4}
/> />
{purposeError ? ( {purposeError ? (
<div className='new-channel-modal-purpose-error'> <div className='new-channel-modal-purpose-error'>

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

@@ -36,7 +36,6 @@ exports[`components/marketplace/ doesn't show web marketplace banner in FeatureF
<div <div
className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher" className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
onKeyDown={[Function]} onKeyDown={[Function]}
tabIndex={0}
> >
<ModalHeader <ModalHeader
bsClass="modal-header" bsClass="modal-header"
@@ -148,7 +147,6 @@ exports[`components/marketplace/ hides search, shows web marketplace banner in F
<div <div
className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher" className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
onKeyDown={[Function]} onKeyDown={[Function]}
tabIndex={0}
> >
<ModalHeader <ModalHeader
bsClass="modal-header" bsClass="modal-header"
@@ -261,7 +259,6 @@ exports[`components/marketplace/ should render default 1`] = `
<div <div
className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher" className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
onKeyDown={[Function]} onKeyDown={[Function]}
tabIndex={0}
> >
<ModalHeader <ModalHeader
bsClass="modal-header" bsClass="modal-header"
@@ -425,7 +422,6 @@ exports[`components/marketplace/ should render with error banner 1`] = `
<div <div
className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher" className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
onKeyDown={[Function]} onKeyDown={[Function]}
tabIndex={0}
> >
<ModalHeader <ModalHeader
bsClass="modal-header" bsClass="modal-header"
@@ -606,7 +602,6 @@ exports[`components/marketplace/ should render with no plugins available 1`] = `
<div <div
className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher" className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
onKeyDown={[Function]} onKeyDown={[Function]}
tabIndex={0}
> >
<ModalHeader <ModalHeader
bsClass="modal-header" bsClass="modal-header"
@@ -778,7 +773,6 @@ exports[`components/marketplace/ should render with plugins available 1`] = `
<div <div
className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher" className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
onKeyDown={[Function]} onKeyDown={[Function]}
tabIndex={0}
> >
<ModalHeader <ModalHeader
bsClass="modal-header" bsClass="modal-header"
@@ -968,7 +962,6 @@ exports[`components/marketplace/ should render with plugins installed 1`] = `
<div <div
className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher" className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
onKeyDown={[Function]} onKeyDown={[Function]}
tabIndex={0}
> >
<ModalHeader <ModalHeader
bsClass="modal-header" bsClass="modal-header"

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

@@ -125,6 +125,7 @@ Object {
aria-hidden="true" aria-hidden="true"
aria-label="" aria-label=""
class="icon error icon-alert-circle-outline" class="icon error icon-alert-circle-outline"
role="img"
/> />
<span> <span>
Please enter a valid business email address. Please enter a valid business email address.

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

@@ -42,6 +42,7 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
clearable?: boolean; clearable?: boolean;
clearableTooltipText?: string; clearableTooltipText?: string;
onClear?: () => void; onClear?: () => void;
rows?: number;
validate?: (value: React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>['value']) => CustomMessageInputType | undefined; validate?: (value: React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>['value']) => CustomMessageInputType | undefined;
} }
@@ -74,6 +75,7 @@ const Input = React.forwardRef((
onBlur, onBlur,
onChange, onChange,
onClear, onClear,
rows,
validate, validate,
...otherProps ...otherProps
}: InputProps, }: InputProps,
@@ -223,7 +225,7 @@ const Input = React.forwardRef((
aria-label={ariaLabel} aria-label={ariaLabel}
aria-describedby={customInputLabel ? errorId : undefined} aria-describedby={customInputLabel ? errorId : undefined}
aria-invalid={error || hasError} aria-invalid={error || hasError}
rows={3} rows={rows || 3}
name={name} name={name}
disabled={disabled} disabled={disabled}
{...otherProps} {...otherProps}
@@ -291,8 +293,9 @@ const Input = React.forwardRef((
'icon-information-outline': (customInputLabel?.type || 'error') === ItemStatus.INFO, 'icon-information-outline': (customInputLabel?.type || 'error') === ItemStatus.INFO,
'icon-check': (customInputLabel?.type || 'error') === ItemStatus.SUCCESS, 'icon-check': (customInputLabel?.type || 'error') === ItemStatus.SUCCESS,
})} })}
aria-hidden={Boolean(customInputLabel.value)} role='img'
aria-label={customInputLabel.value ? '' : customInputLabel.type || 'error'} aria-label={customInputLabel.value ? '' : customInputLabel.type || 'error'}
aria-hidden={Boolean(customInputLabel.value)}
/> />
<span>{customInputLabel?.value}</span> <span>{customInputLabel?.value}</span>
</div> </div>

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

@@ -70,13 +70,17 @@ function UrlInput({
onBlur(event); onBlur(event);
} }
}; };
const handleOnButtonClick = () => { const handleOnButtonClick = () => {
if (!hasError) { if (!hasError) {
setEditing(!editing); setEditing(!editing);
} }
}; };
const handlePropagateKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter') {
event.stopPropagation();
}
};
const urlInputLabel = ( const urlInputLabel = (
<span <span
className='url-input-label' className='url-input-label'
@@ -116,12 +120,14 @@ function UrlInput({
hasError={hasError} hasError={hasError}
onChange={handleOnInputChange} onChange={handleOnInputChange}
onBlur={handleOnInputBlur} onBlur={handleOnInputBlur}
aria-describedby='url-input-error'
/> />
)} )}
<button <button
className={classNames('url-input-button', {disabled: hasError})} className={classNames('url-input-button', {disabled: hasError})}
disabled={hasError} disabled={hasError}
onClick={handleOnButtonClick} onClick={handleOnButtonClick}
onKeyDown={handlePropagateKeyDown}
> >
<span className='url-input-button-label'> <span className='url-input-button-label'>
{editing ? formatMessage({id: 'url_input.buttonLabel.done', defaultMessage: 'Done'}) : formatMessage({id: 'url_input.buttonLabel.edit', defaultMessage: 'Edit'})} {editing ? formatMessage({id: 'url_input.buttonLabel.done', defaultMessage: 'Done'}) : formatMessage({id: 'url_input.buttonLabel.edit', defaultMessage: 'Edit'})}
@@ -131,7 +137,12 @@ function UrlInput({
{error && ( {error && (
<div className='url-input-error'> <div className='url-input-error'>
<i className='icon icon-alert-outline'/> <i className='icon icon-alert-outline'/>
<span>{error}</span> <span
id='url-input-error'
role='alert'
>
{error}
</span>
</div> </div>
)} )}
</div> </div>

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

@@ -12,14 +12,16 @@
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px; border-radius: 4px;
background: var(--center-channel-bg); background: var(--center-channel-bg);
box-shadow: var(--elevation-1);
&:not(.a11y--focused) {
box-shadow: var(--elevation-1);
}
&:not(:first-child) { &:not(:first-child) {
margin-left: 8px; margin-left: 8px;
} }
&.a11y--focused, &:not(.a11y--focused):hover {
&:hover {
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
box-shadow: var(--elevation-2); box-shadow: var(--elevation-2);
} }
@@ -32,7 +34,7 @@
fill: var(--button-bg); fill: var(--button-bg);
} }
&:hover { &:not(.a11y--focused):hover {
border: 1px solid rgba(var(--sidebar-text-active-border-rgb), 0.4); border: 1px solid rgba(var(--sidebar-text-active-border-rgb), 0.4);
box-shadow: var(--elevation-2); box-shadow: var(--elevation-2);
} }

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

@@ -3609,6 +3609,7 @@
"channel_modal.name.placeholder": "Enter a name for your new channel", "channel_modal.name.placeholder": "Enter a name for your new channel",
"channel_modal.name.shorter": "Channel names must have maximum 64 characters.", "channel_modal.name.shorter": "Channel names must have maximum 64 characters.",
"channel_modal.purpose.info": "This will be displayed when browsing for channels.", "channel_modal.purpose.info": "This will be displayed when browsing for channels.",
"channel_modal.purpose.label": "Channel Purpose",
"channel_modal.purpose.placeholder": "Enter a purpose for this channel (optional)", "channel_modal.purpose.placeholder": "Enter a purpose for this channel (optional)",
"channel_modal.purposeTooLong": "The purpose exceeds the maximum of 250 characters", "channel_modal.purposeTooLong": "The purpose exceeds the maximum of 250 characters",
"channel_modal.type.private.description": "Only invited members", "channel_modal.type.private.description": "Only invited members",

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

@@ -34,7 +34,6 @@ exports[`GenericModal should match snapshot for base case 1`] = `
> >
<div <div
class="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher" class="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
tabindex="0"
> >
<div <div
class="modal-header" class="modal-header"

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

@@ -115,7 +115,6 @@ export const GenericModal: React.FC<Props> = ({
compassDesign, compassDesign,
backdrop, backdrop,
backdropClassName, backdropClassName,
tabIndex,
children, children,
autoFocusConfirmButton, autoFocusConfirmButton,
headerInput, headerInput,
@@ -288,7 +287,6 @@ export const GenericModal: React.FC<Props> = ({
<div <div
ref={containerRef} ref={containerRef}
onKeyDown={onEnterKeyDown} onKeyDown={onEnterKeyDown}
tabIndex={tabIndex || 0}
className='GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher' className='GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher'
> >
{showHeader && ( {showHeader && (