правочки
This commit is contained in:
@@ -7,6 +7,7 @@ import { routeStepStatusLabels, texts } from '../../texts';
|
|||||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||||
import { useResolveRoute } from '../../hooks/useResolveRoute';
|
import { useResolveRoute } from '../../hooks/useResolveRoute';
|
||||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||||
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -34,13 +35,31 @@ export interface IResolveScreenProps {
|
|||||||
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
||||||
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
|
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
|
||||||
|
|
||||||
|
// Форма проверки маршрута заполняется одними и теми же параметрами раз за разом
|
||||||
|
// (тип заявки, инициатор, проект, статус) — персистим её в localStorage, чтобы
|
||||||
|
// не вводить всё заново при каждом открытии экрана.
|
||||||
|
const RESOLVE_FORM_STORAGE_KEY = 'approvalAdmin.resolveForm';
|
||||||
|
|
||||||
|
interface IResolveFormState {
|
||||||
|
requestTypeId: string;
|
||||||
|
userEmail: string;
|
||||||
|
projectId: string;
|
||||||
|
projectLabel: string;
|
||||||
|
status: RouteStepStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_RESOLVE_FORM: IResolveFormState = {
|
||||||
|
requestTypeId: '',
|
||||||
|
userEmail: '',
|
||||||
|
projectId: '',
|
||||||
|
projectLabel: '',
|
||||||
|
status: RouteStepStatus.Approval
|
||||||
|
};
|
||||||
|
|
||||||
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, currentUserEmail }) => {
|
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, currentUserEmail }) => {
|
||||||
const requestTypes = useRequestTypes(api);
|
const requestTypes = useRequestTypes(api);
|
||||||
const [requestTypeId, setRequestTypeId] = React.useState('');
|
const [form, setForm] = useLocalStorageState<IResolveFormState>(RESOLVE_FORM_STORAGE_KEY, EMPTY_RESOLVE_FORM);
|
||||||
const [userEmail, setUserEmail] = React.useState('');
|
const { requestTypeId, userEmail, projectId, projectLabel, status } = form;
|
||||||
const [projectId, setProjectId] = React.useState('');
|
|
||||||
const [projectLabel, setProjectLabel] = React.useState('');
|
|
||||||
const [status, setStatus] = React.useState<RouteStepStatus>(RouteStepStatus.Approval);
|
|
||||||
|
|
||||||
const resolved = useResolveRoute(api);
|
const resolved = useResolveRoute(api);
|
||||||
const employees = usePortalEmployees(dictionary);
|
const employees = usePortalEmployees(dictionary);
|
||||||
@@ -83,7 +102,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
|||||||
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||||
placeholder={texts.resolve.requestTypePlaceholder}
|
placeholder={texts.resolve.requestTypePlaceholder}
|
||||||
value={requestTypeId}
|
value={requestTypeId}
|
||||||
onChange={e => setRequestTypeId(e.target.value)}
|
onChange={e => setForm({ ...form, requestTypeId: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field
|
<Field
|
||||||
@@ -99,7 +118,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
|||||||
placeholder={texts.resolve.userEmailPlaceholder}
|
placeholder={texts.resolve.userEmailPlaceholder}
|
||||||
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
searchPlaceholder={texts.select.employeeSearchPlaceholder}
|
||||||
emptyMessage={texts.select.employeesEmpty}
|
emptyMessage={texts.select.employeesEmpty}
|
||||||
onChange={setUserEmail}
|
onChange={userEmail => setForm({ ...form, userEmail })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={texts.resolve.projectLabel} hint={texts.resolve.projectHint}>
|
<Field label={texts.resolve.projectLabel} hint={texts.resolve.projectHint}>
|
||||||
@@ -115,14 +134,15 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
|||||||
getValue={p => p.oid}
|
getValue={p => p.oid}
|
||||||
getLabel={p => p.name}
|
getLabel={p => p.name}
|
||||||
getSublabel={p => [p.code, p.status].filter(Boolean).join(' · ') || undefined}
|
getSublabel={p => [p.code, p.status].filter(Boolean).join(' · ') || undefined}
|
||||||
onChange={(nextId, item) => {
|
onChange={(nextId, item) => setForm({ ...form, projectId: nextId, projectLabel: item ? item.name : '' })}
|
||||||
setProjectId(nextId);
|
|
||||||
setProjectLabel(item ? item.name : '');
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={texts.resolve.statusLabel} required>
|
<Field label={texts.resolve.statusLabel} required>
|
||||||
<Select options={STATUS_OPTIONS} value={status} onChange={e => setStatus(e.target.value as RouteStepStatus)} />
|
<Select
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
value={status}
|
||||||
|
onChange={e => setForm({ ...form, status: e.target.value as RouteStepStatus })}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
<div className={styles.runButtonRow}>
|
<div className={styles.runButtonRow}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './SearchableSelect.module.scss';
|
import styles from './SearchableSelect.module.scss';
|
||||||
|
import { DropdownPortal } from './DropdownPortal';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ export function AsyncSearchableSelect<T>({
|
|||||||
const [error, setError] = React.useState<string | undefined>();
|
const [error, setError] = React.useState<string | undefined>();
|
||||||
|
|
||||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||||
const listRef = React.useRef<HTMLDivElement>(null);
|
const listRef = React.useRef<HTMLDivElement>(null);
|
||||||
const sentinelRef = React.useRef<HTMLDivElement>(null);
|
const sentinelRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -103,7 +105,10 @@ export function AsyncSearchableSelect<T>({
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
const onDocumentMouseDown = (e: MouseEvent): void => {
|
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 => {
|
const onKeyDown = (e: KeyboardEvent): void => {
|
||||||
if (e.key === 'Escape') setOpen(false);
|
if (e.key === 'Escape') setOpen(false);
|
||||||
@@ -167,51 +172,49 @@ export function AsyncSearchableSelect<T>({
|
|||||||
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
<DropdownPortal anchorRef={rootRef} open={open} className={styles.menu} menuRef={menuRef}>
|
||||||
<div className={styles.menu}>
|
<input
|
||||||
<input
|
ref={searchRef}
|
||||||
ref={searchRef}
|
className={styles.search}
|
||||||
className={styles.search}
|
placeholder={searchPlaceholder}
|
||||||
placeholder={searchPlaceholder}
|
value={query}
|
||||||
value={query}
|
onChange={e => setQuery(e.target.value)}
|
||||||
onChange={e => setQuery(e.target.value)}
|
/>
|
||||||
/>
|
<div className={styles.list} ref={listRef}>
|
||||||
<div className={styles.list} ref={listRef}>
|
{loading && <div className={styles.empty}>{texts.select.searching}</div>}
|
||||||
{loading && <div className={styles.empty}>{texts.select.searching}</div>}
|
{!loading && error && <div className={styles.errorText}>{error}</div>}
|
||||||
{!loading && error && <div className={styles.errorText}>{error}</div>}
|
{!loading && !error && items.length === 0 && (
|
||||||
{!loading && !error && items.length === 0 && (
|
<div className={styles.empty}>{texts.select.nothingFound}</div>
|
||||||
<div className={styles.empty}>{texts.select.nothingFound}</div>
|
)}
|
||||||
)}
|
{!loading && !error && items.map(item => {
|
||||||
{!loading && !error && items.map(item => {
|
const itemValue = getValue(item);
|
||||||
const itemValue = getValue(item);
|
const sub = getSublabel ? getSublabel(item) : undefined;
|
||||||
const sub = getSublabel ? getSublabel(item) : undefined;
|
return (
|
||||||
return (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
key={itemValue}
|
||||||
key={itemValue}
|
className={`${styles.item} ${itemValue === value ? styles.selected : ''}`}
|
||||||
className={`${styles.item} ${itemValue === value ? styles.selected : ''}`}
|
onClick={() => pick(item)}
|
||||||
onClick={() => pick(item)}
|
>
|
||||||
>
|
{getLabel(item)}
|
||||||
{getLabel(item)}
|
{sub && <span className={styles.itemSub}>{sub}</span>}
|
||||||
{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>
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!loading && !error && hasMore && (
|
||||||
|
<div ref={sentinelRef} className={styles.sentinel}>
|
||||||
|
{loadingMore && 'Загрузка...'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{allowClear && value && (
|
||||||
|
<div className={styles.clear}>
|
||||||
|
<button type="button" className={styles.item} onClick={() => pick(undefined)}>
|
||||||
|
{clearLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DropdownPortal>
|
||||||
</div>
|
</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
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -40,10 +40,9 @@
|
|||||||
.chevron { color: $gray-400; flex-shrink: 0; }
|
.chevron { color: $gray-400; flex-shrink: 0; }
|
||||||
|
|
||||||
.menu {
|
.menu {
|
||||||
position: absolute;
|
// position/top/left/width задаются инлайн из DropdownPortal (position: fixed
|
||||||
top: calc(100% + 6px);
|
// по координатам триггера) — портал рендерится в document.body, чтобы список
|
||||||
left: 0;
|
// не обрезался overflow-y: auto модалки (Modal.module.scss .box).
|
||||||
right: 0;
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: $r-md;
|
border-radius: $r-md;
|
||||||
box-shadow: $sh-modal;
|
box-shadow: $sh-modal;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './SearchableSelect.module.scss';
|
import styles from './SearchableSelect.module.scss';
|
||||||
|
import { DropdownPortal } from './DropdownPortal';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
|||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [query, setQuery] = React.useState('');
|
const [query, setQuery] = React.useState('');
|
||||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Сохранённое значение может отсутствовать в справочнике (запись удалили в 1С
|
// Сохранённое значение может отсутствовать в справочнике (запись удалили в 1С
|
||||||
@@ -68,9 +70,10 @@ export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
const onDocumentMouseDown = (e: MouseEvent): void => {
|
const onDocumentMouseDown = (e: MouseEvent): void => {
|
||||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
const target = e.target as Node;
|
||||||
setOpen(false);
|
if (rootRef.current?.contains(target)) return;
|
||||||
}
|
if (menuRef.current?.contains(target)) return;
|
||||||
|
setOpen(false);
|
||||||
};
|
};
|
||||||
const onKeyDown = (e: KeyboardEvent): void => {
|
const onKeyDown = (e: KeyboardEvent): void => {
|
||||||
if (e.key === 'Escape') setOpen(false);
|
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} />
|
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
<DropdownPortal anchorRef={rootRef} open={open} className={styles.menu} menuRef={menuRef}>
|
||||||
<div className={styles.menu}>
|
<input
|
||||||
<input
|
ref={searchRef}
|
||||||
ref={searchRef}
|
className={styles.search}
|
||||||
className={styles.search}
|
placeholder={searchPlaceholder}
|
||||||
placeholder={searchPlaceholder}
|
value={query}
|
||||||
value={query}
|
onChange={e => setQuery(e.target.value)}
|
||||||
onChange={e => setQuery(e.target.value)}
|
/>
|
||||||
/>
|
<div className={styles.list}>
|
||||||
<div className={styles.list}>
|
{filtered.length === 0 && (
|
||||||
{filtered.length === 0 && (
|
<div className={styles.empty}>
|
||||||
<div className={styles.empty}>
|
{effectiveOptions.length === 0 ? emptyMessage : texts.select.nothingFound}
|
||||||
{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>
|
</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>
|
</div>
|
||||||
)}
|
{allowClear && value && (
|
||||||
|
<div className={styles.clear}>
|
||||||
|
<button type="button" className={styles.item} onClick={() => pick('')}>
|
||||||
|
{clearLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DropdownPortal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Как React.useState, но значение переживает перезагрузку страницы — хранится
|
||||||
|
* в localStorage под key. Падение доступа к localStorage (приватный режим,
|
||||||
|
* квота) не ломает форму — просто не персистит.
|
||||||
|
*/
|
||||||
|
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||||
|
const [value, setValue] = React.useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key);
|
||||||
|
return raw ? (JSON.parse(raw) as T) : initial;
|
||||||
|
} catch {
|
||||||
|
return initial;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// приватный режим / переполненная квота — не критично, просто не персистим
|
||||||
|
}
|
||||||
|
}, [key, value]);
|
||||||
|
|
||||||
|
return [value, setValue];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user