fix: нейтральный текст при пустом маршруте в заявке 1С
Tests / frontend-test (push) Successful in 1m19s
Tests / frontend-test (push) Successful in 1m19s
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user