React TypeScript Component to render a button which shows a pop up on click, and close it when elsewhere than the popup is clicked

Question:
I have a React component which should render a button. When the button is clicked, the popup should open. When elsewhere than the popup is clicked, the popup should close. I am not sure why it does not work.
Repl link:
@MiloCat/Replit-Profile-Clone

import React, { useEffect, useRef, useState } from "react";
import { Tooltip, Button } from "../rui";

interface ButtonToolTipProps {
  link: string;
}

function ButtonToolTip({ link }: ButtonToolTipProps) {
  const [tooltipOpen, setTooltipOpen] = useState(false);
  const tooltipRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    const handleOutsideClick = (event: MouseEvent) => {
      if (
        tooltipRef.current &&
        !tooltipRef.current.contains(event.target as Node)
      ) {
        setTooltipOpen(false);
      }
    };

    const handleButtonClick = () => {
      setTooltipOpen(true);
    };

    document.addEventListener("mousedown", handleOutsideClick);

    return () => {
      document.removeEventListener("mousedown", handleOutsideClick);
    };
  }, []);

  return (
    <>
      <Tooltip
        isOpen={tooltipOpen}
        delay={0}
        tooltip={`${link} copied to clipboard`}
      >
        <Button
          onClick={handleButtonClick}
          ref={tooltipRef}
          text="Button!"
        />
      </Tooltip>
    </>
  );
}

export default ButtonToolTip;

../rui/Tooltip.tsx

import { useState, ReactNode } from "react";
import { css } from "@emotion/react";

import type {
  Placement,
  PositioningStrategy,
  VirtualElement,
} from "@popperjs/core";
import { useTooltip, useTooltipTrigger } from "@react-aria/tooltip";
import { mergeProps } from "@react-aria/utils";
import {
  useTooltipTriggerState,
  TooltipTriggerState,
} from "@react-stately/tooltip";
import type { AriaTooltipProps } from "@react-types/tooltip";
import { usePopper } from "react-popper";
import { Portal } from "@reach/portal";

import { useRefState } from "./hooks";
import { rcss, tokens, ModalZIndex } from "./themes";
import { SpecializedView } from "./View";

/**
 * Generally we should rely on the default delay for tooltips. This delay was
 * chosen so that tooltips would trigger quickly when a user hovers over the
 * target, but would likely not trigger from a user simply moving their mouse
 * over the target (e.g. moving your mouse "through" the save button on the way
 * to some other element in a form).
 *
 * For tooltips attached to help icons or key information, prefer "none" as the
 * delay. For other UI tooltips where a tooltip might be intrusive if it pops up
 * too quickly, prefer "long" delays.
 *
 * For more info, see:
 * https://spectrum.adobe.com/page/tooltip/#Immediate-or-delayed-appearance
 */
export type TooltipDelay = "none" | "default" | "long";

const tooltipDelayMap: Record<TooltipDelay, number> = {
  none: 0,
  default: 250,
  long: 1000,
};

/** Properties used by both Tooltip and TargetedTooltip */
interface CommonProps {
  /** Tooltip content */
  tooltip: ReactNode;
  /** Where the tooltip wants to appear */
  placement?: Placement;
  /** How to get the tooltip into position */
  strategy?: PositioningStrategy;
  /** Hard override the Z index of the tooltip
   *
   * Portaling elements into the document.documentElement breaks
   * accessibility and styling (CSS variables!) in a few ways, so the
   * next-best thing that we can do is set a z-index and try to fix it
   * locally. Due to the weirdness of stacking contexts in browsers,
   * there isn't any single ideal z-index that could just be set-and-forget.
   */
  zIndex?: number;
  /**
   * Override the default border color. This also updates the arrow color to match
   * */
  borderColor?: string;
  /**
   * Override the default background color.
   */
  backgroundColor?: string;
}

