From b485b1a4f139e18c0aaed7adbe11477af6ba9190 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 7 Jul 2025 12:14:48 +0100 Subject: [PATCH] Update Tooltip to use React Aria Signed-off-by: Charles de Dreuille --- packages/canon/css/styles.css | 93 +++++++------- .../components/Tooltip/Tooltip.stories.tsx | 76 ++++++----- .../src/components/Tooltip/Tooltip.styles.css | 103 +++++++-------- .../canon/src/components/Tooltip/Tooltip.tsx | 120 +++++------------- .../canon/src/components/Tooltip/index.ts | 3 +- .../canon/src/components/Tooltip/types.ts | 22 ++++ .../canon/src/utils/componentDefinitions.ts | 4 +- 7 files changed, 189 insertions(+), 232 deletions(-) create mode 100644 packages/canon/src/components/Tooltip/types.ts diff --git a/packages/canon/css/styles.css b/packages/canon/css/styles.css index 8d075136bd..0b66952888 100644 --- a/packages/canon/css/styles.css +++ b/packages/canon/css/styles.css @@ -10461,66 +10461,59 @@ height: 1rem; } -.canon-TooltipPopup { - box-sizing: border-box; - transform-origin: var(--transform-origin); - background-color: canvas; - background-color: var(--canon-bg-surface-1); +.canon-Tooltip { + background: var(--canon-bg-surface-1); color: var(--canon-fg-primary); - outline: 1px solid var(--canon-border); - box-shadow: 0 10px 15px -3px var(--canon-border), 0 4px 6px -4px var(--canon-border); - border-radius: .375rem; - flex-direction: column; - padding: .25rem .5rem; - font-size: .875rem; - line-height: 1.25rem; - transition: transform .15s, opacity .15s; - display: flex; + forced-color-adjust: none; + padding: var(--canon-space-1_5) var(--canon-space-2); + border-radius: 4px; + outline: none; + max-width: 150px; + transition: transform .2s, opacity .2s; + transform: translate3d(0, 0, 0); + box-shadow: 0 8px 20px #0000001a; - &[data-starting-style], &[data-ending-style] { + &[data-entering], &[data-exiting] { + transform: var(--origin); opacity: 0; - transform: scale(.9); } - &[data-instant] { - transition-duration: 0s; - } -} - -.canon-TooltipArrow { - display: flex; - - &[data-side="top"] { - bottom: -8px; - rotate: 180deg; + &[data-placement="top"] { + --origin: translateY(4px); + margin-bottom: 8px; } - &[data-side="bottom"] { - top: -8px; - rotate: none; - } + &[data-placement="bottom"] { + --origin: translateY(-4px); + margin-top: 8px; - &[data-side="left"] { - right: -13px; - rotate: 90deg; - } - - &[data-side="right"] { - left: -13px; - rotate: -90deg; - } -} - -.canon-TooltipArrow-fill { - fill: var(--canon-bg-surface-1); -} - -.canon-TooltipArrow-outer-stroke { - @media (prefers-color-scheme: light) { - & { - fill: var(--canon-border); + & .canon-TooltipArrow svg { + transform: rotate(180deg); } } + + &[data-placement="right"] { + --origin: translateX(-4px); + margin-left: 8px; + + & .canon-TooltipArrow svg { + transform: rotate(90deg); + } + } + + &[data-placement="left"] { + --origin: translateX(4px); + margin-right: 8px; + + & .canon-TooltipArrow svg { + transform: rotate(-90deg); + } + } +} + +.canon-TooltipArrow svg { + fill: var(--canon-bg-surface-1); + display: block; } .canon-ScrollAreaRoot { diff --git a/packages/canon/src/components/Tooltip/Tooltip.stories.tsx b/packages/canon/src/components/Tooltip/Tooltip.stories.tsx index e0ae6f0be8..929f88d475 100644 --- a/packages/canon/src/components/Tooltip/Tooltip.stories.tsx +++ b/packages/canon/src/components/Tooltip/Tooltip.stories.tsx @@ -15,13 +15,13 @@ */ import type { Meta, StoryObj } from '@storybook/react'; -import { Tooltip } from './Tooltip'; +import { TooltipTrigger, Tooltip } from './Tooltip'; import { Button } from '../Button/Button'; const meta = { title: 'Components/Tooltip', - component: Tooltip.Root, -} satisfies Meta; + component: TooltipTrigger, +} satisfies Meta; export default meta; type Story = StoryObj; @@ -30,50 +30,58 @@ export const Default: Story = { args: { children: ( <> - ( - - )} - /> - - - Nice! - - + + I am a tooltip ), }, }; -export const Open: Story = { +export const IsOpen: Story = { args: { ...Default.args, - open: true, + isOpen: true, }, }; -export const WithArrow: Story = { +export const IsDisabled: Story = { args: { - open: true, + ...Default.args, + isDisabled: true, + }, +}; + +export const PlacementTop: Story = { + args: { + ...Default.args, children: ( <> - ( - - )} - /> - - - - - Nice! - - - + + I'm a tooltip + + ), + }, +}; + +export const PlacementRight: Story = { + args: { + ...Default.args, + children: ( + <> + + I'm a tooltip + + ), + }, +}; + +export const PlacementLeft: Story = { + args: { + ...Default.args, + children: ( + <> + + I'm a tooltip ), }, diff --git a/packages/canon/src/components/Tooltip/Tooltip.styles.css b/packages/canon/src/components/Tooltip/Tooltip.styles.css index d6850c0d26..6cc3818489 100644 --- a/packages/canon/src/components/Tooltip/Tooltip.styles.css +++ b/packages/canon/src/components/Tooltip/Tooltip.styles.css @@ -14,70 +14,59 @@ * limitations under the License. */ -.canon-TooltipPopup { - box-sizing: border-box; - font-size: 0.875rem; - line-height: 1.25rem; - display: flex; - flex-direction: column; - padding: 0.25rem 0.5rem; - border-radius: 0.375rem; - background-color: canvas; - transform-origin: var(--transform-origin); - transition: transform 150ms, opacity 150ms; - background-color: var(--canon-bg-surface-1); +.canon-Tooltip { + box-shadow: 0 8px 20px rgba(0 0 0 / 0.1); + border-radius: 4px; + background: var(--canon-bg-surface-1); color: var(--canon-fg-primary); - outline: 1px solid var(--canon-border); - box-shadow: 0 10px 15px -3px var(--canon-border), - 0 4px 6px -4px var(--canon-border); + forced-color-adjust: none; + outline: none; + padding: var(--canon-space-1_5) var(--canon-space-2); + max-width: 150px; + /* fixes FF gap */ + transform: translate3d(0, 0, 0); + transition: transform 200ms, opacity 200ms; - &[data-starting-style], - &[data-ending-style] { + &[data-entering], + &[data-exiting] { + transform: var(--origin); opacity: 0; - transform: scale(0.9); } - &[data-instant] { - transition-duration: 0ms; + &[data-placement='top'] { + margin-bottom: 8px; + --origin: translateY(4px); + } + + &[data-placement='bottom'] { + margin-top: 8px; + --origin: translateY(-4px); + + & .canon-TooltipArrow svg { + transform: rotate(180deg); + } + } + + &[data-placement='right'] { + margin-left: 8px; + --origin: translateX(-4px); + + & .canon-TooltipArrow svg { + transform: rotate(90deg); + } + } + + &[data-placement='left'] { + margin-right: 8px; + --origin: translateX(4px); + + & .canon-TooltipArrow svg { + transform: rotate(-90deg); + } } } -.canon-TooltipArrow { - display: flex; - - &[data-side='top'] { - bottom: -8px; - rotate: 180deg; - } - - &[data-side='bottom'] { - top: -8px; - rotate: 0deg; - } - - &[data-side='left'] { - right: -13px; - rotate: 90deg; - } - - &[data-side='right'] { - left: -13px; - rotate: -90deg; - } -} - -.canon-TooltipArrow-fill { +.canon-TooltipArrow svg { + display: block; fill: var(--canon-bg-surface-1); } - -.canon-TooltipArrow-outer-stroke { - @media (prefers-color-scheme: light) { - fill: var(--canon-border); - } -} - -.canon-TooltipArrow-inner-stroke { - @media (prefers-color-scheme: dark) { - /* fill: var(--canon-border); */ - } -} diff --git a/packages/canon/src/components/Tooltip/Tooltip.tsx b/packages/canon/src/components/Tooltip/Tooltip.tsx index 95ddcbc123..47a18fd6e4 100644 --- a/packages/canon/src/components/Tooltip/Tooltip.tsx +++ b/packages/canon/src/components/Tooltip/Tooltip.tsx @@ -15,96 +15,42 @@ */ import { forwardRef } from 'react'; -import { Tooltip as TooltipPrimitive } from '@base-ui-components/react/tooltip'; +import { + OverlayArrow, + Tooltip as AriaTooltip, + TooltipTrigger as AriaTooltipTrigger, + TooltipTriggerComponentProps, +} from 'react-aria-components'; import clsx from 'clsx'; +import { TooltipProps } from './types'; import { useStyles } from '../../hooks/useStyles'; -const TooltipTrigger = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Tooltip'); +export const TooltipTrigger = (props: TooltipTriggerComponentProps) => { + const { delay = 600 } = props; - return ( - - ); -}); -TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName; - -const TooltipPositioner = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Tooltip'); - - return ( - - ); -}); -TooltipPositioner.displayName = TooltipPrimitive.Positioner.displayName; - -const TooltipPopup = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Tooltip'); - - return ( - - ); -}); -TooltipPopup.displayName = TooltipPrimitive.Popup.displayName; - -const TooltipArrow = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Tooltip'); - - return ( - - - - - - - - ); -}); -TooltipArrow.displayName = TooltipPrimitive.Arrow.displayName; + return ; +}; /** @public */ -export const Tooltip = { - Provider: TooltipPrimitive.Provider, - Root: TooltipPrimitive.Root, - Trigger: TooltipTrigger, - Portal: TooltipPrimitive.Portal, - Positioner: TooltipPositioner, - Popup: TooltipPopup, - Arrow: TooltipArrow, -}; +export const Tooltip = forwardRef( + ({ className, children, ...rest }, ref) => { + const { classNames } = useStyles('Tooltip'); + + return ( + + + + + + + {children} + + ); + }, +); + +Tooltip.displayName = Tooltip.displayName; diff --git a/packages/canon/src/components/Tooltip/index.ts b/packages/canon/src/components/Tooltip/index.ts index cf97688854..613631091b 100644 --- a/packages/canon/src/components/Tooltip/index.ts +++ b/packages/canon/src/components/Tooltip/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ -export * from './Tooltip'; +export { TooltipTrigger, Tooltip } from './Tooltip'; +export type { TooltipProps } from './types'; diff --git a/packages/canon/src/components/Tooltip/types.ts b/packages/canon/src/components/Tooltip/types.ts new file mode 100644 index 0000000000..2ef44fbbf4 --- /dev/null +++ b/packages/canon/src/components/Tooltip/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { TooltipProps as AriaTooltipProps } from 'react-aria-components'; + +/** @public */ +export interface TooltipProps extends Omit { + children: React.ReactNode; +} diff --git a/packages/canon/src/utils/componentDefinitions.ts b/packages/canon/src/utils/componentDefinitions.ts index 3dcb19f138..70fe7d1704 100644 --- a/packages/canon/src/utils/componentDefinitions.ts +++ b/packages/canon/src/utils/componentDefinitions.ts @@ -257,9 +257,7 @@ export const componentDefinitions = { }, Tooltip: { classNames: { - trigger: 'canon-TooltipTrigger', - positioner: 'canon-TooltipPositioner', - popup: 'canon-TooltipPopup', + tooltip: 'canon-Tooltip', arrow: 'canon-TooltipArrow', }, },