Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add tooltip component #2644

Merged
merged 12 commits into from
Feb 18, 2025
2 changes: 2 additions & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ export { default as RatingField } from './molecules/RatingField'
export type { RatingFieldProps } from './molecules/RatingField'
export { default as RegionBar } from './molecules/RegionBar'
export type { RegionBarProps } from './molecules/RegionBar'
export { default as Tooltip } from './molecules/Tooltip'
export type { TooltipProps } from './molecules/Tooltip'

export { default as SearchProvider } from './molecules/SearchProvider'
export type { SearchProviderContextValue } from './molecules/SearchProvider'
Expand Down
163 changes: 163 additions & 0 deletions packages/components/src/molecules/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import React, {
type ReactNode,
useState,
forwardRef,
type HTMLAttributes,
useRef,
useEffect,
} from 'react'
import Icon from '../../atoms/Icon'
import IconButton from '../IconButton'

/**
* Specifies tooltip position.
*/
export type Side = 'top' | 'right' | 'bottom' | 'left'

/**
* Specifies tooltip alignment.
*/
export type Alignment = 'start' | 'center' | 'end'

/**
* Combines side + alignment (e.g., "top-start").
*/
export type Placement = `${Side}-${Alignment}`

export interface TooltipProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'content'> {
/**
* Text/content of the tooltip.
*/
content: ReactNode
/**
* Defines the side or side-alignment (e.g., "top-center", "right-end") of the tooltip.
*/
placement?: Placement
/**
* If the tooltip can be closed by a button.
*/
dismissible?: boolean
Copy link
Contributor

Choose a reason for hiding this comment

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

@renatamottam do we really need to have this dismissible prop?

The tooltip is expected to disappear when the focus is lost or on mouse out.
And according to this accessibility doc, it shouldn't contain interactive elements 🤔 So adding a button would be a problem (will add focus to the button). WDYT?

Ps: Add a big notice to the doc: this component should only be used for additional information and not for crucial content, as tooltip content may not be accessible to screen readers and could go unnoticed.

/**
* Called when the dismiss button is clicked.
*/
onDismiss?: (
ev:
| React.KeyboardEvent<HTMLDivElement>
| React.MouseEvent<HTMLButtonElement>
) => void
/**
* Element that activates the tooltip on hover/focus.
*/
children: ReactNode
/**
* ID to find this component in testing tools (e.g.: cypress, testing library, and jest).
*/
testId?: string
/**
* Maximum width of the tooltip.
*/
maxWidth?: number
/**
* ID for the tooltip content to be used with aria-describedby.
*/
describedById?: string
}

const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(function Tooltip(
{
content,
placement = 'top-center',
dismissible = false,
onDismiss,
children,
testId = 'fs-tooltip',
maxWidth = 300,
describedById = 'tooltip-content',
...otherProps
},
ref
) {
const [open, setOpen] = useState(false)
const [dismissed, setDismissed] = useState(false)
const dismissButtonRef = useRef<HTMLButtonElement>(null)
const triggerRef = useRef<HTMLDivElement>(null)

const handleDismiss = (
ev:
| React.KeyboardEvent<HTMLDivElement>
| React.MouseEvent<HTMLButtonElement>
) => {
onDismiss?.(ev)
setOpen(false)
setDismissed(true)
}

const toggleOpen = () => {
if (dismissed) {
setDismissed(false)
}
setOpen(true)
}

const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
handleDismiss(event)
}
}

useEffect(() => {
if (open && dismissible) {
dismissButtonRef.current?.focus()
}
}, [open, dismissible])

return (
<div
data-fs-tooltip
onMouseEnter={toggleOpen}
onMouseLeave={() => setOpen(false)}
onFocus={toggleOpen}
onBlur={() => setOpen(false)}
data-testid={testId}
aria-describedby={describedById}
onKeyDown={handleKeyDown}
tabIndex={0}
ref={triggerRef}
>
{children}

{open && !dismissed && (
<div
ref={ref}
data-fs-tooltip-wrapper
data-fs-tooltip-placement={placement}
data-fs-tooltip-dismissible={dismissible}
role="tooltip"
onKeyDown={handleKeyDown}
style={{ maxWidth }}
{...otherProps}
>
<div data-fs-tooltip-content id={describedById}>
{content}
</div>
{dismissible && (
<IconButton
size="small"
variant="tertiary"
inverse
icon={<Icon name="X" width={20} height={20} />}
aria-label="Dismiss tooltip"
data-fs-tooltip-dismiss-button
onClick={handleDismiss}
ref={dismissButtonRef}
/>
)}
<div data-fs-tooltip-indicator aria-hidden="true" />
</div>
)}
</div>
)
})

export default Tooltip
2 changes: 2 additions & 0 deletions packages/components/src/molecules/Tooltip/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from './Tooltip'
export { TooltipProps } from './Tooltip'
Loading
Loading