From f9f2f9fff00f469e64d0bae7207b715082bf767d Mon Sep 17 00:00:00 2001 From: vicky-comeau Date: Tue, 29 Oct 2024 13:54:23 -0400 Subject: [PATCH 1/5] Feat: [DS-209] Tooltip --- .changeset/nice-fireants-worry.md | 5 + packages/components/src/Tooltip/index.ts | 1 + .../src/Tooltip/src/Tooltip.module.css | 67 +++++++++ .../components/src/Tooltip/src/Tooltip.tsx | 136 ++++++++++++++++++ .../src/Tooltip/src/TooltipContext.ts | 8 ++ .../src/Tooltip/src/TooltipTrigger.tsx | 55 +++++++ .../src/Tooltip/src/TooltipTriggerContext.ts | 7 + packages/components/src/Tooltip/src/index.ts | 2 + .../tests/chromatic/Tooltip.stories.tsx | 133 +++++++++++++++++ .../Tooltip/tests/jest/Tooltip.ssr.test.tsx | 25 ++++ .../src/Tooltip/tests/jest/Tooltip.test.tsx | 78 ++++++++++ 11 files changed, 517 insertions(+) create mode 100644 .changeset/nice-fireants-worry.md create mode 100644 packages/components/src/Tooltip/index.ts create mode 100644 packages/components/src/Tooltip/src/Tooltip.module.css create mode 100644 packages/components/src/Tooltip/src/Tooltip.tsx create mode 100644 packages/components/src/Tooltip/src/TooltipContext.ts create mode 100644 packages/components/src/Tooltip/src/TooltipTrigger.tsx create mode 100644 packages/components/src/Tooltip/src/TooltipTriggerContext.ts create mode 100644 packages/components/src/Tooltip/src/index.ts create mode 100644 packages/components/src/Tooltip/tests/chromatic/Tooltip.stories.tsx create mode 100644 packages/components/src/Tooltip/tests/jest/Tooltip.ssr.test.tsx create mode 100644 packages/components/src/Tooltip/tests/jest/Tooltip.test.tsx diff --git a/.changeset/nice-fireants-worry.md b/.changeset/nice-fireants-worry.md new file mode 100644 index 000000000..57d49456b --- /dev/null +++ b/.changeset/nice-fireants-worry.md @@ -0,0 +1,5 @@ +--- +"@hopper-ui/components": patch +--- + +Added the Tooltip component. diff --git a/packages/components/src/Tooltip/index.ts b/packages/components/src/Tooltip/index.ts new file mode 100644 index 000000000..401c73ac2 --- /dev/null +++ b/packages/components/src/Tooltip/index.ts @@ -0,0 +1 @@ +export * from "./src/index.ts"; diff --git a/packages/components/src/Tooltip/src/Tooltip.module.css b/packages/components/src/Tooltip/src/Tooltip.module.css new file mode 100644 index 000000000..411664e50 --- /dev/null +++ b/packages/components/src/Tooltip/src/Tooltip.module.css @@ -0,0 +1,67 @@ +.hop-Tooltip { + --hop-Tooltip-max-inline-size: 25rem; + --hop-Tooltip-slide-amount: 0.25rem; /* Tokens not available here */ + + /* Internal Variables */ + --origin-x: 0; + --origin-y: 0; + + max-inline-size: var(--hop-Tooltip-max-inline-size); +} + +.hop-Tooltip--top { + --origin-x: 0; + --origin-y: var(--hop-Tooltip-slide-amount); +} + +.hop-Tooltip--right { + --origin-x: calc(-1 * var(--hop-Tooltip-slide-amount)); + --origin-y: 0; +} + +.hop-Tooltip--bottom { + --origin-x: 0; + --origin-y: calc(-1 * var(--hop-Tooltip-slide-amount)); +} + +.hop-Tooltip--left { + --origin-x: var(--hop-Tooltip-slide-amount); + --origin-y: 0; +} + +.hop-Tooltip[data-entering] { + animation: slide 0.2s ease-out; +} + +.hop-Tooltip[data-exiting] { + animation: slide 0.2s ease-in reverse; +} + +.hop-Tooltip__container { + --hop-Tooltip-background: var(--hop-neutral-surface-strong); + --hop-Tooltip-border-radius: var(--hop-shape-rounded-md); + --hop-Tooltip-box-shadow: var(--hop-elevation-raised); + --hop-Tooltip-color: var(--hop-neutral-text-strong); + --hop-Tooltip-padding: var(--hop-space-inset-squish-md); + + padding: var(--hop-Tooltip-padding); + + color: var(--hop-Tooltip-color); + + background: var(--hop-Tooltip-background); + border-radius: var(--hop-Tooltip-border-radius); + box-shadow: var(--hop-Tooltip-box-shadow); +} + +@keyframes slide { + from { + transform: translate(var(--origin-x), var(--origin-y)); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } + } + diff --git a/packages/components/src/Tooltip/src/Tooltip.tsx b/packages/components/src/Tooltip/src/Tooltip.tsx new file mode 100644 index 000000000..2c4ee1023 --- /dev/null +++ b/packages/components/src/Tooltip/src/Tooltip.tsx @@ -0,0 +1,136 @@ +import { + useColorSchemeContext, + useStyledSystem, + type StyledComponentProps, + type StyledSystemProps +} from "@hopper-ui/styled-system"; +import clsx from "clsx"; +import { forwardRef, useContext, type ForwardedRef } from "react"; +import { composeRenderProps, Tooltip as RACTooltip, useContextProps, type TooltipProps as RACTooltipProps } from "react-aria-components"; + +import { HopperProvider } from "../../HopperProvider/index.ts"; +import { TextContext } from "../../typography/index.ts"; +import { composeClassnameRenderProps, cssModule, ensureTextWrapper, SlotProvider, type BaseComponentDOMProps } from "../../utils/index.ts"; + +import { TooltipContext } from "./TooltipContext.ts"; +import { TooltipTriggerContext } from "./TooltipTriggerContext.ts"; + + +import styles from "./Tooltip.module.css"; + +export const GlobalTooltipCssSelector = "hop-Tooltip"; + +type PropsToOmit = "triggerRef" | "UNSTABLE_portalContainer" | "placement" | "containerPadding" | "offset" | "crossOffset" | +"shouldFlip" | "arrowBoundaryOffset" | "isOpen" | "defaultOpen" | "onOpenChange"; + +export type TooltipContainerProps = Omit & StyledSystemProps; + +export interface TooltipProps extends StyledComponentProps> { + /** + * The props of the tooltip's inner container. + */ + containerProps?: TooltipContainerProps; +} + +function Tooltip(props: TooltipProps, ref: ForwardedRef) { + [props, ref] = useContextProps(props, ref, TooltipContext); + const { stylingProps, ...ownProps } = useStyledSystem(props); + const { + children: childrenProp, + className, + containerProps, + style: styleProp, + ...otherProps + } = ownProps; + + const { + containerPadding, + crossOffset, + offset, + placement = "top", + shouldFlip + } = useContext(TooltipTriggerContext); + + const { colorScheme } = useColorSchemeContext(); + + const { stylingProps: containerStylingProps, ...containerOwnProps } = useStyledSystem(containerProps ?? {}); + const { + className: containerClassName, + style: containerStyleProp, + ...containerOtherProps + } = containerOwnProps; + + const classNames = composeClassnameRenderProps( + className, + GlobalTooltipCssSelector, + cssModule( + styles, + "hop-Tooltip", + placement + ), + stylingProps.className + ); + + const containerClassNames = clsx(containerClassName, styles["hop-Tooltip__container"], containerStylingProps.className); + + const style = composeRenderProps(styleProp, prev => { + return { + ...stylingProps.style, + ...prev + }; + }); + + const containerStyle = { + ...containerStylingProps.style, + ...containerStyleProp + }; + + const children = composeRenderProps(childrenProp, prev => { + return ensureTextWrapper(prev); + }); + + return ( + + {tooltipRenderProps => { + return ( + +
+ + {children(tooltipRenderProps)} + +
+
+ ); + }} +
+ ); +} + +/** + * Displays concise information on hover or focus. + * + * [View Documentation](TODO) + */ +const _Tooltip = forwardRef(Tooltip); +_Tooltip.displayName = "Tooltip"; + +export { _Tooltip as Tooltip }; diff --git a/packages/components/src/Tooltip/src/TooltipContext.ts b/packages/components/src/Tooltip/src/TooltipContext.ts new file mode 100644 index 000000000..cf5d3247e --- /dev/null +++ b/packages/components/src/Tooltip/src/TooltipContext.ts @@ -0,0 +1,8 @@ +import { createContext } from "react"; +import type { ContextValue } from "react-aria-components"; + +import type { TooltipProps } from "./Tooltip.tsx"; + +export const TooltipContext = createContext>({}); + +TooltipContext.displayName = "TooltipContext"; diff --git a/packages/components/src/Tooltip/src/TooltipTrigger.tsx b/packages/components/src/Tooltip/src/TooltipTrigger.tsx new file mode 100644 index 000000000..85edcad8b --- /dev/null +++ b/packages/components/src/Tooltip/src/TooltipTrigger.tsx @@ -0,0 +1,55 @@ +import { TooltipTrigger as RACTooltipTrigger, type TooltipProps as RACTooltipProps, type TooltipTriggerComponentProps as RACTooltipTriggerProps } from "react-aria-components"; + +import { TooltipTriggerContext } from "./TooltipTriggerContext.ts"; + +export type TooltipPlacement = "start" | "end" | "right" | "left" | "top" | "bottom"; + +type PropsToPick = "shouldFlip" | "containerPadding" | "offset" | "crossOffset" | "isOpen" | "defaultOpen" | "onOpenChange"; + +export interface TooltipTriggerProps extends RACTooltipTriggerProps, Pick { + /** + * The placement of the element with respect to its anchor element. + * + * @default 'top' + */ + placement?: TooltipPlacement; +} + +/** + * A TooltipTrigger wraps a trigger element and Tooltip, handling visibility and positioning. + * + * [View Documentation](TODO) + */ +export function TooltipTrigger(props: TooltipTriggerProps) { + const { + children, + containerPadding = 16, /* Should this be on the trigger or the actual tooltip component? */ + crossOffset, + delay = 1000, + offset = 4, + placement = "top", + shouldFlip, + ...otherProps + } = props; + + return ( + + + {children} + + + ); +} + +TooltipTrigger.displayName = "TooltipTrigger"; diff --git a/packages/components/src/Tooltip/src/TooltipTriggerContext.ts b/packages/components/src/Tooltip/src/TooltipTriggerContext.ts new file mode 100644 index 000000000..84aad836b --- /dev/null +++ b/packages/components/src/Tooltip/src/TooltipTriggerContext.ts @@ -0,0 +1,7 @@ +import { createContext } from "react"; + +import type { TooltipTriggerProps } from "./TooltipTrigger.tsx"; + +export const TooltipTriggerContext = createContext>({}); + +TooltipTriggerContext.displayName = "TooltipTriggerContext"; diff --git a/packages/components/src/Tooltip/src/index.ts b/packages/components/src/Tooltip/src/index.ts new file mode 100644 index 000000000..b7b44f899 --- /dev/null +++ b/packages/components/src/Tooltip/src/index.ts @@ -0,0 +1,2 @@ +export * from "./Tooltip.tsx"; +export * from "./TooltipContext.ts"; diff --git a/packages/components/src/Tooltip/tests/chromatic/Tooltip.stories.tsx b/packages/components/src/Tooltip/tests/chromatic/Tooltip.stories.tsx new file mode 100644 index 000000000..befa8ced9 --- /dev/null +++ b/packages/components/src/Tooltip/tests/chromatic/Tooltip.stories.tsx @@ -0,0 +1,133 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { userEvent } from "@storybook/test"; + +import { Button } from "../../../buttons/index.ts"; +import { Flex, Grid } from "../../../layout/index.ts"; +import { Link } from "../../../Link/index.ts"; +import { Tooltip } from "../../src/Tooltip.tsx"; +import { TooltipTrigger } from "../../src/TooltipTrigger.tsx"; + +const BUTTON_TEXT = "Hover me"; + +const meta = { + title: "Components/Tooltip", + component: Tooltip, + args: { + children: "Click to learn more." + }, + decorators: [ + Story => ( + + + + ) + ] +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default = { + render: args => ( + + + + + ) +} satisfies Story; + +export const Placement = { + render: args => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} satisfies Story; + +export const LinkTrigger = { + render: args => ( + + {BUTTON_TEXT} + + + ) +} satisfies Story; + +export const LongContent = { + render: args => ( + + + + + ), + args: { + children: `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam in turpis + ac libero tincidunt hendrerit. Ut vitae nisl nec orci laoreet tristique. + Nulla facilisi. Nulla facilisi. Nulla facilisi. Nulla facilisi. Nulla + facilisi. Nulla facilisi. Nulla facilisi. Nulla facilisi. Nulla facilisi.` + }, + decorators: [ + Story => ( + + + + ) + ] +} satisfies Story; + +export const Focus = { + render: args => ( + + + + + ), + play: async () => { + userEvent.tab(); + } +} satisfies Story; + +export const Styling = { + render: args => ( + + + + + ), + args: { + containerProps: { + style: { + backgroundColor: "red", + color: "white" + } + } + } +} satisfies Story; diff --git a/packages/components/src/Tooltip/tests/jest/Tooltip.ssr.test.tsx b/packages/components/src/Tooltip/tests/jest/Tooltip.ssr.test.tsx new file mode 100644 index 000000000..520a1f48c --- /dev/null +++ b/packages/components/src/Tooltip/tests/jest/Tooltip.ssr.test.tsx @@ -0,0 +1,25 @@ +/** + * @jest-environment node + */ +import { renderToString } from "react-dom/server"; + +import { Button } from "../../../buttons/index.ts"; +import { Tooltip } from "../../src/Tooltip.tsx"; +import { TooltipTrigger } from "../../src/TooltipTrigger.tsx"; + +describe("Tooltip", () => { + const BUTTON_TEXT = "Trigger"; + const TOOLTIP_TEXT = "Tooltip text"; + + it("should render on the server", () => { + const renderOnServer = () => + renderToString( + + + {TOOLTIP_TEXT} + + ); + + expect(renderOnServer).not.toThrow(); + }); +}); diff --git a/packages/components/src/Tooltip/tests/jest/Tooltip.test.tsx b/packages/components/src/Tooltip/tests/jest/Tooltip.test.tsx new file mode 100644 index 000000000..f842530c0 --- /dev/null +++ b/packages/components/src/Tooltip/tests/jest/Tooltip.test.tsx @@ -0,0 +1,78 @@ +import { Button } from "@hopper-ui/components"; +import { render, screen } from "@hopper-ui/test-utils"; +import { createRef } from "react"; + +import { Tooltip } from "../../src/Tooltip.tsx"; +import { TooltipContext } from "../../src/TooltipContext.ts"; +import { TooltipTrigger } from "../../src/TooltipTrigger.tsx"; + +describe("Tooltip", () => { + const BUTTON_TEXT = "Trigger"; + const TOOLTIP_TEXT = "Tooltip text"; + + it("should render with default class", () => { + render( + + {TOOLTIP_TEXT} + ); + + const element = screen.getByRole("tooltip"); + expect(element).toHaveClass("hop-Tooltip"); + }); + + it("should support custom class", () => { + render( + + {TOOLTIP_TEXT} + ); + + const element = screen.getByRole("tooltip"); + expect(element).toHaveClass("hop-Tooltip"); + expect(element).toHaveClass("test"); + }); + + it("should support custom style", () => { + render( + + {TOOLTIP_TEXT} + ); + + const element = screen.getByRole("tooltip"); + expect(element).toHaveStyle({ marginTop: "var(--hop-space-stack-sm)", marginBottom: "13px" }); + }); + + it("should support DOM props", () => { + render( + + {TOOLTIP_TEXT} + ); + + const element = screen.getByRole("tooltip"); + expect(element).toHaveAttribute("data-foo", "bar"); + }); + + it("should support context", () => { + render( + + ( + + {TOOLTIP_TEXT} + + + ); + + const tooltip = screen.getByRole("tooltip"); + expect(tooltip).toHaveAttribute("aria-label", "test"); + }); + + it("should support refs", () => { + const ref = createRef(); + render( + + {TOOLTIP_TEXT} + ); + + expect(ref.current).not.toBeNull(); + expect(ref.current instanceof HTMLDivElement).toBeTruthy(); + }); +}); From fbc46e5868dd5c21dda89e0a7d8b5edfda770acf Mon Sep 17 00:00:00 2001 From: vicky-comeau Date: Fri, 1 Nov 2024 12:01:24 -0400 Subject: [PATCH 2/5] Tooltip fixes and added PassiveTrigger --- .../src/Tooltip/src/PassiveTrigger.module.css | 5 + .../src/Tooltip/src/PassiveTrigger.tsx | 80 ++++++++++ .../src/Tooltip/src/PassiveTriggerContext.ts | 8 + .../src/Tooltip/src/Tooltip.module.css | 3 +- .../components/src/Tooltip/src/Tooltip.tsx | 4 +- .../src/Tooltip/src/TooltipTrigger.tsx | 4 +- packages/components/src/Tooltip/src/index.ts | 4 + .../tests/chromatic/Tooltip.stories.tsx | 140 +++++++++++++++--- .../src/Tooltip/tests/jest/Tooltip.test.tsx | 59 ++++++-- packages/components/src/index.ts | 1 + 10 files changed, 266 insertions(+), 42 deletions(-) create mode 100644 packages/components/src/Tooltip/src/PassiveTrigger.module.css create mode 100644 packages/components/src/Tooltip/src/PassiveTrigger.tsx create mode 100644 packages/components/src/Tooltip/src/PassiveTriggerContext.ts diff --git a/packages/components/src/Tooltip/src/PassiveTrigger.module.css b/packages/components/src/Tooltip/src/PassiveTrigger.module.css new file mode 100644 index 000000000..74c9e6b3d --- /dev/null +++ b/packages/components/src/Tooltip/src/PassiveTrigger.module.css @@ -0,0 +1,5 @@ +.hop-PassiveTrigger { + --hop-PassiveTrigger-inline-size: max-content; + + inline-size: var(--hop-PassiveTrigger-inline-size); +} \ No newline at end of file diff --git a/packages/components/src/Tooltip/src/PassiveTrigger.tsx b/packages/components/src/Tooltip/src/PassiveTrigger.tsx new file mode 100644 index 000000000..3d745ac6e --- /dev/null +++ b/packages/components/src/Tooltip/src/PassiveTrigger.tsx @@ -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) { + [props, ref] = useContextProps(props, ref, PassiveTriggerContext); + + const { stylingProps, ...ownProps } = useStyledSystem(props); + const backupRef = useRef(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 ( +
+ {children} +
+ ); +} + +/** + * Wraps a tooltip trigger that is not normally focusable. + * + * [View Documentation](TODO) + */ +const _PassiveTrigger = forwardRef(PassiveTrigger); +_PassiveTrigger.displayName = "PassiveTrigger"; + +export { _PassiveTrigger as PassiveTrigger }; + diff --git a/packages/components/src/Tooltip/src/PassiveTriggerContext.ts b/packages/components/src/Tooltip/src/PassiveTriggerContext.ts new file mode 100644 index 000000000..ac2b46a52 --- /dev/null +++ b/packages/components/src/Tooltip/src/PassiveTriggerContext.ts @@ -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>({}); + +PassiveTriggerContext.displayName = "PassiveTriggerContext"; diff --git a/packages/components/src/Tooltip/src/Tooltip.module.css b/packages/components/src/Tooltip/src/Tooltip.module.css index 411664e50..5f2c36dfb 100644 --- a/packages/components/src/Tooltip/src/Tooltip.module.css +++ b/packages/components/src/Tooltip/src/Tooltip.module.css @@ -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 { diff --git a/packages/components/src/Tooltip/src/Tooltip.tsx b/packages/components/src/Tooltip/src/Tooltip.tsx index 2c4ee1023..00d0afe21 100644 --- a/packages/components/src/Tooltip/src/Tooltip.tsx +++ b/packages/components/src/Tooltip/src/Tooltip.tsx @@ -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"; @@ -44,7 +43,7 @@ function Tooltip(props: TooltipProps, ref: ForwardedRef) { } = ownProps; const { - containerPadding, + containerPadding = 16, crossOffset, offset, placement = "top", @@ -76,6 +75,7 @@ function Tooltip(props: TooltipProps, ref: ForwardedRef) { const style = composeRenderProps(styleProp, prev => { return { ...stylingProps.style, + "--container-padding": `${containerPadding}px`, ...prev }; }); diff --git a/packages/components/src/Tooltip/src/TooltipTrigger.tsx b/packages/components/src/Tooltip/src/TooltipTrigger.tsx index 85edcad8b..59edc18cb 100644 --- a/packages/components/src/Tooltip/src/TooltipTrigger.tsx +++ b/packages/components/src/Tooltip/src/TooltipTrigger.tsx @@ -23,9 +23,9 @@ export interface TooltipTriggerProps extends RACTooltipTriggerProps, Pick ( - - - - ) + (Story, context) => { + if (context.parameters.skipGlobalDecorator) { + return ; + } + + return ( + + + + ); + } ] } satisfies Meta; @@ -31,7 +41,7 @@ type Story = StoryObj; export const Default = { render: args => ( - + ) @@ -45,46 +55,81 @@ export const Placement = { width="100%" > - + - + - + - + - + - + ) } satisfies Story; +export const ShouldFlip = { + render: args => ( + +

Original Placement: left

+ + + + +
+ ), + decorators: [ + Story => ( + + + + ) + ], + parameters: { + skipGlobalDecorator: true + } +} satisfies Story; + export const LinkTrigger = { render: args => ( - {BUTTON_TEXT} + {buttonText} ) } satisfies Story; +export const AvatarTrigger = { + render: function Render(args) { + return ( + + + + + + + ); + } +} satisfies Story; + export const LongContent = { render: args => ( - - + + ), @@ -96,17 +141,66 @@ export const LongContent = { }, decorators: [ Story => ( - + ) - ] + ], + parameters: { + skipGlobalDecorator: true + } +} satisfies Story; + +export const DisabledOpen = { + render: args => ( + + + + + ), + play: async () => { + userEvent.tab(); + } +} satisfies Story; + +export const DisabledClosed = { + render: args => ( + + + + + ), + play: async () => { + userEvent.tab(); + } +} satisfies Story; + +export const DisabledTrigger = { + render: args => ( + + + + + + + ), + 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 => ( - + ), @@ -118,7 +212,7 @@ export const Focus = { export const Styling = { render: args => ( - + ), diff --git a/packages/components/src/Tooltip/tests/jest/Tooltip.test.tsx b/packages/components/src/Tooltip/tests/jest/Tooltip.test.tsx index f842530c0..71f55fe37 100644 --- a/packages/components/src/Tooltip/tests/jest/Tooltip.test.tsx +++ b/packages/components/src/Tooltip/tests/jest/Tooltip.test.tsx @@ -1,5 +1,6 @@ import { Button } from "@hopper-ui/components"; import { render, screen } from "@hopper-ui/test-utils"; +import { userEvent } from "@testing-library/user-event"; import { createRef } from "react"; import { Tooltip } from "../../src/Tooltip.tsx"; @@ -7,13 +8,13 @@ import { TooltipContext } from "../../src/TooltipContext.ts"; import { TooltipTrigger } from "../../src/TooltipTrigger.tsx"; describe("Tooltip", () => { - const BUTTON_TEXT = "Trigger"; - const TOOLTIP_TEXT = "Tooltip text"; + const buttonText = "Trigger"; + const tooltipText = "Tooltip text"; it("should render with default class", () => { render( - - {TOOLTIP_TEXT} + + {tooltipText} ); const element = screen.getByRole("tooltip"); @@ -22,8 +23,8 @@ describe("Tooltip", () => { it("should support custom class", () => { render( - - {TOOLTIP_TEXT} + + {tooltipText} ); const element = screen.getByRole("tooltip"); @@ -33,8 +34,8 @@ describe("Tooltip", () => { it("should support custom style", () => { render( - - {TOOLTIP_TEXT} + + {tooltipText} ); const element = screen.getByRole("tooltip"); @@ -43,8 +44,8 @@ describe("Tooltip", () => { it("should support DOM props", () => { render( - - {TOOLTIP_TEXT} + + {tooltipText} ); const element = screen.getByRole("tooltip"); @@ -55,8 +56,8 @@ describe("Tooltip", () => { render( ( - - {TOOLTIP_TEXT} + + {tooltipText} ); @@ -68,11 +69,41 @@ describe("Tooltip", () => { it("should support refs", () => { const ref = createRef(); render( - - {TOOLTIP_TEXT} + + {tooltipText} ); expect(ref.current).not.toBeNull(); expect(ref.current instanceof HTMLDivElement).toBeTruthy(); }); + + it("should render a visible tooltip that won't disappear", async () => { + render( + + + {tooltipText} + + ); + const user = userEvent.setup(); + + await user.tab(); + + const tooltip = screen.queryByRole("tooltip"); + expect(tooltip).toBeVisible(); + }); + + it("should render a tooltip that will not appear", async () => { + render( + + + {tooltipText} + + ); + const user = userEvent.setup(); + + await user.tab(); + + const tooltip = screen.queryByRole("tooltip"); + expect(tooltip).toBeNull(); + }); }); diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 299ced646..2d53257ed 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -21,6 +21,7 @@ export * from "./Select/index.ts"; export * from "./Spinner/index.ts"; export * from "./switch/index.ts"; export * from "./tag/index.ts"; +export * from "./Tooltip/index.ts"; export * from "./typography/Heading/index.ts"; export * from "./typography/Label/index.ts"; export * from "./typography/OverlineText/index.ts"; From e6bfefa1a56a8fa068728157a535cfe1cff39ae3 Mon Sep 17 00:00:00 2001 From: vicky-comeau Date: Fri, 1 Nov 2024 12:07:48 -0400 Subject: [PATCH 3/5] removed stories that were added to jest tests --- .../tests/chromatic/Tooltip.stories.tsx | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/packages/components/src/Tooltip/tests/chromatic/Tooltip.stories.tsx b/packages/components/src/Tooltip/tests/chromatic/Tooltip.stories.tsx index d5ecb7233..791892444 100644 --- a/packages/components/src/Tooltip/tests/chromatic/Tooltip.stories.tsx +++ b/packages/components/src/Tooltip/tests/chromatic/Tooltip.stories.tsx @@ -151,30 +151,6 @@ export const LongContent = { } } satisfies Story; -export const DisabledOpen = { - render: args => ( - - - - - ), - play: async () => { - userEvent.tab(); - } -} satisfies Story; - -export const DisabledClosed = { - render: args => ( - - - - - ), - play: async () => { - userEvent.tab(); - } -} satisfies Story; - export const DisabledTrigger = { render: args => ( From 5485bb4a48ed7d71642a2bd620ffad5196ecaa41 Mon Sep 17 00:00:00 2001 From: vicky-comeau Date: Fri, 1 Nov 2024 14:37:33 -0400 Subject: [PATCH 4/5] Added disabled listItem with tooltip --- .../src/Tooltip/src/PassiveTrigger.tsx | 2 +- .../tests/chromatic/Tooltip.stories.tsx | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/components/src/Tooltip/src/PassiveTrigger.tsx b/packages/components/src/Tooltip/src/PassiveTrigger.tsx index 3d745ac6e..3a792f9b0 100644 --- a/packages/components/src/Tooltip/src/PassiveTrigger.tsx +++ b/packages/components/src/Tooltip/src/PassiveTrigger.tsx @@ -44,7 +44,7 @@ function PassiveTrigger(props: PassiveTriggerProps, ref: ForwardedRef ( + + + + + + Earth + + + Mars + Saturn + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + let trigger; + let trigger2; + await waitFor(async () => { + trigger = canvas.getAllByTestId("passive-trigger")[0]; + trigger2 = canvas.getAllByTestId("passive-trigger")[1]; + }); + // For some reason, we need to hover over the second trigger first + if (trigger2) { + await userEvent.hover(trigger2); + } + if (trigger) { + await userEvent.hover(trigger); + } + await waitFor(async () => { + await expect(screen.getByText(childrenText)).toBeVisible(); + }); + } +} satisfies Story; + export const Focus = { render: args => ( From 1a6b0b0d2fcf02580096a4d88ad94ed8b5961d13 Mon Sep 17 00:00:00 2001 From: vicky-comeau Date: Tue, 5 Nov 2024 11:23:36 -0500 Subject: [PATCH 5/5] Fixed come pr comments --- .../tests/chromatic/ListBox.stories.tsx | 42 ++++++++++++++++- .../src/Tooltip/src/PassiveTrigger.tsx | 7 +-- .../tests/chromatic/Tooltip.stories.tsx | 45 ++----------------- 3 files changed, 45 insertions(+), 49 deletions(-) diff --git a/packages/components/src/ListBox/tests/chromatic/ListBox.stories.tsx b/packages/components/src/ListBox/tests/chromatic/ListBox.stories.tsx index 811779915..1a75fe000 100644 --- a/packages/components/src/ListBox/tests/chromatic/ListBox.stories.tsx +++ b/packages/components/src/ListBox/tests/chromatic/ListBox.stories.tsx @@ -1,7 +1,7 @@ import { SparklesIcon } from "@hopper-ui/icons"; import { Div } from "@hopper-ui/styled-system"; import type { Meta, StoryObj } from "@storybook/react"; -import { within } from "@storybook/test"; +import { expect, screen, userEvent, waitFor, within } from "@storybook/test"; import { Avatar } from "../../../Avatar/index.ts"; import { Badge } from "../../../Badge/index.ts"; @@ -9,6 +9,7 @@ import { Header } from "../../../Header/index.ts"; import { IconList } from "../../../IconList/index.ts"; import { Inline, Stack } from "../../../layout/index.ts"; import { Section } from "../../../Section/index.ts"; +import { PassiveTrigger, Tooltip, TooltipTrigger } from "../../../Tooltip/index.ts"; import { Text } from "../../../typography/Text/index.ts"; import { ListBox, ListBoxItem, type ListBoxProps } from "../../index.ts"; @@ -734,6 +735,45 @@ export const InputMultiSelect = { } } satisfies Story; +export const DisabledListItemWithTooltip = { + render: args => ( + + + + + + Earth + + + Mars + Saturn + + + More info + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + let trigger; + let trigger2; + await waitFor(async () => { + trigger = canvas.getAllByTestId("passive-trigger")[0]; + trigger2 = canvas.getAllByTestId("passive-trigger")[1]; + }); + // For some reason, we need to hover over the second trigger first + if (trigger2) { + await userEvent.hover(trigger2); + } + if (trigger) { + await userEvent.hover(trigger); + } + await waitFor(async () => { + await expect(screen.getByText("More info")).toBeVisible(); + }); + } +} satisfies Story; + interface StateTemplateProps extends Partial> { "data-chromatic-force"?: string[]; } diff --git a/packages/components/src/Tooltip/src/PassiveTrigger.tsx b/packages/components/src/Tooltip/src/PassiveTrigger.tsx index 3a792f9b0..b4c0ff4dc 100644 --- a/packages/components/src/Tooltip/src/PassiveTrigger.tsx +++ b/packages/components/src/Tooltip/src/PassiveTrigger.tsx @@ -18,12 +18,7 @@ export interface PassiveTriggerProps extends StyledSystemProps, BaseComponentDOM */ 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) { [props, ref] = useContextProps(props, ref, PassiveTriggerContext); diff --git a/packages/components/src/Tooltip/tests/chromatic/Tooltip.stories.tsx b/packages/components/src/Tooltip/tests/chromatic/Tooltip.stories.tsx index 29b79edf6..ca88741aa 100644 --- a/packages/components/src/Tooltip/tests/chromatic/Tooltip.stories.tsx +++ b/packages/components/src/Tooltip/tests/chromatic/Tooltip.stories.tsx @@ -1,13 +1,11 @@ +import { SparklesIcon } from "@hopper-ui/icons"; import type { Meta, StoryObj } from "@storybook/react"; import { expect, screen, userEvent, waitFor, within } from "@storybook/test"; -import { Avatar } from "../../../Avatar/index.ts"; import { Button } from "../../../buttons/index.ts"; import { Flex, Grid, Stack } from "../../../layout/index.ts"; import { Link } from "../../../Link/index.ts"; -import { ListBox, ListBoxItem } from "../../../ListBox/index.ts"; import { H1 } from "../../../typography/Heading/index.ts"; -import { Text } from "../../../typography/Text/index.ts"; import { PassiveTrigger } from "../../src/PassiveTrigger.tsx"; import { Tooltip } from "../../src/Tooltip.tsx"; import { TooltipTrigger } from "../../src/TooltipTrigger.tsx"; @@ -115,12 +113,12 @@ export const LinkTrigger = { ) } satisfies Story; -export const AvatarTrigger = { +export const IconTrigger = { render: function Render(args) { return ( - + @@ -175,43 +173,6 @@ export const DisabledTrigger = { } } satisfies Story; -export const DisabledListItem = { - render: args => ( - - - - - - Earth - - - Mars - Saturn - - - - ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - let trigger; - let trigger2; - await waitFor(async () => { - trigger = canvas.getAllByTestId("passive-trigger")[0]; - trigger2 = canvas.getAllByTestId("passive-trigger")[1]; - }); - // For some reason, we need to hover over the second trigger first - if (trigger2) { - await userEvent.hover(trigger2); - } - if (trigger) { - await userEvent.hover(trigger); - } - await waitFor(async () => { - await expect(screen.getByText(childrenText)).toBeVisible(); - }); - } -} satisfies Story; - export const Focus = { render: args => (