Files
TREK/client/src/components/shared/Tooltip.tsx
T
Maurice b64da26651 feat(assistant): trip assistant chat with your own AI connection
Adds an in-app assistant that reads and plans the open trip. Users connect
their own provider (Sign in with ChatGPT, or an Anthropic, OpenAI, Gemini or
OpenRouter key) or use the one the admin set up, including a custom
OpenAI-compatible server. It is an addon and only shows while a connection
is usable.

The chat runs on the existing MCP tools, pinned to the trip. Every change is
held as a change card the user applies or discards, and applied changes can
be undone. On the desktop it is a floating window that can be moved and
resized; on the phone it is fullscreen. Search results show as place cards
that can go onto a day, weather lookups as a strip, proposed places as
ghost markers on the map with a before/after switch, and an answer can be
shared into the trip chat. Booking files can be dropped in and are read
into change cards.
2026-10-22 21:30:12 +00:00

120 lines
4.9 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[]
children: React.ReactElement
}
export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, shortcut, 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: '5px 10px',
borderRadius: 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',
}}
>
{shortcut && shortcut.length > 0 ? (
<span className="inline-flex items-center gap-2">
{label}
<Kbd keys={shortcut} />
</span>
) : label}
</div>,
document.body,
)}
</>
)
}
export default Tooltip