Skip to content

Commit

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.

syntaqxcommitted Parent24069c6Browse files
1 file+74−00/1 viewed
+74−0
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+}