Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tooltip-suppression.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Tooltip: Add `disabled` to suppress the tooltip without disabling its trigger or removing its accessible label or description. Deprecate `_privateDisableTooltip` in favor of `disabled`.
2 changes: 1 addition & 1 deletion packages/react/src/Button/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const IconButton = forwardRef(
type={description ? undefined : 'label'}
direction={tooltipDirection}
keybindingHint={keybindingHint ?? keyshortcuts}
_privateDisableTooltip={hasActivePopup}
disabled={hasActivePopup}
>
<ButtonBase
icon={Icon}
Expand Down
22 changes: 22 additions & 0 deletions packages/react/src/Button/__tests__/Button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,28 @@ describe('Button', () => {
await expect.element(wrapper).toHaveClass(classes.ConditionalWrapperLink)
})

it('keeps an active popup trigger enabled while suppressing its tooltip', async () => {
const onClick = vi.fn()
const example = (expanded: boolean) => (
<IconButton icon={HeartIcon} aria-label="Heart" aria-haspopup="true" aria-expanded={expanded} onClick={onClick} />
)
const {container, rerender} = render(example(false))
const button = page.getByRole('button', {name: 'Heart', exact: true})
const tooltip = container.querySelector('[data-component="Tooltip"]')!
const labelId = button.element().getAttribute('aria-labelledby')
await userEvent.hover(button)
await expect.poll(() => tooltip.matches(':popover-open')).toBe(true)

rerender(example(true))
expect(tooltip.matches(':popover-open')).toBe(false)
expect(button.element()).toHaveAttribute('aria-labelledby', labelId)
expect(button.element()).toHaveAccessibleName('Heart')
await expect.element(button).toBeEnabled()
await userEvent.click(button)
expect(onClick).toHaveBeenCalledOnce()
expect(tooltip.matches(':popover-open')).toBe(false)
})

it('should render tooltip on an icon button when unsafeDisableTooltip prop is passed as false', async () => {
render(<IconButton icon={HeartIcon} aria-label="Heart" />)
const triggerEL = page.getByRole('button')
Expand Down
6 changes: 6 additions & 0 deletions packages/react/src/TooltipV2/Tooltip.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@
"type": "'short' | 'medium' | 'long'",
"defaultValue": "short",
"description": "Sets the delay before showing the tooltip. `short` is 50ms (default), `medium` is 400ms, and `long` is 1200ms."
},
{
"name": "disabled",
"type": "boolean",
"defaultValue": "false",
"description": "Closes the visual tooltip, cancels pending opens, and prevents reopening without disabling its trigger or removing its accessible label or description. Re-enabling requires a fresh hover or focus. The deprecated _privateDisableTooltip alias remains compatible; either prop being true suppresses the tooltip."
}
],
"subcomponents": []
Expand Down
65 changes: 64 additions & 1 deletion packages/react/src/TooltipV2/Tooltip.examples.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {useState, useCallback, useRef} from 'react'
import {useId, useState, useCallback, useRef} from 'react'
import {action} from 'storybook/actions'
import {Button, IconButton} from '../Button'
import Breadcrumbs from '../Breadcrumbs'
import {ActionMenu} from '../ActionMenu'
Expand All @@ -8,6 +9,7 @@ import {Tooltip} from './Tooltip'
import {Dialog} from '../experimental'
import {GitBranchIcon, KebabHorizontalIcon, TriangleDownIcon, CheckIcon, XIcon} from '@primer/octicons-react'
import {default as VisuallyHidden} from '../_VisuallyHidden'
import Checkbox from '../Checkbox'

export default {
title: 'Components/TooltipV2/Examples',
Expand All @@ -20,6 +22,67 @@ export const CustomId = () => (
</Tooltip>
)

export function SuppressWhileMenuOpen() {
const [isMenuOpen, setIsMenuOpen] = useState(false)
return (
<div style={{padding: 48}}>
<p>Hover or focus to show the hint. Opening the menu suppresses it without disabling the button.</p>
<ActionMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<ActionMenu.Anchor>
<Tooltip disabled={isMenuOpen} text="Choose an action">
<Button>Actions</Button>
</Tooltip>
</ActionMenu.Anchor>
<ActionMenu.Overlay>
<ActionList>
<ActionList.Item>View details</ActionList.Item>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</div>
)
}

SuppressWhileMenuOpen.parameters = {
docs: {
description: {
story:
'Use Tooltip.disabled to suppress the hint while its menu is open. The trigger remains enabled and its accessible description is retained. This does not control tooltip visibility directly.',
},
},
}

export function TooltipOnlyDisabled() {
const checkboxId = useId()
const [disabled, setDisabled] = useState(false)
return (
<div style={{padding: 48}}>
<p>
Suppressing the tooltip does not disable the action or remove its accessible description. Trigger clicks still
appear in Actions.
</p>
<label htmlFor={checkboxId}>
<Checkbox id={checkboxId} checked={disabled} onChange={event => setDisabled(event.currentTarget.checked)} />
Suppress tooltip
</label>
<div style={{marginTop: 16}}>
<Tooltip disabled={disabled} text="Run the action">
<Button onClick={action('onClick')}>Run action</Button>
</Tooltip>
</div>
</div>
)
}

TooltipOnlyDisabled.parameters = {
docs: {
description: {
story:
'disabled closes visible bubbles and cancels pending opens. Re-enable, then hover or focus again to reopen. The deprecated _privateDisableTooltip alias remains compatible: either prop being true wins. Disable the Button separately when the action itself is unavailable.',
},
},
}

export const FilesPage = () => (
<PageHeader role="banner" aria-label="Banner">
<PageHeader.ContextArea>
Expand Down
38 changes: 28 additions & 10 deletions packages/react/src/TooltipV2/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import useSafeTimeout from '../hooks/useSafeTimeout'
import type {SlotMarker} from '../utils/types'
import {TooltipContext} from './TooltipContext'
import {reactMajorVersion} from '../utils/environment'
import {useEffectCallback} from '../internal/hooks/useEffectCallback'

export type TooltipDirection = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
export type TooltipProps = React.PropsWithChildren<{
Expand All @@ -30,7 +31,15 @@ export type TooltipProps = React.PropsWithChildren<{
*/
delay?: 'short' | 'medium' | 'long'
/**
* Private API for use internally only. Prevents the tooltip from opening if `true`.
* Closes and suppresses the tooltip without disabling its trigger or removing
* its accessible label or description. Either suppression prop being true wins.
* @default false
*/
disabled?: boolean
/**
* @deprecated Use `disabled` instead.
*
* Private API for use internally only. Closes and prevents the tooltip from opening if `true`.
*
* Accessibility note: This prop should be used with caution. Only use when needing to
* programmatically close the tooltip in response to a specific user action, such as
Expand Down Expand Up @@ -121,11 +130,13 @@ export const Tooltip: ForwardRefExoticComponent<
className,
keybindingHint = emptyKeybindingHints,
delay = 'short',
disabled = false,
_privateDisableTooltip = false,
...rest
}: TooltipProps,
forwardedRef,
) => {
const isTooltipDisabled = disabled || _privateDisableTooltip
const tooltipId = useId(id)
const child = Children.only(children)
const isFragmentTrigger = React.isValidElement(child) && child.type === React.Fragment
Expand Down Expand Up @@ -161,7 +172,7 @@ export const Tooltip: ForwardRefExoticComponent<
readTriggerRef.current instanceof HTMLElement &&
tooltipElRef.current.hasAttribute('popover') &&
!tooltipElRef.current.matches(':popover-open') &&
!_privateDisableTooltip
!isTooltipDisabled
) {
const tooltip = tooltipElRef.current
const trigger = readTriggerRef.current
Expand Down Expand Up @@ -197,8 +208,8 @@ export const Tooltip: ForwardRefExoticComponent<
}
}
}
const closeTooltip = () => {
if (openTimeoutRef.current) {
const closeTooltip = useEffectCallback(() => {
if (openTimeoutRef.current !== null) {
safeClearTimeout(openTimeoutRef.current)
openTimeoutRef.current = null
}
Expand All @@ -210,10 +221,8 @@ export const Tooltip: ForwardRefExoticComponent<
tooltipElRef.current.matches(':popover-open')
) {
tooltipElRef.current.hidePopover()
setIsPopoverOpen(false)
} else {
setIsPopoverOpen(false)
}
setIsPopoverOpen(false)
} catch (error) {
// older browsers don't support the :popover-open selector and will throw, even though we use a polyfill
// see https://github.com/github/issues/issues/12468
Expand All @@ -229,7 +238,13 @@ export const Tooltip: ForwardRefExoticComponent<
throw error
}
}
}
})

useEffect(() => {
// Suppression must synchronize the external Popover API, not just future events.
// eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler
if (isTooltipDisabled) closeTooltip()
}, [isTooltipDisabled, closeTooltip])

// context value
const value = useMemo(() => ({tooltipId}), [tooltipId])
Expand Down Expand Up @@ -289,14 +304,14 @@ export const Tooltip: ForwardRefExoticComponent<
useOnEscapePress(
(event: KeyboardEvent) => {
if (isPopoverOpen) {
if (!_privateDisableTooltip) {
if (!isTooltipDisabled) {
event.stopImmediatePropagation()
event.preventDefault()
}
closeTooltip()
}
},
[isPopoverOpen, _privateDisableTooltip],
[isPopoverOpen, isTooltipDisabled],
)

const platform = usePlatform()
Expand Down Expand Up @@ -356,6 +371,8 @@ export const Tooltip: ForwardRefExoticComponent<
child.props.onFocus?.(event)
},
onMouseOverCapture: (event: React.MouseEvent) => {
if (isTooltipDisabled) return
if (openTimeoutRef.current !== null) safeClearTimeout(openTimeoutRef.current)
const delayTime = delayTimeMap[delay] || 50
// We use a `capture` event to ensure this is called first before
// events that might cancel the opening timeout (like `onTouchEnd`)
Expand All @@ -364,6 +381,7 @@ export const Tooltip: ForwardRefExoticComponent<
openTimeoutRef.current = safeSetTimeout(() => {
// if the mouse is already moved out, do not show the tooltip
if (!openTimeoutRef.current) return
openTimeoutRef.current = null
openTooltip()
child.props.onMouseEnter?.(event)
}, delayTime)
Expand Down
Loading
Loading