Skip to content

Commit

Permalink
Tooltip fixes and added PassiveTrigger
Browse files Browse the repository at this point in the history
  • Loading branch information
vicky-comeau committed Nov 1, 2024
1 parent f9f2f9f commit fbc46e5
Show file tree
Hide file tree
Showing 10 changed files with 266 additions and 42 deletions.
5 changes: 5 additions & 0 deletions packages/components/src/Tooltip/src/PassiveTrigger.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.hop-PassiveTrigger {
--hop-PassiveTrigger-inline-size: max-content;

inline-size: var(--hop-PassiveTrigger-inline-size);
}
80 changes: 80 additions & 0 deletions packages/components/src/Tooltip/src/PassiveTrigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useStyledSystem, type StyledSystemProps } from "@hopper-ui/styled-system";
import clsx from "clsx";
import { forwardRef, useRef, type ForwardedRef, type ReactNode } from "react";
import { useFocusable } from "react-aria";
import { useContextProps } from "react-aria-components";

import { cssModule, type BaseComponentDOMProps } from "../../utils/index.ts";

import { PassiveTriggerContext } from "./PassiveTriggerContext.ts";

import styles from "./PassiveTrigger.module.css";

export const GlobalPassiveTriggerCssSelector = "hop-PassiveTrigger";

export interface PassiveTriggerProps extends StyledSystemProps, BaseComponentDOMProps {
/**
* The children of the PassiveTrigger.
*/
children?: ReactNode;
}
/**
* A PassiveTrigger wraps a trigger element and Tooltip, handling visibility and positioning.
*
* [View Documentation](TODO)
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function PassiveTrigger(props: PassiveTriggerProps, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, PassiveTriggerContext);

const { stylingProps, ...ownProps } = useStyledSystem(props);
const backupRef = useRef<HTMLDivElement>(null);
const determinedRef = (ref ?? backupRef);
const { focusableProps } = useFocusable(ownProps, determinedRef);
const {
children,
className,
slot,
style: styleProp,
...otherProps
} = ownProps;

const classNames = clsx(
className,
GlobalPassiveTriggerCssSelector,
cssModule(
styles,
"hop-FloatingBadge"
),
stylingProps.className
);

const style = {
...stylingProps.style,
...styleProp
};

return (
<div
ref={determinedRef}
slot={slot ?? undefined}
className={classNames}
style={style}
{...focusableProps}
{...otherProps}
>
{children}
</div>
);
}

/**
* Wraps a tooltip trigger that is not normally focusable.
*
* [View Documentation](TODO)
*/
const _PassiveTrigger = forwardRef<HTMLDivElement, PassiveTriggerProps>(PassiveTrigger);
_PassiveTrigger.displayName = "PassiveTrigger";

export { _PassiveTrigger as PassiveTrigger };

8 changes: 8 additions & 0 deletions packages/components/src/Tooltip/src/PassiveTriggerContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createContext } from "react";
import type { ContextValue } from "react-aria-components";

import type { PassiveTriggerProps } from "./PassiveTrigger.tsx";

export const PassiveTriggerContext = createContext<ContextValue<PassiveTriggerProps, HTMLDivElement>>({});

PassiveTriggerContext.displayName = "PassiveTriggerContext";
3 changes: 2 additions & 1 deletion packages/components/src/Tooltip/src/Tooltip.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
--origin-x: 0;
--origin-y: 0;

max-inline-size: var(--hop-Tooltip-max-inline-size);
/* Ensures there's always 1rem space around the tooltip, but it'll still have a max width of 25rem. */
max-inline-size: min(var(--hop-Tooltip-max-inline-size), calc(100% - (var(--container-padding) * 2)));
}

