wip: dynamic section menu

This commit is contained in:
Meier Lukas committed 2024-10-04 22:47:29 +02:00
1 parent b5e0848436
commit 0aa760a786
6 files changed
+375 -6

No files matched your search

@@ -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<HTMLDivElement>(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 (
<Popover width="auto" position="right" withArrow shadow="md">
<Popover.Target>
<UnstyledButton pos="absolute" top={4} right={4} style={{ zIndex: 10 }}>
<Popover width="auto" position="right" withArrow shadow="md" returnFocus>
<Popover.Target popupType="menu">
<UnstyledButton
pos="absolute"
top={4}
right={4}
style={{ zIndex: 10 }}
onKeyDown={(event) => {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
document.querySelectorAll<HTMLButtonElement>("[data-section-item]")[0]?.focus();
}
}}
>
<Indicator label={aboveIds.length + 1} styles={{ indicator: { height: "1rem" } }} offset={4}>
<ActionIcon component="div" variant="default" radius={"xl"}>
<IconDotsVertical size={"1rem"} />
@@ -40,7 +52,19 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection }
</Indicator>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<Popover.Dropdown
ref={dropdownRef}
role="menu"
aria-orientation="vertical"
tabIndex={-1}
onKeyDown={(event) => {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
document.querySelectorAll<HTMLButtonElement>("[data-section-item]")[0]?.focus();
}
}}
>
<div tabIndex={-1} data-autofocus data-mantine-stop-propagation style={{ outline: 0 }} />
<Stack>
{[...aboveIds, section.id].map((id, index) => (
<Fragment key={id}>
@@ -90,7 +114,7 @@ const DesktopButton = ({ index, id }: DesktopButtonProps) => {
return (
<InnerMenu id={id} trigger="hover">
<UnstyledButton visibleFrom="md" ref={ref}>
<UnstyledButton data-section-item visibleFrom="md" ref={ref}>
Section {index + 1}
</UnstyledButton>
</InnerMenu>
@@ -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<SectionMenuFactory>;
menuItemTabIndex: -1 | 0 | undefined;
openedViaClick: boolean;
setOpenedViaClick: (value: boolean) => void;
}
export const [SectionMenuContextProvider, useSectionMenuContext] = createSafeContext<SectionMenuContext>(
"SectionMenu component was not found in the tree",
);
@@ -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<SectionMenuDropdownFactory>(({ childOpened, children }, ref) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const ctx = useSectionMenuContext();
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (childOpened) return;
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
wrapperRef.current?.querySelectorAll<HTMLButtonElement>("[data-section-menu-item]:not(:disabled)")[0]?.focus();
}
};
return (
<Popover.Dropdown
role="menu"
aria-orientation="vertical"
ref={useMergedRef(ref, wrapperRef)}
{...ctx.getStyles("dropdown")}
tabIndex={-1}
data-section-menu-dropdown
onKeyDown={handleKeyDown}
>
<div tabIndex={-1} data-autofocus data-mantine-stop-propagation style={{ outline: 0 }} />
{children}
</Popover.Dropdown>
);
});
@@ -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<SectionMenuItemFactory> {
/** 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<SectionMenuItemProps> = {};
export const SectionMenuItem = polymorphicFactory<SectionMenuItemFactory>((props, ref) => {
const { childMenu, classNames, className, style, styles, vars, children, ...others } = useProps(
'SectionMenuItem',
defaultProps,
props
);
const ctx = useSectionMenuContext();
const { dir } = useDirection();
const itemRef = useRef<HTMLButtonElement>();
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<HTMLButtonElement>) => {
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 (
<UnstyledButton
{...others}
unstyled={ctx.unstyled}
tabIndex={ctx.menuItemTabIndex}
onFocus={handleFocus}
{...ctx.getStyles('item', { className, style, styles, classNames })}
ref={useMergedRef(itemRef, ref)}
role="menuitem"
data-section-menu-item
data-hovered={ctx.hovered === itemIndex ? true : undefined}
data-mantine-stop-propagation
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onKeyDown={handleKeyDown}
>
{children}
</UnstyledButton>
);
});
@@ -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<SectionMenuTargetProps> = {
refProp: "ref",
};
export const SectionMenuTarget = forwardRef<HTMLElement, SectionMenuTargetProps>((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 (
<Popover.Target refProp={refProp} popupType="menu" ref={ref} {...others}>
{cloneElement(children, {
onClick,
"data-expanded": ctx.opened ? true : undefined,
})}
</Popover.Target>
);
});
@@ -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<SectionMenuFactory>({
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 (
<SectionMenuContextProvider
value={{
getStyles,
opened: _opened,
toggleDropdown,
getItemIndex,
hovered,
setHovered,
openedViaClick,
setOpenedViaClick,
closeOnItemClick: false,
closeDropdown: close,
openDropdown: open,
closeDropdownImmediately: close,
loop: true,
trigger: "click",
unstyled: false,
menuItemTabIndex: -1,
}}
>
<Popover
{...others}
opened={_opened}
onChange={toggleDropdown}
__staticSelector="SectionMenu"
clickOutsideEvents={["mousedown", "touchstart", "keydown"]}
withArrow
withinPortal={false}
returnFocus
>
{children}
</Popover>
</SectionMenuContextProvider>
);
}