diff --git a/packages/react/src/components/alert-dialog/alert-dialog.tsx b/packages/react/src/components/alert-dialog/alert-dialog.tsx index ff8b7d74f4..28869599cb 100644 --- a/packages/react/src/components/alert-dialog/alert-dialog.tsx +++ b/packages/react/src/components/alert-dialog/alert-dialog.tsx @@ -19,6 +19,7 @@ import { Pressable as PressablePrimitive, } from "react-aria-components/Modal"; +import {enableChildProps, forwardChildProps} from "../../utils/children"; import {composeSlotClassName, composeTwRenderProps} from "../../utils/compose"; import {dom} from "../../utils/dom"; import {CloseButton} from "../close-button"; @@ -43,7 +44,8 @@ const AlertDialogContext = createContext({}); * -----------------------------------------------------------------------------------------------*/ interface AlertDialogRootProps extends ComponentPropsWithRef {} -const AlertDialogRoot = ({children, ...props}: AlertDialogRootProps) => { +const AlertDialogRoot = enableChildProps((inputProps: AlertDialogRootProps) => { + const {children, ...props} = forwardChildProps(inputProps); const alertDialogContext = useMemo( () => ({slots: alertDialogVariants(), placement: undefined}), [], @@ -56,7 +58,7 @@ const AlertDialogRoot = ({children, ...props}: AlertDialogRootProps) => { ); -}; +}); /* ------------------------------------------------------------------------------------------------- * AlertDialog Trigger diff --git a/packages/react/src/components/button-group/button-group.stories.tsx b/packages/react/src/components/button-group/button-group.stories.tsx index fc9d75aad5..bfb642ce99 100644 --- a/packages/react/src/components/button-group/button-group.stories.tsx +++ b/packages/react/src/components/button-group/button-group.stories.tsx @@ -8,6 +8,7 @@ import {Chip} from "../chip"; import {Description} from "../description"; import {Dropdown} from "../dropdown"; import {Label} from "../label"; +import {Tooltip} from "../tooltip"; import {ButtonGroup} from "./"; @@ -298,6 +299,21 @@ export const WithoutSeparator: Story = { ), }; +export const WithTooltips: Story = { + render: () => ( + + + + First action + + + + Second action + + + ), +}; + export const Examples: Story = { render: () => (
diff --git a/packages/react/src/components/button-group/button-group.tsx b/packages/react/src/components/button-group/button-group.tsx index 0f6549f4ea..c48bf6a85a 100644 --- a/packages/react/src/components/button-group/button-group.tsx +++ b/packages/react/src/components/button-group/button-group.tsx @@ -12,6 +12,7 @@ import {useSlottedContext} from "react-aria-components/slots"; import {ToggleButtonGroupContext as RACToggleButtonGroupContext} from "react-aria-components/ToggleButtonGroup"; import {composeSlotClassName, composeTwRenderProps} from "../../utils"; +import {supportsChildProps, withChildProps} from "../../utils/children"; import {dom} from "../../utils/dom"; /* ------------------------------------------------------------------------------------------------- @@ -60,16 +61,15 @@ const ButtonGroupRoot = ({ [fullWidth, orientation], ); - // Wrap only direct children with context provider + // Mark only direct children that can consume or forward the group marker. const wrappedChildren = Children.map(children as React.ReactNode, (child) => { - if (!isValidElement(child)) { + if (!isValidElement(child) || !supportsChildProps(child)) { return child; } - // Clone the child and add the special prop - return React.cloneElement(child, { + return withChildProps(child, { [BUTTON_GROUP_CHILD]: true, - } as any); + }); }); return ( diff --git a/packages/react/src/components/button/button.tsx b/packages/react/src/components/button/button.tsx index 64d602d34d..cccbbc1a36 100644 --- a/packages/react/src/components/button/button.tsx +++ b/packages/react/src/components/button/button.tsx @@ -8,6 +8,7 @@ import {use} from "react"; import {Button as ButtonPrimitive} from "react-aria-components/Button"; import {composeTwRenderProps} from "../../utils"; +import {enableChildProps, mergeChildProps} from "../../utils/children"; import {BUTTON_GROUP_CHILD, ButtonGroupContext} from "../button-group"; /* ------------------------------------------------------------------------------------------------- @@ -17,19 +18,20 @@ interface ButtonRootProps extends ComponentPropsWithRef, [BUTTON_GROUP_CHILD]?: boolean; } -const ButtonRoot = ({ - children, - className, - fullWidth, - isDisabled, - isIconOnly, - size, - slot, - style, - variant, - [BUTTON_GROUP_CHILD]: isButtonGroupChild, - ...rest -}: ButtonRootProps) => { +const ButtonRoot = enableChildProps((inputProps: ButtonRootProps) => { + const { + children, + className, + fullWidth, + isDisabled, + isIconOnly, + size, + slot, + style, + variant, + [BUTTON_GROUP_CHILD]: isButtonGroupChild, + ...rest + } = mergeChildProps(inputProps); const buttonGroupContext = use(ButtonGroupContext); // Only use context if this button is a direct child of ButtonGroup @@ -62,7 +64,7 @@ const ButtonRoot = ({ {(renderProps) => (typeof children === "function" ? children(renderProps) : children)} ); -}; +}); /* ------------------------------------------------------------------------------------------------- * Exports diff --git a/packages/react/src/components/drawer/drawer.tsx b/packages/react/src/components/drawer/drawer.tsx index b4ffe0d521..7355e12c67 100644 --- a/packages/react/src/components/drawer/drawer.tsx +++ b/packages/react/src/components/drawer/drawer.tsx @@ -23,6 +23,7 @@ import { Modal as ModalPrimitive, } from "react-aria-components/Modal"; +import {enableChildProps, forwardChildProps} from "../../utils/children"; import {composeSlotClassName, composeTwRenderProps} from "../../utils/compose"; import {dom} from "../../utils/dom"; import {CloseButton} from "../close-button"; @@ -219,7 +220,8 @@ interface DrawerRootProps extends ComponentPropsWithRef { +const DrawerRoot = enableChildProps(({children, state, ...props}: DrawerRootProps) => { + const {children: forwardedChildren, ...rootProps} = forwardChildProps({children, ...props}); const drawerContext = useMemo( () => ({slots: drawerVariants(), placement: undefined, isDismissable: true}), [], @@ -232,12 +234,12 @@ const DrawerRoot = ({children, state, ...props}: DrawerRootProps) => { return ( - - {children} + + {forwardedChildren} ); -}; +}); DrawerRoot.displayName = "HeroUI.Drawer"; diff --git a/packages/react/src/components/dropdown/dropdown.tsx b/packages/react/src/components/dropdown/dropdown.tsx index b13b68ef05..21d95f37d2 100644 --- a/packages/react/src/components/dropdown/dropdown.tsx +++ b/packages/react/src/components/dropdown/dropdown.tsx @@ -14,6 +14,7 @@ import { SubmenuTrigger as SubmenuTriggerPrimitive, } from "react-aria-components/Menu"; +import {enableChildProps, forwardChildProps} from "../../utils/children"; import {composeTwRenderProps} from "../../utils/compose"; import {MenuItemIndicator, MenuItemRoot, MenuItemSubmenuIndicator} from "../menu-item"; import {MenuSectionRoot} from "../menu-section"; @@ -36,7 +37,8 @@ interface DropdownRootProps className?: string; } -const DropdownRoot = ({children, ...props}: DropdownRootProps) => { +const DropdownRoot = enableChildProps((inputProps: DropdownRootProps) => { + const {children, ...props} = forwardChildProps(inputProps); const slots = React.useMemo(() => dropdownVariants(), []); return ( @@ -44,7 +46,7 @@ const DropdownRoot = ({children, ...props}: DropdownRootProps) => { {children} ); -}; +}); /* ------------------------------------------------------------------------------------------------- * Dropdown Trigger (Button wrapper) diff --git a/packages/react/src/components/modal/modal.tsx b/packages/react/src/components/modal/modal.tsx index fe692ba72e..9f16423a69 100644 --- a/packages/react/src/components/modal/modal.tsx +++ b/packages/react/src/components/modal/modal.tsx @@ -22,6 +22,7 @@ import { Pressable as PressablePrimitive, } from "react-aria-components/Modal"; +import {enableChildProps, forwardChildProps} from "../../utils/children"; import {composeSlotClassName, composeTwRenderProps} from "../../utils/compose"; import {dom} from "../../utils/dom"; import {CloseButton} from "../close-button"; @@ -46,7 +47,8 @@ interface ModalRootProps extends ComponentPropsWithRef { +const ModalRoot = enableChildProps(({children, state, ...props}: ModalRootProps) => { + const {children: forwardedChildren, ...rootProps} = forwardChildProps({children, ...props}); const modalContext = useMemo( () => ({slots: modalVariants(), placement: undefined}), [], @@ -59,12 +61,12 @@ const ModalRoot = ({children, state, ...props}: ModalRootProps) => { return ( - - {children} + + {forwardedChildren} ); -}; +}); /* ------------------------------------------------------------------------------------------------- * Modal Trigger diff --git a/packages/react/src/components/popover/popover.tsx b/packages/react/src/components/popover/popover.tsx index 799cbbab26..2be192bf97 100644 --- a/packages/react/src/components/popover/popover.tsx +++ b/packages/react/src/components/popover/popover.tsx @@ -18,6 +18,7 @@ import { Pressable as PressablePrimitive, } from "react-aria-components/Popover"; +import {enableChildProps, forwardChildProps} from "../../utils/children"; import {composeSlotClassName, composeTwRenderProps} from "../../utils/compose"; import {dom} from "../../utils/dom"; import {SurfaceContext} from "../surface"; @@ -36,10 +37,8 @@ const PopoverContext = createContext({}); * -----------------------------------------------------------------------------------------------*/ type PopoverRootProps = ComponentPropsWithRef; -const PopoverRoot = ({ - children, - ...props -}: ComponentPropsWithRef) => { +const PopoverRoot = enableChildProps((inputProps: PopoverRootProps) => { + const {children, ...props} = forwardChildProps(inputProps); const slots = React.useMemo(() => popoverVariants(), []); return ( @@ -49,7 +48,7 @@ const PopoverRoot = ({ ); -}; +}); /* ------------------------------------------------------------------------------------------------- * Popover Content diff --git a/packages/react/src/components/tooltip/tooltip.tsx b/packages/react/src/components/tooltip/tooltip.tsx index 5882c7819e..e35341dcc3 100644 --- a/packages/react/src/components/tooltip/tooltip.tsx +++ b/packages/react/src/components/tooltip/tooltip.tsx @@ -15,6 +15,7 @@ import { } from "react-aria-components/Tooltip"; import {useCSSVariable} from "../../hooks/use-css-variable"; +import {enableChildProps, forwardChildProps} from "../../utils/children"; import {composeSlotClassName, composeTwRenderProps} from "../../utils/compose"; import {parseCSSTime} from "../../utils/css"; import {dom} from "../../utils/dom"; @@ -33,12 +34,8 @@ const TooltipContext = createContext({}); * -----------------------------------------------------------------------------------------------*/ type TooltipRootProps = ComponentPropsWithRef; -const TooltipRoot = ({ - children, - closeDelay, - delay, - ...props -}: ComponentPropsWithRef) => { +const TooltipRoot = enableChildProps((inputProps: TooltipRootProps) => { + const {children, closeDelay, delay, ...props} = forwardChildProps(inputProps); const slots = React.useMemo(() => tooltipVariants(), []); const cssDelay = useCSSVariable("--tooltip-delay"); @@ -59,7 +56,7 @@ const TooltipRoot = ({ ); -}; +}); /* ------------------------------------------------------------------------------------------------- * Tooltip Content diff --git a/packages/react/src/utils/children.ts b/packages/react/src/utils/children.ts index fb66a58cb8..fe2f04481c 100644 --- a/packages/react/src/utils/children.ts +++ b/packages/react/src/utils/children.ts @@ -1,6 +1,129 @@ -import type {ReactNode} from "react"; +import type {ReactElement, ReactNode} from "react"; -import {Children, isValidElement} from "react"; +import {Children, Fragment, cloneElement, isValidElement} from "react"; + +const CHILD_PROPS = "__heroui_child_props"; +const SUPPORTS_CHILD_PROPS = Symbol("supportsChildProps"); + +type ChildProps = Record; +type ChildPropsCarrier = { + [CHILD_PROPS]?: ChildProps; +}; +type ChildPropsComponent = React.ElementType & { + [SUPPORTS_CHILD_PROPS]?: true; +}; + +/** Marks a component as able to consume or forward internal child props. */ +export const enableChildProps = ( + component: T, +): T & {displayName?: string} => { + Object.defineProperty(component, SUPPORTS_CHILD_PROPS, {value: true}); + + return component; +}; + +/** Returns whether an element can safely receive internal child props. */ +export const supportsChildProps = (child: ReactElement): boolean => { + if (child.type === Fragment) { + let firstElement: ReactElement | undefined; + + Children.forEach((child.props as {children?: ReactNode}).children, (fragmentChild) => { + if (!firstElement && isValidElement(fragmentChild)) { + firstElement = fragmentChild; + } + }); + + return firstElement ? supportsChildProps(firstElement) : false; + } + + if (typeof child.type === "string") { + return false; + } + + return (child.type as ChildPropsComponent)[SUPPORTS_CHILD_PROPS] === true; +}; + +const setChildProps = (child: ReactElement, childProps: ChildProps): ReactElement => { + const element = child as ReactElement; + + return cloneElement(element, { + [CHILD_PROPS]: { + ...element.props[CHILD_PROPS], + ...childProps, + }, + }); +}; + +const forwardToFirstChild = (children: ReactNode, childProps: ChildProps): [ReactNode, boolean] => { + let didForward = false; + + const forwardedChildren = Children.map(children, (child) => { + if (didForward || !isValidElement(child)) { + return child; + } + + if (child.type === Fragment) { + const [fragmentChildren, fragmentDidForward] = forwardToFirstChild( + (child.props as {children?: ReactNode}).children, + childProps, + ); + + if (!fragmentDidForward) { + return child; + } + + didForward = true; + + return cloneElement(child, undefined, fragmentChildren); + } + + didForward = true; + + return setChildProps(child, childProps); + }); + + return [forwardedChildren, didForward]; +}; + +/** + * Passes internal parent props through a transparent wrapper to its first element child. + */ +export const forwardChildProps = (inputProps: T): T => { + const {[CHILD_PROPS]: childProps, ...props} = inputProps as T & ChildPropsCarrier; + + if (!childProps) { + return inputProps; + } + + const [children] = forwardToFirstChild(props.children, childProps); + + return {...props, children} as T; +}; + +/** Applies props forwarded by a transparent parent to the current component. */ +export const mergeChildProps = (inputProps: T): T => { + const {[CHILD_PROPS]: childProps, ...props} = inputProps as T & ChildPropsCarrier; + + if (!childProps) { + return inputProps; + } + + return {...childProps, ...props} as T; +}; + +/** Marks an element with props that transparent wrappers pass to their first child. */ +export const withChildProps = (child: ReactElement, childProps: ChildProps): ReactElement => { + if (child.type === Fragment) { + const [children] = forwardToFirstChild( + (child.props as {children?: ReactNode}).children, + childProps, + ); + + return cloneElement(child, undefined, children); + } + + return setChildProps(child, childProps); +}; /** * Gets only the valid children of a component,