diff --git a/src/components/tooltip/__tests__/__snapshots__/tooltip.test.tsx.snap b/src/components/tooltip/__tests__/__snapshots__/tooltip.test.tsx.snap new file mode 100644 index 0000000..4f3da53 --- /dev/null +++ b/src/components/tooltip/__tests__/__snapshots__/tooltip.test.tsx.snap @@ -0,0 +1,59 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Tooltip with createPortal > should not render the tooltip when hidden 1`] = ` +
+ +
+`; + +exports[`Tooltip with createPortal > should render the tooltip when visible 1`] = ` +
+ + +
+`; diff --git a/src/components/tooltip/__tests__/tooltip.test.tsx b/src/components/tooltip/__tests__/tooltip.test.tsx new file mode 100644 index 0000000..ff2b030 --- /dev/null +++ b/src/components/tooltip/__tests__/tooltip.test.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { vi } from 'vitest' +import { fireEvent, render } from '@testing-library/react' +import { useTooltip } from '../use-tooltip' +import { Button } from '../../button' +import { Tooltip } from '../tooltip' + +// Mock createPortal for rendering Tooltip to the body +vi.mock('react-dom', async () => { + const actual = await vi.importActual('react-dom') + return { + ...actual, + createPortal: (node: React.ReactNode) => node, // Render the node directly + } +}) + +describe('Tooltip with createPortal', () => { + it('should render the tooltip when visible', () => { + const TestComponent = () => { + const tooltip = useTooltip() + return ( + <> + + + + ) + } + + const { container, getByText } = render() + // Trigger visibility + const button = getByText('Hover me') + fireEvent.mouseEnter(button) + + // Snapshot test with the tooltip visible + expect(container).toMatchSnapshot() + }) + + it('should not render the tooltip when hidden', () => { + const TestComponent = () => { + const tooltip = useTooltip() + return ( + <> + + + + ) + } + + const { container } = render() + // Snapshot test with the tooltip hidden + expect(container).toMatchSnapshot() + }) +}) diff --git a/src/components/tooltip/__tests__/use-tooltip.test.tsx b/src/components/tooltip/__tests__/use-tooltip.test.tsx new file mode 100644 index 0000000..0a3ac5d --- /dev/null +++ b/src/components/tooltip/__tests__/use-tooltip.test.tsx @@ -0,0 +1,64 @@ +import { renderHook } from '@testing-library/react-hooks' +import { render, screen, fireEvent } from '@testing-library/react' +import { useTooltip } from '../use-tooltip' +import { Button } from '../../button' +import { Tooltip } from '../tooltip' +import { describe, expect, test } from 'vitest' + +// Helper component to test hook behavior +const TooltipTestComponent = () => { + const tooltip = useTooltip() + + return ( + <> + + + + ) +} + +describe('useTooltip', () => { + test('should return tooltip and trigger props', () => { + const { result } = renderHook(() => useTooltip()) + + const triggerProps = result.current.getTriggerProps() + const tooltipProps = result.current.getTooltipProps() + + expect(triggerProps).toHaveProperty('aria-describedby') + expect(triggerProps).toHaveProperty('onFocus') + expect(triggerProps).toHaveProperty('onBlur') + expect(triggerProps).toHaveProperty('onMouseEnter') + expect(triggerProps).toHaveProperty('onMouseLeave') + + expect(tooltipProps).toHaveProperty('id') + expect(tooltipProps).toHaveProperty('role', 'tooltip') + expect(tooltipProps).toHaveProperty('aria-hidden', true) + }) + + test('should position the tooltip correctly', () => { + render() + + const trigger = screen.getByText('Hover me') + + fireEvent.mouseEnter(trigger) + + const tooltip = screen.getByText('Tooltip description') + + const tooltipStyle = window.getComputedStyle(tooltip) + expect(tooltipStyle.position).toBe('absolute') + // Result for top and left can be negative as we haven't added support for the overflowing tooltip over viewport + // Once support is added remove '-' from the match below + expect(tooltipStyle.transform).toMatch(/translate\(-?\d+px, -?\d+px\)/) + }) + + test('should clean up event listeners on unmount', () => { + const { unmount } = render() + + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener') + + unmount() + + expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)) + removeEventListenerSpy.mockRestore() + }) +}) diff --git a/src/components/tooltip/index.ts b/src/components/tooltip/index.ts new file mode 100644 index 0000000..8c3f7a0 --- /dev/null +++ b/src/components/tooltip/index.ts @@ -0,0 +1,2 @@ +export * from './styles' +export * from './tooltip' diff --git a/src/components/tooltip/styles.ts b/src/components/tooltip/styles.ts new file mode 100644 index 0000000..18edd46 --- /dev/null +++ b/src/components/tooltip/styles.ts @@ -0,0 +1,34 @@ +import { styled } from '@linaria/react' + +export const ElTooltip = styled.div` + width: max-content; + background: var(--fill-default-darkest, #222b33); + color: var(--text-white); + padding: var(--spacing-2) var(--spacing-3) var(--spacing-2) var(--spacing-3); + border-radius: var(--corner-default); + font-family: var(--font-family); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-xs); + letter-spacing: var(--letter-spacing-xs); + text-align: left; + position: absolute; + transition: opacity 0.2s; + pointer-events: none; + white-space: normal; + word-wrap: break-word; + z-index: 99999; // Adding additional CSS to position tooltip on top of everything + + // To do (In Future): Tooltip Positioning to be replaced with css anchor positioning feature + // Currently not supported by wider browser group + // See: https://developer.mozilla.org/en-US/docs/Web/CSS/position-anchor#browser_compatibility +` + +export const ElTooltipLabel = styled.span` + font-family: var(--font-family); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-xs); + letter-spacing: var(--letter-spacing-xs); + text-align: left; +` diff --git a/src/components/tooltip/tooltip.mdx b/src/components/tooltip/tooltip.mdx new file mode 100644 index 0000000..23e24d1 --- /dev/null +++ b/src/components/tooltip/tooltip.mdx @@ -0,0 +1,16 @@ +import { Meta, Canvas, Controls, Description } from '@storybook/blocks' +import { RenderHtmlMarkup } from '../../storybook/render-html-markup' +import * as TooltipStories from './tooltip.stories' + + + +# Tooltip + + + + + + + + + diff --git a/src/components/tooltip/tooltip.stories.tsx b/src/components/tooltip/tooltip.stories.tsx new file mode 100644 index 0000000..7523f5b --- /dev/null +++ b/src/components/tooltip/tooltip.stories.tsx @@ -0,0 +1,157 @@ +import { Meta } from '@storybook/react' +import { Button } from '../button' +import { useTooltip } from './use-tooltip' +import { Tooltip, TooltipProps } from './tooltip' +import { ElTooltip, ElTooltipLabel } from './styles' + +const meta: Meta = { + title: 'Components/Tooltip', + component: Tooltip, + argTypes: { + description: { + control: 'text', + description: 'Defines the description of the tooltip.', + }, + label: { + control: 'text', + description: 'Defines the label of the tooltip', + }, + position: { + control: 'select', + options: [ + 'top', + 'bottom', + 'right', + 'left', + 'top-start', + 'top-end', + 'bottom-start', + 'bottom-end', + 'right-start', + 'right-end', + 'left-start', + 'left-end', + ], + description: 'Defines where to position of the tooltip relative to the trigger.', + table: { + type: { summary: 'enum' }, + defaultValue: { summary: 'top' }, + }, + }, + maxWidth: { + control: 'text', + description: 'Defines the max-width of the tooltip container.', + table: { + type: { summary: 'string', detail: 'CSS max-width value (e.g., 400px, 50%)' }, + defaultValue: { summary: '400px' }, + }, + }, + }, +} + +export default meta + +/** + * The Tooltip component is a lightweight UI element used to display contextual + * information when a user hovers over or focuses on a target element. + * In the default configuration, the tooltip provides a brief label and/or description, + * positioned to the top relative to the target, + * with customizable properties such as label, description, position, and maxWidth. + * + * The component utilizes the `useTooltip` hook, which simplifies the integration of tooltip behavior + * by providing properties and methods required to manage the tooltip and its trigger seamlessly. + * + * The `useTooltip` hook provides two keys props: + * + * `getTriggerProps()`: Applied to the trigger element (e.g., a Button or an Icon, etc) + * to bind the necessary event handlers for displaying the tooltip. + * + * `getTooltipProps()`: Applied to the Tooltip component to manage its visibility, positioning, and accessibility. + */ +export const BasicUsage = { + args: { + description: 'Tooltip text', // Default value for description + label: 'Label', // Default value for label + }, + // This is to style the story, as the story parent container has overflow hidden. + decorators: [ + (Story) => ( +
+ +
+ ), + ], + render: (args: any) => { + const tooltip = useTooltip() + + return ( + <> + + + + ) + }, + parameters: { + docs: { + source: { + code: ` +render: () => { + const tooltip = useTooltip() + return ( + + + ) +} + `, + }, + }, + }, +} + +// Mock createPortal for this story +const MockedTooltip = (props: TooltipProps) => { + const content = ( + + {props.label && {props.label}: } + {props.description} + + ) + + return content +} + +/** + * Below is the UI representation for the tooltip component without the trigger. + */ +export const DisplayTooltipWithoutTrigger = { + args: { + description: 'Tooltip text', // Default value for description + label: 'Label', // Default value for label + position: 'top', + maxWidth: '400px', + isVisible: true, + }, + render: (args) => { + console.log('args', args) + // Pass args to MockedTooltip to make it reactive + return + }, + parameters: { + docs: { + canvas: { + sourceState: 'none', + }, + story: { + inline: false, + iframeHeight: 60, + }, + }, + }, +} diff --git a/src/components/tooltip/tooltip.tsx b/src/components/tooltip/tooltip.tsx new file mode 100644 index 0000000..5d97e70 --- /dev/null +++ b/src/components/tooltip/tooltip.tsx @@ -0,0 +1,43 @@ +import React, { FC, HTMLAttributes } from 'react' +import { ElTooltip, ElTooltipLabel } from './styles' +import { createPortal } from 'react-dom' + +export interface TooltipProps extends HTMLAttributes { + label?: string + description: string + isVisible?: boolean + maxWidth?: string + position?: + | 'top' + | 'bottom' + | 'right' + | 'left' + | 'top-start' + | 'top-end' + | 'bottom-start' + | 'bottom-end' + | 'right-start' + | 'right-end' + | 'left-start' + | 'left-end' +} + +export const Tooltip: FC = ({ + isVisible, + label, + description, + maxWidth = '400px', + position = 'top', + ...rest +}) => { + const tooltip = ( + + {label && {label}: } + {description} + + ) + if (!isVisible) { + return null + } + return createPortal(tooltip, document.body) +} diff --git a/src/components/tooltip/use-tooltip.ts b/src/components/tooltip/use-tooltip.ts new file mode 100644 index 0000000..afdc543 --- /dev/null +++ b/src/components/tooltip/use-tooltip.ts @@ -0,0 +1,137 @@ +import { HTMLAttributes, useEffect, useState } from 'react' +import { useId } from '#src/storybook/random-id' + +export const useTooltip = () => { + const tooltipId = `tooltip-id-${useId()}` + const [isVisible, setIsVisible] = useState(false) + + const show = () => setIsVisible(true) + const hide = () => setIsVisible(false) + + const positionTooltip = () => { + // To do (In Future): Tooltip/Trigger selector to use useRef once project upgraded to React 19 + // See: https://react.dev/reference/react/forwardRef + // See: https://react.dev/blog/2024/12/05/react-19#ref-as-a-prop + const tooltip = document.querySelector(`[id="${tooltipId}"]`) + const trigger = document.querySelector(`[data-visible-id="${tooltipId}"]`) + + if (!tooltip || !trigger) return + + const triggerRect = trigger.getBoundingClientRect() + const tooltipRect = tooltip.getBoundingClientRect() + + // Calculate scroll offsets + const scrollTop = document.documentElement.scrollTop || document.body.scrollTop + const scrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft + + // const viewportWidth = window.innerWidth + // const viewportHeight = window.innerHeight + const position = tooltip.getAttribute('data-position') + + let top = 0 + let left = 0 + + switch (position) { + case 'top': + top = triggerRect.top + scrollTop - tooltipRect.height - 4 + left = triggerRect.left + scrollLeft + (triggerRect.width - tooltipRect.width) / 2 + break + case 'top-start': + top = triggerRect.top + scrollTop - tooltipRect.height - 4 + left = triggerRect.left + scrollLeft + break + case 'top-end': + top = triggerRect.top + scrollTop - tooltipRect.height - 4 + left = triggerRect.right + scrollLeft - tooltipRect.width + break + case 'bottom': + top = triggerRect.bottom + scrollTop + 4 + left = triggerRect.left + scrollLeft + (triggerRect.width - tooltipRect.width) / 2 + break + case 'bottom-start': + top = triggerRect.bottom + scrollTop + 4 + left = triggerRect.left + scrollLeft + break + case 'bottom-end': + top = triggerRect.bottom + scrollTop + 4 + left = triggerRect.right + scrollLeft - tooltipRect.width + break + case 'left': + top = triggerRect.top + scrollTop + (triggerRect.height - tooltipRect.height) / 2 + left = triggerRect.left + scrollLeft - tooltipRect.width - 4 + break + case 'left-start': + top = triggerRect.top + scrollTop + left = triggerRect.left + scrollLeft - tooltipRect.width - 4 + break + case 'left-end': + top = triggerRect.bottom + scrollTop - tooltipRect.height + left = triggerRect.left + scrollLeft - tooltipRect.width - 4 + break + case 'right': + top = triggerRect.top + scrollTop + (triggerRect.height - tooltipRect.height) / 2 + left = triggerRect.right + scrollLeft + 4 + break + case 'right-start': + top = triggerRect.top + scrollTop + left = triggerRect.right + scrollLeft + 4 + break + case 'right-end': + top = triggerRect.bottom + scrollTop - tooltipRect.height + left = triggerRect.right + scrollLeft + 4 + break + default: + break + } + + // Below are inline style applied to tooltip to position it relevant to respective trigger + tooltip.style.position = 'absolute' + tooltip.style.inset = '0px auto auto 0px' + tooltip.style.transform = `translate(${left}px, ${top}px)` + tooltip.style.margin = '0px' + } + + // Update tooltip position on visibility change or window resize + useEffect(() => { + if (isVisible) { + positionTooltip() + window.addEventListener('resize', positionTooltip) + } + + return () => { + window.removeEventListener('resize', positionTooltip) + } + }, [isVisible]) + + const getTriggerProps = (props?: HTMLAttributes) => ({ + ...props, + 'data-visible-id': tooltipId, + 'aria-describedby': tooltipId, + onFocus: (e: React.FocusEvent) => { + props?.onFocus?.(e) + show() + }, + onBlur: (e: React.FocusEvent) => { + props?.onBlur?.(e) + hide() + }, + onMouseEnter: (e: React.MouseEvent) => { + props?.onMouseEnter?.(e) + show() + }, + onMouseLeave: (e: React.MouseEvent) => { + props?.onMouseLeave?.(e) + hide() + }, + }) + + const getTooltipProps = (props?: HTMLAttributes) => ({ + ...props, + id: tooltipId, + role: 'tooltip', + 'aria-hidden': !isVisible, + isVisible, + }) + + return { getTriggerProps, getTooltipProps } +} diff --git a/src/index.ts b/src/index.ts index 510cf07..0411344 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,6 +58,7 @@ export * from './components/menu' export * from './components/dialog' export * from './components/avatar' export * from './components/skeleton' +export * from './components/tooltip' export * from './components/table/table-container' export * from './components/table/table-toolbar'