| 1 | import { type ComponentProps, type MouseEvent, type PointerEvent, type ReactElement, type ReactNode, useRef, useState } from "react"; |
| 2 | |
| 3 | import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"; |
| 4 | |
| 5 | /** |
| 6 | * A hover hint: the site's tooltip around exactly one element, in place of |
| 7 | * the browser's `title`, which g1t does not use (app/lib/no-native-title.test.ts |
| 8 | * keeps it out). It opens on hover and keyboard focus, and on a tap of |
| 9 | * something that does nothing else when tapped, since a touch screen has no |
| 10 | * hover. Without a `label` the child renders as it is. |
| 11 | * |
| 12 | * The hint is not the element's accessible name: an icon-only button keeps |
| 13 | * its `aria-label`, and text cut short keeps its whole text for a screen |
| 14 | * reader. Anything else passed (as from a parent's `asChild`) goes to the child. |
| 15 | */ |
| 16 | export function Hint({ |
| 17 | label, |
| 18 | children, |
| 19 | side, |
| 20 | align, |
| 21 | disabled, |
| 22 | onClick, |
| 23 | onPointerDown, |
| 24 | ...props |
| 25 | }: Omit<ComponentProps<typeof TooltipTrigger>, "children"> & { |
| 26 | label: ReactNode; |
| 27 | /** One element that takes a ref and passes its props on (asChild). */ |
| 28 | children: ReactElement; |
| 29 | side?: ComponentProps<typeof TooltipContent>["side"]; |
| 30 | align?: ComponentProps<typeof TooltipContent>["align"]; |
| 31 | /** |
| 32 | * The child is disabled, so it gets no pointer events and no focus: the |
| 33 | * hint hangs on a focusable span around it instead, as when it says why. |
| 34 | */ |
| 35 | disabled?: boolean; |
| 36 | }) { |
| 37 | const [open, setOpen] = useState(false); |
| 38 | const touch = useRef(false); |
| 39 | if (label == null || label === false || label === "") return children; |
| 40 | const trigger = disabled ? ( |
| 41 | <span tabIndex={0} className="inline-flex rounded-md outline-none focus-visible:ring-2 focus-visible:ring-accent *:pointer-events-none"> |
| 42 | {children} |
| 43 | </span> |
| 44 | ) : ( |
| 45 | children |
| 46 | ); |
| 47 | return ( |
| 48 | <Tooltip open={open} onOpenChange={setOpen}> |
| 49 | <TooltipTrigger |
| 50 | asChild |
| 51 | {...props} |
| 52 | onPointerDown={(event: PointerEvent<HTMLButtonElement>) => { |
| 53 | touch.current = event.pointerType === "touch"; |
| 54 | onPointerDown?.(event); |
| 55 | }} |
| 56 | onClick={(event: MouseEvent<HTMLButtonElement>) => { |
| 57 | onClick?.(event); |
| 58 | // A tap on text or a badge shows the hint; a tap on a link or a |
| 59 | // button does what it does. preventDefault keeps the trigger's own |
| 60 | // click handler from closing the hint again. |
| 61 | if (touch.current && !event.defaultPrevented && !(event.target as Element).closest("a, button, input, select, textarea, label, summary")) { |
| 62 | event.preventDefault(); |
| 63 | setOpen(true); |
| 64 | } |
| 65 | }} |
| 66 | > |
| 67 | {trigger} |
| 68 | </TooltipTrigger> |
| 69 | <TooltipContent side={side} align={align}> |
| 70 | {label} |
| 71 | </TooltipContent> |
| 72 | </Tooltip> |
| 73 | ); |
| 74 | } |