Skip to content

Commit 91903a6

Browse files
Simon TaggartTheSisb
andauthored
fix(combobox): ensure listbox optionn ids in groups are always unique (#893)
Co-authored-by: Shadi <[email protected]>
1 parent e9c2be5 commit 91903a6

File tree

3 files changed

+32
-68
lines changed

3 files changed

+32
-68
lines changed

packages/paste-core/components/combobox/__tests__/index.spec.tsx

Lines changed: 30 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
import * as React from 'react';
22
import _ from 'lodash';
33
import {axe} from 'jest-axe';
4-
import {useUIDSeed} from '@twilio-paste/uid-library';
54
import {render, screen, fireEvent} from '@testing-library/react';
6-
import {Label} from '@twilio-paste/label';
7-
import {HelpText} from '@twilio-paste/help-text';
8-
import {InputElement} from '@twilio-paste/input';
95
import {Button} from '@twilio-paste/button';
106
import {CloseIcon} from '@twilio-paste/icons/esm/CloseIcon';
117
import {Box} from '@twilio-paste/box';
12-
import {useCombobox, Combobox, ComboboxInputWrapper, ComboboxListbox, ComboboxListboxOption} from '../src';
8+
import {useCombobox, Combobox} from '../src';
139
import {ComboboxProps} from '../src/types';
1410

1511
const items = ['Alert', 'Anchor', 'Button', 'Card', 'Heading', 'List', 'Modal', 'Paragraph'];
@@ -64,54 +60,6 @@ const ComboboxMock: React.FC<{}> = () => {
6460
);
6561
};
6662

