F: Replace clickOutside with onBlur trigger for dropdowns
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled
Backend CI / notify-failure (push) Has been cancelled
Frontend CI / notify-failure (push) Has been cancelled

This commit is contained in:
Ivan 2025-03-20 19:47:08 +03:00
parent ac4fd37b65
commit 25663322e3
20 changed files with 94 additions and 90 deletions

View File

@ -2,18 +2,21 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { useClickedOutside } from '@/hooks/use-clicked-outside';
export function useDropdown() { export function useDropdown() {
const [isOpen, setIsOpen] = useState(false); 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 { return {
ref, ref,
isOpen, isOpen,
setIsOpen, setIsOpen,
handleBlur,
toggle: () => setIsOpen(!isOpen), toggle: () => setIsOpen(!isOpen),
hide: () => setIsOpen(false) hide: () => setIsOpen(false)
}; };

View File

@ -30,6 +30,7 @@ export function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownPro
return ( return (
<div <div
ref={menu.ref} ref={menu.ref}
onBlur={menu.handleBlur}
className={clsx( className={clsx(
'absolute left-0 w-54', // 'absolute left-0 w-54', //
noNavigation ? 'top-0' : 'top-12', noNavigation ? 'top-0' : 'top-12',

View File

@ -101,7 +101,7 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
/> />
</div> </div>
<div className='relative'> <div className='relative' ref={ownerSelector.ref} onBlur={ownerSelector.handleBlur}>
{ownerSelector.isOpen ? ( {ownerSelector.isOpen ? (
<div className='absolute -top-2 right-0'> <div className='absolute -top-2 right-0'>
<SelectUser className='w-100 text-sm' value={schema.owner} onChange={onSelectUser} /> <SelectUser className='w-100 text-sm' value={schema.owner} onChange={onSelectUser} />

View File

@ -44,7 +44,7 @@ export function MenuRole({ isOwned, isEditor }: MenuRoleProps) {
} }
return ( return (
<div ref={accessMenu.ref} className='relative'> <div ref={accessMenu.ref} onBlur={accessMenu.handleBlur} className='relative'>
<Button <Button
dense dense
noBorder noBorder

View File

@ -28,7 +28,12 @@ export function MiniSelectorOSS({ items, onSelect, className, ...restProps }: Mi
} }
return ( 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 <MiniButton
title='Операционные схемы' title='Операционные схемы'
icon={<IconOSS size='1.25rem' className='icon-primary' />} icon={<IconOSS size='1.25rem' className='icon-primary' />}

View File

@ -35,7 +35,12 @@ export function SelectLocationContext({
} }
return ( 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 <MiniButton
title={title} title={title}
hideTitle={menu.isOpen} hideTitle={menu.isOpen}

View File

@ -112,7 +112,7 @@ export function PickSchema({
query={filterText} query={filterText}
onChangeQuery={newValue => setFilterText(newValue)} onChangeQuery={newValue => setFilterText(newValue)}
/> />
<div className='relative' ref={locationMenu.ref}> <div className='relative' ref={locationMenu.ref} onBlur={locationMenu.handleBlur}>
<MiniButton <MiniButton
title='Фильтр по расположению' title='Фильтр по расположению'
icon={<IconFolderTree size='1.25rem' className={!!filterLocation ? 'icon-green' : 'icon-primary'} />} icon={<IconFolderTree size='1.25rem' className={!!filterLocation ? 'icon-green' : 'icon-primary'} />}

View File

@ -38,7 +38,7 @@ export function SelectAccessPolicy({
} }
return ( return (
<div ref={menu.ref} className={clsx('relative', className)} {...restProps}> <div ref={menu.ref} onBlur={menu.handleBlur} className={clsx('relative', className)} {...restProps}>
<MiniButton <MiniButton
title={`Доступ: ${labelAccessPolicy(value)}`} title={`Доступ: ${labelAccessPolicy(value)}`}
hideTitle={menu.isOpen} hideTitle={menu.isOpen}

View File

@ -37,7 +37,7 @@ export function SelectItemType({
} }
return ( return (
<div ref={menu.ref} className={clsx('relative', className)} {...restProps}> <div ref={menu.ref} onBlur={menu.handleBlur} className={clsx('relative', className)} {...restProps}>
<SelectorButton <SelectorButton
transparent transparent
title={describeLibraryItemType(value)} title={describeLibraryItemType(value)}

View File

@ -33,7 +33,12 @@ export function SelectLocationHead({
} }
return ( 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 <SelectorButton
transparent transparent
tabIndex={-1} tabIndex={-1}

View File

@ -32,8 +32,8 @@ interface ToolbarSearchProps {
} }
export function ToolbarSearch({ className, total, filtered }: ToolbarSearchProps) { export function ToolbarSearch({ className, total, filtered }: ToolbarSearchProps) {
const headMenu = useDropdown();
const userMenu = useDropdown(); const userMenu = useDropdown();
const headMenu = useDropdown();
const query = useLibrarySearchStore(state => state.query); const query = useLibrarySearchStore(state => state.query);
const setQuery = useLibrarySearchStore(state => state.setQuery); const setQuery = useLibrarySearchStore(state => state.setQuery);
@ -88,7 +88,7 @@ export function ToolbarSearch({ className, total, filtered }: ToolbarSearchProps
onClick={toggleVisible} onClick={toggleVisible}
/> />
<div ref={userMenu.ref} className='relative flex'> <div ref={userMenu.ref} onBlur={userMenu.handleBlur} className='relative flex'>
<MiniButton <MiniButton
title='Поиск пользователя' title='Поиск пользователя'
hideTitle={userMenu.isOpen} hideTitle={userMenu.isOpen}
@ -137,7 +137,11 @@ export function ToolbarSearch({ className, total, filtered }: ToolbarSearchProps
onChangeQuery={setQuery} onChangeQuery={setQuery}
/> />
{!folderMode ? ( {!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 <SelectorButton
transparent transparent
className='rounded-lg py-1' className='rounded-lg py-1'

View File

@ -18,7 +18,6 @@ import {
IconNewRSForm, IconNewRSForm,
IconRSForm IconRSForm
} from '@/components/icons'; } from '@/components/icons';
import { useClickedOutside } from '@/hooks/use-clicked-outside';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { errorMsg } from '@/utils/labels'; import { errorMsg } from '@/utils/labels';
import { prepareTooltip } from '@/utils/utils'; import { prepareTooltip } from '@/utils/utils';
@ -82,7 +81,12 @@ export function NodeContextMenu({ isOpen, operation, cursorX, cursorY, onHide }:
})(); })();
const ref = useRef<HTMLDivElement>(null); 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() { function handleOpenSchema() {
if (!operation) { if (!operation) {
@ -167,7 +171,12 @@ export function NodeContextMenu({ isOpen, operation, cursorX, cursorY, onHide }:
} }
return ( 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 <Dropdown
isOpen={isOpen} isOpen={isOpen}
stretchLeft={cursorX >= window.innerWidth - MENU_WIDTH} stretchLeft={cursorX >= window.innerWidth - MENU_WIDTH}

View File

@ -11,14 +11,14 @@ import { useOssEdit } from './oss-edit-context';
export function MenuEditOss() { export function MenuEditOss() {
const { isAnonymous } = useAuthSuspense(); const { isAnonymous } = useAuthSuspense();
const editMenu = useDropdown(); const menu = useDropdown();
const { schema, isMutable } = useOssEdit(); const { schema, isMutable } = useOssEdit();
const isProcessing = useMutatingOss(); const isProcessing = useMutatingOss();
const showRelocateConstituents = useDialogsStore(state => state.showRelocateConstituents); const showRelocateConstituents = useDialogsStore(state => state.showRelocateConstituents);
function handleRelocate() { function handleRelocate() {
editMenu.hide(); menu.hide();
showRelocateConstituents({ showRelocateConstituents({
oss: schema, oss: schema,
initialTarget: undefined, initialTarget: undefined,
@ -31,19 +31,19 @@ export function MenuEditOss() {
} }
return ( return (
<div ref={editMenu.ref} className='relative'> <div ref={menu.ref} onBlur={menu.handleBlur} className='relative'>
<Button <Button
dense dense
noBorder noBorder
noOutline noOutline
tabIndex={-1} tabIndex={-1}
title='Редактирование' title='Редактирование'
hideTitle={editMenu.isOpen} hideTitle={menu.isOpen}
className='h-full px-2' className='h-full px-2'
icon={<IconEdit2 size='1.25rem' className={isMutable ? 'icon-green' : 'icon-red'} />} 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 <DropdownButton
text='Конституенты' text='Конституенты'
titleHtml='Перенос конституент</br>между схемами' titleHtml='Перенос конституент</br>между схемами'

View File

@ -24,15 +24,15 @@ export function MenuMain() {
const showQR = useDialogsStore(state => state.showQR); const showQR = useDialogsStore(state => state.showQR);
const schemaMenu = useDropdown(); const menu = useDropdown();
function handleDelete() { function handleDelete() {
schemaMenu.hide(); menu.hide();
deleteSchema(); deleteSchema();
} }
function handleShare() { function handleShare() {
schemaMenu.hide(); menu.hide();
sharePage(); sharePage();
} }
@ -41,24 +41,24 @@ export function MenuMain() {
} }
function handleShowQR() { function handleShowQR() {
schemaMenu.hide(); menu.hide();
showQR({ target: generatePageQR() }); showQR({ target: generatePageQR() });
} }
return ( return (
<div ref={schemaMenu.ref} className='relative'> <div ref={menu.ref} onBlur={menu.handleBlur} className='relative'>
<Button <Button
dense dense
noBorder noBorder
noOutline noOutline
tabIndex={-1} tabIndex={-1}
title='Меню' title='Меню'
hideTitle={schemaMenu.isOpen} hideTitle={menu.isOpen}
icon={<IconMenu size='1.25rem' className='clr-text-controls' />} icon={<IconMenu size='1.25rem' className='clr-text-controls' />}
className='h-full pl-2' 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 <DropdownButton
text='Поделиться' text='Поделиться'
title='Скопировать ссылку в буфер обмена' title='Скопировать ссылку в буфер обмена'

View File

@ -30,7 +30,7 @@ interface ToolbarRSListProps {
export function ToolbarRSList({ className }: ToolbarRSListProps) { export function ToolbarRSList({ className }: ToolbarRSListProps) {
const isProcessing = useMutatingRSForm(); const isProcessing = useMutatingRSForm();
const insertMenu = useDropdown(); const menu = useDropdown();
const { const {
schema, schema,
selected, selected,
@ -74,15 +74,15 @@ export function ToolbarRSList({ className }: ToolbarRSListProps) {
onClick={moveDown} onClick={moveDown}
disabled={isProcessing || selected.length === 0 || selected.length === schema.items.length} 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 <MiniButton
title='Добавить пустую конституенту' title='Добавить пустую конституенту'
hideTitle={insertMenu.isOpen} hideTitle={menu.isOpen}
icon={<IconOpenList size='1.25rem' className='icon-green' />} icon={<IconOpenList size='1.25rem' className='icon-green' />}
onClick={insertMenu.toggle} onClick={menu.toggle}
disabled={isProcessing} 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 => ( {Object.values(CstType).map(typeStr => (
<DropdownButton <DropdownButton
key={`${prefixes.csttype_list}${typeStr}`} key={`${prefixes.csttype_list}${typeStr}`}

View File

@ -31,7 +31,7 @@ export function MenuEditSchema() {
const { isAnonymous } = useAuthSuspense(); const { isAnonymous } = useAuthSuspense();
const { isModified } = useModificationStore(); const { isModified } = useModificationStore();
const router = useConceptNavigation(); const router = useConceptNavigation();
const editMenu = useDropdown(); const menu = useDropdown();
const { schema, activeCst, setSelected, isArchive, isContentEditable, promptTemplate, deselectAll } = useRSEdit(); const { schema, activeCst, setSelected, isArchive, isContentEditable, promptTemplate, deselectAll } = useRSEdit();
const isProcessing = useMutatingRSForm(); const isProcessing = useMutatingRSForm();
@ -43,17 +43,17 @@ export function MenuEditSchema() {
const showSubstituteCst = useDialogsStore(state => state.showSubstituteCst); const showSubstituteCst = useDialogsStore(state => state.showSubstituteCst);
function handleReindex() { function handleReindex() {
editMenu.hide(); menu.hide();
void resetAliases({ itemID: schema.id }); void resetAliases({ itemID: schema.id });
} }
function handleRestoreOrder() { function handleRestoreOrder() {
editMenu.hide(); menu.hide();
void restoreOrder({ itemID: schema.id }); void restoreOrder({ itemID: schema.id });
} }
function handleSubstituteCst() { function handleSubstituteCst() {
editMenu.hide(); menu.hide();
if (isModified && !promptUnsaved()) { if (isModified && !promptUnsaved()) {
return; return;
} }
@ -64,12 +64,12 @@ export function MenuEditSchema() {
} }
function handleTemplates() { function handleTemplates() {
editMenu.hide(); menu.hide();
promptTemplate(); promptTemplate();
} }
function handleProduceStructure(targetCst: IConstituenta | null) { function handleProduceStructure(targetCst: IConstituenta | null) {
editMenu.hide(); menu.hide();
if (!targetCst) { if (!targetCst) {
return; return;
} }
@ -87,7 +87,7 @@ export function MenuEditSchema() {
} }
function handleInlineSynthesis() { function handleInlineSynthesis() {
editMenu.hide(); menu.hide();
if (isModified && !promptUnsaved()) { if (isModified && !promptUnsaved()) {
return; return;
} }
@ -109,7 +109,7 @@ export function MenuEditSchema() {
noOutline noOutline
tabIndex={-1} tabIndex={-1}
titleHtml='<b>Архив</b>: Редактирование запрещено<br />Перейти к актуальной версии' titleHtml='<b>Архив</b>: Редактирование запрещено<br />Перейти к актуальной версии'
hideTitle={editMenu.isOpen} hideTitle={menu.isOpen}
className='h-full px-2' className='h-full px-2'
icon={<IconArchive size='1.25rem' className='icon-primary' />} icon={<IconArchive size='1.25rem' className='icon-primary' />}
onClick={event => router.push({ path: urls.schema(schema.id), newTab: event.ctrlKey || event.metaKey })} onClick={event => router.push({ path: urls.schema(schema.id), newTab: event.ctrlKey || event.metaKey })}
@ -118,19 +118,19 @@ export function MenuEditSchema() {
} }
return ( return (
<div ref={editMenu.ref} className='relative'> <div ref={menu.ref} onBlur={menu.handleBlur} className='relative'>
<Button <Button
dense dense
noBorder noBorder
noOutline noOutline
tabIndex={-1} tabIndex={-1}
title='Редактирование' title='Редактирование'
hideTitle={editMenu.isOpen} hideTitle={menu.isOpen}
className='h-full px-2' className='h-full px-2'
icon={<IconEdit2 size='1.25rem' className={isContentEditable ? 'icon-green' : 'icon-red'} />} 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 <DropdownButton
text='Шаблоны' text='Шаблоны'
title='Создать конституенту из шаблона' title='Создать конституенту из шаблона'

View File

@ -47,7 +47,7 @@ export function MenuMain() {
const showClone = useDialogsStore(state => state.showCloneLibraryItem); const showClone = useDialogsStore(state => state.showCloneLibraryItem);
const showUpload = useDialogsStore(state => state.showUploadRSForm); const showUpload = useDialogsStore(state => state.showUploadRSForm);
const schemaMenu = useDropdown(); const menu = useDropdown();
function calculateCloneLocation() { function calculateCloneLocation() {
const location = schema.location; const location = schema.location;
@ -62,12 +62,12 @@ export function MenuMain() {
} }
function handleDelete() { function handleDelete() {
schemaMenu.hide(); menu.hide();
deleteSchema(); deleteSchema();
} }
function handleDownload() { function handleDownload() {
schemaMenu.hide(); menu.hide();
if (isModified && !promptUnsaved()) { if (isModified && !promptUnsaved()) {
return; return;
} }
@ -85,12 +85,12 @@ export function MenuMain() {
} }
function handleUpload() { function handleUpload() {
schemaMenu.hide(); menu.hide();
showUpload({ itemID: schema.id }); showUpload({ itemID: schema.id });
} }
function handleClone() { function handleClone() {
schemaMenu.hide(); menu.hide();
if (isModified && !promptUnsaved()) { if (isModified && !promptUnsaved()) {
return; return;
} }
@ -103,29 +103,29 @@ export function MenuMain() {
} }
function handleShare() { function handleShare() {
schemaMenu.hide(); menu.hide();
sharePage(); sharePage();
} }
function handleShowQR() { function handleShowQR() {
schemaMenu.hide(); menu.hide();
showQR({ target: generatePageQR() }); showQR({ target: generatePageQR() });
} }
return ( return (
<div ref={schemaMenu.ref} className='relative'> <div ref={menu.ref} onBlur={menu.handleBlur} className='relative'>
<Button <Button
dense dense
noBorder noBorder
noOutline noOutline
tabIndex={-1} tabIndex={-1}
title='Меню' title='Меню'
hideTitle={schemaMenu.isOpen} hideTitle={menu.isOpen}
icon={<IconMenu size='1.25rem' className='clr-text-controls' />} icon={<IconMenu size='1.25rem' className='clr-text-controls' />}
className='h-full pl-2' 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 <DropdownButton
text='Поделиться' text='Поделиться'
titleHtml={tooltipText.shareItem(schema.access_policy === AccessPolicy.PUBLIC)} titleHtml={tooltipText.shareItem(schema.access_policy === AccessPolicy.PUBLIC)}

View File

@ -28,7 +28,7 @@ export function SelectGraphFilter({ value, dense, className, onChange, ...restPr
} }
return ( return (
<div ref={menu.ref} className={clsx('relative', className)} {...restProps}> <div ref={menu.ref} onBlur={menu.handleBlur} className={clsx('relative', className)} {...restProps}>
<SelectorButton <SelectorButton
transparent transparent
tabIndex={-1} tabIndex={-1}

View File

@ -28,7 +28,7 @@ export function SelectMatchMode({ value, dense, className, onChange, ...restProp
} }
return ( return (
<div ref={menu.ref} className={clsx('relative', className)} {...restProps}> <div ref={menu.ref} onBlur={menu.handleBlur} className={clsx('relative', className)} {...restProps}>
<SelectorButton <SelectorButton
transparent transparent
titleHtml='Настройка фильтрации <br/>по проверяемым атрибутам' titleHtml='Настройка фильтрации <br/>по проверяемым атрибутам'

View File

@ -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]);
}