diff --git a/assets/js/dashboard/nav-menu/filter-menu.tsx b/assets/js/dashboard/nav-menu/filter-menu.tsx index cfffb730419b..2ed6381d8501 100644 --- a/assets/js/dashboard/nav-menu/filter-menu.tsx +++ b/assets/js/dashboard/nav-menu/filter-menu.tsx @@ -1,18 +1,9 @@ -import React, { - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState -} from 'react' +import React, { useMemo, useRef } from 'react' import { formattedFilters } from '../util/filters' import { useSiteContext } from '../site-context' import { filterRoute } from '../router' import { FilterIcon } from '../components/icons' import { Popover, Transition } from '@headlessui/react' -import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid' -import { usePopper } from 'react-popper' import { popover, BlurMenuButtonOnEscape } from '../components/popover' import classNames from 'classnames' import { AppNavigationLink } from '../navigation/use-app-navigate' @@ -28,120 +19,16 @@ import { SEGMENTS_ROW } from './filter-menu-rows' import { MenuSeparator } from './nav-menu-components' - -const iconClassName = 'size-4 shrink-0' - -const SUBMENU_CLOSE_DELAY_MS = 150 - -const isMdOrWider = () => window.matchMedia('(min-width: 768px)').matches - -/** - * `besideMenu` is measured when the submenu opens. If wide enough, the submenu - * sits beside the menu; on small screens it replaces the menu. - */ -type OpenSubmenu = { - key: FilterGroupKey - anchor: HTMLElement - besideMenu: boolean -} - -const FilterMenuItems = ({ - open, - closeDropdown -}: { - open: boolean - closeDropdown: () => void -}) => { - const site = useSiteContext() - const segments = useListableSegments() +import { + SubmenuInPlace, + SubmenuPanel, + SubmenuRow, + submenuIconClassName as iconClassName, + useSubmenu +} from './submenu' + +const FilterMenuItems = ({ closeDropdown }: { closeDropdown: () => void }) => { const buttonRef = useRef(null) - const submenuId = useId() - - const rows = useMemo(() => { - const filterRows = getFilterMenuRows(site) - return segments.visible ? [SEGMENTS_ROW, ...filterRows] : filterRows - }, [site, segments.visible]) - - const [openSubmenu, setOpenSubmenu] = useState(null) - const [submenuElement, setSubmenuElement] = useState( - null - ) - const openRow = useMemo( - () => - rows.find( - (row): row is FilterSubmenuRow => - row.kind !== 'item' && row.key === openSubmenu?.key - ) ?? null, - [rows, openSubmenu] - ) - const besideMenu = !!openSubmenu?.besideMenu - - const closeTimeout = useRef>() - const cancelScheduledClose = useCallback(() => { - if (closeTimeout.current) { - clearTimeout(closeTimeout.current) - closeTimeout.current = undefined - } - }, []) - const scheduleClose = useCallback(() => { - cancelScheduledClose() - closeTimeout.current = setTimeout( - () => setOpenSubmenu(null), - SUBMENU_CLOSE_DELAY_MS - ) - }, [cancelScheduledClose]) - useEffect(() => cancelScheduledClose, [cancelScheduledClose]) - - useEffect(() => { - if (!open) { - setOpenSubmenu(null) - } - }, [open]) - - const openWith = useCallback( - (key: FilterGroupKey, anchor: HTMLElement) => { - cancelScheduledClose() - setOpenSubmenu({ key, anchor, besideMenu: isMdOrWider() }) - }, - [cancelScheduledClose] - ) - - const closeSubmenuAndRefocus = useCallback(() => { - cancelScheduledClose() - openSubmenu?.anchor.focus() - setOpenSubmenu(null) - }, [cancelScheduledClose, openSubmenu]) - - /** - * The submenu opens on the right side by default, and flips to the - * left when the viewport doesn't have enough room. - */ - const { styles, attributes } = usePopper( - openSubmenu?.anchor ?? null, - submenuElement, - { - placement: 'right-start', - modifiers: [ - { name: 'offset', options: { offset: [-4, 0] } }, - { name: 'flip', options: { fallbackPlacements: ['left-start'] } }, - { name: 'preventOverflow', options: { padding: 8 } } - ] - } - ) - - const handleEscape = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === 'Escape' && openSubmenu) { - closeSubmenuAndRefocus() - event.stopPropagation() - } - }, - [closeSubmenuAndRefocus, openSubmenu] - ) - - const submenuBody = openRow && ( - - ) return ( <> @@ -175,76 +62,89 @@ const FilterMenuItems = ({ )} data-testid="filtermenu" > - {openRow && !besideMenu ? ( -
- - -
- {submenuBody} -
-
- ) : ( -
- {rows.map((row) => ( - - {row.kind === 'item' ? ( - - ) : ( - <> - - {openRow?.key === row.key && ( -
- {submenuBody} -
- )} - - )} - {row.kind === 'segments' && } -
- ))} -
- )} + ) } +const FilterMenuPanelContent = ({ + closeDropdown +}: { + closeDropdown: () => void +}) => { + const site = useSiteContext() + const segments = useListableSegments() + const submenu = useSubmenu() + + const rows = useMemo(() => { + const filterRows = getFilterMenuRows(site) + return segments.visible ? [SEGMENTS_ROW, ...filterRows] : filterRows + }, [site, segments.visible]) + + const openRow = useMemo( + () => + rows.find( + (row): row is FilterSubmenuRow => + row.kind !== 'item' && row.key === submenu.openKey + ) ?? null, + [rows, submenu.openKey] + ) + + const submenuBody = openRow && ( + + ) + + if (openRow && !submenu.besideMenu) { + return ( + + {submenuBody} + + ) + } + + return ( +
+ {rows.map((row) => ( + + {row.kind === 'item' ? ( + + ) : ( + <> + submenu.openWith(row.key, anchor)} + /> + {openRow?.key === row.key && ( + + {submenuBody} + + )} + + )} + {row.kind === 'segments' && } + + ))} +
+ ) +} + const ItemRow = ({ row, onPointerOrFocus, @@ -268,52 +168,6 @@ const ItemRow = ({ ) -const SubmenuRow = ({ - row, - expanded, - submenuId, - onOpen -}: { - row: FilterSubmenuRow - expanded: boolean - submenuId: string - onOpen: (key: FilterGroupKey, anchor: HTMLElement) => void -}) => { - const handleOpen = useCallback( - (event: React.SyntheticEvent) => - onOpen(row.key, event.currentTarget), - [onOpen, row.key] - ) - - const handleHoverOrFocus = useCallback( - (event: React.SyntheticEvent) => { - if (isMdOrWider()) { - handleOpen(event) - } - }, - [handleOpen] - ) - - return ( - - ) -} - const SubmenuBody = ({ row, closeDropdown @@ -346,6 +200,6 @@ const SubmenuBody = ({ export const FilterMenu = () => ( - {({ open, close }) => } + {({ close }) => } ) diff --git a/assets/js/dashboard/nav-menu/submenu.tsx b/assets/js/dashboard/nav-menu/submenu.tsx new file mode 100644 index 000000000000..a113430b6f22 --- /dev/null +++ b/assets/js/dashboard/nav-menu/submenu.tsx @@ -0,0 +1,220 @@ +import React, { + ComponentType, + ReactNode, + useCallback, + useEffect, + useId, + useRef, + useState +} from 'react' +import classNames from 'classnames' +import { usePopper } from 'react-popper' +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid' +import { popover } from '../components/popover' +import { MenuSeparator } from './nav-menu-components' + +export const submenuIconClassName = 'size-4 shrink-0' + +const SUBMENU_CLOSE_DELAY_MS = 150 + +const isMdOrWider = () => window.matchMedia('(min-width: 768px)').matches + +/** + * `besideMenu` is measured when the submenu opens. If wide enough, the submenu + * sits beside the menu; on small screens it replaces the menu. + */ +type OpenSubmenu = { + key: K + anchor: HTMLElement + besideMenu: boolean +} + +export function useSubmenu() { + const submenuId = useId() + const [openSubmenu, setOpenSubmenu] = useState | null>(null) + const [submenuElement, setSubmenuElement] = useState( + null + ) + + const closeTimeout = useRef>() + const cancelScheduledClose = useCallback(() => { + if (closeTimeout.current) { + clearTimeout(closeTimeout.current) + closeTimeout.current = undefined + } + }, []) + const scheduleClose = useCallback(() => { + cancelScheduledClose() + closeTimeout.current = setTimeout( + () => setOpenSubmenu(null), + SUBMENU_CLOSE_DELAY_MS + ) + }, [cancelScheduledClose]) + useEffect(() => cancelScheduledClose, [cancelScheduledClose]) + + const openWith = useCallback( + (key: K, anchor: HTMLElement) => { + cancelScheduledClose() + setOpenSubmenu({ key, anchor, besideMenu: isMdOrWider() }) + }, + [cancelScheduledClose] + ) + + const closeSubmenu = useCallback(() => setOpenSubmenu(null), []) + + const closeSubmenuAndRefocus = useCallback(() => { + cancelScheduledClose() + openSubmenu?.anchor.focus() + setOpenSubmenu(null) + }, [cancelScheduledClose, openSubmenu]) + + /** + * The submenu opens on the right side by default, and flips to the + * left when the viewport doesn't have enough room. + */ + const { styles, attributes } = usePopper( + openSubmenu?.anchor ?? null, + submenuElement, + { + placement: 'right-start', + modifiers: [ + { name: 'offset', options: { offset: [-4, 0] } }, + { name: 'flip', options: { fallbackPlacements: ['left-start'] } }, + { name: 'preventOverflow', options: { padding: 8 } } + ] + } + ) + + const handleEscape = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Escape' && openSubmenu) { + closeSubmenuAndRefocus() + event.stopPropagation() + } + }, + [closeSubmenuAndRefocus, openSubmenu] + ) + + return { + submenuId, + openKey: openSubmenu?.key ?? null, + besideMenu: !!openSubmenu?.besideMenu, + openWith, + closeSubmenu, + scheduleClose, + handleEscape, + panelProps: { + ref: setSubmenuElement, + style: styles.popper, + ...attributes.popper, + id: submenuId, + onMouseEnter: cancelScheduledClose + } + } +} + +type Submenu = ReturnType + +export const SubmenuRow = ({ + label, + Icon, + expanded, + submenuId, + onOpen +}: { + label: string + Icon: ComponentType<{ className?: string }> + expanded: boolean + submenuId: string + onOpen: (anchor: HTMLElement) => void +}) => { + const handleOpen = useCallback( + (event: React.SyntheticEvent) => + onOpen(event.currentTarget), + [onOpen] + ) + + const handleHoverOrFocus = useCallback( + (event: React.SyntheticEvent) => { + if (isMdOrWider()) { + handleOpen(event) + } + }, + [handleOpen] + ) + + return ( + + ) +} + +/** The submenu that sits beside the menu. */ +export const SubmenuPanel = ({ + submenu, + label, + testId, + children +}: { + submenu: Pick + label: string + testId?: string + children: ReactNode +}) => ( +
+ {children} +
+) + +/** The submenu that replaces the menu on small screens. */ +export const SubmenuInPlace = ({ + submenu, + label, + testId, + children +}: { + submenu: Pick + label: string + testId?: string + children: ReactNode +}) => ( +
+ + +
+ {children} +
+
+)