diff --git a/packages/react-components/src/Autocomplete/autocomplete.stories.tsx b/packages/react-components/src/Autocomplete/autocomplete.stories.tsx new file mode 100644 index 00000000..1a7f233f --- /dev/null +++ b/packages/react-components/src/Autocomplete/autocomplete.stories.tsx @@ -0,0 +1,131 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Autocomplete } from './index'; + +const top100Films = [ + { title: 'The Shawshank Redemption', year: 1994 }, + { title: 'The Godfather', year: 1972 }, + { title: 'The Godfather: Part II', year: 1974 }, + { title: 'The Dark Knight', year: 2008 }, + { title: '12 Angry Men', year: 1957 }, + { title: "Schindler's List", year: 1993 }, + { title: 'Pulp Fiction', year: 1994 }, + { title: 'The Lord of the Rings: The Return of the King', year: 2003 }, + { title: 'The Good, the Bad and the Ugly', year: 1966 }, + { title: 'Fight Club', year: 1999 }, + { title: 'The Lord of the Rings: The Fellowship of the Ring', year: 2001 }, + { title: 'Star Wars: Episode V - The Empire Strikes Back', year: 1980 }, + { title: 'Forrest Gump', year: 1994 }, + { title: 'Inception', year: 2010 }, + { title: 'The Lord of the Rings: The Two Towers', year: 2002 }, + { title: "One Flew Over the Cuckoo's Nest", year: 1975 }, + { title: 'Goodfellas', year: 1990 }, + { title: 'The Matrix', year: 1999 }, + { title: 'Seven Samurai', year: 1954 }, + { title: 'Star Wars: Episode IV - A New Hope', year: 1977 }, + { title: 'City of God', year: 2002 }, + { title: 'Se7en', year: 1995 }, + { title: 'The Silence of the Lambs', year: 1991 }, + { title: "It's a Wonderful Life", year: 1946 }, + { title: 'Life Is Beautiful', year: 1997 }, + { title: 'The Usual Suspects', year: 1995 }, + { title: 'Léon: The Professional', year: 1994 }, + { title: 'Spirited Away', year: 2001 }, + { title: 'Saving Private Ryan', year: 1998 }, + { title: 'Once Upon a Time in the West', year: 1968 }, + { title: 'American History X', year: 1998 }, + { title: 'Interstellar', year: 2014 }, + { title: 'Casablanca', year: 1942 }, + { title: 'City Lights', year: 1931 }, + { title: 'Psycho', year: 1960 }, + { title: 'The Green Mile', year: 1999 }, + { title: 'The Intouchables', year: 2011 }, + { title: 'Modern Times', year: 1936 }, + { title: 'Raiders of the Lost Ark', year: 1981 }, + { title: 'Rear Window', year: 1954 }, + { title: 'The Pianist', year: 2002 }, + { title: 'The Departed', year: 2006 }, + { title: 'Terminator 2: Judgment Day', year: 1991 }, + { title: 'Back to the Future', year: 1985 }, + { title: 'Whiplash', year: 2014 }, + { title: 'Gladiator', year: 2000 }, + { title: 'Memento', year: 2000 }, + { title: 'The Prestige', year: 2006 }, + { title: 'The Lion King', year: 1994 }, + { title: 'Apocalypse Now', year: 1979 }, + { title: 'Alien', year: 1979 }, + { title: 'Sunset Boulevard', year: 1950 }, + { title: 'Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb', year: 1964 }, + { title: 'The Great Dictator', year: 1940 }, + { title: 'Cinema Paradiso', year: 1988 }, + { title: 'The Lives of Others', year: 2006 }, + { title: 'Grave of the Fireflies', year: 1988 }, + { title: 'Paths of Glory', year: 1957 }, + { title: 'Django Unchained', year: 2012 }, + { title: 'The Shining', year: 1980 }, + { title: 'WALL·E', year: 2008 }, + { title: 'American Beauty', year: 1999 }, + { title: 'The Dark Knight Rises', year: 2012 }, + { title: 'Princess Mononoke', year: 1997 }, + { title: 'Aliens', year: 1986 }, + { title: 'Oldboy', year: 2003 }, + { title: 'Once Upon a Time in America', year: 1984 }, + { title: 'Witness for the Prosecution', year: 1957 }, + { title: 'Das Boot', year: 1981 }, + { title: 'Citizen Kane', year: 1941 }, + { title: 'North by Northwest', year: 1959 }, + { title: 'Vertigo', year: 1958 }, + { title: 'Star Wars: Episode VI - Return of the Jedi', year: 1983 }, + { title: 'Reservoir Dogs', year: 1992 }, + { title: 'Braveheart', year: 1995 }, + { title: 'M', year: 1931 }, + { title: 'Requiem for a Dream', year: 2000 }, + { title: 'Amélie', year: 2001 }, + { title: 'A Clockwork Orange', year: 1971 }, + { title: 'Like Stars on Earth', year: 2007 }, + { title: 'Taxi Driver', year: 1976 }, + { title: 'Lawrence of Arabia', year: 1962 }, + { title: 'Double Indemnity', year: 1944 }, + { title: 'Eternal Sunshine of the Spotless Mind', year: 2004 }, + { title: 'Amadeus', year: 1984 }, + { title: 'To Kill a Mockingbird', year: 1962 }, + { title: 'Toy Story 3', year: 2010 }, + { title: 'Logan', year: 2017 }, + { title: 'Full Metal Jacket', year: 1987 }, + { title: 'Dangal', year: 2016 }, + { title: 'The Sting', year: 1973 }, + { title: '2001: A Space Odyssey', year: 1968 }, + { title: "Singin' in the Rain", year: 1952 }, + { title: 'Toy Story', year: 1995 }, + { title: 'Bicycle Thieves', year: 1948 }, + { title: 'The Kid', year: 1921 }, + { title: 'Inglourious Basterds', year: 2009 }, + { title: 'Snatch', year: 2000 }, + { title: '3 Idiots', year: 2009 }, + { title: 'Monty Python and the Holy Grail', year: 1975 }, +]; + +const meta: Meta = { + title: 'Components/Autocomplete', + // @ts-ignore + component: Autocomplete, + args: { + options: top100Films, + placeholder: 'Select a movie', + getOptionLabel: (option: any) => option.title, + }, + tags: ['autodocs'], + argTypes: { + options: { control: false }, + getOptionLabel: { control: false }, + defaultValue: { control: false }, + value: { control: false }, + filterOptions: { control: false }, + popoverProps: { control: false }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; diff --git a/packages/react-components/src/Autocomplete/autocomplete.test.tsx b/packages/react-components/src/Autocomplete/autocomplete.test.tsx new file mode 100644 index 00000000..70a5f7a3 --- /dev/null +++ b/packages/react-components/src/Autocomplete/autocomplete.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { renderWithWrapper as render, screen, fireEvent } from '../test-utils'; +import { Autocomplete } from './index'; + +describe('', () => { + const options = ['test-1', 'test-2']; + + it('should render with default styles', () => { + const { asFragment } = render( + , + ); + + expect(asFragment()).toMatchSnapshot(); + }); + + it('should render with default multiple styles', () => { + const { asFragment } = render( + , + ); + + expect(asFragment()).toMatchSnapshot(); + }); + + it('should pass className', () => { + const { asFragment } = render( + , + ); + + expect(asFragment()).toMatchSnapshot(); + }); + + describe('sizes', () => { + const sizes: Array['size']> = [ + 'small', + 'medium', + 'large', + ]; + + sizes.forEach((size) => { + it(`size "${size}"`, () => { + const { asFragment } = render( + , + ); + + expect(asFragment()).toMatchSnapshot(); + }); + }); + }); + + it('should pass loading', () => { + const { baseElement } = render( + , + ); + + fireEvent.click(screen.getByRole('combobox')); + + expect(baseElement).toMatchSnapshot(); + }); + + it('should pass error', () => { + const { baseElement } = render( + , + ); + + fireEvent.click(screen.getByRole('combobox')); + + expect(baseElement).toMatchSnapshot(); + }); + + it('should pass options', () => { + const { baseElement } = render( + , + ); + + fireEvent.click(screen.getByRole('combobox')); + + expect(baseElement).toMatchSnapshot(); + }); +}); diff --git a/packages/react-components/src/Autocomplete/autocomplete.tsx b/packages/react-components/src/Autocomplete/autocomplete.tsx new file mode 100644 index 00000000..9d21a826 --- /dev/null +++ b/packages/react-components/src/Autocomplete/autocomplete.tsx @@ -0,0 +1,689 @@ +import React from 'react'; +import styled from '@emotion/styled'; +import { + useAutocomplete, + UseAutocompleteProps, + UseAutocompleteReturnType, + AutocompleteValue, + useOutsideClick, +} from '../hooks'; +import { Popper } from '../Popper'; +import { Typography, TypographyOwnProps } from '../Typography'; +import { Box } from '../Box'; +import { Chip } from '../Chip'; +import { ArrowDropDownIcon } from '../icons'; +import { MenuItem } from '../MenuList'; + +/** + * Types. + */ +export type AutocompleteRenderGroupParams = { + key: string | number; + group: string; + children?: React.ReactNode; +}; + +export type AutocompleteOwnProps< + T, + Multiple extends boolean | undefined = undefined, +> = UseAutocompleteProps & { + /** + * The className of the component. + */ + className?: string; + /** + * The size of the root component. + */ + size?: ( + 'small' | + 'medium' | + 'large' + ); + /** + * The short hint displayed in the `input` before the user enters a value. + */ + placeholder?: string; + /** + * The label content. + */ + label?: string; + /** + * Text to display when there are no options. + */ + noOptionsText?: React.ReactNode; + /** + * If `true`, the component is in a loading state. + * This shows the `loadingText` in place of suggestions (only if there are no + * suggestions to show, e.g. `options` are empty). + */ + loading?: boolean; + /** + * Text to display when in a loading state. + */ + loadingText?: React.ReactNode; + /** + * The maximum number of tags that will be visible when not focused. + */ + limitTags?: number; + /** + * If `true`, the autocomplete will be disabled. + */ + disabled?: boolean; + /** + * Name attribute of the `input` element. + */ + name?: string; + /** + * If `true`, the `input` element is required. + */ + required?: boolean; + /** + * If `true`, the create button element will be shown. + */ + allowCreateOption?: boolean; + /** + * If `true`, the `input` will indicate an error. + */ + error?: boolean; + errorText?: string; + /** + * Render the root element. + */ + renderRoot?: ( + props: object, + value: AutocompleteValue, + getTagProps: UseAutocompleteReturnType['getTagProps'], + ) => React.ReactNode; + /** + * Render the option, use `getOptionLabel` by default. + */ + renderOption?: (props: object, option: T) => React.ReactNode; + /** + * The label to display when the tags are truncated (`limitTags`). + */ + getLimitTagsText?: (more: number) => string; + /** + * Callback fired when the create button clicked. + */ + onCreate?: (event: React.SyntheticEvent, value: string) => void; +}; +/** + * + */ + +/** + * Styles. + */ +const AutocompleteField = styled(Box)< +TypographyOwnProps & Required, 'size' | 'disabled'>> +>( + (props) => ({ + outline: 'none', + boxSizing: 'border-box', + width: '100%', + borderRadius: '4px', + padding: '3px calc(var(--pv-size-base-2) + 24px) 3px var(--pv-size-base-2)', + backgroundColor: 'var(--pv-color-gray-1)', + borderStyle: 'solid', + borderWidth: '1px', + transition: 'background-color 200ms, color 200ms, border-color 200ms', + appearance: 'none', + userSelect: 'none', + textAlign: 'left', + cursor: 'text', + fontFamily: 'inherit', + minHeight: 'var(--pv-size-base-8)', + position: 'relative', + display: 'inline-flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: 'var(--pv-size-base)', + ...(props.size === 'small' && { + minHeight: 'var(--pv-size-base-6)', + padding: '1px calc(var(--pv-size-base-2) + 24px) 1px var(--pv-size-base-2)', + }), + ...(props.size === 'medium' && { + minHeight: 'var(--pv-size-base-7)', + padding: '2px calc(var(--pv-size-base-2) + 24px) 2px var(--pv-size-base-2)', + }), + }), + (props) => { + const isDark = props.theme.mode === 'dark'; + const color = isDark + ? 'var(--pv-color-white)' + : 'var(--pv-color-black)'; + let borderColor = 'var(--pv-color-gray-8)'; + let colorPlaceholder = 'var(--pv-color-gray-9)'; + let borderColorHover = 'var(--pv-color-gray-7)'; + let borderColorDisabled = 'var(--pv-color-gray-5)'; + let colorDisabled = 'var(--pv-color-gray-7)'; + let invalidBackgroundColor = 'var(--pv-color-wrong-tint-5)'; + let invalidBorderColor = 'var(--pv-color-wrong-tint-3)'; + let backgroundColorFocus = 'var(--pv-color-secondary-tint-5)'; + let borderColorFocus = 'var(--pv-color-secondary-tint-3)'; + + if (isDark) { + borderColor = 'var(--pv-color-gray-5)'; + colorPlaceholder = 'var(--pv-color-gray-6)'; + borderColorHover = 'var(--pv-color-gray-4)'; + borderColorDisabled = 'var(--pv-color-gray-4)'; + colorDisabled = 'var(--pv-color-gray-4)'; + invalidBackgroundColor = 'var(--pv-color-wrong-shade-4)'; + invalidBorderColor = 'var(--pv-color-wrong-shade-1)'; + backgroundColorFocus = 'var(--pv-color-secondary-shade-4)'; + borderColorFocus = 'var(--pv-color-secondary-shade-1)'; + } + + return ({ + borderColor, + '&:hover': { + backgroundColor: 'var(--pv-color-gray-3)', + borderColor: borderColorHover, + }, + '&:disabled': { + cursor: 'not-allowed', + backgroundColor: 'var(--pv-color-gray-1)', + borderColor: borderColorDisabled, + color: colorDisabled, + }, + '&:not(:disabled)': { + color, + '&[aria-placeholder]': { + color: colorPlaceholder, + }, + '&[aria-invalid]': { + backgroundColor: invalidBackgroundColor, + borderColor: invalidBorderColor, + }, + '&:focus-visible': { + backgroundColor: backgroundColorFocus, + borderColor: borderColorFocus, + }, + '&:focus-within': { + backgroundColor: backgroundColorFocus, + borderColor: borderColorFocus, + }, + }, + }); + }, +); + +const AutocompleteArrowIcon = styled(ArrowDropDownIcon)({ + position: 'absolute', + right: '0px', + top: 'calc(50% - 12px)', + margin: '0px var(--pv-size-base)', + color: 'var(--pv-color-gray-10)', + '&[aria-disabled="true"]': { + color: 'inherit', + }, +}); + +const AutocompleteNativeInput = styled('input')({ + bottom: 0, + left: 0, + height: '100%', + position: 'absolute', + opacity: 0, + pointerEvents: 'none', + width: '100%', + boxSizing: 'border-box', +}); + +const AutocompleteDropdownStateItem = styled('div')({ + padding: 'var(--pv-size-base-3) var(--pv-size-base-2)', +}); + +const AutocompleteDropdownList = styled('ul')({ + maxHeight: '36vh', + overflowY: 'auto', + margin: 0, + listStyleType: 'none', + position: 'relative', + padding: '10px 0', +}); + +const AutocompleteDropdownGroupName = styled(Typography)( + (props) => ({ + padding: 'var(--pv-size-base-2)', + color: props.theme.mode === 'dark' + ? 'var(--pv-color-gray-6)' + : 'var(--pv-color-gray-9)', + }), +); + +const AutocompleteDropdownGroupList = styled('ul')({ + padding: 0, + listStyleType: 'none', +}); + +const AutocompleteDropdownGroupListItem = styled(MenuItem)>( + (props) => ({ + ...(props.inGroup && { + padding: '0px var(--pv-size-base-2) 0 var(--pv-size-base-3)', + }), + }), +); + +const AutocompletePopover = styled(Popper)( + { + minWidth: 240, + outline: 0, + marginTop: '1px', + borderRadius: '4px', + minHeight: '16px', + maxHeight: 'calc(100% - 32px)', + zIndex: 1300, + }, + (props) => { + const isDark = props.theme.mode === 'dark'; + + let backgroundColor = 'var(--pv-color-white)'; + let boxShadow = 'var(--pv-shadow-light-low)'; + + if (isDark) { + backgroundColor = 'var(--pv-color-gray-3)'; + boxShadow = 'var(--pv-shadow-dark-medium)'; + } + + return ({ + backgroundColor, + boxShadow, + }); + }, +); + +const AutocompleteTag = styled(Chip)<{ + tagsLength: number, + limitTags: number, + size: AutocompleteOwnProps['size'], +}>((props) => ({ + label: 'Autocomplete-tag', + borderRadius: '3px', + margin: 0, + ...(props.tagsLength === 1 && { + maxWidth: 'calc(100% - var(--pv-size-base))', + }), + ...(props.tagsLength > 1 && props.limitTags > 0 && { + maxWidth: `calc(${100 / props.limitTags}% - var(--pv-size-base))`, + }), + ...(props.size === 'small' && { + height: 'var(--pv-size-base-5)', + }), +})); + +const AutocompleteTagSize = styled(Typography)({ + margin: '0 var(--pv-size-base-2)', +}); + +const AutocompleteInputField = styled(Typography)( + () => ({ + fontFamily: 'inherit', + outline: 'none', + boxSizing: 'border-box', + minWidth: '30px', + width: 0, + flexGrow: 1, + backgroundColor: 'transparent', + borderStyle: 'none', + appearance: 'none', + }), + (props) => { + const isDark = props.theme.mode === 'dark'; + const color = isDark + ? 'var(--pv-color-white)' + : 'var(--pv-color-black)'; + + let colorPlaceholder = 'var(--pv-color-gray-9)'; + let colorDisabled = 'var(--pv-color-gray-7)'; + + if (isDark) { + colorPlaceholder = 'var(--pv-color-gray-6)'; + colorDisabled = 'var(--pv-color-gray-4)'; + } + + return ({ + color, + '&::placeholder': { + color: colorPlaceholder, + }, + '&:disabled': { + cursor: 'not-allowed', + color: colorDisabled, + }, + }); + }, +); + +const AutocompleteError = styled(Typography)({ + marginTop: '2px', +}); + +const AutocompleteLabel = styled('label')({ + label: 'TextField-label', + marginBottom: '2px', + display: 'inline-block', +}); +/** + * + */ + +export const Autocomplete = < + T, + Multiple extends boolean | undefined = false, +>(props: AutocompleteOwnProps): JSX.Element => { + const { + className, + size, + placeholder, + label, + disabled = false, + noOptionsText, + loading, + loadingText, + limitTags = -1, + name, + required, + multiple = false, + readOnly, + error, + errorText, + renderRoot: renderRootProp, + renderOption: renderOptionProp, + getLimitTagsText = (more) => `${more} more`, + groupBy, + onCreate, + } = props; + const { + id, + value, + searchValue, + groupedOptions, + getRootProps, + getInputLabelProps, + getInputProps, + getListboxProps, + getOptionProps, + getPopoverProps, + getTagProps, + getOptionLabel, + handleDeleteAllValues, + } = useAutocomplete(props); + const { + onChange, + ...otherInputProps + } = getInputProps(); + + const rootProps = getRootProps(); + const popoverProps = getPopoverProps(); + + const handleChange = (event: React.ChangeEvent) => { + onChange(event); + + const { value: valueInput } = event.target; + + if (valueInput === '') { + handleDeleteAllValues(event); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + // Wait until IME is settled. + if (event.which !== 229) { + switch (event.key) { + case 'Escape': + // Prevent cursor move + event.preventDefault(); + + popoverProps.onClose(event); + break; + case 'Enter': + // Prevent cursor move + event.preventDefault(); + + if (onCreate && !groupedOptions.length) { + onCreate(event, searchValue); + } + + popoverProps.onKeyDown(event); + break; + + default: + popoverProps.onKeyDown(event); + } + } + }; + + const defaultRenderOption: AutocompleteOwnProps['renderOption'] = (propsOption, option) => ( + + {getOptionLabel(option)} + + ); + + const renderGroup = (params: AutocompleteRenderGroupParams) => ( +
  • + + {params.group} + + + {params.children} + +
  • + ); + + const renderValue = () => { + if (!value || (Array.isArray(value) && value.length === 0)) { + return null; + } + + if (Array.isArray(value)) { + const more = (value.length > limitTags && limitTags !== -1) + && !popoverProps.open ? (value.length - limitTags) : 0; + const valueLimits = more > 0 ? value.slice(0, limitTags) : value; + + return ( + <> + {valueLimits.map((v, index) => ( + + {getOptionLabel(v)} + + ))} + {!!more && ( + + {getLimitTagsText(more)} + + )} + + ); + } + + return getOptionLabel(value as T); + }; + + const renderedValue = renderValue(); + const isValueEmpty = renderedValue === null; + const popoverRef = useOutsideClick(popoverProps.onClose); + + const defaultRenderRoot: AutocompleteOwnProps['renderRoot'] = (propsRoot, valueRoot) => ( + + {multiple ? ( + <> + {isValueEmpty ? null : renderedValue} + + + ) : ( + + )} + + + ); + + const renderOption = renderOptionProp || defaultRenderOption; + const renderRoot = renderRootProp || defaultRenderRoot; + const renderListOption = (option: T, index: number) => { + const optionProps = getOptionProps(option, index); + + return renderOption(optionProps, option); + }; + + return ( +
    + {label && ( + + + {label} + + + )} + {renderRoot( + { + ...rootProps, + disabled, + }, + value, + getTagProps, + )} + {error && errorText && ( + + {errorText} + + )} + +
    + {loading && groupedOptions.length === 0 && ( + + {typeof loadingText === 'string' ? ( + + {loadingText} + + ) : loadingText} + + )} + {groupedOptions.length === 0 && !loading && ( + + {typeof noOptionsText === 'string' ? ( + + {noOptionsText} + + ) : noOptionsText} + + )} + {groupedOptions.length > 0 && ( + + {groupedOptions + // @ts-ignore + .map((option, index) => { + // @ts-ignore + if (groupBy && 'options' in option) { + return renderGroup({ + key: option.key, + group: option.group, + // @ts-ignore + children: option.options.map((option2, index2) => ( + renderListOption(option2, option.index + index2) + )), + }); + } + + return renderListOption(option as T, index); + })} + + )} +
    +
    +
    + ); +}; + +// @ts-ignore +Autocomplete.defaultProps = { + noOptionsText: 'No options', + loading: false, + loadingText: 'Loading...', + required: false, + allowCreateOption: false, + size: 'medium', +}; diff --git a/packages/react-components/src/Autocomplete/index.ts b/packages/react-components/src/Autocomplete/index.ts new file mode 100644 index 00000000..7020b66f --- /dev/null +++ b/packages/react-components/src/Autocomplete/index.ts @@ -0,0 +1 @@ +export { Autocomplete } from './autocomplete'; diff --git a/packages/react-components/src/hooks/index.ts b/packages/react-components/src/hooks/index.ts index 5be5ca6a..c33abf1d 100644 --- a/packages/react-components/src/hooks/index.ts +++ b/packages/react-components/src/hooks/index.ts @@ -21,3 +21,4 @@ export type { export { useEventCallback } from './use_event_callback'; export { useEnhancedEffect } from './use_enhanced_effect'; export { useDebounceCallback } from './use_debounce_callback'; +export { useOutsideClick } from './use_outside_click'; diff --git a/packages/react-components/src/hooks/use_autocomplete.ts b/packages/react-components/src/hooks/use_autocomplete.ts index 8ada1741..1dbc498f 100644 --- a/packages/react-components/src/hooks/use_autocomplete.ts +++ b/packages/react-components/src/hooks/use_autocomplete.ts @@ -136,6 +136,7 @@ Multiple extends boolean | undefined = undefined, onDelete?: (event: React.SyntheticEvent) => void; }; getOptionLabel: (option: T) => string; + handleDeleteAllValues: (event: React.SyntheticEvent,) => void; }; /** * @@ -481,6 +482,7 @@ Multiple extends boolean | undefined = false, }; const handleKeyDown = (event: React.KeyboardEvent) => { + handleOpen(event); // Wait until IME is settled. if (event.which !== 229) { switch (event.key) { @@ -525,6 +527,20 @@ Multiple extends boolean | undefined = false, selectNewValue(event, option, index, 'removeOption'); }; + const handleDeleteAllValues = (event: React.SyntheticEvent) => { + let newValue: T | T[] = null; + + if (multiple) { + newValue = []; + } + + setValue(newValue as AutocompleteValue); + + if (onChange) { + onChange(event, newValue as AutocompleteValue, { option: null, index: 0 }, 'removeOption'); + } + }; + let groupedOptions = filteredOptions; if (groupBy) { @@ -608,6 +624,7 @@ Multiple extends boolean | undefined = false, tabIndex: -1, onDelete: readOnly ? undefined : handleTagDelete(option, index), }), + handleDeleteAllValues, getOptionLabel, groupedOptions, popupOpen, diff --git a/packages/react-components/src/hooks/use_outside_click.ts b/packages/react-components/src/hooks/use_outside_click.ts new file mode 100644 index 00000000..ccea36cf --- /dev/null +++ b/packages/react-components/src/hooks/use_outside_click.ts @@ -0,0 +1,21 @@ +import React from 'react'; + +export const useOutsideClick = (callback: Function) => { + const ref = React.useRef(null); + + React.useEffect(() => { + const handleClick = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target)) { + callback(); + } + }; + + document.addEventListener('click', handleClick, true); + + return () => { + document.removeEventListener('click', handleClick, true); + }; + }, [ref, callback]); + + return ref; +};