Source: dynamic-data-mapping-form-field-type/src/main/resources/META-INF/resources/Select/Select.es.js

/**
 * Copyright (c) 2000-present Liferay, Inc. All rights reserved.
 *
 * This library is free software; you can redistribute it and/or modify it under
 * the terms of the GNU Lesser General Public License as published by the Free
 * Software Foundation; either version 2.1 of the License, or (at your option)
 * any later version.
 *
 * This library is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
 * details.
 */

import ClayDropDown from '@clayui/drop-down';
import {ClayCheckbox} from '@clayui/form';
import React, {forwardRef, useEffect, useMemo, useRef, useState} from 'react';

import {FieldBase} from '../FieldBase/ReactFieldBase.es';
import {useSyncValue} from '../hooks/useSyncValue.es';
import HiddenSelectInput from './HiddenSelectInput.es';
import VisibleSelectInput from './VisibleSelectInput.es';

/**
 * Mapping to be used to match keyCodes
 * returned from keydown events.
 */
const KEYCODES = {
	ARROW_DOWN: 40,
	ARROW_UP: 38,
	ENTER: 13,
	SHIFT: 16,
	SPACE: 32,
	TAB: 9,
};

/**
 * Maximum number of items to be shown without the Search bar
 */
const MAX_ITEMS = 11;

/**
 * Appends a new value on the current value state
 * @param options {Object}
 * @param options.value {Array|String}
 * @param options.valueToBeAppended {Array|String}
 * @returns {Array}
 */
function appendValue({value, valueToBeAppended}) {
	const currentValue = toArray(value);
	const newValue = [...currentValue];

	if (value) {
		newValue.push(valueToBeAppended);
	}

	return newValue;
}

/**
 * Removes a value from the value array.
 * @param options {Object}
 * @param options.value {Array|String}
 * @param options.valueToBeRemoved {Array|String}
 * @returns {Array}
 */
function removeValue({value, valueToBeRemoved}) {
	const currentValue = toArray(value);

	return currentValue.filter((v) => v !== valueToBeRemoved);
}

/**
 * Wraps the given argument into an array.
 * @param value {Array|String}
 */
function toArray(value = '') {
	let newValue = value;

	if (!Array.isArray(newValue)) {
		newValue = [newValue];
	}

	return newValue;
}

function normalizeValue({
	multiple,
	normalizedOptions,
	predefinedValueArray,
	valueArray,
}) {
	const assertValue = valueArray.length ? valueArray : predefinedValueArray;

	const valueWithoutMultiple = assertValue.filter((_, index) => {
		return multiple ? true : index === 0;
	});

	return valueWithoutMultiple.filter((value) =>
		normalizedOptions.some((option) => value === option.value)
	);
}

/**
 * Some parameters on each option
 * needs to be prepared in case of
 * multiple selected values(when the value state is an array).
 */
function assertOptionParameters({multiple, option, valueArray}) {
	const included = valueArray.includes(option.value);

	return {
		...option,
		active: !multiple && included,
		checked: multiple && included,
		type: multiple ? 'checkbox' : 'item',
	};
}

function normalizeOptions({fixedOptions, multiple, options, valueArray}) {
	const emptyOption = {
		label: Liferay.Language.get('choose-an-option'),
		value: null,
	};

	const newOptions = [
		...options.map((option, index) => ({
			...assertOptionParameters({multiple, option, valueArray}),
			separator:
				Array.isArray(fixedOptions) &&
				fixedOptions.length > 0 &&
				index === options.length - 1,
		})),
		...fixedOptions.map((option) =>
			assertOptionParameters({multiple, option, valueArray})
		),
	].filter(({value}) => value !== '');

	if (!multiple) {
		return [emptyOption, ...newOptions];
	}

	return newOptions;
}

function handleDropdownItemClick({currentValue, multiple, option}) {
	const itemValue = option.value;

	let newValue;

	if (multiple) {
		if (currentValue.includes(itemValue)) {
			newValue = removeValue({
				value: currentValue,
				valueToBeRemoved: itemValue,
			});
		}
		else {
			newValue = appendValue({
				value: currentValue,
				valueToBeAppended: itemValue,
			});
		}
	}
	else if (itemValue === null) {
		newValue = [];
	}
	else {
		newValue = [itemValue];
	}

	return newValue;
}

