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
@@ -1,78 +1,85 @@
import {
type ComponentPropsWithoutRef,
forwardRef,
type ReactNode,
} from "react";
import { Icon } from "../Icon/Icon";
import { SpinnerSvg } from "../../assets/svg/SpinnerSvg";
export type ButtonSize = "xs" | "sm" | "md" | "lg";
export type ButtonProps = ComponentPropsWithoutRef<"button"> & {
size?: ButtonSize;
variant?: "filled" | "outline" | "subtle" | "ghost";
shape?: "rounded" | "square" | "circle";
brand?: "success" | "danger" | "info" | "warning" | "emphasize";
} & {
iconOnly?: boolean;
hideIcon?: boolean;
loading?: boolean;
} & {
iconSvg?: ReactNode;
loadingIconSvg?: ReactNode;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const {
size = "md",
variant = "filled",
shape = "rounded",
brand = "info",
// ===========================
iconOnly = false,
hideIcon = false,
loading,
disabled = false,
// =========================
className = "",
children,
// ==========================
iconSvg,
loadingIconSvg = <SpinnerSvg />,
...rest
} = props;
const mergedClass = cpm(
itemSizeRecipe({ size, shape, iconOnly, longPadding: false }),
variantRecipe({ variant, disabled }),
brandRecipe({ brand }),
className,
);
const contentClass = loading
? cpm("visible--hidden", inlineSizeRecipe({ size }))
: inlineSizeRecipe({ size });
const loadingIconClass = loading ? "position--absolute" : "";
const icon = <Icon size={size}>{iconSvg}</Icon>;
const loadingIcon = (
<Icon className={loadingIconClass} size={size}>
{loadingIconSvg}
</Icon>
);
return (
<button ref={ref} className={mergedClass} disabled={disabled} {...rest}>
<span className={contentClass}>
{!hideIcon && iconSvg && icon}
{!iconOnly && children}
</span>
{loading && loadingIcon}
</button>
);
},
);
Button.displayName = "Button";
import {
type ComponentPropsWithoutRef,
forwardRef,
type ReactNode,
} from "react";
import { Icon } from "../Icon/Icon";
import { SpinnerSvg } from "../../assets/svg/SpinnerSvg";
import {
brandRecipe,
cpm,
inlineSizeRecipe,
itemSizeRecipe,
itemVariantRecipe,
} from "@dg/css";
export type ButtonSize = "xs" | "sm" | "md" | "lg";
export type ButtonProps = ComponentPropsWithoutRef<"button"> & {
size?: ButtonSize;
variant?: "filled" | "outline" | "subtle" | "ghost";
shape?: "rounded" | "square" | "circle";
brand?: "success" | "danger" | "info" | "warning" | "emphasize";
} & {
iconOnly?: boolean;
hideIcon?: boolean;
loading?: boolean;
} & {
iconSvg?: ReactNode;
loadingIconSvg?: ReactNode;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const {
size = "md",
variant = "filled",
shape = "rounded",
brand = "info",
// ===========================
iconOnly = false,
hideIcon = false,
loading,
disabled = false,
// =========================
className = "",
children,
// ==========================
iconSvg,
loadingIconSvg = <SpinnerSvg />,
...rest
} = props;
const mergedClass = cpm(
itemSizeRecipe({ size, shape, iconOnly, longPadding: false }),
itemVariantRecipe({ variant, disabled }),
brandRecipe({ brand }),
className,
);
const contentClass = loading
? cpm("visible--hidden", inlineSizeRecipe({ size }))
: inlineSizeRecipe({ size });
const loadingIconClass = loading ? "position--absolute" : "";
const icon = <Icon size={size}>{iconSvg}</Icon>;
const loadingIcon = (
<Icon className={loadingIconClass} size={size}>
{loadingIconSvg}
</Icon>
);
return (
<button ref={ref} className={mergedClass} disabled={disabled} {...rest}>
<span className={contentClass}>
{!hideIcon && iconSvg && icon}
{!iconOnly && children}
</span>
{loading && loadingIcon}
</button>
);
},
);
Button.displayName = "Button";
+149 -50
View File
@@ -1,50 +1,149 @@
import {
autoUpdate,
flip,
useClick,
useDismiss,
useFloating,
useInteractions,
useListNavigation,
useRole,
useTypeahead,
type Placement,
} from "@floating-ui/react";
import { useRef, useState } from "react";
import type { CommonProps } from "../../utils/CommonProps";
export type MenuProps = CommonProps;
export const Menu = (props: MenuProps) => {
const { children } = props;
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [hasFocusInside, setHasFocusInside] = useState(false);
const { refs, floatingStyles, context } = useFloating({
placement: "bottom-start",
open: isOpen,
onOpenChange: setIsOpen,
whileElementsMounted: autoUpdate,
middleware: [flip()],
});
const elementsRef = useRef([]);
const listNav = useListNavigation(context, {
listRef: elementsRef,
activeIndex,
onNavigate: setActiveIndex,
});
const click = useClick(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: "listbox" });
const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions(
[listNav, click, dismiss, role],
);
return;
};
import {
autoUpdate,
flip,
FloatingNode,
offset,
safePolygon,
shift,
useClick,
useDismiss,
useFloating,
useFloatingNodeId,
useFloatingParentNodeId,
useFloatingTree,
useHover,
useInteractions,
useListItem,
useListNavigation,
useMergeRefs,
useRole,
} from "@floating-ui/react";
import {
forwardRef,
useContext,
useEffect,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode,
} from "react";
import type { CommonProps } from "../../utils/CommonProps";
import { MenuContext } from "./MenuContext";
import { Button } from "../Button/Button";
import { MenuItem } from "./MenuItem";
export type MenuProps = ComponentPropsWithoutRef<"button"> & {
size?: "sm" | "md";
iconSvg?: ReactNode;
};
export const Menu = forwardRef<HTMLElement, MenuProps>(
(props: MenuProps, ref) => {
const { children, size = "sm", iconSvg, ...rest } = props;
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
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({
placement: isNested ? "right-start" : "bottom-start",
open: isOpen,
onOpenChange: setIsOpen,
whileElementsMounted: autoUpdate,
middleware: [
offset({
mainAxis: isNested ? 0 : 4,
alignmentAxis: isNested ? -4 : 0,
}),
flip(),
shift(),
],
});
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, {
listRef: elementsRef,
activeIndex,
nested: isNested,
onNavigate: setActiveIndex,
});
const { getReferenceProps, getFloatingProps, getItemProps } =
useInteractions([hover, click, dismiss, role, listNav]);
// Event emitter allows you to communicate across tree components.
// 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>
);
},
);
@@ -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>;
};