export interface TooltipProps extends CommonProps {
  /** Content the tooltip is positioned around */
  children:
    | ReactNode
    | (<T extends HTMLElement = HTMLElement>(
        props: ReturnType<typeof useTooltipTrigger>["triggerProps"],
        ref: React.Ref<T>
      ) => JSX.Element);
  /** Whether the tooltip is open by default (uncontrolled) */
  defaultOpen?: boolean;
  /** Delay before tooltip appears */
  delay?: TooltipDelay;
  /** Whether the tooltip is disabled (independently from the trigger) */
  isDisabled?: boolean;
  /** Whether the tooltip is open (controlled) */
  isOpen?: boolean;
  /** Fired when open state changes */
  onOpenChange?: (open: boolean) => void;
  /** Override default max width with custom value */
  maxWidth?: string | number;
}

interface TargetedProps extends CommonProps {
  /** State of the tooltip. Usually hooked into a trigger with useTooltipTrigger */
  state?: TooltipTriggerState;
  /** Element the tooltip targets, or a virtual element for arbitrary positioning */
  target: null | VirtualElement | HTMLElement;
  /** Accessibility properties as provided by useTooltip */
  tooltipProps: AriaTooltipProps;
  /** Override default max width with custom value */
  maxWidth?: string | number;
}

const tooltipCss = css({
  pointerEvents: "none",
  fontFamily: tokens.fontFamilyDefault,
});

const tooltipContentCss = css([
  {
    border: `1px solid ${tokens.outlineDimmer}`,
    borderRadius: tokens.borderRadius8,
    backgroundColor: tokens.backgroundHighest,
  },
  rcss.p(8),
  rcss.shadow(1),
]);

export const arrowCss = css({
  display: "block",
  pointerEvents: "none",
  "&::after": {
    content: '""',
    display: "block",
    border: `1px solid ${tokens.outlineDimmer}`,
    borderTopLeftRadius: tokens.borderRadius4,
    background: tokens.backgroundHighest,
    width: 12,
    height: 12,
    clipPath: "polygon(0 0, 100% 0, 0 100%)",
  },
  '[data-popper-placement^="top"] > &': {
    bottom: -6,
    "&::after": {
      transform: "rotate(225deg)",
    },
  },
  '[data-popper-placement^="right"] > &': {
    left: -6,
    "&::after": {
      transform: "rotate(315deg)",
    },
  },
  '[data-popper-placement^="bottom"] > &': {
    top: -6,
    "&::after": {
      transform: "rotate(45deg)",
    },
  },
  '[data-popper-placement^="left"] > &': {
    right: -6,
    "&::after": {
      transform: "rotate(135deg)",
    },
  },
});

const SpanView = SpecializedView.span;

/**
 * Use TargetedTooltip when you need a tooltip to appear somewhere
 * outside of your current React tree. For example, you might attach
 * to an element created by a non-React library.
 *
 * You probably want to use Tooltip unless you need that additional
 * behavior.
 *
 * See Tooltip's implementation for usage.
 */
export function TargetedTooltip({
  placement,
  state,
  strategy,
  target: referenceElt,
  tooltip,
  tooltipProps: passedTooltipProps,
  zIndex,
  borderColor,
  backgroundColor,
  maxWidth,
}: TargetedProps): null | JSX.Element {
  const [popperElt, setPopperElt] = useState<null | HTMLElement>(null);
  const [arrowElt, setArrowElt] = useState<null | HTMLElement>(null);
  const { styles, attributes } = usePopper(referenceElt, popperElt, {
    modifiers: [
      { name: "arrow", options: { element: arrowElt, padding: 8 } },
      { name: "offset", options: { offset: [0, 16] } },
    ],
    strategy,
    placement,
  });
  const { tooltipProps } = useTooltip(passedTooltipProps, state);

  if (typeof window === "undefined") return null;

  return (
    <Portal>
      <SpanView
        {...mergeProps(
          {
            ref: setPopperElt,
            style: styles.popper,
            css: [tooltipCss, { zIndex, maxWidth: maxWidth ? maxWidth : 240 }],
          },
          attributes.popper || {},
          tooltipProps
        )}
      >
        <SpanView css={[tooltipContentCss, { borderColor, backgroundColor }]}>
          {tooltip}
        </SpanView>
        <span
          ref={setArrowElt}
          style={styles.arrow}
          css={[
            arrowCss,
            borderColor && {
              "&::after": {
                borderColor,
              },
            },
            backgroundColor && { "&::after": { backgroundColor } },
          ]}
        />
      </SpanView>
    </Portal>
  );
}

