[MM-63035][MM-63970][MM-63978][MM-64028][MM-64019][MM-64022][MM-64026][MM-64023][MM-61625] Create User Group modal accessibility fixes (#31047)

* [MM-63970] Add aria-live for creating a group, always use modal animation

* [MM-63978] Move my fix for multiselect remove button focus to the multiselect component

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

* [MM-64019] Add fieldset and legend to Add people

* [MM-64026] Add required to Name and Mention boxes

* [MM-64023] Add results available aria-live area to multiselect

* [MM-64022] Restore original multivalueremove for users_email_input, use ariaLabelRenderer for Multiselect

* Use noteText for required for now

* Fix

* Fix e2e

* Only show required prompt when there are no users picked

* Fix cancel button submitting form

* PR feedback

* Do the required field for multiselect properly

* Other PR feedback

* Fix checks

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Devin Binnie
2025-06-18 09:25:13 -04:00
коммит произвёл GitHub
родитель 3d2aa70b7b
Коммит 26613fb4c6
29 изменённых файлов: 319 добавлений и 222 удалений

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

@@ -71,7 +71,7 @@ describe('Verify Accessibility Support in Modals & Dialogs', () => {
}); });
// * Verify if the reader is able to read out the selected row // * Verify if the reader is able to read out the selected row
cy.get('.filtered-user-list .sr-only'). cy.get('.filtered-user-list div.sr-only:not([role="status"])').
should('have.attr', 'aria-live', 'polite'). should('have.attr', 'aria-live', 'polite').
and('have.attr', 'aria-atomic', 'true'). and('have.attr', 'aria-atomic', 'true').
invoke('text').then((text) => { invoke('text').then((text) => {
@@ -181,7 +181,7 @@ describe('Verify Accessibility Support in Modals & Dialogs', () => {
}); });
// * Verify if the reader is able to read out the selected row // * Verify if the reader is able to read out the selected row
cy.get('.filtered-user-list .sr-only'). cy.get('.filtered-user-list div.sr-only:not([role="status"])').
should('have.attr', 'aria-live', 'polite'). should('have.attr', 'aria-live', 'polite').
and('have.attr', 'aria-atomic', 'true'). and('have.attr', 'aria-atomic', 'true').
invoke('text').then((text) => { invoke('text').then((text) => {

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

@@ -103,6 +103,7 @@ exports[`components/AddGroupsToChannelModal should match snapshot 1`] = `
"id": "multiselect.addGroupsPlaceholder", "id": "multiselect.addGroupsPlaceholder",
} }
} }
required={false}
saveButtonPosition="top" saveButtonPosition="top"
saving={false} saving={false}
savingEnabled={true} savingEnabled={true}

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

@@ -140,6 +140,7 @@ exports[`components/AddGroupsToTeamModal should match snapshot 1`] = `
"id": "multiselect.addGroupsPlaceholder", "id": "multiselect.addGroupsPlaceholder",
} }
} }
required={false}
saveButtonPosition="top" saveButtonPosition="top"
saving={false} saving={false}
savingEnabled={true} savingEnabled={true}

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

@@ -76,6 +76,7 @@ exports[`component/add_user_to_group_multiselect should match snapshot with diff
"id": "multiselect.placeholder", "id": "multiselect.placeholder",
} }
} }
required={true}
saveButtonPosition="bottom" saveButtonPosition="bottom"
saving={false} saving={false}
savingEnabled={false} savingEnabled={false}
@@ -175,6 +176,7 @@ exports[`component/add_user_to_group_multiselect should match snapshot with prof
"id": "multiselect.placeholder", "id": "multiselect.placeholder",
} }
} }
required={true}
saveButtonPosition="bottom" saveButtonPosition="bottom"
saving={false} saving={false}
savingEnabled={false} savingEnabled={false}
@@ -259,6 +261,7 @@ exports[`component/add_user_to_group_multiselect should match snapshot without a
"id": "multiselect.placeholder", "id": "multiselect.placeholder",
} }
} }
required={true}
saveButtonPosition="bottom" saveButtonPosition="bottom"
saving={false} saving={false}
savingEnabled={false} savingEnabled={false}

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

@@ -245,6 +245,7 @@ export class AddUserToGroupMultiSelect extends React.PureComponent<Props, State>
backButtonText={this.props.backButtonText} backButtonText={this.props.backButtonText}
maxValues={maxValues} maxValues={maxValues}
numRemainingText={numRemainingText} numRemainingText={numRemainingText}
required={true}
/> />
); );
}; };

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

@@ -225,6 +225,7 @@ exports[`components/admin_console/add_users_to_team_modal/AddUsersToTeamModal sh
"id": "multiselect.placeholder", "id": "multiselect.placeholder",
} }
} }
required={false}
saveButtonPosition="top" saveButtonPosition="top"
saving={false} saving={false}
savingEnabled={true} savingEnabled={true}
@@ -466,6 +467,7 @@ exports[`components/admin_console/add_users_to_team_modal/AddUsersToTeamModal sh
"id": "multiselect.placeholder", "id": "multiselect.placeholder",
} }
} }
required={false}
saveButtonPosition="top" saveButtonPosition="top"
saving={false} saving={false}
savingEnabled={true} savingEnabled={true}

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

@@ -84,7 +84,7 @@
} }
&.Input_fieldset___legend { &.Input_fieldset___legend {
>legend { >label {
margin-left: 11px; margin-left: 11px;
} }
} }

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

@@ -184,6 +184,7 @@ exports[`admin_console/add_users_to_role_modal search should not include bot use
"id": "multiselect.placeholder", "id": "multiselect.placeholder",
} }
} }
required={false}
saveButtonPosition="top" saveButtonPosition="top"
saving={false} saving={false}
savingEnabled={true} savingEnabled={true}
@@ -335,6 +336,7 @@ exports[`admin_console/add_users_to_role_modal should exclude user 1`] = `
"id": "multiselect.placeholder", "id": "multiselect.placeholder",
} }
} }
required={false}
saveButtonPosition="top" saveButtonPosition="top"
saving={false} saving={false}
savingEnabled={true} savingEnabled={true}
@@ -530,6 +532,7 @@ exports[`admin_console/add_users_to_role_modal should have single passed value 1
"id": "multiselect.placeholder", "id": "multiselect.placeholder",
} }
} }
required={false}
saveButtonPosition="top" saveButtonPosition="top"
saving={false} saving={false}
savingEnabled={true} savingEnabled={true}
@@ -766,6 +769,7 @@ exports[`admin_console/add_users_to_role_modal should include additional user 1`
"id": "multiselect.placeholder", "id": "multiselect.placeholder",
} }
} }
required={false}
saveButtonPosition="top" saveButtonPosition="top"
saving={false} saving={false}
savingEnabled={true} savingEnabled={true}
@@ -1002,6 +1006,7 @@ exports[`admin_console/add_users_to_role_modal should include additional user 2`
"id": "multiselect.placeholder", "id": "multiselect.placeholder",
} }
} }
required={false}
saveButtonPosition="top" saveButtonPosition="top"
saving={false} saving={false}
savingEnabled={true} savingEnabled={true}
@@ -1197,6 +1202,7 @@ exports[`admin_console/add_users_to_role_modal should not include bot user 1`] =
"id": "multiselect.placeholder", "id": "multiselect.placeholder",
} }
} }
required={false}
saveButtonPosition="top" saveButtonPosition="top"
saving={false} saving={false}
savingEnabled={true} savingEnabled={true}

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

