Skip to content

Commit

Permalink
fix(combobox): address multiselectable state issues with new `TagGrou…
Browse files Browse the repository at this point in the history
…p` component (#1589)
  • Loading branch information
jzempel committed Aug 3, 2023
1 parent bcafe87 commit 1c6a065
Show file tree
Hide file tree
Showing 7 changed files with 161 additions and 54 deletions.
16 changes: 8 additions & 8 deletions packages/dropdowns.next/.size-snapshot.json
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
{
"index.cjs.js": {
"bundled": 55344,
"minified": 40274,
"gzipped": 9098
"bundled": 55890,
"minified": 40577,
"gzipped": 9273
},
"index.esm.js": {
"bundled": 50695,
"minified": 35869,
"gzipped": 8627,
"bundled": 51241,
"minified": 36172,
"gzipped": 8697,
"treeshaked": {
"rollup": {
"code": 28209,
"code": 28490,
"import_statements": 1064
},
"webpack": {
"code": 31080
"code": 31363
}
}
}
Expand Down
39 changes: 39 additions & 0 deletions packages/dropdowns.next/src/elements/combobox/Combobox.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,14 @@ describe('Combobox', () => {

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

expect(combobox).toHaveAttribute('tabIndex', '-1');
expect(input).toHaveAttribute('readonly');
expect(input).toHaveAttribute('hidden');
expect(input).toHaveAttribute('aria-hidden', 'true');
expect(input).toHaveStyleRule('display', 'none', { modifier: '&[aria-hidden="true"]' });
});

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

it('handles tag group expansion as expected', async () => {
const tagProps = { 'data-test-id': 'tag' } as HTMLAttributes<HTMLDivElement>;
const { getByTestId } = render(
<TestCombobox isMultiselectable maxTags={1}>
<Option isSelected value="one" />
<Option isSelected tagProps={tagProps} value="two" />
</TestCombobox>
);
const combobox = getByTestId('combobox');
const trigger = combobox.firstChild as HTMLElement;
const input = getByTestId('input');
const tag = getByTestId('tag');
const button = tag.nextSibling as HTMLElement;

expect(tag).toHaveAttribute('hidden');
expect(button).not.toHaveAttribute('hidden');

await user.click(button);

expect(tag).not.toHaveAttribute('hidden');
expect(button).toHaveAttribute('hidden');
expect(input).toHaveFocus();

await user.keyboard('{Tab}');

expect(tag).toHaveAttribute('hidden');
expect(button).not.toHaveAttribute('hidden');

await user.click(trigger);

expect(tag).not.toHaveAttribute('hidden');
expect(button).toHaveAttribute('hidden');
expect(input).toHaveFocus();
});

it('handles `renderValue` as expected', () => {
const { getByTestId } = render(
<TestCombobox renderValue={({ selection }) => `test-${(selection as ISelectedOption).value}`}>
Expand Down
96 changes: 51 additions & 45 deletions packages/dropdowns.next/src/elements/combobox/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import React, {
} from 'react';
import PropTypes from 'prop-types';
import { ThemeContext } from 'styled-components';
import { IOption, IUseComboboxReturnValue, useCombobox } from '@zendeskgarden/container-combobox';
import { IUseComboboxReturnValue, useCombobox } from '@zendeskgarden/container-combobox';
import { DEFAULT_THEME, useText, useWindow } from '@zendeskgarden/react-theming';
import { VALIDATION } from '@zendeskgarden/react-forms';
import ChevronIcon from '@zendeskgarden/svg-icons/src/16/chevron-down-stroke.svg';
Expand All @@ -31,13 +31,13 @@ import {
StyledInputIcon,
StyledInput,
StyledInputGroup,
StyledTagsButton,
StyledTrigger,
StyledValue
} from '../../views';
import { StyledTagsButton } from '../../views/combobox/StyledTagsButton';
import { Listbox } from './Listbox';
import { Tag } from './Tag';
import { toOptions, toString } from './utils';
import { TagGroup } from './TagGroup';
import { toOptions } from './utils';

const MAX_TAGS = 4;

Expand Down Expand Up @@ -82,6 +82,7 @@ export const Combobox = forwardRef<HTMLDivElement, IComboboxProps>(
) => {
const { hasHint, hasMessage, labelProps, setLabelProps } = useFieldContext();
const [isLabelHovered, setIsLabelHovered] = useState(false);
const [isTagGroupExpanded, setIsTagGroupExpanded] = useState(false);
const [optionTagProps, setOptionTagProps] = useState<Record<string, IOptionProps['tagProps']>>(
{}
);
Expand All @@ -99,7 +100,6 @@ export const Combobox = forwardRef<HTMLDivElement, IComboboxProps>(
const triggerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const tagsButtonRef = useRef<HTMLButtonElement>(null);
/* istanbul ignore next */
const theme = useContext(ThemeContext) || DEFAULT_THEME;
const environment = useWindow(theme);
Expand Down Expand Up @@ -178,10 +178,18 @@ export const Combobox = forwardRef<HTMLDivElement, IComboboxProps>(
...(getTriggerProps({
onFocus: () => {
hasFocus.current = true;

if (isMultiselectable) {
setIsTagGroupExpanded(true);
}
},
onBlur: event => {
if (event.relatedTarget === null || !triggerRef.current?.contains(event.relatedTarget)) {
hasFocus.current = false;

if (isMultiselectable) {
setIsTagGroupExpanded(false);
}
}
}
}) as HTMLAttributes<HTMLDivElement>)
Expand All @@ -191,6 +199,7 @@ export const Combobox = forwardRef<HTMLDivElement, IComboboxProps>(
hidden: !(isEditable && hasFocus.current),
isBare,
isCompact,
isEditable,
isMultiselectable,
placeholder,
...(getInputProps({
Expand All @@ -215,47 +224,14 @@ export const Combobox = forwardRef<HTMLDivElement, IComboboxProps>(
return () => labelProps && setLabelProps(undefined);
}, [getLabelProps, labelProps, setLabelProps]);

const Tags = ({ selectedOptions }: { selectedOptions: IOption[] }) => {
const value = selectedOptions.length - maxTags;

return (
<>
{selectedOptions.map((option, index) => {
const key = toString(option);
const disabled = isDisabled || option.disabled;
const hidden = !hasFocus.current && index >= maxTags;

return (
<Tag
key={key}
hidden={hidden}
option={{ ...option, disabled }}
tooltipZIndex={listboxZIndex ? listboxZIndex + 1 : undefined}
{...optionTagProps[key]}
/>
);
})}
{!hasFocus.current && selectedOptions.length > maxTags && (
<StyledTagsButton
disabled={isDisabled}
isCompact={isCompact}
onClick={() => isEditable && inputRef.current?.focus()}
tabIndex={-1}
type="button"
ref={tagsButtonRef}
>
{renderExpandTags
? renderExpandTags(value)
: expandTags?.replace('{{value}}', value.toString())}
</StyledTagsButton>
)}
</>
);
};

return (
<ComboboxContext.Provider value={contextValue}>
<StyledCombobox isCompact={isCompact} {...props} ref={ref}>
<StyledCombobox
isCompact={isCompact}
tabIndex={-1} // HACK: otherwise screenreaders can't read the label
{...props}
ref={ref}
>
<StyledTrigger {...triggerProps}>
<StyledContainer>
{startIcon && (
Expand All @@ -265,7 +241,37 @@ export const Combobox = forwardRef<HTMLDivElement, IComboboxProps>(
)}
<StyledInputGroup>
{isMultiselectable && Array.isArray(selection) && (
<Tags selectedOptions={selection} />
<TagGroup
isDisabled={isDisabled}
isExpanded={isTagGroupExpanded}
maxTags={maxTags}
optionTagProps={optionTagProps}
selection={selection}
>
{selection.length > maxTags && (
<StyledTagsButton
disabled={isDisabled}
hidden={isTagGroupExpanded}
isCompact={isCompact}
onClick={event => {
if (isEditable) {
event.stopPropagation();
inputRef.current?.focus();
}
}}
tabIndex={-1}
type="button"
>
{(() => {
const value = selection.length - maxTags;

return renderExpandTags
? renderExpandTags(value)
: expandTags?.replace('{{value}}', value.toString());
})()}
</StyledTagsButton>
)}
</TagGroup>
)}
{!(isEditable && hasFocus.current) && (
<StyledValue
Expand Down
41 changes: 41 additions & 0 deletions packages/dropdowns.next/src/elements/combobox/TagGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Copyright Zendesk, Inc.
*
* Use of this source code is governed under the Apache License, Version 2.0
* found at http://www.apache.org/licenses/LICENSE-2.0.
*/

import React, { PropsWithChildren } from 'react';
import { toString } from './utils';
import { Tag } from './Tag';
import { ITagGroupProps } from '../../types';

export const TagGroup = ({
children,
isDisabled,
isExpanded,
listboxZIndex,
maxTags,
optionTagProps,
selection
}: PropsWithChildren<ITagGroupProps>) => (
<>
{selection.map((option, index) => {
const key = toString(option);
const disabled = isDisabled || option.disabled;

return (
<Tag
key={key}
hidden={!isExpanded && index >= maxTags}
option={{ ...option, disabled }}
tooltipZIndex={listboxZIndex ? listboxZIndex + 1 : undefined}
{...optionTagProps[key]}
/>
);
})}
{children}
</>
);

TagGroup.displayName = 'TagGroup';
15 changes: 15 additions & 0 deletions packages/dropdowns.next/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,18 @@ export interface ITagProps extends Omit<IBaseTagProps, 'isRound' | 'size'> {
/** @ignore Sets the `z-index` of the tooltip */
tooltipZIndex?: number;
}

export interface ITagGroupProps {
/** Indicates that the tag group is not interactive */
isDisabled?: boolean;
/** Determines tag group expansion */
isExpanded: boolean;
/** Indicates the `z-index` of the listbox */
listboxZIndex?: number;
/** Determines the maximum number of tags displayed when the tag group is collapsed */
maxTags: number;
/** Provides tag props for the associated option */
optionTagProps: Record<string, IOptionProps['tagProps']>;
/** Provides the current selection */
selection: IOption[];
}
7 changes: 6 additions & 1 deletion packages/dropdowns.next/src/views/combobox/StyledInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const COMPONENT_ID = 'dropdowns.combobox.input';
interface IStyledInputProps extends ThemeProps<DefaultTheme> {
isBare?: boolean;
isCompact?: boolean;
isEditable?: boolean;
isMultiselectable?: boolean;
}

Expand Down Expand Up @@ -84,7 +85,11 @@ export const StyledInput = styled.input.attrs({
&[hidden] {
display: revert;
${hideVisually()}
${props => props.isEditable && hideVisually()}
}
&[aria-hidden='true'] {
display: none;
}
${props => retrieveComponentStyles(COMPONENT_ID, props)};
Expand Down
1 change: 1 addition & 0 deletions packages/dropdowns.next/src/views/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export * from './combobox/StyledOptionIcon';
export * from './combobox/StyledOptionMeta';
export * from './combobox/StyledOptionTypeIcon';
export * from './combobox/StyledTag';
export * from './combobox/StyledTagsButton';
export * from './combobox/StyledTrigger';
export * from './combobox/StyledValue';
export * from './menu/StyledFloatingMenu';
Expand Down

0 comments on commit 1c6a065

Please sign in to comment.