/**
 * Tooltip triggered on hover or focus.
 */
export function Tooltip({
  children,
  defaultOpen,
  delay = "default",
  isDisabled,
  isOpen,
  onOpenChange,
  placement,
  strategy,
  tooltip,
  zIndex = ModalZIndex,
  borderColor = tokens.outlineDefault,
  maxWidth,
  backgroundColor,
}: TooltipProps): JSX.Element {
  const [ref, setRef] = useRefState<HTMLElement>();

  const tooltipTriggerOptions = {
    defaultOpen,
    delay: tooltipDelayMap[delay],
    isDisabled,
    isOpen,
    onOpenChange,
  };
  const state = useTooltipTriggerState(tooltipTriggerOptions);
  const { triggerProps, tooltipProps } = useTooltipTrigger(
    tooltipTriggerOptions,
    state,
    ref
  );

  return (
    <>
      {typeof children === "function" ? (
        children(triggerProps, setRef)
      ) : (
        <SpanView {...mergeProps({ ref: setRef }, triggerProps)}>
          {children}
        </SpanView>
      )}
      {state.isOpen ? (
        <TargetedTooltip
          placement={placement}
          state={state}
          strategy={strategy}
          target={ref.current}
          tooltip={tooltip}
          tooltipProps={tooltipProps}
          zIndex={zIndex}
          borderColor={borderColor}
          backgroundColor={backgroundColor}
          maxWidth={maxWidth}
        />
      ) : null}
    </>
  );
}

../rui/Button.tsx

import * as React from "react";

import {
  Colorway,
  filledInteractive,
  outlined as colorwayOutlined,
} from "./Colorway";
import { Props as IconProps } from "./icons/Icon";
import { interactive } from "./Interactive";
import { Text } from "./Text";
import { rcss, tokens } from "./themes";
import { View, SpecializedView } from "./View";
import { loadingStyle } from "./LoadingStyle";

export interface BaseProps extends React.AriaAttributes {
  /** ID is useful for associating the button with other elements for a11y */
  id?: string;
  /** You can't pass children to a `<Button>`, but forwardRef tries to permit it unless forbidden.
   *
   * @private
   */
  children?: never;
  /** Sets the text of the button */
  text: string | React.ReactElement;
  /** Sets an additional piece of text in the button */
  secondaryText?: string | React.ReactElement;
  /** Allows customizing button look via `css` prop */
  className?: string;
  /** Pick a pre-defined color and hover state to communicate the purpose */
  colorway?: Colorway;
  /** Makes the button look inactive and prevents any interaction with it */
  disabled?: boolean;
  /** Icon on the left side of the button */
  iconLeft?: React.ReactElement<IconProps>;
  /** Icon on the right side of the button */
  iconRight?: React.ReactElement<IconProps>;
  /** Switches from interactive.filled to interactive.outlined preset */
  outlined?: boolean;
  /** Makes the button smaller */
  small?: boolean;
  /** Makes the button bigger */
  big?: boolean;
  /** Makes the button fill its container */
  stretch?: boolean;
  /** Allows the content to be aligned left/center/right */
  alignment?: "start" | "center" | "end";
  /** Adds a loading style to the button */
  loading?: boolean;
  /** Allows selection of button via cypress */
  dataCy?: string;
  /**  Custom property added by @MiloCat to allow placement of icons inside text */
  custom?: boolean
  customIndex?: number
  customElement?: string
}

export interface ButtonProps extends BaseProps {
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
  onFocus?: React.FocusEventHandler<HTMLButtonElement>;
  onBlur?: React.FocusEventHandler<HTMLButtonElement>;
  /**
   * Sets the button type.
   * defaults to `button` instead of the browser default of `submit`.
   */
  type?: "button" | "submit" | "reset";
}

const ButtonView = SpecializedView.button;

