[Wildcard] New Tooltip Component (#35870)

* Stub in basic example Tooltip compound component

* Add new Wildcard Tooltip using Radix, basic Storybook story

* Simplify Tooltip API, add Wildcard styles

* Updates from PR feedback

* Finalize Tooltip tests, ESLint rule

* Additional PR feedback, support conditional tooltips

* Fix existing Tooltip imports

* Remove old ESLint rule disabling import of Tooltip component from Wildcard

* Update which Tooltip component is used in main JetBrains app file
This commit is contained in:
Laura Hacker 2022-05-31 13:40:48 -04:00 committed by GitHub
parent 0c0e3af871
commit feb3429727
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1017 additions and 399 deletions

View File

@ -59,12 +59,6 @@ const config = {
importNames: ['Form'],
message: 'Use the <Form /> component from @sourcegraph/branded package instead',
},
{
name: '@sourcegraph/wildcard',
importNames: ['Tooltip'],
message:
'Please ensure there is only a single `<Tooltip />` component present in the React tree. To display a specific tooltip, you can add the `data-tooltip` attribute to the relevant element.',
},
{
name: 'zustand',
importNames: ['default'],
@ -181,6 +175,11 @@ See https://handbook.sourcegraph.com/community/faq#is-all-of-sourcegraph-open-so
message:
'Consider using useTemporarySetting instead of useLocalStorage so settings are synced when users log in elsewhere. More info at https://docs.sourcegraph.com/dev/background-information/web/temporary_settings',
},
{
selector: 'JSXAttribute JSXIdentifier[name="data-tooltip"]',
message:
'The use of data-tooltip has been deprecated. Please wrap your trigger element with the <Tooltip> component from Wildcard instead. If there are problems using the new <Tooltip>, please contact the Frontend Platform Team.',
},
],
// https://reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html#eslint
'react/jsx-uses-react': 'off',

View File

@ -6,7 +6,7 @@ import { ThemeProps } from '@sourcegraph/shared/src/theme'
import { MockedStoryProvider, MockedStoryProviderProps, usePrependStyles, useTheme } from '@sourcegraph/storybook'
// Add root Tooltip for Storybook
// eslint-disable-next-line no-restricted-imports
import { Tooltip, WildcardThemeContext } from '@sourcegraph/wildcard'
import { DeprecatedTooltip, WildcardThemeContext } from '@sourcegraph/wildcard'
import brandedStyles from '../global-styles/index.scss'
@ -33,7 +33,7 @@ export const BrandedStory: React.FunctionComponent<React.PropsWithChildren<Brand
<MockedStoryProvider mocks={mocks} useStrictMocking={useStrictMocking}>
<WildcardThemeContext.Provider value={{ isBranded: true }}>
<MemoryRouter {...memoryRouterProps}>
<Tooltip />
<DeprecatedTooltip />
<Children isLightTheme={isLightTheme} />
</MemoryRouter>
</WildcardThemeContext.Provider>

View File

