nicolium: migrate multiselect to component, slightly improve its accessibility

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-02-26 01:33:42 +01:00
parent 5f68d58730
commit f0c3eb606a
5 changed files with 211 additions and 405 deletions

View File

@ -23,398 +23,248 @@ THE SOFTWARE.
*/
// Adapted from [multiselect-react-dropdown](https://github.com/srigar/multiselect-react-dropdown)
/* eslint-disable jsdoc/require-jsdoc */
// @ts-nocheck
import clsx from 'clsx';
import React, { useRef, useEffect } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Icon from './icon';
function useOutsideAlerter(ref, clickEvent) {
useEffect(() => {
function handleClickOutside(event) {
if (ref.current && !ref.current.contains(event.target)) {
clickEvent();
}
}
const messages = defineMessages({
placeholder: { id: 'select.placeholder', defaultMessage: 'Select' },
noOptions: { id: 'select.no_options', defaultMessage: 'No options available' },
removeItem: { id: 'select.remove_item', defaultMessage: 'Remove item' },
});
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [ref]);
interface Option {
key: string;
value: string;
}
/**
* Component that alerts if you click outside of it
*/
const OutsideAlerter = (props) => {
const wrapperRef = useRef(null);
useOutsideAlerter(wrapperRef, props.outsideClick);
return <div ref={wrapperRef}>{props.children}</div>;
};
interface IMultiselectProps {
options: any;
selectedValues?: any;
displayValue?: string;
placeholder?: string;
loading?: boolean;
emptyRecordMsg?: string;
onSelect?: (selectedList: any, selectedItem: any) => void;
onRemove?: (selectedList: any, selectedItem: any) => void;
onSearch?: (value: string) => void;
onKeyPressFn?: (event: any, value: string) => void;
id?: string;
name?: string;
disabled?: boolean;
interface IMultiselect {
className?: string;
items: Record<string, string>;
value?: string[];
onChange?: (values: string[]) => void;
disabled?: boolean;
}
export class Multiselect extends React.Component<IMultiselectProps, any> {
static defaultProps: IMultiselectProps;
const matchValues = (value: string, search: string): boolean =>
value.toLowerCase().includes(search.toLowerCase());
constructor(props) {
super(props);
this.state = {
inputValue: '',
options: props.options,
filteredOptions: props.options,
unfilteredOptions: props.options,
selectedValues: Object.assign([], props.selectedValues),
toggleOptionsList: false,
highlightOption: 0,
const Multiselect: React.FC<IMultiselect> = ({
className,
items,
value,
onChange,
disabled = false,
}) => {
const intl = useIntl();
const allOptions = useMemo<Option[]>(
() => Object.entries(items).map(([key, value]) => ({ key, value })),
[items],
);
const selectedValues = useMemo<Option[]>(
() =>
(value ?? [])
.map((key) => allOptions.find((o) => o.key === key))
.filter((o): o is Option => o !== undefined),
[value, allOptions],
);
const [inputValue, setInputValue] = useState('');
const [isOpen, setIsOpen] = useState(false);
const searchWrapperRef = useRef<HTMLDivElement>(null);
const searchBoxRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const visibleOptions = useMemo(() => {
const selectedKeys = new Set(selectedValues.map((v) => v.key));
return allOptions
.filter((o) => !selectedKeys.has(o.key))
.filter((o) => matchValues(o.value, inputValue));
}, [allOptions, selectedValues, inputValue]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
setInputValue('');
}
};
// @ts-ignore
this.optionTimeout = null;
// @ts-ignore
this.searchWrapper = React.createRef();
// @ts-ignore
this.searchBox = React.createRef();
this.onChange = this.onChange.bind(this);
this.onKeyPress = this.onKeyPress.bind(this);
this.onFocus = this.onFocus.bind(this);
this.onBlur = this.onBlur.bind(this);
this.renderMultiselectContainer = this.renderMultiselectContainer.bind(this);
this.renderSelectedList = this.renderSelectedList.bind(this);
this.onRemoveSelectedItem = this.onRemoveSelectedItem.bind(this);
this.toggleOptionList = this.toggleOptionList.bind(this);
this.onArrowKeyNavigation = this.onArrowKeyNavigation.bind(this);
this.onSelectItem = this.onSelectItem.bind(this);
this.filterOptionsByInput = this.filterOptionsByInput.bind(this);
this.removeSelectedValuesFromOptions = this.removeSelectedValuesFromOptions.bind(this);
this.isSelectedValue = this.isSelectedValue.bind(this);
this.renderOption = this.renderOption.bind(this);
this.listenerCallback = this.listenerCallback.bind(this);
this.onCloseOptionList = this.onCloseOptionList.bind(this);
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
initialSetValue() {
this.removeSelectedValuesFromOptions(false);
}
useEffect(() => {
const wrapper = searchWrapperRef.current;
const focusInput = () => searchBoxRef.current?.focus();
wrapper?.addEventListener('click', focusInput);
return () => wrapper?.removeEventListener('click', focusInput);
}, []);
componentDidMount() {
this.initialSetValue();
// @ts-ignore
this.searchWrapper.current.addEventListener('click', this.listenerCallback);
}
const emitChange = useCallback(
(next: Option[]) => onChange?.(next.map((o) => o.key)),
[onChange],
);
componentDidUpdate(prevProps) {
const { options, selectedValues } = this.props;
const { options: prevOptions, selectedValues: prevSelectedvalues } = prevProps;
if (JSON.stringify(prevOptions) !== JSON.stringify(options)) {
this.setState(
{ options, filteredOptions: options, unfilteredOptions: options },
this.initialSetValue,
);
}
if (JSON.stringify(prevSelectedvalues) !== JSON.stringify(selectedValues)) {
this.setState({ selectedValues: Object.assign([], selectedValues) }, this.initialSetValue);
}
}
listenerCallback() {
// @ts-ignore
this.searchBox.current.focus();
}
componentWillUnmount() {
// @ts-ignore
if (this.optionTimeout) {
// @ts-ignore
clearTimeout(this.optionTimeout);
}
// @ts-ignore
this.searchWrapper.current.removeEventListener('click', this.listenerCallback);
}
// Skipcheck flag - value will be true when the func called from on deselect anything.
removeSelectedValuesFromOptions(skipCheck) {
const { displayValue } = this.props;
const { selectedValues = [], unfilteredOptions } = this.state;
if (!selectedValues.length && !skipCheck) {
return;
}
const optionList = unfilteredOptions.filter((item) => {
return selectedValues.findIndex((v) => v[displayValue] === item[displayValue]) === -1;
});
this.setState({ options: optionList, filteredOptions: optionList }, this.filterOptionsByInput);
}
onChange(event) {
const { onSearch } = this.props;
this.setState({ inputValue: event.target.value }, this.filterOptionsByInput);
if (onSearch) {
onSearch(event.target.value);
}
}
onKeyPress(event) {
const { onKeyPressFn } = this.props;
if (onKeyPressFn) {
onKeyPressFn(event, event.target.value);
}
}
filterOptionsByInput() {
let { options } = this.state;
const { filteredOptions, inputValue } = this.state;
const { displayValue } = this.props;
options = filteredOptions.filter((i) => this.matchValues(i[displayValue], inputValue));
this.setState({ options });
}
matchValues(value, search) {
if (value.toLowerCase) {
return value.toLowerCase().indexOf(search.toLowerCase()) > -1;
}
return value.toString().indexOf(search) > -1;
}
onArrowKeyNavigation(e) {
const { options, highlightOption, toggleOptionsList, inputValue, selectedValues } = this.state;
if (e.keyCode === 8 && !inputValue && selectedValues.length) {
this.onRemoveSelectedItem(selectedValues.length - 1);
}
if (!options.length) {
return;
}
if (e.keyCode === 38) {
if (highlightOption > 0) {
this.setState((previousState) => ({
highlightOption: previousState.highlightOption - 1,
}));
const onSelectItem = useCallback(
(option: Option) => {
setInputValue('');
const alreadySelected = selectedValues.some((v) => v.key === option.key);
if (alreadySelected) {
emitChange(selectedValues.filter((v) => v.key !== option.key));
} else {
this.setState({ highlightOption: options.length - 1 });
emitChange([...selectedValues, option]);
}
} else if (e.keyCode === 40) {
if (highlightOption < options.length - 1) {
this.setState((previousState) => ({
highlightOption: previousState.highlightOption + 1,
}));
} else {
this.setState({ highlightOption: 0 });
}
} else if (e.key === 'Enter' && options.length && toggleOptionsList) {
if (highlightOption === -1) {
},
[selectedValues, emitChange],
);
const onRemoveSelectedItem = useCallback(
(option: Option) => {
emitChange(selectedValues.filter((v) => v.key !== option.key));
},
[selectedValues, emitChange],
);
const onInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
}, []);
const onFocus = useCallback(() => {
setIsOpen(true);
}, []);
const optionTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const onBlur = useCallback(() => {
optionTimeoutRef.current = setTimeout(() => {
const activeElement = document.activeElement;
const stillInsideContainer =
!!activeElement && !!containerRef.current?.contains(activeElement);
if (stillInsideContainer) {
return;
}
this.onSelectItem(options[highlightOption]);
}
// TODO: Instead of scrollIntoView need to find better soln for scroll the dropwdown container.
// setTimeout(() => {
// const element = document.querySelector("ul.optionContainer .highlight");
// if (element) {
// element.scrollIntoView();
// }
// });
}
onRemoveSelectedItem(item) {
const { selectedValues } = this.state;
let { index = 0 } = this.state;
const { onRemove, displayValue } = this.props;
index = selectedValues.findIndex((i) => i[displayValue] === item[displayValue]);
selectedValues.splice(index, 1);
onRemove(selectedValues, item);
this.setState({ selectedValues }, () => {
this.removeSelectedValuesFromOptions(true);
});
}
setInputValue('');
setIsOpen(false);
}, 0);
}, []);
onSelectItem(item) {
const { selectedValues } = this.state;
const { onSelect } = this.props;
this.setState({
inputValue: '',
});
if (this.isSelectedValue(item)) {
this.onRemoveSelectedItem(item);
return;
}
selectedValues.push(item);
onSelect(selectedValues, item);
this.setState({ selectedValues }, () => {
this.removeSelectedValuesFromOptions(true);
});
}
const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !inputValue && selectedValues.length) {
onRemoveSelectedItem(selectedValues[selectedValues.length - 1]);
return;
}
if (!visibleOptions.length) return;
isSelectedValue(item) {
const { displayValue } = this.props;
const { selectedValues } = this.state;
return selectedValues.filter((i) => i[displayValue] === item[displayValue]).length > 0;
}
const options = Array.from(
containerRef.current?.querySelectorAll('ul li') || [],
) as HTMLLIElement[];
const index = options.indexOf(document.activeElement as any);
renderOptionList() {
const { emptyRecordMsg, loading, loadingMessage = 'loading...' } = this.props;
const { options } = this.state;
if (loading) {
return (
<ul className='optionContainer'>
{typeof loadingMessage === 'string' && <span className='notFound'>{loadingMessage}</span>}
{typeof loadingMessage !== 'string' && loadingMessage}
</ul>
);
}
return (
<ul className='optionContainer'>
{options.length === 0 && <span className='notFound'>{emptyRecordMsg}</span>}
{this.renderOption()}
</ul>
);
}
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
options[Math.max(index - 1, 0) as any]?.focus();
break;
case 'ArrowDown':
e.preventDefault();
options[Math.min(index + 1, options.length - 1) as any]?.focus();
break;
case 'PageUp':
case 'Home':
e.preventDefault();
options[0]?.focus();
break;
case 'PageDown':
case 'End':
e.preventDefault();
options[options.length - 1]?.focus();
break;
case 'Enter':
if (isOpen) {
e.preventDefault();
if (index !== -1) onSelectItem(visibleOptions[index]);
}
break;
case 'Escape':
setIsOpen(false);
break;
default:
}
},
[inputValue, selectedValues, visibleOptions, isOpen, onSelectItem, onRemoveSelectedItem],
);
renderOption() {
const { displayValue } = this.props;
const { highlightOption } = this.state;
return this.state.options.map((option, i) => {
const isSelected = this.isSelectedValue(option);
return (
<li
key={`option${i}`}
className={`option ${isSelected ? 'selected' : ''} ${highlightOption === i ? 'highlightOption highlight' : ''}`}
onClick={() => {
this.onSelectItem(option);
}}
>
{option[displayValue]}
</li>
);
});
}
renderSelectedList() {
const { displayValue } = this.props;
const { selectedValues } = this.state;
return selectedValues.map((value, index) => (
<span className='chip' key={index}>
{value[displayValue]}
<button
onClick={() => {
this.onRemoveSelectedItem(value);
}}
>
<Icon
className='ml-1 size-4 hover:cursor-pointer'
src={require('@phosphor-icons/core/regular/x-circle.svg')}
/>
</button>
</span>
));
}
toggleOptionList() {
this.setState({
toggleOptionsList: !this.state.toggleOptionsList,
highlightOption: 0,
});
}
onCloseOptionList() {
this.setState({
toggleOptionsList: false,
highlightOption: 0,
inputValue: '',
});
}
onFocus() {
if (this.state.toggleOptionsList) {
// @ts-ignore
clearTimeout(this.optionTimeout);
} else {
this.toggleOptionList();
}
}
onBlur() {
this.setState({ inputValue: '' }, this.filterOptionsByInput);
// @ts-ignore
this.optionTimeout = setTimeout(this.onCloseOptionList, 250);
}
renderMultiselectContainer() {
const { inputValue, toggleOptionsList } = this.state;
const { placeholder, id, name, disabled, className } = this.props;
return (
return (
<div ref={containerRef} onBlur={onBlur} onKeyDown={onKeyDown}>
<div
className={clsx(
'multiselect-container',
{ 'multiselect-container--disabled': disabled },
className,
)}
id={id}
>
<div className='searchWrapper' ref={this.searchWrapper}>
{this.renderSelectedList()}
<div className='searchWrapper' ref={searchWrapperRef}>
{selectedValues.map((option) => (
<span className='chip' key={option.key}>
{option.value}
<button
type='button'
onClick={() => onRemoveSelectedItem(option)}
title={intl.formatMessage(messages.removeItem)}
aria-label={intl.formatMessage(messages.removeItem)}
>
<Icon
className='ml-1 size-4 hover:cursor-pointer'
src={require('@phosphor-icons/core/regular/x-circle.svg')}
/>
</button>
</span>
))}
<input
type='text'
ref={this.searchBox}
ref={searchBoxRef}
className='searchBox'
name={`${name ?? 'search-name'}-input`}
onChange={this.onChange}
onKeyPress={this.onKeyPress}
name='search-name-input'
onChange={onInputChange}
value={inputValue}
onFocus={this.onFocus}
onBlur={this.onBlur}
placeholder={placeholder}
onKeyDown={this.onArrowKeyNavigation}
onFocus={onFocus}
placeholder={intl.formatMessage(messages.placeholder)}
autoComplete='off'
disabled={disabled}
/>
</div>
<div
className={`optionListContainer ${toggleOptionsList ? 'displayBlock' : 'displayNone'}`}
onMouseDown={(e) => {
e.preventDefault();
}}
className={clsx('optionListContainer', isOpen ? 'displayBlock' : 'displayNone')}
onMouseDown={(e) => e.preventDefault()}
>
{this.renderOptionList()}
<ul className='optionContainer'>
{visibleOptions.length === 0 && (
<span className='notFound'>{intl.formatMessage(messages.noOptions)}</span>
)}
{visibleOptions.map((option, i) => (
<li
key={option.key}
className='option'
onClick={() => onSelectItem(option)}
data-index={i}
tabIndex={0}
>
{option.value}
</li>
))}
</ul>
</div>
</div>
);
}
</div>
);
};
render() {
return (
<OutsideAlerter outsideClick={this.onCloseOptionList}>
{this.renderMultiselectContainer()}
</OutsideAlerter>
);
}
}
Multiselect.defaultProps = {
options: [],
selectedValues: [],
displayValue: 'model',
placeholder: 'Select',
emptyRecordMsg: 'No Options Available',
onSelect: () => {},
onRemove: () => {},
onKeyPressFn: () => {},
id: '',
name: '',
disabled: false,
className: '',
} as IMultiselectProps;
export { Multiselect };