interface ButtonCssOptions {
  disabled?: boolean;
  outlined?: boolean;
  stretch?: boolean;
  colorway?: Colorway;
  alignment?: "start" | "center" | "end";
  size: number;
  loading?: boolean;
}

/** Generates CSS to properly style a button. */
export function buttonCss({
  disabled,
  outlined,
  stretch,
  colorway,
  alignment,
  size,
  loading,
}: ButtonCssOptions) {
  return [
    rcss.rowWithGap(8),
    rcss.align[alignment ?? "center"],
    rcss.justify[alignment ?? "center"],
    rcss.reset.button,
    disabled || loading ? { color: tokens.foregroundDimmest } : null,
    outlined ? interactive.outlined : interactive.filled,
    rcss.p(8),
    rcss.borderRadius(8),
    stretch && { alignSelf: "stretch" },
    loading ? { ...loadingStyle.backgroundPulse() } : null,
    colorway &&
    (outlined
      ? colorwayOutlined(colorway, loading)
      : filledInteractive(colorway, loading)),
    { height: size + 16 },
  ];
}

// TODO can this be a subset of or main Variant type?
type TextVariant = "text" | "small" | "subheadDefault";

export function getTextVariant({
  small,
  big,
}: Pick<BaseProps, "small" | "big">): TextVariant {
  if (big) return "subheadDefault";

  if (small) return "small";

  return "text";
}

// TODO can this be a subset of or main IconSize type?
type IconSize = 12 | 16 | 20;

export function getIconSize({
  small,
  big,
}: Pick<BaseProps, "small" | "big">): IconSize {
  if (big) return 20;

  if (small) return 12;

  return 16;
}

interface ButtonContentProps
  extends Pick<
    BaseProps,
    "iconLeft" | "iconRight" | "text" | "secondaryText" | "small" | "alignment"
  > {
  iconSize: IconSize;
  variant: TextVariant;
}

export function ButtonContent({
  iconLeft,
  iconRight,
  text,
  secondaryText,
  iconSize,
  variant,
  alignment,
  small,
}: ButtonContentProps) {
  const buttonText = text ? <Text variant={variant}>{text}</Text> : null;

  return (
    <>
      {iconLeft ? (
        <View
          css={[
            rcss.flex.growAndShrink(1),
            rcss.align[alignment ?? "center"],
            rcss.justify[alignment ?? "center"],
            rcss.rowWithGap(small ? 4 : 8),
          ]}
        >
          {React.cloneElement(iconLeft, { size: iconSize })}
          {buttonText}
        </View>
      ) : (
        buttonText
      )}
      {secondaryText && (
        <Text variant={variant} color={"default"}>
          {secondaryText}
        </Text>
      )}
      {iconRight && React.cloneElement(iconRight, { size: iconSize })}
    </>
  );
}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      colorway,
      disabled,
      iconLeft,
      iconRight,
      outlined,
      small,
      big,
      stretch,
      text,
      secondaryText,
      alignment,
      loading,
      type = "button",
      ...props
    }: ButtonProps,
    ref
  ) => {
    const variant = getTextVariant({ small, big });
    const iconSize = getIconSize({ small, big });

    const eltCss = buttonCss({
      disabled,
      outlined,
      stretch,
      colorway,
      alignment,
      size: iconSize,
      loading,
    });

    return (
      <ButtonView
        type={type}
        ref={ref}
        css={eltCss}
        disabled={disabled || loading}
        {...props}
      >
        <ButtonContent
          text={text}
          secondaryText={secondaryText}
          iconLeft={iconLeft}
          iconRight={iconRight}
          iconSize={iconSize}
          variant={variant}
          alignment={alignment}
        />
      </ButtonView>
    );
  }
);

Button.displayName = "Button";
3 Likes

handleButtonClick and handleOutsideClick are declared inside useEffect, they wouldn’t exist for Tooltip and Button. And you need to make sure that handleOutsideClick isn’t run when you click the button so it doesn’t hide right after it’s shown.

6 Likes

This topic was automatically closed 7 days after the last reply. New replies are no longer allowed.