Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/6
This commit was merged in pull request #6.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import styles from './SearchableSelect.module.scss';
|
||||
import { DropdownPortal } from './DropdownPortal';
|
||||
import { Icon } from './Icon';
|
||||
import { texts } from '../../texts';
|
||||
|
||||
@@ -12,6 +13,8 @@ export interface IAsyncSearchableSelectProps<T> {
|
||||
value: string;
|
||||
/** Название выбранного значения — показываем в триггере без похода на сервер. */
|
||||
selectedLabel?: string;
|
||||
/** Вторая строка триггера мельче и серым — например, url под названием сайта. */
|
||||
selectedSublabel?: string;
|
||||
onChange: (value: string, item: T | undefined) => void;
|
||||
search: (query: string, page: number) => Promise<IAsyncSearchResult<T>>;
|
||||
getValue: (item: T) => string;
|
||||
@@ -29,6 +32,7 @@ export interface IAsyncSearchableSelectProps<T> {
|
||||
export function AsyncSearchableSelect<T>({
|
||||
value,
|
||||
selectedLabel,
|
||||
selectedSublabel,
|
||||
onChange,
|
||||
search,
|
||||
getValue,
|
||||
@@ -51,6 +55,7 @@ export function AsyncSearchableSelect<T>({
|
||||
const [error, setError] = React.useState<string | undefined>();
|
||||
|
||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
const listRef = React.useRef<HTMLDivElement>(null);
|
||||
const sentinelRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -103,7 +108,10 @@ export function AsyncSearchableSelect<T>({
|
||||
if (!open) return;
|
||||
|
||||
const onDocumentMouseDown = (e: MouseEvent): void => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||
const target = e.target as Node;
|
||||
if (rootRef.current?.contains(target)) return;
|
||||
if (menuRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
@@ -161,57 +169,62 @@ export function AsyncSearchableSelect<T>({
|
||||
onClick={toggle}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={`${styles.value} ${value ? '' : styles.placeholder}`}>
|
||||
{value ? (selectedLabel ?? value) : placeholder}
|
||||
</span>
|
||||
{value && selectedSublabel ? (
|
||||
<span className={styles.valueStack}>
|
||||
<span className={styles.valueMain}>{selectedLabel ?? value}</span>
|
||||
<span className={styles.valueSub}>{selectedSublabel}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={`${styles.value} ${value ? '' : styles.placeholder}`}>
|
||||
{value ? (selectedLabel ?? value) : 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} ref={listRef}>
|
||||
{loading && <div className={styles.empty}>{texts.select.searching}</div>}
|
||||
{!loading && error && <div className={styles.errorText}>{error}</div>}
|
||||
{!loading && !error && items.length === 0 && (
|
||||
<div className={styles.empty}>{texts.select.nothingFound}</div>
|
||||
)}
|
||||
{!loading && !error && items.map(item => {
|
||||
const itemValue = getValue(item);
|
||||
const sub = getSublabel ? getSublabel(item) : undefined;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={itemValue}
|
||||
className={`${styles.item} ${itemValue === value ? styles.selected : ''}`}
|
||||
onClick={() => pick(item)}
|
||||
>
|
||||
{getLabel(item)}
|
||||
{sub && <span className={styles.itemSub}>{sub}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!loading && !error && hasMore && (
|
||||
<div ref={sentinelRef} className={styles.sentinel}>
|
||||
{loadingMore && 'Загрузка...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{allowClear && value && (
|
||||
<div className={styles.clear}>
|
||||
<button type="button" className={styles.item} onClick={() => pick(undefined)}>
|
||||
{clearLabel}
|
||||
<DropdownPortal anchorRef={rootRef} open={open} className={styles.menu} menuRef={menuRef}>
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={styles.search}
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className={styles.list} ref={listRef}>
|
||||
{loading && <div className={styles.empty}>{texts.select.searching}</div>}
|
||||
{!loading && error && <div className={styles.errorText}>{error}</div>}
|
||||
{!loading && !error && items.length === 0 && (
|
||||
<div className={styles.empty}>{texts.select.nothingFound}</div>
|
||||
)}
|
||||
{!loading && !error && items.map(item => {
|
||||
const itemValue = getValue(item);
|
||||
const sub = getSublabel ? getSublabel(item) : undefined;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={itemValue}
|
||||
className={`${styles.item} ${itemValue === value ? styles.selected : ''}`}
|
||||
onClick={() => pick(item)}
|
||||
>
|
||||
{getLabel(item)}
|
||||
{sub && <span className={styles.itemSub}>{sub}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!loading && !error && hasMore && (
|
||||
<div ref={sentinelRef} className={styles.sentinel}>
|
||||
{loadingMore && 'Загрузка...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{allowClear && value && (
|
||||
<div className={styles.clear}>
|
||||
<button type="button" className={styles.item} onClick={() => pick(undefined)}>
|
||||
{clearLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</DropdownPortal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
|
||||
export interface IDropdownPortalProps {
|
||||
/** Элемент, под которым (или над которым) рисуется меню. */
|
||||
anchorRef: React.RefObject<HTMLElement>;
|
||||
open: boolean;
|
||||
className?: string;
|
||||
/** Прокидывается наружу — нужен обработчику клика вне меню (портал живёт вне DOM-дерева root). */
|
||||
menuRef?: React.Ref<HTMLDivElement>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface IPosition {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const GAP = 6;
|
||||
const MIN_SPACE_BELOW = 240;
|
||||
|
||||
/**
|
||||
* Рендерит выпадающее меню в document.body с position: fixed по координатам
|
||||
* anchor-элемента, а не как обычный absolute-потомок. Внутри модалок наши формы
|
||||
* скроллятся через overflow-y: auto на .box (Modal.module.scss) — обычный
|
||||
* position: absolute-дочерний элемент обрезается этим скроллом, если список не
|
||||
* помещается. Портал избегает этого, т.к. живёт вне скроллящегося поддерева.
|
||||
*/
|
||||
export const DropdownPortal: React.FC<IDropdownPortalProps> = ({ anchorRef, open, className, menuRef, children }) => {
|
||||
const [position, setPosition] = React.useState<IPosition | undefined>();
|
||||
|
||||
const updatePosition = React.useCallback(() => {
|
||||
const el = anchorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const openUp = spaceBelow < MIN_SPACE_BELOW && rect.top > spaceBelow;
|
||||
|
||||
setPosition(
|
||||
openUp
|
||||
? { bottom: window.innerHeight - rect.top + GAP, left: rect.left, width: rect.width }
|
||||
: { top: rect.bottom + GAP, left: rect.left, width: rect.width }
|
||||
);
|
||||
}, [anchorRef]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) return undefined;
|
||||
updatePosition();
|
||||
|
||||
// capture: true — ловим скролл любого предка (в т.ч. .box модалки), не только window.
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
}, [open, updatePosition]);
|
||||
|
||||
if (!open || !position) return null;
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
left: position.left,
|
||||
width: position.width,
|
||||
top: position.top,
|
||||
bottom: position.bottom
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div ref={menuRef} className={className} style={style}>
|
||||
{children}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -29,21 +29,10 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: $sh-2;
|
||||
box-shadow: $sh-1;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -60px;
|
||||
top: -60px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(212,160,30,.14), transparent 65%);
|
||||
}
|
||||
}
|
||||
|
||||
.phLeft { display: flex; align-items: center; gap: 18px; position: relative; z-index: 1; }
|
||||
@@ -52,9 +41,9 @@
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: $r-md;
|
||||
background: rgba(212,160,30,.14);
|
||||
border: 1px solid rgba(212,160,30,.3);
|
||||
color: $gold-300;
|
||||
background: $gold-100;
|
||||
border: 1px solid $gold-300;
|
||||
color: $gold-600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -63,7 +52,7 @@
|
||||
|
||||
.phSub {
|
||||
font-size: 10.5px;
|
||||
color: rgba(255,255,255,.55);
|
||||
color: $gray-500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.8px;
|
||||
font-weight: 500;
|
||||
@@ -73,7 +62,7 @@
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-page-title;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
color: $navy-900;
|
||||
margin-top: 3px;
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
|
||||
@@ -35,15 +35,39 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Двухстрочный триггер — название сверху, мелкая серая подпись (url) снизу,
|
||||
// как itemSub в списке. Отдельно от .value, у которого одна строка целиком
|
||||
// участвует в ellipsis и не может параллельно нести подпись другим кеглем.
|
||||
.valueStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.valueMain {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.valueSub {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: $gray-400;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.placeholder { color: $gray-400; }
|
||||
|
||||
.chevron { color: $gray-400; flex-shrink: 0; }
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
// position/top/left/width задаются инлайн из DropdownPortal (position: fixed
|
||||
// по координатам триггера) — портал рендерится в document.body, чтобы список
|
||||
// не обрезался overflow-y: auto модалки (Modal.module.scss .box).
|
||||
background: #fff;
|
||||
border-radius: $r-md;
|
||||
box-shadow: $sh-modal;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import styles from './SearchableSelect.module.scss';
|
||||
import { DropdownPortal } from './DropdownPortal';
|
||||
import { Icon } from './Icon';
|
||||
import { texts } from '../../texts';
|
||||
|
||||
@@ -40,6 +41,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Сохранённое значение может отсутствовать в справочнике (запись удалили в 1С
|
||||
@@ -68,9 +70,10 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
||||
if (!open) return;
|
||||
|
||||
const onDocumentMouseDown = (e: MouseEvent): void => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
const target = e.target as Node;
|
||||
if (rootRef.current?.contains(target)) return;
|
||||
if (menuRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
@@ -113,42 +116,40 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
||||
<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}>
|
||||
{effectiveOptions.length === 0 ? emptyMessage : texts.select.nothingFound}
|
||||
</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>
|
||||
<DropdownPortal anchorRef={rootRef} open={open} className={styles.menu} menuRef={menuRef}>
|
||||
<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}>
|
||||
{effectiveOptions.length === 0 ? emptyMessage : texts.select.nothingFound}
|
||||
</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>
|
||||
)}
|
||||
</DropdownPortal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user