From 0aa760a786ec336621ecec07e504b112d0a3dc9d Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Fri, 4 Oct 2024 22:47:29 +0200 Subject: [PATCH] wip: dynamic section menu --- .../board/sections/dynamic/dynamic-menu.tsx | 36 +++++- .../sections/menu/section-menu-context.tsx | 27 ++++ .../sections/menu/section-menu-dropdown.tsx | 48 +++++++ .../board/sections/menu/section-menu-item.tsx | 111 +++++++++++++++++ .../sections/menu/section-menu-target.tsx | 42 +++++++ .../board/sections/menu/section-menu.tsx | 117 ++++++++++++++++++ 6 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 apps/nextjs/src/components/board/sections/menu/section-menu-context.tsx create mode 100644 apps/nextjs/src/components/board/sections/menu/section-menu-dropdown.tsx create mode 100644 apps/nextjs/src/components/board/sections/menu/section-menu-item.tsx create mode 100644 apps/nextjs/src/components/board/sections/menu/section-menu-target.tsx create mode 100644 apps/nextjs/src/components/board/sections/menu/section-menu.tsx diff --git a/apps/nextjs/src/components/board/sections/dynamic/dynamic-menu.tsx b/apps/nextjs/src/components/board/sections/dynamic/dynamic-menu.tsx index 5adc47959..609500d9e 100644 --- a/apps/nextjs/src/components/board/sections/dynamic/dynamic-menu.tsx +++ b/apps/nextjs/src/components/board/sections/dynamic/dynamic-menu.tsx @@ -1,5 +1,5 @@ import type { PropsWithChildren } from "react"; -import { Fragment, useEffect } from "react"; +import { Fragment, useEffect, useRef } from "react"; import { ActionIcon, Group, Indicator, Menu, Popover, Stack, UnstyledButton } from "@mantine/core"; import { useHover } from "@mantine/hooks"; import { IconDotsVertical, IconTrash } from "@tabler/icons-react"; @@ -13,6 +13,7 @@ import { useDynamicSectionActions } from "./dynamic-actions"; import { useAboveDynamicSectionIds } from "./dynamic-context"; export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection }) => { + const dropdownRef = useRef(null); const [isEditMode] = useEditMode(); const aboveIds = useAboveDynamicSectionIds(); const board = useRequiredBoard(); @@ -30,9 +31,20 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection } if (aboveIds.length >= 1 && !hasItemInTopRightCorner) { return ( - - - + + + { + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault(); + document.querySelectorAll("[data-section-item]")[0]?.focus(); + } + }} + > @@ -40,7 +52,19 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection } - + { + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault(); + document.querySelectorAll("[data-section-item]")[0]?.focus(); + } + }} + > +
{[...aboveIds, section.id].map((id, index) => ( @@ -90,7 +114,7 @@ const DesktopButton = ({ index, id }: DesktopButtonProps) => { return ( - + Section {index + 1} diff --git a/apps/nextjs/src/components/board/sections/menu/section-menu-context.tsx b/apps/nextjs/src/components/board/sections/menu/section-menu-context.tsx new file mode 100644 index 000000000..037a7d4ee --- /dev/null +++ b/apps/nextjs/src/components/board/sections/menu/section-menu-context.tsx @@ -0,0 +1,27 @@ +import type { GetStylesApi } from "@mantine/core"; +import { createSafeContext } from "@mantine/core"; + +import type { SectionMenuFactory } from "./section-menu"; + +interface SectionMenuContext { + toggleDropdown: () => void; + closeDropdownImmediately: () => void; + closeDropdown: () => void; + openDropdown: () => void; + getItemIndex: (node: HTMLButtonElement) => number | null; + setHovered: (index: number | null) => void; + hovered: number | null; + closeOnItemClick: boolean | undefined; + loop: boolean | undefined; + trigger: "click" | "hover" | "click-hover" | undefined; + opened: boolean; + unstyled: boolean | undefined; + getStyles: GetStylesApi; + menuItemTabIndex: -1 | 0 | undefined; + openedViaClick: boolean; + setOpenedViaClick: (value: boolean) => void; +} + +export const [SectionMenuContextProvider, useSectionMenuContext] = createSafeContext( + "SectionMenu component was not found in the tree", +); diff --git a/apps/nextjs/src/components/board/sections/menu/section-menu-dropdown.tsx b/apps/nextjs/src/components/board/sections/menu/section-menu-dropdown.tsx new file mode 100644 index 000000000..d02ab7ae7 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/menu/section-menu-dropdown.tsx @@ -0,0 +1,48 @@ +import { useRef } from "react"; +import type { Factory } from "@mantine/core"; +import { factory, Popover } from "@mantine/core"; +import { useMergedRef } from "@mantine/hooks"; + +import { useSectionMenuContext } from "./section-menu-context"; + +export type SectionMenuDropdownStylesNames = "dropdown"; + +export interface SectionMenuDropdownProps { + childOpened: boolean; + children: React.ReactNode; +} + +export type SectionMenuDropdownFactory = Factory<{ + props: SectionMenuDropdownProps; + ref: HTMLDivElement; + stylesNames: SectionMenuDropdownStylesNames; + compound: true; +}>; + +export const SectionMenuDropdown = factory(({ childOpened, children }, ref) => { + const wrapperRef = useRef(null); + const ctx = useSectionMenuContext(); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (childOpened) return; + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault(); + wrapperRef.current?.querySelectorAll("[data-section-menu-item]:not(:disabled)")[0]?.focus(); + } + }; + + return ( + +
+ {children} + + ); +}); diff --git a/apps/nextjs/src/components/board/sections/menu/section-menu-item.tsx b/apps/nextjs/src/components/board/sections/menu/section-menu-item.tsx new file mode 100644 index 000000000..e9461849b --- /dev/null +++ b/apps/nextjs/src/components/board/sections/menu/section-menu-item.tsx @@ -0,0 +1,111 @@ +import { KeyboardEvent, useRef } from 'react'; +import { + BoxProps, + CompoundStylesApiProps, + createEventHandler, + createScopedKeydownHandler, + polymorphicFactory, + PolymorphicFactory, + UnstyledButton, + useDirection, + useProps, +} from '@mantine/core'; +import { useMergedRef } from '@mantine/hooks'; +import { useSectionMenuContext } from './section-menu-context'; + +export type SectionMenuItemStylesNames = 'item' | 'itemLabel' | 'itemSection'; + +export interface SectionMenuItemProps + extends BoxProps, + CompoundStylesApiProps { + /** Item label */ + children?: React.ReactNode; + childMenu: { + opened: boolean; + close: () => void; + open: () => void; + }; +} + +export type SectionMenuItemFactory = PolymorphicFactory<{ + props: SectionMenuItemProps; + defaultRef: HTMLButtonElement; + defaultComponent: 'button'; + stylesNames: SectionMenuItemStylesNames; + compound: true; +}>; + +const defaultProps: Partial = {}; + +export const SectionMenuItem = polymorphicFactory((props, ref) => { + const { childMenu, classNames, className, style, styles, vars, children, ...others } = useProps( + 'SectionMenuItem', + defaultProps, + props + ); + + const ctx = useSectionMenuContext(); + const { dir } = useDirection(); + const itemRef = useRef(); + const itemIndex = ctx.getItemIndex(itemRef.current!); + const _others: any = others; + + const handleMouseLeave = createEventHandler(_others.onMouseLeave, () => ctx.setHovered(-1)); + const handleMouseEnter = createEventHandler(_others.onMouseEnter, () => + ctx.setHovered(ctx.getItemIndex(itemRef.current!)) + ); + + const handleFocus = createEventHandler(_others.onFocus, () => + ctx.setHovered(ctx.getItemIndex(itemRef.current!)) + ); + + const scopedKeydownHandler = createScopedKeydownHandler({ + siblingSelector: '[data-section-menu-item]', + parentSelector: '[data-section-menu-dropdown]', + activateOnFocus: false, + loop: ctx.loop, + dir, + orientation: 'vertical', + onKeyDown: _others.onKeyDown, + }); + + const handleKeyDown = (event: KeyboardEvent) => { + console.log('hi'); + if ( + (event.key === 'ArrowRight' && dir === 'ltr') || + (event.key === 'ArrowLeft' && dir === 'rtl') + ) { + childMenu.open(); + return; + } + + // We don't want to handle keydown event if child menu is opened + if (childMenu.opened) { + console.log('hey'); + return; + } + + console.log('hallo'); + scopedKeydownHandler(event); + }; + + return ( + + {children} + + ); +}); diff --git a/apps/nextjs/src/components/board/sections/menu/section-menu-target.tsx b/apps/nextjs/src/components/board/sections/menu/section-menu-target.tsx new file mode 100644 index 000000000..7ae4aae3f --- /dev/null +++ b/apps/nextjs/src/components/board/sections/menu/section-menu-target.tsx @@ -0,0 +1,42 @@ +import { cloneElement, forwardRef } from "react"; +import { createEventHandler, isElement, Popover, useProps } from "@mantine/core"; + +import { useSectionMenuContext } from "./section-menu-context"; + +export interface SectionMenuTargetProps { + /** Target element */ + children: React.ReactNode; + + /** Key of the prop that should be used to get element ref */ + refProp?: string; +} + +const defaultProps: Partial = { + refProp: "ref", +}; + +export const SectionMenuTarget = forwardRef((props, ref) => { + const { children, refProp, ...others } = useProps("SectionMenuTarget", defaultProps, props); + + if (!isElement(children)) { + throw new Error( + "SectionMenu.Target component children should be an element or a component that accepts ref. Fragments, strings, numbers and other primitive values are not supported", + ); + } + + const ctx = useSectionMenuContext(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access + const onClick = createEventHandler(children.props.onClick, () => { + ctx.toggleDropdown(); + }); + + return ( + + {cloneElement(children, { + onClick, + "data-expanded": ctx.opened ? true : undefined, + })} + + ); +}); diff --git a/apps/nextjs/src/components/board/sections/menu/section-menu.tsx b/apps/nextjs/src/components/board/sections/menu/section-menu.tsx new file mode 100644 index 000000000..9327890b5 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/menu/section-menu.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import type { Factory, PopoverStylesNames } from "@mantine/core"; +import { getContextItemIndex, Popover, useHovered, useStyles } from "@mantine/core"; +import { useDidUpdate, useUncontrolled } from "@mantine/hooks"; + +import { SectionMenuContextProvider } from "./section-menu-context"; +import classes from "./SectionMenu.module.css"; + +export type SectionMenuStylesNames = "item" | "itemLabel" | "itemSection" | "label" | "divider" | PopoverStylesNames; + +export type SectionMenuFactory = Factory<{ + props: SectionMenuProps; + ref: HTMLDivElement; + stylesNames: SectionMenuStylesNames; +}>; + +export interface SectionMenuProps { + /** Menu content */ + children?: React.ReactNode; + + /** Controlled menu opened state */ + opened?: boolean; + + /** Called when Menu is opened */ + onOpen?: () => void; + + /** Called when Menu is closed */ + onClose?: () => void; +} + +export function SectionMenu(props: SectionMenuProps) { + const { children, onOpen, onClose, opened, ...others } = props; + + const getStyles = useStyles({ + name: "SectionMenu", + classes, + props, + classNames: [], + styles: {}, + unstyled: false, + }); + + const [hovered, { setHovered, resetHovered }] = useHovered(); + const [_opened, setOpened] = useUncontrolled({ + value: opened, + defaultValue: false, + finalValue: false, + onChange: () => undefined, + }); + const [openedViaClick, setOpenedViaClick] = useState(false); + + const close = () => { + setOpened(false); + setOpenedViaClick(false); + if (_opened) { + onClose?.(); + } + }; + + const open = () => { + setOpened(true); + if (!_opened) { + onOpen?.(); + } + }; + + const toggleDropdown = () => { + if (_opened) { + close(); + } else { + open(); + } + }; + + const getItemIndex = (node: HTMLButtonElement) => + getContextItemIndex("[data-section-menu-item]", "[data-section-menu-dropdown]", node); + + useDidUpdate(() => { + resetHovered(); + }, [_opened]); + + return ( + + + {children} + + + ); +}