const DropdownItem = ({
	currentValue,
	expand,
	index,
	multiple,
	onSelect,
	option,
	options,
}) => (
	<>
		<ClayDropDown.Item
			active={expand && currentValue === option.label}
			data-testid={`dropdownItem-${index}`}
			label={option.label}
			onClick={(event) => {
				event.preventDefault();
				event.stopPropagation();

				onSelect({
					currentValue,
					event,
					multiple,
					option,
				});
			}}
			value={options.value}
		>
			{multiple ? (
				<ClayCheckbox
					aria-label={option.label}
					checked={currentValue.includes(option.value)}
					data-testid={`labelItem-${option.value}`}
					label={option.label}
					onChange={(event) => {
						onSelect({
							currentValue,
							event,
							multiple,
							option,
						});
					}}
				/>
			) : (
				option.label
			)}
		</ClayDropDown.Item>

		{option && option.separator && <ClayDropDown.Divider />}
	</>
);

const DropdownList = ({
	currentValue,
	expand,
	handleSelect,
	multiple,
	options,
}) => (
	<ClayDropDown.ItemList>
		{options.map((option, index) => (
			<DropdownItem
				currentValue={currentValue}
				expand={expand}
				index={index}
				key={`${option.value}-${index}`}
				multiple={multiple}
				onSelect={handleSelect}
				option={option}
				options={options}
			/>
		))}
	</ClayDropDown.ItemList>
);

