Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8a507be
IconButton: introduce tooltip behaviuor behind a prop
broccolinisoup Feb 6, 2024
3169fe4
changeset add
broccolinisoup Feb 6, 2024
71c9b6f
add comment
broccolinisoup Feb 6, 2024
d6f26f1
Merge branch 'main' into icon-button-tooltip-feature
broccolinisoup Feb 8, 2024
7ca980a
Merge branch 'main' into icon-button-tooltip-feature
broccolinisoup Feb 9, 2024
bb3c92a
rename the tooltip prop
broccolinisoup Feb 9, 2024
2c75e8c
rename the prop again 🙃
broccolinisoup Feb 9, 2024
b30e480
remove redundant stories
broccolinisoup Feb 9, 2024
841c517
clean up the mess
broccolinisoup Feb 9, 2024
20a0c4f
test(vrt): update snapshots
broccolinisoup Feb 9, 2024
ed6759d
Merge branch 'main' into icon-button-tooltip-feature
broccolinisoup Feb 14, 2024
8c5a010
toolbar button disable tooltip
broccolinisoup Feb 14, 2024
9604383
Merge branch 'main' into icon-button-tooltip-feature
broccolinisoup Feb 21, 2024
6841b54
icon button as menu anchor & external tooltip & tooltipDirection
broccolinisoup Feb 21, 2024
ce78708
update snaps
broccolinisoup Feb 21, 2024
12132a2
Merge branch 'main' into icon-button-tooltip-feature
broccolinisoup Feb 26, 2024
480b6aa
Merge branch 'main' into icon-button-tooltip-feature
broccolinisoup Mar 1, 2024
290e33e
code review comments
broccolinisoup Mar 1, 2024
f8ad63a
show tooltip only on focus-visible
broccolinisoup Mar 5, 2024
7be1721
Merge branch 'main' into icon-button-tooltip-feature
broccolinisoup Mar 5, 2024
c20bb37
catch focus-visible for jestdom
siddharthkp Mar 6, 2024
11e60b4
rename the prop and update the default version to be true
broccolinisoup Mar 8, 2024
8c48b2f
fix tests
broccolinisoup Mar 8, 2024
bb428b7
already default true
broccolinisoup Mar 8, 2024
579cab5
test(vrt): update snapshots
broccolinisoup Mar 8, 2024
b3f4f26
improve test and example
broccolinisoup Mar 8, 2024
c990fc9
Merge branch 'main' into icon-button-tooltip-feature
broccolinisoup Mar 13, 2024
2a8d931
Merge branch 'main' into icon-button-tooltip-feature
broccolinisoup Apr 5, 2024
414c662
test(vrt): update snapshots
broccolinisoup Apr 5, 2024
8ba386f
Merge branch 'main' into icon-button-tooltip-feature
broccolinisoup Apr 8, 2024
5b5fa94
fix linting
broccolinisoup Apr 8, 2024
a44ab67
Merge branch 'main' into icon-button-tooltip-feature
broccolinisoup Apr 11, 2024
a4cef3b
pass the id down and add test
broccolinisoup Apr 12, 2024
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
7 changes: 7 additions & 0 deletions .changeset/smart-chefs-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@primer/react': minor
---

IconButton: introduce tooltips on icon buttons by default with an option to disable with `disableTooltip` prop when necessary.

This change is considered potentially breaking due to introducing a new behaviour by default. Please upgrade with caution.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Comment thread
siddharthkp marked this conversation as resolved.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(not blocking) Ooh, we might want to bring Only show tooltip on :focus-visible, not :focus #4092 before we start enabling them in dotcom 😢

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a commit for this in this PR (copied your code) - let me know if you have any additional feedback 🙌🏻

Also do you have any recommendation about how to resolve that :focus-visible test issue? https://github.com/primer/react/actions/runs/8150351502/job/22276476856

@siddharthkp siddharthkp Mar 6, 2024

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sigh, looks like jsdom doesn't support the selector yet 🤷
jsdom/jsdom#3426

Added a commit to fallback to opening the tooltip in that scenario: c20bb37

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 14 additions & 2 deletions packages/react/src/ActionMenu/ActionMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react'
import React, {useEffect, useState} from 'react'
import {TriangleDownIcon} from '@primer/octicons-react'
import type {AnchoredOverlayProps} from '../AnchoredOverlay'
import {AnchoredOverlay} from '../AnchoredOverlay'
Expand Down Expand Up @@ -147,6 +147,17 @@ const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
const containerRef = React.useRef<HTMLDivElement>(null)
useMenuKeyboardNavigation(open, onClose, containerRef, anchorRef)

