diff --git a/.changeset/four-clowns-read.md b/.changeset/four-clowns-read.md new file mode 100644 index 00000000000..c3c09bb9593 --- /dev/null +++ b/.changeset/four-clowns-read.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Introduce `` primitive diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index cbafeb4b68a..346b4b026ee 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -5,7 +5,7 @@ { "path": "./dist/clerk.legacy.browser.js", "maxSize": "110KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "52KB" }, { "path": "./dist/ui-common*.js", "maxSize": "104KB" }, - { "path": "./dist/vendors*.js", "maxSize": "39KB" }, + { "path": "./dist/vendors*.js", "maxSize": "39.5KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, { "path": "./dist/createorganization*.js", "maxSize": "5KB" }, { "path": "./dist/impersonationfab*.js", "maxSize": "5KB" }, diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts index 4575bb3a936..f5d56d3ae25 100644 --- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts +++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts @@ -422,6 +422,10 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'qrCodeRow', 'qrCodeContainer', + 'tooltip', + 'tooltipContent', + 'tooltipText', + 'badge', 'notificationBadge', 'buttonArrowIcon', diff --git a/packages/clerk-js/src/ui/elements/Tooltip.tsx b/packages/clerk-js/src/ui/elements/Tooltip.tsx new file mode 100644 index 00000000000..2d4dd5bdc2f --- /dev/null +++ b/packages/clerk-js/src/ui/elements/Tooltip.tsx @@ -0,0 +1,220 @@ +import type { Placement } from '@floating-ui/react'; +import { + autoUpdate, + flip, + FloatingPortal, + offset, + shift, + useDismiss, + useFloating, + useFocus, + useHover, + useInteractions, + useMergeRefs, + useRole, + useTransitionStyles, +} from '@floating-ui/react'; +import * as React from 'react'; + +import { Box, descriptors, type LocalizationKey, Span, Text, useAppearance } from '../customizables'; +import { usePrefersReducedMotion } from '../hooks'; + +interface TooltipOptions { + initialOpen?: boolean; + placement?: Placement; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function useTooltip({ + initialOpen = false, + placement = 'top', + open: controlledOpen, + onOpenChange: setControlledOpen, +}: TooltipOptions = {}) { + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen); + + const open = controlledOpen ?? uncontrolledOpen; + const setOpen = setControlledOpen ?? setUncontrolledOpen; + + const prefersReducedMotion = usePrefersReducedMotion(); + const { animations: layoutAnimations } = useAppearance().parsedLayout; + const isMotionSafe = !prefersReducedMotion && layoutAnimations === true; + + const data = useFloating({ + placement, + open, + onOpenChange: setOpen, + whileElementsMounted: autoUpdate, + middleware: [ + offset(6), + flip({ + crossAxis: placement.includes('-'), + fallbackAxisSideDirection: 'start', + padding: 6, + }), + shift({ padding: 6 }), + ], + }); + + 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 { isMounted, styles: transitionStyles } = useTransitionStyles(context, { + duration: isMotionSafe ? 200 : 0, + initial: ({ side }) => { + return { + opacity: 0, + transform: side === 'top' ? 'translateY(4px)' : 'translateY(-4px)', + }; + }, + open: { + opacity: 1, + transform: 'translate(0)', + }, + close: ({ side }) => ({ + opacity: 0, + transform: side === 'top' ? 'translateY(4px)' : 'translateY(-4px)', + }), + }); + + const interactions = useInteractions([hover, focus, dismiss, role]); + + return React.useMemo( + () => ({ + open, + setOpen, + isMounted, + ...interactions, + ...data, + transitionStyles, + }), + [open, setOpen, interactions, data, isMounted, transitionStyles], + ); +} + +type ContextType = ReturnType | null; + +const TooltipContext = React.createContext(null); + +export const useTooltipContext = () => { + const context = React.useContext(TooltipContext); + + if (context == null) { + throw new Error('Tooltip components must be wrapped in '); + } + + return context; +}; + +function Root({ children, ...options }: { children: React.ReactNode } & TooltipOptions) { + // This can accept any props as options, e.g. `placement`, + // or other positioning options. + const tooltip = useTooltip(options); + return {children}; +} + +const Trigger = React.forwardRef>(function TooltipTrigger( + { children, ...props }, + propRef, +) { + const context = useTooltipContext(); + const childrenRef = (children as any).ref; + const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]); + + if (!React.isValidElement(children)) { + return null; + } + + // If the child is disabled, wrap it in a span to handle hover events + if (children.props.isDisabled || children.props.disabled) { + return ( + + {children} + + ); + } + + return React.cloneElement( + children, + context.getReferenceProps({ + ref, + ...props, + ...children.props, + 'data-state': context.open ? 'open' : 'closed', + }), + ); +}); + +const Content = React.forwardRef< + HTMLDivElement, + React.HTMLProps & { + text: string | LocalizationKey; + } +>(function TooltipContent({ style, text, ...props }, propRef) { + const context = useTooltipContext(); + const ref = useMergeRefs([context.refs.setFloating, propRef]); + + if (!context.isMounted) return null; + + return ( + + + ({ + paddingBlock: t.space.$1, + paddingInline: t.space.$1x5, + borderRadius: t.radii.$md, + backgroundColor: t.colors.$primary500, + maxWidth: t.sizes.$60, + })} + > + + + + + ); +}); + +export const Tooltip = { + Root, + Trigger, + Content, +}; diff --git a/packages/clerk-js/src/ui/elements/index.ts b/packages/clerk-js/src/ui/elements/index.ts index aa2272771e5..d57f5bf1549 100644 --- a/packages/clerk-js/src/ui/elements/index.ts +++ b/packages/clerk-js/src/ui/elements/index.ts @@ -56,6 +56,7 @@ export * from './TagInput'; export * from './ThreeDotsMenu'; export * from './TileButton'; export * from './TimerButton'; +export * from './Tooltip'; export * from './UserAvatar'; export * from './UserPreview'; export * from './VerificationCodeCard'; diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index 292fa51a751..e1d81e7dc2f 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -541,6 +541,10 @@ export type ElementsConfig = { impersonationFabTitle: WithOptions; impersonationFabActionLink: WithOptions; + tooltip: WithOptions; + tooltipContent: WithOptions; + tooltipText: WithOptions; + invitationsSentIconBox: WithOptions; invitationsSentIcon: WithOptions;