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 { 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)
};

View File

@ -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',

View File

@ -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} />

View File

@ -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

View File

@ -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' />}

View File

@ -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}

View File

@ -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'} />}

View File

@ -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}

View File

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

View File

@ -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}

View File

@ -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'

View File

@ -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}

View File

@ -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>между схемами'

View File

@ -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='Скопировать ссылку в буфер обмена'

View File

@ -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}`}

View File

@ -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='Создать конституенту из шаблона'

View File

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

View File

@ -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}

View File

@ -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/>по проверяемым атрибутам'

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