.hop-Tooltip--top {
Expand Down
4 changes: 2 additions & 2 deletions packages/components/src/Tooltip/src/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { composeClassnameRenderProps, cssModule, ensureTextWrapper, SlotProvider
import { TooltipContext } from "./TooltipContext.ts";
import { TooltipTriggerContext } from "./TooltipTriggerContext.ts";


import styles from "./Tooltip.module.css";

export const GlobalTooltipCssSelector = "hop-Tooltip";
Expand Down Expand Up @@ -44,7 +43,7 @@ function Tooltip(props: TooltipProps, ref: ForwardedRef<HTMLDivElement>) {
} = ownProps;

const {
containerPadding,
containerPadding = 16,
crossOffset,
offset,
placement = "top",
Expand Down Expand Up @@ -76,6 +75,7 @@ function Tooltip(props: TooltipProps, ref: ForwardedRef<HTMLDivElement>) {
const style = composeRenderProps(styleProp, prev => {
return {
...stylingProps.style,
"--container-padding": `${containerPadding}px`,
...prev
};
});
Expand Down
4 changes: 2 additions & 2 deletions packages/components/src/Tooltip/src/TooltipTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ export interface TooltipTriggerProps extends RACTooltipTriggerProps, Pick<RACToo
export function TooltipTrigger(props: TooltipTriggerProps) {
const {
children,
containerPadding = 16, /* Should this be on the trigger or the actual tooltip component? */
containerPadding = 16,
crossOffset,
delay = 1000,
delay = 600,
offset = 4,
placement = "top",
shouldFlip,
Expand Down
4 changes: 4 additions & 0 deletions packages/components/src/Tooltip/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
export * from "./PassiveTrigger.tsx";
export * from "./PassiveTriggerContext.ts";
export * from "./Tooltip.tsx";
export * from "./TooltipContext.ts";
export * from "./TooltipTrigger.tsx";

140 changes: 117 additions & 23 deletions packages/components/src/Tooltip/tests/chromatic/Tooltip.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,36 @@
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent } from "@storybook/test";
import { expect, screen, userEvent, waitFor, within } from "@storybook/test";

import { Avatar } from "../../../Avatar/index.ts";
import { Button } from "../../../buttons/index.ts";
import { Flex, Grid } from "../../../layout/index.ts";
import { Flex, Grid, Stack } from "../../../layout/index.ts";
import { Link } from "../../../Link/index.ts";
import { H1 } from "../../../typography/Heading/index.ts";
import { PassiveTrigger } from "../../src/PassiveTrigger.tsx";
import { Tooltip } from "../../src/Tooltip.tsx";
import { TooltipTrigger } from "../../src/TooltipTrigger.tsx";

const BUTTON_TEXT = "Hover me";
const buttonText = "Hover me";
const childrenText = "More info";

const meta = {
title: "Components/Tooltip",
component: Tooltip,
args: {
children: "Click to learn more."
children: childrenText
},
decorators: [
Story => (
<Flex UNSAFE_marginBottom="4rem" UNSAFE_marginTop="3rem" justifyContent="center">
<Story />
</Flex>
)
(Story, context) => {
if (context.parameters.skipGlobalDecorator) {
return <Story />;
}

return (
<Flex UNSAFE_marginBottom="4rem" UNSAFE_marginTop="3rem" justifyContent="center">
<Story />
</Flex>
);
}
]
} satisfies Meta<typeof Tooltip>;

Expand All @@ -31,7 +41,7 @@ type Story = StoryObj<typeof meta>;
export const Default = {
render: args => (
<TooltipTrigger defaultOpen>
<Button>{BUTTON_TEXT}</Button>
<Button>{buttonText}</Button>
<Tooltip {...args} />
</TooltipTrigger>
)
Expand All @@ -45,46 +55,81 @@ export const Placement = {
width="100%"
>
<TooltipTrigger defaultOpen placement="start">
<Button>{BUTTON_TEXT}</Button>
<Button>{buttonText}</Button>
<Tooltip {...args} />
</TooltipTrigger>
<TooltipTrigger defaultOpen placement="end">
<Button>{BUTTON_TEXT}</Button>
<Button>{buttonText}</Button>
<Tooltip {...args} />
</TooltipTrigger>
<TooltipTrigger defaultOpen placement="right">
<Button>{BUTTON_TEXT}</Button>
<Button>{buttonText}</Button>
<Tooltip {...args} />
</TooltipTrigger>
<TooltipTrigger defaultOpen placement="left">
<Button>{BUTTON_TEXT}</Button>
<Button>{buttonText}</Button>
<Tooltip {...args} />
</TooltipTrigger>
<TooltipTrigger defaultOpen placement="top">
<Button>{BUTTON_TEXT}</Button>
<Button>{buttonText}</Button>
<Tooltip {...args} />
</TooltipTrigger>
<TooltipTrigger defaultOpen placement="bottom">
<Button>{BUTTON_TEXT}</Button>
<Button>{buttonText}</Button>
<Tooltip {...args} />
</TooltipTrigger>
</Grid>
)
} satisfies Story;

export const ShouldFlip = {
render: args => (
<Stack>
<H1>Original Placement: left</H1>
<TooltipTrigger defaultOpen placement="left">
<Button>{buttonText}</Button>
<Tooltip {...args} />
</TooltipTrigger>
</Stack>
),
decorators: [
Story => (
<Flex justifyContent="left">
<Story />
</Flex>
)
],
parameters: {
skipGlobalDecorator: true
}
} satisfies Story;

export const LinkTrigger = {
render: args => (
<TooltipTrigger defaultOpen>
<Link>{BUTTON_TEXT}</Link>
<Link>{buttonText}</Link>
<Tooltip {...args} />
</TooltipTrigger>
)
} satisfies Story;

export const AvatarTrigger = {
render: function Render(args) {
return (
<TooltipTrigger defaultOpen>
<PassiveTrigger>
<Avatar name="Fred Allen" />
</PassiveTrigger>
<Tooltip {...args} />
</TooltipTrigger>
);
}
} satisfies Story;

export const LongContent = {
render: args => (
<TooltipTrigger defaultOpen>
<Button>{BUTTON_TEXT}</Button>
<TooltipTrigger isOpen>
<Button>{buttonText}</Button>
<Tooltip {...args} />
</TooltipTrigger>
),
Expand All @@ -96,17 +141,66 @@ export const LongContent = {
},
decorators: [
Story => (
<Flex UNSAFE_marginTop="6rem" justifyContent="center">
<Flex UNSAFE_marginTop="8rem" justifyContent="center">
<Story />
</Flex>
)
]
],
parameters: {
skipGlobalDecorator: true
}
} satisfies Story;

export const DisabledOpen = {
render: args => (
<TooltipTrigger defaultOpen isDisabled>
<Button>{buttonText}</Button>
<Tooltip {...args} />
</TooltipTrigger>
),
play: async () => {
userEvent.tab();
}
} satisfies Story;

export const DisabledClosed = {
render: args => (
<TooltipTrigger isDisabled>
<Button>{buttonText}</Button>
<Tooltip {...args} />
</TooltipTrigger>
),
play: async () => {
userEvent.tab();
}
} satisfies Story;

export const DisabledTrigger = {
render: args => (
<TooltipTrigger>
<PassiveTrigger data-testid="passive-trigger">
<Button isDisabled>{buttonText}</Button>
</PassiveTrigger>
<Tooltip {...args} />
</TooltipTrigger>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getAllByTestId("passive-trigger")[0];
const trigger2 = canvas.getAllByTestId("passive-trigger")[1];
// For some reason, we need to hover over the second trigger first
await userEvent.hover(trigger2);
await userEvent.hover(trigger);
await waitFor(async () => {
await expect(screen.getByText(childrenText)).toBeVisible();
});
}
} satisfies Story;

export const Focus = {
render: args => (
<TooltipTrigger>
<Button>{BUTTON_TEXT}</Button>
<Button>{buttonText}</Button>
<Tooltip {...args} />
</TooltipTrigger>
),
Expand All @@ -118,7 +212,7 @@ export const Focus = {
export const Styling = {
render: args => (
<TooltipTrigger defaultOpen>
<Button>{BUTTON_TEXT}</Button>
<Button>{buttonText}</Button>
<Tooltip {...args} />
</TooltipTrigger>
),
Expand Down
Loading

0 comments on commit fbc46e5

Please sign in to comment.