This commit is contained in:
2026-08-05 02:07:02 +08:00
parent 2a0326683d
commit e618f6e04d
6 changed files with 693 additions and 579 deletions
@@ -6,6 +6,13 @@ import {
import { Icon } from "../Icon/Icon"; import { Icon } from "../Icon/Icon";
import { SpinnerSvg } from "../../assets/svg/SpinnerSvg"; import { SpinnerSvg } from "../../assets/svg/SpinnerSvg";
import {
brandRecipe,
cpm,
inlineSizeRecipe,
itemSizeRecipe,
itemVariantRecipe,
} from "@dg/css";
export type ButtonSize = "xs" | "sm" | "md" | "lg"; export type ButtonSize = "xs" | "sm" | "md" | "lg";
@@ -46,7 +53,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
const mergedClass = cpm( const mergedClass = cpm(
itemSizeRecipe({ size, shape, iconOnly, longPadding: false }), itemSizeRecipe({ size, shape, iconOnly, longPadding: false }),
variantRecipe({ variant, disabled }), itemVariantRecipe({ variant, disabled }),
brandRecipe({ brand }), brandRecipe({ brand }),
className, className,
); );
+116 -17
View File
@@ -1,50 +1,149 @@
import { import {
autoUpdate, autoUpdate,
flip, flip,
FloatingNode,
offset,
safePolygon,
shift,
useClick, useClick,
useDismiss, useDismiss,
useFloating, useFloating,
useFloatingNodeId,
useFloatingParentNodeId,
useFloatingTree,
useHover,
useInteractions, useInteractions,
useListItem,
useListNavigation, useListNavigation,
useMergeRefs,
useRole, useRole,
useTypeahead,
type Placement,
} from "@floating-ui/react"; } from "@floating-ui/react";
import { useRef, useState } from "react"; import {
forwardRef,
useContext,
useEffect,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode,
} from "react";
import type { CommonProps } from "../../utils/CommonProps"; import type { CommonProps } from "../../utils/CommonProps";
import { MenuContext } from "./MenuContext";
import { Button } from "../Button/Button";
import { MenuItem } from "./MenuItem";
export type MenuProps = CommonProps; export type MenuProps = ComponentPropsWithoutRef<"button"> & {
size?: "sm" | "md";
iconSvg?: ReactNode;
};
export const Menu = (props: MenuProps) => { export const Menu = forwardRef<HTMLElement, MenuProps>(
const { children } = props; (props: MenuProps, ref) => {
const { children, size = "sm", iconSvg, ...rest } = props;
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null); const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [hasFocusInside, setHasFocusInside] = useState(false); const [hasFocusInside, setHasFocusInside] = useState(false);
const elementsRef = useRef([]);
const parent = useContext(MenuContext);
const tree = useFloatingTree();
const nodeId = useFloatingNodeId();
const parentId = useFloatingParentNodeId();
const item = useListItem();
const isNested = parentId != null;
const { refs, floatingStyles, context } = useFloating({ const { refs, floatingStyles, context } = useFloating({
placement: "bottom-start", placement: isNested ? "right-start" : "bottom-start",
open: isOpen, open: isOpen,
onOpenChange: setIsOpen, onOpenChange: setIsOpen,
whileElementsMounted: autoUpdate, whileElementsMounted: autoUpdate,
middleware: [flip()], middleware: [
offset({
mainAxis: isNested ? 0 : 4,
alignmentAxis: isNested ? -4 : 0,
}),
flip(),
shift(),
],
}); });
const elementsRef = useRef([]); 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 listNav = useListNavigation(context, { const listNav = useListNavigation(context, {
listRef: elementsRef, listRef: elementsRef,
activeIndex, activeIndex,
nested: isNested,
onNavigate: setActiveIndex, onNavigate: setActiveIndex,
}); });
const click = useClick(context); const { getReferenceProps, getFloatingProps, getItemProps } =
const dismiss = useDismiss(context); useInteractions([hover, click, dismiss, role, listNav]);
const role = useRole(context, { role: "listbox" });
const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions( // Event emitter allows you to communicate across tree components.
[listNav, click, dismiss, role], // This effect closes all menus when an item gets clicked anywhere
// in the tree.
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]);
const Trigger = isNested ? MenuItem : Button;
return (
<FloatingNode id={nodeId}>
<Trigger
ref={useMergeRefs([refs.setReference, item.ref, ref])}
tabIndex={
!isNested ? undefined : parent.activeIndex === item.index ? 0 : -1
}
role={isNested ? "menuitem" : undefined}
iconSvg={iconSvg}
size={size}
{...getReferenceProps(
parent.getItemProps({
...rest,
onFocus(event: React.FocusEvent<HTMLButtonElement>) {
rest.onFocus?.(event);
setHasFocusInside(false);
parent.setHasFocusInside(true);
},
}),
)}
></Trigger>
</FloatingNode>
); );
},
return; );
};
@@ -0,0 +1,8 @@
import { useContext } from "react";
import { MenuContext } from "./MenuContext";
export const MenuItem = () => {
const menuContext = useContext(MenuContext);
const isRoot = menuContext ?? false;
return <div></div>;
};