// If the menu anchor is an icon button, we need to label the menu by tooltip that also labelled the anchor.
Comment thread
broccolinisoup marked this conversation as resolved.
const [anchorAriaLabelledby, setAnchorAriaLabelledby] = useState<null | string>(null)
useEffect(() => {
if (anchorRef.current) {
const ariaLabelledby = anchorRef.current.getAttribute('aria-labelledby')
if (ariaLabelledby) {
setAnchorAriaLabelledby(ariaLabelledby)
}
}
}, [anchorRef])

return (
<AnchoredOverlay
anchorRef={anchorRef}
Expand All @@ -165,7 +176,8 @@ const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
value={{
container: 'ActionMenu',
listRole: 'menu',
listLabelledBy: ariaLabelledby || anchorId,
// If there is a custom aria-labelledby, use that. Otherwise, if exists, use the id that labels the anchor such as tooltip. If none of them exist, use anchor id.
listLabelledBy: ariaLabelledby || anchorAriaLabelledby || anchorId,
selectionAttribute: 'aria-checked', // Should this be here?
afterSelect: onClose,
}}
Expand Down
56 changes: 55 additions & 1 deletion packages/react/src/Button/IconButton.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import {HeartIcon} from '@primer/octicons-react'
import {HeartIcon, InboxIcon, ChevronDownIcon} from '@primer/octicons-react'
import React from 'react'
import {IconButton} from '.'
import {ActionMenu} from '../ActionMenu'
import {ActionList} from '../ActionList'
import {Tooltip} from '../TooltipV2'
import {default as TooltipV1} from '../Tooltip'

export default {
title: 'Components/IconButton/Features',
Expand All @@ -19,3 +23,53 @@ export const Small = () => <IconButton size="small" icon={HeartIcon} aria-label=
export const Medium = () => <IconButton size="medium" icon={HeartIcon} aria-label="Favorite" />

export const Large = () => <IconButton size="large" icon={HeartIcon} aria-label="Favorite" />

export const DisabledTooltip = () => (

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am thinking to remove this story after the PR is reviewed - not to encourage this use case as it is only here as a feature flag.

@siddharthkp siddharthkp Feb 21, 2024

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(non blocking) Good idea to remove it!

<IconButton disableTooltip={true} icon={HeartIcon} variant="primary" aria-label="Favorite" />
)

export const WithDescription = () => (
<IconButton icon={InboxIcon} aria-label="Notifications" description="You have no unread notifications." />
)

export const ExternalTooltip = () => (
<Tooltip text="this is a supportive description for icon button" direction="se">
<IconButton icon={HeartIcon} aria-label="HeartIcon" />
</Tooltip>
)

export const ExternalTooltipVersion1 = () => (
<TooltipV1 text="this is a supportive description for icon button" direction="se">
<IconButton icon={HeartIcon} aria-label="HeartIcon" />
</TooltipV1>
)

export const AsAMenuAnchor = () => (
<ActionMenu>
<ActionMenu.Anchor>
<IconButton icon={ChevronDownIcon} aria-label="Something" />
</ActionMenu.Anchor>

<ActionMenu.Overlay width="medium">
<ActionList>
<ActionList.Item onSelect={() => alert('Copy link clicked')}>
Copy link
<ActionList.TrailingVisual>⌘C</ActionList.TrailingVisual>
</ActionList.Item>
<ActionList.Item onSelect={() => alert('Quote reply clicked')}>
Quote reply
<ActionList.TrailingVisual>⌘Q</ActionList.TrailingVisual>
</ActionList.Item>
<ActionList.Item onSelect={() => alert('Edit comment clicked')}>
Edit comment
<ActionList.TrailingVisual>⌘E</ActionList.TrailingVisual>
</ActionList.Item>
<ActionList.Divider />
<ActionList.Item variant="danger" onSelect={() => alert('Delete file clicked')}>
Delete file
<ActionList.TrailingVisual>⌘D</ActionList.TrailingVisual>
</ActionList.Item>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
)
79 changes: 67 additions & 12 deletions packages/react/src/Button/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,75 @@ import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../uti
import {ButtonBase} from './ButtonBase'
import {defaultSxProp} from '../utils/defaultSxProp'
import {generateCustomSxProp} from './Button'
import {TooltipContext, Tooltip} from '../TooltipV2/Tooltip'
import {TooltipContext as TooltipContextV1} from '../Tooltip/Tooltip'

const IconButton = forwardRef(({sx: sxProp = defaultSxProp, icon: Icon, ...props}, forwardedRef): JSX.Element => {
let sxStyles = sxProp
// grap the button props that have associated data attributes in the styles
const {size} = props
const IconButton = forwardRef(
(
{
sx: sxProp = defaultSxProp,
icon: Icon,
'aria-label': ariaLabel,
description,
disabled,
tooltipDirection,
// This is planned to be a temporary prop until the default tooltip on icon buttons are fully rolled out.
disableTooltip = false,

@siddharthkp siddharthkp Feb 21, 2024

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...props
},
forwardedRef,
): JSX.Element => {
let sxStyles = sxProp
// grap the button props that have associated data attributes in the styles
const {size} = props

if (sxProp !== null && Object.keys(sxProp).length > 0) {
sxStyles = generateCustomSxProp({size}, sxProp)
}
if (sxProp !== null && Object.keys(sxProp).length > 0) {
sxStyles = generateCustomSxProp({size}, sxProp)
}

return (
// @ts-expect-error StyledButton wants both Anchor and Button refs
<ButtonBase icon={Icon} data-component="IconButton" sx={sxStyles} type="button" {...props} ref={forwardedRef} />
)
}) as PolymorphicForwardRefComponent<'button' | 'a', IconButtonProps>
// If the icon button is already wrapped in a tooltip, do not add one.
const {tooltipId} = React.useContext(TooltipContext) // Tooltip v2
const {tooltipId: tooltipIdV1} = React.useContext(TooltipContextV1) // Tooltip v1

const hasExternalTooltip = tooltipId || tooltipIdV1
Comment thread
siddharthkp marked this conversation as resolved.
const withoutTooltip =
disableTooltip || disabled || ariaLabel === undefined || ariaLabel === '' || hasExternalTooltip

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(non blocking question) is the check for empty/undefined aria-label temporary for smooth adoption or a permanent check?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great question! There is a possibility of aria-label being undefined i.e. when aria-labelledby is provided.

When an icon button has an aria-labelledby prop, very high likely that it will have a tooltip (search results to reference) so I chose to not complicate things and not to show a tooltip when there is an aria-labelledby. I guess there is a still a change for cases like below to show up but imo it is not a traditional icon button API and we might not need to cover this edge case? They can always wrap it with tooltip for additional text. Let me know what you think 🙌🏻

<IconButton aria-labelledby="some-element-to-label" description="additional text" .../>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds fair to me, up to you!


if (withoutTooltip) {
return (
<ButtonBase
icon={Icon}
data-component="IconButton"
sx={sxStyles}
type="button"
aria-label={ariaLabel}
disabled={disabled}
{...props}
// @ts-expect-error StyledButton wants both Anchor and Button refs
ref={forwardedRef}
/>
)
} else {
return (
<Tooltip
ref={forwardedRef}
text={description ?? ariaLabel}
type={description ? undefined : 'label'}
direction={tooltipDirection}
>
<ButtonBase
icon={Icon}
data-component="IconButton"
sx={sxStyles}
type="button"
// If description is provided, we will use the tooltip to describe the button, so we need to keep the aria-label to label the button.
aria-label={description ? ariaLabel : undefined}
{...props}
/>
</Tooltip>
)
}
},
) as PolymorphicForwardRefComponent<'button' | 'a', IconButtonProps>

export {IconButton}
24 changes: 23 additions & 1 deletion packages/react/src/Button/__tests__/Button.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {SearchIcon} from '@primer/octicons-react'
import {SearchIcon, HeartIcon} from '@primer/octicons-react'
import {render, screen, fireEvent} from '@testing-library/react'
import {axe} from 'jest-axe'
import React from 'react'
Expand Down Expand Up @@ -113,4 +113,26 @@ describe('Button', () => {
const position = screen.getByText('content').compareDocumentPosition(screen.getByTestId('trailingVisual'))
expect(position).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
})

it('should render tooltip on an icon button by default', () => {
const {getByRole, getByText} = render(<IconButton icon={HeartIcon} aria-label="Heart" />)
const triggerEL = getByRole('button')
const tooltipEl = getByText('Heart')
expect(triggerEL).toHaveAttribute('aria-labelledby', tooltipEl.id)
})
it('should render description type tooltip on an icon button by default', () => {
const {getByRole, getByText} = render(
<IconButton icon={HeartIcon} aria-label="Heart" description="Love is all around" />,
)
const triggerEL = getByRole('button')
expect(triggerEL).toHaveAttribute('aria-label', 'Heart')
const tooltipEl = getByText('Love is all around')
expect(triggerEL).toHaveAttribute('aria-describedby', tooltipEl.id)
})
it('should not render tooltip on an icon button when disableTooltip prop is passed as true', () => {
const {getByRole} = render(<IconButton icon={HeartIcon} aria-label="Heart" disableTooltip={true} />)
const triggerEl = getByRole('button')
expect(triggerEl).not.toHaveAttribute('aria-labelledby')
expect(triggerEl).toHaveAttribute('aria-label', 'Heart')
})
})
4 changes: 4 additions & 0 deletions packages/react/src/Button/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import styled from 'styled-components'
import type {SxProp} from '../sx'
import sx from '../sx'
import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles'
import type {TooltipDirection} from '../TooltipV2'

