diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index c6051c2318..70c4f5e0cc 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -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' diff --git a/packages/components/src/molecules/Tooltip/Tooltip.tsx b/packages/components/src/molecules/Tooltip/Tooltip.tsx new file mode 100644 index 0000000000..8433c5cb73 --- /dev/null +++ b/packages/components/src/molecules/Tooltip/Tooltip.tsx @@ -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, '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 + /** + * Called when the dismiss button is clicked. + */ + onDismiss?: ( + ev: + | React.KeyboardEvent + | React.MouseEvent + ) => 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(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(null) + const triggerRef = useRef(null) + + const handleDismiss = ( + ev: + | React.KeyboardEvent + | React.MouseEvent + ) => { + onDismiss?.(ev) + setOpen(false) + setDismissed(true) + } + + const toggleOpen = () => { + if (dismissed) { + setDismissed(false) + } + setOpen(true) + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + handleDismiss(event) + } + } + + useEffect(() => { + if (open && dismissible) { + dismissButtonRef.current?.focus() + } + }, [open, dismissible]) + + return ( +
setOpen(false)} + onFocus={toggleOpen} + onBlur={() => setOpen(false)} + data-testid={testId} + aria-describedby={describedById} + onKeyDown={handleKeyDown} + tabIndex={0} + ref={triggerRef} + > + {children} + + {open && !dismissed && ( +
+
+ {content} +
+ {dismissible && ( + } + aria-label="Dismiss tooltip" + data-fs-tooltip-dismiss-button + onClick={handleDismiss} + ref={dismissButtonRef} + /> + )} + + )} +
+ ) +}) + +export default Tooltip diff --git a/packages/components/src/molecules/Tooltip/index.ts b/packages/components/src/molecules/Tooltip/index.ts new file mode 100644 index 0000000000..9825087096 --- /dev/null +++ b/packages/components/src/molecules/Tooltip/index.ts @@ -0,0 +1,2 @@ +export { default } from './Tooltip' +export { TooltipProps } from './Tooltip' diff --git a/packages/ui/src/components/molecules/Tooltip/styles.scss b/packages/ui/src/components/molecules/Tooltip/styles.scss new file mode 100644 index 0000000000..4fb158c719 --- /dev/null +++ b/packages/ui/src/components/molecules/Tooltip/styles.scss @@ -0,0 +1,242 @@ +[data-fs-tooltip] { + // -------------------------------------------------------- + // Design Tokens for Tooltip + // -------------------------------------------------------- + + // Default properties + --fs-button-height : var(--fs-control-tap-size); + --fs-tooltip-z-index : var(--fs-z-index-high); + --fs-tooltip-background : var(--fs-color-neutral-6); + --fs-tooltip-text-color : var(--fs-color-text-inverse); + --fs-tooltip-border-radius : var(--fs-border-radius); + --fs-tooltip-padding : var(--fs-spacing-2); + --fs-tooltip-gap : var(--fs-spacing-1); + + --fs-tooltip-transition-property : opacity; + --fs-tooltip-transition-timing : var(--fs-transition-timing); + --fs-tooltip-transition-function : var(--fs-transition-function); + + // Indicator + --fs-tooltip-indicator-size : var(--fs-spacing-1); + --fs-tooltip-indicator-distance-edge : var(--fs-spacing-3); + --fs-tooltip-indicator-distance-base : var(--fs-spacing-1); + --fs-tooltip-indicator-translate : calc(var(--fs-tooltip-indicator-size) + var(--fs-tooltip-indicator-distance-base)); + + // -------------------------------------------------------- + // Structural Styles + // -------------------------------------------------------- + + position: relative; + display: inline-flex; + width: fit-content; + + [data-fs-tooltip-wrapper] { + position: absolute; + z-index: var(--fs-tooltip-z-index); + display: flex; + gap: var(--fs-tooltip-gap); + align-items: flex-start; + padding: var(--fs-tooltip-padding); + color: var(--fs-tooltip-text-color); + background-color: var(--fs-tooltip-background); + border-radius: var(--fs-tooltip-border-radius); + opacity: 1; + transition: + var(--fs-tooltip-transition-property) + var(--fs-tooltip-transition-timing) var(--fs-tooltip-transition-function); + } + + [data-fs-tooltip-content] { + width: max-content; + font-size: var(--fs-text-size-0); + font-weight: var(--fs-text-weight-medium); + line-height: 16px; + } + + [data-fs-tooltip-dismiss-button] { + flex-shrink: 0; + width: var(--fs-control-tap-size-smallest); + height: var(--fs-control-tap-size-smallest); + min-height: var(--fs-control-tap-size-smallest); + padding: 0; + } + + [data-fs-tooltip-indicator] { + position: absolute; + width: 0; + height: 0; + border: var(--fs-tooltip-indicator-size) solid transparent; + } + + // -------------------------------------------------------- + // Variants Styles + // -------------------------------------------------------- + + /* TOP */ + [data-fs-tooltip-placement^="top-center"] { + bottom: 100%; + transform: translateY(calc(-1 * var(--fs-tooltip-indicator-translate))); + } + + [data-fs-tooltip-placement^="top-center"] [data-fs-tooltip-indicator] { + top: 100%; + border-top-color: var(--fs-tooltip-background); + } + + /* TOP-CENTER */ + [data-fs-tooltip-placement="top-center"] { + left: 50%; + transform: + translateX(-50%) + translateY(calc(-1 * var(--fs-tooltip-indicator-translate))); + } + + [data-fs-tooltip-placement="top-center"] [data-fs-tooltip-indicator] { + left: 50%; + transform: translateX(-50%); + } + + /* TOP-START */ + [data-fs-tooltip-placement="top-start"] { + left: 0; + } + + [data-fs-tooltip-placement="top-start"] [data-fs-tooltip-indicator] { + left: var(--fs-spacing-3); + } + + /* TOP-END */ + [data-fs-tooltip-placement="top-end"] { + right: 0; + } + + [data-fs-tooltip-placement="top-end"] [data-fs-tooltip-indicator] { + right: var(--fs-spacing-3); + } + + /* RIGHT */ + [data-fs-tooltip-placement^="right-center"] { + left: 100%; + transform: translateX(var(--fs-tooltip-indicator-translate)); + } + + [data-fs-tooltip-placement^="right-center"] [data-fs-tooltip-indicator] { + right: 100%; + border-right-color: var(--fs-tooltip-background); + } + + /* RIGHT-CENTER */ + [data-fs-tooltip-placement="right-center"] { + top: 50%; + transform: + translateY(-50%) + translateX(var(--fs-tooltip-indicator-translate)); + } + + [data-fs-tooltip-placement="right-center"] [data-fs-tooltip-indicator] { + top: 50%; + transform: translateY(-50%); + } + + /* RIGHT-START */ + [data-fs-tooltip-placement="right-start"] { + top: 0; + } + + [data-fs-tooltip-placement="right-start"] [data-fs-tooltip-indicator] { + top: var(--fs-spacing-3); + } + + /* RIGHT-END */ + [data-fs-tooltip-placement="right-end"] { + bottom: 0; + } + + [data-fs-tooltip-placement="right-end"] [data-fs-tooltip-indicator] { + bottom: var(--fs-spacing-3); + } + + /* BOTTOM */ + [data-fs-tooltip-placement^="bottom-center"] { + top: 100%; + transform: translateY(var(--fs-tooltip-indicator-translate)); + } + + [data-fs-tooltip-placement^="bottom-center"] [data-fs-tooltip-indicator] { + bottom: 100%; + border-bottom-color: var(--fs-tooltip-background); + } + + /* BOTTOM-CENTER */ + [data-fs-tooltip-placement="bottom-center"] { + left: 50%; + transform: + translateX(-50%) + translateY(var(--fs-tooltip-indicator-translate)); + } + + [data-fs-tooltip-placement="bottom-center"] [data-fs-tooltip-indicator] { + left: 50%; + transform: translateX(-50%); + } + + /* BOTTOM-START */ + [data-fs-tooltip-placement="bottom-start"] { + left: 0; + } + + [data-fs-tooltip-placement="bottom-start"] [data-fs-tooltip-indicator] { + left: var(--fs-spacing-3); + } + + /* BOTTOM-END */ + [data-fs-tooltip-placement="bottom-end"] { + right: 0; + } + + [data-fs-tooltip-placement="bottom-end"] [data-fs-tooltip-indicator] { + right: var(--fs-spacing-3); + } + + /* LEFT */ + [data-fs-tooltip-placement^="left-center"] { + right: 100%; + transform: translateX(calc(-1 * var(--fs-tooltip-indicator-translate))); + } + + [data-fs-tooltip-placement^="left-center"] [data-fs-tooltip-indicator] { + left: 100%; + border-left-color: var(--fs-tooltip-background); + } + + /* LEFT-CENTER */ + [data-fs-tooltip-placement="left-center"] { + top: 50%; + transform: + translateY(-50%) + translateX(calc(-1 * var(--fs-tooltip-indicator-translate))); + } + + [data-fs-tooltip-placement="left-center"] [data-fs-tooltip-indicator] { + top: 50%; + transform: translateY(-50%); + } + + /* LEFT-START */ + [data-fs-tooltip-placement="left-start"] { + top: 0; + } + + [data-fs-tooltip-placement="left-start"] [data-fs-tooltip-indicator] { + top: var(--fs-spacing-3); + } + + /* LEFT-END */ + [data-fs-tooltip-placement="left-end"] { + bottom: 0; + } + + [data-fs-tooltip-placement="left-end"] [data-fs-tooltip-indicator] { + bottom: var(--fs-spacing-3); + } +} diff --git a/packages/ui/src/styles/components.scss b/packages/ui/src/styles/components.scss index 610b7c7f1d..83b13a55ac 100644 --- a/packages/ui/src/styles/components.scss +++ b/packages/ui/src/styles/components.scss @@ -57,6 +57,7 @@ @import "../components/molecules/Toast/styles"; @import "../components/molecules/Toggle/styles"; @import "../components/molecules/ToggleField/styles"; +@import "../components/molecules/Tooltip/styles"; // Organisms @import "../components/organisms/BannerText/styles";