@@ -106,6 +106,7 @@ exports[`components/channel_invite_modal should match snapshot for channel_invit
"id": "multiselect.placeholder.peopleOrGroups", "id": "multiselect.placeholder.peopleOrGroups",
} }
} }
required={false}
saveButtonPosition="bottom" saveButtonPosition="bottom"
saving={false} saving={false}
savingEnabled={true} savingEnabled={true}
@@ -233,6 +234,7 @@ exports[`components/channel_invite_modal should match snapshot for channel_invit
"id": "multiselect.placeholder.peopleOrGroups", "id": "multiselect.placeholder.peopleOrGroups",
} }
} }
required={false}
saveButtonPosition="bottom" saveButtonPosition="bottom"
saving={false} saving={false}
savingEnabled={true} savingEnabled={true}
@@ -360,6 +362,7 @@ exports[`components/channel_invite_modal should match snapshot for channel_invit
"id": "multiselect.placeholder.peopleOrGroups", "id": "multiselect.placeholder.peopleOrGroups",
} }
} }
required={false}
saveButtonPosition="bottom" saveButtonPosition="bottom"
saving={false} saving={false}
savingEnabled={true} savingEnabled={true}
@@ -487,6 +490,7 @@ exports[`components/channel_invite_modal should match snapshot with exclude and
"id": "multiselect.placeholder.peopleOrGroups", "id": "multiselect.placeholder.peopleOrGroups",
} }
} }
required={false}
saveButtonPosition="bottom" saveButtonPosition="bottom"
saving={false} saving={false}
savingEnabled={true} savingEnabled={true}

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

@@ -11,7 +11,7 @@
&__content { &__content {
padding: 0; padding: 0;
.multi-select__container { .multi-select__container, .multi-select__error {
padding: 0 !important; padding: 0 !important;
} }

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

@@ -149,6 +149,7 @@ exports[`components/ChannelSelectorModal exclude already selected 1`] = `
"id": "multiselect.addChannelsPlaceholder", "id": "multiselect.addChannelsPlaceholder",
} }
} }
required={false}
saveButtonPosition="top" saveButtonPosition="top"
saving={false} saving={false}
savingEnabled={true} savingEnabled={true}
@@ -336,6 +337,7 @@ exports[`components/ChannelSelectorModal should match snapshot 1`] = `
"id": "multiselect.addChannelsPlaceholder", "id": "multiselect.addChannelsPlaceholder",
} }
} }
required={false}
saveButtonPosition="top" saveButtonPosition="top"
saving={false} saving={false}
savingEnabled={true} savingEnabled={true}

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

