Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

react-components: Add rendering combobox prop to Autocomplete component #92

Closed
wants to merge 8 commits into from
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,15 @@ Default.args = {
getOptionLabel: (option) => option.title,
};

export const Combobox = Template.bind({});

Combobox.args = {
options: top100Films,
placeholder: 'Select a movie',
getOptionLabel: (option) => option.title,
combobox: true,
};

// @ts-ignore
export const Multiple: ComponentStory<typeof Autocomplete<OptionFilmType, true>> = Template
.bind({});
Expand Down
237 changes: 182 additions & 55 deletions packages/react-components/src/Autocomplete/autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
AutocompleteValue,
} from '../hooks';
import { Popover } from '../Popover';
import { Popper } from '../Popper';
import { TextField } from '../TextField';
import { Typography } from '../Typography';
import { Box } from '../Box';
Expand Down Expand Up @@ -63,6 +64,10 @@ export type AutocompleteProps<T, Multiple extends boolean | undefined = undefine
* If `true`, the popup search input will be hidden.
*/
disableSearch?: boolean;
/**
* If `true`, the root will be a search.
*/
combobox?: boolean;
/**
* If `true`, the autocomplete will be disabled.
*/
Expand Down Expand Up @@ -140,12 +145,12 @@ const stylesRoot = (size: AutocompleteProps<any>['size']) => css({
textAlign: 'left',
cursor: 'pointer',
fontFamily: 'inherit',
height: 'var(--pv-size-base-8)',
minHeight: 'var(--pv-size-base-8)',
...(size === 'small' && {
height: 'var(--pv-size-base-6)',
minHeight: 'var(--pv-size-base-6)',
}),
...(size === 'medium' && {
height: 'var(--pv-size-base-7)',
minHeight: 'var(--pv-size-base-7)',
}),
'&:hover': {
backgroundColor: 'var(--pv-color-gray-3)',
Expand All @@ -169,20 +174,41 @@ const stylesRoot = (size: AutocompleteProps<any>['size']) => css({
},
});

const stylesRootSearchWrapper = (size: AutocompleteProps<any>['size']) => css({
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
columnGap: 'var(--pv-size-base)',
minHeight: 'var(--pv-size-base-8)',
...(size === 'small' && {
minHeight: 'var(--pv-size-base-6)',
}),
...(size === 'medium' && {
minHeight: 'var(--pv-size-base-7)',
}),
'&:focus-within': {
backgroundColor: 'var(--pv-color-secondary-tint-5)',
borderColor: 'var(--pv-color-secondary-tint-3)',
},
});

const stylesRootMultiple = () => css({
label: 'multiple',
display: 'inline-flex',
alignItems: 'center',
});

const stylesInputArrowIcon = () => css({
const stylesInputArrowIcon = (open: boolean) => css({
label: 'Autocomplete-arrow-icon',
position: 'absolute',
right: '0px',
top: 'calc(50% - 12px)',
pointerEvents: 'none',
margin: '0px var(--pv-size-base)',
color: 'var(--pv-color-gray-10)',
...(open && {
transform: 'rotate(180deg)',
}),
'&[aria-disabled="true"]': {
color: 'var(--pv-color-gray-7)',
},
Expand Down Expand Up @@ -237,27 +263,58 @@ const stylesInputSearch = () => css({
padding: 'var(--pv-size-base-3) var(--pv-size-base-3) var(--pv-size-base-2)',
});

const stylesRootInputSearch = (size: AutocompleteProps<any>['size']) => css({
label: 'Autocomplete-root-input-search',
flex: 1,
marginLeft: 'var(--pv-size-base)',
minWidth: '30%',
'& input': {
backgroundColor: 'transparent',
border: 'none',
padding: '0',
// Set height for combobox search same as tag height
height: 'var(--pv-size-base-6)',
...(size === 'small' && {
height: 'var(--pv-size-base-5)',
}),
'&:hover': {
backgroundColor: 'transparent',
},
},
});

const stylesListBoxState = () => css({
label: 'Autocomplete-listbox-state',
padding: 'var(--pv-size-base-3) var(--pv-size-base-2)',
});

const stylesPopover = () => css({
label: 'Autocomplete-popover',
minWidth: 240,
minWidth: '240px',
outline: 0,
marginTop: '1px',
borderRadius: '4px',
maxWidth: 'calc(100% - 32px)',
maxHeight: 'calc(100% - 32px)',
minHeight: '16px',
backgroundColor: 'var(--pv-color-white)',
boxShadow: 'var(--pv-shadow-light-low)',
zIndex: 1300,
});

const stylesTagsList = () => css({
const stylesTagsList = (isEmbedded = false) => css({
label: 'Autocomplete-tags-list',
overflow: 'hidden',
width: '100%',
...(!isEmbedded && {
width: '100%',
}),
});

const stylesTag = (tagsLength: number, limitTags: number, size: AutocompleteProps<any>['size']) => css({
label: 'Autocomplete-tag',
borderRadius: '2px',
borderColor: 'var(--pv-color-gray-7)',
margin: '0 var(--pv-size-base) 0 0',
margin: '2px 0',
...(tagsLength === 1 && {
maxWidth: 'calc(100% - var(--pv-size-base))',
}),
Expand Down Expand Up @@ -321,6 +378,7 @@ export const Autocomplete = <T, Multiple extends boolean | undefined = undefined
size,
placeholder,
disableSearch,
combobox,
disabled = false,
noOptionsText,
loading,
Expand Down Expand Up @@ -403,14 +461,14 @@ export const Autocomplete = <T, Multiple extends boolean | undefined = undefined
return null;
}

if (Array.isArray(value)) {
if (Array.isArray(value) && !combobox && limitTags) {
const more = (value.length > limitTags) ? (value.length - limitTags) : 0;
const valueLimits = more > 0 ? value.slice(0, limitTags) : value;

return (
<>
<div
className={stylesTagsList()}
className={stylesTagsList(combobox)}
>
{valueLimits.map((v, index) => (
<Chip
Expand All @@ -436,56 +494,124 @@ export const Autocomplete = <T, Multiple extends boolean | undefined = undefined
);
}

if (Array.isArray(value)) {
return value.map((v, index) => (
<Chip
{...getTagProps(v, index)}
color="default"
variant="contained"
className={stylesTag(value.length, undefined, size)}
>
{getOptionLabel(v)}
</Chip>
));
}

return getOptionLabel(value as T);
};

const renderedValue = renderValue();
const isValueEmpty = renderedValue === null;

const defaultRenderRoot: AutocompleteProps<T, Multiple>['renderRoot'] = (propsRoot, valueRoot) => (
<div
className={stylesContainer()}
>
<Typography
{...propsRoot}
noWrap
component="button"
variant="c1"
color={isValueEmpty ? 'gray-9' : 'black'}
className={cx({
[stylesRoot(size)]: true,
[stylesRootMultiple()]: multiple,
[className]: !!className,
})}
aria-invalid={error || undefined}
type="button"
const defaultRenderRoot: AutocompleteProps<T, Multiple>['renderRoot'] = (propsRoot, valueRoot) => {
if (combobox) {
return (
<div className={stylesContainer()}>
<Box
{...propsRoot}
component="button"
type="button"
className={cx({
[stylesRoot(size)]: true,
[stylesRootSearchWrapper(size)]: true,
})}
tabIndex={-1}
onBlur={popoverProps.onClose}
disabled={disabled}
aria-invalid={error || undefined}
>
{multiple ? renderedValue : null}
<TextField
disabled={disabled}
inputProps={otherInputProps}
className={stylesRootInputSearch(size)}
onChange={onChange}
onKeyDown={popoverProps.onKeyDown}
value={searchValue}
size={size}
placeholder={placeholder}
readOnly={readOnly}
/>
</Box>
<ArrowDropDownIcon
className={stylesInputArrowIcon(popoverProps.open)}
aria-disabled={disabled}
aria-hidden
/>
<input
type="text"
value={isValueEmpty ? '' : JSON.stringify(valueRoot)}
tabIndex={-1}
aria-hidden="true"
disabled={disabled}
className={stylesNativeInput()}
autoComplete="off"
id={id}
name={name}
required={required}
readOnly={readOnly}
onChange={() => { }}
/>
</div>
);
}

return (
<div
className={stylesContainer()}
>
{isValueEmpty ? placeholder : renderedValue}
</Typography>
<ArrowDropDownIcon
className={stylesInputArrowIcon()}
aria-disabled={disabled}
aria-hidden
/>
<input
type="text"
value={isValueEmpty ? '' : JSON.stringify(valueRoot)}
tabIndex={-1}
aria-hidden="true"
disabled={disabled}
className={stylesNativeInput()}
autoComplete="off"
id={id}
name={name}
required={required}
readOnly={readOnly}
onChange={() => {}}
/>
</div>
);
<Typography
{...propsRoot}
noWrap
component="button"
variant="c1"
color={isValueEmpty ? 'gray-9' : 'black'}
className={cx({
[stylesRoot(size)]: true,
[stylesRootMultiple()]: multiple,
[className]: !!className,
})}
aria-invalid={error || undefined}
type="button"
>
{isValueEmpty ? placeholder : renderedValue}
</Typography>
<ArrowDropDownIcon
className={stylesInputArrowIcon(popoverProps.open)}
aria-disabled={disabled}
aria-hidden
/>
<input
type="text"
value={isValueEmpty ? '' : JSON.stringify(valueRoot)}
tabIndex={-1}
aria-hidden="true"
disabled={disabled}
className={stylesNativeInput()}
autoComplete="off"
id={id}
name={name}
required={required}
readOnly={readOnly}
onChange={() => { }}
/>
</div>
);
};

const renderOption = renderOptionProp || defaultRenderOption;
const renderRoot = renderRootProp || defaultRenderRoot;
const PopperComponent = combobox ? Popper : Popover;

const renderListOption = (option: T, index: number) => {
const optionProps = getOptionProps(option, index);
Expand All @@ -507,13 +633,15 @@ export const Autocomplete = <T, Multiple extends boolean | undefined = undefined
{errorText}
</Typography>
)}
<Popover
placement="bottom-start"
<PopperComponent
placement="top-start"
allowUseSameWidth
disablePortal={false}
{...popoverProps}
tabIndex={-1}
className={stylesPopover()}
>
{!disableSearch && (
{!disableSearch && !combobox && (
<Box
borderColor="gray-3"
borderPosition="bottom"
Expand Down Expand Up @@ -595,7 +723,7 @@ export const Autocomplete = <T, Multiple extends boolean | undefined = undefined
</Button>
</Box>
)}
</Popover>
</PopperComponent>
</>
);
};
Expand All @@ -606,7 +734,6 @@ Autocomplete.defaultProps = {
noOptionsText: 'No options',
loading: false,
loadingText: 'Loading...',
limitTags: 2,
required: false,
allowCreateOption: false,
createOptionText: 'Create new',
Expand Down
Loading
Loading