Skip to content

Commit 1597756

Browse files
reidbarbersnowystingerLFDanLuMichael Jordan
authored
Fix SearchWithin aria-label and aria-labelledby (#2288)
* fix aria-label and aria-labelledby in searchwithin * update tests * Get translations started, block help text on sub components * fix SearchWithin deps * fix labels for group and elements * fix test cases for aria-label application * fix style regression when focuses * add story for when no label or aria-label provided * improve input query in tests * modify useSelect id to use id from triggerProps * override user-provided aria-label for searchfield * add story for external label Co-authored-by: Robert Snow <[email protected]> Co-authored-by: Daniel Lu <[email protected]> Co-authored-by: Michael Jordan <[email protected]>
1 parent 10e57d4 commit 1597756

File tree

6 files changed

+158
-37
lines changed

6 files changed

+158
-37
lines changed

packages/@react-aria/select/src/useSelect.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import {AriaButtonProps} from '@react-types/button';
1414
import {AriaListBoxOptions} from '@react-aria/listbox';
1515
import {AriaSelectProps} from '@react-types/select';
16-
import {chain, filterDOMProps, mergeProps, useId} from '@react-aria/utils';
16+
import {chain, filterDOMProps, mergeProps} from '@react-aria/utils';
1717
import {FocusEvent, HTMLAttributes, RefObject, useMemo} from 'react';
1818
import {KeyboardDelegate} from '@react-types/shared';
1919
import {ListKeyboardDelegate, useTypeSelect} from '@react-aria/selection';
@@ -121,7 +121,8 @@ export function useSelect<T>(props: AriaSelectOptions<T>, state: SelectState<T>,
121121
let domProps = filterDOMProps(props, {labelable: true});
122122
let triggerProps = mergeProps(typeSelectProps, menuTriggerProps, fieldProps);
123123

124-
let valueId = useId();
124+
// used to make predictable id's based on the trigger which is already generated, this aids us in testing
125+
let valueId = `${triggerProps.id}-value`;
125126

126127
return {
127128
labelProps: {
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"search": "Search",
3+
"searchWithin": "Search within"
4+
}

packages/@react-spectrum/searchwithin/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
},
3333
"dependencies": {
3434
"@babel/runtime": "^7.6.2",
35+
"@react-aria/i18n": "^3.3.2",
36+
"@react-spectrum/form": "^3.2.3",
3537
"@react-aria/label": "^3.2.1",
3638
"@react-aria/utils": "^3.10.0",
3739
"@react-spectrum/label": "^3.4.1",

packages/@react-spectrum/searchwithin/src/SearchWithin.tsx

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,26 +13,42 @@
1313
import {classNames, SlotProvider, useFocusableRef, useResizeObserver, useStyleProps} from '@react-spectrum/utils';
1414
import {Field} from '@react-spectrum/label';
1515
import {FocusableRef} from '@react-types/shared';
16+
// @ts-ignore
17+
import intlMessages from '../intl/*.json';
1618
import React, {useCallback, useRef, useState} from 'react';
1719
import {SpectrumSearchWithinProps} from '@react-types/searchwithin';
1820
import styles from '@adobe/spectrum-css-temp/components/searchwithin/vars.css';
21+
import {useFormProps} from '@react-spectrum/form';
22+
import {useId} from '@react-aria/utils';
1923
import {useLabel} from '@react-aria/label';
2024
import {useLayoutEffect} from '@react-aria/utils';
25+
import {useMessageFormatter} from '@react-aria/i18n';
2126
import {useProvider, useProviderProps} from '@react-spectrum/provider';
2227

2328
function SearchWithin(props: SpectrumSearchWithinProps, ref: FocusableRef<HTMLElement>) {
2429
props = useProviderProps(props);
30+
props = useFormProps(props);
31+
let formatMessage = useMessageFormatter(intlMessages);
2532
let {styleProps} = useStyleProps(props);
26-
let {labelProps, fieldProps} = useLabel(props);
2733
let {
2834
children,
2935
isDisabled,
3036
isRequired,
31-
label,
32-
'aria-label': ariaLabel,
33-
'aria-labelledby': ariaLabelledby
37+
label
3438
} = props;
3539

40+
let defaultAriaLabel = formatMessage('search');
41+
if (!label && !props['aria-label'] && !props['aria-labelledby']) {
42+
props['aria-label'] = defaultAriaLabel;
43+
}
44+
// Get label and group props (aka fieldProps)
45+
let {labelProps, fieldProps} = useLabel(props);
46+
47+
// Grab aria-labelledby for the search input. Will need the entire concatenated aria-labelledby if it exists since pointing at the group id doesn’t
48+
// suffice if there is a external label
49+
let labelledBy = fieldProps['aria-labelledby'] || (fieldProps['aria-label'] !== defaultAriaLabel ? fieldProps.id : '');
50+
let pickerId = useId();
51+
3652
let domRef = useFocusableRef(ref);
3753
let groupRef = useRef<HTMLDivElement>();
3854

@@ -61,18 +77,40 @@ function SearchWithin(props: SpectrumSearchWithinProps, ref: FocusableRef<HTMLEl
6177
isRequired,
6278
label: null,
6379
isQuiet: false,
64-
'aria-labelledby': labelProps.id || ariaLabel,
65-
validationState: null
80+
validationState: null,
81+
description: null,
82+
errorMessage: null,
83+
descriptionProps: null,
84+
errorMessageProps: null,
85+
'aria-label': null
6686
};
87+
6788
let searchFieldClassName = classNames(styles, 'spectrum-SearchWithin-searchfield');
6889
let pickerClassName = classNames(styles, 'spectrum-SearchWithin-picker');
90+
6991
let slots = {
70-
searchfield: {UNSAFE_className: searchFieldClassName, ...fieldProps, ...defaultSlotValues},
71-
picker: {UNSAFE_className: pickerClassName, menuWidth, align: 'end', ...defaultSlotValues}
92+
searchfield: {
93+
...defaultSlotValues,
94+
UNSAFE_className: searchFieldClassName,
95+
// Apply aria-labelledby of group or the group id to searchfield. No need to pass the group id (we want a new one) and aria-label (aria-labelledby will suffice)
96+
'aria-labelledby': `${labelledBy} ${pickerId} ${pickerId}-value`,
97+
// When label is provided, input should have id referenced by htmlFor of label, instead of group
98+
id: label && fieldProps.id
99+
},
100+
picker: {
101+
...defaultSlotValues,
102+
id: pickerId,
103+
UNSAFE_className: pickerClassName,
104+
menuWidth,
105+
align: 'end',
106+
'aria-label': formatMessage('searchWithin'),
107+
'aria-labelledby': `${labelledBy} ${pickerId}`
108+
}
72109
};
73110

74-
if (!label && !ariaLabel && !ariaLabelledby) {
75-
console.warn('If you do not provide a `label` prop, you must specify an aria-label or aria-labelledby attribute for accessibility');
111+
if (label) {
112+
// When label is provided, input should have id referenced by htmlFor of label, instead of group
113+
delete fieldProps.id;
76114
}
77115

78116
return (
@@ -85,8 +123,8 @@ function SearchWithin(props: SpectrumSearchWithinProps, ref: FocusableRef<HTMLEl
85123
'spectrum-SearchWithin-container'
86124
)}>
87125
<div
126+
{...fieldProps}
88127
role="group"
89-
aria-labelledby={labelProps.id || ariaLabel}
90128
className={classNames(styles, 'spectrum-SearchWithin', styleProps.className)}
91129
ref={groupRef}>
92130
<SlotProvider slots={slots}>

packages/@react-spectrum/searchwithin/stories/SearchWithin.stories.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export default {
2626

2727
function render(props: Omit<SpectrumSearchWithinProps, 'children'> = {}, searchFieldProps: SearchFieldProps = {}, pickerProps: Omit<SpectrumPickerProps<object>, 'children'> = {}) {
2828
return (
29-
<SearchWithin label="Search" {...props}>
29+
<SearchWithin label="This is label" {...props}>
3030
<SearchField placeholder="Search" {...searchFieldProps} onChange={action('change')} onSubmit={action('submit')} />
3131
<Picker defaultSelectedKey="all" {...pickerProps} onSelectionChange={action('selectionChange')}>
3232
<Item key="all">All</Item>
@@ -41,7 +41,7 @@ function render(props: Omit<SpectrumSearchWithinProps, 'children'> = {}, searchF
4141

4242
function renderReverse(props: Omit<SpectrumSearchWithinProps, 'children'> = {}, searchFieldProps: SearchFieldProps = {}, pickerProps: Omit<SpectrumPickerProps<object>, 'children'> = {}) {
4343
return (
44-
<SearchWithin label="Search" {...props}>
44+
<SearchWithin label="Test label" {...props}>
4545
<Picker defaultSelectedKey="all" {...pickerProps} onSelectionChange={action('selectionChange')}>
4646
<Item key="all">All</Item>
4747
<Item key="campaigns">Campaigns</Item>
@@ -60,7 +60,7 @@ function ResizeSearchWithinApp(props) {
6060
return (
6161
<Flex direction="column" gap="size-200" alignItems="start">
6262
<div style={{width: state ? '300px' : '400px'}}>
63-
<SearchWithin label="Search" {...props} width="100%">
63+
<SearchWithin label="Test label" {...props} width="100%">
6464
<SearchField placeholder="Search" onChange={action('change')} onSubmit={action('submit')} />
6565
<Picker defaultSelectedKey="all" onSelectionChange={action('selectionChange')}>
6666
<Item key="all">All</Item>
@@ -119,7 +119,16 @@ CustomWidth30.storyName = 'Custom width: 30';
119119
export const LabelPositionSide = () => render({labelPosition: 'side'});
120120
LabelPositionSide.storyName = 'labelPosition: side';
121121

122-
export const NoLabel = () => render({label: undefined, 'aria-label': 'Aria Label'});
122+
export const NoVisibleLabel = () => render({label: undefined, 'aria-label': 'Test aria label'});
123+
124+
export const NoLabels = () => render({label: undefined});
125+
126+
export const ExternalLabel = () => (
127+
<div style={{display: 'flex', flexDirection: 'column'}}>
128+
<span id="foo">External label</span>
129+
{render({label: undefined, 'aria-labelledby': 'foo'})}
130+
</div>
131+
);
123132

124133
export const AutoFocusSearchField = () => render({}, {autoFocus: true});
125134
AutoFocusSearchField.storyName = 'autoFocus: true on SearchField';

packages/@react-spectrum/searchwithin/test/SearchWithin.test.js

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ describe('SearchWithin', function () {
5353
});
5454

5555
it('renders correctly', function () {
56-
let {getAllByText, getByRole, getByLabelText} = renderSearchWithin();
56+
let {getAllByText, getByRole} = renderSearchWithin();
5757

58-
let searchfield = getByLabelText('Test', {selector: 'input'});
58+
let searchfield = getByRole('searchbox');
5959
expect(searchfield).toBeVisible();
6060
expect(searchfield).toHaveAttribute('type', 'search');
6161

@@ -69,8 +69,8 @@ describe('SearchWithin', function () {
6969

7070
it('can type in search and get onChange', function () {
7171
let onChange = jest.fn();
72-
let {getByLabelText} = renderSearchWithin({}, {onChange});
73-
let searchfield = getByLabelText('Test', {selector: 'input'});
72+
let {getByRole} = renderSearchWithin({}, {onChange});
73+
let searchfield = getByRole('searchbox');
7474
expect(searchfield).toHaveAttribute('value', '');
7575

7676
act(() => {searchfield.focus();});
@@ -94,34 +94,34 @@ describe('SearchWithin', function () {
9494
});
9595

9696
it('searchfield and picker are labelled correctly', function () {
97-
let {getByRole, getAllByText, getByLabelText} = renderSearchWithin();
97+
let {getByRole, getAllByText} = renderSearchWithin();
9898

99-
let searchfield = getByLabelText('Test', {selector: 'input'});
99+
let searchfield = getByRole('searchbox');
100100
let picker = getByRole('button');
101101
let group = getByRole('group');
102102
triggerPress(picker);
103103

104104
let listbox = getByRole('listbox');
105105
let label = getAllByText('Test')[0];
106-
expect(listbox).toHaveAttribute('aria-labelledby', label.id);
107-
expect(searchfield).toHaveAttribute('aria-labelledby', label.id);
106+
expect(listbox).toHaveAttribute('aria-labelledby', `${label.id} ${picker.id}`);
107+
expect(searchfield).toHaveAttribute('aria-labelledby', `${label.id} ${picker.id} ${picker.childNodes[0].id}`);
108108
expect(group).toHaveAttribute('aria-labelledby', label.id);
109109
});
110110

111111
it('isDisabled=true disables both the searchfield and picker', function () {
112-
let {getByRole, getByLabelText} = renderSearchWithin({isDisabled: true});
112+
let {getByRole} = renderSearchWithin({isDisabled: true});
113113

114-
let searchfield = getByLabelText('Test', {selector: 'input'});
114+
let searchfield = getByRole('searchbox');
115115
let picker = getByRole('button');
116116

117117
expect(searchfield).toHaveAttribute('disabled');
118118
expect(picker).toHaveAttribute('disabled');
119119
});
120120

121121
it('autoFocus=true on searchfield will automatically focus the input', function () {
122-
let {getByLabelText} = renderSearchWithin({}, {autoFocus: true});
122+
let {getByRole} = renderSearchWithin({}, {autoFocus: true});
123123

124-
let searchfield = getByLabelText('Test', {selector: 'input'});
124+
let searchfield = getByRole('searchbox');
125125

126126
expect(searchfield).toHaveFocus();
127127
});
@@ -135,13 +135,13 @@ describe('SearchWithin', function () {
135135
});
136136

137137
it('slot props override props provided to children', function () {
138-
let {getByRole, getAllByText, getByLabelText} = renderSearchWithin(
138+
let {getByRole, getAllByText} = renderSearchWithin(
139139
{isDisabled: true, isRequired: false, label: 'Test1'},
140140
{isDisabled: false, isRequired: true, label: 'Test2', isQuiet: true},
141141
{isDisabled: false, isRequired: true, label: 'Test3', isQuiet: true}
142142
);
143143

144-
let searchfield = getByLabelText('Test1', {selector: 'input'});
144+
let searchfield = getByRole('searchbox');
145145
let picker = getByRole('button');
146146
let group = getByRole('group');
147147
triggerPress(picker);
@@ -152,20 +152,87 @@ describe('SearchWithin', function () {
152152

153153
expect(searchfield).not.toHaveAttribute('aria-required');
154154

155-
expect(searchfield).toHaveAttribute('aria-labelledby', label.id);
155+
expect(searchfield).toHaveAttribute('aria-labelledby', `${label.id} ${picker.id} ${picker.childNodes[0].id}`);
156156
expect(group).toHaveAttribute('aria-labelledby', label.id);
157157

158158
expect(searchfield.classList.contains('is-quiet')).toBeFalsy();
159159
expect(picker.classList.contains('spectrum-Dropdown--quiet')).toBeFalsy();
160160
});
161+
});
162+
163+
describe('SearchWithin labeling', function () {
164+
it('no label - default', function () {
165+
let {getByRole} = renderSearchWithin({label: undefined});
166+
167+
let group = getByRole('group');
168+
let searchfield = getByRole('searchbox');
169+
let picker = getByRole('button');
170+
171+
expect(group).toHaveAttribute('aria-label', 'Search');
172+
173+
expect(group).not.toHaveAttribute('aria-labelledby');
174+
expect(searchfield).toHaveAttribute('aria-labelledby', `${picker.id} ${picker.childNodes[0].id}`);
175+
expect(picker).toHaveAttribute('aria-labelledby', `${picker.id} ${picker.childNodes[0].id}`);
176+
});
161177

162-
it('searchfield and group are labelled correctly if aria-label used', function () {
163-
let {getByRole, getByLabelText} = renderSearchWithin({label: undefined, 'aria-label': 'Aria Label'});
178+
it('label = foo', function () {
179+
let {getByRole, getByText} = renderSearchWithin({label: 'foo'});
164180

165-
let searchfield = getByLabelText('Aria Label', {selector: 'input'});
166181
let group = getByRole('group');
182+
let searchfield = getByRole('searchbox');
183+
let picker = getByRole('button');
184+
let label = getByText('foo');
185+
186+
expect(group).not.toHaveAttribute('aria-label');
187+
188+
expect(group).toHaveAttribute('aria-labelledby', label.id);
189+
expect(searchfield).toHaveAttribute('aria-labelledby', `${label.id} ${picker.id} ${picker.childNodes[0].id}`);
190+
expect(picker).toHaveAttribute('aria-labelledby', `${label.id} ${picker.id} ${picker.childNodes[0].id}`);
191+
192+
expect(label).toHaveAttribute('for', searchfield.id);
193+
});
194+
195+
it('aria-label = bar', function () {
196+
let {getByRole} = renderSearchWithin({'aria-label': 'bar', label: undefined});
197+
198+
let group = getByRole('group');
199+
let searchfield = getByRole('searchbox');
200+
let picker = getByRole('button');
201+
202+
expect(group).toHaveAttribute('aria-label', 'bar');
203+
204+
expect(group).not.toHaveAttribute('aria-labelledby');
205+
expect(searchfield).toHaveAttribute('aria-labelledby', `${group.id} ${picker.id} ${picker.childNodes[0].id}`);
206+
expect(picker).toHaveAttribute('aria-labelledby', `${group.id} ${picker.id} ${picker.childNodes[0].id}`);
207+
});
208+
209+
it('aria-labelledby = {id}', function () {
210+
let {getByRole} = render(
211+
<Provider theme={theme}>
212+
<label id="id-foo-label" htmlFor="id-searchfield">
213+
Foo
214+
</label>
215+
<SearchWithin aria-labelledby="id-foo-label">
216+
<SearchField id="id-searchfield" placeholder="Search" />
217+
<Picker defaultSelectedKey="all">
218+
<Item key="all">All</Item>
219+
<Item key="campaigns">Campaigns</Item>
220+
<Item key="audiences">Audiences</Item>
221+
<Item key="tags">Tags</Item>
222+
</Picker>
223+
</SearchWithin>
224+
</Provider>
225+
);
226+
227+
let group = getByRole('group');
228+
let searchfield = getByRole('searchbox');
229+
let picker = getByRole('button');
230+
231+
expect(group).not.toHaveAttribute('aria-label');
167232

168-
expect(searchfield).toHaveAttribute('aria-label', 'Aria Label');
169-
expect(group).toHaveAttribute('aria-labelledby', 'Aria Label');
233+
expect(group).toHaveAttribute('aria-labelledby', 'id-foo-label');
234+
expect(searchfield).toHaveAttribute('aria-labelledby', `id-foo-label ${picker.id} ${picker.childNodes[0].id}`);
235+
expect(searchfield).toHaveAttribute('id', 'id-searchfield');
236+
expect(picker).toHaveAttribute('aria-labelledby', `id-foo-label ${picker.id} ${picker.childNodes[0].id}`);
170237
});
171238
});

0 commit comments

Comments
 (0)