@@ -24,13 +24,18 @@ exports[`component/create_user_groups_modal should match snapshot with back butt
"remove": [Function], "remove": [Function],
} }
} }
onExited={[MockFunction]} onExited={[Function]}
onHide={[Function]} onHide={[Function]}
renderBackdrop={[Function]} renderBackdrop={[Function]}
restoreFocus={true} restoreFocus={true}
role="none" role="none"
show={true} show={true}
> >
<div
aria-live="polite"
className="sr-only"
role="alert"
/>
<ModalHeader <ModalHeader
bsClass="modal-header" bsClass="modal-header"
closeButton={true} closeButton={true}
@@ -88,6 +93,7 @@ exports[`component/create_user_groups_modal should match snapshot with back butt
"id": "user_groups_modal.name", "id": "user_groups_modal.name",
} }
} }
required={true}
type="text" type="text"
value="" value=""
/> />
@@ -111,37 +117,42 @@ exports[`component/create_user_groups_modal should match snapshot with back butt
"id": "user_groups_modal.mention", "id": "user_groups_modal.mention",
} }
} }
required={true}
type="text" type="text"
value="" value=""
/> />
</div> </div>
<h2> <fieldset
<MemoizedFormattedMessage className="group-add-people-fieldset"
defaultMessage="Add People"
id="user_groups_modal.addPeople"
/>
</h2>
<div
className="group-add-user"
> >
<Connect(injectIntl(AddUserToGroupMultiSelect)) <legend>
addUserCallback={[Function]} <MemoizedFormattedMessage
backButtonClass="multiselect-back" defaultMessage="Add People"
backButtonClick={[Function]} id="user_groups_modal.addPeople"
backButtonText={ />
Object { </legend>
"defaultMessage": "Cancel", <div
"id": "multiselect.cancelButton", className="group-add-user"
>
<Connect(injectIntl(AddUserToGroupMultiSelect))
addUserCallback={[Function]}
backButtonClass="multiselect-back"
backButtonClick={[Function]}
backButtonText={
Object {
"defaultMessage": "Cancel",
"id": "multiselect.cancelButton",
}
} }
} deleteUserCallback={[Function]}
deleteUserCallback={[Function]} focusOnLoad={false}
focusOnLoad={false} multilSelectKey="addUsersToGroupKey"
multilSelectKey="addUsersToGroupKey" onSubmitCallback={[Function]}
onSubmitCallback={[Function]} saving={false}
saving={false} savingEnabled={false}
savingEnabled={false} />
/> </div>
</div> </fieldset>
</div> </div>
</ModalBody> </ModalBody>
</Modal> </Modal>
@@ -171,13 +182,18 @@ exports[`component/create_user_groups_modal should match snapshot without back b
"remove": [Function], "remove": [Function],
} }
} }
onExited={[MockFunction]} onExited={[Function]}
onHide={[Function]} onHide={[Function]}
renderBackdrop={[Function]} renderBackdrop={[Function]}
restoreFocus={true} restoreFocus={true}
role="none" role="none"
show={true} show={true}
> >
<div
aria-live="polite"
className="sr-only"
role="alert"
/>
<ModalHeader <ModalHeader
bsClass="modal-header" bsClass="modal-header"
closeButton={true} closeButton={true}
@@ -221,6 +237,7 @@ exports[`component/create_user_groups_modal should match snapshot without back b
"id": "user_groups_modal.name", "id": "user_groups_modal.name",
} }
} }
required={true}
type="text" type="text"
value="" value=""
/> />
@@ -244,37 +261,42 @@ exports[`component/create_user_groups_modal should match snapshot without back b
"id": "user_groups_modal.mention", "id": "user_groups_modal.mention",
} }
} }
required={true}
type="text" type="text"
value="" value=""
/> />
</div> </div>
<h2> <fieldset
<MemoizedFormattedMessage className="group-add-people-fieldset"
defaultMessage="Add People"
id="user_groups_modal.addPeople"
/>
</h2>
<div
className="group-add-user"
> >
<Connect(injectIntl(AddUserToGroupMultiSelect)) <legend>
addUserCallback={[Function]} <MemoizedFormattedMessage
backButtonClass="multiselect-back" defaultMessage="Add People"
backButtonClick={[Function]} id="user_groups_modal.addPeople"
backButtonText={ />
Object { </legend>
"defaultMessage": "Cancel", <div
"id": "multiselect.cancelButton", className="group-add-user"
>
<Connect(injectIntl(AddUserToGroupMultiSelect))
addUserCallback={[Function]}
backButtonClass="multiselect-back"
backButtonClick={[Function]}
backButtonText={
Object {
"defaultMessage": "Cancel",
"id": "multiselect.cancelButton",
}
} }
} deleteUserCallback={[Function]}
deleteUserCallback={[Function]} focusOnLoad={false}
focusOnLoad={false} multilSelectKey="addUsersToGroupKey"
multilSelectKey="addUsersToGroupKey" onSubmitCallback={[Function]}
onSubmitCallback={[Function]} saving={false}
saving={false} savingEnabled={false}
savingEnabled={false} />
/> </div>
</div> </fieldset>
</div> </div>
</ModalBody> </ModalBody>
</Modal> </Modal>

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

