Hint: the site's tooltip around one element, in place of a native title
components/ui/hint.tsx wraps the shadcn Tooltip for a hover hint: it opens on hover and keyboard focus, on a tap of text or a badge on a touch screen, and with disabled hangs on a focusable span so a disabled control can say why. No label leaves the child as it is.
1 file+74−00/1 viewed
| 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 | + | } |