Skip to content

Commit 1c6a065

Browse files
authored
fix(combobox): address multiselectable state issues with new TagGroup component (#1589)
1 parent bcafe87 commit 1c6a065

File tree

7 files changed

+161
-54
lines changed

7 files changed

+161
-54
lines changed

packages/dropdowns.next/.size-snapshot.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
{
22
"index.cjs.js": {
3-
"bundled": 55344,
4-
"minified": 40274,
5-
"gzipped": 9098
3+
"bundled": 55890,
4+
"minified": 40577,
5+
"gzipped": 9273
66
},
77
"index.esm.js": {
8-
"bundled": 50695,
9-
"minified": 35869,
10-
"gzipped": 8627,
8+
"bundled": 51241,
9+
"minified": 36172,
10+
"gzipped": 8697,
1111
"treeshaked": {
1212
"rollup": {
13-
"code": 28209,
13+
"code": 28490,
1414
"import_statements": 1064
1515
},
1616
"webpack": {
17-
"code": 31080
17+
"code": 31363
1818
}
1919
}
2020
}

packages/dropdowns.next/src/elements/combobox/Combobox.spec.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,10 +220,14 @@ describe('Combobox', () => {
220220

221221
it('renders non-editable as expected', () => {
222222
const { getByTestId } = render(<TestCombobox isEditable={false} />);
223+
const combobox = getByTestId('combobox');
223224
const input = getByTestId('input');
224225

226+
expect(combobox).toHaveAttribute('tabIndex', '-1');
225227
expect(input).toHaveAttribute('readonly');
226228
expect(input).toHaveAttribute('hidden');
229+
expect(input).toHaveAttribute('aria-hidden', 'true');
230+
expect(input).toHaveStyleRule('display', 'none', { modifier: '&[aria-hidden="true"]' });
227231
});
228232

229233
it('renders `isMultiselectable` as expected', () => {
@@ -349,6 +353,41 @@ describe('Combobox', () => {
349353
expect(button).toHaveTextContent('test-1');
350354
});
351355

356+
it('handles tag group expansion as expected', async () => {
357+
const tagProps = { 'data-test-id': 'tag' } as HTMLAttributes<HTMLDivElement>;
358+
const { getByTestId } = render(
359+
<TestCombobox isMultiselectable maxTags={1}>
360+
<Option isSelected value="one" />
361+
<Option isSelected tagProps={tagProps} value="two" />
362+
</TestCombobox>
363+
);
364+
const combobox = getByTestId('combobox');
365+
const trigger = combobox.firstChild as HTMLElement;
366+
const input = getByTestId('input');
367+
const tag = getByTestId('tag');
368+
const button = tag.nextSibling as HTMLElement;
369+
370+
expect(tag).toHaveAttribute('hidden');
371+
expect(button).not.toHaveAttribute('hidden');
372+
373+
await user.click(button);
374+
375+
expect(tag).not.toHaveAttribute('hidden');
376+
expect(button).toHaveAttribute('hidden');
377+
expect(input).toHaveFocus();
378+
379+
await user.keyboard('{Tab}');
380+
381+
expect(tag).toHaveAttribute('hidden');
382+
expect(button).not.toHaveAttribute('hidden');
383+
384+
await user.click(trigger);
385+
386+
expect(tag).not.toHaveAttribute('hidden');
387+
expect(button).toHaveAttribute('hidden');
388+
expect(input).toHaveFocus();
389+
});
390+
352391
it('handles `renderValue` as expected', () => {
353392
const { getByTestId } = render(
354393
<TestCombobox renderValue={({ selection }) => `test-${(selection as ISelectedOption).value}`}>

packages/dropdowns.next/src/elements/combobox/Combobox.tsx

Lines changed: 51 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import React, {
1818
} from 'react';
1919
import PropTypes from 'prop-types';
2020
import { ThemeContext } from 'styled-components';
21-
import { IOption, IUseComboboxReturnValue, useCombobox } from '@zendeskgarden/container-combobox';
21+
import { IUseComboboxReturnValue, useCombobox } from '@zendeskgarden/container-combobox';
2222
import { DEFAULT_THEME, useText, useWindow } from '@zendeskgarden/react-theming';
2323
import { VALIDATION } from '@zendeskgarden/react-forms';
2424
import ChevronIcon from '@zendeskgarden/svg-icons/src/16/chevron-down-stroke.svg';
@@ -31,13 +31,13 @@ import {
3131
StyledInputIcon,
3232
StyledInput,
3333
StyledInputGroup,
34+
StyledTagsButton,
3435
StyledTrigger,
3536
StyledValue
3637
} from '../../views';
37-
import { StyledTagsButton } from '../../views/combobox/StyledTagsButton';
3838
import { Listbox } from './Listbox';
39-
import { Tag } from './Tag';
40-
import { toOptions, toString } from './utils';
39+
import { TagGroup } from './TagGroup';
40+
import { toOptions } from './utils';
4141

4242
const MAX_TAGS = 4;
4343

@@ -82,6 +82,7 @@ export const Combobox = forwardRef<HTMLDivElement, IComboboxProps>(
8282
) => {
8383
const { hasHint, hasMessage, labelProps, setLabelProps } = useFieldContext();
8484
const [isLabelHovered, setIsLabelHovered] = useState(false);
85+
const [isTagGroupExpanded, setIsTagGroupExpanded] = useState(false);
8586
const [optionTagProps, setOptionTagProps] = useState<Record<string, IOptionProps['tagProps']>>(
8687
{}
8788
);
@@ -99,7 +100,6 @@ export const Combobox = forwardRef<HTMLDivElement, IComboboxProps>(
99100
const triggerRef = useRef<HTMLDivElement>(null);
100101
const inputRef = useRef<HTMLInputElement>(null);
101102
const listboxRef = useRef<HTMLUListElement>(null);
102-
const tagsButtonRef = useRef<HTMLButtonElement>(null);
103103
/* istanbul ignore next */
104104
const theme = useContext(ThemeContext) || DEFAULT_THEME;
105105
const environment = useWindow(theme);
@@ -178,10 +178,18 @@ export const Combobox = forwardRef<HTMLDivElement, IComboboxProps>(
178178
...(getTriggerProps({
179179
onFocus: () => {
180180
hasFocus.current = true;
181+
182+
if (isMultiselectable) {
183+
setIsTagGroupExpanded(true);
184+
}
181185
},
182186
onBlur: event => {
183187
if (event.relatedTarget === null || !triggerRef.current?.contains(event.relatedTarget)) {
184188
hasFocus.current = false;
189+
190+
if (isMultiselectable) {
191+
setIsTagGroupExpanded(false);
192+
}
185193
}
186194
}
187195
}) as HTMLAttributes<HTMLDivElement>)
@@ -191,6 +199,7 @@ export const Combobox = forwardRef<HTMLDivElement, IComboboxProps>(
191199
hidden: !(isEditable && hasFocus.current),
192200
isBare,
193201
isCompact,
202+
isEditable,
194203
isMultiselectable,
195204
placeholder,
196205
...(getInputProps({
@@ -215,47 +224,14 @@ export const Combobox = forwardRef<HTMLDivElement, IComboboxProps>(
215224
return () => labelProps && setLabelProps(undefined);
216225
}, [getLabelProps, labelProps, setLabelProps]);
217226

218-
const Tags = ({ selectedOptions }: { selectedOptions: IOption[] }) => {
219-
const value = selectedOptions.length - maxTags;
220-
221-
return (
222-
<>
223-
{selectedOptions.map((option, index) => {
224-
const key = toString(option);
225-
const disabled = isDisabled || option.disabled;
226-
const hidden = !hasFocus.current && index >= maxTags;
227-
228-
return (
229-
<Tag
230-
key={key}
231-
hidden={hidden}
232-
option={{ ...option, disabled }}
233-
tooltipZIndex={listboxZIndex ? listboxZIndex + 1 : undefined}
234-
{...optionTagProps[key]}
235-
/>
236-
);
237-
})}
238-
{!hasFocus.current && selectedOptions.length > maxTags && (
239-
<StyledTagsButton
240-
disabled={isDisabled}
241-
isCompact={isCompact}
242-
onClick={() => isEditable && inputRef.current?.focus()}
243-
tabIndex={-1}
244-
type="button"
245-
ref={tagsButtonRef}
246-
>
247-
{renderExpandTags
248-
? renderExpandTags(value)
249-
: expandTags?.replace('{{value}}', value.toString())}
250-
</StyledTagsButton>
251-
)}
252-
</>
253-
);
254-
};
255-
256227
return (
257228
<ComboboxContext.Provider value={contextValue}>
258-
<StyledCombobox isCompact={isCompact} {...props} ref={ref}>
229+
<StyledCombobox
230+
isCompact={isCompact}
231+
tabIndex={-1} // HACK: otherwise screenreaders can't read the label
232+
{...props}
233+
ref={ref}
234+
>
259235
<StyledTrigger {...triggerProps}>
260236
<StyledContainer>
261237
{startIcon && (
@@ -265,7 +241,37 @@ export const Combobox = forwardRef<HTMLDivElement, IComboboxProps>(
265241
)}
266242
<StyledInputGroup>
267243
{isMultiselectable && Array.isArray(selection) && (
268-
<Tags selectedOptions={selection} />
244+
<TagGroup
245+
isDisabled={isDisabled}
246+
isExpanded={isTagGroupExpanded}
247+
maxTags={maxTags}
248+
optionTagProps={optionTagProps}
249+
selection={selection}
250+
>
251+
{selection.length > maxTags && (
252+
<StyledTagsButton
253+
disabled={isDisabled}
254+
hidden={isTagGroupExpanded}
255+
isCompact={isCompact}
256+
onClick={event => {
257+
if (isEditable) {
258+
event.stopPropagation();
259+
inputRef.current?.focus();
260+
}
261+
}}
262+
tabIndex={-1}
263+
type="button"
264+
>
265+
{(() => {
266+
const value = selection.length - maxTags;
267+
268+
return renderExpandTags
269+
? renderExpandTags(value)
270+
: expandTags?.replace('{{value}}', value.toString());
271+
})()}
272+
</StyledTagsButton>
273+
)}
274+
</TagGroup>
269275
)}
270276
{!(isEditable && hasFocus.current) && (
271277
<StyledValue
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Copyright Zendesk, Inc.
3+
*
4+
* Use of this source code is governed under the Apache License, Version 2.0
5+
* found at http://www.apache.org/licenses/LICENSE-2.0.
6+
*/
7+
8+
import React, { PropsWithChildren } from 'react';
9+
import { toString } from './utils';
10+
import { Tag } from './Tag';
11+
import { ITagGroupProps } from '../../types';
12+
13+
export const TagGroup = ({
14+
children,
15+
isDisabled,
16+
isExpanded,
17+
listboxZIndex,
18+
maxTags,
19+
optionTagProps,
20+
selection
21+
}: PropsWithChildren<ITagGroupProps>) => (
22+
<>
23+
{selection.map((option, index) => {
24+
const key = toString(option);
25+
const disabled = isDisabled || option.disabled;
26+
27+
return (
28+
<Tag
29+
key={key}
30+
hidden={!isExpanded && index >= maxTags}
31+
option={{ ...option, disabled }}
32+
tooltipZIndex={listboxZIndex ? listboxZIndex + 1 : undefined}
33+
{...optionTagProps[key]}
34+
/>
35+
);
36+
})}
37+
{children}
38+
</>
39+
);
40+
41+
TagGroup.displayName = 'TagGroup';

packages/dropdowns.next/src/types/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,18 @@ export interface ITagProps extends Omit<IBaseTagProps, 'isRound' | 'size'> {
237237
/** @ignore Sets the `z-index` of the tooltip */
238238
tooltipZIndex?: number;
239239
}
240+
241+
export interface ITagGroupProps {
242+
/** Indicates that the tag group is not interactive */
243+
isDisabled?: boolean;
244+
/** Determines tag group expansion */
245+
isExpanded: boolean;
246+
/** Indicates the `z-index` of the listbox */
247+
listboxZIndex?: number;
248+
/** Determines the maximum number of tags displayed when the tag group is collapsed */
249+
maxTags: number;
250+
/** Provides tag props for the associated option */
251+
optionTagProps: Record<string, IOptionProps['tagProps']>;
252+
/** Provides the current selection */
253+
selection: IOption[];
254+
}

packages/dropdowns.next/src/views/combobox/StyledInput.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const COMPONENT_ID = 'dropdowns.combobox.input';
1919
interface IStyledInputProps extends ThemeProps<DefaultTheme> {
2020
isBare?: boolean;
2121
isCompact?: boolean;
22+
isEditable?: boolean;
2223
isMultiselectable?: boolean;
2324
}
2425

@@ -84,7 +85,11 @@ export const StyledInput = styled.input.attrs({
8485
8586
&[hidden] {
8687
display: revert;
87-
${hideVisually()}
88+
${props => props.isEditable && hideVisually()}
89+
}
90+
91+
&[aria-hidden='true'] {
92+
display: none;
8893
}
8994
9095
${props => retrieveComponentStyles(COMPONENT_ID, props)};

packages/dropdowns.next/src/views/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export * from './combobox/StyledOptionIcon';
2424
export * from './combobox/StyledOptionMeta';
2525
export * from './combobox/StyledOptionTypeIcon';
2626
export * from './combobox/StyledTag';
27+
export * from './combobox/StyledTagsButton';
2728
export * from './combobox/StyledTrigger';
2829
export * from './combobox/StyledValue';
2930
export * from './menu/StyledFloatingMenu';

0 commit comments

Comments
 (0)