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';