const DropdownListWithSearch = ({
	currentValue,
	expand,
	handleSelect,
	multiple,
	options,
}) => {
	const [query, setQuery] = useState('');
	const [filteredOptions, setFilteredOptions] = useState([]);

	const emptyOption = {
		label: Liferay.Language.get('choose-an-option'),
		value: null,
	};

	useEffect(() => {
		const result = options.filter(
			(option) =>
				option.value &&
				option.label.toLowerCase().includes(query.toLowerCase())
		);

		setFilteredOptions([emptyOption, ...result]);
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [options, query]);

	return (
		<>
			<ClayDropDown.Search
				onChange={(event) => setQuery(event.target.value)}
				value={query}
			/>
			{filteredOptions.length > 1 ? (
				<DropdownList
					currentValue={currentValue}
					expand={expand}
					handleSelect={handleSelect}
					multiple={multiple}
					options={filteredOptions}
				/>
			) : (
				<div className="dropdown-section text-muted">
					{Liferay.Language.get('empty-list')}
				</div>
			)}
		</>
	);
};

const Trigger = forwardRef(
	(
		{
			onCloseButtonClicked,
			onTriggerClicked,
			onTriggerKeyDown,
			readOnly,
			value,
			...otherProps
		},
		ref
	) => {
		return (
			<>
				{!readOnly && (
					<HiddenSelectInput value={value} {...otherProps} />
				)}
				<VisibleSelectInput
					onClick={onTriggerClicked}
					onCloseButtonClicked={onCloseButtonClicked}
					onKeyDown={onTriggerKeyDown}
					readOnly={readOnly}
					ref={ref}
					value={value}
					{...otherProps}
				/>
			</>
		);
	}
);

const Select = ({
	multiple,
	onCloseButtonClicked,
	onDropdownItemClicked,
	onExpand,
	options,
	predefinedValue,
	readOnly,
	value,
	...otherProps
}) => {
	const menuElementRef = useRef(null);
	const triggerElementRef = useRef(null);

	const [currentValue, setCurrentValue] = useSyncValue(value, false);
	const [expand, setExpand] = useState(false);

	const handleFocus = (event, direction) => {
		const target = event.target;
		const focusabledElements = event.currentTarget.querySelectorAll(
			'button'
		);
		const targetIndex = [...focusabledElements].findIndex(
			(current) => current === target
		);

		let nextElement;

		if (direction) {
			nextElement = focusabledElements[targetIndex - 1];
		}
		else {
			nextElement = focusabledElements[targetIndex + 1];
		}

		if (nextElement) {
			event.preventDefault();
			event.stopPropagation();
			nextElement.focus();
		}
		else if (targetIndex === 0 && direction) {
			event.preventDefault();
			event.stopPropagation();
			menuElementRef.current.focus();
		}
	};

	const handleSelect = ({currentValue, event, multiple, option}) => {
		const newValue = handleDropdownItemClick({
			currentValue,
			multiple,
			option,
		});

		setCurrentValue(newValue);

		onDropdownItemClicked({event, value: newValue});

		if (!multiple) {
			setExpand(false);

			onExpand({event, expand: false});

			triggerElementRef.current.firstChild.focus();
		}
	};

	return (
		<>
			<Trigger
				multiple={multiple}
				onCloseButtonClicked={({event, value}) => {
					const newValue = removeValue({
						value: currentValue,
						valueToBeRemoved: value,
					});

					setCurrentValue(newValue);

					onCloseButtonClicked({event, value: newValue});
				}}
				onTriggerClicked={(event) => {
					if (readOnly) {
						return;
					}

					setExpand(!expand);
					onExpand({event, expand: !expand});

					if (expand) {
						triggerElementRef.current.firstChild.focus();
					}
				}}
				onTriggerKeyDown={(event) => {
					if (
						(event.keyCode === KEYCODES.TAB ||
							event.keyCode === KEYCODES.ARROW_DOWN) &&
						!event.shiftKey &&
						expand
					) {
						event.preventDefault();
						event.stopPropagation();

						const firstElement = menuElementRef.current.querySelector(
							'button'
						);

						firstElement.focus();
					}

					if (
						event.keyCode === KEYCODES.ENTER ||
						(event.keyCode === KEYCODES.SPACE && !event.shiftKey)
					) {
						event.preventDefault();
						event.stopPropagation();

						setExpand(!expand);

						onExpand({event, expand: !expand});

						if (expand) {
							triggerElementRef.current.firstChild.focus();
						}
					}
				}}
				options={options}
				predefinedValue={predefinedValue}
				readOnly={readOnly}
				ref={triggerElementRef}
				value={currentValue}
				{...otherProps}
			/>
			<ClayDropDown.Menu
				active={expand}
				alignElementRef={triggerElementRef}
				className="ddm-btn-full ddm-select-dropdown"
				onKeyDown={(event) => {
					switch (event.keyCode) {
						case KEYCODES.ARROW_DOWN:
							handleFocus(event, false);
							break;
						case KEYCODES.ARROW_UP:
							handleFocus(event, true);
							break;
						case KEYCODES.TAB:
							handleFocus(event, event.shiftKey);
							break;
						default:
							break;
					}
				}}
				onSetActive={setExpand}
				ref={menuElementRef}
			>
				{options.length > MAX_ITEMS ? (
					<DropdownListWithSearch
						currentValue={currentValue}
						expand={expand}
						handleSelect={handleSelect}
						multiple={multiple}
						options={options}
					/>
				) : (
					<DropdownList
						currentValue={currentValue}
						expand={expand}
						handleSelect={handleSelect}
						multiple={multiple}
						options={options}
					/>
				)}
			</ClayDropDown.Menu>
		</>
	);
};

const Main = ({
	fixedOptions = [],
	label,
	localizedValue = {},
	multiple,
	name,
	onBlur = () => {},
	onChange,
	onFocus = () => {},
	options = [],
	predefinedValue = [],
	readOnly = false,
	value = [],
	...otherProps
}) => {
	const predefinedValueArray = toArray(predefinedValue);
	const valueArray = toArray(value);

	const normalizedOptions = useMemo(
		() =>
			normalizeOptions({
				fixedOptions,
				multiple,
				options,
				valueArray,
			}),
		[fixedOptions, multiple, options, valueArray]
	);

	value = useMemo(
		() =>
			normalizeValue({
				multiple,
				normalizedOptions,
				predefinedValueArray,
				valueArray,
			}),
		[multiple, normalizedOptions, predefinedValueArray, valueArray]
	);

	return (
		<FieldBase
			label={label}
			localizedValue={localizedValue}
			name={name}
			readOnly={readOnly}
			{...otherProps}
		>
			<Select
				multiple={multiple}
				name={name}
				onCloseButtonClicked={({event, value}) =>
					onChange(event, value)
				}
				onDropdownItemClicked={({event, value}) =>
					onChange(event, value)
				}
				onExpand={({event, expand}) => {
					if (expand) {
						onFocus(event);
					}
					else {
						onBlur(event);
					}
				}}
				options={normalizedOptions}
				predefinedValue={predefinedValueArray}
				readOnly={readOnly}
				value={value}
				{...otherProps}
			/>
		</FieldBase>
	);
};

Main.displayName = 'Select';

export default Main;