import type { CSSProperties, ReactElement, ReactNode } from "react"; import { Children, cloneElement } from "react"; import type { RenderConditionalPortalProps } from "@react-md/portal"; import { DEFAULT_TOOLTIP_DENSE_SPACING, DEFAULT_TOOLTIP_MARGIN, DEFAULT_TOOLTIP_POSITION, DEFAULT_TOOLTIP_SPACING, DEFAULT_TOOLTIP_THRESHOLD, } from "./constants"; import type { TooltipProps } from "./Tooltip"; import { Tooltip } from "./Tooltip"; import type { TooltippedElementEventHandlers, BaseTooltipHookOptions, } from "./useTooltip"; import { useTooltip } from "./useTooltip"; interface TooltippedProvidedProps extends TooltippedElementEventHandlers { id: string; "aria-describedby"?: string; tooltip: ReactNode; } export type ChildrenRenderer = (props: TooltippedProvidedProps) => ReactElement; type R = Record; type ChildProps = Partial>; type ChildElement = ReactElement; const MERGABLE_PROPS: (keyof TooltippedElementEventHandlers)[] = [ "onClick", "onMouseEnter", "onMouseLeave", "onTouchStart", "onFocus", "onKeyDown", "onContextMenu", ]; export interface TooltippedProps extends RenderConditionalPortalProps, BaseTooltipHookOptions, Pick { /** * The id for the element that has a tooltip. This is always required since it * will be passed down to the `containerProps` in the children renderer * function. It is also used to generate a `tooltipId` when there is a * tooltip. */ id: string; /** * The tooltip to display. When this is false-ish, the children renderer will * always return `null` for the `tooltip` prop. */ tooltip?: ReactNode; /** * An optional additional `aria-describedby` id or ids to merge with the * tooltip id. This is really used for things like notifications or when * multiple elements describe your tooltipped element. */ "aria-describedby"?: string; /** * An optional style for the tooltip. */ style?: CSSProperties; /** * An optional className for the tooltip */ className?: string; /** * The children for this component should either be a function or a single * element. When the children is a single React element, this component will * clone in an `id`, `aria-describedby`, and all the event handlers required * to show and hide a tooltip relative to that element. This means that you * will need to ensure that the child component accepts and passes down the * `on*` event handlers to a DOM node as well as the `id` and * `aria-describedby` for accessibility. Every component within react-md * should do this by default. * * If the children is a function, the `id`, `aria-describedby`, and event * handlers will be provided as well as a new `tooltip` prop so that you have * more control over rendering the tooltip. * * If the tooltip prop was not provided to this component, the * `aria-describedby` and the event handlers will be omitted. */ children: ChildElement | ChildrenRenderer; } /** * The `Tooltipped` component can be used to dynamically add a tooltip to child * element by cloning the required event handlers and accessibility props into * the child with `React.cloneChild`. * * Note: This component is _kind of_ deprecated in favor of using the * `useTooltip` hook and `Tooltip` component instead. * * @see {@link Tooltip} for an example */ export function Tooltipped({ id, style, children, tooltip: tooltipChildren, dense = false, vhMargin = DEFAULT_TOOLTIP_MARGIN, vwMargin = DEFAULT_TOOLTIP_MARGIN, spacing = DEFAULT_TOOLTIP_SPACING, denseSpacing = DEFAULT_TOOLTIP_DENSE_SPACING, position: propPosition, threshold = DEFAULT_TOOLTIP_THRESHOLD, onClick, onMouseEnter, onMouseLeave, onTouchStart, onContextMenu, onBlur, onFocus, onKeyDown, "aria-describedby": describedBy, defaultPosition = DEFAULT_TOOLTIP_POSITION, temporary = true, disableSwapping, disableHoverMode, disableAutoSpacing = process.env.NODE_ENV === "test", ...props }: TooltippedProps): ReactElement { const { elementProps, tooltipProps } = useTooltip({ baseId: id, style, dense, spacing, denseSpacing, vwMargin, vhMargin, position: propPosition, defaultPosition, disableSwapping, disableHoverMode, disableAutoSpacing, onFocus, onBlur, onKeyDown, onClick, onMouseEnter, onMouseLeave, onTouchStart, onContextMenu, threshold, }); if (!tooltipChildren) { if (typeof children === "function") { return children({ id, tooltip: null, "aria-describedby": describedBy }); } const child = Children.only(children); return cloneElement(child, { id, "aria-describedby": describedBy }); } const tooltip = ( {tooltipChildren} ); if (typeof children === "function") { return children({ ...elementProps, tooltip }); } const child = Children.only(children); // TODO: remove this mess since you should provide handlers to the // `Tooltipped` component instead of the child element. /* istanbul ignore next */ const merged = MERGABLE_PROPS.reduce( (result, propName) => { const propHandler = child.props[propName]; const configHandler = elementProps[propName]; if (!propHandler) { (result as R)[propName] = configHandler; } else if (!configHandler) { (result as R)[propName] = propHandler; } else { // not sure of a way to actually strongly type this nicely. // eslint-disable-next-line @typescript-eslint/no-explicit-any result[propName] = (event: any) => { propHandler(event); configHandler(event); }; } return result; }, { ...elementProps } ); return ( <> {cloneElement(child, merged)} {tooltip} ); }