переделка компании и департамента
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background: #fff;
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
color: $gray-900;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: .18s;
|
||||
|
||||
@include input-focus-gold;
|
||||
|
||||
&:disabled { background: $gray-50; color: $gray-400; cursor: not-allowed; }
|
||||
&.open { border-color: $gold-500; box-shadow: 0 0 0 3px rgba(212,160,30,.12); }
|
||||
}
|
||||
|
||||
.value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.placeholder { color: $gray-400; }
|
||||
|
||||
.chevron { color: $gray-400; flex-shrink: 0; }
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
border-radius: $r-md;
|
||||
box-shadow: $sh-modal;
|
||||
border: 1px solid $gray-200;
|
||||
padding: 6px;
|
||||
z-index: 400;
|
||||
max-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
outline: 0;
|
||||
margin-bottom: 6px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::placeholder { color: $gray-400; }
|
||||
}
|
||||
|
||||
.list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 9px 12px;
|
||||
border-radius: $r-sm;
|
||||
cursor: pointer;
|
||||
font-size: 13.5px;
|
||||
color: $gray-700;
|
||||
transition: .1s;
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-family: $font-sans;
|
||||
|
||||
&:hover, &.active { background: $gold-50; color: $navy-900; }
|
||||
&.selected { font-weight: 600; color: $navy-900; }
|
||||
}
|
||||
|
||||
.itemSub {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: $gray-400;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
color: $gray-400;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.clear {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid $gray-100;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import * as React from 'react';
|
||||
import styles from './SearchableSelect.module.scss';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export interface ISearchableOption {
|
||||
value: string;
|
||||
label: string;
|
||||
/** Второстепенная строка под названием — например, компания у отдела. */
|
||||
sublabel?: string;
|
||||
}
|
||||
|
||||
export interface ISearchableSelectProps {
|
||||
options: ISearchableOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
/** Показывать пункт «очистить» — для необязательных полей. */
|
||||
allowClear?: boolean;
|
||||
clearLabel?: string;
|
||||
}
|
||||
|
||||
export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Выберите значение',
|
||||
searchPlaceholder = 'Поиск...',
|
||||
disabled,
|
||||
loading,
|
||||
allowClear,
|
||||
clearLabel = 'Не выбрано'
|
||||
}) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const selected = options.filter(o => o.value === value)[0];
|
||||
|
||||
// Поиск на клиенте: справочник целиком уже загружен, отделов ~700 —
|
||||
// фильтровать в памяти дешевле, чем ходить на сервер на каждый символ.
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return options;
|
||||
return options.filter(o =>
|
||||
o.label.toLowerCase().indexOf(q) !== -1 ||
|
||||
(o.sublabel ? o.sublabel.toLowerCase().indexOf(q) !== -1 : false)
|
||||
);
|
||||
}, [options, query]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const onDocumentMouseDown = (e: MouseEvent): void => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', onDocumentMouseDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocumentMouseDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && searchRef.current) searchRef.current.focus();
|
||||
}, [open]);
|
||||
|
||||
const toggle = (): void => {
|
||||
if (disabled) return;
|
||||
setQuery('');
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
const pick = (next: string): void => {
|
||||
onChange(next);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.trigger} ${open ? styles.open : ''}`}
|
||||
onClick={toggle}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<span className={`${styles.value} ${selected ? '' : styles.placeholder}`}>
|
||||
{loading ? 'Загрузка справочника...' : (selected ? selected.label : placeholder)}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className={styles.menu}>
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={styles.search}
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className={styles.list}>
|
||||
{filtered.length === 0 && <div className={styles.empty}>Ничего не найдено</div>}
|
||||
{filtered.map(opt => (
|
||||
<button
|
||||
type="button"
|
||||
key={opt.value}
|
||||
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
|
||||
onClick={() => pick(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.sublabel && <span className={styles.itemSub}>{opt.sublabel}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{allowClear && value && (
|
||||
<div className={styles.clear}>
|
||||
<button type="button" className={styles.item} onClick={() => pick('')}>
|
||||
{clearLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,3 +7,4 @@ export * from './Card';
|
||||
export * from './DataTable';
|
||||
export * from './Modal';
|
||||
export * from './FormField';
|
||||
export * from './SearchableSelect';
|
||||
|
||||
Reference in New Issue
Block a user