export const StyledButton = styled.button<SxProp>`
${getGlobalFocusStyles('-2px')};
Expand Down Expand Up @@ -77,6 +78,9 @@ export type ButtonProps = {

export type IconButtonProps = ButtonA11yProps & {
icon: React.ElementType
disableTooltip?: boolean
Comment thread
broccolinisoup marked this conversation as resolved.
Outdated
description?: string
tooltipDirection?: TooltipDirection
} & Omit<ButtonBaseProps, 'aria-label' | 'aria-labelledby'>

// adopted from React.AnchorHTMLAttributes
Expand Down
18 changes: 13 additions & 5 deletions packages/react/src/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import clsx from 'clsx'
import React from 'react'
import React, {useMemo} from 'react'
import styled from 'styled-components'
import {get} from '../constants'
import type {SxProp} from '../sx'
import sx from '../sx'
import type {ComponentProps} from '../utils/types'
import {useId} from '../hooks'

/* Tooltip v1 */

Expand Down Expand Up @@ -193,18 +194,25 @@ export type TooltipProps = {
wrap?: boolean
} & ComponentProps<typeof TooltipBase>

function Tooltip({direction = 'n', children, className, text, noDelay, align, wrap, ...rest}: TooltipProps) {
export const TooltipContext = React.createContext<{tooltipId?: string}>({})
function Tooltip({direction = 'n', children, className, text, noDelay, align, wrap, id, ...rest}: TooltipProps) {
const tooltipId = useId(id)
const classes = clsx(
className,
`tooltipped-${direction}`,
align && `tooltipped-align-${align}-2`,
noDelay && 'tooltipped-no-delay',
wrap && 'tooltipped-multiline',
)

const value = useMemo(() => ({tooltipId}), [tooltipId])
return (
<TooltipBase role="tooltip" aria-label={text} {...rest} className={classes}>
{children}
</TooltipBase>
// This provider is used to check if an icon button is wrapped with tooltip or not.
<TooltipContext.Provider value={value}>
<TooltipBase role="tooltip" aria-label={text} {...rest} className={classes}>
{children}
</TooltipBase>
</TooltipContext.Provider>
)
}

Expand Down
37 changes: 36 additions & 1 deletion packages/react/src/TooltipV2/Tooltip.examples.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from 'react'
import React, {useState, useCallback, useRef} from 'react'
import {Button, IconButton, Breadcrumbs, ActionMenu, ActionList, NavList} from '..'
import {PageHeader} from '../PageHeader'
import {Tooltip} from './Tooltip'
import {Dialog} from '../drafts'
import {
GitBranchIcon,
KebabHorizontalIcon,
Expand Down Expand Up @@ -186,3 +187,37 @@ export const Hyperlist = () => (
</NavList.Item>
</NavList>
)

export const DialogTrigger = () => {
const [isOpen, setIsOpen] = useState(false)
const [secondOpen, setSecondOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
const onDialogClose = useCallback(() => setIsOpen(false), [])
const onSecondDialogClose = useCallback(() => setSecondOpen(false), [])
const openSecondDialog = useCallback(() => setSecondOpen(true), [])
return (
<>
<Tooltip text="Ready to merge">
<IconButton ref={buttonRef} onClick={() => setIsOpen(!isOpen)} icon={CheckIcon} aria-label="Check mark" />
</Tooltip>
{isOpen && (
<Dialog
title="My Dialog"
onClose={onDialogClose}
footerButtons={[
{buttonType: 'default', content: 'Open Second Dialog', onClick: openSecondDialog},
{buttonType: 'danger', content: 'Delete the universe', onClick: onDialogClose},
{buttonType: 'primary', content: 'Proceed', onClick: openSecondDialog, autoFocus: true},
]}
>
Dialog content
{secondOpen && (
<Dialog title="Inner dialog!" onClose={onSecondDialogClose} width="small">
Hello world
</Dialog>
)}
</Dialog>
)}
</>
)
}
6 changes: 4 additions & 2 deletions packages/react/src/TooltipV2/Tooltip.playground.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {Button, Box} from '..'
import {Tooltip} from './Tooltip'
import type {Meta, StoryFn} from '@storybook/react'

export default {
const meta: Meta<typeof Tooltip> = {
title: 'Components/TooltipV2/Playground',
component: Tooltip,

Expand All @@ -22,7 +22,9 @@ export default {
control: false,
},
},
} as Meta<typeof Tooltip>
}

export default meta

// Description type, north direction by default
export const Playground: StoryFn = args => {
Expand Down
Loading