/* eslint-disable max-lines */ import { Option, Options, TypeGuards } from '@codeleap/types' import { onMount, onUpdate, usePrevious, useSearch, useConditionalState, } from '@codeleap/hooks' import React, { useCallback, useMemo } from 'react' import { List } from '../List' import { TextInput } from '../TextInput' import { SelectProps, ValueBoundSelectProps } from './types' import { Button } from '../Button' import { AnyRecord, AppIcon, IJSX, StyledComponentProps, useCompositionStyles } from '@codeleap/styles' import { Modal } from '../Modal' import { MobileStyleRegistry } from '../../Registry' import { SearchInput } from '../SearchInput' import { useStylesFor } from '../../hooks' import { SelectableField, fields } from '@codeleap/form' import { useInputBase } from '../InputBase/useInputBase' export * from './styles' export * from './types' /** Client-side only — skipped entirely when `loadOptions` is provided, since server search supersedes local filtering. */ const defaultFilterFunction = (search: string, options: Options) => { return options.filter((option) => { if (TypeGuards.isString(option.label)) { return option.label.toLowerCase().includes(search.toLowerCase()) } return option.label === search }) } const defaultGetLabel = (option) => { if (TypeGuards.isArray(option)) { if (option?.length === 0) return null const labels = option?.map(option => option?.label)?.filter(value => !!value) return labels?.join(', ') } else { if (!option) return null return option?.label } } /** * The TextInput here is intentionally non-editable — `onValueChange` returns the current label * to prevent the native keyboard from mutating the display value directly. Editing is done via the modal. */ const OuterInput: ValueBoundSelectProps['outerInputComponent'] = (props) => { const { currentValueLabel, debugName, clearIcon, label, toggle, style, placeholder, disabled = false, inputProps = {}, } = props return currentValueLabel} rightIcon={clearIcon} onPress={disabled ? null : () => toggle()} disabled={disabled} label={label} debugName={debugName} style={style} innerWrapperProps={{ rippleDisabled: true, }} placeholder={placeholder as any} {...inputProps} /> } /** * Dropdown is always rendered inside a Modal — there is no native ActionSheet or platform-specific * picker. Keyboard is not auto-dismissed on open; pass `keyboardShouldPersistTaps` via `listProps` * if the modal appears beneath a focused input. */ export const Select = (selectProps: SelectProps) => { const allProps = { ...Select.defaultProps, ...selectProps, } const { label, options = [], style, description, renderItem: Item, listProps, debugName, placeholder, arrowIconName, clearIconName, clearable, selectedIcon, inputProps = {}, hideInput, itemProps = {}, searchable, loadOptions, multiple, closeOnSelect = !multiple, limit = null, defaultOptions = options, visible: _visible, toggle: _toggle, ListHeaderComponent, ListComponent, onLoadOptionsError, loadOptionsOnMount = defaultOptions.length === 0, loadOptionsOnOpen, filterItems, getLabel, searchInputProps, outerInputComponent, disabled, field, value, onValueChange, onSelect, ...modalProps } = allProps const { inputValue, onInputValueChange, } = useInputBase( field, fields.selectable as () => SelectableField, { value, onValueChange } ) const isValueArray = TypeGuards.isArray(inputValue) && multiple const { loading, setLoading, labelOptions, setLabelOptions, filteredOptions, load, onChangeSearch, } = useSearch({ value: inputValue, multiple, options, filterItems, debugName, defaultOptions, loadOptions, onLoadOptionsError, }) const [visible, toggle] = useConditionalState(_visible, _toggle, { initialValue: false, isBooleanToggle: true }) const currentValueLabel = useMemo(() => { const _options = (multiple ? labelOptions : labelOptions?.[0]) as Multi extends true ? Options : Option const label = getLabel(_options) return label }, [labelOptions]) onMount(() => { if (loadOptionsOnMount && !!loadOptions) { load() } }) const prevVisible = usePrevious(visible) onUpdate(() => { if (visible && !prevVisible && loadOptionsOnOpen && !!loadOptions) { load() } }, [visible, prevVisible]) const styles = useStylesFor(Select.styleRegistryName, style) const compositionStyles = useCompositionStyles(['item', 'list', 'input', 'searchInput'], styles) const currentOptions = searchable ? filteredOptions : defaultOptions const close = () => toggle?.() const select = useCallback((selectedValue) => { let newValue = null let newOption = null let removedIndex = null if (multiple && isValueArray) { if (inputValue.includes(selectedValue)) { removedIndex = inputValue.findIndex(v => v === selectedValue) newValue = inputValue.filter((v, i) => i !== removedIndex) } else { if (TypeGuards.isNumber(limit) && inputValue.length >= limit) { return } newOption = currentOptions.find(o => o.value === selectedValue) newValue = [...inputValue, selectedValue] } } else { newValue = selectedValue newOption = currentOptions.find(o => o.value === selectedValue) } onInputValueChange(newValue) onSelect?.(newValue) if (isValueArray) { if (removedIndex !== null) { const newOptions = [...labelOptions] newOptions.splice(removedIndex, 1) setLabelOptions(newOptions) } else { const newLabels = [...labelOptions, newOption] setLabelOptions(newLabels) } } else { setLabelOptions([newOption]) } if (closeOnSelect) { close?.() } }, [isValueArray, (isValueArray ? inputValue : [inputValue]), limit, multiple, labelOptions, currentOptions]) const renderListItem = useCallback(({ item, index }) => { let selected = false if (multiple && isValueArray) { selected = inputValue?.includes(item.value) } else { selected = inputValue === item.value } return ( select(item.value)} // @ts-ignore icon={selectedIcon} // @ts-ignore rightIcon={selectedIcon} style={compositionStyles?.item} index={index} {...itemProps} /> ) }, [inputValue, select, multiple]) const isEmpty = TypeGuards.isNil(inputValue) const showClearIcon = !isEmpty && clearable const inputIcon = showClearIcon ? clearIconName : arrowIconName const onPressInputIcon = () => { if (showClearIcon) { onInputValueChange(null) } else { close?.() } } /** Debounce is only applied when `loadOptions` is provided; local filtering runs synchronously. */ const searchHeader = searchable ? { if (searchable && !!loadOptions) { setLoading(isTyping) } }} debounce={!!loadOptions ? 800 : null} onSearchChange={onChangeSearch} style={compositionStyles?.searchInput} {...searchInputProps} /> : null const _ListHeaderComponent = useMemo(() => { if (ListHeaderComponent) { return } return searchHeader }, [searchable, ListHeaderComponent]) const Input = outerInputComponent return <> { !hideInput ? ( // @ts-ignore ) : null } i.value} renderItem={renderListItem} fakeEmpty={loading} separators keyboardAware={false} {...listProps} ListHeaderComponent={_ListHeaderComponent} placeholder={{ loading, }} /> } Select.styleRegistryName = 'Select' Select.elements = [...Modal.elements, 'input', 'list', 'item', 'searchInput'] Select.rootElement = 'inputWrapper' Select.withVariantTypes = (styles: S) => { return Select as ((props: StyledComponentProps, typeof styles>) => IJSX) } Select.defaultProps = { getLabel: defaultGetLabel, outerInputComponent: OuterInput, searchInputProps: {}, arrowIconName: 'chevrons-up-down' as AppIcon, clearIconName: 'x' as AppIcon, placeholder: 'Select', clearable: false, selectedIcon: 'check' as AppIcon, hideInput: false, multiple: false, loadOptionsOnOpen: false, disabled: false, filterItems: defaultFilterFunction, renderItem: Button, ListComponent: List, } as Partial> MobileStyleRegistry.registerComponent(Select)