[MM-63007][MM-63004][MM-63020][MM-63009][MM-63008] More accessibility fixes around Search (#31409)

* [MM-63008] Make collapse button on search bar an actual button

* [MM-63004][MM-63020] Convert search box to floating-ui, fix some of the roles and labels that were incorrect

* [MM-63009] Add radiogroup and radio roles to the search box types

* [MM-63007] Ensure search box reads out number of results with suggestion items

* Fix playwright tests

* PR feedback

* Remove floating ui overlay

* Remove unnecessary .first() by being more specific about the search box

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Devin Binnie
2025-06-17 17:47:30 -04:00
коммит произвёл GitHub
родитель e367872c0b
Коммит df1b278f62
35 изменённых файлов: 204 добавлений и 147 удалений

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

@@ -1,8 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
useFloating,
autoUpdate,
useClick,
useDismiss,
useInteractions,
useRole,
FloatingFocusManager,
FloatingPortal,
offset,
} from '@floating-ui/react';
import React, {useEffect, useState, useRef, useCallback} from 'react';
import {FormattedMessage} from 'react-intl';
import {FormattedMessage, useIntl} from 'react-intl';
import {useSelector, useDispatch} from 'react-redux';
import styled from 'styled-components';
@@ -16,30 +27,15 @@ import {updateSearchTerms, showSearchResults, updateSearchType, updateSearchTeam
import {getSearchButtons} from 'selectors/plugins';
import {getSearchTeam, getSearchTerms, getSearchType} from 'selectors/rhs';
import Popover from 'components/widgets/popover';
import a11yController from 'utils/a11y_controller_instance';
import {focusElement} from 'utils/a11y_utils';
import Constants from 'utils/constants';
import {RootHtmlPortalId, Constants} from 'utils/constants';
import * as Keyboard from 'utils/keyboard';
import {isServerVersionGreaterThanOrEqualTo} from 'utils/server_version';
import {isDesktopApp, getDesktopVersion, isMacApp} from 'utils/user_agent';
import SearchBox from './search_box';
const PopoverStyled = styled(Popover)`
min-width: 600px;
left: -90px;
top: -12px;
border-radius: 12px;
max-height: 90vh;
overflow-y: auto;
.popover-content {
padding: 0px;
}
`;
const SearchTypeBadge = styled.div`
display: flex;
align-items: center;
@@ -106,7 +102,18 @@ const NewSearchTerms = styled.span`
white-space: nowrap;
`;
const SearchBoxContainer = styled.div`
min-width: 600px;
border-radius: 12px;
max-height: 90vh;
overflow-y: auto;
background: var(--center-channel-bg);
box-shadow: 0 0 0 1px rgba(var(--center-channel-color-rgb), 0.16), 0 4px 6px rgba(0, 0, 0, 0.12);
z-index: 1050;
`;
const NewSearch = (): JSX.Element => {
const intl = useIntl();
const currentChannelName = useSelector(getCurrentChannelNameForSearchShortcut);
const searchTerms = useSelector(getSearchTerms) || '';
const searchType = useSelector(getSearchType) || '';
@@ -120,7 +127,25 @@ const NewSearch = (): JSX.Element => {
const [focused, setFocused] = useState<boolean>(false);
const [currentChannel, setCurrentChannel] = useState('');
const searchBoxRef = useRef<HTMLDivElement | null>(null);
const searchButtonRef = useRef<HTMLDivElement | null>(null);
const {refs, floatingStyles, context: floatingContext} = useFloating<HTMLDivElement>({
open: focused,
onOpenChange: setFocused,
whileElementsMounted: autoUpdate,
placement: 'bottom',
middleware: [offset({mainAxis: -28})],
});
const searchButtonRef = refs.reference as React.RefObject<HTMLDivElement>;
const clickInteractions = useClick(floatingContext);
const dismissInteraction = useDismiss(floatingContext);
const role = useRole(floatingContext);
const {getReferenceProps, getFloatingProps} = useInteractions([
clickInteractions,
dismissInteraction,
role,
]);
useEffect(() => {
const isDesktop = isDesktopApp() && isServerVersionGreaterThanOrEqualTo(getDesktopVersion(), '4.7.0');
@@ -255,75 +280,89 @@ const NewSearch = (): JSX.Element => {
const clearSearchType = useCallback(() => dispatch(updateSearchType('')), []);
return (
<NewSearchContainer
tabIndex={0}
onKeyDown={openSearchBoxOnKeyPress}
onClick={openSearchBox}
id='searchFormContainer'
role='button'
className='a11y__region'
ref={searchButtonRef}
>
<i className='icon icon-magnify'/>
{(searchType === 'messages' || searchType === 'files') && (
<SearchTypeBadge data-testid='searchTypeBadge'>
{searchType === 'messages' && (
<FormattedMessage
id='search_bar.search_types.messages'
defaultMessage='MESSAGES'
<>
<NewSearchContainer
tabIndex={0}
id='searchFormContainer'
role='button'
className='a11y__region'
ref={refs.setReference}
{...getReferenceProps({
onKeyDown: openSearchBoxOnKeyPress,
onClick: openSearchBox,
})}
>
<i className='icon icon-magnify'/>
{(searchType === 'messages' || searchType === 'files') && (
<SearchTypeBadge data-testid='searchTypeBadge'>
{searchType === 'messages' && (
<FormattedMessage
id='search_bar.search_types.messages'
defaultMessage='MESSAGES'
/>
)}
{searchType === 'files' && (
<FormattedMessage
id='search_bar.search_types.files'
defaultMessage='FILES'
/>
)}
<i
className='icon icon-close icon-12'
onClick={clearSearchType}
/>
)}
{searchType === 'files' && (
<FormattedMessage
id='search_bar.search_types.files'
defaultMessage='FILES'
/>
)}
<i
className='icon icon-close icon-12'
onClick={clearSearchType}
/>
</SearchTypeBadge>
)}
{searchTerms && <NewSearchTerms tabIndex={0}>{searchTerms}</NewSearchTerms>}
{searchTerms && (
<CloseIcon
data-testid='input-clear'
role='button'
onClick={onClose}
>
<span
className='input-clear-x'
aria-hidden='true'
</SearchTypeBadge>
)}
{searchTerms && <NewSearchTerms tabIndex={0}>{searchTerms}</NewSearchTerms>}
{searchTerms && (
<CloseIcon
data-testid='input-clear'
role='button'
onClick={onClose}
>
<i className='icon icon-close-circle'/>
</span>
</CloseIcon>
)}
{!searchTerms && (
<FormattedMessage
id='search_bar.search'
defaultMessage='Search'
/>
)}
{focused && (
<PopoverStyled
id='searchPopover'
placement='bottom'
>
<SearchBox
ref={searchBoxRef}
onClose={closeSearchBox}
onSearch={runSearch}
initialSearchTerms={currentChannel ? `in:${currentChannel} ` : searchTerms}
initialSearchType={searchType}
initialSearchTeam={searchTeam}
crossTeamSearchEnabled={crossTeamSearchEnabled}
myTeams={myTeams}
<span
className='input-clear-x'
aria-hidden='true'
>
<i className='icon icon-close-circle'/>
</span>
</CloseIcon>
)}
{!searchTerms && (
<FormattedMessage
id='search_bar.search'
defaultMessage='Search'
/>
</PopoverStyled>
)}
</NewSearchContainer>
{focused && (
<FloatingPortal id={RootHtmlPortalId}>
<FloatingFocusManager context={floatingContext}>
<SearchBoxContainer
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
aria-label={intl.formatMessage({
id: 'search_bar.search_box',
defaultMessage: 'Search box',
})}
>
<SearchBox
ref={searchBoxRef}
onClose={closeSearchBox}
onSearch={runSearch}
initialSearchTerms={currentChannel ? `in:${currentChannel} ` : searchTerms}
initialSearchType={searchType}
initialSearchTeam={searchTeam}
crossTeamSearchEnabled={crossTeamSearchEnabled}
myTeams={myTeams}
/>
</SearchBoxContainer>
</FloatingFocusManager>
</FloatingPortal>
)}
</NewSearchContainer>
</>
);
};

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

@@ -268,12 +268,6 @@ const SearchBox = forwardRef(
<SearchBoxContainer
ref={ref}
id='searchBox'
aria-label={intl.formatMessage({
id: 'search_bar.search',
defaultMessage: 'Search',
})}
aria-describedby='searchHints'
role='searchbox'
>
<CloseIcon
data-testid='searchBoxClose'

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

@@ -116,6 +116,7 @@ const SearchInput = forwardRef<HTMLInputElement, Props>(({searchTerms, searchTyp
autoFocus={true}
onKeyDown={onKeyDown}
tabIndex={0}
role='searchbox'
/>
{searchTerms.length > 0 && (
<ClearButton

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

@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import styled from 'styled-components';
@@ -93,7 +94,15 @@ const SearchSuggestions = ({searchType, searchTeam, searchTerms, suggestionsHead
className='sr-only'
key={providerResults.terms[selectedOption]}
>
{generateLabel(providerResults.items[selectedOption])}
<FormattedMessage
id='search_box_suggestions.suggestions_readout'
defaultMessage='{label} ({idx} of {total} results available)'
values={{
label: generateLabel(providerResults.items[selectedOption]),
idx: selectedOption + 1,
total: providerResults.items.length,
}}
/>
</div>
)}
{providerResults.items.map((item, idx) => {

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

@@ -55,10 +55,13 @@ const SearchTypeSelector = ({searchType, setSearchType}: Props) => {
const searchPluginButtons = useSelector(getSearchButtons);
return (
<SearchTypeSelectorContainer>
<SearchTypeSelectorContainer
role='radiogroup'
>
<SearchTypeItem
selected={searchType === 'messages'}
onClick={setMessagesSearchType}
role='radio'
>
<FormattedMessage
id='search_bar.usage.search_type_messages'
@@ -68,6 +71,7 @@ const SearchTypeSelector = ({searchType, setSearchType}: Props) => {
<SearchTypeItem
selected={searchType === 'files'}
onClick={setFilesSearchType}
role='radio'
>
<FormattedMessage
id='search_bar.usage.search_type_files'
@@ -81,6 +85,7 @@ const SearchTypeSelector = ({searchType, setSearchType}: Props) => {
key={pluginId}
selected={searchType === pluginId}
onClick={() => setSearchType(pluginId)}
role='radio'
>
<ErrorBoundary>
<Component/>

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

@@ -81,6 +81,7 @@ export type Props = {
id?: string;
onInput?: (e?: React.FormEvent<HTMLInputElement>) => void;
tabIndex?: number;
role?: string;
}
// A component that can be used to make controlled inputs that function properly in certain

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

@@ -466,16 +466,18 @@ const Search = ({
const renderSearchBar = (): JSX.Element => (
<>
<div className='sidebar-collapse__container'>
<div
<button
id={isSideBarRight ? 'sbrSidebarCollapse' : 'sidebarCollapse'}
className='sidebar-collapse'
onClick={handleClose}
aria-label={intl.formatMessage({id: 'channel_header.back', defaultMessage: 'Back to channel'})}
>
<span
className='fa fa-2x fa-angle-left'
title={intl.formatMessage({id: 'generic_icons.back', defaultMessage: 'Back Icon'})}
aria-hidden='true'
/>
</div>
</button>
</div>
<SearchBar
updateHighlightedSearchHint={updateHighlightedSearchHint}

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

@@ -3478,6 +3478,7 @@
"channel_bookmarks.editBookmarkLabel": "Bookmark menu",
"channel_bookmarks.open": "Open",
"channel_groups": "{channel} Groups",
"channel_header.back": "Back to channel",
"channel_header.channel_settings": "Channel Settings",
"channel_header.channelFiles": "Channel files",
"channel_header.channelHasGuests": "Channel has guests",
@@ -5191,6 +5192,7 @@
"search_bar.files_tab": "Files",
"search_bar.messages_tab": "Messages",
"search_bar.search": "Search",
"search_bar.search_box": "Search box",
"search_bar.search_files": "Search files",
"search_bar.search_messages": "Search messages",
"search_bar.search_types.files": "FILES",
@@ -5204,6 +5206,7 @@
"search_bar.usage.title_files": "File search options",
"search_bar.usage.title_messages": "Message search options",
"search_bar.users": "Users",
"search_box_suggestions.suggestions_readout": "{label} ({idx} of {total} results available)",
"search_file_extension_suggestion.aria_label": "{fileType} (.{extension})",
"search_files_list_option.after": "Files after a date",
"search_files_list_option.before": "Files before a date",

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

@@ -277,6 +277,8 @@
height: 48px;
align-items: center;
justify-content: center;
border: none;
background: transparent;
cursor: pointer;
text-align: center;
transform: translateX(0);