Compare commits

...
8 changed files with 522 additions and 14 deletions

No files matched your search

@@ -2,6 +2,7 @@ import { Box, Card } from "@mantine/core";
import type { DynamicSection } from "~/app/[locale]/boards/_types";
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
import { DynamicSectionProvider } from "./dynamic/dynamic-context";
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
import { GridStack } from "./gridstack/gridstack";
import classes from "./item.module.css";
@@ -12,6 +13,7 @@ interface Props {
export const BoardDynamicSection = ({ section }: Props) => {
const board = useRequiredBoard();
return (
<Box className="grid-stack-item-content">
<Card
@@ -27,7 +29,9 @@ export const BoardDynamicSection = ({ section }: Props) => {
}}
p={0}
>
<GridStack section={section} className="min-row" />
<DynamicSectionProvider section={section}>
<GridStack section={section} className="min-row" />
</DynamicSectionProvider>
</Card>
<BoardDynamicSectionMenu section={section} />
</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 { useConfirmModal } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
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 { useAboveDynamicSectionIds } from "./dynamic-context";
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 tDynamic = useScopedI18n("section.dynamic");
const { removeDynamicSection } = useDynamicSectionActions();
const { openConfirmModal } = useConfirmModal();
const [isEditMode] = useEditMode();
if (!isEditMode) return null;
const openRemoveModal = () => {
openConfirmModal({
title: tDynamic("remove.title"),
children: tDynamic("remove.message"),
onConfirm: () => {
removeDynamicSection({ id: section.id });
removeDynamicSection({ id });
},
});
};
return (
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
<Menu.Target>
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={4} right={4} style={{ zIndex: 10 }}>
<IconDotsVertical size={"1rem"} />
</ActionIcon>
</Menu.Target>
<Menu withinPortal withArrow position="right-start" arrowPosition="center" trigger={trigger}>
<Menu.Target>{children}</Menu.Target>
<Menu.Dropdown miw={128}>
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<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>
);
}