@@ -41,6 +41,7 @@ type State = {
nameInputErrorText: React.ReactNode; nameInputErrorText: React.ReactNode;
showUnknownError: boolean; showUnknownError: boolean;
saving: boolean; saving: boolean;
saved: boolean;
} }
export class CreateUserGroupsModal extends React.PureComponent<Props, State> { export class CreateUserGroupsModal extends React.PureComponent<Props, State> {
@@ -58,6 +59,7 @@ export class CreateUserGroupsModal extends React.PureComponent<Props, State> {
nameInputErrorText: '', nameInputErrorText: '',
showUnknownError: false, showUnknownError: false,
saving: false, saving: false,
saved: false,
}; };
} }
@@ -92,15 +94,22 @@ export class CreateUserGroupsModal extends React.PureComponent<Props, State> {
this.setState({usersToAdd}); this.setState({usersToAdd});
}; };
goBack = () => { onExited = () => {
if (typeof this.props.backButtonCallback === 'function') { if (typeof this.props.backButtonCallback === 'function') {
this.props.backButtonCallback(); this.props.backButtonCallback();
this.props.onExited();
} }
this.props.onExited();
}; };
createGroup = async (users?: UserProfile[]) => { createGroup = async (users?: UserProfile[]) => {
this.setState({showUnknownError: false, mentionInputErrorText: '', nameInputErrorText: '', saving: true}); this.setState({
showUnknownError: false,
mentionInputErrorText: '',
nameInputErrorText: '',
saving: true,
saved: false,
});
let mention = this.state.mention; let mention = this.state.mention;
const displayName = this.state.name; const displayName = this.state.name;
@@ -200,9 +209,8 @@ export class CreateUserGroupsModal extends React.PureComponent<Props, State> {
this.setState({showUnknownError: true}); this.setState({showUnknownError: true});
} }
this.setState({saving: false}); this.setState({saving: false});
} else if (typeof this.props.backButtonCallback === 'function') {
this.goBack();
} else { } else {
this.setState({saved: true});
this.doHide(); this.doHide();
} }
}; };
@@ -213,11 +221,23 @@ export class CreateUserGroupsModal extends React.PureComponent<Props, State> {
dialogClassName='a11y__modal user-groups-modal-create' dialogClassName='a11y__modal user-groups-modal-create'
show={this.state.show} show={this.state.show}
onHide={this.doHide} onHide={this.doHide}
onExited={this.props.onExited} onExited={this.onExited}
role='none' role='none'
aria-labelledby='createUserGroupsModalLabel' aria-labelledby='createUserGroupsModalLabel'
id='createUserGroupsModal' id='createUserGroupsModal'
> >
<div
role='alert'
aria-live='polite'
className='sr-only'
>
{this.state.saved &&
<FormattedMessage
id='user_groups_modal.groupCreatedSuccess'
defaultMessage='Group created successfully'
/>
}
</div>
<Modal.Header closeButton={true}> <Modal.Header closeButton={true}>
{ {
typeof this.props.backButtonCallback === 'function' ? ( typeof this.props.backButtonCallback === 'function' ? (
@@ -226,9 +246,7 @@ export class CreateUserGroupsModal extends React.PureComponent<Props, State> {
type='button' type='button'
className='modal-header-back-button btn btn-icon' className='modal-header-back-button btn btn-icon'
aria-label={this.props.intl.formatMessage({id: 'user_groups_modal.goBackLabel', defaultMessage: 'Back'})} aria-label={this.props.intl.formatMessage({id: 'user_groups_modal.goBackLabel', defaultMessage: 'Back'})}
onClick={() => { onClick={this.doHide}
this.goBack();
}}
> >
<i className='icon icon-arrow-left'/> <i className='icon icon-arrow-left'/>
</button> </button>
@@ -267,6 +285,7 @@ export class CreateUserGroupsModal extends React.PureComponent<Props, State> {
data-testid='nameInput' data-testid='nameInput'
maxLength={64} maxLength={64}
autoFocus={true} autoFocus={true}
required={true}
customMessage={{type: ItemStatus.ERROR, value: this.state.nameInputErrorText}} customMessage={{type: ItemStatus.ERROR, value: this.state.nameInputErrorText}}
/> />
</div> </div>
@@ -278,31 +297,32 @@ export class CreateUserGroupsModal extends React.PureComponent<Props, State> {
value={this.state.mention} value={this.state.mention}
maxLength={64} maxLength={64}
data-testid='mentionInput' data-testid='mentionInput'
required={true}
customMessage={{type: ItemStatus.ERROR, value: this.state.mentionInputErrorText}} customMessage={{type: ItemStatus.ERROR, value: this.state.mentionInputErrorText}}
/> />
</div> </div>
<h2> <fieldset className='group-add-people-fieldset'>
<FormattedMessage <legend>
id='user_groups_modal.addPeople' <FormattedMessage
defaultMessage='Add People' id='user_groups_modal.addPeople'
/> defaultMessage='Add People'
</h2> />
<div className='group-add-user'> </legend>
<AddUserToGroupMultiSelect <div className='group-add-user'>
multilSelectKey={'addUsersToGroupKey'} <AddUserToGroupMultiSelect
onSubmitCallback={this.createGroup} multilSelectKey={'addUsersToGroupKey'}
focusOnLoad={false} onSubmitCallback={this.createGroup}
savingEnabled={this.isSaveEnabled()} focusOnLoad={false}
addUserCallback={this.addUserCallback} savingEnabled={this.isSaveEnabled()}
deleteUserCallback={this.deleteUserCallback} addUserCallback={this.addUserCallback}
backButtonText={defineMessage({id: 'multiselect.cancelButton', defaultMessage: 'Cancel'})} deleteUserCallback={this.deleteUserCallback}
backButtonClick={ backButtonText={defineMessage({id: 'multiselect.cancelButton', defaultMessage: 'Cancel'})}
typeof this.props.backButtonCallback === 'function' ? this.goBack : this.doHide backButtonClick={this.doHide}
} backButtonClass={'multiselect-back'}
backButtonClass={'multiselect-back'} saving={this.state.saving}
saving={this.state.saving} />
/> </div>
</div> </fieldset>
{ {
this.state.showUnknownError && this.state.showUnknownError &&
<div className='Input___error group-error'> <div className='Input___error group-error'>

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

@@ -12,6 +12,7 @@ exports[`components/multiselect/multiselect MultiSelectList should match snapsho
className="multi-select__container react-select" className="multi-select__container react-select"
> >
<ForwardRef <ForwardRef
aria-invalid={false}
className="" className=""
classNamePrefix="react-select-auto react-select" classNamePrefix="react-select-auto react-select"
components={ components={
@@ -29,6 +30,7 @@ exports[`components/multiselect/multiselect MultiSelectList should match snapsho
isClearable={false} isClearable={false}
isMulti={true} isMulti={true}
menuIsOpen={false} menuIsOpen={false}
onBlur={[Function]}
onChange={[Function]} onChange={[Function]}
onInputChange={[Function]} onInputChange={[Function]}
onKeyDown={[Function]} onKeyDown={[Function]}
@@ -196,6 +198,7 @@ exports[`components/multiselect/multiselect should match snapshot 1`] = `
className="multi-select__container react-select" className="multi-select__container react-select"
> >
<ForwardRef <ForwardRef
aria-invalid={false}
className="" className=""
classNamePrefix="react-select-auto react-select" classNamePrefix="react-select-auto react-select"
components={ components={
@@ -213,6 +216,7 @@ exports[`components/multiselect/multiselect should match snapshot 1`] = `
isClearable={false} isClearable={false}
isMulti={true} isMulti={true}
menuIsOpen={false} menuIsOpen={false}
onBlur={[Function]}
onChange={[Function]} onChange={[Function]}
onInputChange={[Function]} onInputChange={[Function]}
onKeyDown={[Function]} onKeyDown={[Function]}
@@ -371,6 +375,7 @@ exports[`components/multiselect/multiselect should match snapshot for page 2 1`]
className="multi-select__container react-select" className="multi-select__container react-select"
> >
<ForwardRef <ForwardRef
aria-invalid={false}
className="" className=""
classNamePrefix="react-select-auto react-select" classNamePrefix="react-select-auto react-select"
components={ components={
@@ -388,6 +393,7 @@ exports[`components/multiselect/multiselect should match snapshot for page 2 1`]
isClearable={false} isClearable={false}
isMulti={true} isMulti={true}
menuIsOpen={false} menuIsOpen={false}
onBlur={[Function]}
onChange={[Function]} onChange={[Function]}
onInputChange={[Function]} onInputChange={[Function]}
onKeyDown={[Function]} onKeyDown={[Function]}

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

@@ -7,7 +7,7 @@ import type {ComponentProps, ReactNode} from 'react';
import type {IntlShape, MessageDescriptor} from 'react-intl'; import type {IntlShape, MessageDescriptor} from 'react-intl';
import {FormattedMessage} from 'react-intl'; import {FormattedMessage} from 'react-intl';
import ReactSelect, {components} from 'react-select'; import ReactSelect, {components} from 'react-select';
import type {GetOptionValue, InputActionMeta, SelectInstance} from 'react-select'; import type {GetOptionValue, InputActionMeta, MultiValueRemoveProps, SelectInstance} from 'react-select';
import SaveButton from 'components/save_button'; import SaveButton from 'components/save_button';
import CloseCircleSolidIcon from 'components/widgets/icons/close_circle_solid_icon'; import CloseCircleSolidIcon from 'components/widgets/icons/close_circle_solid_icon';
@@ -67,12 +67,14 @@ export type Props<T extends Value> = {
savingEnabled?: boolean; savingEnabled?: boolean;
handleCancel?: () => void; handleCancel?: () => void;
customNoOptionsMessage?: React.ReactNode; customNoOptionsMessage?: React.ReactNode;
required?: boolean;
} }
export type State = { export type State = {
a11yActive: boolean; a11yActive: boolean;
input: string; input: string;
page: number; page: number;
hasError: boolean;
} }
const KeyCodes = Constants.KeyCodes; const KeyCodes = Constants.KeyCodes;
@@ -88,6 +90,7 @@ export class MultiSelect<T extends Value> extends React.PureComponent<Props<T>,
valueWithImage: false, valueWithImage: false,
focusOnLoad: true, focusOnLoad: true,
savingEnabled: true, savingEnabled: true,
required: false,
}; };
public constructor(props: Props<T>) { public constructor(props: Props<T>) {
@@ -97,9 +100,16 @@ export class MultiSelect<T extends Value> extends React.PureComponent<Props<T>,
a11yActive: false, a11yActive: false,
page: 0, page: 0,
input: '', input: '',
hasError: false,
}; };
} }
public componentDidUpdate(prevProps: Props<T>) {
if (prevProps.values !== this.props.values) {
this.validateInput();
}
}
public componentDidMount() { public componentDidMount() {
const inputRef: unknown = this.reactSelectRef.current && this.reactSelectRef.current.inputRef; const inputRef: unknown = this.reactSelectRef.current && this.reactSelectRef.current.inputRef;
@@ -265,12 +275,6 @@ export class MultiSelect<T extends Value> extends React.PureComponent<Props<T>,
this.props.handleDelete(values); this.props.handleDelete(values);
}; };
MultiValueRemove = ({children, innerProps}: any) => (
<div {...innerProps}>
{children || <CloseCircleSolidIcon/>}
</div>
);
formatOptionLabel = (user: any) => { formatOptionLabel = (user: any) => {
const profileImg = imageURLForUser(user.id, user.last_picture_update); const profileImg = imageURLForUser(user.id, user.last_picture_update);
@@ -292,6 +296,33 @@ export class MultiSelect<T extends Value> extends React.PureComponent<Props<T>,
return this.props.valueWithImage ? <components.MultiValueLabel {...props}/> : this.props.valueRenderer; return this.props.valueWithImage ? <components.MultiValueLabel {...props}/> : this.props.valueRenderer;
}; };
private MultiValueRemove = ({children, innerProps, data}: MultiValueRemoveProps<T>) => (
<div
{...innerProps}
role='button'
tabIndex={0}
aria-label={this.props.intl.formatMessage({
id: 'multiselect.remove',
defaultMessage: 'Remove {label}',
}, {
label: this.props.ariaLabelRenderer(data),
})}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
innerProps.onClick?.(e as unknown as React.MouseEvent<HTMLDivElement, MouseEvent>);
}
}}
>
{children || <CloseCircleSolidIcon/>}
</div>
);
private validateInput = () => {
this.setState({hasError: Boolean(this.props.required && this.props.values.length === 0)});
};
public render() { public render() {
const options = Object.assign([...this.props.options]); const options = Object.assign([...this.props.options]);
const {totalCount, users, values} = this.props; const {totalCount, users, values} = this.props;
@@ -463,7 +494,11 @@ export class MultiSelect<T extends Value> extends React.PureComponent<Props<T>,
<> <>
<div className='filtered-user-list'> <div className='filtered-user-list'>
<div className='filter-row filter-row--full'> <div className='filter-row filter-row--full'>
<div className='multi-select__container react-select'> <div
className={classNames('multi-select__container react-select', {
'has-error': this.state.hasError,
})}
>
<ReactSelect <ReactSelect
id='selectItems' id='selectItems'
ref={this.reactSelectRef as React.RefObject<any>} // type of ref on @types/react-select is outdated ref={this.reactSelectRef as React.RefObject<any>} // type of ref on @types/react-select is outdated
@@ -491,6 +526,9 @@ export class MultiSelect<T extends Value> extends React.PureComponent<Props<T>,
aria-label={formatAsString(this.props.intl.formatMessage, this.props.placeholderText)} aria-label={formatAsString(this.props.intl.formatMessage, this.props.placeholderText)}
className={this.state.a11yActive ? 'multi-select__focused' : ''} className={this.state.a11yActive ? 'multi-select__focused' : ''}
classNamePrefix='react-select-auto react-select' classNamePrefix='react-select-auto react-select'
aria-invalid={this.state.hasError}
aria-describedby={this.state.hasError ? 'multiSelectMessageError' : undefined}
onBlur={this.validateInput}
/> />
{this.props.saveButtonPosition === 'top' && {this.props.saveButtonPosition === 'top' &&
<SaveButton <SaveButton
@@ -518,6 +556,18 @@ export class MultiSelect<T extends Value> extends React.PureComponent<Props<T>,
> >
{noteTextContainer} {noteTextContainer}
</div> </div>
{this.state.hasError && (
<div
className='multi-select__error'
role='alert'
id='multiSelectMessageError'
>
<i className='icon icon-alert-circle-outline'/>
<span>
{this.props.intl.formatMessage({id: 'multiselect.required', defaultMessage: 'This field is required'})}
</span>
</div>
)}
{this.props.saveButtonPosition === 'top' && {this.props.saveButtonPosition === 'top' &&
<div className='filter-controls'> <div className='filter-controls'>
{previousButton} {previousButton}

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

@@ -213,6 +213,17 @@ export default class MultiSelectList<T extends Value> extends React.PureComponen
renderOutput = ( renderOutput = (
<div className='more-modal__list'> <div className='more-modal__list'>
<div
className='sr-only'
aria-live='polite'
role='status'
>
<FormattedMessage
id='multiselect.list.resultsAvailable'
defaultMessage='{count, plural, one {# result found} other {# results found}} for your search.'
values={{count: options.length}}
/>
</div>
<div <div
className='sr-only' className='sr-only'
aria-live='polite' aria-live='polite'

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

@@ -14,7 +14,7 @@
padding-bottom: 12px; padding-bottom: 12px;
font-weight: 400; font-weight: 400;
fieldset { .Input_fieldset {
max-width: 110px; max-width: 110px;
} }

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

@@ -66,10 +66,11 @@ Object {
<div <div
class="Input_container" class="Input_container"
> >
<fieldset <div
class="Input_fieldset name_input" class="Input_fieldset name_input"
data-testid="input-wrapper"
> >
<legend <label
class="Input_legend" class="Input_legend"
/> />
<div <div
@@ -85,19 +86,20 @@ Object {
value="" value=""
/> />
</div> </div>
</fieldset> </div>
</div> </div>
<div <div
class="Input_container" class="Input_container"
> >
<fieldset <div
class="Input_fieldset email_input Input_fieldset___error Input_fieldset___legend" class="Input_fieldset email_input Input_fieldset___error Input_fieldset___legend"
data-testid="input-wrapper"
> >
<legend <label
class="Input_legend Input_legend___focus" class="Input_legend Input_legend___focus"
> >
Business Email Business Email
</legend> </label>
<div <div
class="Input_wrapper" class="Input_wrapper"
> >
@@ -113,7 +115,7 @@ Object {
value="test@mattermost.com" value="test@mattermost.com"
/> />
</div> </div>
</fieldset> </div>
<div <div
class="Input___customMessage Input___error" class="Input___customMessage Input___error"
id="error_email" id="error_email"
@@ -132,10 +134,11 @@ Object {
<div <div
class="Input_container" class="Input_container"
> >
<fieldset <div
class="Input_fieldset company_name_input" class="Input_fieldset company_name_input"
data-testid="input-wrapper"
> >
<legend <label
class="Input_legend" class="Input_legend"
/> />
<div <div
@@ -151,7 +154,7 @@ Object {
value="" value=""
/> />
</div> </div>
</fieldset> </div>
</div> </div>
<div <div
class="DropdownInput Input_container" class="DropdownInput Input_container"

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

@@ -6,7 +6,7 @@
} }
.description-section-input { .description-section-input {
fieldset { .Input_fieldset {
height: 100%; height: 100%;
} }
} }

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

@@ -389,12 +389,19 @@
padding: 12px 32px 24px; padding: 12px 32px 24px;
} }
h2 { .group-add-people-fieldset {
padding: 0 32px; margin-top: 20px;
color: inherit;
font-size: 18px; legend {
font-weight: 600; padding: 0 32px;
line-height: 24px; border: none;
margin-bottom: 10px;
color: inherit;
font-family: Metropolis, sans-serif;
font-size: 18px;
font-weight: 600;
line-height: 24px;
}
} }
.group-add-user { .group-add-user {

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

@@ -5,10 +5,11 @@ exports[`components/widgets/inputs/Input should match snapshot 1`] = `
<div <div
class="Input_container" class="Input_container"
> >
<fieldset <div
class="Input_fieldset" class="Input_fieldset"
data-testid="input-wrapper"
> >
<legend <label
class="Input_legend" class="Input_legend"
/> />
<div <div
@@ -20,7 +21,7 @@ exports[`components/widgets/inputs/Input should match snapshot 1`] = `
value="" value=""
/> />
</div> </div>
</fieldset> </div>
</div> </div>
</div> </div>
`; `;

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

@@ -77,7 +77,7 @@ describe('components/widgets/inputs/Input', () => {
}); });
// Check for error styling // Check for error styling
const fieldset = screen.getByRole('group'); const fieldset = screen.getByTestId('input-wrapper');
expect(fieldset).toHaveClass('Input_fieldset___error'); expect(fieldset).toHaveClass('Input_fieldset___error');
// Check for error message // Check for error message
@@ -108,7 +108,7 @@ describe('components/widgets/inputs/Input', () => {
}); });
// Check for error styling // Check for error styling
const fieldset = screen.getByRole('group'); const fieldset = screen.getByTestId('input-wrapper');
expect(fieldset).toHaveClass('Input_fieldset___error'); expect(fieldset).toHaveClass('Input_fieldset___error');
// Check for error message // Check for error message
@@ -159,7 +159,7 @@ describe('components/widgets/inputs/Input', () => {
}); });
// Check for error styling // Check for error styling
const fieldset = screen.getByRole('group'); const fieldset = screen.getByTestId('input-wrapper');
expect(fieldset).toHaveClass('Input_fieldset___error'); expect(fieldset).toHaveClass('Input_fieldset___error');
}); });
@@ -205,7 +205,7 @@ describe('components/widgets/inputs/Input', () => {
expect(errorMessage).toBeInTheDocument(); expect(errorMessage).toBeInTheDocument();
// Check for error styling // Check for error styling
const fieldset = screen.getByRole('group'); const fieldset = screen.getByTestId('input-wrapper');
expect(fieldset).toHaveClass('Input_fieldset___error'); expect(fieldset).toHaveClass('Input_fieldset___error');
}); });

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

@@ -256,16 +256,17 @@ const Input = React.forwardRef((
return ( return (
<div className={classNames('Input_container', containerClassName, {disabled})}> <div className={classNames('Input_container', containerClassName, {disabled})}>
<fieldset <div
className={classNames('Input_fieldset', className, { className={classNames('Input_fieldset', className, {
Input_fieldset___error: hasError || customInputLabel?.type === 'error', Input_fieldset___error: hasError || customInputLabel?.type === 'error',
Input_fieldset___legend: showLegend, Input_fieldset___legend: showLegend,
})} })}
data-testid='input-wrapper'
> >
{useLegend && ( {useLegend && (
<legend className={classNames('Input_legend', {Input_legend___focus: showLegend})}> <label className={classNames('Input_legend', {Input_legend___focus: showLegend})}>
{showLegend ? formatAsString(formatMessage, label || placeholder) : null} {showLegend ? formatAsString(formatMessage, label || placeholder) : null}
</legend> </label>
)} )}
<div className={classNames('Input_wrapper', wrapperClassName)}> <div className={classNames('Input_wrapper', wrapperClassName)}>
{inputPrefix} {inputPrefix}
@@ -275,7 +276,7 @@ const Input = React.forwardRef((
{clearButton} {clearButton}
</div> </div>
{addon} {addon}
</fieldset> </div>
{/* Display custom or derived error messages */} {/* Display custom or derived error messages */}
{customInputLabel && ( {customInputLabel && (
<div <div

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

@@ -1,93 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import type {UserProfile} from '@mattermost/types/users';
import UsersEmailsInput from './users_emails_input';
describe('components/widgets/inputs/UsersEmailsInput', () => {
test('should match snapshot', () => {
const wrapper = shallow(
<UsersEmailsInput
placeholder='test'
ariaLabel='test'
usersLoader={jest.fn()}
onChange={jest.fn()}
value={[
'test@email.com',
{
id: 'test-user-id',
username: 'test-username',
first_name: 'test',
last_name: 'user',
} as UserProfile,
]}
errorMessage={{
id: 'errorMessageId',
defaultMessage: 'errorMessageDefault',
}}
onInputChange={jest.fn()}
inputValue=''
emailInvitationsEnabled={false}
/>,
);
expect(wrapper).toMatchInlineSnapshot(`
<Fragment>
<ForwardRef
aria-label="test"
className="UsersEmailsInput empty"
classNamePrefix="users-emails-input"
components={
Object {
"IndicatorsContainer": [Function],
"Input": [Function],
"MultiValueRemove": [Function],
"NoOptionsMessage": [Function],
}
}
defaultMenuIsOpen={false}
defaultOptions={false}
formatOptionLabel={[Function]}
getOptionValue={[Function]}
inputValue=""
isClearable={false}
isMulti={true}
isValidNewOption={[Function]}
loadOptions={[Function]}
loadingMessage={[Function]}
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
onInputChange={[Function]}
openMenuOnClick={false}
openMenuOnFocus={true}
placeholder="test"
styles={
Object {
"input": [Function],
"placeholder": [Function],
}
}
tabSelectsValue={true}
value={
Array [
Object {
"label": "test@email.com",
"value": "test@email.com",
},
Object {
"first_name": "test",
"id": "test-user-id",
"last_name": "user",
"username": "test-username",
},
]
}
/>
</Fragment>
`);
});
});

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

@@ -4,8 +4,8 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React from 'react'; import React from 'react';
import type {RefObject} from 'react'; import type {RefObject} from 'react';
import type {MessageDescriptor} from 'react-intl'; import type {MessageDescriptor, IntlShape} from 'react-intl';
import {FormattedMessage, defineMessages} from 'react-intl'; import {FormattedMessage, defineMessages, injectIntl} from 'react-intl';
import {components} from 'react-select'; import {components} from 'react-select';
import type {FormatOptionLabelMeta, InputActionMeta, InputProps, Options, StylesConfig, SelectInstance, MultiValue, SingleValue, OptionsOrGroups, GroupBase, MultiValueRemoveProps} from 'react-select'; import type {FormatOptionLabelMeta, InputActionMeta, InputProps, Options, StylesConfig, SelectInstance, MultiValue, SingleValue, OptionsOrGroups, GroupBase, MultiValueRemoveProps} from 'react-select';
import AsyncCreatable from 'react-select/async-creatable'; import AsyncCreatable from 'react-select/async-creatable';
@@ -50,6 +50,7 @@ type Props = {
autoFocus?: boolean; autoFocus?: boolean;
suppressNoOptionsMessage?: boolean; suppressNoOptionsMessage?: boolean;
onPaste?: (e: ClipboardEvent) => void; onPaste?: (e: ClipboardEvent) => void;
intl: IntlShape;
} }
export type EmailInvite = { export type EmailInvite = {
@@ -79,7 +80,7 @@ const messages = defineMessages({
}, },
}); });
export default class UsersEmailsInput extends React.PureComponent<Props, State> { export class UsersEmailsInput extends React.PureComponent<Props, State> {
static defaultProps = { static defaultProps = {
noMatchMessage: messages.noMatchDefault, noMatchMessage: messages.noMatchDefault,
validAddress: messages.validAddressDefault, validAddress: messages.validAddressDefault,
@@ -259,6 +260,18 @@ export default class UsersEmailsInput extends React.PureComponent<Props, State>
); );
}; };
private renderAriaLabel = (option: UserProfile | EmailInvite): string => {
if (!option) {
return '';
}
if (this.isUserProfile(option)) {
return (option as UserProfile).username;
}
return (option as EmailInvite).value;
};
MultiValueRemove = (props: MultiValueRemoveProps<EmailInvite | UserProfile, true>) => { MultiValueRemove = (props: MultiValueRemoveProps<EmailInvite | UserProfile, true>) => {
const {children, innerProps} = props; const {children, innerProps} = props;
@@ -267,6 +280,12 @@ export default class UsersEmailsInput extends React.PureComponent<Props, State>
{...innerProps} {...innerProps}
role='button' role='button'
tabIndex={0} tabIndex={0}
aria-label={this.props.intl.formatMessage({
id: 'multiselect.remove',
defaultMessage: 'Remove {label}',
}, {
label: this.renderAriaLabel(props.data),
})}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
@@ -564,3 +583,5 @@ export default class UsersEmailsInput extends React.PureComponent<Props, State>
); );
} }
} }
export default injectIntl(UsersEmailsInput);

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

@@ -4736,6 +4736,7 @@
"multiselect.creating": "Creating...", "multiselect.creating": "Creating...",
"multiselect.go": "Go", "multiselect.go": "Go",
"multiselect.list.notFound": "No results found matching <b>{searchQuery}</b>", "multiselect.list.notFound": "No results found matching <b>{searchQuery}</b>",
"multiselect.list.resultsAvailable": "{count, plural, one {# result found} other {# results found}} for your search.",
"multiselect.loading": "Loading...", "multiselect.loading": "Loading...",
"multiselect.maxGroupMembers": "No more than 256 members can be added to a group at once.", "multiselect.maxGroupMembers": "No more than 256 members can be added to a group at once.",
"multiselect.maxPeople": "Use ↑↓ to browse, ↵ to select. You can't add more than 7 people. Please <a>create a channel</a> to include more people. ", "multiselect.maxPeople": "Use ↑↓ to browse, ↵ to select. You can't add more than 7 people. Please <a>create a channel</a> to include more people. ",
@@ -4745,6 +4746,8 @@
"multiselect.numRemaining": "Up to {max, number} can be added at a time. You have {num, number} remaining.", "multiselect.numRemaining": "Up to {max, number} can be added at a time. You have {num, number} remaining.",
"multiselect.placeholder": "Search for people", "multiselect.placeholder": "Search for people",
"multiselect.placeholder.peopleOrGroups": "Search for people or groups", "multiselect.placeholder.peopleOrGroups": "Search for people or groups",
"multiselect.remove": "Remove {label}",
"multiselect.required": "This field is required",
"multiselect.saveDetailsButton": "Save Details", "multiselect.saveDetailsButton": "Save Details",
"multiselect.savingDetailsButton": "Saving...", "multiselect.savingDetailsButton": "Saving...",
"multiselect.selectChannels": "Use ↑↓ to browse, ↵ to select.", "multiselect.selectChannels": "Use ↑↓ to browse, ↵ to select.",
@@ -5764,6 +5767,7 @@
"user_groups_modal.editGroupTitle": "Edit Group Details", "user_groups_modal.editGroupTitle": "Edit Group Details",
"user_groups_modal.filterAriaLabel": "Groups Filter", "user_groups_modal.filterAriaLabel": "Groups Filter",
"user_groups_modal.goBackLabel": "Back", "user_groups_modal.goBackLabel": "Back",
"user_groups_modal.groupCreatedSuccess": "Group created successfully",
"user_groups_modal.joinGroup": "Join Group", "user_groups_modal.joinGroup": "Join Group",
"user_groups_modal.leaveGroup": "Leave Group", "user_groups_modal.leaveGroup": "Leave Group",
"user_groups_modal.memberCount": "{member_count} {member_count, plural, one {member} other {members}}", "user_groups_modal.memberCount": "{member_count} {member_count, plural, one {member} other {members}}",

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

@@ -3,7 +3,7 @@
.modal .channel-invite__content, .modal .channel-invite__content,
.modal .user-groups-modal__content { .modal .user-groups-modal__content {
.multi-select__container { .multi-select__container, .multi-select__error {
height: auto; height: auto;
padding: 0 3.2rem; padding: 0 3.2rem;
} }

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

@@ -9,6 +9,10 @@
height: 40px; height: 40px;
margin-left: 16px; margin-left: 16px;
} }
&.has-error.react-select .react-select__control.react-select-auto {
border-color: var(--error-text);
}
} }
.multi-select__focused { .multi-select__focused {
@@ -52,3 +56,23 @@
} }
} }
} }
.multi-select__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;
}
}
}

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

@@ -525,7 +525,7 @@
border: none; border: none;
} }
.multi-select__container { .multi-select__container, .multi-select__error {
.btn { .btn {
display: block; display: block;
min-width: 50px; min-width: 50px;