diff --git a/CHANGELOG.react.md b/CHANGELOG.react.md index 01c0b90d9..c8bfe8344 100644 --- a/CHANGELOG.react.md +++ b/CHANGELOG.react.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Text field enhancements (`chips`, `onFocus`, `onBlur`, `textfieldRef`, `inputRef` props) [#199](https://github.com/lumapps/design-system/pull/199) +- Autocomplete component [#191](https://github.com/lumapps/design-system/pull/191) ### Changed diff --git a/demo/react/doc/product/components/autocomplete.mdx b/demo/react/doc/product/components/autocomplete.mdx new file mode 100644 index 000000000..c4377b295 --- /dev/null +++ b/demo/react/doc/product/components/autocomplete.mdx @@ -0,0 +1,111 @@ +```javascript import +import { Autocomplete, List, ListItem, ListItemSize, Size, TextField, Chip, ListDivider, Icon, ListSubheader } from 'LumX'; +import { useBooleanState } from 'LumX/core/react/hooks'; +import { DOWN_KEY_CODE, ENTER_KEY_CODE, TAB_KEY_CODE, UP_KEY_CODE } from 'LumX/core/constants'; +``` + +# Autocomplete + +## Default + +```javascript jsx withThemeSwitcher +(theme) => { + const CITIES = [ + { + text: 'Los Angeles', + id: 'losangeles' + }, + { + text: 'San Francisco', + id: 'sanfrancisco' + }, + { + text: 'Paris', + id: 'paris' + }, + { + text: 'Montpellier', + id: 'montpellier' + }, + { + text: 'Bordeaux', + id: 'bordeaux' + }, + { + text: 'Toulouse', + id: 'toulouse' + }, + { + text: 'Lyon', + id: 'lyon' + }, + { + text: 'Montevideo', + id: 'montevideo' + } + ]; + + const [showSuggestions, setShowSuggestions] = React.useState(false); + const [filterValue, setFilterValue] = React.useState(''); + const inputRef = React.useRef(null); + + const filteredCities = CITIES.filter(city => city.text.replace(' ', '').toLowerCase().includes(filterValue.replace(' ', '').toLowerCase())); + const closeAutocomplete = () => setShowSuggestions(false); + + const setSelectedCity = (city) => { + setFilterValue(city.text); + setShowSuggestions(false); + } + + const onChange = (value) => { + setFilterValue(value); + setShowSuggestions(value.length > 0); + }; + + const { + activeItemIndex, + } = List.useKeyboardListNavigation( + filteredCities, + setSelectedCity, + inputRef, + ); + + const onFocus = (evt) => { + setShowSuggestions(filterValue.length > 0); + } + + const hasSuggestions = filteredCities.length > 0; + + return ( + + { hasSuggestions && ( + + {filteredCities.map((city, index) => ( + setSelectedCity(city)} + > +
{city.text}
+
+ ))} +
+ )} +
+ ); +}; +``` + +### Properties + + diff --git a/demo/react/layout/MainNav.tsx b/demo/react/layout/MainNav.tsx index 5fa9493ee..661cc7f43 100644 --- a/demo/react/layout/MainNav.tsx +++ b/demo/react/layout/MainNav.tsx @@ -45,6 +45,7 @@ const ITEMS: Item[] = [ { label: 'Components', children: [ + 'Autocomplete', 'Avatar', 'Button', 'Checkbox', diff --git a/src/components/autocomplete/react/Autocomplete.test.tsx b/src/components/autocomplete/react/Autocomplete.test.tsx new file mode 100644 index 000000000..8ad9cfafb --- /dev/null +++ b/src/components/autocomplete/react/Autocomplete.test.tsx @@ -0,0 +1,99 @@ +import React, { ReactElement } from 'react'; + +import { mount, shallow } from 'enzyme'; + +import { ICommonSetup, Wrapper, commonTestsSuite } from 'LumX/core/testing/utils.test'; + +import { Autocomplete, AutocompleteProps, CLASSNAME } from './Autocomplete'; + +///////////////////////////// + +/** + * Define the overriding properties waited by the `setup` function. + */ +type ISetupProps = Partial; + +/** + * Defines what the `setup` function will return. + */ +interface ISetup extends ICommonSetup { + props: ISetupProps; + + /** + * The
element that holds the popover content. + */ + wrapper: Wrapper; +} + +///////////////////////////// + +/** + * Mounts the component and returns common DOM elements / data needed in multiple tests further down. + * + * @param props The props to use to override the default props of the component. + * @param [shallowRendering=true] Indicates if we want to do a shallow or a full rendering. + * @return An object with the props, the component wrapper and some shortcut to some element inside of the component. + */ +const setup = (props: ISetupProps = {}, shallowRendering: boolean = true): ISetup => { + const renderer: (el: ReactElement) => Wrapper = shallowRendering ? shallow : mount; + + // @ts-ignore + const wrapper: Wrapper = renderer(); + + return { + props, + wrapper, + }; +}; + +describe(`<${Autocomplete.displayName}>`, (): void => { + // 1. Test render via snapshot (default states of component). + describe('Snapshots and structure', (): void => { + // Here is an example of a basic rendering check, with snapshot. + + it('should render correctly', (): void => { + const { wrapper } = setup(); + expect(wrapper).toMatchSnapshot(); + + expect(wrapper).toExist(); + + expect(wrapper).toHaveClassName(CLASSNAME); + }); + }); + + ///////////////////////////// + + // 2. Test defaultProps value and important props custom values. + describe('Props', (): void => { + it('should use default props', (): void => { + const { wrapper }: ISetup = setup(); + + expect(wrapper).toHaveClassName(CLASSNAME); + }); + }); + + ///////////////////////////// + + // 3. Test events. + describe('Events', (): void => { + // Nothing to do here. + }); + ///////////////////////////// + + // 4. Test conditions (i.e. things that display or not in the UI based on props). + describe('Conditions', (): void => { + // Nothing to do here. + }); + + ///////////////////////////// + + // 5. Test state. + describe('State', (): void => { + // Nothing to do here. + }); + + ///////////////////////////// + + // Common tests suite. + commonTestsSuite(setup, { className: 'wrapper', prop: 'wrapper' }, { className: CLASSNAME }); +}); diff --git a/src/components/autocomplete/react/Autocomplete.tsx b/src/components/autocomplete/react/Autocomplete.tsx new file mode 100644 index 000000000..d04928cf4 --- /dev/null +++ b/src/components/autocomplete/react/Autocomplete.tsx @@ -0,0 +1,257 @@ +import React, { ReactElement, RefObject, useRef } from 'react'; + +import classNames from 'classnames'; + +import { Dropdown, Offset, Placement, TextField, TextFieldType, Theme } from 'LumX'; + +import { COMPONENT_PREFIX } from 'LumX/core/react/constants'; +import { handleBasicClasses } from 'LumX/core/utils'; +import { IGenericProps, getRootClassName } from 'LumX/react/utils'; + +///////////////////////////// + +/** + * Defines the props of the component. + */ +interface IAutocompleteProps extends IGenericProps { + /** A ref that will be passed to the input or textarea element. */ + inputRef?: RefObject; + + /** + * Vertical and/or horizontal offsets that will be applied to the Dropdown position. + * @see {@link DropdownProps#offset} + */ + offset?: Offset; + + /** + * The preferred Dropdown location against the anchor element. + * @see {@link DropdownProps#placement} + */ + placement?: Placement; + + /** + * Whether the dropdown should fit to the anchor width + * @see {@link DropdownProps#hasError} + */ + fitToAnchorWidth?: boolean; + + /** + * Whether the text field is displayed with error style or not. + * @see {@link TextFieldProps#hasError} + */ + hasError?: boolean; + + /** + * Text field helper message. + * @see {@link TextFieldProps#helper} + */ + helper?: string; + + /** + * Text field icon (SVG path) + * @see {@link TextFieldProps#icon} + */ + icon?: string; + + /** + * Whether the text field is disabled or not. + * @see {@link TextFieldProps#isDisabled} + */ + isDisabled?: boolean; + + /** + * Whether the text field is displayed with valid style or not. + * @see {@link TextFieldProps#isValid} + */ + isValid?: boolean; + + /** + * Text field label displayed in a label tag. + * @see {@link TextFieldProps#label} + */ + label?: string; + + /** + * Text field placeholder message. + * @see {@link TextFieldProps#placeholder} + */ + placeholder?: string; + + /** Theme. */ + theme?: Theme; + + /** + * Children of the Autocomplete. This should be a list of the different + * suggestions that + */ + children: React.ReactNode; + + /** + * Text field value. + * @see {@link TextFieldProps#onChange} + */ + value: string; + + /** + * Whether the suggestions from the autocomplete should be displayed or not. + * Useful to control when the suggestions are displayed from outside the component + */ + isOpen: boolean; + + /** + * Whether a click anywhere out of the Autocomplete would close it + * @see {@link DropdownProps#closeOnClick} + */ + closeOnClick?: boolean; + + /** + * Whether an escape key press would close the Autocomplete. + * @see {@link DropdownProps#closeOnEscape} + */ + closeOnEscape?: boolean; + + /** + * The function to be called when the user clicks away or Escape is pressed + * @see {@link DropdownProps#onClose} + */ + onClose?: VoidFunction; + + /** + * The callback function called when the bottom of the dropdown is reached. + * @see {@link DropdownProps#onInfinite} + */ + onInfinite?: VoidFunction; + + /** + * Text field value change handler. + * @see {@link TextFieldProps#onChange} + */ + onChange(value: string): void; + + /** + * Text field focus change handler. + * @see {@link TextFieldProps#onFocus} + */ + onFocus?(event: React.FocusEvent): void; + + /** + * Text field blur change handler. + * @see {@link TextFieldProps#onBlur} + */ + onBlur?(event: React.FocusEvent): void; +} +type AutocompleteProps = IAutocompleteProps; + +///////////////////////////// + +///////////////////////////// +// // +// Public attributes // +// // +///////////////////////////// + +/** + * The display name of the component. + */ +const COMPONENT_NAME = `${COMPONENT_PREFIX}Autocomplete`; + +/** + * The default class name and classes prefix for this component. + */ +const CLASSNAME = getRootClassName(COMPONENT_NAME); + +/** + * The default value of props. + */ +const DEFAULT_PROPS: Partial = { + closeOnClick: true, + closeOnEscape: true, + isOpen: undefined, +}; + +///////////////////////////// + +/** + * This component allows to make the connection between a Text Field and a Dropdown, + * displaying a list of suggestions from the text entered on the text field. + * + * @return The component. + */ +const Autocomplete: React.FC = (props: AutocompleteProps): ReactElement => { + const { + className, + children, + value, + onBlur, + onChange, + onFocus, + isOpen, + closeOnClick, + closeOnEscape, + hasError, + helper, + icon, + isDisabled, + isValid, + label, + placeholder, + theme, + onClose, + offset, + placement, + inputRef = useRef(null), + fitToAnchorWidth, + onInfiniteScroll, + ...forwardedProps + } = props; + + const textFieldRef = useRef(null); + + return ( +
+ + } + showDropdown={isOpen} + closeOnClick={closeOnClick} + closeOnEscape={closeOnEscape} + onClose={onClose} + offset={offset} + placement={placement} + fitToAnchorWidth={fitToAnchorWidth} + onInfiniteScroll={onInfiniteScroll} + > + {children} + +
+ ); +}; +Autocomplete.displayName = COMPONENT_NAME; + +///////////////////////////// + +export { CLASSNAME, DEFAULT_PROPS, Autocomplete, AutocompleteProps }; diff --git a/src/components/autocomplete/react/__snapshots__/Autocomplete.test.tsx.snap b/src/components/autocomplete/react/__snapshots__/Autocomplete.test.tsx.snap new file mode 100644 index 000000000..a176865d7 --- /dev/null +++ b/src/components/autocomplete/react/__snapshots__/Autocomplete.test.tsx.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` Snapshots and structure should render correctly 1`] = ` +
+ + +
+`; diff --git a/src/components/list/react/List.tsx b/src/components/list/react/List.tsx index 2d225bd25..197ddf4c3 100644 --- a/src/components/list/react/List.tsx +++ b/src/components/list/react/List.tsx @@ -11,6 +11,8 @@ import { ListItem, ListItemProps, Theme } from 'LumX'; import { IGenericProps, getRootClassName, isComponent } from 'LumX/core/react/utils'; import { handleBasicClasses } from 'LumX/core/utils'; +import { useKeyboardListNavigation, useKeyboardListNavigationType } from 'LumX/core/react/hooks'; + ///////////////////////////// /** * Defines the props of the component. @@ -65,12 +67,16 @@ const DEFAULT_PROPS: IDefaultPropsType = { }; ///////////////////////////// +interface IList { + useKeyboardListNavigation: useKeyboardListNavigationType; +} + /** * List component - Use vertical layout to display elements * * @return The component. */ -const List: React.FC = ({ +const List: React.FC & IList = ({ className = '', isClickable = DEFAULT_PROPS.isClickable, onListItemSelected, @@ -221,7 +227,9 @@ const List: React.FC = ({ ); }; + List.displayName = COMPONENT_NAME; +List.useKeyboardListNavigation = useKeyboardListNavigation; ///////////////////////////// diff --git a/src/components/list/react/ListItem.tsx b/src/components/list/react/ListItem.tsx index 019926ec9..7eb0de59e 100644 --- a/src/components/list/react/ListItem.tsx +++ b/src/components/list/react/ListItem.tsx @@ -36,6 +36,9 @@ interface IListItemProps extends IGenericProps { /** Whether the list item is active. */ isActive?: boolean; + /** Whether the list item should be highlighted. */ + isHighlighted?: boolean; + /** Whether the list item can be clicked. */ isClickable?: boolean; @@ -82,6 +85,7 @@ const CLASSNAME: string = getRootClassName(COMPONENT_NAME); const DEFAULT_PROPS: IDefaultPropsType = { isActive: false, isClickable: false, + isHighlighted: false, isSelected: false, size: ListItemSize.regular, theme: Theme.light, @@ -97,6 +101,7 @@ const ListItem: React.FC = ({ after, children, className = '', + isHighlighted, isSelected = DEFAULT_PROPS.isSelected, isClickable = DEFAULT_PROPS.isSelected, isActive = DEFAULT_PROPS.isActive, @@ -141,7 +146,14 @@ const ListItem: React.FC = ({ ref={element} className={classNames( className, - handleBasicClasses({ prefix: CLASSNAME, theme, selected: isSelected, clickable: isClickable, size }), + handleBasicClasses({ + clickable: isClickable, + highlighted: isHighlighted, + prefix: CLASSNAME, + selected: isSelected, + size, + theme, + }), )} tabIndex={isClickable ? 0 : -1} onFocusCapture={preventParentFocus} diff --git a/src/components/list/style/lumapps/_index.scss b/src/components/list/style/lumapps/_index.scss index 71d9d8380..105e9e351 100644 --- a/src/components/list/style/lumapps/_index.scss +++ b/src/components/list/style/lumapps/_index.scss @@ -41,6 +41,10 @@ &--is-selected { @include lumx-list-item-selected; } + + &--is-highlighted { + @include lumx-list-item-highlighted; + } } /* Subheader diff --git a/src/components/list/style/lumapps/_mixins.scss b/src/components/list/style/lumapps/_mixins.scss index a84a4f903..0b0b1b95f 100644 --- a/src/components/list/style/lumapps/_mixins.scss +++ b/src/components/list/style/lumapps/_mixins.scss @@ -36,6 +36,16 @@ } } +@mixin lumx-list-item-highlighted() { + cursor: pointer; + + @include lumx-state(state-hover, emphasis-low, dark); + + &:active { + @include lumx-state(state-active, emphasis-low, dark); + } +} + @mixin lumx-list-item-selected() { @include lumx-state-as-selected('state-default', 'dark'); diff --git a/src/core/react/hooks/index.tsx b/src/core/react/hooks/index.tsx index 769a9be20..b754a7ab3 100644 --- a/src/core/react/hooks/index.tsx +++ b/src/core/react/hooks/index.tsx @@ -4,6 +4,8 @@ export { useComputePosition, useComputePositionType } from './useComputePosition export { useInterval } from './useInterval'; +export { useKeyboardListNavigation, useKeyboardListNavigationType } from './useKeyboardListNavigation'; + export { useBooleanState } from './useBooleanState'; export { useInfiniteScroll } from './useInfiniteScroll'; diff --git a/src/core/react/hooks/useKeyboardListNavigation.tsx b/src/core/react/hooks/useKeyboardListNavigation.tsx new file mode 100644 index 000000000..6e94f4afb --- /dev/null +++ b/src/core/react/hooks/useKeyboardListNavigation.tsx @@ -0,0 +1,128 @@ +import { RefObject, SetStateAction, useEffect, useState } from 'react'; + +import { DOWN_KEY_CODE, ENTER_KEY_CODE, UP_KEY_CODE } from 'LumX/core/constants'; + +///////////////////////////// + +type useKeyboardListNavigationType = ( + /** the list of items that will be navigated using the keyboard */ + items: object[], + /** callback to be executed when the ENTER key is pressed on an item */ + onListItemSelected: (itemSelected: object) => {}, + /** A reference to the element that is controlling the navigation. */ + ref: RefObject, + /** where should the navigation start from. it defaults to `-1`, so the first item navigated is the item on position `0` */ + initialIndex: number, +) => { + /** the current active index */ + activeItemIndex: number; + /** callback to be used when a key is pressed. usually used with the native prop `onKeyDown` */ + onKeyboardNavigation(evt: KeyboardEvent): void; + /** Resets the active index to the initial state */ + resetActiveIndex(): void; + /** Sets the active index to a given value */ + setActiveItemIndex(value: SetStateAction): void; +}; + +///////////////////////////// + +const INITIAL_INDEX = -1; + +///////////////////////////// + +/** + * This custom hook provides the necessary set of functions and values to properly navigate + * a list using the keyboard. + * @param items - the list of items that will be navigated using the keyboard + * @param onListItemSelected - callback to be executed when the ENTER key is pressed on an item + * @param initialIndex - where should the navigation start from. it defaults to `-1`, so the first item navigated is the item on position `0` + */ +const useKeyboardListNavigation: useKeyboardListNavigationType = ( + items: object[], + onListItemSelected: (itemSelected: object) => {}, + ref: RefObject, + initialIndex: number = INITIAL_INDEX, +): { + activeItemIndex: number; + onKeyboardNavigation(evt: KeyboardEvent): void; + resetActiveIndex(): void; + setActiveItemIndex(value: SetStateAction): void; +} => { + const [activeItemIndex, setActiveItemIndex] = useState(initialIndex); + + /** + * Calculates the next active item index depending on the key pressed. + * If the key pressed was ENTER, the function executes the callback `onListItemSelected` + * and resets the active index, since an item was selected. + * @param evt - key pressed or key down event + */ + const onKeyboardNavigation = (evt: KeyboardEvent): void => { + // tslint:disable-next-line: deprecation + const { keyCode } = evt; + + if (keyCode === DOWN_KEY_CODE || keyCode === UP_KEY_CODE) { + setActiveItemIndex(calculateActiveIndex(keyCode)); + evt.preventDefault(); + evt.stopPropagation(); + } else if (keyCode === ENTER_KEY_CODE && onListItemSelected) { + evt.preventDefault(); + evt.stopPropagation(); + (evt.currentTarget as HTMLElement).blur(); + + const selectedItem: object = items[activeItemIndex]; + + if (selectedItem) { + // tslint:disable-next-line: no-inferred-empty-object-type + onListItemSelected(selectedItem); + resetActiveIndex(); + } + } + }; + + /** + * Resets the active index to the initial state + */ + const resetActiveIndex = (): void => { + setActiveItemIndex(initialIndex); + }; + + useEffect(() => { + if (ref && ref.current) { + const textFieldRefCurrent = ref.current; + textFieldRefCurrent.addEventListener('focus', resetActiveIndex); + textFieldRefCurrent.addEventListener('keydown', onKeyboardNavigation); + + return (): void => { + textFieldRefCurrent.removeEventListener('focus', resetActiveIndex); + textFieldRefCurrent.removeEventListener('keydown', onKeyboardNavigation); + }; + } + + return undefined; + }); + + /** + * This function calculates the next index in the list to be highlighted + * @param keyPressed - key code pressed + * @return next active index + */ + const calculateActiveIndex = (keyPressed: number): number => { + switch (keyPressed) { + case DOWN_KEY_CODE: + return activeItemIndex + 1 < items.length ? activeItemIndex + 1 : 0; + case UP_KEY_CODE: + return activeItemIndex - 1 >= 0 ? activeItemIndex - 1 : items.length - 1; + default: + return initialIndex; + } + }; + + return { + activeItemIndex, + onKeyboardNavigation, + resetActiveIndex, + setActiveItemIndex, + }; +}; + +export { useKeyboardListNavigation, useKeyboardListNavigationType }; diff --git a/src/react.index.ts b/src/react.index.ts index c3246da0e..f7ea980ea 100644 --- a/src/react.index.ts +++ b/src/react.index.ts @@ -6,6 +6,8 @@ export * from './components'; export { Avatar, AvatarProps } from './components/avatar/react/Avatar'; +export { Autocomplete } from './components/autocomplete/react/Autocomplete'; + export { Button, ButtonEmphasis, ButtonProps } from './components/button/react/Button'; export { ButtonGroup, ButtonGroupProps } from './components/button/react/ButtonGroup'; @@ -83,7 +85,7 @@ export { Tabs, TabsProps, TabsLayout, TabsPosition } from './components/tabs/rea export { Tab, TabProps } from './components/tabs/react/Tab'; -export { TextField, TextFieldProps } from './components/text-field/react/TextField'; +export { TextField, TextFieldProps, TextFieldType } from './components/text-field/react/TextField'; export { Tooltip, TooltipProps, TooltipPlacement } from './components/tooltip/react/Tooltip';