Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2ddb676fe | ||
|
|
fe99421ec9 | ||
|
|
0aa760a786 | ||
|
|
b5e0848436 | ||
|
|
0386449ec1 |
No files matched your search
@@ -2,6 +2,7 @@ import { Box, Card } from "@mantine/core";
|
|||||||
|
|
||||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||||
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||||
|
import { DynamicSectionProvider } from "./dynamic/dynamic-context";
|
||||||
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
|
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
|
||||||
import { GridStack } from "./gridstack/gridstack";
|
import { GridStack } from "./gridstack/gridstack";
|
||||||
import classes from "./item.module.css";
|
import classes from "./item.module.css";
|
||||||
@@ -12,6 +13,7 @@ interface Props {
|
|||||||
|
|
||||||
export const BoardDynamicSection = ({ section }: Props) => {
|
export const BoardDynamicSection = ({ section }: Props) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="grid-stack-item-content">
|
<Box className="grid-stack-item-content">
|
||||||
<Card
|
<Card
|
||||||
@@ -27,7 +29,9 @@ export const BoardDynamicSection = ({ section }: Props) => {
|
|||||||
}}
|
}}
|
||||||
p={0}
|
p={0}
|
||||||
>
|
>
|
||||||
|
<DynamicSectionProvider section={section}>
|
||||||
<GridStack section={section} className="min-row" />
|
<GridStack section={section} className="min-row" />
|
||||||
|
</DynamicSectionProvider>
|
||||||
</Card>
|
</Card>
|
||||||
<BoardDynamicSectionMenu section={section} />
|
<BoardDynamicSectionMenu section={section} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||||
|
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||||
|
|
||||||
|
interface DynamicSectionContextProps {
|
||||||
|
sectionIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DynamicSectionContext = createContext<DynamicSectionContextProps>({
|
||||||
|
sectionIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
interface DynamicSectionProviderProps {
|
||||||
|
section: DynamicSection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DynamicSectionProvider = ({ section, children }: PropsWithChildren<DynamicSectionProviderProps>) => {
|
||||||
|
const aboveIds = useAboveDynamicSectionIds();
|
||||||
|
const resetPrevious = usePlacedTopRightAndInDynamicSection({ sectionOrItem: section, previousId: aboveIds.at(-1) });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DynamicSectionContext.Provider value={{ sectionIds: resetPrevious ? [section.id] : [...aboveIds, section.id] }}>
|
||||||
|
{children}
|
||||||
|
</DynamicSectionContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAboveDynamicSectionIds = () => {
|
||||||
|
return useContext(DynamicSectionContext).sectionIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UsePlacedTopRightAndInDynamicSectionProps {
|
||||||
|
sectionOrItem: { width: number; xOffset: number; yOffset: number };
|
||||||
|
previousId: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePlacedTopRightAndInDynamicSection = ({
|
||||||
|
sectionOrItem,
|
||||||
|
previousId,
|
||||||
|
}: UsePlacedTopRightAndInDynamicSectionProps) => {
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
const aboveSection = board.sections.find(
|
||||||
|
(section): section is DynamicSection => section.id === previousId && section.kind === "dynamic",
|
||||||
|
);
|
||||||
|
|
||||||
|
// When not on top, no above section or not reaching the end of the above section, we want to reset the previous sections
|
||||||
|
return (
|
||||||
|
sectionOrItem.yOffset >= 1 || !aboveSection || sectionOrItem.xOffset + sectionOrItem.width !== aboveSection.width
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,39 +1,144 @@
|
|||||||
import { ActionIcon, Menu } from "@mantine/core";
|
import type { PropsWithChildren } 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";
|
import { IconDotsVertical, IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||||
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
|
import { useEditMode, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||||
import { useDynamicSectionActions } from "./dynamic-actions";
|
import { useDynamicSectionActions } from "./dynamic-actions";
|
||||||
|
import { useAboveDynamicSectionIds } from "./dynamic-context";
|
||||||
|
|
||||||
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection }) => {
|
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection }) => {
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isEditMode] = useEditMode();
|
||||||
|
const aboveIds = useAboveDynamicSectionIds();
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
const hasItemInTopRightCorner =
|
||||||
|
section.items.some((item) => item.xOffset + item.width === section.width && item.yOffset === 0) ||
|
||||||
|
board.sections.some(
|
||||||
|
(innerSection) =>
|
||||||
|
innerSection.kind === "dynamic" &&
|
||||||
|
innerSection.parentSectionId === section.id &&
|
||||||
|
innerSection.xOffset + innerSection.width == section.width &&
|
||||||
|
innerSection.yOffset == 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isEditMode) return null;
|
||||||
|
|
||||||
|
if (aboveIds.length >= 1 && !hasItemInTopRightCorner) {
|
||||||
|
return (
|
||||||
|
<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
|
||||||
|
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}>
|
||||||
|
<Group hiddenFrom="md"></Group>
|
||||||
|
<DesktopButton id={id} index={index} />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aboveIds.length >= 1 && !hasItemInTopRightCorner) {
|
||||||
|
return (
|
||||||
|
<InnerMenu id={section.id}>
|
||||||
|
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={4} right={4} style={{ zIndex: 10 }}>
|
||||||
|
<IconDotsVertical size={"1rem"} />
|
||||||
|
</ActionIcon>
|
||||||
|
</InnerMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DesktopButtonProps {
|
||||||
|
index: number;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DesktopButton = ({ index, id }: DesktopButtonProps) => {
|
||||||
|
const { hovered, ref } = useHover<HTMLButtonElement>();
|
||||||
|
useEffect(() => {
|
||||||
|
const sectionRef = document.querySelector<HTMLDivElement>(`[data-section-id="${id}"]`);
|
||||||
|
if (!sectionRef) return;
|
||||||
|
|
||||||
|
if (hovered) {
|
||||||
|
const previousBackground = sectionRef.style.backgroundColor;
|
||||||
|
sectionRef.style.backgroundColor = "rgba(255, 0, 0, 0.1)";
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sectionRef.style.backgroundColor = previousBackground;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [hovered]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InnerMenu id={id} trigger="hover">
|
||||||
|
<UnstyledButton data-section-item visibleFrom="md" ref={ref}>
|
||||||
|
Section {index + 1}
|
||||||
|
</UnstyledButton>
|
||||||
|
</InnerMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const InnerMenu = ({ children, id, trigger }: PropsWithChildren<{ id: string; trigger?: "hover" }>) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tDynamic = useScopedI18n("section.dynamic");
|
const tDynamic = useScopedI18n("section.dynamic");
|
||||||
const { removeDynamicSection } = useDynamicSectionActions();
|
const { removeDynamicSection } = useDynamicSectionActions();
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
const [isEditMode] = useEditMode();
|
|
||||||
|
|
||||||
if (!isEditMode) return null;
|
|
||||||
|
|
||||||
const openRemoveModal = () => {
|
const openRemoveModal = () => {
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
title: tDynamic("remove.title"),
|
title: tDynamic("remove.title"),
|
||||||
children: tDynamic("remove.message"),
|
children: tDynamic("remove.message"),
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
removeDynamicSection({ id: section.id });
|
removeDynamicSection({ id });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
<Menu withinPortal withArrow position="right-start" arrowPosition="center" trigger={trigger}>
|
||||||
<Menu.Target>
|
<Menu.Target>{children}</Menu.Target>
|
||||||
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={4} right={4} style={{ zIndex: 10 }}>
|
|
||||||
<IconDotsVertical size={"1rem"} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown miw={128}>
|
<Menu.Dropdown miw={128}>
|
||||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||||
|
|||||||
@@ -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