231 lines
		
	
	
	
		
			7.9 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			231 lines
		
	
	
	
		
			7.9 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | import React, {useCallback, useMemo, useRef} from "react"; | ||
|  | import {Suggestible} from "./Suggestible"; | ||
|  | import {OptionsSuggestions, useSuggestionsNavigation} from "./OptionsSuggestions"; | ||
|  | import {classes, Modify, normalizeString} from "../../Utils"; | ||
|  | import {CaretDown, Check, X} from "@phosphor-icons/react"; | ||
|  | 
 | ||
|  | /** | ||
|  |  * Generic select component. | ||
|  |  */ | ||
|  | export function Select<OptionKey extends keyof any, Option>( | ||
|  | 	{ | ||
|  | 		className, | ||
|  | 		value, onChange, | ||
|  | 		options, renderOption, match, | ||
|  | 		selectibleMaxCount, multiple, | ||
|  | 		blurOnSelect, blurWhenMaxCountSelected, | ||
|  | 		// Properties to pass down.
 | ||
|  | 		onKeyDown, | ||
|  | 		// Already set properties.
 | ||
|  | 		type, role, | ||
|  | 		children, | ||
|  | 		...props | ||
|  | 	}: React.PropsWithChildren<Modify<React.InputHTMLAttributes<HTMLInputElement>, { | ||
|  | 		/** | ||
|  | 		 * The currently selected option(s). | ||
|  | 		 */ | ||
|  | 		value: OptionKey|OptionKey[]; | ||
|  | 
 | ||
|  | 		/** | ||
|  | 		 * Called when new options are selected. | ||
|  | 		 * @param newValue | ||
|  | 		 */ | ||
|  | 		onChange: (newValue: OptionKey|OptionKey[]) => void; | ||
|  | 
 | ||
|  | 		/** | ||
|  | 		 * Options list or a way to get it from a given search. | ||
|  | 		 */ | ||
|  | 		options: Record<OptionKey, Option>; | ||
|  | 
 | ||
|  | 		/** | ||
|  | 		 * Render an option. | ||
|  | 		 * @param option The option to render. | ||
|  | 		 */ | ||
|  | 		renderOption?: (option: Option) => React.ReactNode; | ||
|  | 
 | ||
|  | 		/** | ||
|  | 		 * Option match function. Return true if the given option matches the given search query. | ||
|  | 		 * @param search The search query. | ||
|  | 		 * @param option The option that should match the search query. | ||
|  | 		 */ | ||
|  | 		match?: (search: string, option: Option) => boolean; | ||
|  | 
 | ||
|  | 		/** | ||
|  | 		 * Max count of options to allow to select. | ||
|  | 		 * 1 by default when multiple is false, infinity when multiple is true. | ||
|  | 		 */ | ||
|  | 		selectibleMaxCount?: number; | ||
|  | 
 | ||
|  | 		/** | ||
|  | 		 * True to allow to select an infinite count of options. | ||
|  | 		 * false by default. | ||
|  | 		 */ | ||
|  | 		multiple?: boolean; | ||
|  | 
 | ||
|  | 		/** | ||
|  | 		 * When true, the select loses focus when a new option is selected (by clicking or pressing Enter). | ||
|  | 		 * false by default. | ||
|  | 		 */ | ||
|  | 		blurOnSelect?: boolean; | ||
|  | 
 | ||
|  | 		/** | ||
|  | 		 * When true, the select loses focus when the max count of selectible options have been reached (by clicking or pressing Enter). | ||
|  | 		 * true by default. | ||
|  | 		 */ | ||
|  | 		blurWhenMaxCountSelected?: boolean; | ||
|  | 
 | ||
|  | 		// Already set properties.
 | ||
|  | 		type?: never; | ||
|  | 		role?: never; | ||
|  | 	}>>): React.ReactElement | ||
|  | { | ||
|  | 	const [search, setSearch] = React.useState(""); | ||
|  | 
 | ||
|  | 	// By default, allow to select only one option.
 | ||
|  | 	// If `multiple` is set and `selectibleMaxCount` is not, allow an infinite count of options to select.
 | ||
|  | 	selectibleMaxCount = selectibleMaxCount ?? ((multiple === undefined ? false : multiple) ? Infinity : 1); | ||
|  | 
 | ||
|  | 	// true by default.
 | ||
|  | 	blurOnSelect = blurOnSelect === undefined ? false : blurOnSelect; | ||
|  | 	blurWhenMaxCountSelected = blurWhenMaxCountSelected === undefined ? true : blurWhenMaxCountSelected; | ||
|  | 
 | ||
|  | 	// Initialize default option render function.
 | ||
|  | 	const defaultRenderOption = useCallback((option: Option) => (String(option)), []); | ||
|  | 
 | ||
|  | 	// Initialize default match option function.
 | ||
|  | 	const defaultMatchOption = useCallback((search: string, option: Option) => normalizeString(String(option)).includes(normalizeString(search)), []); | ||
|  | 
 | ||
|  | 	// An array of the selected options.
 | ||
|  | 	const selectedOptions = useMemo(() => ( | ||
|  | 		// Normalize value to an array.
 | ||
|  | 		((!Array.isArray(value) ? [value] : value) | ||
|  | 			// Try to get the corresponding option for each given value.
 | ||
|  | 			.map((optionKey) => [optionKey, options?.[optionKey]]) as [OptionKey, Option][]) | ||
|  | 			// Filter non-existing options.
 | ||
|  | 			.filter(([_, option]) => (option !== undefined)) | ||
|  | 	), [value, options]); | ||
|  | 
 | ||
|  | 	// A reference to the main search input.
 | ||
|  | 	const inputRef = useRef<HTMLInputElement>(); | ||
|  | 
 | ||
|  | 	// The suggestions' navigator.
 | ||
|  | 	const suggestionsNavigator = useSuggestionsNavigation(); | ||
|  | 
 | ||
|  | 	// Get all available options, filtered using search query.
 | ||
|  | 	const filteredOptions = useMemo(() => ( | ||
|  | 		!search || (search.length == 0) | ||
|  | 			// Nothing is searched, return all options.
 | ||
|  | 			? options | ||
|  | 			// Filter options using search query and matching function.
 | ||
|  | 			: Object.fromEntries( | ||
|  | 				(Object.entries(options) as [OptionKey, Option][]).filter(([optionKey, option]) => ( | ||
|  | 					(match ?? defaultMatchOption)(search, option) | ||
|  | 				)) | ||
|  | 			) as Record<OptionKey, Option> | ||
|  | 	), [options, search, match, defaultMatchOption]); | ||
|  | 
 | ||
|  | 	// Called when a new option is selected.
 | ||
|  | 	const handleSelectedOption = useCallback((selectedOption: OptionKey) => { | ||
|  | 		// Get an associative object from selected options.
 | ||
|  | 		const currentSelection = Object.fromEntries(selectedOptions) as Record<OptionKey, Option>; | ||
|  | 
 | ||
|  | 		// Initialize the new selection variable.
 | ||
|  | 		let newSelection: OptionKey|OptionKey[] = Object.keys(currentSelection) as OptionKey[]; | ||
|  | 
 | ||
|  | 		if (selectibleMaxCount == 1) | ||
|  | 			// Only one possible selection = the newly selected option is the new selection.
 | ||
|  | 			newSelection = selectedOption; | ||
|  | 		else | ||
|  | 		{ // Multiple selections possible.
 | ||
|  | 			if (!currentSelection[selectedOption]) | ||
|  | 			{ // The newly selected option wasn't selected, we should add it in the selection array.
 | ||
|  | 				// Add the newly selected option in the array.
 | ||
|  | 				newSelection = [...newSelection, selectedOption]; | ||
|  | 				if (newSelection.length > selectibleMaxCount) | ||
|  | 					// If the array is now too big, we remove the first options to match the max count of options.
 | ||
|  | 					newSelection.splice(0, newSelection.length - selectibleMaxCount); | ||
|  | 			} | ||
|  | 			else | ||
|  | 			{ // The option was already selected, we should deselect it.
 | ||
|  | 				newSelection = newSelection.filter((key) => key != selectedOption); | ||
|  | 			} | ||
|  | 		} | ||
|  | 
 | ||
|  | 		// Call onChange event with the new selection.
 | ||
|  | 		onChange(newSelection); | ||
|  | 
 | ||
|  | 		// Reset search query.
 | ||
|  | 		setSearch(""); | ||
|  | 
 | ||
|  | 		if ( | ||
|  | 			// Always blur on selection.
 | ||
|  | 			blurOnSelect | ||
|  | 			|| | ||
|  | 			// Blur when the new selection now reach the max selectible count.
 | ||
|  | 			(blurWhenMaxCountSelected && ((selectibleMaxCount == 1 && !!newSelection) || (selectibleMaxCount == (newSelection as OptionKey[])?.length))) | ||
|  | 		) // Try to lose focus, as a new selection has been made and blur conditions are reached.
 | ||
|  | 			window.setTimeout(() => { inputRef?.current?.blur(); }, 0); | ||
|  | 	}, [selectedOptions, onChange, setSearch]); | ||
|  | 
 | ||
|  | 	// Called when an option is deselected.
 | ||
|  | 	const handleDeselectedOption = useCallback((deselectedOption: OptionKey) => { | ||
|  | 		// Call onChange event with the new selection.
 | ||
|  | 		onChange( | ||
|  | 			selectedOptions | ||
|  | 				// Remove deselected option if it was in the selected options array.
 | ||
|  | 				.filter(([optionKey]) => (optionKey != deselectedOption)) | ||
|  | 				.map(([optionKey]) => optionKey) | ||
|  | 		); | ||
|  | 	}, [selectedOptions, onChange]); | ||
|  | 
 | ||
|  | 	return ( | ||
|  | 		<label className={classes("select", className)} | ||
|  | 		       onFocus={useCallback(() => { | ||
|  | 			       inputRef.current?.focus(); | ||
|  | 		       }, [inputRef])}> | ||
|  | 			<div> | ||
|  | 				<Suggestible suggestions={ | ||
|  | 					<OptionsSuggestions options={filteredOptions} renderOption={renderOption} | ||
|  | 						                  // Get an associative object from selected options.
 | ||
|  | 					                    selectedOptions={Object.fromEntries(selectedOptions) as Record<OptionKey, Option>} | ||
|  | 					                    onSelected={handleSelectedOption} navigator={suggestionsNavigator}/> | ||
|  | 				}> | ||
|  | 					<input ref={inputRef} type={"text"} role={"select"} value={search} | ||
|  | 					       onKeyDown={(event) => { | ||
|  | 						       if (event.key == "ArrowDown") | ||
|  | 							       suggestionsNavigator.current?.next(); | ||
|  | 						       else if (event.key == "ArrowUp") | ||
|  | 							       suggestionsNavigator.current?.previous(); | ||
|  | 						       else if (event.key == "Enter") | ||
|  | 							       suggestionsNavigator.current?.select(); | ||
|  | 
 | ||
|  | 						       return onKeyDown?.(event); | ||
|  | 					       }} | ||
|  | 					       onChange={(event) => setSearch(event.currentTarget.value)} | ||
|  | 					       {...props} /> | ||
|  | 				</Suggestible> | ||
|  | 
 | ||
|  | 				<a className={"button"} tabIndex={-1}><CaretDown weight={"bold"}/></a> | ||
|  | 			</div> | ||
|  | 
 | ||
|  | 			<ul className={"selected"}> | ||
|  | 				{ // Showing each selected value.
 | ||
|  | 					selectedOptions.map(([optionKey, option]) => ( | ||
|  | 						<li key={String(optionKey)}> | ||
|  | 							<Check weight={"bold"}/> | ||
|  | 							<div className={"option"}>{(renderOption ?? defaultRenderOption)(option)}</div> | ||
|  | 							<button className={"remove flat"} type={"button"} onClick={() => handleDeselectedOption(optionKey)}> | ||
|  | 								<X weight={"bold"}/> | ||
|  | 							</button> | ||
|  | 						</li> | ||
|  | 					)) | ||
|  | 				} | ||
|  | 			</ul> | ||
|  | 
 | ||
|  | 			<div className={"label"}> | ||
|  | 				{children} | ||
|  | 			</div> | ||
|  | 		</label> | ||
|  | 	); | ||
|  | } |