From 2573407851c1ee3e9392ce504f020db68b7856b2 Mon Sep 17 00:00:00 2001 From: Aman Harwara Date: Thu, 21 Jul 2022 02:20:14 +0530 Subject: [PATCH] feat: responsive popovers & menus (#1323) --- .../Components/AccountMenu/AccountMenu.tsx | 40 +-- .../WorkspaceSwitcherMenu.tsx | 4 +- .../WorkspaceSwitcherOption.tsx | 56 ++-- .../AttachedFilesButton.tsx | 137 +++------ .../AttachedFilesPopover.tsx | 10 +- .../AttachedFilesPopover/PopoverFileItem.tsx | 2 - .../PopoverFileItemProps.tsx | 1 - .../PopoverFileSubmenu.tsx | 290 +++++++----------- .../LockscreenWorkspaceSwitcher.tsx | 63 ++-- .../ChangeEditor/ChangeEditorButton.tsx | 106 ++----- .../ChangeEditor/ChangeEditorMenu.tsx | 28 +- .../Header/ContentListHeader.tsx | 58 ++-- .../Header/DisplayOptionsMenu.tsx | 9 +- .../Header/DisplayOptionsMenuPortal.tsx | 63 ---- .../FileContextMenu/FileContextMenu.tsx | 105 ++----- .../FileContextMenu/FileMenuOptions.tsx | 9 - .../FileContextMenu/FileOptionsPanel.tsx | 95 ++---- .../Components/Footer/AccountMenuButton.tsx | 52 ++++ .../javascripts/Components/Footer/Footer.tsx | 70 ++--- .../Components/Footer/QuickSettingsButton.tsx | 55 ++++ .../NotesContextMenu/NotesContextMenu.tsx | 59 ++-- .../Components/NotesOptions/AddTagOption.tsx | 141 +++------ .../NotesOptions/ChangeEditorOption.tsx | 119 +++---- .../NotesOptions/ListedActionsMenu.tsx | 8 +- .../NotesOptions/ListedActionsOption.tsx | 99 ++---- .../Components/NotesOptions/NotesOptions.tsx | 20 +- .../NotesOptions/NotesOptionsPanel.tsx | 106 ++----- .../NotesOptions/NotesOptionsProps.ts | 1 - .../Popover/GetPositionedPopoverStyles.ts | 54 ++++ .../Components/Popover/Popover.tsx | 36 +++ .../Popover/PositionedPopoverContent.tsx | 80 +++++ .../javascripts/Components/Popover/Types.ts | 49 +++ .../Components/Popover/Utils/Collisions.ts | 86 ++++++ .../Components/Popover/Utils/Rect.ts | 120 ++++++++ .../Utils/usePopoverCloseOnClickOutside.ts | 36 +++ .../javascripts/Components/Portal/Portal.tsx | 24 ++ .../QuickSettingsMenu/QuickSettingsMenu.tsx | 189 +++++------- .../QuickSettingsMenu/ThemesMenuButton.tsx | 4 +- .../Components/Tags/TagContextMenu.tsx | 94 +++--- .../src/javascripts/Hooks/useDocumentRect.ts | 25 ++ .../src/javascripts/Hooks/useElementRect.ts | 53 ++++ .../Utils/CalculateSubmenuStyle.tsx | 64 ---- packages/web/src/javascripts/Utils/index.ts | 1 - packages/web/src/stylesheets/_main.scss | 2 +- 44 files changed, 1308 insertions(+), 1415 deletions(-) delete mode 100644 packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenuPortal.tsx create mode 100644 packages/web/src/javascripts/Components/Footer/AccountMenuButton.tsx create mode 100644 packages/web/src/javascripts/Components/Footer/QuickSettingsButton.tsx create mode 100644 packages/web/src/javascripts/Components/Popover/GetPositionedPopoverStyles.ts create mode 100644 packages/web/src/javascripts/Components/Popover/Popover.tsx create mode 100644 packages/web/src/javascripts/Components/Popover/PositionedPopoverContent.tsx create mode 100644 packages/web/src/javascripts/Components/Popover/Types.ts create mode 100644 packages/web/src/javascripts/Components/Popover/Utils/Collisions.ts create mode 100644 packages/web/src/javascripts/Components/Popover/Utils/Rect.ts create mode 100644 packages/web/src/javascripts/Components/Popover/Utils/usePopoverCloseOnClickOutside.ts create mode 100644 packages/web/src/javascripts/Components/Portal/Portal.tsx create mode 100644 packages/web/src/javascripts/Hooks/useDocumentRect.ts create mode 100644 packages/web/src/javascripts/Hooks/useElementRect.ts delete mode 100644 packages/web/src/javascripts/Utils/CalculateSubmenuStyle.tsx diff --git a/packages/web/src/javascripts/Components/AccountMenu/AccountMenu.tsx b/packages/web/src/javascripts/Components/AccountMenu/AccountMenu.tsx index 8c34f60dd..ce43a1ffb 100644 --- a/packages/web/src/javascripts/Components/AccountMenu/AccountMenu.tsx +++ b/packages/web/src/javascripts/Components/AccountMenu/AccountMenu.tsx @@ -1,26 +1,24 @@ import { observer } from 'mobx-react-lite' -import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside' import { ViewControllerManager } from '@/Services/ViewControllerManager' import { WebApplication } from '@/Application/Application' -import { useCallback, useRef, FunctionComponent, KeyboardEventHandler } from 'react' +import { useCallback, FunctionComponent, KeyboardEventHandler } from 'react' import { ApplicationGroup } from '@/Application/ApplicationGroup' import { AccountMenuPane } from './AccountMenuPane' import MenuPaneSelector from './MenuPaneSelector' -type Props = { +export type AccountMenuProps = { viewControllerManager: ViewControllerManager application: WebApplication onClickOutside: () => void mainApplicationGroup: ApplicationGroup } -const AccountMenu: FunctionComponent = ({ +const AccountMenu: FunctionComponent = ({ application, viewControllerManager, - onClickOutside, mainApplicationGroup, }) => { - const { currentPane, shouldAnimateCloseMenu } = viewControllerManager.accountMenuController + const { currentPane } = viewControllerManager.accountMenuController const closeAccountMenu = useCallback(() => { viewControllerManager.accountMenuController.closeAccountMenu() @@ -33,11 +31,6 @@ const AccountMenu: FunctionComponent = ({ [viewControllerManager], ) - const ref = useRef(null) - useCloseOnClickOutside(ref, () => { - onClickOutside() - }) - const handleKeyDown: KeyboardEventHandler = useCallback( (event) => { switch (event.key) { @@ -56,22 +49,15 @@ const AccountMenu: FunctionComponent = ({ ) return ( -
-
- -
+
+
) } diff --git a/packages/web/src/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu.tsx b/packages/web/src/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu.tsx index 3f1186d8f..e060d4113 100644 --- a/packages/web/src/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu.tsx +++ b/packages/web/src/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu.tsx @@ -23,7 +23,9 @@ const WorkspaceSwitcherMenu: FunctionComponent = ({ isOpen, hideWorkspaceOptions = false, }: Props) => { - const [applicationDescriptors, setApplicationDescriptors] = useState([]) + const [applicationDescriptors, setApplicationDescriptors] = useState( + mainApplicationGroup.getDescriptors(), + ) useEffect(() => { const applicationDescriptors = mainApplicationGroup.getDescriptors() diff --git a/packages/web/src/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherOption.tsx b/packages/web/src/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherOption.tsx index f89b07083..0e89ed3fd 100644 --- a/packages/web/src/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherOption.tsx +++ b/packages/web/src/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherOption.tsx @@ -1,13 +1,13 @@ import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants' import { ApplicationGroup } from '@/Application/ApplicationGroup' import { ViewControllerManager } from '@/Services/ViewControllerManager' -import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle' import { observer } from 'mobx-react-lite' -import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react' +import { FunctionComponent, useCallback, useRef, useState } from 'react' import Icon from '@/Components/Icon/Icon' import WorkspaceSwitcherMenu from './WorkspaceSwitcherMenu' import MenuItem from '@/Components/Menu/MenuItem' import { MenuItemType } from '@/Components/Menu/MenuItemType' +import Popover from '@/Components/Popover/Popover' type Props = { mainApplicationGroup: ApplicationGroup @@ -16,32 +16,11 @@ type Props = { const WorkspaceSwitcherOption: FunctionComponent = ({ mainApplicationGroup, viewControllerManager }) => { const buttonRef = useRef(null) - const menuRef = useRef(null) const [isOpen, setIsOpen] = useState(false) - const [menuStyle, setMenuStyle] = useState() const toggleMenu = useCallback(() => { - if (!isOpen) { - const menuPosition = calculateSubmenuStyle(buttonRef.current) - if (menuPosition) { - setMenuStyle(menuPosition) - } - } - - setIsOpen(!isOpen) - }, [isOpen, setIsOpen]) - - useEffect(() => { - if (isOpen) { - setTimeout(() => { - const newMenuPosition = calculateSubmenuStyle(buttonRef.current, menuRef.current) - - if (newMenuPosition) { - setMenuStyle(newMenuPosition) - } - }) - } - }, [isOpen]) + setIsOpen((isOpen) => !isOpen) + }, []) return ( <> @@ -58,19 +37,20 @@ const WorkspaceSwitcherOption: FunctionComponent = ({ mainApplicationGrou
- {isOpen && ( -
- -
- )} + + + ) } diff --git a/packages/web/src/javascripts/Components/AttachedFilesPopover/AttachedFilesButton.tsx b/packages/web/src/javascripts/Components/AttachedFilesPopover/AttachedFilesButton.tsx index b573cb391..f04e61089 100644 --- a/packages/web/src/javascripts/Components/AttachedFilesPopover/AttachedFilesButton.tsx +++ b/packages/web/src/javascripts/Components/AttachedFilesPopover/AttachedFilesButton.tsx @@ -1,11 +1,7 @@ import { WebApplication } from '@/Application/Application' -import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/Constants' -import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' -import VisuallyHidden from '@reach/visually-hidden' import { observer } from 'mobx-react-lite' import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react' import Icon from '@/Components/Icon/Icon' -import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' import AttachedFilesPopover from './AttachedFilesPopover' import { usePremiumModal } from '@/Hooks/usePremiumModal' import { PopoverTabs } from './PopoverTabs' @@ -18,6 +14,8 @@ import { SelectedItemsController } from '@/Controllers/SelectedItemsController' import { useFileDragNDrop } from '@/Components/FileDragNDropProvider/FileDragNDropProvider' import { FileItem, SNNote } from '@standardnotes/snjs' import { addToast, ToastType } from '@standardnotes/toast' +import { classNames } from '@/Utils/ConcatenateClassNames' +import Popover from '../Popover/Popover' type Props = { application: WebApplication @@ -34,7 +32,6 @@ const AttachedFilesButton: FunctionComponent = ({ application, featuresController, filesController, - filePreviewModalController, navigationController, notesController, selectionController, @@ -46,24 +43,9 @@ const AttachedFilesButton: FunctionComponent = ({ const premiumModal = usePremiumModal() const note: SNNote | undefined = notesController.firstSelectedNote - const [open, setOpen] = useState(false) - const [position, setPosition] = useState({ - top: 0, - right: 0, - }) - const [maxHeight, setMaxHeight] = useState('auto') + const [isOpen, setIsOpen] = useState(false) const buttonRef = useRef(null) - const panelRef = useRef(null) const containerRef = useRef(null) - const [closeOnBlur, keepMenuOpen] = useCloseOnBlur(containerRef, setOpen) - - useEffect(() => { - if (filePreviewModalController.isOpen) { - keepMenuOpen(true) - } else { - keepMenuOpen(false) - } - }, [filePreviewModalController.isOpen, keepMenuOpen]) const [currentTab, setCurrentTab] = useState( navigationController.isInFilesView ? PopoverTabs.AllFiles : PopoverTabs.AttachedFiles, @@ -78,29 +60,14 @@ const AttachedFilesButton: FunctionComponent = ({ }, [currentTab, isAttachedTabDisabled]) const toggleAttachedFilesMenu = useCallback(async () => { - const rect = buttonRef.current?.getBoundingClientRect() - if (rect) { - const { clientHeight } = document.documentElement - const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect() - const footerHeightInPx = footerElementRect?.height + const newOpenState = !isOpen - if (footerHeightInPx) { - setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER) - } - - setPosition({ - top: rect.bottom, - right: document.body.clientWidth - rect.right, - }) - - const newOpenState = !open - if (newOpenState && onClickPreprocessing) { - await onClickPreprocessing() - } - - setOpen(newOpenState) + if (newOpenState && onClickPreprocessing) { + await onClickPreprocessing() } - }, [onClickPreprocessing, open]) + + setIsOpen(newOpenState) + }, [onClickPreprocessing, isOpen]) const prospectivelyShowFilesPremiumModal = useCallback(() => { if (!featuresController.hasFiles) { @@ -132,10 +99,10 @@ const AttachedFilesButton: FunctionComponent = ({ const { isDraggingFiles, addFilesDragInCallback, addFilesDropCallback } = useFileDragNDrop() useEffect(() => { - if (isDraggingFiles && !open) { + if (isDraggingFiles && !isOpen) { void toggleAttachedFilesMenu() } - }, [isDraggingFiles, open, toggleAttachedFilesMenu]) + }, [isDraggingFiles, isOpen, toggleAttachedFilesMenu]) const filesDragInCallback = useCallback((tab: PopoverTabs) => { setCurrentTab(tab) @@ -162,53 +129,41 @@ const AttachedFilesButton: FunctionComponent = ({ return (
- - { - if (event.key === 'Escape') { - setOpen(false) - } - }} - ref={buttonRef} - className={`bg-text-padding flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border text-neutral hover:bg-contrast focus:bg-contrast ${ - attachedFilesCount > 0 ? 'py-1 px-3' : '' - }`} - onBlur={closeOnBlur} - > - Attached files - - {attachedFilesCount > 0 && {attachedFilesCount}} - - { - if (event.key === 'Escape') { - setOpen(false) - buttonRef.current?.focus() - } - }} - ref={panelRef} - style={{ - ...position, - maxHeight, - }} - className="slide-down-animation max-h-120 fixed flex min-w-80 max-w-xs flex-col overflow-y-auto rounded bg-default shadow-main transition-transform duration-150" - onBlur={closeOnBlur} - > - {open && ( - - )} - - + + + +
) } diff --git a/packages/web/src/javascripts/Components/AttachedFilesPopover/AttachedFilesPopover.tsx b/packages/web/src/javascripts/Components/AttachedFilesPopover/AttachedFilesPopover.tsx index 9d5d17328..07d1a0b9a 100644 --- a/packages/web/src/javascripts/Components/AttachedFilesPopover/AttachedFilesPopover.tsx +++ b/packages/web/src/javascripts/Components/AttachedFilesPopover/AttachedFilesPopover.tsx @@ -16,7 +16,6 @@ type Props = { filesController: FilesController allFiles: FileItem[] attachedFiles: FileItem[] - closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void currentTab: PopoverTabs isDraggingFiles: boolean setCurrentTab: Dispatch> @@ -28,7 +27,6 @@ const AttachedFilesPopover: FunctionComponent = ({ filesController, allFiles, attachedFiles, - closeOnBlur, currentTab, isDraggingFiles, setCurrentTab, @@ -87,7 +85,6 @@ const AttachedFilesPopover: FunctionComponent = ({ onClick={() => { setCurrentTab(PopoverTabs.AttachedFiles) }} - onBlur={closeOnBlur} disabled={attachedTabDisabled} > Attached @@ -100,7 +97,6 @@ const AttachedFilesPopover: FunctionComponent = ({ onClick={() => { setCurrentTab(PopoverTabs.AllFiles) }} - onBlur={closeOnBlur} > All files @@ -117,7 +113,6 @@ const AttachedFilesPopover: FunctionComponent = ({ onInput={(e) => { setSearchQuery((e.target as HTMLInputElement).value) }} - onBlur={closeOnBlur} ref={searchInputRef} /> {searchQuery.length > 0 && ( @@ -127,7 +122,6 @@ const AttachedFilesPopover: FunctionComponent = ({ setSearchQuery('') searchInputRef.current?.focus() }} - onBlur={closeOnBlur} > @@ -144,7 +138,6 @@ const AttachedFilesPopover: FunctionComponent = ({ isAttachedToNote={attachedFiles.includes(file)} handleFileAction={filesController.handleFileAction} getIconType={application.iconsController.getIconForFileType} - closeOnBlur={closeOnBlur} previewHandler={previewHandler} /> ) @@ -161,7 +154,7 @@ const AttachedFilesPopover: FunctionComponent = ({ ? 'No files attached to this note' : 'No files found in this account'} -
Or drop your files here
@@ -172,7 +165,6 @@ const AttachedFilesPopover: FunctionComponent = ({ + + - {isAttachedToNote ? ( - - ) : ( - - )} - - - - - - -
-
- File ID: {file.uuid} -
-
- Size: {formatSizeToReadableString(file.decryptedSize)} -
-
- - )} - - + + Preview file + + {isAttachedToNote ? ( + + ) : ( + + )} + + + + + + +
+
+ File ID: {file.uuid} +
+
+ Size: {formatSizeToReadableString(file.decryptedSize)} +
+
+
) } diff --git a/packages/web/src/javascripts/Components/ChallengeModal/LockscreenWorkspaceSwitcher.tsx b/packages/web/src/javascripts/Components/ChallengeModal/LockscreenWorkspaceSwitcher.tsx index 6c1dea69e..ff1664291 100644 --- a/packages/web/src/javascripts/Components/ChallengeModal/LockscreenWorkspaceSwitcher.tsx +++ b/packages/web/src/javascripts/Components/ChallengeModal/LockscreenWorkspaceSwitcher.tsx @@ -1,11 +1,10 @@ import { ApplicationGroup } from '@/Application/ApplicationGroup' import { ViewControllerManager } from '@/Services/ViewControllerManager' -import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle' -import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react' +import { FunctionComponent, useCallback, useRef, useState } from 'react' import WorkspaceSwitcherMenu from '@/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu' import Button from '@/Components/Button/Button' import Icon from '@/Components/Icon/Icon' -import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside' +import Popover from '../Popover/Popover' type Props = { mainApplicationGroup: ApplicationGroup @@ -14,36 +13,12 @@ type Props = { const LockscreenWorkspaceSwitcher: FunctionComponent = ({ mainApplicationGroup, viewControllerManager }) => { const buttonRef = useRef(null) - const menuRef = useRef(null) const containerRef = useRef(null) const [isOpen, setIsOpen] = useState(false) - const [menuStyle, setMenuStyle] = useState() - - useCloseOnClickOutside(containerRef, () => setIsOpen(false)) const toggleMenu = useCallback(() => { - if (!isOpen) { - const menuPosition = calculateSubmenuStyle(buttonRef.current) - if (menuPosition) { - setMenuStyle(menuPosition) - } - } - - setIsOpen(!isOpen) - }, [isOpen]) - - useEffect(() => { - if (isOpen) { - const timeToWaitBeforeCheckingMenuCollision = 0 - setTimeout(() => { - const newMenuPosition = calculateSubmenuStyle(buttonRef.current, menuRef.current) - - if (newMenuPosition) { - setMenuStyle(newMenuPosition) - } - }, timeToWaitBeforeCheckingMenuCollision) - } - }, [isOpen]) + setIsOpen((isOpen) => !isOpen) + }, []) return (
@@ -51,20 +26,22 @@ const LockscreenWorkspaceSwitcher: FunctionComponent = ({ mainApplication Switch workspace - {isOpen && ( -
- -
- )} + + +
) } diff --git a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx index 80a6c345a..3ad137d03 100644 --- a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx +++ b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx @@ -1,13 +1,10 @@ import { WebApplication } from '@/Application/Application' import { ViewControllerManager } from '@/Services/ViewControllerManager' -import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/Constants' -import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' -import VisuallyHidden from '@reach/visually-hidden' import { observer } from 'mobx-react-lite' -import { FunctionComponent, useRef, useState } from 'react' +import { FunctionComponent, useCallback, useRef, useState } from 'react' import Icon from '@/Components/Icon/Icon' import ChangeEditorMenu from './ChangeEditorMenu' -import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' +import Popover from '../Popover/Popover' type Props = { application: WebApplication @@ -22,89 +19,38 @@ const ChangeEditorButton: FunctionComponent = ({ }: Props) => { const note = viewControllerManager.notesController.firstSelectedNote const [isOpen, setIsOpen] = useState(false) - const [isVisible, setIsVisible] = useState(false) - const [position, setPosition] = useState({ - top: 0, - right: 0, - }) - const [maxHeight, setMaxHeight] = useState('auto') const buttonRef = useRef(null) - const panelRef = useRef(null) const containerRef = useRef(null) - const [closeOnBlur] = useCloseOnBlur(containerRef, setIsOpen) - const toggleChangeEditorMenu = async () => { - const rect = buttonRef.current?.getBoundingClientRect() - if (rect) { - const { clientHeight } = document.documentElement - const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect() - const footerHeightInPx = footerElementRect?.height - - if (footerHeightInPx) { - setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER) - } - - setPosition({ - top: rect.bottom, - right: document.body.clientWidth - rect.right, - }) - - const newOpenState = !isOpen - if (newOpenState && onClickPreprocessing) { - await onClickPreprocessing() - } - - setIsOpen(newOpenState) - setTimeout(() => { - setIsVisible(newOpenState) - }) + const toggleMenu = useCallback(async () => { + const willMenuOpen = !isOpen + if (willMenuOpen && onClickPreprocessing) { + await onClickPreprocessing() } - } + setIsOpen(willMenuOpen) + }, [onClickPreprocessing, isOpen]) return (
- - { - if (event.key === 'Escape') { - setIsOpen(false) - } + + + { + setIsOpen(false) }} - onBlur={closeOnBlur} - ref={buttonRef} - className="flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border text-neutral hover:bg-contrast focus:bg-contrast" - > - Change note type - - - { - if (event.key === 'Escape') { - setIsOpen(false) - buttonRef.current?.focus() - } - }} - ref={panelRef} - style={{ - ...position, - maxHeight, - }} - className="slide-down-animation max-h-120 fixed flex min-w-68 max-w-xs flex-col overflow-y-auto rounded bg-default shadow-main transition-transform duration-150" - onBlur={closeOnBlur} - > - {isOpen && ( - { - setIsOpen(false) - }} - /> - )} - - + /> +
) } diff --git a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx index 2ffa185be..230f5d16a 100644 --- a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx +++ b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx @@ -14,7 +14,7 @@ import { SNNote, TransactionalMutation, } from '@standardnotes/snjs' -import { Fragment, FunctionComponent, useCallback, useEffect, useState } from 'react' +import { Fragment, FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react' import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup' import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem' import { createEditorMenuGroups } from './createEditorMenuGroups' @@ -28,7 +28,6 @@ import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/Premium type ChangeEditorMenuProps = { application: WebApplication - closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void closeMenu: () => void isVisible: boolean note: SNNote | undefined @@ -36,25 +35,17 @@ type ChangeEditorMenuProps = { const getGroupId = (group: EditorMenuGroup) => group.title.toLowerCase().replace(/\s/, '-') -const ChangeEditorMenu: FunctionComponent = ({ - application, - closeOnBlur, - closeMenu, - isVisible, - note, -}) => { - const [editors] = useState(() => - application.componentManager.componentsForArea(ComponentArea.Editor).sort((a, b) => { - return a.displayName.toLowerCase() < b.displayName.toLowerCase() ? -1 : 1 - }), +const ChangeEditorMenu: FunctionComponent = ({ application, closeMenu, isVisible, note }) => { + const editors = useMemo( + () => + application.componentManager.componentsForArea(ComponentArea.Editor).sort((a, b) => { + return a.displayName.toLowerCase() < b.displayName.toLowerCase() ? -1 : 1 + }), + [application.componentManager], ) - const [groups, setGroups] = useState([]) + const groups = useMemo(() => createEditorMenuGroups(application, editors), [application, editors]) const [currentEditor, setCurrentEditor] = useState() - useEffect(() => { - setGroups(createEditorMenuGroups(application, editors)) - }, [application, editors]) - useEffect(() => { if (note) { setCurrentEditor(application.componentManager.editorForNote(note)) @@ -195,7 +186,6 @@ const ChangeEditorMenu: FunctionComponent = ({ type={MenuItemType.RadioButton} onClick={onClickEditorItem} className={'flex-row-reverse py-2'} - onBlur={closeOnBlur} checked={item.isEntitled ? isSelectedEditor(item) : undefined} >
diff --git a/packages/web/src/javascripts/Components/ContentListView/Header/ContentListHeader.tsx b/packages/web/src/javascripts/Components/ContentListView/Header/ContentListHeader.tsx index 841844b6d..6d5b63027 100644 --- a/packages/web/src/javascripts/Components/ContentListView/Header/ContentListHeader.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/Header/ContentListHeader.tsx @@ -1,10 +1,9 @@ import { WebApplication } from '@/Application/Application' -import { Disclosure, DisclosurePanel } from '@reach/disclosure' import { memo, useCallback, useRef, useState } from 'react' import Icon from '../../Icon/Icon' -import { DisplayOptionsMenuPositionProps } from './DisplayOptionsMenuProps' -import DisplayOptionsMenuPortal from './DisplayOptionsMenuPortal' -import StyledDisplayOptionsButton from './StyledDisplayOptionsButton' +import { classNames } from '@/Utils/ConcatenateClassNames' +import Popover from '@/Components/Popover/Popover' +import DisplayOptionsMenu from './DisplayOptionsMenu' type Props = { application: { @@ -26,21 +25,12 @@ const ContentListHeader = ({ isFilesSmartView, optionsSubtitle, }: Props) => { - const [displayOptionsMenuPosition, setDisplayOptionsMenuPosition] = useState() const displayOptionsContainerRef = useRef(null) const displayOptionsButtonRef = useRef(null) const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false) const toggleDisplayOptionsMenu = useCallback(() => { - if (displayOptionsButtonRef.current) { - const buttonBoundingRect = displayOptionsButtonRef.current.getBoundingClientRect() - setDisplayOptionsMenuPosition({ - top: buttonBoundingRect.bottom, - left: buttonBoundingRect.right - buttonBoundingRect.width, - }) - } - setShowDisplayOptionsMenu((show) => !show) }, []) @@ -52,24 +42,30 @@ const ContentListHeader = ({
- - - - - - {showDisplayOptionsMenu && displayOptionsMenuPosition && ( - - )} - - + + + +
) : shouldShowAttachOption ? ( {shouldShowRenameOption && ( )} + + { + setIsOpen(false) + }} + shouldShowAttachOption={false} + shouldShowRenameOption={false} + /> + + ) } diff --git a/packages/web/src/javascripts/Components/Footer/AccountMenuButton.tsx b/packages/web/src/javascripts/Components/Footer/AccountMenuButton.tsx new file mode 100644 index 000000000..40f78dea4 --- /dev/null +++ b/packages/web/src/javascripts/Components/Footer/AccountMenuButton.tsx @@ -0,0 +1,52 @@ +import { classNames } from '@/Utils/ConcatenateClassNames' +import { useRef } from 'react' +import AccountMenu, { AccountMenuProps } from '../AccountMenu/AccountMenu' +import Icon from '../Icon/Icon' +import Popover from '../Popover/Popover' + +type Props = AccountMenuProps & { + isOpen: boolean + hasError: boolean + toggleMenu: () => void + user: unknown +} + +const AccountMenuButton = ({ + application, + hasError, + isOpen, + mainApplicationGroup, + onClickOutside, + toggleMenu, + user, + viewControllerManager, +}: Props) => { + const buttonRef = useRef(null) + + return ( + <> + + + + + + ) +} + +export default AccountMenuButton diff --git a/packages/web/src/javascripts/Components/Footer/Footer.tsx b/packages/web/src/javascripts/Components/Footer/Footer.tsx index da735cf3d..eeddf9ea0 100644 --- a/packages/web/src/javascripts/Components/Footer/Footer.tsx +++ b/packages/web/src/javascripts/Components/Footer/Footer.tsx @@ -12,13 +12,13 @@ import { STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON, } from '@/Constants/Strings' import { alertDialog, confirmDialog } from '@/Services/AlertService' -import AccountMenu from '@/Components/AccountMenu/AccountMenu' import Icon from '@/Components/Icon/Icon' -import QuickSettingsMenu from '@/Components/QuickSettingsMenu/QuickSettingsMenu' import SyncResolutionMenu from '@/Components/SyncResolutionMenu/SyncResolutionMenu' import { Fragment } from 'react' import { AccountMenuPane } from '../AccountMenu/AccountMenuPane' import { EditorEventSource } from '@/Types/EditorEventSource' +import QuickSettingsButton from './QuickSettingsButton' +import AccountMenuButton from './AccountMenuButton' type Props = { application: WebApplication @@ -287,12 +287,10 @@ class Footer extends PureComponent { } accountMenuClickHandler = () => { - this.viewControllerManager.quickSettingsMenuController.closeQuickSettingsMenu() this.viewControllerManager.accountMenuController.toggleShow() } quickSettingsClickHandler = () => { - this.viewControllerManager.accountMenuController.closeAccountMenu() this.viewControllerManager.quickSettingsMenuController.toggle() } @@ -342,55 +340,31 @@ class Footer extends PureComponent { override render() { return (
- +
) } diff --git a/packages/web/src/javascripts/Components/Footer/QuickSettingsButton.tsx b/packages/web/src/javascripts/Components/Footer/QuickSettingsButton.tsx new file mode 100644 index 000000000..41adfa6e6 --- /dev/null +++ b/packages/web/src/javascripts/Components/Footer/QuickSettingsButton.tsx @@ -0,0 +1,55 @@ +import { WebApplication } from '@/Application/Application' +import { PreferencesController } from '@/Controllers/PreferencesController' +import { QuickSettingsController } from '@/Controllers/QuickSettingsController' +import { useRef } from 'react' +import Icon from '../Icon/Icon' +import Popover from '../Popover/Popover' +import QuickSettingsMenu from '../QuickSettingsMenu/QuickSettingsMenu' + +type Props = { + isOpen: boolean + toggleMenu: () => void + application: WebApplication + preferencesController: PreferencesController + quickSettingsMenuController: QuickSettingsController +} + +const QuickSettingsButton = ({ + application, + isOpen, + toggleMenu, + preferencesController, + quickSettingsMenuController, +}: Props) => { + const buttonRef = useRef(null) + + return ( + <> + + + + + + ) +} + +export default QuickSettingsButton diff --git a/packages/web/src/javascripts/Components/NotesContextMenu/NotesContextMenu.tsx b/packages/web/src/javascripts/Components/NotesContextMenu/NotesContextMenu.tsx index cafc4194a..b212a337f 100644 --- a/packages/web/src/javascripts/Components/NotesContextMenu/NotesContextMenu.tsx +++ b/packages/web/src/javascripts/Components/NotesContextMenu/NotesContextMenu.tsx @@ -1,13 +1,12 @@ -import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' -import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside' import { observer } from 'mobx-react-lite' import NotesOptions from '@/Components/NotesOptions/NotesOptions' -import { useCallback, useEffect, useRef } from 'react' +import { useRef } from 'react' import { WebApplication } from '@/Application/Application' import { NotesController } from '@/Controllers/NotesController' import { NavigationController } from '@/Controllers/Navigation/NavigationController' import { NoteTagsController } from '@/Controllers/NoteTagsController' import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalController' +import Popover from '../Popover/Popover' type Props = { application: WebApplication @@ -24,43 +23,33 @@ const NotesContextMenu = ({ noteTagsController, historyModalController, }: Props) => { - const { contextMenuOpen, contextMenuPosition, contextMenuMaxHeight } = notesController + const { contextMenuOpen, contextMenuClickLocation } = notesController const contextMenuRef = useRef(null) - const [closeOnBlur] = useCloseOnBlur(contextMenuRef, (open: boolean) => notesController.setContextMenuOpen(open)) - useCloseOnClickOutside(contextMenuRef, () => notesController.setContextMenuOpen(false)) - - const reloadContextMenuLayout = useCallback(() => { - notesController.reloadContextMenuLayout() - }, [notesController]) - - useEffect(() => { - window.addEventListener('resize', reloadContextMenuLayout) - return () => { - window.removeEventListener('resize', reloadContextMenuLayout) - } - }, [reloadContextMenuLayout]) - - return contextMenuOpen ? ( -
notesController.setContextMenuOpen(!contextMenuOpen)} > - -
- ) : null +
+ +
+ + ) } export default observer(NotesContextMenu) diff --git a/packages/web/src/javascripts/Components/NotesOptions/AddTagOption.tsx b/packages/web/src/javascripts/Components/NotesOptions/AddTagOption.tsx index 76dc44ef4..eff32d6c0 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/AddTagOption.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/AddTagOption.tsx @@ -1,12 +1,11 @@ -import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle' -import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' import { observer } from 'mobx-react-lite' -import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react' +import { FunctionComponent, useCallback, useRef, useState } from 'react' import Icon from '@/Components/Icon/Icon' -import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' import { NavigationController } from '@/Controllers/Navigation/NavigationController' import { NotesController } from '@/Controllers/NotesController' import { NoteTagsController } from '@/Controllers/NoteTagsController' +import { KeyboardKey } from '@/Services/IOService' +import Popover from '../Popover/Popover' type Props = { navigationController: NavigationController @@ -16,101 +15,59 @@ type Props = { const AddTagOption: FunctionComponent = ({ navigationController, notesController, noteTagsController }) => { const menuContainerRef = useRef(null) - const menuRef = useRef(null) - const menuButtonRef = useRef(null) + const buttonRef = useRef(null) - const [isMenuOpen, setIsMenuOpen] = useState(false) - const [menuStyle, setMenuStyle] = useState({ - right: 0, - bottom: 0, - maxHeight: 'auto', - }) + const [isOpen, setIsOpen] = useState(false) - const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen) - - const toggleTagsMenu = useCallback(() => { - if (!isMenuOpen) { - const menuPosition = calculateSubmenuStyle(menuButtonRef.current) - if (menuPosition) { - setMenuStyle(menuPosition) - } - } - - setIsMenuOpen(!isMenuOpen) - }, [isMenuOpen]) - - const recalculateMenuStyle = useCallback(() => { - const newMenuPosition = calculateSubmenuStyle(menuButtonRef.current, menuRef.current) - - if (newMenuPosition) { - setMenuStyle(newMenuPosition) - } + const toggleMenu = useCallback(() => { + setIsOpen((isOpen) => !isOpen) }, []) - useEffect(() => { - if (isMenuOpen) { - setTimeout(() => { - recalculateMenuStyle() - }) - } - }, [isMenuOpen, recalculateMenuStyle]) - return (
- - { - if (event.key === 'Escape') { - setIsMenuOpen(false) - } - }} - onBlur={closeOnBlur} - ref={menuButtonRef} - className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none" - > -
- - Add tag -
- -
- { - if (event.key === 'Escape') { - setIsMenuOpen(false) - menuButtonRef.current?.focus() - } - }} - style={{ - ...menuStyle, - position: 'fixed', - }} - className={`${ - isMenuOpen ? 'flex' : 'hidden' - } max-h-120 fixed min-w-80 max-w-xs flex-col overflow-y-auto rounded bg-default py-2 shadow-main`} - > - {navigationController.tags.map((tag) => ( - + + {navigationController.tags.map((tag) => ( + - ))} - -
+ > + {noteTagsController.getLongTitle(tag)} + + + ))} +
) } diff --git a/packages/web/src/javascripts/Components/NotesOptions/ChangeEditorOption.tsx b/packages/web/src/javascripts/Components/NotesOptions/ChangeEditorOption.tsx index 3b8eed83a..8fdfbff64 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/ChangeEditorOption.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/ChangeEditorOption.tsx @@ -1,12 +1,10 @@ import { KeyboardKey } from '@/Services/IOService' import { WebApplication } from '@/Application/Application' -import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' import { SNNote } from '@standardnotes/snjs' -import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react' +import { FunctionComponent, useCallback, useRef, useState } from 'react' import Icon from '@/Components/Icon/Icon' import ChangeEditorMenu from '@/Components/ChangeEditor/ChangeEditorMenu' -import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle' -import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' +import Popover from '../Popover/Popover' type ChangeEditorOptionProps = { application: WebApplication @@ -15,91 +13,48 @@ type ChangeEditorOptionProps = { const ChangeEditorOption: FunctionComponent = ({ application, note }) => { const [isOpen, setIsOpen] = useState(false) - const [isVisible, setIsVisible] = useState(false) - const [menuStyle, setMenuStyle] = useState({ - right: 0, - bottom: 0, - maxHeight: 'auto', - }) const menuContainerRef = useRef(null) - const menuRef = useRef(null) const buttonRef = useRef(null) - const [closeOnBlur] = useCloseOnBlur(menuContainerRef, (open: boolean) => { - setIsOpen(open) - setIsVisible(open) - }) - - const toggleChangeEditorMenu = useCallback(() => { - if (!isOpen) { - const menuStyle = calculateSubmenuStyle(buttonRef.current) - if (menuStyle) { - setMenuStyle(menuStyle) - } - } - - setIsOpen(!isOpen) - }, [isOpen]) - - useEffect(() => { - if (isOpen) { - setTimeout(() => { - const newMenuStyle = calculateSubmenuStyle(buttonRef.current, menuRef.current) - - if (newMenuStyle) { - setMenuStyle(newMenuStyle) - setIsVisible(true) - } - }, 5) - } - }, [isOpen]) + const toggleMenu = useCallback(async () => { + setIsOpen((isOpen) => !isOpen) + }, []) return (
- - { - if (event.key === KeyboardKey.Escape) { - setIsOpen(false) - } + + + { + setIsOpen(false) }} - onBlur={closeOnBlur} - ref={buttonRef} - className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none" - > -
- - Change note type -
- -
- { - if (event.key === KeyboardKey.Escape) { - setIsOpen(false) - buttonRef.current?.focus() - } - }} - style={{ - ...menuStyle, - position: 'fixed', - }} - className="max-h-120 fixed flex min-w-68 flex-col overflow-y-auto rounded bg-default shadow-main" - > - {isOpen && ( - { - setIsOpen(false) - }} - /> - )} - -
+ /> +
) } diff --git a/packages/web/src/javascripts/Components/NotesOptions/ListedActionsMenu.tsx b/packages/web/src/javascripts/Components/NotesOptions/ListedActionsMenu.tsx index 469451f72..eb0d3a515 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/ListedActionsMenu.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/ListedActionsMenu.tsx @@ -9,10 +9,9 @@ import Spinner from '@/Components/Spinner/Spinner' type ListedActionsMenuProps = { application: WebApplication note: SNNote - recalculateMenuStyle: () => void } -const ListedActionsMenu = ({ application, note, recalculateMenuStyle }: ListedActionsMenuProps) => { +const ListedActionsMenu = ({ application, note }: ListedActionsMenuProps) => { const [menuGroups, setMenuGroups] = useState([]) const [isFetchingAccounts, setIsFetchingAccounts] = useState(true) @@ -88,14 +87,11 @@ const ListedActionsMenu = ({ application, note, recalculateMenuStyle }: ListedAc console.error(err) } finally { setIsFetchingAccounts(false) - setTimeout(() => { - recalculateMenuStyle() - }) } } void fetchListedAccounts() - }, [application, note.uuid, recalculateMenuStyle]) + }, [application, note.uuid]) return ( <> diff --git a/packages/web/src/javascripts/Components/NotesOptions/ListedActionsOption.tsx b/packages/web/src/javascripts/Components/NotesOptions/ListedActionsOption.tsx index d00c33b5a..0bceef05a 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/ListedActionsOption.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/ListedActionsOption.tsx @@ -1,11 +1,10 @@ import { WebApplication } from '@/Application/Application' -import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle' -import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' import { SNNote } from '@standardnotes/snjs' -import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react' +import { FunctionComponent, useCallback, useRef, useState } from 'react' import Icon from '@/Components/Icon/Icon' -import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' import ListedActionsMenu from './ListedActionsMenu' +import { KeyboardKey } from '@/Services/IOService' +import Popover from '../Popover/Popover' type Props = { application: WebApplication @@ -14,74 +13,42 @@ type Props = { const ListedActionsOption: FunctionComponent = ({ application, note }) => { const menuContainerRef = useRef(null) - const menuRef = useRef(null) - const menuButtonRef = useRef(null) + const buttonRef = useRef(null) - const [isMenuOpen, setIsMenuOpen] = useState(false) - const [menuStyle, setMenuStyle] = useState({ - right: 0, - bottom: 0, - maxHeight: 'auto', - }) + const [isOpen, setIsOpen] = useState(false) - const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen) - - const toggleListedMenu = useCallback(() => { - if (!isMenuOpen) { - const menuPosition = calculateSubmenuStyle(menuButtonRef.current) - if (menuPosition) { - setMenuStyle(menuPosition) - } - } - - setIsMenuOpen(!isMenuOpen) - }, [isMenuOpen]) - - const recalculateMenuStyle = useCallback(() => { - const newMenuPosition = calculateSubmenuStyle(menuButtonRef.current, menuRef.current) - - if (newMenuPosition) { - setMenuStyle(newMenuPosition) - } + const toggleMenu = useCallback(() => { + setIsOpen((isOpen) => !isOpen) }, []) - useEffect(() => { - if (isMenuOpen) { - setTimeout(() => { - recalculateMenuStyle() - }) - } - }, [isMenuOpen, recalculateMenuStyle]) - return (
- - -
- - Listed actions -
- -
- - {isMenuOpen && ( - - )} - -
+ + + +
) } diff --git a/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx b/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx index d021343df..5e39547c0 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx @@ -15,13 +15,11 @@ import HorizontalSeparator from '../Shared/HorizontalSeparator' import { formatDateForContextMenu } from '@/Utils/DateUtils' type DeletePermanentlyButtonProps = { - closeOnBlur: NotesOptionsProps['closeOnBlur'] onClick: () => void } -const DeletePermanentlyButton = ({ closeOnBlur, onClick }: DeletePermanentlyButtonProps) => ( +const DeletePermanentlyButton = ({ onClick }: DeletePermanentlyButtonProps) => ( )} {unarchived && ( { await notesController.deleteNotesPermanently() }} /> + + + + ) } diff --git a/packages/web/src/javascripts/Components/NotesOptions/NotesOptionsProps.ts b/packages/web/src/javascripts/Components/NotesOptions/NotesOptionsProps.ts index 9c59d1509..619618308 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/NotesOptionsProps.ts +++ b/packages/web/src/javascripts/Components/NotesOptions/NotesOptionsProps.ts @@ -10,5 +10,4 @@ export type NotesOptionsProps = { notesController: NotesController noteTagsController: NoteTagsController historyModalController: HistoryModalController - closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void } diff --git a/packages/web/src/javascripts/Components/Popover/GetPositionedPopoverStyles.ts b/packages/web/src/javascripts/Components/Popover/GetPositionedPopoverStyles.ts new file mode 100644 index 000000000..2883a6c5b --- /dev/null +++ b/packages/web/src/javascripts/Components/Popover/GetPositionedPopoverStyles.ts @@ -0,0 +1,54 @@ +import { CSSProperties } from 'react' +import { PopoverAlignment, PopoverSide } from './Types' +import { OppositeSide, checkCollisions, getNonCollidingSide, getNonCollidingAlignment } from './Utils/Collisions' +import { getPositionedPopoverRect } from './Utils/Rect' + +const getStylesFromRect = (rect: DOMRect): CSSProperties => { + return { + willChange: 'transform', + transform: `translate(${rect.x}px, ${rect.y}px)`, + } +} + +type Options = { + align: PopoverAlignment + anchorRect?: DOMRect + documentRect: DOMRect + popoverRect?: DOMRect + side: PopoverSide +} + +export const getPositionedPopoverStyles = ({ + align, + anchorRect, + documentRect, + popoverRect, + side, +}: Options): [CSSProperties | null, PopoverSide, PopoverAlignment] => { + if (!popoverRect || !anchorRect) { + return [null, side, align] + } + + const matchesMediumBreakpoint = matchMedia('(min-width: 768px)').matches + + if (!matchesMediumBreakpoint) { + return [null, side, align] + } + + const rectForPreferredSide = getPositionedPopoverRect(popoverRect, anchorRect, side, align) + const preferredSideRectCollisions = checkCollisions(rectForPreferredSide, documentRect) + + const oppositeSide = OppositeSide[side] + const rectForOppositeSide = getPositionedPopoverRect(popoverRect, anchorRect, oppositeSide, align) + const oppositeSideRectCollisions = checkCollisions(rectForOppositeSide, documentRect) + + const finalSide = getNonCollidingSide(side, preferredSideRectCollisions, oppositeSideRectCollisions) + const finalAlignment = getNonCollidingAlignment(finalSide, align, preferredSideRectCollisions, { + popoverRect, + buttonRect: anchorRect, + documentRect, + }) + const finalPositionedRect = getPositionedPopoverRect(popoverRect, anchorRect, finalSide, finalAlignment) + + return [getStylesFromRect(finalPositionedRect), finalSide, finalAlignment] +} diff --git a/packages/web/src/javascripts/Components/Popover/Popover.tsx b/packages/web/src/javascripts/Components/Popover/Popover.tsx new file mode 100644 index 000000000..328c484af --- /dev/null +++ b/packages/web/src/javascripts/Components/Popover/Popover.tsx @@ -0,0 +1,36 @@ +import PositionedPopoverContent from './PositionedPopoverContent' +import { PopoverProps } from './Types' + +type Props = PopoverProps & { + open: boolean +} + +const Popover = ({ + align, + anchorElement, + anchorPoint, + children, + className, + open, + overrideZIndex, + side, + togglePopover, +}: Props) => { + return open ? ( + <> + + {children} + + + ) : null +} + +export default Popover diff --git a/packages/web/src/javascripts/Components/Popover/PositionedPopoverContent.tsx b/packages/web/src/javascripts/Components/Popover/PositionedPopoverContent.tsx new file mode 100644 index 000000000..ef675bbc1 --- /dev/null +++ b/packages/web/src/javascripts/Components/Popover/PositionedPopoverContent.tsx @@ -0,0 +1,80 @@ +import { useDocumentRect } from '@/Hooks/useDocumentRect' +import { useAutoElementRect } from '@/Hooks/useElementRect' +import { classNames } from '@/Utils/ConcatenateClassNames' +import { useState } from 'react' +import Icon from '../Icon/Icon' +import Portal from '../Portal/Portal' +import HorizontalSeparator from '../Shared/HorizontalSeparator' +import { getPositionedPopoverStyles } from './GetPositionedPopoverStyles' +import { PopoverContentProps } from './Types' +import { getPopoverMaxHeight, getAppRect } from './Utils/Rect' +import { usePopoverCloseOnClickOutside } from './Utils/usePopoverCloseOnClickOutside' + +const PositionedPopoverContent = ({ + align = 'end', + anchorElement, + anchorPoint, + children, + className, + overrideZIndex, + side = 'bottom', + togglePopover, +}: PopoverContentProps) => { + const [popoverElement, setPopoverElement] = useState(null) + const popoverRect = useAutoElementRect(popoverElement) + const anchorElementRect = useAutoElementRect(anchorElement, { + updateOnWindowResize: true, + }) + const anchorPointRect = DOMRect.fromRect({ + x: anchorPoint?.x, + y: anchorPoint?.y, + }) + const anchorRect = anchorPoint ? anchorPointRect : anchorElementRect + const documentRect = useDocumentRect() + + const [styles, positionedSide, positionedAlignment] = getPositionedPopoverStyles({ + align, + anchorRect, + documentRect, + popoverRect: popoverRect ?? popoverElement?.getBoundingClientRect(), + side, + }) + + usePopoverCloseOnClickOutside({ + popoverElement, + anchorElement, + togglePopover, + }) + + return ( + +
{ + setPopoverElement(node) + }} + data-popover + > +
+
+ +
+ +
+ {children} +
+
+ ) +} + +export default PositionedPopoverContent diff --git a/packages/web/src/javascripts/Components/Popover/Types.ts b/packages/web/src/javascripts/Components/Popover/Types.ts new file mode 100644 index 000000000..face9d2db --- /dev/null +++ b/packages/web/src/javascripts/Components/Popover/Types.ts @@ -0,0 +1,49 @@ +import { ReactNode } from 'react' + +export type PopoverState = 'closed' | 'positioning' | 'open' + +export type PopoverElement = HTMLDivElement | HTMLMenuElement + +export type PopoverSide = 'top' | 'left' | 'bottom' | 'right' + +export type PopoverAlignment = 'start' | 'center' | 'end' + +export type PopoverOptions = { + side: PopoverSide + align: PopoverAlignment +} + +export type RectCollisions = Record + +type Point = { + x: number + y: number +} + +type PopoverAnchorElementProps = { + anchorElement: HTMLElement | null + anchorPoint?: never +} + +type PopoverAnchorPointProps = { + anchorPoint: Point + anchorElement?: never +} + +type CommonPopoverProps = { + align?: PopoverAlignment + children: ReactNode + side?: PopoverSide + overrideZIndex?: string + togglePopover: () => void + className?: string +} + +export type PopoverContentProps = CommonPopoverProps & { + anchorElement?: HTMLElement | null + anchorPoint?: Point +} + +export type PopoverProps = + | (CommonPopoverProps & PopoverAnchorElementProps) + | (CommonPopoverProps & PopoverAnchorPointProps) diff --git a/packages/web/src/javascripts/Components/Popover/Utils/Collisions.ts b/packages/web/src/javascripts/Components/Popover/Utils/Collisions.ts new file mode 100644 index 000000000..4b28bcdcf --- /dev/null +++ b/packages/web/src/javascripts/Components/Popover/Utils/Collisions.ts @@ -0,0 +1,86 @@ +import { PopoverSide, PopoverAlignment, RectCollisions } from '../Types' +import { getAppRect, getPositionedPopoverRect } from './Rect' + +export const OppositeSide: Record = { + top: 'bottom', + bottom: 'top', + left: 'right', + right: 'left', +} + +export const checkCollisions = (popoverRect: DOMRect, containerRect: DOMRect): RectCollisions => { + const appRect = getAppRect(containerRect) + + return { + top: popoverRect.top < appRect.top, + left: popoverRect.left < appRect.left, + bottom: popoverRect.bottom > appRect.bottom, + right: popoverRect.right > appRect.right, + } +} + +export const getNonCollidingSide = ( + preferredSide: PopoverSide, + preferredSideCollisions: RectCollisions, + oppositeSideCollisions: RectCollisions, +): PopoverSide => { + const oppositeSide = OppositeSide[preferredSide] + + return preferredSideCollisions[preferredSide] && !oppositeSideCollisions[oppositeSide] ? oppositeSide : preferredSide +} + +const OppositeAlignment: Record, PopoverAlignment> = { + start: 'end', + end: 'start', +} + +export const getNonCollidingAlignment = ( + finalSide: PopoverSide, + preferredAlignment: PopoverAlignment, + collisions: RectCollisions, + { + popoverRect, + buttonRect, + documentRect, + }: { + popoverRect: DOMRect + buttonRect: DOMRect + documentRect: DOMRect + }, +): PopoverAlignment => { + const isHorizontalSide = finalSide === 'top' || finalSide === 'bottom' + const boundToCheckForStart = isHorizontalSide ? 'right' : 'bottom' + const boundToCheckForEnd = isHorizontalSide ? 'left' : 'top' + + const prefersAligningAtStart = preferredAlignment === 'start' + const prefersAligningAtCenter = preferredAlignment === 'center' + const prefersAligningAtEnd = preferredAlignment === 'end' + + if (prefersAligningAtCenter) { + if (collisions[boundToCheckForStart]) { + return 'end' + } + if (collisions[boundToCheckForEnd]) { + return 'start' + } + } else { + const oppositeAlignmentCollisions = checkCollisions( + getPositionedPopoverRect(popoverRect, buttonRect, finalSide, OppositeAlignment[preferredAlignment]), + documentRect, + ) + + if ( + prefersAligningAtStart && + collisions[boundToCheckForStart] && + !oppositeAlignmentCollisions[boundToCheckForEnd] + ) { + return 'end' + } + + if (prefersAligningAtEnd && collisions[boundToCheckForEnd] && !oppositeAlignmentCollisions[boundToCheckForStart]) { + return 'start' + } + } + + return preferredAlignment +} diff --git a/packages/web/src/javascripts/Components/Popover/Utils/Rect.ts b/packages/web/src/javascripts/Components/Popover/Utils/Rect.ts new file mode 100644 index 000000000..42cc40bb4 --- /dev/null +++ b/packages/web/src/javascripts/Components/Popover/Utils/Rect.ts @@ -0,0 +1,120 @@ +import { PopoverSide, PopoverAlignment } from '../Types' + +export const getPopoverMaxHeight = ( + appRect: DOMRect, + buttonRect: DOMRect | undefined, + side: PopoverSide, + alignment: PopoverAlignment, +): number | 'none' => { + const matchesMediumBreakpoint = matchMedia('(min-width: 768px)').matches + + if (!matchesMediumBreakpoint) { + return 'none' + } + + const MarginFromAppBorderInPX = 10 + + let constraint = 0 + + if (buttonRect) { + switch (side) { + case 'top': + constraint = appRect.height - buttonRect.top + break + case 'bottom': + constraint = buttonRect.bottom + break + case 'left': + case 'right': + switch (alignment) { + case 'start': + constraint = buttonRect.top + break + case 'end': + constraint = appRect.height - buttonRect.bottom + break + } + break + } + } + + return appRect.height - constraint - MarginFromAppBorderInPX +} + +export const getMaxHeightAdjustedRect = (rect: DOMRect, maxHeight: number) => { + return DOMRect.fromRect({ + width: rect.width, + height: rect.height < maxHeight ? rect.height : maxHeight, + x: rect.x, + y: rect.y, + }) +} + +export const getAppRect = (updatedDocumentRect?: DOMRect) => { + const footerRect = document.querySelector('footer')?.getBoundingClientRect() + const documentRect = updatedDocumentRect ? updatedDocumentRect : document.documentElement.getBoundingClientRect() + + const appRect = footerRect + ? DOMRect.fromRect({ + width: documentRect.width, + height: documentRect.height - footerRect.height, + }) + : documentRect + + return appRect +} + +export const getPositionedPopoverRect = ( + popoverRect: DOMRect, + buttonRect: DOMRect, + side: PopoverSide, + align: PopoverAlignment, +): DOMRect => { + const { width, height } = popoverRect + + const positionPopoverRect = DOMRect.fromRect(popoverRect) + + switch (side) { + case 'top': { + positionPopoverRect.y = buttonRect.top - height + break + } + case 'bottom': + positionPopoverRect.y = buttonRect.bottom + break + case 'left': + positionPopoverRect.x = buttonRect.left - width + break + case 'right': + positionPopoverRect.x = buttonRect.right + break + } + + if (side === 'top' || side === 'bottom') { + switch (align) { + case 'start': + positionPopoverRect.x = buttonRect.left + break + case 'center': + positionPopoverRect.x = buttonRect.left - width / 2 + buttonRect.width / 2 + break + case 'end': + positionPopoverRect.x = buttonRect.right - width + break + } + } else { + switch (align) { + case 'start': + positionPopoverRect.y = buttonRect.top + break + case 'center': + positionPopoverRect.y = buttonRect.top - height / 2 + buttonRect.height / 2 + break + case 'end': + positionPopoverRect.y = buttonRect.bottom - height + break + } + } + + return positionPopoverRect +} diff --git a/packages/web/src/javascripts/Components/Popover/Utils/usePopoverCloseOnClickOutside.ts b/packages/web/src/javascripts/Components/Popover/Utils/usePopoverCloseOnClickOutside.ts new file mode 100644 index 000000000..79c91debb --- /dev/null +++ b/packages/web/src/javascripts/Components/Popover/Utils/usePopoverCloseOnClickOutside.ts @@ -0,0 +1,36 @@ +import { useEffect } from 'react' + +type Options = { + popoverElement: HTMLElement | null + anchorElement: HTMLElement | null | undefined + togglePopover: () => void +} + +export const usePopoverCloseOnClickOutside = ({ popoverElement, anchorElement, togglePopover }: Options) => { + useEffect(() => { + const closeIfClickedOutside = (event: MouseEvent) => { + const matchesMediumBreakpoint = matchMedia('(min-width: 768px)').matches + + if (!matchesMediumBreakpoint) { + return + } + + const target = event.target as Element + + const isDescendantOfMenu = popoverElement?.contains(target) + const isAnchorElement = anchorElement ? anchorElement === event.target || anchorElement.contains(target) : false + const isDescendantOfPopover = target.closest('[data-popover]') + + if (!isDescendantOfMenu && !isAnchorElement && !isDescendantOfPopover) { + togglePopover() + } + } + + document.addEventListener('click', closeIfClickedOutside, { capture: true }) + return () => { + document.removeEventListener('click', closeIfClickedOutside, { + capture: true, + }) + } + }, [anchorElement, popoverElement, togglePopover]) +} diff --git a/packages/web/src/javascripts/Components/Portal/Portal.tsx b/packages/web/src/javascripts/Components/Portal/Portal.tsx new file mode 100644 index 000000000..bbefc1fb3 --- /dev/null +++ b/packages/web/src/javascripts/Components/Portal/Portal.tsx @@ -0,0 +1,24 @@ +import { ReactNode, useState, useEffect } from 'react' +import { createPortal } from 'react-dom' + +type Props = { + children: ReactNode +} + +const randomPortalId = () => Math.random() + +const Portal = ({ children }: Props) => { + const [container, setContainer] = useState() + + useEffect(() => { + const container = document.createElement('div') + container.id = `react-portal-${randomPortalId()}` + document.body.append(container) + setContainer(container) + return () => container.remove() + }, []) + + return container ? createPortal(children, container) : null +} + +export default Portal diff --git a/packages/web/src/javascripts/Components/QuickSettingsMenu/QuickSettingsMenu.tsx b/packages/web/src/javascripts/Components/QuickSettingsMenu/QuickSettingsMenu.tsx index 5ef8e4791..ccb5fd187 100644 --- a/packages/web/src/javascripts/Components/QuickSettingsMenu/QuickSettingsMenu.tsx +++ b/packages/web/src/javascripts/Components/QuickSettingsMenu/QuickSettingsMenu.tsx @@ -1,27 +1,26 @@ import { WebApplication } from '@/Application/Application' -import { ViewControllerManager } from '@/Services/ViewControllerManager' -import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' import { ComponentArea, ContentType, FeatureIdentifier, GetFeatures, SNComponent } from '@standardnotes/snjs' import { observer } from 'mobx-react-lite' import { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react' import Icon from '@/Components/Icon/Icon' import Switch from '@/Components/Switch/Switch' -import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' -import { quickSettingsKeyDownHandler, themesMenuKeyDownHandler } from './EventHandlers' +import { quickSettingsKeyDownHandler } from './EventHandlers' import FocusModeSwitch from './FocusModeSwitch' import ThemesMenuButton from './ThemesMenuButton' -import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside' import { ThemeItem } from './ThemeItem' import { sortThemes } from '@/Utils/SortThemes' import RadioIndicator from '../RadioIndicator/RadioIndicator' import HorizontalSeparator from '../Shared/HorizontalSeparator' +import Popover from '../Popover/Popover' +import { PreferencesController } from '@/Controllers/PreferencesController' +import { QuickSettingsController } from '@/Controllers/QuickSettingsController' const focusModeAnimationDuration = 1255 type MenuProps = { - viewControllerManager: ViewControllerManager + preferencesController: PreferencesController + quickSettingsMenuController: QuickSettingsController application: WebApplication - onClickOutside: () => void } const toggleFocusMode = (enabled: boolean) => { @@ -38,25 +37,23 @@ const toggleFocusMode = (enabled: boolean) => { } } -const QuickSettingsMenu: FunctionComponent = ({ application, viewControllerManager, onClickOutside }) => { - const { closeQuickSettingsMenu, shouldAnimateCloseMenu, focusModeEnabled, setFocusModeEnabled } = - viewControllerManager.quickSettingsMenuController +const QuickSettingsMenu: FunctionComponent = ({ + application, + preferencesController, + quickSettingsMenuController, +}) => { + const { closeQuickSettingsMenu, focusModeEnabled, setFocusModeEnabled } = quickSettingsMenuController const [themes, setThemes] = useState([]) const [toggleableComponents, setToggleableComponents] = useState([]) const [themesMenuOpen, setThemesMenuOpen] = useState(false) - const [themesMenuPosition, setThemesMenuPosition] = useState({}) const [defaultThemeOn, setDefaultThemeOn] = useState(false) - const themesMenuRef = useRef(null) const themesButtonRef = useRef(null) const prefsButtonRef = useRef(null) const quickSettingsMenuRef = useRef(null) const defaultThemeButtonRef = useRef(null) const mainRef = useRef(null) - useCloseOnClickOutside(mainRef, () => { - onClickOutside() - }) useEffect(() => { toggleFocusMode(focusModeEnabled) @@ -139,25 +136,14 @@ const QuickSettingsMenu: FunctionComponent = ({ application, viewCont prefsButtonRef.current?.focus() }, []) - const [closeOnBlur] = useCloseOnBlur(themesMenuRef, setThemesMenuOpen) - const toggleThemesMenu = useCallback(() => { - if (!themesMenuOpen && themesButtonRef.current) { - const themesButtonRect = themesButtonRef.current.getBoundingClientRect() - setThemesMenuPosition({ - left: themesButtonRect.right, - bottom: document.documentElement.clientHeight - themesButtonRect.bottom, - }) - setThemesMenuOpen(true) - } else { - setThemesMenuOpen(false) - } - }, [themesMenuOpen]) + setThemesMenuOpen((isOpen) => !isOpen) + }, []) const openPreferences = useCallback(() => { closeQuickSettingsMenu() - viewControllerManager.preferencesController.openPreferences() - }, [viewControllerManager, closeQuickSettingsMenu]) + preferencesController.openPreferences() + }, [closeQuickSettingsMenu, preferencesController]) const toggleComponent = useCallback( (component: SNComponent) => { @@ -193,10 +179,6 @@ const QuickSettingsMenu: FunctionComponent = ({ application, viewCont [closeQuickSettingsMenu, themesMenuOpen], ) - const handlePanelKeyDown: React.KeyboardEventHandler = useCallback((event) => { - themesMenuKeyDownHandler(event, themesMenuRef, setThemesMenuOpen, themesButtonRef) - }, []) - const toggleDefaultTheme = useCallback(() => { const activeTheme = themes.map((item) => item.component).find((theme) => theme?.active && !theme.isLayerable()) if (activeTheme) { @@ -205,90 +187,71 @@ const QuickSettingsMenu: FunctionComponent = ({ application, viewCont }, [application, themes]) return ( -
-
+
Quick Settings
+ - {themes.map((theme) => ( - - ))} - - - {toggleableComponents.map((component) => ( - - ))} - - +
+ + Themes +
+ + + +
Themes
-
+ {themes.map((theme) => ( + + ))} + + {toggleableComponents.map((component) => ( + + ))} + + +
) } diff --git a/packages/web/src/javascripts/Components/QuickSettingsMenu/ThemesMenuButton.tsx b/packages/web/src/javascripts/Components/QuickSettingsMenu/ThemesMenuButton.tsx index 5bf50c896..e7e4506f9 100644 --- a/packages/web/src/javascripts/Components/QuickSettingsMenu/ThemesMenuButton.tsx +++ b/packages/web/src/javascripts/Components/QuickSettingsMenu/ThemesMenuButton.tsx @@ -11,10 +11,9 @@ import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/Premium type Props = { item: ThemeItem application: WebApplication - onBlur: (event: { relatedTarget: EventTarget | null }) => void } -const ThemesMenuButton: FunctionComponent = ({ application, item, onBlur }) => { +const ThemesMenuButton: FunctionComponent = ({ application, item }) => { const premiumModal = usePremiumModal() const isThirdPartyTheme = useMemo( @@ -50,7 +49,6 @@ const ThemesMenuButton: FunctionComponent = ({ application, item, onBlur 'flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:bg-info-backdrop focus:shadow-none focus:shadow-none' } onClick={toggleTheme} - onBlur={onBlur} > {item.component?.isLayerable() ? ( <> diff --git a/packages/web/src/javascripts/Components/Tags/TagContextMenu.tsx b/packages/web/src/javascripts/Components/Tags/TagContextMenu.tsx index e6764e7e2..5775663cd 100644 --- a/packages/web/src/javascripts/Components/Tags/TagContextMenu.tsx +++ b/packages/web/src/javascripts/Components/Tags/TagContextMenu.tsx @@ -1,5 +1,5 @@ import { observer } from 'mobx-react-lite' -import { useCallback, useEffect, useRef, useMemo } from 'react' +import { useCallback, useRef, useMemo } from 'react' import Icon from '@/Components/Icon/Icon' import Menu from '@/Components/Menu/Menu' import MenuItem from '@/Components/Menu/MenuItem' @@ -11,6 +11,7 @@ import { NavigationController } from '@/Controllers/Navigation/NavigationControl import HorizontalSeparator from '../Shared/HorizontalSeparator' import { formatDateForContextMenu } from '@/Utils/DateUtils' import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon' +import Popover from '../Popover/Popover' type ContextMenuProps = { navigationController: NavigationController @@ -21,22 +22,11 @@ type ContextMenuProps = { const TagContextMenu = ({ navigationController, isEntitledToFolders, selectedTag }: ContextMenuProps) => { const premiumModal = usePremiumModal() - const { contextMenuOpen, contextMenuPosition, contextMenuMaxHeight } = navigationController + const { contextMenuOpen, contextMenuClickLocation } = navigationController const contextMenuRef = useRef(null) useCloseOnClickOutside(contextMenuRef, () => navigationController.setContextMenuOpen(false)) - const reloadContextMenuLayout = useCallback(() => { - navigationController.reloadContextMenuLayout() - }, [navigationController]) - - useEffect(() => { - window.addEventListener('resize', reloadContextMenuLayout) - return () => { - window.removeEventListener('resize', reloadContextMenuLayout) - } - }, [reloadContextMenuLayout]) - const onClickAddSubtag = useCallback(() => { if (!isEntitledToFolders) { premiumModal.activate('Folders') @@ -63,52 +53,46 @@ const TagContextMenu = ({ navigationController, isEntitledToFolders, selectedTag const tagCreatedAt = useMemo(() => formatDateForContextMenu(selectedTag.created_at), [selectedTag.created_at]) - return contextMenuOpen ? ( -
navigationController.setContextMenuOpen(!contextMenuOpen)} + className="py-2" > - { - navigationController.setContextMenuOpen(false) - }} - > - -
- - Add subtag +
+ + +
+ + Add subtag +
+ {!isEntitledToFolders && } +
+ + + Rename + + + + Delete + +
+ +
+
+ Last modified: {tagLastModified} +
+
+ Created: {tagCreatedAt} +
+
+ Tag ID: {selectedTag.uuid}
- {!isEntitledToFolders && } - - - - Rename - - - - Delete - -
- -
-
- Last modified: {tagLastModified} -
-
- Created: {tagCreatedAt} -
-
- Tag ID: {selectedTag.uuid}
-
- ) : null + + ) } export default observer(TagContextMenu) diff --git a/packages/web/src/javascripts/Hooks/useDocumentRect.ts b/packages/web/src/javascripts/Hooks/useDocumentRect.ts new file mode 100644 index 000000000..5ddffc1a5 --- /dev/null +++ b/packages/web/src/javascripts/Hooks/useDocumentRect.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react' + +const DebounceTimeInMs = 100 + +export const useDocumentRect = (): DOMRect => { + const [documentRect, setDocumentRect] = useState(document.documentElement.getBoundingClientRect()) + + useEffect(() => { + let debounceTimeout: number + + const handleWindowResize = () => { + window.clearTimeout(debounceTimeout) + + window.setTimeout(() => { + setDocumentRect(document.documentElement.getBoundingClientRect()) + }, DebounceTimeInMs) + } + + window.addEventListener('resize', handleWindowResize) + + return () => window.removeEventListener('resize', handleWindowResize) + }, []) + + return documentRect +} diff --git a/packages/web/src/javascripts/Hooks/useElementRect.ts b/packages/web/src/javascripts/Hooks/useElementRect.ts new file mode 100644 index 000000000..e17c88131 --- /dev/null +++ b/packages/web/src/javascripts/Hooks/useElementRect.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react' + +const DebounceTimeInMs = 100 + +type Options = { + updateOnWindowResize: boolean +} + +/** + * Returns the bounding rect of an element, auto-updated when the element resizes. + * Can optionally be auto-update on window resize. + */ +export const useAutoElementRect = ( + element: HTMLElement | null | undefined, + { updateOnWindowResize }: Options = { updateOnWindowResize: false }, +) => { + const [rect, setRect] = useState() + + useEffect(() => { + let windowResizeDebounceTimeout: number + let windowResizeHandler: () => void + + if (element) { + const resizeObserver = new ResizeObserver(() => { + setRect(element.getBoundingClientRect()) + }) + resizeObserver.observe(element) + + if (updateOnWindowResize) { + windowResizeHandler = () => { + window.clearTimeout(windowResizeDebounceTimeout) + + window.setTimeout(() => { + setRect(element.getBoundingClientRect()) + }, DebounceTimeInMs) + } + window.addEventListener('resize', windowResizeHandler) + } + + return () => { + resizeObserver.unobserve(element) + if (windowResizeHandler) { + window.removeEventListener('resize', windowResizeHandler) + } + } + } else { + setRect(undefined) + return + } + }, [element, updateOnWindowResize]) + + return rect +} diff --git a/packages/web/src/javascripts/Utils/CalculateSubmenuStyle.tsx b/packages/web/src/javascripts/Utils/CalculateSubmenuStyle.tsx deleted file mode 100644 index 8e2e4755f..000000000 --- a/packages/web/src/javascripts/Utils/CalculateSubmenuStyle.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { MAX_MENU_SIZE_MULTIPLIER, MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/Constants' - -export type SubmenuStyle = { - top?: number | 'auto' - right?: number | 'auto' - bottom: number | 'auto' - left?: number | 'auto' - visibility?: 'hidden' | 'visible' - maxHeight: number | 'auto' -} - -export const calculateSubmenuStyle = ( - button: HTMLButtonElement | null, - menu?: HTMLDivElement | HTMLMenuElement | null, -): SubmenuStyle | undefined => { - const defaultFontSize = window.getComputedStyle(document.documentElement).fontSize - const maxChangeEditorMenuSize = parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER - const { clientWidth, clientHeight } = document.documentElement - const buttonRect = button?.getBoundingClientRect() - const buttonParentRect = button?.parentElement?.getBoundingClientRect() - const menuBoundingRect = menu?.getBoundingClientRect() - const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect() - const footerHeightInPx = footerElementRect?.height ?? 0 - - let position: SubmenuStyle = { - bottom: 'auto', - maxHeight: 'auto', - } - - if (buttonRect && buttonParentRect) { - let positionBottom = clientHeight - buttonRect.bottom - buttonRect.height / 2 - - if (positionBottom < footerHeightInPx) { - positionBottom = footerHeightInPx + MENU_MARGIN_FROM_APP_BORDER - } - - position = { - bottom: positionBottom, - visibility: 'hidden', - maxHeight: 'auto', - } - - if (buttonRect.right + maxChangeEditorMenuSize > clientWidth) { - position.right = clientWidth - buttonRect.left - } else { - position.left = buttonRect.right - } - } - - if (menuBoundingRect?.height && buttonRect && position.bottom !== 'auto') { - position.visibility = 'visible' - - if (menuBoundingRect.y < MENU_MARGIN_FROM_APP_BORDER) { - position.bottom = position.bottom + menuBoundingRect.y - MENU_MARGIN_FROM_APP_BORDER * 2 - } - - if (footerElementRect && menuBoundingRect.height > footerElementRect.y) { - position.bottom = footerElementRect.height + MENU_MARGIN_FROM_APP_BORDER - position.maxHeight = clientHeight - footerElementRect.height - MENU_MARGIN_FROM_APP_BORDER * 2 - } - } - - return position -} diff --git a/packages/web/src/javascripts/Utils/index.ts b/packages/web/src/javascripts/Utils/index.ts index 31ddeac61..a28985cb0 100644 --- a/packages/web/src/javascripts/Utils/index.ts +++ b/packages/web/src/javascripts/Utils/index.ts @@ -1,4 +1,3 @@ -export * from './CalculateSubmenuStyle' export * from './ConcatenateUint8Arrays' export * from './IsMobile' export * from './StringUtils' diff --git a/packages/web/src/stylesheets/_main.scss b/packages/web/src/stylesheets/_main.scss index 5865f0a9c..ef139acc1 100644 --- a/packages/web/src/stylesheets/_main.scss +++ b/packages/web/src/stylesheets/_main.scss @@ -4,10 +4,10 @@ --z-index-resizer-overlay: 1000; --z-index-component-view: 1000; --z-index-panel-resizer: 1001; - --z-index-dropdown-menu: 1002; --z-index-footer-bar: 2000; --z-index-footer-bar-item: 2000; --z-index-footer-bar-item-panel: 2000; + --z-index-dropdown-menu: 2500; --z-index-preferences: 3000; --z-index-purchase-flow: 4000; --z-index-lock-screen: 10000;