diff --git a/package.json b/package.json index 0277abf9..c3ef010c 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "@tsconfig/vite-react": "^3.0.0", "@types/eslint": "^9.0.0", "@types/node": "^22.0.0", - "@types/react": "^18.2.21", + "@types/react": "^19.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vector-im/compound-design-tokens": "^2.0.0", @@ -86,8 +86,8 @@ "eslint-plugin-storybook": "^0.11.0", "jsdom": "^25.0.0", "prettier": "3.4.2", - "react": "^18.2.0", - "react-dom": "^18.3.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", "resize-observer-polyfill": "^1.5.1", "rimraf": "^6.0.0", "serve": "^14.2.1", @@ -111,7 +111,6 @@ "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "classnames": "^2.5.1", - "ts-xor": "^1.3.0", "vaul": "^1.0.0" }, "peerDependencies": { @@ -119,7 +118,7 @@ "@fontsource/inter": "^5", "@types/react": "*", "@vector-im/compound-design-tokens": ">=1.6.1 <3.0.0", - "react": "^18" + "react": "^18 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index ef42cb09..71a28118 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -15,20 +15,16 @@ limitations under the License. */ import classnames from "classnames"; -import React, { forwardRef } from "react"; +import React, { ComponentProps, forwardRef } from "react"; import { getInitialLetter } from "../../utils/string"; -import { SuspenseImg } from "../../utils/SuspenseImg"; import styles from "./Avatar.module.css"; import { useIdColorHash } from "./useIdColorHash"; -type AvatarProps = ( - | JSX.IntrinsicElements["button"] - | JSX.IntrinsicElements["span"] -) & { +type AvatarProps = (ComponentProps<"button"> | ComponentProps<"span">) & { /** * The avatar image URL, if any. */ - src?: React.ComponentProps["src"]; + src?: React.ComponentProps<"img">["src"]; /** * The Matrix ID, Room ID, or Alias to generate the color when no image source * is provided. Also used as a fallback when name is empty. @@ -62,7 +58,7 @@ type AvatarProps = ( /** * Callback when the image has failed to load. */ - onError?: React.ComponentProps["onError"]; + onError?: React.ComponentProps<"img">["onError"]; }; /** diff --git a/src/components/Button/IconButton/IconButton.stories.tsx b/src/components/Button/IconButton/IconButton.stories.tsx index a3c63b3a..62fa9173 100644 --- a/src/components/Button/IconButton/IconButton.stories.tsx +++ b/src/components/Button/IconButton/IconButton.stories.tsx @@ -21,6 +21,7 @@ import { fn } from "@storybook/test"; import { IconButton as IconButtonComponent } from "./IconButton"; import UserIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-profile"; +import { TooltipProvider } from "../../Tooltip/TooltipProvider"; const meta = { title: "Button/IconButton", @@ -99,8 +100,16 @@ export const WithSubtleBackground: Story = { }; export const WithLabel: Story = { + decorators: [ + (Story) => ( + + + + ), + ], + args: { - label: "label", + tooltip: "label", }, }; diff --git a/src/components/Button/IconButton/IconButton.tsx b/src/components/Button/IconButton/IconButton.tsx index 49e71298..2a4e2ce1 100644 --- a/src/components/Button/IconButton/IconButton.tsx +++ b/src/components/Button/IconButton/IconButton.tsx @@ -18,8 +18,7 @@ import React, { PropsWithChildren, forwardRef } from "react"; import classnames from "classnames"; import styles from "./IconButton.module.css"; -import { UnstyledButton } from "../UnstyledButton"; -import { UnstyledButtonPropsFor } from "../UnstyledButton"; +import { UnstyledButton, UnstyledButtonPropsFor } from "../UnstyledButton"; import { IndicatorIcon } from "../../Icon/IndicatorIcon/IndicatorIcon"; import { Tooltip } from "../../Tooltip/Tooltip"; @@ -53,7 +52,6 @@ type IconButtonProps = UnstyledButtonPropsFor<"button"> & { */ tooltip?: string; subtleBackground?: boolean; - label?: string; }; /** diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index efcdce52..47e907c9 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -268,10 +268,10 @@ const DropdownItem = memo(function DropdownItem({ function useOpen(): [ boolean, Dispatch>, - RefObject, + RefObject, ] { const [open, setOpen] = useState(false); - const ref = useRef(null); + const ref = useRef(null); // If the user clicks outside the dropdown, close it useEffect(() => { diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.tsx b/src/components/Form/Controls/EditInPlace/EditInPlace.tsx index 7b76c731..576a912b 100644 --- a/src/components/Form/Controls/EditInPlace/EditInPlace.tsx +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.tsx @@ -201,7 +201,9 @@ export const EditInPlace = forwardRef( const shouldShowSaveButton = state === State.Dirty || state === State.Saving || isFocusWithin; - const hideTimer = useRef>(); + const hideTimer = useRef | undefined>( + undefined, + ); useEffect(() => { // Start a timer when we switch to the saved state diff --git a/src/components/Menu/MenuItem.tsx b/src/components/Menu/MenuItem.tsx index 132075e0..a351c391 100644 --- a/src/components/Menu/MenuItem.tsx +++ b/src/components/Menu/MenuItem.tsx @@ -89,7 +89,7 @@ export const MenuItem = ({ onClick: onClickProp, disabled, ...props -}: Props): JSX.Element => { +}: Props): React.ReactElement => { const Component = as ?? ("button" as ElementType); const context = useContext(MenuContext); diff --git a/src/components/ReleaseAnnouncement/ReleaseAnnouncement.tsx b/src/components/ReleaseAnnouncement/ReleaseAnnouncement.tsx index 6fb0ae8e..422c0bb3 100644 --- a/src/components/ReleaseAnnouncement/ReleaseAnnouncement.tsx +++ b/src/components/ReleaseAnnouncement/ReleaseAnnouncement.tsx @@ -106,11 +106,10 @@ function ReleaseAnnouncementAnchor({ children, context.getReferenceProps({ ref, - ...children.props, // If the ReleaseAnnouncement is open, we need manually aria-describedby. // The RA has the dialog role and it's not adding automatically the aria-describedby. ...(context.open && { - "aria-describedby": context.getFloatingProps().id, + "aria-describedby": context.getFloatingProps().id as string, }), }), ); diff --git a/src/components/Tooltip/Tooltip.stories.tsx b/src/components/Tooltip/Tooltip.stories.tsx index f086c6b6..9cc6e6e9 100644 --- a/src/components/Tooltip/Tooltip.stories.tsx +++ b/src/components/Tooltip/Tooltip.stories.tsx @@ -61,6 +61,9 @@ const meta = { args: { // needed, to prevent the tooltip to be in controlled mode onOpenChange: undefined, + open: undefined, + description: "", + label: "", children: ( @@ -68,7 +71,7 @@ const meta = { ), }, decorators: [ - (Story: StoryFn) => ( + (Story) => (
diff --git a/src/components/Tooltip/Tooltip.test.tsx b/src/components/Tooltip/Tooltip.test.tsx index d517232a..6adc36a4 100644 --- a/src/components/Tooltip/Tooltip.test.tsx +++ b/src/components/Tooltip/Tooltip.test.tsx @@ -18,25 +18,12 @@ import { describe, it, expect, vi } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import React, { act } from "react"; -import * as stories from "./Tooltip.stories"; -import { composeStories, composeStory } from "@storybook/react"; +import { IconButton } from "../Button"; import userEvent from "@testing-library/user-event"; import { TooltipProvider } from "./TooltipProvider"; import { Tooltip } from "./Tooltip"; import { UserIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -const { - Default, - WithStringCaption, - WithComponentCaption, - ForcedOpen, - ForcedClose, - ForcedDisabled, - InteractiveTrigger, - NonInteractiveTrigger, - Descriptive, -} = composeStories(stories); - /** * Patches an element to always match :focus-visible whenever it's in focus. * JSDOM doesn't seem to support this selector on its own. @@ -52,28 +39,56 @@ function mockFocusVisible(e: Element): void { describe("Tooltip", () => { it("renders open by default", () => { - render(); + render( + + + + + + + , + ); // tooltip labels button and does not use role="tooltip" screen.getByRole("button", { name: "I'm always open" }); expect(screen.queryByRole("tooltip")).toBe(null); }); it("renders closed by default", () => { - render(); + render( + + + No tooltip to see here + + , + ); // no tooltip, and no label either expect(screen.queryByRole("tooltip")).toBe(null); expect(screen.queryByRole("button", { name: /.*/ })).toBe(null); }); it("renders disabled", () => { - render(); + render( + + + No tooltip to see here + + , + ); // no tooltip, and no label either expect(screen.queryByRole("tooltip")).toBe(null); expect(screen.queryByRole("button", { name: /.*/ })).toBe(null); }); it("renders default tooltip", async () => { - render(); + render( + + + + + + + , + ); // tooltip labels button and does not use role="tooltip" screen.getByRole("button", { name: "@bob:example.org" }); expect(screen.queryByRole("tooltip")).toBe(null); @@ -81,18 +96,30 @@ describe("Tooltip", () => { it("opens tooltip on focus", async () => { const user = userEvent.setup(); - render(); + render( + + + Link + + , + ); mockFocusVisible(screen.getByRole("link")); expect(screen.queryByRole("tooltip")).toBe(null); await user.tab(); // trigger focused, tooltip shown expect(screen.getByRole("link")).toHaveFocus(); - screen.getByRole("tooltip"); + await screen.findByRole("tooltip"); }); it("opens tooltip on focus where trigger is non interactive", async () => { const user = userEvent.setup(); - render(); + render( + + + Just some text + + , + ); mockFocusVisible(screen.getByText("Just some text").parentElement!); expect(screen.queryByRole("tooltip")).toBe(null); await user.tab(); @@ -104,7 +131,13 @@ describe("Tooltip", () => { it("opens tooltip on long press", async () => { vi.useFakeTimers(); try { - render(); + render( + + + Link + + , + ); expect(screen.queryByRole("tooltip")).toBe(null); // Press fireEvent.touchStart(screen.getByRole("link")); @@ -125,17 +158,17 @@ describe("Tooltip", () => { it("overrides default tab index for non interactive triggers", async () => { const user = userEvent.setup(); - const Component = composeStory( - { - ...stories.NonInteractiveTrigger, - args: { - ...stories.NonInteractiveTrigger.args, - nonInteractiveTriggerTabIndex: -1, - }, - }, - stories.default, + render( + + + Just some text + + , ); - render(); await user.tab(); // trigger cannot be focused expect(screen.queryByRole("tooltip")).toBe(null); @@ -143,7 +176,15 @@ describe("Tooltip", () => { it("renders with string caption", async () => { const user = userEvent.setup(); - render(); + render( + + + + + + + , + ); await user.tab(); // tooltip labels button and describes button with caption expect( @@ -153,7 +194,22 @@ describe("Tooltip", () => { it("renders with component caption", async () => { const user = userEvent.setup(); - render(); + render( + + + Ctrl + C + + } + > + + + + + , + ); await user.tab(); // tooltip labels button and describes button with caption expect( @@ -162,7 +218,13 @@ describe("Tooltip", () => { }); it("renders a descriptive tooltip", async () => { - render(); + render( + + + EIN + + , + ); // tooltip shown, but does not change the button's accessible name screen.getByRole("tooltip", { name: "Employer Identification Number" }); expect(screen.queryByRole("button", { name: "EIN" })).toBe(null); diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 8646f00e..a9454f6b 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -40,12 +40,11 @@ import { TooltipLabel, useTooltip, } from "./useTooltip"; -import { XOR } from "ts-xor"; // Unfortunately Omit doesn't distribute nicely over sum types, so we have to // piece together the useTooltip options type by hand type TooltipProps = Omit & - XOR & { + (TooltipLabel | TooltipDescription) & { /** * Whether the trigger element is interactive. * When trigger is interactive: @@ -63,6 +62,10 @@ type TooltipProps = Omit & nonInteractiveTriggerTabIndex?: number; }; +const hasLabel = ( + props: TooltipLabel | TooltipDescription, +): props is TooltipLabel => "label" in props && !!props.label; + /** * A tooltip component */ @@ -84,7 +87,7 @@ export function Tooltip({ - {"label" in props ? props.label : props.description} + {hasLabel(props) ? props.label : props.description} @@ -184,7 +187,7 @@ const TooltipAnchor: FC = ({ if (!isValidElement(children)) return; if (isTriggerInteractive) { - const props = context.getReferenceProps({ ref, ...children.props }); + const props = context.getReferenceProps({ ref }); return cloneElement(children, props); } else { // For a non-interactive trigger, we want most of the props to go on the @@ -202,7 +205,7 @@ const TooltipAnchor: FC = ({ } = props; return ( - {cloneElement(children as ReactElement, { + {cloneElement(children as ReactElement>, { "aria-labelledby": labelId, "aria-describedby": descriptionId, })} diff --git a/src/components/Tooltip/useTooltip.ts b/src/components/Tooltip/useTooltip.ts index 0a783f93..ee81e1f8 100644 --- a/src/components/Tooltip/useTooltip.ts +++ b/src/components/Tooltip/useTooltip.ts @@ -183,7 +183,7 @@ export function useTooltip({ }); // On touch screens, show the tooltip on a long press - const pressTimer = useRef(); + const pressTimer = useRef(undefined); useEffect(() => () => window.clearTimeout(pressTimer.current), []); const press = useMemo(() => { const onTouchEnd = () => { diff --git a/src/utils/SuspenseImg.tsx b/src/utils/SuspenseImg.tsx deleted file mode 100644 index 95afd0bb..00000000 --- a/src/utils/SuspenseImg.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* -Copyright 2023 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 React from "react"; - -interface ImageLoadingCache { - /** - * A map of all sources loaded with this cache instance - * Contains a promise if the image is being loaded, and `true` if the source - * has succesfully been loaded. - */ - __cache: Map | boolean>; - /** - * Will attempt to load the source and will throw an exception until - * the image has been loaded succesfully. - * The exception will be caught by React Suspense and that will notify it to - * display the fallback - * @param src the image source. - * @returns true if the source has been loaded successfully - */ - read: (src: string) => boolean; -} - -const imgCache: ImageLoadingCache = { - __cache: new Map | boolean>(), - read(src: string): boolean { - if (!this.__cache.has(src)) { - // Create a cache entry with a promise to notify the image is still being loaded - this.__cache.set( - src, - new Promise((resolve) => { - const img = new Image(); - img.onload = () => { - this.__cache.set(src, true); - resolve(this.__cache.get(src)); - }; - img.src = src; - }).then(() => { - this.__cache.set(src, true); - }), - ); - } - if (this.__cache.get(src) instanceof Promise) { - throw this.__cache.get(src); - } - return this.__cache.get(src) as boolean; - }, -}; - -type SuspenseImgProps = JSX.IntrinsicElements["img"] & { - /** - * The source of the image to load - */ - src: string; - /** - * The cache instance to drive the suspense loading - * Useful to override in a test environment - * @default imgCache a generic cache instance shared globally - */ - cache?: ImageLoadingCache; -}; - -export const SuspenseImg: React.FC = ({ - src, - cache = imgCache, - ...props -}) => { - /** - * Read the cache, if the image has already been loaded, it will be displayed - * straight away. If not, it will throw an exception that will be caught by the - * `` wrapper. - * Once the promise is resolved, suspense will replace the fallback with the below - */ - cache.read(src); - return ( - - ); -}; diff --git a/yarn.lock b/yarn.lock index 1485c97f..f2c04bd3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1573,17 +1573,11 @@ dependencies: undici-types "~6.20.0" -"@types/prop-types@*": - version "15.7.14" - resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.14.tgz#1433419d73b2a7ebfc6918dcefd2ec0d5cd698f2" - integrity sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ== - -"@types/react@^18.2.21": - version "18.3.18" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.18.tgz#9b382c4cd32e13e463f97df07c2ee3bbcd26904b" - integrity sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ== +"@types/react@^19.0.0": + version "19.0.1" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.1.tgz#a000d5b78f473732a08cecbead0f3751e550b3df" + integrity sha512-YW6614BDhqbpR5KtUYzTA+zlA7nayzJRA9ljz9CQoxthR0sDisYZLuvSMsil36t4EH/uAt8T52Xb4sVw17G+SQ== dependencies: - "@types/prop-types" "*" csstype "^3.0.2" "@types/resolve@^1.20.2": @@ -4715,7 +4709,7 @@ react-docgen@^7.0.0: resolve "^1.22.1" strip-indent "^4.0.0" -"react-dom@^16.8.0 || ^17.0.0 || ^18.0.0", react-dom@^18.3.1: +"react-dom@^16.8.0 || ^17.0.0 || ^18.0.0": version "18.3.1" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== @@ -4723,6 +4717,13 @@ react-docgen@^7.0.0: loose-envify "^1.1.0" scheduler "^0.23.2" +react-dom@^19.0.0: + version "19.0.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.0.0.tgz#43446f1f01c65a4cd7f7588083e686a6726cfb57" + integrity sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ== + dependencies: + scheduler "^0.25.0" + react-is@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -4765,13 +4766,18 @@ react-style-singleton@^2.2.1, react-style-singleton@^2.2.2: get-nonce "^1.0.0" tslib "^2.0.0" -"react@^16.8.0 || ^17.0.0 || ^18.0.0", react@^18.2.0: +"react@^16.8.0 || ^17.0.0 || ^18.0.0": version "18.3.1" resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== dependencies: loose-envify "^1.1.0" +react@^19.0.0: + version "19.0.0" + resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz#6e1969251b9f108870aa4bff37a0ce9ddfaaabdd" + integrity sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ== + recast@^0.23.5: version "0.23.9" resolved "https://registry.yarnpkg.com/recast/-/recast-0.23.9.tgz#587c5d3a77c2cfcb0c18ccce6da4361528c2587b" @@ -4998,6 +5004,11 @@ scheduler@^0.23.2: dependencies: loose-envify "^1.1.0" +scheduler@^0.25.0: + version "0.25.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.25.0.tgz#336cd9768e8cceebf52d3c80e3dcf5de23e7e015" + integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA== + semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" @@ -5557,11 +5568,6 @@ ts-dedent@^2.0.0, ts-dedent@^2.2.0: resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5" integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ== -ts-xor@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/ts-xor/-/ts-xor-1.3.0.tgz#3e59f24f0321f9f10f350e0cee3b534b89a2c70b" - integrity sha512-RLXVjliCzc1gfKQFLRpfeD0rrWmjnSTgj7+RFhoq3KRkUYa8LE/TIidYOzM5h+IdFBDSjjSgk9Lto9sdMfDFEA== - tsconfig-paths@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz#ef78e19039133446d244beac0fd6a1632e2d107c"