mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
c7d5fc12c6
The row's badges said who brings an item, who it is shared with and who takes care of it in the browser's own tooltip. They now use TREK's, and the bringers one lists everyone with their picture on a line of their own. The others show as a +N circle tucked under the first picture. Tooltip takes an optional content for tips that hold more than words.
125 lines
5.2 KiB
TypeScript
125 lines
5.2 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import Kbd from './Kbd'
|
|
|
|
type Placement = 'top' | 'bottom' | 'left' | 'right'
|
|
|
|
interface TooltipProps {
|
|
label: string
|
|
placement?: Placement
|
|
delay?: number
|
|
disabled?: boolean
|
|
/** The keys that do the same, shown beside the label: ["⌘", "K"]. */
|
|
shortcut?: readonly string[]
|
|
/**
|
|
* Shown in place of the label when the tip holds more than words, such as a list of
|
|
* people with their pictures. `label` still names it and decides whether it opens.
|
|
*/
|
|
content?: React.ReactNode
|
|
children: React.ReactElement
|
|
}
|
|
|
|
export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, shortcut, content, children }: TooltipProps) {
|
|
const [open, setOpen] = useState(false)
|
|
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
|
|
const triggerRef = useRef<HTMLElement | null>(null)
|
|
const tooltipRef = useRef<HTMLDivElement | null>(null)
|
|
const timerRef = useRef<number | null>(null)
|
|
|
|
const show = () => {
|
|
if (disabled || !label) return
|
|
if (timerRef.current) window.clearTimeout(timerRef.current)
|
|
timerRef.current = window.setTimeout(() => setOpen(true), delay)
|
|
}
|
|
const hide = () => {
|
|
if (timerRef.current) window.clearTimeout(timerRef.current)
|
|
setOpen(false)
|
|
}
|
|
|
|
useEffect(() => () => { if (timerRef.current) window.clearTimeout(timerRef.current) }, [])
|
|
|
|
useEffect(() => {
|
|
if (!open || !triggerRef.current) return
|
|
const r = triggerRef.current.getBoundingClientRect()
|
|
const tipW = tooltipRef.current?.offsetWidth ?? 0
|
|
const tipH = tooltipRef.current?.offsetHeight ?? 0
|
|
const gap = 6
|
|
let top = 0, left = 0
|
|
if (placement === 'top') { top = r.top - tipH - gap; left = r.left + r.width / 2 - tipW / 2 }
|
|
else if (placement === 'bottom') { top = r.bottom + gap; left = r.left + r.width / 2 - tipW / 2 }
|
|
else if (placement === 'left') { top = r.top + r.height / 2 - tipH / 2; left = r.left - tipW - gap }
|
|
else { top = r.top + r.height / 2 - tipH / 2; left = r.right + gap }
|
|
const pad = 6
|
|
left = Math.max(pad, Math.min(left, window.innerWidth - tipW - pad))
|
|
top = Math.max(pad, Math.min(top, window.innerHeight - tipH - pad))
|
|
setCoords({ top, left })
|
|
}, [open, placement, label])
|
|
|
|
// The wrapped child can be any focusable/hoverable element. React's element
|
|
// props are typed as `unknown`, so we narrow to the handlers we chain onto.
|
|
type TriggerProps = {
|
|
ref?: React.Ref<HTMLElement>
|
|
onMouseEnter?: (e: React.MouseEvent) => void
|
|
onMouseLeave?: (e: React.MouseEvent) => void
|
|
onFocus?: (e: React.FocusEvent) => void
|
|
onBlur?: (e: React.FocusEvent) => void
|
|
}
|
|
const child = React.Children.only(children) as React.ReactElement<TriggerProps>
|
|
const childProps = child.props as TriggerProps
|
|
const trigger = React.cloneElement(child, {
|
|
ref: (node: HTMLElement | null) => {
|
|
triggerRef.current = node
|
|
const r = childProps.ref
|
|
if (typeof r === 'function') r(node)
|
|
else if (r && typeof r === 'object') (r as React.RefObject<HTMLElement | null>).current = node
|
|
},
|
|
onMouseEnter: (e: React.MouseEvent) => { show(); childProps.onMouseEnter?.(e) },
|
|
onMouseLeave: (e: React.MouseEvent) => { hide(); childProps.onMouseLeave?.(e) },
|
|
// Keyboard focus only. A dialog hands focus back to the button that opened it
|
|
// when it closes, and a tooltip over whatever comes next is noise, not help.
|
|
onFocus: (e: React.FocusEvent) => { if ((e.currentTarget as HTMLElement).matches(':focus-visible')) show(); childProps.onFocus?.(e) },
|
|
onBlur: (e: React.FocusEvent) => { hide(); childProps.onBlur?.(e) },
|
|
} as TriggerProps)
|
|
|
|
return (
|
|
<>
|
|
{trigger}
|
|
{open && !disabled && createPortal(
|
|
<div
|
|
ref={tooltipRef}
|
|
role="tooltip"
|
|
className="trek-popover-enter bg-surface-card text-content border border-edge-faint"
|
|
style={{
|
|
position: 'fixed',
|
|
top: coords?.top ?? -9999,
|
|
left: coords?.left ?? -9999,
|
|
visibility: coords ? 'visible' : 'hidden',
|
|
pointerEvents: 'none',
|
|
zIndex: 100000,
|
|
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
|
fontWeight: 500,
|
|
padding: content ? '8px 10px' : '5px 10px',
|
|
borderRadius: content ? 10 : 8,
|
|
// A label of several lines (the paddle check bench) breaks at its lines and wraps within the window.
|
|
whiteSpace: label.includes('\n') ? 'pre-line' : 'nowrap',
|
|
maxWidth: label.includes('\n') ? 'min(22rem, calc(100vw - 12px))' : undefined,
|
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
|
fontFamily: "var(--font-system)",
|
|
transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left',
|
|
}}
|
|
>
|
|
{content ?? (shortcut && shortcut.length > 0 ? (
|
|
<span className="inline-flex items-center gap-2">
|
|
{label}
|
|
<Kbd keys={shortcut} />
|
|
</span>
|
|
) : label)}
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default Tooltip
|