f2764e9258
Headless UI's Modal component autofocuses the first focusable item inside it when opened. This is by design for accessibility reasons. See https://headlessui.com/react/dialog#managing-initial-focus Ironically this means we'll have to remove keyboard focus for tooltips because this causes the tooltips to pop up unnecessarily for all users whenever the dialog is opened. The alternative is managing focus manually for several dialogs, which may not be possible as some of our modals lack a sensible element to focus by default.
103 lines
2.6 KiB
TypeScript
103 lines
2.6 KiB
TypeScript
import {
|
|
arrow,
|
|
autoUpdate,
|
|
flip,
|
|
offset,
|
|
Placement,
|
|
shift,
|
|
useFloating,
|
|
useHover,
|
|
useInteractions,
|
|
useRole,
|
|
} from '@floating-ui/react-dom-interactions'
|
|
import { Transition } from '@headlessui/react'
|
|
import clsx from 'clsx'
|
|
import { ReactNode, useRef, useState } from 'react'
|
|
|
|
// See https://floating-ui.com/docs/react-dom
|
|
|
|
export function Tooltip(props: {
|
|
text: string | false | undefined | null
|
|
children: ReactNode
|
|
className?: string
|
|
placement?: Placement
|
|
noTap?: boolean
|
|
}) {
|
|
const { text, children, className, placement = 'top', noTap } = props
|
|
|
|
const arrowRef = useRef(null)
|
|
|
|
const [open, setOpen] = useState(false)
|
|
|
|
const { x, y, reference, floating, strategy, middlewareData, context } =
|
|
useFloating({
|
|
open,
|
|
onOpenChange: setOpen,
|
|
whileElementsMounted: autoUpdate,
|
|
placement,
|
|
middleware: [
|
|
offset(8),
|
|
flip(),
|
|
shift({ padding: 4 }),
|
|
arrow({ element: arrowRef }),
|
|
],
|
|
})
|
|
|
|
const { x: arrowX, y: arrowY } = middlewareData.arrow ?? {}
|
|
|
|
const { getReferenceProps, getFloatingProps } = useInteractions([
|
|
useHover(context, { mouseOnly: noTap }),
|
|
useRole(context, { role: 'tooltip' }),
|
|
])
|
|
// which side of tooltip arrow is on. like: if tooltip is top-left, arrow is on bottom of tooltip
|
|
const arrowSide = {
|
|
top: 'bottom',
|
|
right: 'left',
|
|
bottom: 'top',
|
|
left: 'right ',
|
|
}[placement.split('-')[0]] as string
|
|
|
|
return text ? (
|
|
<div className="contents">
|
|
<div
|
|
className={clsx('inline-block', className)}
|
|
ref={reference}
|
|
{...getReferenceProps()}
|
|
>
|
|
{children}
|
|
</div>
|
|
{/* conditionally render tooltip and fade in/out */}
|
|
<Transition
|
|
show={open}
|
|
enter="transition ease-out duration-200"
|
|
enterFrom="opacity-0 "
|
|
enterTo="opacity-100"
|
|
leave="transition ease-in duration-150"
|
|
leaveFrom="opacity-100"
|
|
leaveTo="opacity-0"
|
|
// div attributes
|
|
role="tooltip"
|
|
ref={floating}
|
|
style={{ position: strategy, top: y ?? 0, left: x ?? 0 }}
|
|
className="z-10 max-w-xs rounded bg-slate-700 px-2 py-1 text-center text-sm text-white"
|
|
{...getFloatingProps()}
|
|
>
|
|
{text}
|
|
<div
|
|
ref={arrowRef}
|
|
className="absolute h-2 w-2 rotate-45 bg-slate-700"
|
|
style={{
|
|
top: arrowY != null ? arrowY : '',
|
|
left: arrowX != null ? arrowX : '',
|
|
right: '',
|
|
bottom: '',
|
|
[arrowSide]: '-4px',
|
|
}}
|
|
/>
|
|
</Transition>
|
|
</div>
|
|
) : (
|
|
<>{children}</>
|
|
)
|
|
}
|