From 343c5e2ed220a566f5d57bdddf177e31179f4828 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Thu, 10 Nov 2022 16:10:57 +0800 Subject: [PATCH] feat: tooltips for everyone --- lib/core.tsx | 37 ++++++++---- lib/tooltip.css.ts | 35 +++++++++++ lib/tooltip.tsx | 144 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 + yarn.lock | 36 +++++++++++- 5 files changed, 242 insertions(+), 12 deletions(-) create mode 100644 lib/tooltip.css.ts create mode 100644 lib/tooltip.tsx diff --git a/lib/core.tsx b/lib/core.tsx index 2915696..21a0c66 100644 --- a/lib/core.tsx +++ b/lib/core.tsx @@ -1,8 +1,9 @@ import { ClassValue, clsx } from 'clsx'; -import { createElement, PropsWithChildren } from 'react'; +import { createElement, forwardRef, PropsWithChildren, ReactNode } from 'react'; import type { Merge } from 'type-fest'; import { marginVariants, paddingVariants } from './core.css.js'; import { Align, alignItems } from './layout.css.js'; +import { Tooltip } from './tooltip.js'; import type { ReactHTMLAttributesHacked } from './types.js'; export type Space = 'none' | 'large' | 'small' | 'tiny' | 'huge' | 'standard'; @@ -19,22 +20,27 @@ export type BoxBasedComponentProps< margin?: Space | undefined; padding?: Space | undefined; className?: ClassValue; + tooltip?: ReactNode; // tone // rounded } > >; -export function Box({ - children, - component = 'div', - className, - align, - margin, - padding, - ...props -}: BoxBasedComponentProps) { - return createElement( +function BoxInner( + { + children, + component = 'div', + className, + align, + margin, + padding, + tooltip, + ...props + }: BoxBasedComponentProps, + ref: React.ForwardedRef, +) { + const el = createElement( component, { ...props, @@ -44,7 +50,16 @@ export function Box({ padding && paddingVariants[padding], className, ), + ref, }, children, ); + + if (tooltip) { + return {el}; + } + + return el; } + +export const Box = forwardRef(BoxInner); diff --git a/lib/tooltip.css.ts b/lib/tooltip.css.ts new file mode 100644 index 0000000..2f2de10 --- /dev/null +++ b/lib/tooltip.css.ts @@ -0,0 +1,35 @@ +import { createVar, style } from '@vanilla-extract/css'; +import { calc } from '@vanilla-extract/css-utils'; +import { genericVars } from './design-system.css.js'; +import { contrastSchemeVars } from './schemes/color.css.js'; +import { hsl } from './utils.js'; + +export const tooltipClass = style({ + position: 'absolute', + top: '0', + left: '0', + background: hsl(0, 0, contrastSchemeVars.fg.l), + color: hsl(0, 0, contrastSchemeVars.bg.l), + fontWeight: genericVars.text.weight.bold, + fontFamily: 'sans-serif', + padding: `${genericVars.space.tiny} ${genericVars.space.small}`, + borderRadius: genericVars.radius.standard, + fontSize: genericVars.text.size.small, + pointerEvents: 'none', + width: 'max-content', +}); + +export const arrowoffsetVar = createVar(); + +export const tooltipArrowStyle = style({ + vars: { + [arrowoffsetVar]: calc(genericVars.space.tiny).negate().toString(), + }, + position: 'absolute', + background: 'inherit', + height: genericVars.space.small, + borderRadius: genericVars.radius.large, + borderBottomRightRadius: '0', + aspectRatio: '1/1', + transform: 'rotate(45deg)', +}); diff --git a/lib/tooltip.tsx b/lib/tooltip.tsx new file mode 100644 index 0000000..8b5afa5 --- /dev/null +++ b/lib/tooltip.tsx @@ -0,0 +1,144 @@ +import type { Placement } from '@floating-ui/react-dom-interactions'; +import { + arrow, + autoUpdate, + flip, + offset, + shift, + Side, + useDismiss, + useFloating, + useFocus, + useHover, + useInteractions, + useRole, +} from '@floating-ui/react-dom-interactions'; +import { + cloneElement, + FC, + isValidElement, + PropsWithChildren, + ReactNode, + useMemo, + useRef, + useState, +} from 'react'; +import { + arrowoffsetVar, + tooltipArrowStyle, + tooltipClass, +} from './tooltip.css.js'; + +export type TooltipState = ReturnType; + +export function useTooltipState({ + initialOpen = true, + placement = 'top', +}: { + initialOpen?: boolean; + placement?: Placement; +} = {}) { + const [open, setOpen] = useState(initialOpen); + const arrowRef = useRef(null); + + const data = useFloating({ + placement, + open, + onOpenChange: setOpen, + whileElementsMounted: autoUpdate, + middleware: [ + offset(5), + flip(), + shift(), + // arrow should always be at the end, after shift() + // per https://floating-ui.com/docs/arrow#order + arrow({ element: arrowRef }), + ], + }); + + const { context } = data; + + const hover = useHover(context, { move: false }); + const focus = useFocus(context); + const dismiss = useDismiss(context); + const role = useRole(context, { role: 'tooltip' }); + + const interactions = useInteractions([hover, focus, dismiss, role]); + + return useMemo( + () => ({ + open, + setOpen, + arrowRef, + ...interactions, + ...data, + }), + [open, setOpen, interactions, data], + ); +} + +export const Tooltip: FC> = ({ + content, + children, +}) => { + const { + placement, + reference, + floating, + strategy, + x, + y, + open, + arrowRef, + getReferenceProps, + getFloatingProps, + middlewareData: { arrow: { x: arrowX, y: arrowY } = {} }, + } = useTooltipState(); + + if (!isValidElement(children)) { + return <>{children}; + } + + const staticSide: Record = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right', + }; + + const arrowPlacement = placement.split('-')[0] as Side; + + return ( + <> + {cloneElement( + children, + getReferenceProps({ ref: reference, ...children.props }), + )} + {open && ( +
+
+ {content} +
+ )} + + ); +}; diff --git a/package.json b/package.json index ac1ce8d..449674a 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "prepare": "npx patch-package" }, "dependencies": { + "@floating-ui/react-dom": "^1.0.0", + "@floating-ui/react-dom-interactions": "^0.10.3", "clsx": "^1.2.1" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 0f5c138..9d69e5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -315,6 +315,33 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@floating-ui/core@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.0.1.tgz#00e64d74e911602c8533957af0cce5af6b2e93c8" + integrity sha512-bO37brCPfteXQfFY0DyNDGB3+IMe4j150KFQcgJ5aBP295p9nBGeHEs/p0czrRbtlHq4Px/yoPXO/+dOCcF4uA== + +"@floating-ui/dom@^1.0.0": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.0.4.tgz#cc0f2a03db7193b1b932b90d09c5c81235682a60" + integrity sha512-maYJRv+sAXTy4K9mzdv0JPyNW5YPVHrqtY90tEdI6XNpuLOP26Ci2pfwPsKBA/Wh4Z3FX5sUrtUFTdMYj9v+ug== + dependencies: + "@floating-ui/core" "^1.0.1" + +"@floating-ui/react-dom-interactions@^0.10.3": + version "0.10.3" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom-interactions/-/react-dom-interactions-0.10.3.tgz#1d988aad169bf752b54c688db942f12e4fed61c5" + integrity sha512-UEHqdnzyoiWNU5az/tAljr9iXFzN18DcvpMqW+/cXz4FEhDEB1ogLtWldOWCujLerPBnSRocADALafelOReMpw== + dependencies: + "@floating-ui/react-dom" "^1.0.0" + aria-hidden "^1.1.3" + +"@floating-ui/react-dom@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-1.0.0.tgz#e0975966694433f1f0abffeee5d8e6bb69b7d16e" + integrity sha512-uiOalFKPG937UCLm42RxjESTWUVpbbatvlphQAU6bsv+ence6IoVG8JOUZcy8eW81NkU+Idiwvx10WFLmR4MIg== + dependencies: + "@floating-ui/dom" "^1.0.0" + "@formatjs/ecma402-abstract@1.13.0": version "1.13.0" resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.13.0.tgz#df6db3cbee0182bbd2fd6217103781c802aee819" @@ -920,6 +947,13 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +aria-hidden@^1.1.3: + version "1.2.1" + resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.1.tgz#ad8c1edbde360b454eb2bf717ea02da00bfee0f8" + integrity sha512-PN344VAf9j1EAi+jyVHOJ8XidQdPVssGco39eNcsGdM4wcsILtxrKLkbuiMfLWYROK1FjRQasMWCBttrhjnr6A== + dependencies: + tslib "^2.0.0" + aria-query@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b" @@ -3413,7 +3447,7 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.4.0: +tslib@^2.0.0, tslib@^2.4.0: version "2.4.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==