67-
const ComboboxPartsMock: React.FC<{}> = () => {
68-
const {
69-
getComboboxProps,
70-
getInputProps,
71-
getItemProps,
72-
getLabelProps,
73-
getMenuProps,
74-
getToggleButtonProps,
75-
highlightedIndex,
76-
isOpen,
77-
selectedItem,
78-
} = useCombobox({items});
79-
const seed = useUIDSeed();
80-
return (
81-
<>
82-
<Label htmlFor="example-textbox" {...getLabelProps()} id="example-label" data-testid="label">
83-
Choose a component:
84-
</Label>
85-
<ComboboxInputWrapper {...getComboboxProps({role: 'combobox'})} aria-owns="example-combobox-menu">
86-
<InputElement
87-
type="text"
88-
{...getToggleButtonProps({tabIndex: 0})}
89-
{...getInputProps()}
90-
value={selectedItem || ''}
91-
// id="example-textbox"
92-
data-testid="textbox"
93-
aria-controls="example-combobox-menu"
94-
aria-describedby="example-helptext"
95-
aria-labelledby="example-label"
96-
/>
97-
</ComboboxInputWrapper>
98-
<ComboboxListbox {...getMenuProps()} aria-labelledby="example-label">
99-
{isOpen &&
100-
items.map((item, index) => (
101-
<ComboboxListboxOption
102-
{...getItemProps({item, index})}
103-
highlighted={highlightedIndex === index}
104-
key={seed(item)}
105-
>
106-
{item}
107-
</ComboboxListboxOption>
108-
))}
109-
</ComboboxListbox>
110-
<HelpText id="example-helptext">This is the help text</HelpText>
111-
</>
112-
);
113-
};
114-
11563
const GroupedMockCombobox: React.FC<{groupLabelTemplate?: ComboboxProps['groupLabelTemplate']}> = ({
11664
groupLabelTemplate,
11765
}) => {
@@ -124,7 +72,7 @@ const GroupedMockCombobox: React.FC<{groupLabelTemplate?: ComboboxProps['groupLa
12472
helpText="This is group"
12573
groupLabelTemplate={groupLabelTemplate}
12674
optionTemplate={(item: any) => <div>{item.label}</div>}
127-
itemToString={item => (item ? item.label : null)}
75+
itemToString={item => (item && typeof item !== 'string' ? item.label : null)}
12876
/>
12977
);
13078
};
@@ -191,31 +139,39 @@ describe('Combobox ', () => {
191139
});
192140

193141
it('should render a combobox with aria attributes', () => {
194-
render(<ComboboxPartsMock />);
142+
render(<ComboboxMock />);
195143
const renderedCombobox = screen.getByRole('combobox');
196144
expect(renderedCombobox.getAttribute('aria-haspopup')).toEqual('listbox');
197-
expect(renderedCombobox.getAttribute('aria-owns')).toEqual('example-combobox-menu');
198-
expect(renderedCombobox.getAttribute('aria-expanded')).toEqual('false');
145+
expect(renderedCombobox.getAttribute('aria-owns')).toEqual(screen.getByRole('listbox').id);
146+
expect(renderedCombobox.getAttribute('aria-expanded')).toEqual('true');
199147
});
200148

201149
it('should render a textbox with aria attributes', () => {
202-
render(<ComboboxPartsMock />);
150+
render(<ComboboxMock />);
203151
const renderedCombobox = screen.getByRole('textbox');
204-
expect(renderedCombobox.getAttribute('aria-controls')).toEqual('example-combobox-menu');
205-
expect(renderedCombobox.getAttribute('aria-labelledby')).toEqual('example-label');
206-
expect(renderedCombobox.getAttribute('aria-describedby')).toEqual('example-helptext');
152+
expect(renderedCombobox.getAttribute('aria-controls')).toEqual(screen.getByRole('listbox').id);
153+
expect(renderedCombobox.getAttribute('aria-labelledby')).toEqual(document.querySelector('label').id);
154+
expect(renderedCombobox.getAttribute('aria-describedby')).not.toEqual('');
207155
});
208156

209157
it('should render a list with aria attributes', () => {
210-
render(<ComboboxPartsMock />);
158+
render(<ComboboxMock />);
211159
const renderedCombobox = screen.getByRole('listbox');
212-
expect(renderedCombobox.getAttribute('aria-labelledby')).toEqual('example-label');
160+
expect(renderedCombobox.getAttribute('aria-labelledby')).toEqual(document.querySelector('label').id);
161+
});
162+
163+
it('should render a list with unique option ids', () => {
164+
render(<ComboboxMock />);
165+
const renderedOptions = screen.getAllByRole('option');
166+
const optionIDs = renderedOptions.map(option => option.id);
167+
const uniqueIDs = _.uniq(optionIDs);
168+
expect(uniqueIDs.length).toEqual(optionIDs.length);
213169
});
214170

215171
it('should render a label for that matches the input id', () => {
216-
render(<ComboboxPartsMock />);
217-
const renderedLabel = screen.getByTestId('label');
218-
const renderedTextbox = screen.getByTestId('textbox');
172+
render(<ComboboxMock />);
173+
const renderedLabel = document.querySelector('label');
174+
const renderedTextbox = screen.getByRole('textbox');
219175
expect(renderedLabel.getAttribute('for')).toEqual(renderedTextbox.getAttribute('id'));
220176
});
221177
});
@@ -243,6 +199,14 @@ describe('Combobox ', () => {
243199
expect(renderedListbox.querySelectorAll('[role="listbox"] > [role="option"]').length).toEqual(1);
244200
});
245201

202+
it('should render a listbox with groups of options that contains no duplicate ids', () => {
203+
render(<GroupedMockCombobox />);
204+
const renderedOptions = screen.getAllByRole('option');
205+
const optionIDs = renderedOptions.map(option => option.id);
206+
const uniqueIDs = _.uniq(optionIDs);
207+
expect(uniqueIDs.length).toEqual(optionIDs.length);
208+
});
209+
246210
it('should render a custom group label', () => {
247211
render(<GroupedMockCombobox groupLabelTemplate={groupName => <span>hi {groupName}</span>} />);
248212
const renderedGroups = screen.getAllByRole('group');

packages/paste-core/components/combobox/src/Combobox.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ const GroupedItems: React.FC<GroupItemsProps> = ({
105105
return (
106106
<Item
107107
item={item}
108-
index={index}
108+
index={UIDSeed(`${groupedItemKey}-${index}`)}
109109
key={UIDSeed(`${groupedItemKey}-${index}`)}
110110
getItemProps={getItemProps}
111111
highlightedIndex={highlightedIndex}

packages/paste-core/components/combobox/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export interface ComboboxProps extends Omit<InputProps, 'id' | 'type' | 'value'>
3030

3131
export interface ItemProps extends Pick<ComboboxProps, 'optionTemplate'> {
3232
item: Item;
33-
index: number;
33+
index: number | string;
3434
getItemProps: any;
3535
highlightedIndex: UseComboboxPrimitiveState<Item>['highlightedIndex'];
3636
inGroup?: boolean;

0 commit comments

Comments
 (0)