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";