Skip to content

refactor(site): make HelpTooltip easier to reuse and compose #11242

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Dec 19, 2023
17 changes: 11 additions & 6 deletions site/src/components/ActiveUserChart/ActiveUserChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
HelpTooltip,
HelpTooltipTitle,
HelpTooltipText,
HelpTooltipContent,
HelpTooltipTrigger,
} from "components/HelpTooltip/HelpTooltip";
import dayjs from "dayjs";
import { useTheme } from "@emotion/react";
Expand Down Expand Up @@ -139,12 +141,15 @@ export const ActiveUsersTitle: FC = () => {
return (
<div css={{ display: "flex", alignItems: "center", gap: 8 }}>
Active Users
<HelpTooltip size="small">
<HelpTooltipTitle>How do we calculate active users?</HelpTooltipTitle>
<HelpTooltipText>
When a connection is initiated to a user&apos;s workspace they are
considered an active user. e.g. apps, web terminal, SSH
</HelpTooltipText>
<HelpTooltip>
<HelpTooltipTrigger size="small" />
<HelpTooltipContent>
<HelpTooltipTitle>How do we calculate active users?</HelpTooltipTitle>
<HelpTooltipText>
When a connection is initiated to a user&apos;s workspace they are
considered an active user. e.g. apps, web terminal, SSH
</HelpTooltipText>
</HelpTooltipContent>
</HelpTooltip>
</div>
);
Expand Down
215 changes: 63 additions & 152 deletions site/src/components/HelpTooltip/HelpTooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,170 +1,107 @@
import Link from "@mui/material/Link";
// This is used as base for the main HelpTooltip component
// eslint-disable-next-line no-restricted-imports -- Read above
import Popover, { type PopoverProps } from "@mui/material/Popover";
import HelpIcon from "@mui/icons-material/HelpOutline";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import {
createContext,
useContext,
useRef,
useState,
type FC,
type PropsWithChildren,
type HTMLAttributes,
type ReactNode,
forwardRef,
ComponentProps,
} from "react";
import { Stack } from "components/Stack/Stack";
import type { CSSObject } from "@emotion/css";
import { type CSSObject } from "@emotion/css";
import { css, type Interpolation, type Theme, useTheme } from "@emotion/react";
import { type ClassName, useClassName } from "hooks/useClassName";
import {
Popover,
PopoverContent,
PopoverTrigger,
usePopover,
} from "components/Popover/Popover";

type Icon = typeof HelpIcon;

type Size = "small" | "medium";

export const HelpTooltipContext = createContext<
{ open: boolean; onClose: () => void } | undefined
>(undefined);

const useHelpTooltip = () => {
const helpTooltipContext = useContext(HelpTooltipContext);
export const HelpTooltipIcon = HelpIcon;

if (!helpTooltipContext) {
throw new Error(
"This hook should be used in side of the HelpTooltipContext.",
);
}

return helpTooltipContext;
export const HelpTooltip: FC<ComponentProps<typeof Popover>> = (props) => {
return <Popover mode="hover" {...props} />;
};

interface HelpPopoverProps extends PopoverProps {
onOpen: () => void;
onClose: () => void;
}

export const HelpPopover: FC<HelpPopoverProps> = ({
onOpen,
onClose,
children,
...props
}) => {
const popover = useClassName(classNames.popover, []);
const paper = useClassName(classNames.paper, []);
export const HelpTooltipContent = (
props: ComponentProps<typeof PopoverContent>,
) => {
const theme = useTheme();

return (
<Popover
className={popover}
classes={{ paper }}
onClose={onClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
PaperProps={{
onMouseEnter: onOpen,
onMouseLeave: onClose,
}}
<PopoverContent
{...props}
>
{children}
</Popover>
css={{
"& .MuiPaper-root": {
fontSize: 14,
width: 304,
padding: 20,
color: theme.palette.text.secondary,
},
}}
/>
);
};

export interface HelpTooltipProps {
// Useful to test on storybook
open?: boolean;
type HelpTooltipTriggerProps = HTMLAttributes<HTMLButtonElement> & {
size?: Size;
icon?: Icon;
buttonStyles?: Interpolation<Theme>;
iconStyles?: Interpolation<Theme>;
children?: ReactNode;
}

export const HelpTooltip: FC<HelpTooltipProps> = ({
children,
open = false,
size = "medium",
icon: Icon = HelpIcon,
buttonStyles,
iconStyles,
}) => {
const theme = useTheme();
const anchorRef = useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = useState(open);
const id = isOpen ? "help-popover" : undefined;
hoverEffect?: boolean;
};

const onClose = () => {
setIsOpen(false);
};
export const HelpTooltipTrigger = forwardRef<
HTMLButtonElement,
HelpTooltipTriggerProps
>((props, ref) => {
const {
size = "medium",
children = <HelpTooltipIcon />,
hoverEffect = true,
...buttonProps
} = props;

const hoverEffectStyles = css({
opacity: 0.5,
"&:hover": {
opacity: 0.75,
},
});

return (
<>
<PopoverTrigger>
<button
ref={anchorRef}
aria-describedby={id}
{...buttonProps}
aria-label="More info"
ref={ref}
css={[
css`
display: flex;
align-items: center;
justify-content: center;
width: ${theme.spacing(getButtonSpacingFromSize(size))};
height: ${theme.spacing(getButtonSpacingFromSize(size))};
padding: 0;
padding: 4px 0;
border: 0;
background: transparent;
color: ${theme.palette.text.primary};
opacity: 0.5;
cursor: pointer;
color: inherit;

&:hover {
opacity: 0.75;
& svg {
width: ${getIconSpacingFromSize(size)}px;
height: ${getIconSpacingFromSize(size)}px;
}
`,
buttonStyles,
hoverEffect ? hoverEffectStyles : null,
]}
onClick={(event) => {
event.stopPropagation();
setIsOpen(true);
}}
onMouseEnter={() => {
setIsOpen(true);
}}
onMouseLeave={() => {
setIsOpen(false);
}}
aria-label="More info"
>
<Icon
css={[
{
width: theme.spacing(getIconSpacingFromSize(size)),
height: theme.spacing(getIconSpacingFromSize(size)),
},
iconStyles,
]}
/>
{children}
</button>
<HelpPopover
id={id}
open={isOpen}
anchorEl={anchorRef.current}
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
>
<HelpTooltipContext.Provider value={{ open: isOpen, onClose }}>
{children}
</HelpTooltipContext.Provider>
</HelpPopover>
</>
</PopoverTrigger>
);
};
});

export const HelpTooltipTitle: FC<HTMLAttributes<HTMLHeadingElement>> = ({
children,
Expand Down Expand Up @@ -213,7 +150,7 @@ export const HelpTooltipAction: FC<HelpTooltipActionProps> = ({
onClick,
ariaLabel,
}) => {
const tooltip = useHelpTooltip();
const popover = usePopover();

return (
<button
Expand All @@ -222,7 +159,7 @@ export const HelpTooltipAction: FC<HelpTooltipActionProps> = ({
onClick={(event) => {
event.stopPropagation();
onClick();
tooltip.onClose();
popover.setIsOpen(false);
}}
>
<Icon css={styles.actionIcon} />
Expand All @@ -239,42 +176,16 @@ export const HelpTooltipLinksGroup: FC<PropsWithChildren> = ({ children }) => {
);
};

const getButtonSpacingFromSize = (size?: Size): number => {
switch (size) {
case "small":
return 2.5;
case "medium":
default:
return 3;
}
};

const getIconSpacingFromSize = (size?: Size): number => {
switch (size) {
case "small":
return 1.5;
return 12;
case "medium":
default:
return 2;
return 16;
}
};

const classNames = {
popover: (css) => css`
pointer-events: none;
`,

paper: (css, theme) => css`
${theme.typography.body2 as CSSObject}

margin-top: 4px;
width: 304px;
padding: 20px;
color: ${theme.palette.text.secondary};
pointer-events: auto;
`,
} satisfies Record<string, ClassName>;

const styles = {
title: (theme) => ({
marginTop: 0,
Expand Down
22 changes: 13 additions & 9 deletions site/src/components/InfoTooltip/InfoTooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { type FC, type ReactNode } from "react";
import {
HelpTooltip,
HelpTooltipContent,
HelpTooltipIcon,
HelpTooltipText,
HelpTooltipTitle,
HelpTooltipTrigger,
} from "components/HelpTooltip/HelpTooltip";
import InfoIcon from "@mui/icons-material/InfoOutlined";
import { Interpolation, Theme, css, useTheme } from "@emotion/react";

interface InfoTooltipProps {
Expand All @@ -22,14 +24,16 @@ export const InfoTooltip: FC<InfoTooltipProps> = ({
const theme = useTheme();

return (
<HelpTooltip
size="small"
icon={InfoIcon}
iconStyles={{ color: theme.experimental.roles[type].outline }}
buttonStyles={styles.button}
>
<HelpTooltipTitle>{title}</HelpTooltipTitle>
<HelpTooltipText>{message}</HelpTooltipText>
<HelpTooltip>
<HelpTooltipTrigger size="small" css={styles.button}>
<HelpTooltipIcon
css={{ color: theme.experimental.roles[type].outline }}
/>
</HelpTooltipTrigger>
<HelpTooltipContent>
<HelpTooltipTitle>{title}</HelpTooltipTitle>
<HelpTooltipText>{message}</HelpTooltipText>
</HelpTooltipContent>
</HelpTooltip>
);
};
Expand Down
7 changes: 6 additions & 1 deletion site/src/components/Popover/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
useId,
useRef,
useState,
HTMLAttributes,
} from "react";
// This is used as base for the main Popover component
// eslint-disable-next-line no-restricted-imports -- Read above
Expand Down Expand Up @@ -79,8 +80,11 @@ export const usePopover = () => {
return context;
};

export const PopoverTrigger = (props: { children: TriggerElement }) => {
export const PopoverTrigger = (
props: HTMLAttributes<HTMLElement> & { children: TriggerElement },
) => {
const popover = usePopover();
const { children, ...elementProps } = props;

const clickProps = {
onClick: () => {
Expand All @@ -98,6 +102,7 @@ export const PopoverTrigger = (props: { children: TriggerElement }) => {
};

return cloneElement(props.children, {
...elementProps,
...(popover.mode === "click" ? clickProps : hoverProps),
"aria-haspopup": true,
"aria-owns": popover.isOpen ? popover.id : undefined,
Expand Down
Loading