@ -24,8 +24,7 @@ import { fetchStreamSuggestions } from '@sourcegraph/shared/src/search/suggestio
import { EMPTY_SETTINGS_CASCADE, SettingsCascadeOrError } from '@sourcegraph/shared/src/settings/settings'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
// Add root Tooltip for JetBrains
// eslint-disable-next-line no-restricted-imports
import { useObservable, WildcardThemeContext, Tooltip } from '@sourcegraph/wildcard'
import { useObservable, WildcardThemeContext, DeprecatedTooltip } from '@sourcegraph/wildcard'
import { getAuthenticatedUser } from '../sourcegraph-api-access/api-gateway'
import { initializeSourcegraphSettings } from '../sourcegraphSettings'
@ -203,7 +202,7 @@ export const App: React.FunctionComponent<React.PropsWithChildren<Props>> = ({
return (
<WildcardThemeContext.Provider value={{ isBranded: true }}>
<Tooltip />
<DeprecatedTooltip />
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div className={styles.root} onMouseDown={preventAll}>
<div className={styles.searchBoxContainer}>

View File

@ -8,7 +8,7 @@ import { Observable, merge, of } from 'rxjs'
import { tap, switchMapTo, startWith, delay } from 'rxjs/operators'
import { KeyboardShortcut } from '@sourcegraph/shared/src/keyboardShortcuts'
import { Button, Icon, TooltipController, useEventObservable } from '@sourcegraph/wildcard'
import { Button, Icon, DeprecatedTooltipController, useEventObservable } from '@sourcegraph/wildcard'
interface Props {
fullQuery: string
@ -36,7 +36,7 @@ export const CopyQueryButton: React.FunctionComponent<React.PropsWithChildren<Pr
clicks.pipe(
tap(copyFullQuery),
switchMapTo(merge(of(true), of(false).pipe(delay(2000)))),
tap(() => TooltipController.forceUpdate()),
tap(() => DeprecatedTooltipController.forceUpdate()),
startWith(false)
),
[copyFullQuery]

View File

@ -0,0 +1,10 @@
/* eslint-disable unicorn/prevent-abbreviations */
// JSDOM does not have support for DOMRect, needed for tooltips.
// https://github.com/radix-ui/primitives/issues/420#issuecomment-771615182
if ('DOMRect' in window === false) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
window.DOMRect = {
fromRect: () => ({ top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 }),
} as any
}

View File

@ -51,8 +51,7 @@ import { TemporarySettingsStorage } from '@sourcegraph/shared/src/settings/tempo
import { globbingEnabledFromSettings } from '@sourcegraph/shared/src/util/globbing'
import {
// This is the root Tooltip usage
// eslint-disable-next-line no-restricted-imports
Tooltip,
DeprecatedTooltip,
FeedbackText,
setLinkComponent,
RouterLink,
@ -468,7 +467,7 @@ export class SourcegraphWebApp extends React.Component<SourcegraphWebAppProps, S
<BrowserExtensionTracker />
</Router>
</ScrollManager>
<Tooltip key={1} />
<DeprecatedTooltip key={1} />
<Notifications
key={2}
extensionsController={this.extensionsController}

View File

@ -7,7 +7,7 @@ import { ThemeProps } from '@sourcegraph/shared/src/theme'
import { MockedStoryProvider, MockedStoryProviderProps, usePrependStyles, useTheme } from '@sourcegraph/storybook'
// Add root Tooltip for Storybook
// eslint-disable-next-line no-restricted-imports
import { Tooltip, WildcardThemeContext } from '@sourcegraph/wildcard'
import { DeprecatedTooltip, WildcardThemeContext } from '@sourcegraph/wildcard'
import { BreadcrumbSetters, BreadcrumbsProps, useBreadcrumbs } from './Breadcrumbs'
@ -41,7 +41,7 @@ export const WebStory: React.FunctionComponent<React.PropsWithChildren<WebStoryP
<MockedStoryProvider mocks={mocks} useStrictMocking={useStrictMocking}>
<WildcardThemeContext.Provider value={{ isBranded: true }}>
<MemoryRouter {...memoryRouterProps}>
<Tooltip />
<DeprecatedTooltip />
<Children
{...breadcrumbSetters}
isLightTheme={isLightTheme}

View File

@ -4,7 +4,7 @@ import copy from 'copy-to-clipboard'
import { merge, Observable, of } from 'rxjs'
import { delay, startWith, switchMapTo, tap } from 'rxjs/operators'
import { TooltipController, useEventObservable } from '@sourcegraph/wildcard'
import { DeprecatedTooltipController, useEventObservable } from '@sourcegraph/wildcard'
type URLValue = string | undefined
type useCopiedHandlerReturn = [(value?: URLValue) => void, boolean | undefined]
@ -24,7 +24,7 @@ export function useCopyURLHandler(): useCopiedHandlerReturn {
clicks.pipe(
tap(copyDashboardURL),
switchMapTo(merge(of(true), of(false).pipe(delay(2000)))),
tap(() => TooltipController.forceUpdate()),
tap(() => DeprecatedTooltipController.forceUpdate()),
startWith(false)
),
[copyDashboardURL]

View File

@ -16,7 +16,7 @@ import {
TabPanel,
TabPanels,
Tabs,
TooltipController,
DeprecatedTooltipController,
Icon,
Link,
ProductStatusBadge,
@ -208,7 +208,7 @@ const QueryPanel: React.FunctionComponent<React.PropsWithChildren<QueryPanelProp
setTimeout(() => setCurrentCopyTooltip(copyTooltip), 1000)
requestAnimationFrame(() => {
TooltipController.forceUpdate()
DeprecatedTooltipController.forceUpdate()
})
event.preventDefault()

View File

@ -9,8 +9,7 @@ import { FlatExtensionHostAPI } from '@sourcegraph/shared/src/api/contract'
import { pretendProxySubscribable, pretendRemote } from '@sourcegraph/shared/src/api/util'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { extensionsController, NOOP_PLATFORM_CONTEXT } from '@sourcegraph/shared/src/testing/searchTestHelpers'
// eslint-disable-next-line no-restricted-imports
import { Tooltip } from '@sourcegraph/wildcard'
import { DeprecatedTooltip } from '@sourcegraph/wildcard'
import { AppRouterContainer } from '../../components/AppRouterContainer'
@ -63,7 +62,7 @@ const decorator: DecoratorFn = story => (
<>
<style>{webStyles}</style>
<AppRouterContainer>
<Tooltip />
<DeprecatedTooltip />
<div className="container mt-3">{story()}</div>
</AppRouterContainer>
</>

View File

@ -17,7 +17,7 @@ import {
RenderModeSpec,
UIRangeSpec,
} from '@sourcegraph/shared/src/util/url'
import { TooltipController } from '@sourcegraph/wildcard'
import { DeprecatedTooltipController } from '@sourcegraph/wildcard'
import { getWebGraphQLClient, requestGraphQL } from '../backend/graphql'
import { eventLogger } from '../tracking/eventLogger'
@ -73,7 +73,7 @@ export function createPlatformContext(): PlatformContext {
},
getGraphQLClient: getWebGraphQLClient,
requestGraphQL: ({ request, variables }) => requestGraphQL(request, variables),
forceUpdateTooltip: () => TooltipController.forceUpdate(),
forceUpdateTooltip: () => DeprecatedTooltipController.forceUpdate(),
createExtensionHost: async () =>
(await import('@sourcegraph/shared/src/api/extension/worker')).createExtensionHost(),
urlToFile: toPrettyWebBlobURL,

View File

@ -4,7 +4,7 @@ import copy from 'copy-to-clipboard'
import ContentCopyIcon from 'mdi-react/ContentCopyIcon'
import { useLocation } from 'react-router'
import { Button, TooltipController, Icon, screenReaderAnnounce } from '@sourcegraph/wildcard'
import { Button, DeprecatedTooltipController, Icon, screenReaderAnnounce } from '@sourcegraph/wildcard'
import { eventLogger } from '../../tracking/eventLogger'
import { parseBrowserRepoURL } from '../../util/url'
@ -19,7 +19,7 @@ export const CopyPathAction: React.FunctionComponent<React.PropsWithChildren<unk
const [copied, setCopied] = useState(false)
useLayoutEffect(() => {
TooltipController.forceUpdate()
DeprecatedTooltipController.forceUpdate()
}, [copied])
const onClick = (event: React.MouseEvent<HTMLButtonElement>): void => {

View File

@ -13,7 +13,7 @@ import {
toViewStateHash,
} from '@sourcegraph/common'
import { parseQueryAndHash } from '@sourcegraph/shared/src/util/url'
import { TooltipController, Icon } from '@sourcegraph/wildcard'
import { DeprecatedTooltipController, Icon } from '@sourcegraph/wildcard'
import { eventLogger } from '../../../tracking/eventLogger'
import { RepoHeaderActionButtonLink } from '../../components/RepoHeaderActions'
@ -65,7 +65,7 @@ export class ToggleHistoryPanel extends React.PureComponent<
const visible = ToggleHistoryPanel.isVisible(this.props.location)
eventLogger.log(visible ? 'HideHistoryPanel' : 'ShowHistoryPanel')
this.props.history.push(ToggleHistoryPanel.locationWithVisibility(this.props.location, !visible))
TooltipController.forceUpdate()
DeprecatedTooltipController.forceUpdate()
})
)

View File

@ -5,7 +5,7 @@ import { fromEvent, Subject, Subscription } from 'rxjs'
import { filter } from 'rxjs/operators'
import { WrapDisabledIcon } from '@sourcegraph/shared/src/components/icons'
import { TooltipController, Icon } from '@sourcegraph/wildcard'
import { DeprecatedTooltipController, Icon } from '@sourcegraph/wildcard'
import { eventLogger } from '../../../tracking/eventLogger'
import { RepoHeaderActionButtonLink } from '../../components/RepoHeaderActions'
@ -52,7 +52,7 @@ export class ToggleLineWrap extends React.PureComponent<
ToggleLineWrap.setValue(value)
this.setState({ value })
this.props.onDidUpdate(value)
TooltipController.forceUpdate()
DeprecatedTooltipController.forceUpdate()
})
)

View File

@ -4,7 +4,7 @@ import EyeIcon from 'mdi-react/EyeIcon'
import { useLocation } from 'react-router'
import { RenderMode } from '@sourcegraph/shared/src/util/url'
import { TooltipController, Icon } from '@sourcegraph/wildcard'
import { DeprecatedTooltipController, Icon } from '@sourcegraph/wildcard'
import { RepoHeaderActionButtonLink } from '../../components/RepoHeaderActions'
import { RepoHeaderContext } from '../../RepoHeader'
@ -33,7 +33,7 @@ export const ToggleRenderedFileMode: React.FunctionComponent<React.PropsWithChil
const location = useLocation()
useEffect(() => {
TooltipController.forceUpdate()
DeprecatedTooltipController.forceUpdate()
}, [mode])
if (actionType === 'dropdown') {

View File

@ -7,7 +7,7 @@ import DotsHorizontalIcon from 'mdi-react/DotsHorizontalIcon'
import FileDocumentIcon from 'mdi-react/FileDocumentIcon'
import { pluralize } from '@sourcegraph/common'
import { Button, ButtonGroup, TooltipController, Link, Icon, Typography } from '@sourcegraph/wildcard'
import { Button, ButtonGroup, DeprecatedTooltipController, Link, Icon, Typography } from '@sourcegraph/wildcard'
import { Timestamp } from '../../components/time/Timestamp'
import { GitCommitFields } from '../../graphql-operations'
@ -89,7 +89,7 @@ export const GitCommitNode: React.FunctionComponent<React.PropsWithChildren<GitC
}, [showCommitMessageBody])
useEffect(() => {
TooltipController.forceUpdate()
DeprecatedTooltipController.forceUpdate()
}, [flashCopiedToClipboardMessage])
const copyToClipboard = useCallback((oid): void => {

View File

@ -1,167 +1,33 @@
// Base class
.tooltip {
--tooltip-opacity: 1;
--tooltip-font-size: 0.75rem;
--tooltip-zindex: 1070;
--tooltip-arrow-width: 0.8rem;
--tooltip-arrow-height: 0.4rem;
--tooltip-bg: var(--gray-08);
--tooltip-arrow-color: var(--tooltip-bg);
:root {
--tooltip-font-size: 0.75rem; // 12px
--tooltip-line-height: 1.02rem; // 16.32px / 16px, per Figma
--tooltip-max-width: 256px;
--tooltip-color: var(--white);
--tooltip-color: var(--light-text);
--tooltip-border-radius: var(--border-radius);
--tooltip-padding-y: 0.25rem;
--tooltip-padding-x: 0.5rem;
--tooltip-margin: 0;
}
position: absolute;
z-index: var(--tooltip-zindex);
display: block;
margin: var(--tooltip-margin);
.tooltip {
// This goes along with the fix described here: https://github.com/radix-ui/primitives/issues/620#issuecomment-1079147761
// This display style keeps the tooltip aligned on Button components, since `inline` or `inline-block` did not fill the full height,
// resulting in tooltips anchoring on just the Button text, not the full Button element
display: inline-flex;
user-select: text;
}
.tooltip-content {
font-size: var(--tooltip-font-size);
// Our parent element can be arbitrary since tooltips are by default inserted as a sibling of their target element.
// So reset our font and text properties to avoid inheriting weird values.
// rules from reset-text() mixin
font-style: normal;
font-weight: normal;
text-align: left; // Fallback for where `start` is not supported
text-align: start;
text-decoration: none;
text-shadow: none;
text-transform: none;
letter-spacing: normal;
word-break: normal;
word-spacing: normal;
line-break: auto;
// Allow breaking very long words so they don't overflow the tooltip's bounds
word-wrap: break-word;
opacity: 0;
// Allows line breaks in tooltips
white-space: pre-wrap;
animation: 0.25s fade-in;
pointer-events: none;
line-height: 1rem;
&.show {
opacity: var(--tooltip-opacity);
}
.arrow {
position: absolute;
display: block;
width: var(--tooltip-arrow-width);
height: var(--tooltip-arrow-height);
&::before {
position: absolute;
content: '';
border-style: solid;
border-color: transparent;
height: 0.8rem; // Fix bug in Firefox where the arrow won't inherit the parent's height
}
}
}
.tooltip-top {
padding: var(--tooltip-arrow-height) 0;
.arrow {
bottom: 0;
&::before {
top: 0;
border-width: var(--tooltip-arrow-height) calc(var(--tooltip-arrow-width) / 2) 0;
border-top-color: var(--tooltip-arrow-color);
}
}
}
.tooltip-right {
padding: 0 var(--tooltip-arrow-height);
.arrow {
left: 0;
width: var(--tooltip-arrow-height);
height: var(--tooltip-arrow-width);
&::before {
right: 0;
border-width: calc(var(--tooltip-arrow-width) / 2) var(--tooltip-arrow-height)
calc(var(--tooltip-arrow-width) / 2) 0;
border-right-color: var(--tooltip-arrow-color);
}
}
}
.tooltip-bottom {
padding: var(--tooltip-arrow-height) 0;
.arrow {
top: 0;
&::before {
bottom: 0;
border-width: 0 calc(var(--tooltip-arrow-width) / 2) var(--tooltip-arrow-height);
border-bottom-color: var(--tooltip-arrow-color);
}
}
}
.tooltip-left {
padding: 0 var(--tooltip-arrow-height);
.arrow {
right: 0;
width: var(--tooltip-arrow-height);
height: var(--tooltip-arrow-width);
&::before {
left: 0;
border-width: calc(var(--tooltip-arrow-width) / 2) 0 calc(var(--tooltip-arrow-width) / 2)
var(--tooltip-arrow-height);
border-left-color: var(--tooltip-arrow-color);
}
}
}
.tooltip-auto {
&[x-placement^='top'] {
@extend .tooltip-top;
}
&[x-placement^='right'] {
@extend .tooltip-right;
}
&[x-placement^='bottom'] {
@extend .tooltip-bottom;
}
&[x-placement^='left'] {
@extend .tooltip-left;
}
}
// Wrapper for the tooltip content
.tooltip-inner {
line-height: var(--tooltip-line-height);
max-width: var(--tooltip-max-width);
padding: var(--tooltip-padding-y) var(--tooltip-padding-x);
color: var(--tooltip-color);
text-align: center;
background-color: var(--tooltip-bg);
border-radius: var(--tooltip-border-radius);
color: var(--tooltip-color);
padding: var(--tooltip-padding-y) var(--tooltip-padding-x);
user-select: text;
}
@keyframes fade-in {
0% {
opacity: 0;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
.tooltip-arrow {
fill: var(--tooltip-bg);
}

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'
import { useState } from 'react'
import { DecoratorFn, Meta, Story } from '@storybook/react'
@ -7,10 +7,8 @@ import webStyles from '@sourcegraph/web/src/SourcegraphWebApp.scss'
import { Button, Grid, Typography, Text } from '..'
import { Tooltip } from './Tooltip'
import { TooltipController } from './TooltipController'
import { Tooltip } from '.'
// BrandedStory already renders `<Tooltip />` so in Stories we don't render `<Tooltip />`
const decorator: DecoratorFn = story => (
<BrandedStory styles={webStyles}>{() => <div className="p-5">{story()}</div>}</BrandedStory>
)
@ -40,141 +38,168 @@ const config: Meta = {
export default config
export const Basic: Story = () => (
<>
<Text>
You can <strong data-tooltip="Tooltip 1">hover me</strong> or <strong data-tooltip="Tooltip 2">me</strong>.
</Text>
</>
<Text>
You can{' '}
<Tooltip content="Tooltip 1">
<strong>hover me</strong>
</Tooltip>{' '}
or{' '}
<Tooltip content="Tooltip 2">
<strong>me</strong>
</Tooltip>
.
</Text>
)
Basic.parameters = {
chromatic: {
disable: true,
},
}
export const Conditional: Story = () => {
const [clicked, setClicked] = useState<boolean>(false)
export const Positions: Story = () => (
<>
<Typography.H1>Tooltip</Typography.H1>
<Typography.H2>Positions</Typography.H2>
<Grid columnCount={4}>
<div>
<Button variant="secondary" data-placement="top" data-tooltip="Tooltip on top">
Tooltip on top
</Button>
</div>
<div>
<Button variant="secondary" data-placement="bottom" data-tooltip="Tooltip on bottom">
Tooltip on bottom
</Button>
</div>
<div>
<Button variant="secondary" data-placement="left" data-tooltip="Tooltip on left">
Tooltip on left
</Button>
</div>
<div>
<Button variant="secondary" data-placement="right" data-tooltip="Tooltip on right">
Tooltip on right
</Button>
</div>
</Grid>
<Typography.H2>Max width</Typography.H2>
<Grid columnCount={1}>
<div>
<Button
variant="secondary"
data-tooltip="Nulla porttitor accumsan tincidunt. Proin eget tortor risus. Quisque velit nisi, pretium ut lacinia in, elementum id enim. Donec rutrum congue leo eget malesuada."
>
Tooltip with long text
</Button>
</div>
</Grid>
</>
)
Positions.parameters = {
chromatic: {
disable: true,
},
}
/*
If you take a look at the handleEvent function in useTooltipState, you can see that the listeners are being added to the 'document',
which means any 'mouseover/click' event will cause the tooltip to disappear.
*/
export const Pinned: Story = () => {
const clickElement = useCallback((element: HTMLElement | null) => {
if (element) {
// The tooltip takes some time to set-up.
// hence we need to delay the click by some ms.
setTimeout(() => {
element.click()
}, 10)
}
}, [])
function onClick() {
setClicked(true)
setTimeout(() => setClicked(false), 1500)
}
return (
<>
<span data-tooltip="My tooltip" ref={clickElement}>
Example
</span>
<Grid columnCount={1}>
<div>
<Tooltip content={clicked ? "Now there's a Tooltip!" : null}>
<Button variant="primary" onClick={onClick}>
Click Me to See a Tooltip!
</Button>
</Tooltip>
</div>
<Text>
<small>
(A pinned tooltip is shown when the target element is rendered, without any user interaction
needed.)
</small>
A Tooltip can be conditionally shown by alternating between passing{' '}
<Typography.Code>null</Typography.Code> and a <Typography.Code>string</Typography.Code> in as{' '}
<Typography.Code>content</Typography.Code>.
</Text>
</>
</Grid>
)
}
Pinned.parameters = {
export const DisabledTrigger: Story = () => (
<Grid columnCount={1}>
<div>
<Tooltip content="Tooltip still works properly">
<Button variant="primary" disabled={true} style={{ pointerEvents: 'none' }}>
Disabled Button 🚫
</Button>
</Tooltip>
</div>
<Text>
{/**
* This is necessary to support our current implementation using Radix.
* Reference: https://www.radix-ui.com/docs/primitives/components/tooltip#displaying-a-tooltip-from-a-disabled-button
* */}
When rendering a Tooltip for a disabled <Typography.Code>{'<Button>'}</Typography.Code>, the button element
also needs to have the CSS property <Typography.Code>pointer-events: none</Typography.Code>.
</Text>
</Grid>
)
export const LongContent: Story = () => (
<Grid columnCount={1}>
<div>
<Tooltip content="Nulla porttitor accumsan tincidunt. Proin eget tortor risus. Quisque velit nisi, pretium ut lacinia in, elementum id enim. Donec rutrum congue leo eget malesuada.">
<Button variant="primary">Example</Button>
</Tooltip>
</div>
<Text>
Tooltips with long text will not exceed the width specified by{' '}
<Typography.Code>--tooltip-max-width</Typography.Code>.
</Text>
</Grid>
)
export const DefaultOpen: Story = () => (
<Grid columnCount={1}>
<div>
<Tooltip content="Click me!" defaultOpen={true}>
<Button variant="primary">Example</Button>
</Tooltip>
</div>
<Text>
A pinned tooltip is shown on initial render (no user input required) by setting{' '}
<Typography.Code>defaultOpen={'{true}'}</Typography.Code>.
</Text>
</Grid>
)
DefaultOpen.storyName = 'Default Open (Pinned)'
DefaultOpen.parameters = {
chromatic: {
// Chromatic pauses CSS animations by default and resets them to their initial state
pauseAnimationAtEnd: true,
enableDarkMode: true,
disableSnapshot: false,
},
}
const ForceUpdateTooltip = () => {
const [copied, setCopied] = useState<boolean>(false)
export const PlacementOptions: Story = () => (
<>
<Grid columnCount={5}>
<div>
<Tooltip content="Tooltip on top" placement="top">
<Button variant="primary">top</Button>
</Tooltip>
</div>
useEffect(() => {
TooltipController.forceUpdate()
}, [copied])
<div>
<Tooltip content="Tooltip on right" placement="right">
<Button variant="primary">right</Button>
</Tooltip>
</div>
const onClick: React.MouseEventHandler<HTMLButtonElement> = event => {
event.preventDefault()
<div>
<Tooltip content="Tooltip on bottom" placement="bottom">
<Button variant="primary">bottom</Button>
</Tooltip>
</div>
setCopied(true)
<div>
<Tooltip content="Tooltip on left" placement="left">
<Button variant="primary">left</Button>
</Tooltip>
</div>
setTimeout(() => {
setCopied(false)
}, 1500)
<div>
<Tooltip content="Default Tooltip placement">
<Button variant="primary">(default)</Button>
</Tooltip>
</div>
</Grid>
<Text>
The Tooltip will use the specified <Typography.Code>placement</Typography.Code> unless a viewport collision
is detected, in which case it will be mirrored.
</Text>
</>
)
export const UpdateContent: Story = () => {
const [clicked, setClicked] = useState<boolean>(false)
function onClick() {
setClicked(true)
setTimeout(() => setClicked(false), 1500)
}
return (
<>
<Typography.H2>
Force update tooltip with <Typography.Code>TooltipController.forceUpdate()</Typography.Code>
</Typography.H2>
<Grid columnCount={1}>
<div>
<Tooltip content={clicked ? 'New message!' : 'Click to change the message.'}>
<Button variant="primary" onClick={onClick}>
Click Me
</Button>
</Tooltip>
</div>
<Text>
<Button variant="primary" onClick={onClick} data-tooltip={copied ? 'Copied!' : 'Click to copy'}>
Button
</Button>
The string passed in as <Typography.Code>content</Typography.Code> can be modified without any
controlled or forced updates required.
</Text>
</>
</Grid>
)
}
export const Controller: Story = () => <ForceUpdateTooltip />
Controller.parameters = {
chromatic: {
disable: true,
},
}

View File

@ -1,60 +1,85 @@
import { render, RenderResult, cleanup, fireEvent, waitFor, screen } from '@testing-library/react'
import { render, RenderResult, cleanup, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Tooltip } from './Tooltip'
const TooltipTest = () => (
<>
<Tooltip />
<div>
Hover on{' '}
<strong data-testid="hoverable-1" data-tooltip="Tooltip 1">
me
</strong>
, or{' '}
<strong data-testid="hoverable-2" data-tooltip="Tooltip 2">
me
</strong>
</div>
Hover on{' '}
<Tooltip content="Tooltip 1">
<strong data-testid="trigger-1">me</strong>
</Tooltip>
, or{' '}
<Tooltip content="Tooltip 2">
<strong data-testid="trigger-2">me</strong>
</Tooltip>
</>
)
describe('Tooltip', () => {
let queries: RenderResult
let rendered: RenderResult
afterEach(cleanup)
describe('Hoverable Tooltip', () => {
beforeEach(() => {
queries = render(<TooltipTest />)
beforeEach(() => {
rendered = render(<TooltipTest />)
})
it('displays content when the trigger is hovered', async () => {
userEvent.hover(rendered.getByTestId('trigger-1'))
await waitFor(() => {
expect(rendered.getByTestId('trigger-1').parentElement).toHaveAttribute('aria-describedby', 'radix-0')
expect(rendered.getByTestId('trigger-2').parentElement).not.toHaveAttribute('aria-describedby')
// Should be one tooltip for visual users, and a second for use with aria-describedby
const tooltips = rendered.getAllByRole('tooltip')
expect(tooltips).toHaveLength(2)
expect(tooltips[0]).toHaveTextContent('Tooltip 1')
expect(tooltips[1]).toHaveTextContent('Tooltip 1')
expect(tooltips[1]).toHaveAttribute('id', 'radix-0')
})
it('Shows tooltip properly on hover', async () => {
fireEvent.mouseOver(queries.getByTestId('hoverable-1'))
userEvent.hover(rendered.getByTestId('trigger-2'))
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent('Tooltip 1')
expect(screen.getByRole('tooltip').closest('.show.fade')).toBeInTheDocument()
})
await waitFor(() => {
expect(rendered.getByTestId('trigger-1').parentElement).not.toHaveAttribute('aria-describedby')
expect(rendered.getByTestId('trigger-2').parentElement).toHaveAttribute('aria-describedby', 'radix-1')
expect(document.body).toMatchSnapshot()
// Should be one tooltip for visual users, and a second for use with aria-describedby
const tooltips = rendered.getAllByRole('tooltip')
expect(tooltips).toHaveLength(2)
expect(tooltips[0]).toHaveTextContent('Tooltip 2')
expect(tooltips[1]).toHaveTextContent('Tooltip 2')
expect(tooltips[1]).toHaveAttribute('id', 'radix-1')
})
})
it('hides content when the ESC key is pressed', async () => {
userEvent.hover(rendered.getByTestId('trigger-1'))
await waitFor(() => {
expect(rendered.getAllByRole('tooltip')).toHaveLength(2)
})
it('Handles multiple tooltips properly', async () => {
fireEvent.mouseOver(queries.getByTestId('hoverable-1'))
userEvent.type(rendered.getByTestId('trigger-1'), '{esc}')
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent('Tooltip 1')
expect(screen.getByRole('tooltip').closest('.show.fade')).toBeInTheDocument()
})
await waitFor(() => {
expect(rendered.queryByRole('tooltip')).not.toBeInTheDocument()
})
})
fireEvent.mouseOver(queries.getByTestId('hoverable-2'))
it('does not hide content when the trigger is clicked', async () => {
userEvent.hover(rendered.getByTestId('trigger-1'))
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent('Tooltip 2')
expect(screen.getByRole('tooltip').closest('.show.fade')).toBeInTheDocument()
})
await waitFor(() => {
expect(rendered.getAllByRole('tooltip')).toHaveLength(2)
})
expect(document.body).toMatchSnapshot()
userEvent.click(rendered.getByTestId('trigger-1'))
await waitFor(() => {
expect(rendered.getAllByRole('tooltip')).toHaveLength(2)
})
})
})

View File

@ -1,63 +1,81 @@
import React, { ReactNode, useMemo } from 'react'
import React, { ReactNode } from 'react'
import classNames from 'classnames'
import Popper from 'popper.js'
// eslint-disable-next-line no-restricted-imports
import { Tooltip as BootstrapTooltip } from 'reactstrap'
import { useTooltipState } from './useTooltipState'
import { getTooltipStyle } from './utils'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import styles from './Tooltip.module.scss'
interface TooltipProps {
className?: string
children?: ReactNode
/** A single child element that will trigger the Tooltip to open on hover. */
children: ReactNode
/** The text that will be displayed in the Tooltip. If `null`, no Tooltip will be rendered, allowing for Tooltips to be shown conditionally. */
content: string | null
/** The open state of the tooltip when it is initially rendered. Defaults to `false`. */
defaultOpen?: boolean
/** The preferred side of the trigger to render against when open. Will be reversed if a collision is detected. Defaults to `right`. */
placement?: TooltipPrimitive.TooltipContentProps['side']
}
const TOOLTIP_MODIFIERS: Popper.Modifiers = {
flip: {
enabled: false,
},
preventOverflow: {
boundariesElement: 'window',
},
/** Arrow width in pixels */
const TOOLTIP_ARROW_WIDTH = 14
/** Arrow height in pixel */
const TOOLTIP_ARROW_HEIGHT = 6
// Handling the onPointerDownOutside event and preventing the default behavior allows us to keep the Tooltip content open
// even if the trigger <span> was clicked; this allows buttons to be clicked and text to be selected without dismissing content.
// Reference: https://github.com/radix-ui/primitives/issues/1077
function onPointerDownOutside(event: Event): void {
event.preventDefault()
}
/**
* Renders a Tooltip that can be positioned relative to a target element.
* Renders a Tooltip that will be positioned relative to the wrapped child element. Please reference the examples in Storybook
* for more details on specific use cases.
*
* This component should typically only need to be rendered once in a React tree.
* If you need to attach a tooltip to an specific element, simply add the `data-tooltip` attribute to that element.
* To support accessibility, our tooltips should:
* - Be supplemental to the user journey, not essential.
* - Use clear and concise text.
* - Not include interactive content (you probably want a `<Popover>` instead).
*
* Related accessibility documentation: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tooltip_role
*/
export const Tooltip: React.FunctionComponent<React.PropsWithChildren<TooltipProps>> = ({ className }) => {
const { subject, content, subjectSeq, placement = 'auto', delay } = useTooltipState()
export const Tooltip: React.FunctionComponent<TooltipProps> = ({
children,
content,
defaultOpen = false,
placement = 'right',
}) => (
// NOTE: We plan to consolidate this logic with our Popover component in the future, but chose Radix first to support short-term accessibility needs.
// GitHub issue: https://github.com/sourcegraph/sourcegraph/issues/36080
<TooltipPrimitive.Root delayDuration={0} defaultOpen={defaultOpen}>
<TooltipPrimitive.Trigger asChild={true}>
{/** The onClick and role attributes here are part of the onPointerDownOutside fix described above. */}
<span role="presentation" className={styles.tooltip} onClick={event => event.preventDefault()}>
{children}
const tooltipStyle = useMemo(() => getTooltipStyle(placement), [placement])
{
// The rest of the Tooltip components still need to be rendered for the content to correctly be shown conditionally.
content === null ? null : (
/*
* Rendering the Content within the Trigger is a workaround to support being able to hover over the Tooltip content itself.
* Refrence: https://github.com/radix-ui/primitives/issues/620#issuecomment-1079147761
*/
<TooltipPrimitive.TooltipContent
onPointerDownOutside={onPointerDownOutside}
className={styles.tooltipContent}
side={placement}
role="tooltip"
>
{content}
if (!subject || !content) {
return null
}
return (
<BootstrapTooltip
// Set key prop to work around a bug where quickly mousing between 2 elements with tooltips
// displays the 2nd element's tooltip as still pointing to the first.
key={subjectSeq}
isOpen={true}
target={subject}
placement={placement}
// in order to add our own placement classes we need to set the popperClassNames
// here is where bootstrap injects it's placement classes such as 'bs-tooltip-auto' automatically.
popperClassName={classNames(styles.tooltip, styles.show, className, tooltipStyle)}
arrowClassName={styles.arrow}
innerClassName={styles.tooltipInner}
// This is a workaround to an issue with tooltips in reactstrap that causes the entire page to freeze.
// Remove when https://github.com/reactstrap/reactstrap/issues/1482 is fixed.
modifiers={TOOLTIP_MODIFIERS}
delay={delay}
>
{content}
</BootstrapTooltip>
)
}
<TooltipPrimitive.Arrow
className={styles.tooltipArrow}
height={TOOLTIP_ARROW_HEIGHT}
width={TOOLTIP_ARROW_WIDTH}
/>
</TooltipPrimitive.TooltipContent>
)
}
</span>
</TooltipPrimitive.Trigger>
</TooltipPrimitive.Root>
)

View File

@ -1,2 +1 @@
export * from './Tooltip'
export * from './TooltipController'

View File

@ -0,0 +1,167 @@
// Base class
.tooltip {
--tooltip-opacity: 1;
--tooltip-font-size: 0.75rem;
--tooltip-zindex: 1070;
--tooltip-arrow-width: 0.8rem;
--tooltip-arrow-height: 0.4rem;
--tooltip-bg: var(--gray-08);
--tooltip-arrow-color: var(--tooltip-bg);
--tooltip-max-width: 256px;
--tooltip-color: var(--white);
--tooltip-border-radius: var(--border-radius);
--tooltip-padding-y: 0.25rem;
--tooltip-padding-x: 0.5rem;
--tooltip-margin: 0;
position: absolute;
z-index: var(--tooltip-zindex);
display: block;
margin: var(--tooltip-margin);
font-size: var(--tooltip-font-size);
// Our parent element can be arbitrary since tooltips are by default inserted as a sibling of their target element.
// So reset our font and text properties to avoid inheriting weird values.
// rules from reset-text() mixin
font-style: normal;
font-weight: normal;
text-align: left; // Fallback for where `start` is not supported
text-align: start;
text-decoration: none;
text-shadow: none;
text-transform: none;
letter-spacing: normal;
word-break: normal;
word-spacing: normal;
line-break: auto;
// Allow breaking very long words so they don't overflow the tooltip's bounds
word-wrap: break-word;
opacity: 0;
// Allows line breaks in tooltips
white-space: pre-wrap;
animation: 0.25s fade-in;
pointer-events: none;
line-height: 1rem;
&.show {
opacity: var(--tooltip-opacity);
}
.arrow {
position: absolute;
display: block;
width: var(--tooltip-arrow-width);
height: var(--tooltip-arrow-height);
&::before {
position: absolute;
content: '';
border-style: solid;
border-color: transparent;
height: 0.8rem; // Fix bug in Firefox where the arrow won't inherit the parent's height
}
}
}
.tooltip-top {
padding: var(--tooltip-arrow-height) 0;
.arrow {
bottom: 0;
&::before {
top: 0;
border-width: var(--tooltip-arrow-height) calc(var(--tooltip-arrow-width) / 2) 0;
border-top-color: var(--tooltip-arrow-color);
}
}
}
.tooltip-right {
padding: 0 var(--tooltip-arrow-height);
.arrow {
left: 0;
width: var(--tooltip-arrow-height);
height: var(--tooltip-arrow-width);
&::before {
right: 0;
border-width: calc(var(--tooltip-arrow-width) / 2) var(--tooltip-arrow-height)
calc(var(--tooltip-arrow-width) / 2) 0;
border-right-color: var(--tooltip-arrow-color);
}
}
}
.tooltip-bottom {
padding: var(--tooltip-arrow-height) 0;
.arrow {
top: 0;
&::before {
bottom: 0;
border-width: 0 calc(var(--tooltip-arrow-width) / 2) var(--tooltip-arrow-height);
border-bottom-color: var(--tooltip-arrow-color);
}
}
}
.tooltip-left {
padding: 0 var(--tooltip-arrow-height);
.arrow {
right: 0;
width: var(--tooltip-arrow-height);
height: var(--tooltip-arrow-width);
&::before {
left: 0;
border-width: calc(var(--tooltip-arrow-width) / 2) 0 calc(var(--tooltip-arrow-width) / 2)
var(--tooltip-arrow-height);
border-left-color: var(--tooltip-arrow-color);
}
}
}
.tooltip-auto {
&[x-placement^='top'] {
@extend .tooltip-top;
}
&[x-placement^='right'] {
@extend .tooltip-right;
}
&[x-placement^='bottom'] {
@extend .tooltip-bottom;
}
&[x-placement^='left'] {
@extend .tooltip-left;
}
}
// Wrapper for the tooltip content
.tooltip-inner {
max-width: var(--tooltip-max-width);
padding: var(--tooltip-padding-y) var(--tooltip-padding-x);
color: var(--tooltip-color);
text-align: center;
background-color: var(--tooltip-bg);
border-radius: var(--tooltip-border-radius);
}
@keyframes fade-in {
0% {
opacity: 0;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}

View File

@ -0,0 +1,181 @@
import React, { useCallback, useEffect, useState } from 'react'
import { DecoratorFn, Meta, Story } from '@storybook/react'
import { BrandedStory } from '@sourcegraph/branded/src/components/BrandedStory'
import webStyles from '@sourcegraph/web/src/SourcegraphWebApp.scss'
import { Button, Grid, Typography, Text } from '../..'
import { TooltipController } from './TooltipController'
// BrandedStory already renders `<Tooltip />` so in Stories we don't render `<Tooltip />`
const decorator: DecoratorFn = story => (
<BrandedStory styles={webStyles}>{() => <div className="p-5">{story()}</div>}</BrandedStory>
)
const config: Meta = {
title: 'wildcard/Tooltip/Deprecated',
decorators: [decorator],
parameters: {
component: 'DeprecatedTooltip',
design: [
{
type: 'figma',
name: 'Figma Light',
url: 'https://www.figma.com/file/NIsN34NH7lPu04olBzddTw/Wildcard-Design-System?node-id=3131%3A38534',
},
{
type: 'figma',
name: 'Figma Dark',
url: 'https://www.figma.com/file/NIsN34NH7lPu04olBzddTw/Wildcard-Design-System?node-id=3131%3A38727',
},
],
},
}
export default config
export const Basic: Story = () => (
<Text>
You can{' '}
<strong data-tooltip="Tooltip 1" data-placement="right">
hover me
</strong>{' '}
or <strong data-tooltip="Tooltip 2">me</strong>.
</Text>
)
Basic.parameters = {
chromatic: {
disable: true,
},
}
export const Positions: Story = () => (
<>
<Typography.H1>Tooltip</Typography.H1>
<Typography.H2>Positions</Typography.H2>
<Grid columnCount={4}>
<div>
<Button variant="secondary" data-placement="top" data-tooltip="Tooltip on top">
Tooltip on top
</Button>
</div>
<div>
<Button variant="secondary" data-placement="bottom" data-tooltip="Tooltip on bottom">
Tooltip on bottom
</Button>
</div>
<div>
<Button variant="secondary" data-placement="left" data-tooltip="Tooltip on left">
Tooltip on left
</Button>
</div>
<div>
<Button variant="secondary" data-placement="right" data-tooltip="Tooltip on right">
Tooltip on right
</Button>
</div>
</Grid>
<Typography.H2>Max width</Typography.H2>
<Grid columnCount={1}>
<div>
<Button
variant="secondary"
data-tooltip="Nulla porttitor accumsan tincidunt. Proin eget tortor risus. Quisque velit nisi, pretium ut lacinia in, elementum id enim. Donec rutrum congue leo eget malesuada."
>
Tooltip with long text
</Button>
</div>
</Grid>
</>
)
Positions.parameters = {
chromatic: {
disable: true,
},
}
/*
If you take a look at the handleEvent function in useTooltipState, you can see that the listeners are being added to the 'document',
which means any 'mouseover/click' event will cause the tooltip to disappear.
*/
export const Pinned: Story = () => {
const clickElement = useCallback((element: HTMLElement | null) => {
if (element) {
// The tooltip takes some time to set-up.
// hence we need to delay the click by some ms.
setTimeout(() => {
element.click()
}, 10)
}
}, [])
return (
<>
<span data-tooltip="My tooltip" ref={clickElement}>
Example
</span>
<Text>
<small>
(A pinned tooltip is shown when the target element is rendered, without any user interaction
needed.)
</small>
</Text>
</>
)
}
Pinned.parameters = {
chromatic: {
// Chromatic pauses CSS animations by default and resets them to their initial state
pauseAnimationAtEnd: true,
enableDarkMode: true,
disableSnapshot: false,
},
}
const ForceUpdateTooltip = () => {
const [copied, setCopied] = useState<boolean>(false)
useEffect(() => {
TooltipController.forceUpdate()
}, [copied])
const onClick: React.MouseEventHandler<HTMLButtonElement> = event => {
event.preventDefault()
setCopied(true)
setTimeout(() => {
setCopied(false)
}, 1500)
}
return (
<>
<Typography.H2>
Force update tooltip with <Typography.Code>TooltipController.forceUpdate()</Typography.Code>
</Typography.H2>
<Text>
<Button variant="primary" onClick={onClick} data-tooltip={copied ? 'Copied!' : 'Click to copy'}>
Button
</Button>
</Text>
</>
)
}
export const Controller: Story = () => <ForceUpdateTooltip />
Controller.parameters = {
chromatic: {
disable: true,
},
}

View File

@ -0,0 +1,60 @@
import { render, RenderResult, cleanup, fireEvent, waitFor, screen } from '@testing-library/react'
import { Tooltip } from './Tooltip'
const TooltipTest = () => (
<>
<Tooltip />
<div>
Hover on{' '}
<strong data-testid="hoverable-1" data-tooltip="Tooltip 1">
me
</strong>
, or{' '}
<strong data-testid="hoverable-2" data-tooltip="Tooltip 2">
me
</strong>
</div>
</>
)
describe('Tooltip', () => {
let queries: RenderResult
afterEach(cleanup)
describe('Hoverable Tooltip', () => {
beforeEach(() => {
queries = render(<TooltipTest />)
})
it('Shows tooltip properly on hover', async () => {
fireEvent.mouseOver(queries.getByTestId('hoverable-1'))
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent('Tooltip 1')
expect(screen.getByRole('tooltip').closest('.show.fade')).toBeInTheDocument()
})
expect(document.body).toMatchSnapshot()
})
it('Handles multiple tooltips properly', async () => {
fireEvent.mouseOver(queries.getByTestId('hoverable-1'))
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent('Tooltip 1')
expect(screen.getByRole('tooltip').closest('.show.fade')).toBeInTheDocument()
})
fireEvent.mouseOver(queries.getByTestId('hoverable-2'))
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent('Tooltip 2')
expect(screen.getByRole('tooltip').closest('.show.fade')).toBeInTheDocument()
})
expect(document.body).toMatchSnapshot()
})
})
})

View File

@ -0,0 +1,67 @@
import React, { ReactNode, useMemo } from 'react'
import classNames from 'classnames'
import Popper from 'popper.js'
// eslint-disable-next-line no-restricted-imports
import { Tooltip as BootstrapTooltip } from 'reactstrap'
import { useTooltipState } from './useTooltipState'
import { getTooltipStyle } from './utils'
import styles from './Tooltip.module.scss'
interface TooltipProps {
className?: string
children?: ReactNode
}
const TOOLTIP_MODIFIERS: Popper.Modifiers = {
flip: {
enabled: false,
},
preventOverflow: {
boundariesElement: 'window',
},
}
/**
* @deprecated We now provide a `Tooltip` Wildcard component that can be used directly instead of attaching
* tooltips to an element using `data-tooltip`. This component will be removed after all use of `data-tooltip`
* are migrated to use the new `Tooltip` component.
*
* Renders a Tooltip that can be positioned relative to a target element.
*
* This component should typically only need to be rendered once in a React tree.
* If you need to attach a tooltip to an specific element, simply add the `data-tooltip` attribute to that element.
*/
export const Tooltip: React.FunctionComponent<React.PropsWithChildren<TooltipProps>> = ({ className }) => {
const { subject, content, subjectSeq, placement = 'auto', delay } = useTooltipState()
const tooltipStyle = useMemo(() => getTooltipStyle(placement), [placement])
if (!subject || !content) {
return null
}
return (
<BootstrapTooltip
// Set key prop to work around a bug where quickly mousing between 2 elements with tooltips
// displays the 2nd element's tooltip as still pointing to the first.
key={subjectSeq}
isOpen={true}
target={subject}
placement={placement}
// in order to add our own placement classes we need to set the popperClassNames
// here is where bootstrap injects it's placement classes such as 'bs-tooltip-auto' automatically.
popperClassName={classNames(styles.tooltip, styles.show, className, tooltipStyle)}
arrowClassName={styles.arrow}
innerClassName={styles.tooltipInner}
// This is a workaround to an issue with tooltips in reactstrap that causes the entire page to freeze.
// Remove when https://github.com/reactstrap/reactstrap/issues/1482 is fixed.
modifiers={TOOLTIP_MODIFIERS}
delay={delay}
>
{content}
</BootstrapTooltip>
)
}

View File

@ -2,6 +2,11 @@ import { UseTooltipReturn } from './useTooltipState'
type TooltipInstance = Pick<UseTooltipReturn, 'forceUpdate'>
/**
* @deprecated We will be moving away from the `data-tooltip` pattern and now provide a `Tooltip` component
* to use directly. This controller will be removed after all uses of `data-tooltip` are migrated to use the
* new `Tooltip` component.
*/
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class TooltipController {
private static instance: TooltipInstance | undefined

View File

@ -0,0 +1,2 @@
export * from './Tooltip'
export * from './TooltipController'

View File

@ -22,7 +22,8 @@ export { Tabs, Tab, TabList, TabPanel, TabPanels, useTabsContext } from './Tabs'
export { SourcegraphIcon } from './SourcegraphIcon'
export { Badge, ProductStatusBadge, BADGE_VARIANTS, PRODUCT_STATUSES } from './Badge'
export { Panel } from './Panel'
export { Tooltip, TooltipController } from './Tooltip'
export { Tooltip } from './Tooltip'
export { Tooltip as DeprecatedTooltip, TooltipController as DeprecatedTooltipController } from './deprecated/Tooltip'
export { Card, CardBody, CardHeader, CardList, CardSubtitle, CardText, CardTitle, CardFooter } from './Card'
export { Icon } from './Icon'
export { ButtonLink } from './ButtonLink'

View File

@ -56,6 +56,7 @@ const config = {
path.join(__dirname, 'client/shared/dev/mockPopper.ts'),
path.join(__dirname, 'client/shared/dev/fetch'),
path.join(__dirname, 'client/shared/dev/setLinkComponentForTest.ts'),
path.join(__dirname, 'client/shared/dev/mockDomRect.ts'),
path.join(__dirname, 'client/shared/dev/mockResizeObserver.ts'),
path.join(__dirname, 'client/shared/dev/mockUniqueId.ts'),
path.join(__dirname, 'client/shared/dev/mockSentryBrowser.ts'),

View File

@ -359,6 +359,7 @@
"@codemirror/view": "^0.20.4",
"@lezer/highlight": "^0.16.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@radix-ui/react-tooltip": "^0.1.8 || 0.1.8-rc.2",
"@reach/accordion": "^0.16.1",
"@reach/combobox": "^0.16.5",
"@reach/dialog": "^0.16.2",

208
yarn.lock
View File

@ -3469,6 +3469,200 @@
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.10.2.tgz#0798c03351f0dea1a5a4cabddf26a55a7cbee590"
integrity sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ==
"@radix-ui/popper@0.1.0":
version "0.1.0"
resolved "https://registry.npmjs.org/@radix-ui/popper/-/popper-0.1.0.tgz#c387a38f31b7799e1ea0d2bb1ca0c91c2931b063"
integrity sha512-uzYeElL3w7SeNMuQpXiFlBhTT+JyaNMCwDfjKkrzugEcYrf5n52PHqncNdQPUtR42hJh8V9FsqyEDbDxkeNjJQ==
dependencies:
"@babel/runtime" "^7.13.10"
csstype "^3.0.4"
"@radix-ui/primitive@0.1.0":
version "0.1.0"
resolved "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-0.1.0.tgz#6206b97d379994f0d1929809db035733b337e543"
integrity sha512-tqxZKybwN5Fa3VzZry4G6mXAAb9aAqKmPtnVbZpL0vsBwvOHTBwsjHVPXylocYLwEtBY9SCe665bYnNB515uoA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-arrow@0.1.4":
version "0.1.4"
resolved "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.4.tgz#a871448a418cd3507d83840fdd47558cb961672b"
integrity sha512-BB6XzAb7Ml7+wwpFdYVtZpK1BlMgqyafSQNGzhIpSZ4uXvXOHPlR5GP8M449JkeQzgQjv9Mp1AsJxFC0KuOtuA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "0.1.4"
"@radix-ui/react-compose-refs@0.1.0":
version "0.1.0"
resolved "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz#cff6e780a0f73778b976acff2c2a5b6551caab95"
integrity sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-context@0.1.1":
version "0.1.1"
resolved "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz#06996829ea124d9a1bc1dbe3e51f33588fab0875"
integrity sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-dismissable-layer@0.1.5":
version "0.1.5"
resolved "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz#9379032351e79028d472733a5cc8ba4a0ea43314"
integrity sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "0.1.0"
"@radix-ui/react-compose-refs" "0.1.0"
"@radix-ui/react-primitive" "0.1.4"
"@radix-ui/react-use-body-pointer-events" "0.1.1"
"@radix-ui/react-use-callback-ref" "0.1.0"
"@radix-ui/react-use-escape-keydown" "0.1.0"
"@radix-ui/react-id@0.1.5":
version "0.1.5"
resolved "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz#010d311bedd5a2884c1e9bb6aaaa4e6cc1d1d3b8"
integrity sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "0.1.0"
"@radix-ui/react-popper@0.1.4":
version "0.1.4"
resolved "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-0.1.4.tgz#dfc055dcd7dfae6a2eff7a70d333141d15a5d029"
integrity sha512-18gDYof97t8UQa7zwklG1Dr8jIdj3u+rVOQLzPi9f5i1YQak/pVGkaqw8aY+iDUknKKuZniTk/7jbAJUYlKyOw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/popper" "0.1.0"
"@radix-ui/react-arrow" "0.1.4"
"@radix-ui/react-compose-refs" "0.1.0"
"@radix-ui/react-context" "0.1.1"
"@radix-ui/react-primitive" "0.1.4"
"@radix-ui/react-use-rect" "0.1.1"
"@radix-ui/react-use-size" "0.1.1"
"@radix-ui/rect" "0.1.1"
"@radix-ui/react-portal@0.1.4":
version "0.1.4"
resolved "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-0.1.4.tgz#17bdce3d7f1a9a0b35cb5e935ab8bc562441a7d2"
integrity sha512-MO0wRy2eYRTZ/CyOri9NANCAtAtq89DEtg90gicaTlkCfdqCLEBsLb+/q66BZQTr3xX/Vq01nnVfc/TkCqoqvw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "0.1.4"
"@radix-ui/react-use-layout-effect" "0.1.0"
"@radix-ui/react-presence@0.1.2":
version "0.1.2"
resolved "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-0.1.2.tgz#9f11cce3df73cf65bc348e8b76d891f0d54c1fe3"
integrity sha512-3BRlFZraooIUfRlyN+b/Xs5hq1lanOOo/+3h6Pwu2GMFjkGKKa4Rd51fcqGqnVlbr3jYg+WLuGyAV4KlgqwrQw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "0.1.0"
"@radix-ui/react-use-layout-effect" "0.1.0"
"@radix-ui/react-primitive@0.1.4":
version "0.1.4"
resolved "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz#6c233cf08b0cb87fecd107e9efecb3f21861edc1"
integrity sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-slot" "0.1.2"
"@radix-ui/react-slot@0.1.2":
version "0.1.2"
resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz#e6f7ad9caa8ce81cc8d532c854c56f9b8b6307c8"
integrity sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "0.1.0"
"@radix-ui/react-tooltip@^0.1.8 || 0.1.8-rc.2":
version "0.1.8-rc.2"
resolved "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-0.1.8-rc.2.tgz#f393141910b9cda541a41b928f1efc3626088d51"
integrity sha512-2m5kGl/KiOHwehpskaE4KlJ/3c45iQJeZldjoOK71k1FfmGWIxd8Wy4yzMz+RfNNZXiGPYaMXSvXSmjMEVfrGA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "0.1.0"
"@radix-ui/react-compose-refs" "0.1.0"
"@radix-ui/react-context" "0.1.1"
"@radix-ui/react-dismissable-layer" "0.1.5"
"@radix-ui/react-id" "0.1.5"
"@radix-ui/react-popper" "0.1.4"
"@radix-ui/react-portal" "0.1.4"
"@radix-ui/react-presence" "0.1.2"
"@radix-ui/react-primitive" "0.1.4"
"@radix-ui/react-slot" "0.1.2"
"@radix-ui/react-use-controllable-state" "0.1.0"
"@radix-ui/react-visually-hidden" "0.1.4"
"@radix-ui/react-use-body-pointer-events@0.1.1":
version "0.1.1"
resolved "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz#63e7fd81ca7ffd30841deb584cd2b7f460df2597"
integrity sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "0.1.0"
"@radix-ui/react-use-callback-ref@0.1.0":
version "0.1.0"
resolved "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz#934b6e123330f5b3a6b116460e6662cbc663493f"
integrity sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-controllable-state@0.1.0":
version "0.1.0"
resolved "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz#4fced164acfc69a4e34fb9d193afdab973a55de1"
integrity sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-callback-ref" "0.1.0"
"@radix-ui/react-use-escape-keydown@0.1.0":
version "0.1.0"
resolved "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz#dc80cb3753e9d1bd992adbad9a149fb6ea941874"
integrity sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-callback-ref" "0.1.0"
"@radix-ui/react-use-layout-effect@0.1.0":
version "0.1.0"
resolved "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz#ebf71bd6d2825de8f1fbb984abf2293823f0f223"
integrity sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-rect@0.1.1":
version "0.1.1"
resolved "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-0.1.1.tgz#6c15384beee59c086e75b89a7e66f3d2e583a856"
integrity sha512-kHNNXAsP3/PeszEmM/nxBBS9Jbo93sO+xuMTcRfwzXsmxT5gDXQzAiKbZQ0EecCPtJIzqvr7dlaQi/aP1PKYqQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/rect" "0.1.1"
"@radix-ui/react-use-size@0.1.1":
version "0.1.1"
resolved "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-0.1.1.tgz#f6b75272a5d41c3089ca78c8a2e48e5f204ef90f"
integrity sha512-pTgWM5qKBu6C7kfKxrKPoBI2zZYZmp2cSXzpUiGM3qEBQlMLtYhaY2JXdXUCxz+XmD1YEjc8oRwvyfsD4AG4WA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-visually-hidden@0.1.4":
version "0.1.4"
resolved "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-0.1.4.tgz#6c75eae34fb5d084b503506fbfc05587ced05f03"
integrity sha512-K/q6AEEzqeeEq/T0NPChvBqnwlp8Tl4NnQdrI/y8IOY7BRR+Ug0PEsVk6g48HJ7cA1//COugdxXXVVK/m0X1mA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "0.1.4"
"@radix-ui/rect@0.1.1":
version "0.1.1"
resolved "https://registry.npmjs.org/@radix-ui/rect/-/rect-0.1.1.tgz#95b5ba51f469bea6b1b841e2d427e17e37d38419"
integrity sha512-g3hnE/UcOg7REdewduRPAK88EPuLZtaq7sA9ouu8S+YEtnyFRI16jgv6GZYe3VMoQLL1T171ebmEPtDjyxWLzw==
dependencies:
"@babel/runtime" "^7.13.10"
"@reach/accordion@^0.16.1":
version "0.16.1"
resolved "https://registry.npmjs.org/@reach/accordion/-/accordion-0.16.1.tgz#020fcbfa0d0768a95ca1702952b87eafbfbc7f38"
@ -10360,10 +10554,10 @@ csstype@^2.5.7:
resolved "https://registry.npmjs.org/csstype/-/csstype-2.6.4.tgz#d585a6062096e324e7187f80e04f92bd0f00e37f"
integrity sha512-lAJUJP3M6HxFXbqtGRc0iZrdyeN+WzOWeY0q/VnFzI+kqVrYIzC7bWlKqCW7oCIdzoPkvfp82EVvrTlQ8zsWQg==
csstype@^3.0.2:
version "3.0.2"
resolved "https://registry.npmjs.org/csstype/-/csstype-3.0.2.tgz#ee5ff8f208c8cd613b389f7b222c9801ca62b3f7"
integrity sha512-ofovWglpqoqbfLNOTBNZLSbMuGrblAf1efvvArGKOZMBrIoJeu5UsAipQolkijtyQx5MtAzT/J9IHj/CEY1mJw==
csstype@^3.0.2, csstype@^3.0.4:
version "3.1.0"
resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2"
integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==
currently-unhandled@^0.4.1:
version "0.4.1"
@ -21607,9 +21801,9 @@ regenerate@^1.4.0:
integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==
regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.7:
version "0.13.7"
resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55"
integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==
version "0.13.9"
resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
regenerator-transform@^0.14.2:
version "0.14.2"