Skip to content

Commit

Permalink
wip: dynamic section menu
Browse files Browse the repository at this point in the history
  • Loading branch information
Meierschlumpf committed Oct 4, 2024
1 parent b5e0848 commit 0aa760a
Show file tree
Hide file tree
Showing 6 changed files with 375 additions and 6 deletions.
36 changes: 30 additions & 6 deletions apps/nextjs/src/components/board/sections/dynamic/dynamic-menu.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();
Expand All @@ -30,17 +31,40 @@ 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"} />
</ActionIcon>
</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}>
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
);
Original file line number Diff line number Diff line change
@@ -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>
);
});
111 changes: 111 additions & 0 deletions apps/nextjs/src/components/board/sections/menu/section-menu-item.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
});
Original file line number Diff line number Diff line change
@@ -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>
);
});
Loading

0 comments on commit 0aa760a

Please sign in to comment.