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