diff --git a/playwright/visual.test.ts-snapshots/Form-Controls-Password-Visible-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Form-Controls-Password-Visible-1-chromium-linux.png index c5e256de..467ec286 100644 Binary files a/playwright/visual.test.ts-snapshots/Form-Controls-Password-Visible-1-chromium-linux.png and b/playwright/visual.test.ts-snapshots/Form-Controls-Password-Visible-1-chromium-linux.png differ diff --git a/playwright/visual.test.ts-snapshots/Tooltip-Align-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Tooltip-Align-1-chromium-linux.png deleted file mode 100644 index e23d7c37..00000000 Binary files a/playwright/visual.test.ts-snapshots/Tooltip-Align-1-chromium-linux.png and /dev/null differ diff --git a/playwright/visual.test.ts-snapshots/Tooltip-Placement-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Tooltip-Placement-1-chromium-linux.png new file mode 100644 index 00000000..f0c492fa Binary files /dev/null and b/playwright/visual.test.ts-snapshots/Tooltip-Placement-1-chromium-linux.png differ diff --git a/playwright/visual.test.ts-snapshots/Tooltip-Side-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Tooltip-Side-1-chromium-linux.png deleted file mode 100644 index c3dbf0b4..00000000 Binary files a/playwright/visual.test.ts-snapshots/Tooltip-Side-1-chromium-linux.png and /dev/null differ diff --git a/src/components/Form/Controls/Action/Action.stories.tsx b/src/components/Form/Controls/Action/Action.stories.tsx index 06f64efc..d6936264 100644 --- a/src/components/Form/Controls/Action/Action.stories.tsx +++ b/src/components/Form/Controls/Action/Action.stories.tsx @@ -31,7 +31,6 @@ const icons = { }; import { ActionInput } from "./"; -import { TooltipProvider } from "../../../Tooltip/TooltipProvider"; type Props = { invalid?: boolean } & React.ComponentProps; @@ -89,9 +88,7 @@ export default { }, }, render: ({ invalid, ...restArgs }) => ( - - - + ), args: { placeholder: "", diff --git a/src/components/Form/Controls/Action/Action.test.tsx b/src/components/Form/Controls/Action/Action.test.tsx index 3ee66325..529c5b49 100644 --- a/src/components/Form/Controls/Action/Action.test.tsx +++ b/src/components/Form/Controls/Action/Action.test.tsx @@ -20,20 +20,17 @@ import React from "react"; import ChatIcon from "@vector-im/compound-design-tokens/icons/chat.svg"; import { ActionInput } from "./Action"; -import { TooltipProvider } from "../../../Tooltip/TooltipProvider"; describe("ActionInput", () => { it("renders", () => { const { asFragment } = render( - - { - console.log("clicked!"); - }} - /> - , + { + console.log("clicked!"); + }} + />, ); expect(asFragment()).toMatchSnapshot(); }); @@ -42,13 +39,11 @@ describe("ActionInput", () => { const spy = vi.fn(); const { container } = render( - - - , + , ); const actionBtn = getByLabelText(container, "Click me!"); diff --git a/src/components/Form/Controls/Password/Password.stories.tsx b/src/components/Form/Controls/Password/Password.stories.tsx index 97ecbb73..70e970b4 100644 --- a/src/components/Form/Controls/Password/Password.stories.tsx +++ b/src/components/Form/Controls/Password/Password.stories.tsx @@ -22,7 +22,6 @@ import { Meta, StoryObj } from "@storybook/react"; import { PasswordInput } from "./"; import { within } from "@storybook/testing-library"; import { userEvent } from "@storybook/testing-library"; -import { TooltipProvider } from "../../../Tooltip/TooltipProvider"; type Props = { invalid?: boolean } & React.ComponentProps; @@ -63,9 +62,7 @@ export default { }, }, render: ({ invalid, ...restArgs }) => ( - - - + ), args: { placeholder: "", diff --git a/src/components/Form/Controls/Password/Password.test.tsx b/src/components/Form/Controls/Password/Password.test.tsx index 872599d8..64a4b2e6 100644 --- a/src/components/Form/Controls/Password/Password.test.tsx +++ b/src/components/Form/Controls/Password/Password.test.tsx @@ -20,15 +20,10 @@ import { act, getByLabelText, render } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { PasswordInput } from "./Password"; -import { TooltipProvider } from "../../../Tooltip/TooltipProvider"; describe("PasswordControl", () => { it("switches the input type", async () => { - const { container } = render( - - - , - ); + const { container } = render(); expect(container.querySelector("[type=password]")).toBeInTheDocument(); expect(container).toMatchSnapshot("invisible"); diff --git a/src/components/Form/Form.stories.tsx b/src/components/Form/Form.stories.tsx index f4972304..f2be1c59 100644 --- a/src/components/Form/Form.stories.tsx +++ b/src/components/Form/Form.stories.tsx @@ -19,7 +19,6 @@ import React from "react"; import { Meta, StoryObj } from "@storybook/react"; import * as Form from "./index"; -import { TooltipProvider } from "../Tooltip/TooltipProvider"; type Props = { disabled: boolean; @@ -28,129 +27,127 @@ type Props = { }; const KitchenSink = ({ disabled, invalid, readOnly }: Props) => ( - - - - Username - + + Username + + {invalid ? ( + Error message. + ) : ( + Help message. + )} + + + + Password + + {invalid ? ( + Error message. + ) : ( + Help message. + )} + + + + MFA + + {invalid ? ( + Error message. + ) : ( + Help message. + )} + + + - {invalid ? ( - Error message. - ) : ( - Help message. - )} - - - - Password - + Remember me + {invalid ? ( + Error message. + ) : ( + Help message. + )} + + + - {invalid ? ( - Error message. - ) : ( - Help message. - )} - - - - MFA - + Option 1 + {invalid ? ( + Error message. + ) : ( + Help message. + )} + + + - {invalid ? ( - Error message. - ) : ( - Help message. - )} - - - - } - > - Remember me - {invalid ? ( - Error message. - ) : ( - Help message. - )} - - - - } - > - Option 1 - {invalid ? ( - Error message. - ) : ( - Help message. - )} - - - - } - > - Option 2 - {invalid ? ( - Error message. - ) : ( - Help message. - )} - - - - } - > - Toggle - {invalid ? ( - Error message. - ) : ( - Help message. - )} - - - Submit - - + } + > + Option 2 + {invalid ? ( + Error message. + ) : ( + Help message. + )} + + + + } + > + Toggle + {invalid ? ( + Error message. + ) : ( + Help message. + )} + + + Submit + ); export default { diff --git a/src/components/Tooltip/Tooltip.stories.tsx b/src/components/Tooltip/Tooltip.stories.tsx index 0a45971d..66e72e45 100644 --- a/src/components/Tooltip/Tooltip.stories.tsx +++ b/src/components/Tooltip/Tooltip.stories.tsx @@ -14,37 +14,24 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { FC, ReactNode } from "react"; -import { Meta, StoryFn } from "@storybook/react"; - +import { Placement as PlacementType } from "@floating-ui/react"; import { Tooltip as TooltipComponent } from "./Tooltip"; import { IconButton } from "../Button"; - import UserIcon from "@vector-im/compound-design-tokens/icons/user-profile.svg"; -import { TooltipProvider } from "./TooltipProvider"; +import { Meta, StoryFn } from "@storybook/react"; +import React, { FC, ReactNode } from "react"; export default { title: "Tooltip", component: TooltipComponent, tags: ["autodocs"], controls: { - include: [ - "side", - "align", - "open", - "label", - "caption", - "isTriggerInteractive", - ], + include: ["placement", "open", "label", "caption", "isTriggerInteractive"], }, argTypes: { - side: { - control: "inline-radio", - options: ["left", "right", "top", "bottom"], - }, - align: { + placement: { control: "inline-radio", - options: ["center", "start", "end"], + options: ["top", "right", "left", "bottom"], }, open: { control: "boolean", @@ -60,11 +47,10 @@ export default { }, }, args: { - side: "left", - align: "center", - open: undefined, + placement: "left", label: "@bob:example.org", - caption: undefined, + // needed, to prevent the tooltip to be in controlled mode + onOpenChange: undefined, children: ( @@ -73,11 +59,9 @@ export default { }, decorators: [ (Story: StoryFn) => ( - -
- -
-
+
+ +
), ], } as Meta; @@ -99,40 +83,25 @@ const Layout: FC = ({ children }) => ( ); -const TemplateSide: StoryFn = () => ( - - {(["top", "right", "bottom", "left"] as const).map((side) => ( - - - - - - ))} - -); - -export const Side = TemplateSide.bind({}); -Side.args = {}; - -const TemplateAlign: StoryFn = () => ( +const TemplatePlacement: StoryFn = () => ( - - - - - - {(["start", "end"] as const).map((align) => ( + {( + [ + "top", + "top-start", + "right", + "right-end", + "bottom", + "bottom-end", + "left", + "left-start", + ] as Array + ).map((placement) => ( @@ -142,14 +111,13 @@ const TemplateAlign: StoryFn = () => ( ); -export const Align = TemplateAlign.bind({}); -Align.args = {}; +export const Placement = TemplatePlacement.bind({}); +Placement.args = {}; export const Default = { args: { // unset to test defaults - side: undefined, - align: undefined, + placement: undefined, }, }; diff --git a/src/components/Tooltip/Tooltip.test.tsx b/src/components/Tooltip/Tooltip.test.tsx index 3cf55da6..d63eb450 100644 --- a/src/components/Tooltip/Tooltip.test.tsx +++ b/src/components/Tooltip/Tooltip.test.tsx @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { describe, it, expect, beforeAll, afterEach } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; import React from "react"; import * as stories from "./Tooltip.stories"; @@ -31,12 +31,6 @@ const { } = composeStories(stories); describe("Tooltip", () => { - beforeAll(() => { - global.ResizeObserver = require("resize-observer-polyfill"); - }); - - afterEach(cleanup); - it("renders open by default", () => { const { asFragment } = render(); // trigger rendered diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index fe09a097..3d4de45f 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -14,52 +14,34 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { PropsWithChildren } from "react"; -import { Root, Trigger, Portal, Content, Arrow } from "@radix-ui/react-tooltip"; +import { TooltipContext, useTooltipContext } from "./TooltipContext"; +import { + FloatingArrow, + FloatingPortal, + Placement, + useMergeRefs, +} from "@floating-ui/react"; +import React, { + PropsWithChildren, + Ref, + JSX, + isValidElement, + cloneElement, +} from "react"; -import styles from "./Tooltip.module.css"; import classNames from "classnames"; +import styles from "./Tooltip.module.css"; +import { useTooltip } from "./useTooltip"; -type TooltipProps = { - /** - * The tooltip label - */ - label: string; - /** - * The tooltip caption - */ - caption?: string; - /** - * The side where the tooltip is rendered - * @default bottom - */ - side?: React.ComponentProps["side"]; - /** - * The preferred alignment against the trigger. - * May change when collisions occur. - * @default center - */ - align?: React.ComponentProps["align"]; - /** - * Event handler called when the escape key is down. - */ - onEscapeKeyDown?: React.ComponentProps["onEscapeKeyDown"]; - /** - * Event handler called when a pointer event occurs outside - * the bounds of the component. - */ - onPointerDownOutside?: React.ComponentProps< - typeof Content - >["onPointerDownOutside"]; +type UseTooltipParam = Parameters[0]; + +interface TooltipProps + extends Omit { /** - * The controlled open state of the tooltip. - * When true, the tooltip is always open. When false, the tooltip is always hidden. - * When undefined, the tooltip will manage its own open state. - * You will mostly want to omit this property. Will be used the vast majority - * of the time during development. - * @default undefined + * The placement of the component + * @default "bottom" */ - open?: boolean; + placement?: Placement; /** * Whether the trigger element is interactive. * When trigger is interactive: @@ -71,58 +53,114 @@ type TooltipProps = { */ isTriggerInteractive?: boolean; /** - * Tab index to apply to the span wrapping non interactive tooltip triggers. - * Only used when `isTriggerInteractive` is false. + * The tab index for the non interactive trigger. * @default 0 */ nonInteractiveTriggerTabIndex?: number; -}; + /** + * The tooltip label + */ + label: string; +} /** * A tooltip component */ -export const Tooltip = ({ +export function Tooltip({ children, - label, - caption, - side = "bottom", - align = "center", - onEscapeKeyDown, - onPointerDownOutside, + placement = "bottom", isTriggerInteractive = true, nonInteractiveTriggerTabIndex = 0, - open, -}: PropsWithChildren): JSX.Element => { + label, + ...props +}: PropsWithChildren) { + const context = useTooltip({ placement, isTriggerInteractive, ...props }); + return ( - - + + {isTriggerInteractive ? ( children ) : ( {children} )} - - - - {label} - {/* Forcing dark theme, so that we have the correct contrast when - using the text color secondary on a solid dark background. - This is temporary and should only remain until we figure out + + + {label} + {/* Forcing dark theme, so that we have the correct contrast when + using the text color secondary on a solid dark background. + This is temporary and should only remain until we figure out the approach to on-solid tokens */} - {caption && ( - - {caption} - - )} - - - - + {props.caption && ( + + {props.caption} + + )} + + + ); +} + +/** + * The content of the tooltip + * @param children + */ +function TooltipContent({ + children, +}: Readonly): JSX.Element | null { + const { context: floatingContext, arrowRef, ...rest } = useTooltipContext(); + + if (!floatingContext.open) return null; + + return ( + +
+ + {children} +
+
+ ); +} + +/** + * The anchor of the tooltip + * @param children + */ +function TooltipAnchor({ children }: Readonly): JSX.Element { + const context = useTooltipContext(); + + // The children can have a ref and we don't want to discard it + // Doing a dirty cast to get the optional ref + const childrenRef = (children as unknown as { ref?: Ref })?.ref; + const ref = useMergeRefs([context.refs.setReference, childrenRef]); + + if (!isValidElement(children)) { + throw new Error("Tooltip anchor must be a single valid React element"); + } + + return cloneElement( + children, + context.getReferenceProps({ + ref, + ...children.props, + "data-state": context.open ? "open" : "closed", + }), ); -}; +} diff --git a/src/components/Tooltip/TooltipContext.ts b/src/components/Tooltip/TooltipContext.ts new file mode 100644 index 00000000..64fa2ae1 --- /dev/null +++ b/src/components/Tooltip/TooltipContext.ts @@ -0,0 +1,39 @@ +/* + * + * Copyright 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * / + */ + +import { createContext, useContext } from "react"; +import { useTooltip } from "./useTooltip"; + +type ContextType = ReturnType | null; +/** + * The context for the Tooltip components. + */ +export const TooltipContext = createContext(null); + +/** + * Provides the context for the Tooltip components. + */ +export function useTooltipContext() { + const context = useContext(TooltipContext); + + if (context == null) { + throw new Error("Tooltip components must be wrapped in "); + } + + return context; +} diff --git a/src/components/Tooltip/TooltipProvider.tsx b/src/components/Tooltip/TooltipProvider.tsx deleted file mode 100644 index b41ca95e..00000000 --- a/src/components/Tooltip/TooltipProvider.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/* -Copyright 2024 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { Provider } from "@radix-ui/react-tooltip"; -import { FC, ReactNode } from "react"; - -interface TooltipProviderProps { - children: ReactNode; -} - -/** - * Provides global functionality to your tooltips. You must wrap your - * application in this component for tooltips to function. - */ -export const TooltipProvider: FC = Provider; diff --git a/src/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap b/src/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap index 6bb8b4d3..19d9982a 100644 --- a/src/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap +++ b/src/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap @@ -1,13 +1,44 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Tooltip > opens tooltip on focus 1`] = ` - - Shown with delay - + + + Shown with delay + + `; exports[`Tooltip > opens tooltip on focus where trigger is non interactive 1`] = ` @@ -57,13 +88,44 @@ exports[`Tooltip > opens tooltip on focus where trigger is non interactive 1`] = `; exports[`Tooltip > opens tooltip on focus where trigger is non interactive 2`] = ` - - Shown without delay - + + + Shown without delay + + `; exports[`Tooltip > overrides default tab index for non interactive triggers 1`] = ` @@ -127,13 +189,44 @@ exports[`Tooltip > renders closed by default 1`] = ` `; exports[`Tooltip > renders default tooltip 1`] = ` - - @bob:example.org - + + + @bob:example.org + + `; exports[`Tooltip > renders open by default 1`] = ` @@ -142,9 +235,9 @@ exports[`Tooltip > renders open by default 1`] = ` style="padding: 100px;" >