wip: dynamic section menu
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user