feat: searchable unit picker in the work form

Units dictionary has around a hundred entries; typing now filters the
list and Enter picks the first match. Only dictionary values can be
chosen, as before. Generic SearchableSelect lives in shared until the
ui-kit gets one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-19 10:07:07 +05:00
parent 7ca71c6b6c
commit cb810972c4
8 changed files with 258 additions and 8 deletions
@@ -2,6 +2,7 @@ import * as React from 'react';
import { Alert, Button, Dialog, FormField, Select, TextInput } from '@tnsu/ui-kit';
import { ITree } from '../_tree/treeRules';
import { FormInput } from '../_tree/FormInput';
import { SearchableSelect } from '../../shared/components/SearchableSelect';
import { IWorkEditor } from './useWorkEditor';
import { parentOptions, unitOptions } from './worksRules';
import { IWork } from './worksTypes';
@@ -60,12 +61,15 @@ export const WorkModal: React.FC<Props> = ({ editor, tree, units, objectTitle })
/>
</FormField>
<FormField label={TEXTS.unit} htmlFor="work-unit" full>
<Select
<SearchableSelect
id="work-unit"
value={form.unit}
onChange={(value) => setField('unit', value)}
options={unitOptions(units, form.unit)}
placeholder={TEXTS.unitPlaceholder}
searchPlaceholder={TEXTS.unitSearch}
notFoundText={TEXTS.unitNotFound}
clearLabel={TEXTS.cancel}
/>
</FormField>
<FormField label={TEXTS.volume} htmlFor="work-volume" full>
@@ -93,7 +97,14 @@ export const WorkModal: React.FC<Props> = ({ editor, tree, units, objectTitle })
<TextInput id="work-start" type="date" value={form.start} onChange={(value) => setField('start', value)} />
</FormField>
<FormField label={TEXTS.finish} htmlFor="work-finish" full>
<TextInput id="work-finish" type="date" value={form.finish} onChange={(value) => setField('finish', value)} />
{/* min — календарь не даёт выбрать окончание раньше начала. */}
<TextInput
id="work-finish"
type="date"
value={form.finish}
min={form.start || undefined}
onChange={(value) => setField('finish', value)}
/>
</FormField>
{editor.saveError && <Alert variant="err">{editor.saveError}</Alert>}
</div>
+4 -1
View File
@@ -45,7 +45,10 @@ export const WORKS_TEXTS = {
add: 'Добавить',
save: 'Сохранить',
saving: 'Сохранение...',
saveError: 'Ошибка при сохранении. Проверьте права доступа к Dataverse.'
saveError: 'Ошибка при сохранении. Проверьте права доступа к Dataverse.',
dateRangeError: 'Дата окончания не может быть раньше даты начала',
unitSearch: 'Начните вводить ед. изм.',
unitNotFound: 'Ничего не найдено'
},
confirmDelete: {
title: 'Подтвердите удаление',
@@ -0,0 +1,96 @@
@import '../../../../styles/design-tokens';
// Длинный справочник прокручивается внутри списка, а не растягивает модалку.
$dropdown-max-height: 260px;
.wrapper {
position: relative;
width: 100%;
}
.field {
display: flex;
align-items: center;
background: $white;
border: 1px solid $gray-300;
border-radius: $r-md;
padding: 0 $sp-3;
transition: border-color 0.15s, box-shadow 0.15s;
}
.fieldOpen {
border-color: $gold-500;
box-shadow: 0 0 0 3px rgba(212, 160, 30, 0.12);
}
.input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
padding: $sp-2 0;
font-family: $font-sans;
font-size: $fs-body;
color: $gray-900;
}
.clearButton {
display: inline-flex;
align-items: center;
padding: $sp-1;
margin-right: $sp-1;
border: 0;
border-radius: $r-full;
background: transparent;
color: $gray-400;
cursor: pointer;
&:hover {
background: $gray-100;
color: $gray-700;
}
}
.chevron {
display: inline-flex;
color: $gray-500;
pointer-events: none;
}
.dropdown {
position: absolute;
top: calc(100% + #{$sp-1});
left: 0;
right: 0;
z-index: 30;
max-height: $dropdown-max-height;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-md;
box-shadow: $sh-3;
padding: $sp-1 0;
}
.option {
padding: $sp-2 $sp-3;
font-size: $fs-body;
color: $gray-900;
cursor: pointer;
&:hover {
background: $navy-50;
}
}
.optionSelected {
font-weight: 600;
background: $gold-50;
}
.message {
padding: $sp-3;
font-size: $fs-sm;
color: $gray-500;
}
@@ -0,0 +1,110 @@
import * as React from 'react';
import { Icon, ISelectOption } from '@tnsu/ui-kit';
import { useOutsideClose } from '../hooks/useOutsideClose';
import { filterOptions } from '../utils/filterOptions';
import styles from './SearchableSelect.module.scss';
interface Props {
id?: string;
value: string;
options: ISelectOption[];
onChange: (value: string) => void;
/** Текст в пустом поле, когда ничего не выбрано. */
placeholder: string;
/** Подсказка в поле, пока пользователь ищет. */
searchPlaceholder: string;
notFoundText: string;
clearLabel: string;
}
/**
* Выпадающий список с поиском: можно прокрутить или начать печатать — список отфильтруется.
* Выбрать можно только значение из списка (свободный текст не сохраняется). В ui-kit такого
* компонента пока нет — живёт здесь, позже переедет в кит.
*/
export const SearchableSelect: React.FC<Props> = (props) => {
const { id, value, options, onChange, placeholder, searchPlaceholder, notFoundText, clearLabel } = props;
const [isOpen, setIsOpen] = React.useState<boolean>(false);
const [query, setQuery] = React.useState<string>('');
const wrapperRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const selectedLabel = options.filter((option) => option.value === value)[0]?.label ?? value;
const filtered = filterOptions(options, query);
const close = (): void => {
setIsOpen(false);
setQuery('');
};
useOutsideClose(wrapperRef, isOpen, close);
const pick = (option: ISelectOption): void => {
onChange(option.value);
close();
inputRef.current?.blur();
};
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
// Enter выбирает первое найденное — быстрее, чем тянуться к мышке.
if (event.key === 'Enter' && isOpen && filtered.length > 0) {
event.preventDefault();
pick(filtered[0]);
}
};
// mousedown, а не click: иначе поле теряет фокус раньше, чем срабатывает выбор.
const onClear = (event: React.MouseEvent): void => {
event.preventDefault();
onChange('');
close();
};
return (
<div className={styles.wrapper} ref={wrapperRef}>
<div className={isOpen ? `${styles.field} ${styles.fieldOpen}` : styles.field}>
<input
id={id}
ref={inputRef}
type="text"
className={styles.input}
value={isOpen ? query : selectedLabel}
placeholder={isOpen ? searchPlaceholder : placeholder}
autoComplete="off"
onFocus={() => setIsOpen(true)}
onChange={(event) => {
setQuery(event.target.value);
setIsOpen(true);
}}
onKeyDown={onKeyDown}
/>
{value && !isOpen && (
<button type="button" className={styles.clearButton} title={clearLabel} aria-label={clearLabel} onMouseDown={onClear}>
<Icon name="close" size="sm" />
</button>
)}
<span className={styles.chevron} aria-hidden="true">
<Icon name="chevron-down" size="sm" />
</span>
</div>
{isOpen && (
<div className={styles.dropdown} role="listbox">
{filtered.length === 0 && <div className={styles.message}>{notFoundText}</div>}
{filtered.map((option) => (
<div
key={option.value}
role="option"
aria-selected={option.value === value}
className={option.value === value ? `${styles.option} ${styles.optionSelected}` : styles.option}
onMouseDown={(event) => {
event.preventDefault();
pick(option);
}}
>
{option.label}
</div>
))}
</div>
)}
</div>
);
};
@@ -0,0 +1,18 @@
import { filterOptions } from './filterOptions';
const units = ['м', 'м2', 'м3', 'кг', 'Комплект'].map((label) => ({ value: label, label }));
describe('filterOptions', () => {
it('пустой запрос — весь список', () => {
expect(filterOptions(units, ' ')).toHaveLength(5);
});
it('подстрока без учёта регистра', () => {
expect(filterOptions(units, 'м3').map((o) => o.value)).toEqual(['м3']);
expect(filterOptions(units, 'КОМ').map((o) => o.value)).toEqual(['Комплект']);
});
it('ничего не найдено — пустой список', () => {
expect(filterOptions(units, 'литр')).toEqual([]);
});
});
@@ -0,0 +1,8 @@
import { ISelectOption } from '@tnsu/ui-kit';
/** Поиск по подписи: подстрока без учёта регистра. Пустой запрос — весь список. */
export function filterOptions(options: ISelectOption[], query: string): ISelectOption[] {
const normalized = query.trim().toLowerCase();
if (!normalized) return options;
return options.filter((option) => option.label.toLowerCase().indexOf(normalized) !== -1);
}