mirror of
https://github.com/IRBorisov/ConceptPortal.git
synced 2025-06-25 20:40:36 +03:00
F: Replace clickOutside with onBlur trigger for dropdowns
This commit is contained in:
parent
ac4fd37b65
commit
25663322e3
|
@ -2,18 +2,21 @@
|
|||
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { useClickedOutside } from '@/hooks/use-clicked-outside';
|
||||
|
||||
export function useDropdown() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useClickedOutside(isOpen, ref, () => setIsOpen(false));
|
||||
function handleBlur(event: React.FocusEvent<HTMLDivElement>) {
|
||||
if (!ref.current?.contains(event.relatedTarget as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ref,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
handleBlur,
|
||||
toggle: () => setIsOpen(!isOpen),
|
||||
hide: () => setIsOpen(false)
|
||||
};
|
||||
|
|
|
@ -30,6 +30,7 @@ export function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownPro
|
|||
return (
|
||||
<div
|
||||
ref={menu.ref}
|
||||
onBlur={menu.handleBlur}
|
||||
className={clsx(
|
||||
'absolute left-0 w-54', //
|
||||
noNavigation ? 'top-0' : 'top-12',
|
||||
|
|
|
@ -101,7 +101,7 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className='relative'>
|
||||
<div className='relative' ref={ownerSelector.ref} onBlur={ownerSelector.handleBlur}>
|
||||
{ownerSelector.isOpen ? (
|
||||
<div className='absolute -top-2 right-0'>
|
||||
<SelectUser className='w-100 text-sm' value={schema.owner} onChange={onSelectUser} />
|
||||
|
|
|
@ -44,7 +44,7 @@ export function MenuRole({ isOwned, isEditor }: MenuRoleProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={accessMenu.ref} className='relative'>
|
||||
<div ref={accessMenu.ref} onBlur={accessMenu.handleBlur} className='relative'>
|
||||
<Button
|
||||
dense
|
||||
noBorder
|
||||
|
|
|
@ -28,7 +28,12 @@ export function MiniSelectorOSS({ items, onSelect, className, ...restProps }: Mi
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={ossMenu.ref} className={clsx('relative flex items-center', className)} {...restProps}>
|
||||
<div
|
||||
ref={ossMenu.ref}
|
||||
onBlur={ossMenu.handleBlur}
|
||||
className={clsx('relative flex items-center', className)}
|
||||
{...restProps}
|
||||
>
|
||||
<MiniButton
|
||||
title='Операционные схемы'
|
||||
icon={<IconOSS size='1.25rem' className='icon-primary' />}
|
||||
|
|
|
@ -35,7 +35,12 @@ export function SelectLocationContext({
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={menu.ref} className={clsx('relative text-right self-start', className)} {...restProps}>
|
||||
<div
|
||||
ref={menu.ref} //
|
||||
onBlur={menu.handleBlur}
|
||||
className={clsx('relative text-right self-start', className)}
|
||||
{...restProps}
|
||||
>
|
||||
<MiniButton
|
||||
title={title}
|
||||
hideTitle={menu.isOpen}
|
||||
|
|
|
@ -112,7 +112,7 @@ export function PickSchema({
|
|||
query={filterText}
|
||||
onChangeQuery={newValue => setFilterText(newValue)}
|
||||
/>
|
||||
<div className='relative' ref={locationMenu.ref}>
|
||||
<div className='relative' ref={locationMenu.ref} onBlur={locationMenu.handleBlur}>
|
||||
<MiniButton
|
||||
title='Фильтр по расположению'
|
||||
icon={<IconFolderTree size='1.25rem' className={!!filterLocation ? 'icon-green' : 'icon-primary'} />}
|
||||
|
|
|
@ -38,7 +38,7 @@ export function SelectAccessPolicy({
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={menu.ref} className={clsx('relative', className)} {...restProps}>
|
||||
<div ref={menu.ref} onBlur={menu.handleBlur} className={clsx('relative', className)} {...restProps}>
|
||||
<MiniButton
|
||||
title={`Доступ: ${labelAccessPolicy(value)}`}
|
||||
hideTitle={menu.isOpen}
|
||||
|
|
|
@ -37,7 +37,7 @@ export function SelectItemType({
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={menu.ref} className={clsx('relative', className)} {...restProps}>
|
||||
<div ref={menu.ref} onBlur={menu.handleBlur} className={clsx('relative', className)} {...restProps}>
|
||||
<SelectorButton
|
||||
transparent
|
||||
title={describeLibraryItemType(value)}
|
||||
|
|
|
@ -33,7 +33,12 @@ export function SelectLocationHead({
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={menu.ref} className={clsx('text-right relative', className)} {...restProps}>
|
||||
<div
|
||||
ref={menu.ref} //
|
||||
onBlur={menu.handleBlur}
|
||||
className={clsx('text-right relative', className)}
|
||||
{...restProps}
|
||||
>
|
||||
<SelectorButton
|
||||
transparent
|
||||
tabIndex={-1}
|
||||
|
|
|
@ -32,8 +32,8 @@ interface ToolbarSearchProps {
|
|||
}
|
||||
|
||||
export function ToolbarSearch({ className, total, filtered }: ToolbarSearchProps) {
|
||||
const headMenu = useDropdown();
|
||||
const userMenu = useDropdown();
|
||||
const headMenu = useDropdown();
|
||||
|
||||
const query = useLibrarySearchStore(state => state.query);
|
||||
const setQuery = useLibrarySearchStore(state => state.setQuery);
|
||||
|
@ -88,7 +88,7 @@ export function ToolbarSearch({ className, total, filtered }: ToolbarSearchProps
|
|||
onClick={toggleVisible}
|
||||
/>
|
||||
|
||||
<div ref={userMenu.ref} className='relative flex'>
|
||||
<div ref={userMenu.ref} onBlur={userMenu.handleBlur} className='relative flex'>
|
||||
<MiniButton
|
||||
title='Поиск пользователя'
|
||||
hideTitle={userMenu.isOpen}
|
||||
|
@ -137,7 +137,11 @@ export function ToolbarSearch({ className, total, filtered }: ToolbarSearchProps
|
|||
onChangeQuery={setQuery}
|
||||
/>
|
||||
{!folderMode ? (
|
||||
<div ref={headMenu.ref} className='relative flex items-center h-full select-none'>
|
||||
<div
|
||||
ref={headMenu.ref}
|
||||
onBlur={headMenu.handleBlur}
|
||||
className='relative flex items-center h-full select-none'
|
||||
>
|
||||
<SelectorButton
|
||||
transparent
|
||||
className='rounded-lg py-1'
|
||||
|
|
|
@ -18,7 +18,6 @@ import {
|
|||
IconNewRSForm,
|
||||
IconRSForm
|
||||
} from '@/components/icons';
|
||||
import { useClickedOutside } from '@/hooks/use-clicked-outside';
|
||||
import { useDialogsStore } from '@/stores/dialogs';
|
||||
import { errorMsg } from '@/utils/labels';
|
||||
import { prepareTooltip } from '@/utils/utils';
|
||||
|
@ -82,7 +81,12 @@ export function NodeContextMenu({ isOpen, operation, cursorX, cursorY, onHide }:
|
|||
})();
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickedOutside(isOpen, ref, onHide);
|
||||
|
||||
function handleBlur(event: React.FocusEvent<HTMLDivElement>) {
|
||||
if (!ref.current?.contains(event.relatedTarget as Node)) {
|
||||
onHide();
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenSchema() {
|
||||
if (!operation) {
|
||||
|
@ -167,7 +171,12 @@ export function NodeContextMenu({ isOpen, operation, cursorX, cursorY, onHide }:
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className='relative' style={{ top: `calc(${cursorY}px - 2.5rem)`, left: cursorX }}>
|
||||
<div
|
||||
ref={ref}
|
||||
onBlur={handleBlur}
|
||||
className='relative'
|
||||
style={{ top: `calc(${cursorY}px - 2.5rem)`, left: cursorX }}
|
||||
>
|
||||
<Dropdown
|
||||
isOpen={isOpen}
|
||||
stretchLeft={cursorX >= window.innerWidth - MENU_WIDTH}
|
||||
|
|
|
@ -11,14 +11,14 @@ import { useOssEdit } from './oss-edit-context';
|
|||
|
||||
export function MenuEditOss() {
|
||||
const { isAnonymous } = useAuthSuspense();
|
||||
const editMenu = useDropdown();
|
||||
const menu = useDropdown();
|
||||
const { schema, isMutable } = useOssEdit();
|
||||
const isProcessing = useMutatingOss();
|
||||
|
||||
const showRelocateConstituents = useDialogsStore(state => state.showRelocateConstituents);
|
||||
|
||||
function handleRelocate() {
|
||||
editMenu.hide();
|
||||
menu.hide();
|
||||
showRelocateConstituents({
|
||||
oss: schema,
|
||||
initialTarget: undefined,
|
||||
|
@ -31,19 +31,19 @@ export function MenuEditOss() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={editMenu.ref} className='relative'>
|
||||
<div ref={menu.ref} onBlur={menu.handleBlur} className='relative'>
|
||||
<Button
|
||||
dense
|
||||
noBorder
|
||||
noOutline
|
||||
tabIndex={-1}
|
||||
title='Редактирование'
|
||||
hideTitle={editMenu.isOpen}
|
||||
hideTitle={menu.isOpen}
|
||||
className='h-full px-2'
|
||||
icon={<IconEdit2 size='1.25rem' className={isMutable ? 'icon-green' : 'icon-red'} />}
|
||||
onClick={editMenu.toggle}
|
||||
onClick={menu.toggle}
|
||||
/>
|
||||
<Dropdown isOpen={editMenu.isOpen} margin='mt-3'>
|
||||
<Dropdown isOpen={menu.isOpen} margin='mt-3'>
|
||||
<DropdownButton
|
||||
text='Конституенты'
|
||||
titleHtml='Перенос конституент</br>между схемами'
|
||||
|
|
|
@ -24,15 +24,15 @@ export function MenuMain() {
|
|||
|
||||
const showQR = useDialogsStore(state => state.showQR);
|
||||
|
||||
const schemaMenu = useDropdown();
|
||||
const menu = useDropdown();
|
||||
|
||||
function handleDelete() {
|
||||
schemaMenu.hide();
|
||||
menu.hide();
|
||||
deleteSchema();
|
||||
}
|
||||
|
||||
function handleShare() {
|
||||
schemaMenu.hide();
|
||||
menu.hide();
|
||||
sharePage();
|
||||
}
|
||||
|
||||
|
@ -41,24 +41,24 @@ export function MenuMain() {
|
|||
}
|
||||
|
||||
function handleShowQR() {
|
||||
schemaMenu.hide();
|
||||
menu.hide();
|
||||
showQR({ target: generatePageQR() });
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={schemaMenu.ref} className='relative'>
|
||||
<div ref={menu.ref} onBlur={menu.handleBlur} className='relative'>
|
||||
<Button
|
||||
dense
|
||||
noBorder
|
||||
noOutline
|
||||
tabIndex={-1}
|
||||
title='Меню'
|
||||
hideTitle={schemaMenu.isOpen}
|
||||
hideTitle={menu.isOpen}
|
||||
icon={<IconMenu size='1.25rem' className='clr-text-controls' />}
|
||||
className='h-full pl-2'
|
||||
onClick={schemaMenu.toggle}
|
||||
onClick={menu.toggle}
|
||||
/>
|
||||
<Dropdown isOpen={schemaMenu.isOpen} margin='mt-3'>
|
||||
<Dropdown isOpen={menu.isOpen} margin='mt-3'>
|
||||
<DropdownButton
|
||||
text='Поделиться'
|
||||
title='Скопировать ссылку в буфер обмена'
|
||||
|
|
|
@ -30,7 +30,7 @@ interface ToolbarRSListProps {
|
|||
|
||||
export function ToolbarRSList({ className }: ToolbarRSListProps) {
|
||||
const isProcessing = useMutatingRSForm();
|
||||
const insertMenu = useDropdown();
|
||||
const menu = useDropdown();
|
||||
const {
|
||||
schema,
|
||||
selected,
|
||||
|
@ -74,15 +74,15 @@ export function ToolbarRSList({ className }: ToolbarRSListProps) {
|
|||
onClick={moveDown}
|
||||
disabled={isProcessing || selected.length === 0 || selected.length === schema.items.length}
|
||||
/>
|
||||
<div ref={insertMenu.ref} className='relative'>
|
||||
<div ref={menu.ref} onBlur={menu.handleBlur} className='relative'>
|
||||
<MiniButton
|
||||
title='Добавить пустую конституенту'
|
||||
hideTitle={insertMenu.isOpen}
|
||||
hideTitle={menu.isOpen}
|
||||
icon={<IconOpenList size='1.25rem' className='icon-green' />}
|
||||
onClick={insertMenu.toggle}
|
||||
onClick={menu.toggle}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<Dropdown isOpen={insertMenu.isOpen} className='-translate-x-1/2'>
|
||||
<Dropdown isOpen={menu.isOpen} className='-translate-x-1/2'>
|
||||
{Object.values(CstType).map(typeStr => (
|
||||
<DropdownButton
|
||||
key={`${prefixes.csttype_list}${typeStr}`}
|
||||
|
|
|
@ -31,7 +31,7 @@ export function MenuEditSchema() {
|
|||
const { isAnonymous } = useAuthSuspense();
|
||||
const { isModified } = useModificationStore();
|
||||
const router = useConceptNavigation();
|
||||
const editMenu = useDropdown();
|
||||
const menu = useDropdown();
|
||||
const { schema, activeCst, setSelected, isArchive, isContentEditable, promptTemplate, deselectAll } = useRSEdit();
|
||||
const isProcessing = useMutatingRSForm();
|
||||
|
||||
|
@ -43,17 +43,17 @@ export function MenuEditSchema() {
|
|||
const showSubstituteCst = useDialogsStore(state => state.showSubstituteCst);
|
||||
|
||||
function handleReindex() {
|
||||
editMenu.hide();
|
||||
menu.hide();
|
||||
void resetAliases({ itemID: schema.id });
|
||||
}
|
||||
|
||||
function handleRestoreOrder() {
|
||||
editMenu.hide();
|
||||
menu.hide();
|
||||
void restoreOrder({ itemID: schema.id });
|
||||
}
|
||||
|
||||
function handleSubstituteCst() {
|
||||
editMenu.hide();
|
||||
menu.hide();
|
||||
if (isModified && !promptUnsaved()) {
|
||||
return;
|
||||
}
|
||||
|
@ -64,12 +64,12 @@ export function MenuEditSchema() {
|
|||
}
|
||||
|
||||
function handleTemplates() {
|
||||
editMenu.hide();
|
||||
menu.hide();
|
||||
promptTemplate();
|
||||
}
|
||||
|
||||
function handleProduceStructure(targetCst: IConstituenta | null) {
|
||||
editMenu.hide();
|
||||
menu.hide();
|
||||
if (!targetCst) {
|
||||
return;
|
||||
}
|
||||
|
@ -87,7 +87,7 @@ export function MenuEditSchema() {
|
|||
}
|
||||
|
||||
function handleInlineSynthesis() {
|
||||
editMenu.hide();
|
||||
menu.hide();
|
||||
if (isModified && !promptUnsaved()) {
|
||||
return;
|
||||
}
|
||||
|
@ -109,7 +109,7 @@ export function MenuEditSchema() {
|
|||
noOutline
|
||||
tabIndex={-1}
|
||||
titleHtml='<b>Архив</b>: Редактирование запрещено<br />Перейти к актуальной версии'
|
||||
hideTitle={editMenu.isOpen}
|
||||
hideTitle={menu.isOpen}
|
||||
className='h-full px-2'
|
||||
icon={<IconArchive size='1.25rem' className='icon-primary' />}
|
||||
onClick={event => router.push({ path: urls.schema(schema.id), newTab: event.ctrlKey || event.metaKey })}
|
||||
|
@ -118,19 +118,19 @@ export function MenuEditSchema() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={editMenu.ref} className='relative'>
|
||||
<div ref={menu.ref} onBlur={menu.handleBlur} className='relative'>
|
||||
<Button
|
||||
dense
|
||||
noBorder
|
||||
noOutline
|
||||
tabIndex={-1}
|
||||
title='Редактирование'
|
||||
hideTitle={editMenu.isOpen}
|
||||
hideTitle={menu.isOpen}
|
||||
className='h-full px-2'
|
||||
icon={<IconEdit2 size='1.25rem' className={isContentEditable ? 'icon-green' : 'icon-red'} />}
|
||||
onClick={editMenu.toggle}
|
||||
onClick={menu.toggle}
|
||||
/>
|
||||
<Dropdown isOpen={editMenu.isOpen} margin='mt-3'>
|
||||
<Dropdown isOpen={menu.isOpen} margin='mt-3'>
|
||||
<DropdownButton
|
||||
text='Шаблоны'
|
||||
title='Создать конституенту из шаблона'
|
||||
|
|
|
@ -47,7 +47,7 @@ export function MenuMain() {
|
|||
const showClone = useDialogsStore(state => state.showCloneLibraryItem);
|
||||
const showUpload = useDialogsStore(state => state.showUploadRSForm);
|
||||
|
||||
const schemaMenu = useDropdown();
|
||||
const menu = useDropdown();
|
||||
|
||||
function calculateCloneLocation() {
|
||||
const location = schema.location;
|
||||
|
@ -62,12 +62,12 @@ export function MenuMain() {
|
|||
}
|
||||
|
||||
function handleDelete() {
|
||||
schemaMenu.hide();
|
||||
menu.hide();
|
||||
deleteSchema();
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
schemaMenu.hide();
|
||||
menu.hide();
|
||||
if (isModified && !promptUnsaved()) {
|
||||
return;
|
||||
}
|
||||
|
@ -85,12 +85,12 @@ export function MenuMain() {
|
|||
}
|
||||
|
||||
function handleUpload() {
|
||||
schemaMenu.hide();
|
||||
menu.hide();
|
||||
showUpload({ itemID: schema.id });
|
||||
}
|
||||
|
||||
function handleClone() {
|
||||
schemaMenu.hide();
|
||||
menu.hide();
|
||||
if (isModified && !promptUnsaved()) {
|
||||
return;
|
||||
}
|
||||
|
@ -103,29 +103,29 @@ export function MenuMain() {
|
|||
}
|
||||
|
||||
function handleShare() {
|
||||
schemaMenu.hide();
|
||||
menu.hide();
|
||||
sharePage();
|
||||
}
|
||||
|
||||
function handleShowQR() {
|
||||
schemaMenu.hide();
|
||||
menu.hide();
|
||||
showQR({ target: generatePageQR() });
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={schemaMenu.ref} className='relative'>
|
||||
<div ref={menu.ref} onBlur={menu.handleBlur} className='relative'>
|
||||
<Button
|
||||
dense
|
||||
noBorder
|
||||
noOutline
|
||||
tabIndex={-1}
|
||||
title='Меню'
|
||||
hideTitle={schemaMenu.isOpen}
|
||||
hideTitle={menu.isOpen}
|
||||
icon={<IconMenu size='1.25rem' className='clr-text-controls' />}
|
||||
className='h-full pl-2'
|
||||
onClick={schemaMenu.toggle}
|
||||
onClick={menu.toggle}
|
||||
/>
|
||||
<Dropdown isOpen={schemaMenu.isOpen} margin='mt-3'>
|
||||
<Dropdown isOpen={menu.isOpen} margin='mt-3'>
|
||||
<DropdownButton
|
||||
text='Поделиться'
|
||||
titleHtml={tooltipText.shareItem(schema.access_policy === AccessPolicy.PUBLIC)}
|
||||
|
|
|
@ -28,7 +28,7 @@ export function SelectGraphFilter({ value, dense, className, onChange, ...restPr
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={menu.ref} className={clsx('relative', className)} {...restProps}>
|
||||
<div ref={menu.ref} onBlur={menu.handleBlur} className={clsx('relative', className)} {...restProps}>
|
||||
<SelectorButton
|
||||
transparent
|
||||
tabIndex={-1}
|
||||
|
|
|
@ -28,7 +28,7 @@ export function SelectMatchMode({ value, dense, className, onChange, ...restProp
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={menu.ref} className={clsx('relative', className)} {...restProps}>
|
||||
<div ref={menu.ref} onBlur={menu.handleBlur} className={clsx('relative', className)} {...restProps}>
|
||||
<SelectorButton
|
||||
transparent
|
||||
titleHtml='Настройка фильтрации <br/>по проверяемым атрибутам'
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { assertIsNode } from '@/utils/utils';
|
||||
|
||||
export function useClickedOutside(
|
||||
enabled: boolean,
|
||||
ref: React.RefObject<HTMLDivElement | null>,
|
||||
callback?: () => void
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
assertIsNode(event.target);
|
||||
if (ref?.current && !ref.current.contains(event.target)) {
|
||||
callback?.();
|
||||
}
|
||||
}
|
||||
document.addEventListener('mouseup', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', handleClickOutside);
|
||||
};
|
||||
}, [ref, callback, enabled]);
|
||||
}
|
Loading…
Reference in New Issue
Block a user