fix: нейтральный текст при пустом маршруте в заявке 1С
Tests / frontend-test (push) Successful in 1m19s

This commit is contained in:
Тимур Городецкий
2026-09-25 09:33:38 +05:00
parent 1884007161
commit ab21683377
13 changed files with 61 additions and 22 deletions
@@ -0,0 +1,104 @@
@import '../../../../../styles/design-tokens';
.wrap {
position: relative;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: $sp-1;
margin-bottom: $sp-1;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 5px 6px 5px 10px;
border: 1px solid $gray-200;
border-radius: 999px;
background: $navy-50;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
word-break: break-word;
}
.chipRemove {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: $gray-500;
font-size: 15px;
line-height: 1;
cursor: pointer;
transition: .15s;
&:hover {
background: $gray-200;
color: $gray-900;
}
}
.field {
position: relative;
}
.input {
box-sizing: border-box;
width: 100%;
padding: 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
&::placeholder {
color: $gray-400;
}
}
.list {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin-top: $sp-1;
max-height: 260px;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-sm;
box-shadow: $sh-2;
}
.item {
display: block;
width: 100%;
padding: 9px 12px;
border: 0;
background: transparent;
text-align: left;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
cursor: pointer;
&:hover {
background: $navy-50;
}
}
@@ -0,0 +1,136 @@
import * as React from 'react';
import { useMemo, useState } from 'react';
import styles from './MultiChoiceField.module.scss';
import { useFormReadOnly, ISelectOption } from '@tnsu/ui-kit';
export interface IMultiChoiceFieldProps {
value: string[];
onChange: (values: string[]) => void;
options: ISelectOption[];
/** Разрешить ввод произвольного значения, которого нет в списке. */
allowOther?: boolean;
placeholder?: string;
}
const MAX_SUGGESTIONS = 30;
/**
* Мультивыбор из фиксированного списка в стиле пикера-справочника
* (ср. CompanyPickerMulti): при фокусе выпадают доступные опции, ввод текста их
* фильтрует, выбор пункта СРАЗУ добавляет чип (без кнопки «Добавить»). Уже
* выбранные из списка исключаются. allowOther позволяет добавить чипом
* произвольное значение, которого нет в списке. Хранит массив строк (string[]).
*/
export const MultiChoiceField: React.FC<IMultiChoiceFieldProps> = ({
value,
onChange,
options,
allowOther,
placeholder
}) => {
const readOnly = useFormReadOnly();
const [query, setQuery] = useState<string>('');
const [open, setOpen] = useState<boolean>(false);
const selected = useMemo(() => new Set(value), [value]);
// Ярлык для чипа: известное значение — по label из опций, произвольное — как есть.
const labelFor = (v: string): string => options.filter((o) => o.value === v)[0]?.label ?? v;
const suggestions = useMemo(() => {
const q = query.trim().toLowerCase();
// Пустой запрос — показываем все ещё не выбранные опции (клик-дропдаун);
// с запросом — фильтруем по label (типа-хед).
const available = options.filter(
(o) => !selected.has(o.value) && (!q || o.label.toLowerCase().indexOf(q) !== -1)
);
return available.slice(0, MAX_SUGGESTIONS);
}, [options, query, selected]);
// «Другое»: если разрешено и введён текст, которого нет ни среди опций, ни среди
// уже выбранных — предлагаем добавить его как произвольное значение.
const trimmed = query.trim();
const canAddOther =
!!allowOther &&
trimmed.length > 0 &&
!selected.has(trimmed) &&
!options.some((o) => o.value.toLowerCase() === trimmed.toLowerCase());
const add = (v: string): void => {
if (!v || selected.has(v)) return;
onChange([...value, v]);
setQuery('');
// Список НЕ закрываем: поле сохраняет фокус (mousedown с preventDefault),
// поэтому оставляем открытым — сразу видно оставшиеся варианты и можно
// добавлять подряд, не теряя и не возвращая фокус.
setOpen(true);
};
const remove = (v: string): void => onChange(value.filter((x) => x !== v));
return (
<div className={styles.wrap}>
{value.length > 0 && (
<div className={styles.chips}>
{value.map((v) => (
<span key={v} className={styles.chip}>
{labelFor(v)}
{!readOnly && (
<button
type="button"
className={styles.chipRemove}
aria-label={`Убрать ${labelFor(v)}`}
title="Убрать"
onClick={() => remove(v)}
>
×
</button>
)}
</span>
))}
</div>
)}
{!readOnly && (
<div className={styles.field}>
<input
className={styles.input}
value={query}
placeholder={placeholder}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
{open && (suggestions.length > 0 || canAddOther) && (
<div className={styles.list}>
{suggestions.map((o) => (
<button
key={o.value}
type="button"
className={styles.item}
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick.
onMouseDown={(e) => e.preventDefault()}
onClick={() => add(o.value)}
>
{o.label}
</button>
))}
{canAddOther && (
<button
type="button"
className={styles.item}
onMouseDown={(e) => e.preventDefault()}
onClick={() => add(trimmed)}
>
Добавить «{trimmed}»
</button>
)}
</div>
)}
</div>
)}
</div>
);
};
@@ -36,6 +36,7 @@ export type { ISelectOption, IConfirmOptions } from '@tnsu/ui-kit';
export { FormField } from './FormField/FormField';
export { FieldChangesProvider } from './FieldChanges/FieldChangesContext';
export { FieldChangesBanner } from './FieldChanges/FieldChangesBanner';
export { MultiChoiceField } from './MultiChoiceField/MultiChoiceField';
export { ItemsTable } from './ItemsTable/ItemsTable';
export type { IItemsTableProps } from './ItemsTable/ItemsTable';
export type { ILineItem } from './ItemsTable/ILineItem';