staff_data/apps/web/src/components/presentation/dropdown-menu.tsx

294 lines
7.2 KiB
TypeScript
Executable File

import {
autoUpdate,
flip,
FloatingFocusManager,
FloatingList,
FloatingNode,
FloatingPortal,
FloatingTree,
offset,
safePolygon,
shift,
useClick,
useDismiss,
useFloating,
useFloatingNodeId,
useFloatingParentNodeId,
useFloatingTree,
useHover,
useInteractions,
useListItem,
useListNavigation,
useMergeRefs,
useRole,
useTypeahead,
} from "@floating-ui/react";
import * as React from "react";
import { Icon } from "@nice/iconer";
const MenuContext = React.createContext<{
getItemProps: (
userProps?: React.HTMLProps<HTMLElement>
) => Record<string, unknown>;
activeIndex: number | null;
setActiveIndex: React.Dispatch<React.SetStateAction<number | null>>;
setHasFocusInside: React.Dispatch<React.SetStateAction<boolean>>;
isOpen: boolean;
}>({
getItemProps: () => ({}),
activeIndex: null,
setActiveIndex: () => {},
setHasFocusInside: () => {},
isOpen: false,
});
interface MenuProps {
label?: string;
node?: React.ReactNode;
nested?: boolean;
icon?: React.ReactNode;
children?: React.ReactNode;
}
export const MenuComponent = React.forwardRef<
HTMLButtonElement,
MenuProps & React.HTMLProps<HTMLButtonElement>
>(({ children, label, node, icon, ...props }, forwardedRef) => {
const [isOpen, setIsOpen] = React.useState(false);
const [hasFocusInside, setHasFocusInside] = React.useState(false);
const [activeIndex, setActiveIndex] = React.useState<number | null>(null);
const elementsRef = React.useRef<Array<HTMLButtonElement | null>>([]);
const labelsRef = React.useRef<Array<string | null>>([]);
const parent = React.useContext(MenuContext);
const tree = useFloatingTree();
const nodeId = useFloatingNodeId();
const parentId = useFloatingParentNodeId();
const item = useListItem();
const isNested = parentId != null;
const { floatingStyles, refs, context } = useFloating<HTMLButtonElement>({
nodeId,
open: isOpen,
onOpenChange: setIsOpen,
placement: isNested ? "right-start" : "bottom-start",
middleware: [
offset({
mainAxis: isNested ? 4 : 4,
alignmentAxis: isNested ? -4 : 0,
}),
flip(),
shift(),
],
whileElementsMounted: autoUpdate,
});
const hover = useHover(context, {
enabled: isNested,
delay: { open: 75 },
handleClose: safePolygon({ blockPointerEvents: true }),
});
const click = useClick(context, {
event: "mousedown",
toggle: !isNested,
ignoreMouse: isNested,
});
const role = useRole(context, { role: "menu" });
const dismiss = useDismiss(context, { bubbles: true });
const listNavigation = useListNavigation(context, {
listRef: elementsRef,
activeIndex,
nested: isNested,
onNavigate: setActiveIndex,
});
const typeahead = useTypeahead(context, {
listRef: labelsRef,
onMatch: isOpen ? setActiveIndex : undefined,
activeIndex,
});
const { getReferenceProps, getFloatingProps, getItemProps } =
useInteractions([
hover,
click,
role,
dismiss,
listNavigation,
typeahead,
]);
// Event emitter allows you to communicate across tree components.
// This effect closes all menus when an item gets clicked anywhere
// in the tree.
React.useEffect(() => {
if (!tree) return;
function handleTreeClick() {
setIsOpen(false);
}
function onSubMenuOpen(event: { nodeId: string; parentId: string }) {
if (event.nodeId !== nodeId && event.parentId === parentId) {
setIsOpen(false);
}
}
tree.events.on("click", handleTreeClick);
tree.events.on("menuopen", onSubMenuOpen);
return () => {
tree.events.off("click", handleTreeClick);
tree.events.off("menuopen", onSubMenuOpen);
};
}, [tree, nodeId, parentId]);
React.useEffect(() => {
if (isOpen && tree) {
tree.events.emit("menuopen", { parentId, nodeId });
}
}, [tree, isOpen, nodeId, parentId]);
return (
<FloatingNode id={nodeId}>
<button
ref={useMergeRefs([refs.setReference, item.ref, forwardedRef])}
tabIndex={
!isNested
? undefined
: parent.activeIndex === item.index
? 0
: -1
}
role={isNested ? "menuitem" : undefined}
data-open={isOpen ? "" : undefined}
data-nested={isNested ? "" : undefined}
data-focus-inside={hasFocusInside ? "" : undefined}
className={`${
isNested
? "hover:bg-textHover hover:text-default"
: "RootMenu"
} rounded-lg outline-none flex items-center px-2 py-1 justify-between`}
{...getReferenceProps(
parent.getItemProps({
...props,
onFocus(event: React.FocusEvent<HTMLButtonElement>) {
props.onFocus?.(event);
setHasFocusInside(false);
parent.setHasFocusInside(true);
},
})
)}>
<div className="flex items-center">
{icon && <span className="mr-2">{icon}</span>}
{label}
{node}
</div>
{isNested && (
<span aria-hidden style={{ marginLeft: 10, fontSize: 10 }}>
<Icon name={"caret-right"}></Icon>
</span>
)}
</button>
<MenuContext.Provider
value={{
activeIndex,
setActiveIndex,
getItemProps,
setHasFocusInside,
isOpen,
}}>
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
{isOpen && (
<FloatingPortal>
<div
className="fixed inset-0 bg-transparent z-10"
onClick={() => setIsOpen(false)}></div>
<FloatingFocusManager
context={context}
modal={false}
initialFocus={isNested ? -1 : 0}
returnFocus={!isNested}>
<div
ref={refs.setFloating}
className="Menu bg-container flex flex-col p-1 min-w-20 border-default text-sm text-secondary border outline-none rounded-lg shadow-lg z-20"
style={floatingStyles}
{...getFloatingProps()}>
{children}
</div>
</FloatingFocusManager>
</FloatingPortal>
)}
</FloatingList>
</MenuContext.Provider>
</FloatingNode>
);
});
interface MenuItemProps {
label: string;
disabled?: boolean;
icon?: React.ReactNode;
}
export const MenuItem = React.forwardRef<
HTMLButtonElement,
MenuItemProps & React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ label, disabled, icon, ...props }, forwardedRef) => {
const menu = React.useContext(MenuContext);
const item = useListItem({ label: disabled ? null : label });
const tree = useFloatingTree();
const isActive = item.index === menu.activeIndex;
return (
<button
{...props}
ref={useMergeRefs([item.ref, forwardedRef])}
type="button"
role="menuitem"
className={`MenuItem ${
disabled
? "text-quaternary"
: "hover:bg-textHover hover:text-default"
} outline-none rounded-lg flex items-center px-2 py-1 `}
tabIndex={isActive ? 0 : -1}
disabled={disabled}
{...menu.getItemProps({
onClick(event: React.MouseEvent<HTMLButtonElement>) {
props.onClick?.(event);
tree?.events.emit("click");
},
onFocus(event: React.FocusEvent<HTMLButtonElement>) {
props.onFocus?.(event);
menu.setHasFocusInside(true);
},
})}>
{icon && <span className="mr-2">{icon}</span>}
{label}
</button>
);
});
export const Menu = React.forwardRef<
HTMLButtonElement,
MenuProps & React.HTMLProps<HTMLButtonElement>
>((props, ref) => {
const parentId = useFloatingParentNodeId();
if (parentId === null) {
return (
<FloatingTree>
<MenuComponent {...props} ref={ref} />
</FloatingTree>
);
}
return (
<>
<MenuComponent {...props} ref={ref} />
</>
);
});