View File

@ -1,14 +1,7 @@
import React, { useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import React from 'react';
import { Multiselect as Ms } from '@/components/ui/multiselect';
import Select from '@/components/ui/select';
const messages = defineMessages({
selectPlaceholder: { id: 'select.placeholder', defaultMessage: 'Select' },
selectNoOptions: { id: 'select.no_options', defaultMessage: 'No options available' },
});
interface ISelectDropdown {
className?: string;
label?: React.ReactNode;
@ -30,43 +23,4 @@ const SelectDropdown: React.FC<ISelectDropdown> = (props) => {
return <Select {...rest}>{optionElems}</Select>;
};
interface IMultiselect {
className?: string;
items: Record<string, string>;
value?: string[];
onChange?: (values: string[]) => void;
disabled?: boolean;
}
const Multiselect: React.FC<IMultiselect> = (props) => {
const intl = useIntl();
const { items, value, onChange, disabled } = props;
const options = useMemo(
() => Object.entries(items).map(([key, value]) => ({ key, value })),
[items],
);
const selectedValues = value
?.map((key) => options.find((option) => option.key === key))
.filter((value) => value);
const handleChange = (values: Record<'key' | 'value', string>[]) => {
onChange?.(values.map(({ key }) => key));
};
return (
<Ms
className='plfe-multiselect'
options={options}
selectedValues={selectedValues}
onSelect={handleChange}
onRemove={handleChange}
displayValue='value'
disabled={disabled}
placeholder={intl.formatMessage(messages.selectPlaceholder)}
emptyRecordMsg={intl.formatMessage(messages.selectNoOptions)}
/>
);
};
export { Multiselect, SelectDropdown };
export { SelectDropdown };

View File

@ -7,8 +7,9 @@ import List, { ListItem } from '@/components/list';
import Button from '@/components/ui/button';
import Form from '@/components/ui/form';
import HStack from '@/components/ui/hstack';
import { Multiselect } from '@/components/ui/multiselect';
import StepSlider from '@/components/ui/step-slider';
import { Multiselect, SelectDropdown } from '@/features/forms';
import { SelectDropdown } from '@/features/forms';
import SettingToggle from '@/features/settings/components/setting-toggle';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useAppSelector } from '@/hooks/use-app-selector';

View File

@ -1774,6 +1774,7 @@
"security.update_password.success": "Password successfully updated.",
"select.no_options": "No options available",
"select.placeholder": "Select",
"select.remove_item": "Remove item",
"select_bookmark_folder_modal.header_title": "Select folder",
"settings.configure_mfa": "Configure MFA",
"settings.edit_profile": "Edit profile",

View File

@ -84,11 +84,6 @@ select {
z-index: 2;
}
.highlightOption {
@apply bg-primary-600;
color: #fff;
}
.displayBlock {
display: block;
}
@ -108,5 +103,10 @@ select {
.option {
@apply hover:bg-primary-600;
&:focus {
@apply bg-primary-600;
color: #fff;
}
}
}