import { mdiArrowRightDropCircleOutline, mdiCloseCircle, mdiMagnify, } from '@mdi/js'; import Icon from '@mdi/react'; import classnames from 'classnames'; import { noop } from 'lodash'; import { type ChangeEvent, Component, type ReactElement, createRef, } from 'react'; import withStyles, { type WithStylesProps } from 'react-jss'; import type { Theme } from '../../../themes'; // biome-ignore lint/suspicious/noShadowRestrictedNames: import Error from '../error'; import Label from '../label'; import type { IFormField } from '../typings/generic'; import Wrapper from '../wrapper'; let popupTransition: string = 'none'; let toggleTransition: string = 'none'; if (window?.matchMedia('(prefers-reduced-motion: no-preference)')) { popupTransition = 'all 0.3s'; toggleTransition = 'transform 0.3s'; } const styles = (theme: Theme) => ({ select: { background: theme.selectBackground, border: theme.selectBorder, borderRadius: theme.borderRadiusSmall, height: theme.selectHeight, fontSize: theme.uiFontSize, width: '100%', display: 'flex', alignItems: 'center', textAlign: 'left', color: theme.selectColor, }, label: { '& > div': { marginTop: 5, }, }, popup: { opacity: 0, height: 0, overflowX: 'scroll', border: theme.selectBorder, borderTop: 0, transition: popupTransition, }, open: { opacity: 1, height: 350, background: theme.selectPopupBackground, }, option: { padding: 10, borderBottom: theme.selectOptionBorder, color: theme.selectOptionColor, '&:hover': { background: theme.selectOptionItemHover, color: theme.selectOptionItemHoverColor, }, '&:active': { background: theme.selectOptionItemActive, color: theme.selectOptionItemActiveColor, }, }, selected: { background: theme.selectOptionItemActive, color: theme.selectOptionItemActiveColor, }, toggle: { marginLeft: 'auto', fill: theme.selectToggleColor, transition: toggleTransition, }, toggleOpened: { transform: 'rotateZ(90deg)', }, searchContainer: { display: 'flex', background: theme.selectSearchBackground, alignItems: 'center', paddingLeft: 10, color: theme.selectColor, '& svg': { fill: theme.selectSearchColor, }, }, search: { border: 0, width: '100%', fontSize: theme.uiFontSize, background: 'none', marginLeft: 10, padding: [10, 0], color: theme.selectSearchColor, }, clearNeedle: { background: 'none', border: 0, }, focused: { fontWeight: 'bold', background: theme.selectOptionItemHover, color: theme.selectOptionItemHoverColor, }, hasError: { borderColor: theme.brandDanger, }, disabled: { opacity: theme.selectDisabledOpacity, }, input: {}, }); interface IOptions { [index: string]: string; } interface IData { [index: string]: string; } interface IProps extends IFormField, WithStylesProps { actionText: string; className?: string; inputClassName?: string; defaultValue?: string; disabled?: boolean; id?: string; name: string; options: IOptions; value: string; onChange: (event: ChangeEvent | string) => void; showSearch: boolean; data: IData; } interface IState { open: boolean; value: string; needle: string; selected: number; options: IOptions | null; } class SelectComponent extends Component { private componentRef = createRef(); private inputRef = createRef(); private searchInputRef = createRef(); private scrollContainerRef = createRef(); private activeOptionRef = createRef(); private keyListener: (e: KeyboardEvent) => void; static getDerivedStateFromProps( nextProps: IProps, prevState: IProps, ): Partial { if (nextProps.value && nextProps.value !== prevState.value) { return { value: nextProps.value, }; } return { value: prevState.value, }; } constructor(props: IProps) { super(props); this.state = { open: false, value: '', needle: '', selected: 0, options: null, }; this.keyListener = noop; this.arrowKeysHandler = this.arrowKeysHandler.bind(this); } UNSAFE_componentWillMount(): void { const { value } = this.props; if (this.componentRef?.current) { this.componentRef.current.removeEventListener( 'keydown', this.keyListener, ); } if (value) { this.setState({ value, }); } this.setFilter(); } componentDidMount(): void { if (this.inputRef?.current) { const { data } = this.props; if (data) { for (const key of Object.keys(data)) this.inputRef.current!.dataset[key] = data[key]; } } window.addEventListener('keydown', this.arrowKeysHandler, false); } componentDidUpdate(): void { const { open } = this.state; if (this.searchInputRef?.current && open) { this.searchInputRef.current.focus(); } } componentWillUnmount(): void { window.removeEventListener('keydown', this.arrowKeysHandler); } setFilter(needle = ''): void { const { options } = this.props; let filteredOptions = {}; if (needle) { for (const key of Object.keys(options)) { if ( key.toLocaleLowerCase().startsWith(needle.toLocaleLowerCase()) || options[key] .toLocaleLowerCase() .startsWith(needle.toLocaleLowerCase()) ) { Object.assign(filteredOptions, { [`${key}`]: options[key], }); } } } else { filteredOptions = options; } this.setState({ needle, options: filteredOptions, selected: 0, }); } select(key: string): void { this.setState(() => ({ value: key, open: false, })); this.setFilter(); if (this.props.onChange) { this.props.onChange(key); } } arrowKeysHandler(e: KeyboardEvent): void { const { selected, open, options } = this.state; if (!open) return; if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); } if (this.componentRef?.current) { if (e.key === 'ArrowUp' && selected > 0) { this.setState((state: IState) => ({ selected: state.selected - 1, })); } else if ( e.key === 'ArrowDown' && selected < Object.keys(options!).length - 1 ) { this.setState((state: IState) => ({ selected: state.selected + 1, })); } else if (e.key === 'Enter') { this.select(Object.keys(options!)[selected]); } if (this.activeOptionRef?.current && this.scrollContainerRef?.current) { const containerTopOffset = this.scrollContainerRef.current.offsetTop; const optionTopOffset = this.activeOptionRef.current.offsetTop; const topOffset = optionTopOffset - containerTopOffset; this.scrollContainerRef.current.scrollTop = topOffset - 35; } } } render(): ReactElement { const { actionText, classes, className, defaultValue, id, inputClassName, name, label, showSearch, required, onChange = noop, showLabel = true, disabled = false, error = '', } = this.props; const { open, needle, value, selected, options } = this.state; let selection = actionText; if (!value && defaultValue && options![defaultValue]) { selection = options![defaultValue]; } else if (value && options![value]) { selection = options![value]; } return ( {error && } ); } } export default withStyles(styles, { injectTheme: true })(SelectComponent);