waveterm/frontend/app/element/expandablemenu.tsx
Evan Simkowitz 2e91ee843c
Switch from Less to Scss (#1335)
Less hasn't received an update in over a year and the parser is missing
some modern syntax like relative colors so this switches us to scss
2024-11-21 16:05:04 -08:00

201 lines
5.7 KiB
TypeScript

// Copyright 2024, Command Line
// SPDX-License-Identifier: Apache-2.0
import { clsx } from "clsx";
import { atom, useAtom } from "jotai";
import { Children, ReactElement, ReactNode, cloneElement, isValidElement, useRef } from "react";
import "./expandablemenu.scss";
// Define the global atom for managing open groups
const openGroupsAtom = atom<{ [key: string]: boolean }>({});
type BaseExpandableMenuItem = {
type: "item" | "group";
id?: string;
};
interface ExpandableMenuItemType extends BaseExpandableMenuItem {
type: "item";
leftElement?: string | ReactNode;
rightElement?: string | ReactNode;
content?: React.ReactNode | ((props: any) => React.ReactNode);
}
interface ExpandableMenuItemGroupTitleType {
leftElement?: string | ReactNode;
label: string;
rightElement?: string | ReactNode;
}
interface ExpandableMenuItemGroupType extends BaseExpandableMenuItem {
type: "group";
title: ExpandableMenuItemGroupTitleType;
isOpen?: boolean;
children?: ExpandableMenuItemData[];
}
type ExpandableMenuItemData = ExpandableMenuItemType | ExpandableMenuItemGroupType;
type ExpandableMenuProps = {
children: React.ReactNode;
className?: string;
noIndent?: boolean;
singleOpen?: boolean;
};
const ExpandableMenu = ({ children, className, noIndent = false, singleOpen = false }: ExpandableMenuProps) => {
return (
<div className={clsx("expandable-menu", className, { "no-indent": noIndent })}>
{Children.map(children, (child) => {
if (isValidElement(child) && child.type === ExpandableMenuItemGroup) {
return cloneElement(child as any, { singleOpen });
}
return child;
})}
</div>
);
};
type ExpandableMenuItemProps = {
children: ReactNode;
className?: string;
withHoverEffect?: boolean;
onClick?: () => void;
};
const ExpandableMenuItem = ({ children, className, withHoverEffect = true, onClick }: ExpandableMenuItemProps) => {
return (
<div
className={clsx("expandable-menu-item", className, {
"with-hover-effect": withHoverEffect,
})}
onClick={onClick}
>
{children}
</div>
);
};
type ExpandableMenuItemGroupTitleProps = {
children: ReactNode;
className?: string;
onClick?: () => void;
};
const ExpandableMenuItemGroupTitle = ({ children, className, onClick }: ExpandableMenuItemGroupTitleProps) => {
return (
<div className={clsx("expandable-menu-item-group-title", className)} onClick={onClick}>
{children}
</div>
);
};
type ExpandableMenuItemGroupProps = {
children: React.ReactNode;
className?: string;
isOpen?: boolean;
onToggle?: (isOpen: boolean) => void;
singleOpen?: boolean;
};
const ExpandableMenuItemGroup = ({
children,
className,
isOpen,
onToggle,
singleOpen = false,
}: ExpandableMenuItemGroupProps) => {
const [openGroups, setOpenGroups] = useAtom(openGroupsAtom);
// Generate a unique ID for this group using useRef
const idRef = useRef<string>();
if (!idRef.current) {
// Generate a unique ID when the component is first rendered
idRef.current = `group-${Math.random().toString(36).substr(2, 9)}`;
}
const id = idRef.current;
// Determine if the component is controlled or uncontrolled
const isControlled = isOpen !== undefined;
// Get the open state from global atom in uncontrolled mode
const actualIsOpen = isControlled ? isOpen : (openGroups[id] ?? false);
const toggleOpen = () => {
const newIsOpen = !actualIsOpen;
if (isControlled) {
// If controlled, call the onToggle callback
onToggle?.(newIsOpen);
} else {
// If uncontrolled, update global atom
setOpenGroups((prevOpenGroups) => {
if (singleOpen) {
// Close all other groups and open this one
return { [id]: newIsOpen };
} else {
// Toggle this group
return { ...prevOpenGroups, [id]: newIsOpen };
}
});
}
};
const renderChildren = Children.map(children, (child: ReactElement) => {
if (child && child.type === ExpandableMenuItemGroupTitle) {
return cloneElement(child, {
...child.props,
onClick: () => {
child.props.onClick?.();
toggleOpen();
},
});
} else {
return <div className={clsx("expandable-menu-item-group-content", { open: actualIsOpen })}>{child}</div>;
}
});
return (
<div className={clsx("expandable-menu-item-group", className, { open: actualIsOpen })}>{renderChildren}</div>
);
};
type ExpandableMenuItemLeftElementProps = {
children: ReactNode;
onClick?: () => void;
};
const ExpandableMenuItemLeftElement = ({ children, onClick }: ExpandableMenuItemLeftElementProps) => {
return (
<div className="expandable-menu-item-left" onClick={onClick}>
{children}
</div>
);
};
type ExpandableMenuItemRightElementProps = {
children: ReactNode;
onClick?: () => void;
};
const ExpandableMenuItemRightElement = ({ children, onClick }: ExpandableMenuItemRightElementProps) => {
return (
<div className="expandable-menu-item-right" onClick={onClick}>
{children}
</div>
);
};
export {
ExpandableMenu,
ExpandableMenuItem,
ExpandableMenuItemGroup,
ExpandableMenuItemGroupTitle,
ExpandableMenuItemLeftElement,
ExpandableMenuItemRightElement,
};
export type { ExpandableMenuItemData, ExpandableMenuItemGroupTitleType };