diff --git a/lib/components/Tooltip/Tooltip.stories.tsx b/lib/components/Tooltip/Tooltip.stories.tsx new file mode 100644 index 0000000..ea61e7f --- /dev/null +++ b/lib/components/Tooltip/Tooltip.stories.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { Tooltip, TooltipProps } from './Tooltip'; +import { TooltipTrigger } from './TooltipTrigger'; +import { TooltipContent } from './TooltipContent'; + +const meta = { + component: Tooltip, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const parameters = { + design: { + type: 'figma', + url: 'https://www.figma.com/file/6M2LrpSCcB0thlFDaQAI2J/cx_jod_client?type=design&node-id=542-8065', + }, +}; + +const DefaultStoryRender = (props: TooltipProps) => { + const [open, setOpen] = React.useState(true); + return ( + + setOpen((v) => !v)}>Trigger + Lorem ipsum dolor sit amet, no vis verear commodo. + + ); +}; + +export const Default: Story = { + parameters: { + ...parameters, + }, + args: {}, + render: (args) => { + return ; + }, +}; diff --git a/lib/components/Tooltip/Tooltip.tsx b/lib/components/Tooltip/Tooltip.tsx new file mode 100644 index 0000000..d82df66 --- /dev/null +++ b/lib/components/Tooltip/Tooltip.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import type { Placement } from '@floating-ui/react'; +import { useTooltip } from './utils'; + +export interface TooltipOptions { + initialOpen?: boolean; + placement?: Placement; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export interface TooltipProps extends TooltipOptions { + children?: React.ReactNode; +} + +type ContextType = ReturnType | null; + +export const TooltipContext = React.createContext(null); + +export function Tooltip({ children, ...options }: TooltipProps) { + const tooltip = useTooltip(options); + return {children}; +} diff --git a/lib/components/Tooltip/TooltipContent.tsx b/lib/components/Tooltip/TooltipContent.tsx new file mode 100644 index 0000000..e2dcd12 --- /dev/null +++ b/lib/components/Tooltip/TooltipContent.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { FloatingArrow, FloatingPortal, useMergeRefs } from '@floating-ui/react'; +import { useTooltipContext, ARROW_HEIGHT } from './utils'; + +export const TooltipContent = React.forwardRef>( + function TooltipContent(props, propRef) { + const tooltipContext = useTooltipContext(); + const ref = useMergeRefs([tooltipContext.refs.setFloating, propRef]); + + if (!tooltipContext.open) { + return null; + } + + return ( + +
+ {props.children} + +
+
+ ); + }, +); diff --git a/lib/components/Tooltip/TooltipTrigger.tsx b/lib/components/Tooltip/TooltipTrigger.tsx new file mode 100644 index 0000000..e75e33b --- /dev/null +++ b/lib/components/Tooltip/TooltipTrigger.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { useMergeRefs } from '@floating-ui/react'; +import { useTooltipContext } from './utils'; + +export const TooltipTrigger = React.forwardRef< + HTMLElement, + React.HTMLProps & { + asChild?: boolean; + children: React.ReactNode | { ref: React.ForwardedRef }; + } +>(function TooltipTrigger({ children, asChild = false, ...props }, propRef) { + const context = useTooltipContext(); + const childrenRef = ( + children as { + ref: React.ForwardedRef; + } + ).ref; + const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]); + + if (asChild && React.isValidElement(children)) { + return React.cloneElement( + children, + context.getReferenceProps({ + ref, + ...props, + ...(children as React.ReactElement).props, + }), + ); + } + + return ( + + ); +}); diff --git a/lib/components/Tooltip/utils.ts b/lib/components/Tooltip/utils.ts new file mode 100644 index 0000000..ccbc724 --- /dev/null +++ b/lib/components/Tooltip/utils.ts @@ -0,0 +1,87 @@ +import React from 'react'; +import { + arrow, + autoUpdate, + flip, + offset, + shift, + useFloating, + useFocus, + useDismiss, + useHover, + useInteractions, + useRole, +} from '@floating-ui/react'; +import { TooltipContext, TooltipOptions } from './Tooltip'; + +export const ARROW_HEIGHT = 12; +export const GAP = 0; + +export function useTooltip({ + initialOpen = false, + placement = 'top', + open: controlledOpen, + onOpenChange: setControlledOpen, +}: TooltipOptions = {}) { + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen); + const arrowRef = React.useRef(null); + + const open = controlledOpen ?? uncontrolledOpen; + const setOpen = setControlledOpen ?? setUncontrolledOpen; + + const data = useFloating({ + placement, + open, + onOpenChange: setOpen, + whileElementsMounted: autoUpdate, + middleware: [ + offset(ARROW_HEIGHT + GAP), + flip({ + crossAxis: placement.includes('-'), + fallbackAxisSideDirection: 'start', + padding: 5, + }), + shift({ padding: 5 }), + arrow({ + element: arrowRef, + }), + ], + }); + + const context = data.context; + + const hover = useHover(context, { + move: false, + enabled: controlledOpen == null, + }); + + const focus = useFocus(context, { + enabled: controlledOpen == null, + }); + + const dismiss = useDismiss(context); + const role = useRole(context, { role: 'tooltip' }); + + const interactions = useInteractions([hover, focus, dismiss, role]); + + return React.useMemo( + () => ({ + open, + setOpen, + arrowRef, + ...interactions, + ...data, + }), + [open, setOpen, interactions, data], + ); +} + +export const useTooltipContext = () => { + const context = React.useContext(TooltipContext); + + if (context == null) { + throw new Error('Tooltip components must be wrapped in '); + } + + return context; +}; diff --git a/package-lock.json b/package-lock.json index 0488d82..0c6c666 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "license": "EUPL-1.2", "devDependencies": { + "@floating-ui/react": "^0.26.12", "@headlessui/react": "^1.7.18", "@storybook/addon-a11y": "^8.0.4", "@storybook/addon-designs": "^8.0.0", @@ -2740,6 +2741,59 @@ "react": "^16.14.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", + "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", + "dev": true, + "dependencies": { + "@floating-ui/utils": "^0.2.1" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", + "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", + "dev": true, + "dependencies": { + "@floating-ui/core": "^1.0.0", + "@floating-ui/utils": "^0.2.0" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.12", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.12.tgz", + "integrity": "sha512-D09o62HrWdIkstF2kGekIKAC0/N/Dl6wo3CQsnLcOmO3LkW6Ik8uIb3kw8JYkwxNCcg+uJ2bpWUiIijTBep05w==", + "dev": true, + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@floating-ui/utils": "^0.2.0", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.8.tgz", + "integrity": "sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==", + "dev": true, + "dependencies": { + "@floating-ui/dom": "^1.6.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", + "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==", + "dev": true + }, "node_modules/@headlessui/react": { "version": "1.7.18", "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.18.tgz", @@ -14297,6 +14351,12 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "dev": true + }, "node_modules/tailwindcss": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", diff --git a/package.json b/package.json index 928734f..b782f6d 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "react": "^18" }, "devDependencies": { + "@floating-ui/react": "^0.26.12", "@headlessui/react": "^1.7.18", "@storybook/addon-a11y": "^8.0.4", "@storybook/addon-designs": "^8.0.0",