@delacour/react-native-ui 0.1.0-alpha.20261007115740 → 0.1.0-alpha.20261007120055

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.1.0-alpha.20261007115740",
3
+ "version": "0.1.0-alpha.20261007120055",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -76,6 +76,7 @@
76
76
  "./textarea": "./src/components/textarea/index.ts",
77
77
  "./toast": "./src/components/toast/index.ts",
78
78
  "./toggle-button": "./src/components/toggle-button/index.ts",
79
+ "./tooltip": "./src/components/tooltip/index.ts",
79
80
  "./expo/navigation-theme": "./src/expo/navigation-theme.tsx",
80
81
  "./hooks/use-calm-motion": "./src/hooks/use-calm-motion.tsx",
81
82
  "./hooks/use-controllable-state": "./src/hooks/use-controllable-state.ts",
@@ -106,8 +107,8 @@
106
107
  },
107
108
  "peerDependencies": {
108
109
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
109
- "@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261007115740",
110
- "@delacour/react-native-charts": "0.1.0-alpha.20261007115740",
110
+ "@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261007120055",
111
+ "@delacour/react-native-charts": "0.1.0-alpha.20261007120055",
111
112
  "@legendapp/list": ">=3.3",
112
113
  "expo-linear-gradient": ">=15",
113
114
  "expo-router": ">=57",
@@ -0,0 +1,133 @@
1
+ # Tooltip
2
+
3
+ A short label naming the control under the finger — what an icon-only button does, a shortcut, a
4
+ one-line hint. It is not interactive: anything with a button in it is a [Popover](../popover/AGENTS.md).
5
+
6
+ `import { Tooltip } from "@delacour/react-native-ui/tooltip";`
7
+
8
+ It draws through the overlay foundation, so it needs `OverlayProvider` at the app root and
9
+ `react-native-teleport` installed — see [Overlay](../overlay/AGENTS.md). The provider is also what
10
+ hears an outside tap; without it the tooltip renders inline, may be clipped, and closes only on its
11
+ timer or the trigger.
12
+
13
+ ## Anatomy
14
+
15
+ ```tsx
16
+ <Tooltip isOpen? defaultOpen? onOpenChange? openOn? duration? label?>
17
+ <Tooltip.Trigger asChild><Button size="icon-md" variant="ghost" /></Tooltip.Trigger>
18
+ <Tooltip.Content placement? align? offset? alignOffset? variant? width? minWidth? maxHeight? isScrollable?>
19
+ <Tooltip.Arrow />
20
+ <Tooltip.Text /> // inverted: the one line
21
+ <Tooltip.Title /> <Tooltip.Description /> // surface: a heading and a line
22
+ </Tooltip.Content>
23
+ </Tooltip>
24
+ ```
25
+
26
+ `useTooltip()` returns `{ isOpen, setOpen, close }`.
27
+
28
+ ## Files
29
+
30
+ | File | What it holds |
31
+ | --- | --- |
32
+ | `index.ts` | → `@delacour/react-native-ui/tooltip` |
33
+ | `tooltip.tsx` | `Tooltip` — open state, the screen-reader check, activation, the outside-tap subscription, the one-open-at-a-time rule; the `Object.assign` names every part |
34
+ | `tooltip.context.tsx` | **Leaf.** `TooltipContext`, `useTooltip`, and the content context the arrow and text read |
35
+ | `tooltip.variants.ts` | The slotted `tv()` — `content`, `arrow`, `text`, `title`, `description` per variant — the defaults, and the pure `resolveTooltipDuration`, `shouldTooltipActivate`, `resolveTooltipAccessibility`, `readableTextOf` |
36
+ | `tooltip.variants.test.ts` | Both variants and their tokens in both themes, the three resolvers, the defaults |
37
+ | `tooltip-trigger.tsx` | `Tooltip.Trigger` — `Pressable`, or `asChild` to donate the long press or the press; the anchor; the label or hint |
38
+ | `tooltip-content.tsx` | `Tooltip.Content` — the portal, the panel, the timer |
39
+ | `tooltip-arrow.tsx` | `Tooltip.Arrow` — Popover's `AnchoredArrow` in the variant's paint |
40
+ | `tooltip-text.tsx` | `Tooltip.Text` — `Text.Caption`, inked for the variant |
41
+ | `tooltip-title.tsx` | `Tooltip.Title` — `Text.Label`, for the surface variant |
42
+ | `tooltip-description.tsx` | `Tooltip.Description` — `Text.Caption`, for the surface variant |
43
+
44
+ ## Builds on Popover's leaves
45
+
46
+ `popover/popover.position.ts`, `popover/use-anchor-measure.ts`, `popover/use-anchored-content.ts`,
47
+ `popover/popover-arrow.tsx` and `popover/popover.variants.ts` are imported **directly**, never
48
+ `../popover` — package rule 3's leaf exception, written down in Popover's own doc. So the tooltip
49
+ measures, flips, shifts, clamps and aims its arrow exactly as a popover does, and none of it is
50
+ re-tested here. It keeps Popover's collision padding and arrow inset; the inset is sized for the
51
+ card corner, which is larger than the inverted chip's `rounded-md`, so it clears both.
52
+
53
+ ## Design
54
+
55
+ - **A long press opens it, so the control keeps its tap.** Mobile has no hover. A tooltip that
56
+ opened on a tap would take the tap from the button it names, so the default `openOn` is
57
+ `"longPress"`, with a `selection` haptic — fired from the UI thread through `playHaptic`, because
58
+ `Pressable`'s `haptic` plays on press-in, which is every tap. The trigger's own `onLongPress`
59
+ still runs, after the tooltip's. A tap shorter than the long-press delay still presses the
60
+ button; one held past it fails as a tap, so a long press never also presses it. `openOn="press"`
61
+ is for a trigger with no tap of its own — an info glyph.
62
+ - **`asChild` donates the gesture.** For `Popover.Trigger`'s reason: a `Button` inside a pressable
63
+ trigger would win the touch and the tooltip would never open. The trigger hands its handler to
64
+ the child as `onLongPress` or `onPress`, chained ahead of the child's own by `mergeProps`, and
65
+ composes the measuring ref onto the child's — so the child has to be built on `Pressable`.
66
+ - **An outside tap closes it and still lands.** Popover swallows an outside tap behind an invisible
67
+ catcher, which is right for a panel with things to press in it and wrong for a label: a tooltip
68
+ in the way of the next tap would make the app feel stuck. So there is no catcher. The panel is
69
+ `pointerEvents="none"` — a tap on it falls through too — and the tooltip subscribes, while open,
70
+ to `OverlayProvider`'s `subscribeTouchStart`, which hears every touch starting anywhere beneath
71
+ the provider without claiming it, and closes. Only the provider can do this: nothing else is an
72
+ ancestor of a view the tooltip knows nothing about. See [Overlay](../overlay/AGENTS.md).
73
+ - **The trigger again closes it, and that rides on bubbling order.** A touch on the trigger reaches
74
+ the trigger's `onTouchStart` before it bubbles to the provider, so the trigger records whether
75
+ the tooltip was open when this touch began; then the provider closes it; then the long press or
76
+ press arrives and, seeing it was open, leaves it closed rather than re-opening what the touch
77
+ just shut. In long-press mode a plain tap on the trigger closes it too — the user has moved on to
78
+ the button.
79
+ - **It hides itself.** `duration` ms (1500 by default) after the entrance settles, so a slow
80
+ entrance never eats into the time the words are on screen. `0` keeps it until an outside tap or
81
+ the trigger. A negative or non-finite duration is a mistake, not a request to vanish, and takes
82
+ the default (`resolveTooltipDuration`).
83
+ - **One at a time.** A module-level slot holds the open tooltip's id and close; opening another
84
+ closes it. Two labels pointing at two controls at once say nothing about either.
85
+ - **`inverted` is the default look.** The foreground colour as the fill and the background colour
86
+ as the ink reads over anything in either theme, which is what a label floating over arbitrary
87
+ content needs; a compact `rounded-md` chip sized for one caption-sized line. `surface` is the
88
+ popover card — fill, hairline, card corner — with room for `Tooltip.Title` over
89
+ `Tooltip.Description`. The text parts read the variant from the panel, so neither needs a class
90
+ at the call site.
91
+ - **The arrow is Popover's, repainted.** `AnchoredArrow` with `isUnstyled`, so Popover's paint is
92
+ stripped and the variant's `arrow` slot is all there is: fill alone on the borderless chip, fill
93
+ plus the border on its two outer edges on the card.
94
+ - **Motion is a fade and 4pt.** From `useAnchoredContent`: an invisible measure frame, then a fade
95
+ and a 4pt slide from the resolved side, with no scale — a label appears, it does not grow.
96
+ Under reduce motion only the fade runs.
97
+
98
+ ## Accessibility
99
+
100
+ - **The trigger carries the words.** `resolveTooltipAccessibility`: `label` becomes the trigger's
101
+ `accessibilityLabel` when it has none — an icon button — or its `accessibilityHint` when it does;
102
+ a label identical to the trigger's is dropped rather than read twice. An `asChild` trigger's
103
+ label is read off the child's props, and visible text counts as a label (`readableTextOf`): a
104
+ `<Button>Sync now</Button>` is already named, and an early build replaced "Sync now" with the
105
+ tooltip's words — found on a simulator with the `surface` demo. So VoiceOver says the tooltip's words without anything
106
+ opening.
107
+ - **The panel is never read and never takes focus.** `accessibilityElementsHidden` and
108
+ `importantForAccessibility="no-hide-descendants"` on the positioner; non-modal overlays never move
109
+ focus (the overlay plan's rule). Reading it as well would say everything twice.
110
+ - **With a screen reader on, a long press does not open it** (`shouldTooltipActivate`) — the words
111
+ already reached the trigger, and double-tap-and-hold is an action. A press tooltip still opens,
112
+ for someone using zoom alongside VoiceOver, and then never times out
113
+ (`resolveTooltipDuration`): they read at their own pace.
114
+ - **Android back closes it** while it is the top overlay, so a tooltip opened over a popover does
115
+ not leave back doing nothing.
116
+
117
+ ## Out of scope
118
+
119
+ - Following a trigger that scrolls while open — the panel stays where it opened, as Popover's does,
120
+ and the timer clears it soon after.
121
+ - Interactive content. A button in a tooltip cannot be pressed; that is a `Popover`.
122
+
123
+ ## Testing
124
+
125
+ `bun test` reaches the variants and the three resolvers. Positioning is Popover's and tested there.
126
+ Everything else — the long press and its haptic, the button's tap surviving it, the timer, an
127
+ outside tap closing it and still landing, one-at-a-time, VoiceOver reading `label` — is verified on
128
+ a simulator through `apps/playground`'s `/tooltip` gallery.
129
+
130
+ Preview media is not captured yet (see the overlay plan); the flows are in
131
+ `.argent/flows/previews/tooltip/`. When a capture tool is back, mark these `capture`:
132
+ `icon-buttons` `{ flow: "tooltip/icon-buttons", frame: "device", hero: true }`, and `press`,
133
+ `placements`, `surface` `{ flow: "tooltip/<id>", frame: "device" }`.
@@ -0,0 +1,28 @@
1
+ export { Tooltip, type TooltipProps } from "./tooltip";
2
+ export {
3
+ TooltipContext,
4
+ type TooltipContextValue,
5
+ useTooltip,
6
+ } from "./tooltip.context";
7
+ export {
8
+ readableTextOf,
9
+ resolveTooltipAccessibility,
10
+ resolveTooltipDuration,
11
+ shouldTooltipActivate,
12
+ TOOLTIP_DEFAULTS,
13
+ TOOLTIP_DURATION,
14
+ TOOLTIP_ENTER_DISTANCE,
15
+ TOOLTIP_VARIANTS,
16
+ type TooltipAccessibility,
17
+ type TooltipGesture,
18
+ type TooltipOpenOn,
19
+ type TooltipVariant,
20
+ type TooltipVariantProps,
21
+ tooltipVariants,
22
+ } from "./tooltip.variants";
23
+ export type { TooltipArrowProps } from "./tooltip-arrow";
24
+ export type { TooltipContentProps } from "./tooltip-content";
25
+ export type { TooltipDescriptionProps } from "./tooltip-description";
26
+ export type { TooltipTextProps } from "./tooltip-text";
27
+ export type { TooltipTitleProps } from "./tooltip-title";
28
+ export type { TooltipTriggerProps } from "./tooltip-trigger";
@@ -0,0 +1,40 @@
1
+ import type { ReactElement } from "react";
2
+ import { AnchoredArrow } from "../popover/popover-arrow";
3
+ import { useOptionalTooltipContent } from "./tooltip.context";
4
+ import { tooltipVariants } from "./tooltip.variants";
5
+
6
+ export type TooltipArrowProps = {
7
+ className?: string;
8
+ };
9
+
10
+ /**
11
+ * The panel's arrow — Popover's `AnchoredArrow`, wearing the tooltip's paint.
12
+ *
13
+ * Write it anywhere inside `Tooltip.Content`: it reads the resolved placement
14
+ * and offset from the panel, follows a flip, points at the trigger's centre
15
+ * even after the panel shifted along an edge, and is lifted out of a
16
+ * scrollable body. Popover's own paint is stripped and the variant's slot
17
+ * supplies it, so an inverted chip's arrow is the foreground fill with no
18
+ * border and a surface card's is the popover fill with its hairline.
19
+ *
20
+ * @example
21
+ * <Tooltip.Content>
22
+ * <Tooltip.Arrow />
23
+ * <Tooltip.Text>Share</Tooltip.Text>
24
+ * </Tooltip.Content>
25
+ */
26
+ export function TooltipArrow({ className }: TooltipArrowProps): ReactElement | null {
27
+ const content = useOptionalTooltipContent();
28
+ if (content === null) return null;
29
+
30
+ return (
31
+ <AnchoredArrow
32
+ arrowOffset={content.arrowOffset}
33
+ className={tooltipVariants({ variant: content.variant }).arrow({ className })}
34
+ isUnstyled
35
+ placement={content.placement}
36
+ size={content.size}
37
+ />
38
+ );
39
+ }
40
+ TooltipArrow.displayName = "DelacourUI.Tooltip.Arrow";
@@ -0,0 +1,179 @@
1
+ import {
2
+ Children,
3
+ isValidElement,
4
+ type ReactElement,
5
+ type ReactNode,
6
+ useCallback,
7
+ useEffect,
8
+ useId,
9
+ useMemo,
10
+ useRef,
11
+ } from "react";
12
+ import { ScrollView, View, type ViewProps } from "react-native";
13
+ import Animated from "react-native-reanimated";
14
+ import { Overlay, useOverlayBackHandler } from "../overlay";
15
+ import type { PopoverAlign, PopoverPlacement, PopoverWidth } from "../popover/popover.position";
16
+ import { POPOVER_ARROW_INSET, POPOVER_COLLISION_PADDING } from "../popover/popover.variants";
17
+ import { useAnchoredContent } from "../popover/use-anchored-content";
18
+ import { TooltipContentContext, type TooltipContentContextValue, useTooltipContext } from "./tooltip.context";
19
+ import { TOOLTIP_DEFAULTS, TOOLTIP_ENTER_DISTANCE, type TooltipVariant, tooltipVariants } from "./tooltip.variants";
20
+ import { TooltipArrow } from "./tooltip-arrow";
21
+
22
+ export type TooltipContentProps = ViewProps & {
23
+ className?: string;
24
+ /** The side of the trigger the panel prefers. It flips when that side lacks room. Default `"top"`. */
25
+ placement?: PopoverPlacement;
26
+ /** Which edges line up along the cross axis; logical under RTL. Default `"center"`. */
27
+ align?: PopoverAlign;
28
+ /** The gap between trigger and panel, in points. Default 6. */
29
+ offset?: number;
30
+ /** A nudge along the cross axis, inward from the aligned edge. Default 0. */
31
+ alignOffset?: number;
32
+ /** `"inverted"` — a dark chip for one line; `"surface"` — a popover card for a title and a description. Default `"inverted"`. */
33
+ variant?: TooltipVariant;
34
+ /** A number, the trigger's width, the content's own, or the safe span. Default `"content-fit"`. */
35
+ width?: PopoverWidth;
36
+ minWidth?: number;
37
+ /** Clamped to the room on the resolved side. */
38
+ maxHeight?: number;
39
+ /** Scroll the body when it is taller than `maxHeight` — the one case the panel takes a touch. Default false. */
40
+ isScrollable?: boolean;
41
+ };
42
+
43
+ /** Pulls `Tooltip.Arrow` out of the body, so a scrolling body never scrolls or clips it. */
44
+ function partitionArrow(children: ReactNode): { arrows: ReactNode[]; body: ReactNode[] } {
45
+ const arrows: ReactNode[] = [];
46
+ const body: ReactNode[] = [];
47
+ Children.forEach(children, (child) => {
48
+ if (isValidElement(child) && child.type === TooltipArrow) arrows.push(child);
49
+ else body.push(child);
50
+ });
51
+ return { arrows, body };
52
+ }
53
+
54
+ /**
55
+ * The panel, drawn over the app and anchored to the trigger.
56
+ *
57
+ * Renders nothing while closed. On open it mounts in the `anchored` band of
58
+ * the overlay z-order, measures itself invisibly, resolves where it fits —
59
+ * Popover's resolver, so it flips and shifts the same way — and fades in from
60
+ * 4pt toward its resolved side; under reduce motion it only fades. `duration`
61
+ * ms after that entrance settles it hides itself.
62
+ *
63
+ * It takes no touch: there is no catcher under it and the panel is
64
+ * `pointerEvents="none"`, so a tap anywhere — the panel included — reaches
65
+ * what is under it, and the provider closes the tooltip on the way. Only an
66
+ * `isScrollable` body takes a touch, to scroll. It is hidden from assistive
67
+ * technology and never takes focus: its words already reached the trigger.
68
+ * Android back closes it while it is the top overlay.
69
+ *
70
+ * @example
71
+ * <Tooltip.Content placement="bottom">
72
+ * <Tooltip.Arrow />
73
+ * <Tooltip.Text>Copy link</Tooltip.Text>
74
+ * </Tooltip.Content>
75
+ */
76
+ export function TooltipContent({
77
+ children,
78
+ className,
79
+ placement = TOOLTIP_DEFAULTS.placement,
80
+ align = TOOLTIP_DEFAULTS.align,
81
+ offset = TOOLTIP_DEFAULTS.offset,
82
+ alignOffset = TOOLTIP_DEFAULTS.alignOffset,
83
+ variant = TOOLTIP_DEFAULTS.variant,
84
+ width = TOOLTIP_DEFAULTS.width,
85
+ minWidth,
86
+ maxHeight,
87
+ isScrollable = false,
88
+ style,
89
+ onTouchStart,
90
+ ...props
91
+ }: TooltipContentProps): ReactElement | null {
92
+ const { isOpen, close, duration, anchorRect, onContentTouchStart } = useTooltipContext();
93
+ const overlayId = useId();
94
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
95
+
96
+ const clearTimer = useCallback(() => {
97
+ if (timer.current === null) return;
98
+ clearTimeout(timer.current);
99
+ timer.current = null;
100
+ }, []);
101
+
102
+ const startTimer = useCallback(() => {
103
+ clearTimer();
104
+ if (duration > 0) timer.current = setTimeout(close, duration);
105
+ }, [clearTimer, close, duration]);
106
+
107
+ useEffect(() => {
108
+ if (!isOpen) clearTimer();
109
+ }, [isOpen, clearTimer]);
110
+ useEffect(() => clearTimer, [clearTimer]);
111
+
112
+ const anchored = useAnchoredContent({
113
+ isOpen,
114
+ anchor: anchorRect,
115
+ placement,
116
+ align,
117
+ offset,
118
+ alignOffset,
119
+ collisionPadding: POPOVER_COLLISION_PADDING,
120
+ arrowInset: POPOVER_ARROW_INSET,
121
+ width,
122
+ minWidth,
123
+ maxHeight,
124
+ enterDistance: TOOLTIP_ENTER_DISTANCE,
125
+ enterScale: 1,
126
+ onEntered: startTimer,
127
+ });
128
+
129
+ useOverlayBackHandler({ id: overlayId, isEnabled: anchored.isMounted, onBack: close });
130
+
131
+ const contentContext = useMemo<TooltipContentContextValue>(
132
+ () => ({
133
+ variant,
134
+ placement: anchored.position?.placement ?? placement,
135
+ arrowOffset: anchored.position?.arrowOffset ?? 0,
136
+ size: anchored.size ?? { width: 0, height: 0 },
137
+ }),
138
+ [variant, anchored.position?.placement, placement, anchored.position?.arrowOffset, anchored.size]
139
+ );
140
+
141
+ if (!anchored.isMounted) return null;
142
+
143
+ const { arrows, body } = partitionArrow(children);
144
+
145
+ return (
146
+ <Overlay.Portal id={overlayId} layer="anchored">
147
+ <Animated.View
148
+ accessibilityElementsHidden
149
+ importantForAccessibility="no-hide-descendants"
150
+ onLayout={anchored.onLayout}
151
+ pointerEvents={isScrollable ? "box-none" : "none"}
152
+ style={[anchored.positionerStyle, anchored.animatedStyle]}
153
+ >
154
+ <View
155
+ accessible={false}
156
+ className={tooltipVariants({ variant }).content({ className })}
157
+ onTouchStart={(event) => {
158
+ onContentTouchStart();
159
+ onTouchStart?.(event);
160
+ }}
161
+ style={[anchored.frameStyle, style]}
162
+ {...props}
163
+ >
164
+ <TooltipContentContext.Provider value={contentContext}>
165
+ {isScrollable ? (
166
+ <ScrollView className="shrink grow-0" contentContainerClassName="gap-1">
167
+ {body}
168
+ </ScrollView>
169
+ ) : (
170
+ body
171
+ )}
172
+ {arrows}
173
+ </TooltipContentContext.Provider>
174
+ </View>
175
+ </Animated.View>
176
+ </Overlay.Portal>
177
+ );
178
+ }
179
+ TooltipContent.displayName = "DelacourUI.Tooltip.Content";
@@ -0,0 +1,19 @@
1
+ import type { ReactElement } from "react";
2
+ import { Text, type TextPresetProps } from "../text";
3
+ import { useOptionalTooltipContent } from "./tooltip.context";
4
+ import { TOOLTIP_DEFAULTS, tooltipVariants } from "./tooltip.variants";
5
+
6
+ export type TooltipDescriptionProps = TextPresetProps;
7
+
8
+ /**
9
+ * Supporting copy under the title — a `Text.Caption`, muted on a surface card
10
+ * and the background colour at 80% on an inverted chip.
11
+ *
12
+ * @example
13
+ * <Tooltip.Description>Changes reach your other devices within a minute.</Tooltip.Description>
14
+ */
15
+ export function TooltipDescription({ className, ...props }: TooltipDescriptionProps): ReactElement {
16
+ const variant = useOptionalTooltipContent()?.variant ?? TOOLTIP_DEFAULTS.variant;
17
+ return <Text.Caption className={tooltipVariants({ variant }).description({ className })} {...props} />;
18
+ }
19
+ TooltipDescription.displayName = "DelacourUI.Tooltip.Description";
@@ -0,0 +1,21 @@
1
+ import type { ReactElement } from "react";
2
+ import { Text, type TextPresetProps } from "../text";
3
+ import { useOptionalTooltipContent } from "./tooltip.context";
4
+ import { TOOLTIP_DEFAULTS, tooltipVariants } from "./tooltip.variants";
5
+
6
+ export type TooltipTextProps = TextPresetProps;
7
+
8
+ /**
9
+ * The one-line label — a `Text.Caption`, inked for the panel it sits on: the
10
+ * background colour on an inverted chip, the popover foreground on a surface
11
+ * card. Caption rather than label because a tooltip is an aside to its
12
+ * control, not a second control.
13
+ *
14
+ * @example
15
+ * <Tooltip.Text>Share</Tooltip.Text>
16
+ */
17
+ export function TooltipText({ className, ...props }: TooltipTextProps): ReactElement {
18
+ const variant = useOptionalTooltipContent()?.variant ?? TOOLTIP_DEFAULTS.variant;
19
+ return <Text.Caption className={tooltipVariants({ variant }).text({ className })} {...props} />;
20
+ }
21
+ TooltipText.displayName = "DelacourUI.Tooltip.Text";
@@ -0,0 +1,20 @@
1
+ import type { ReactElement } from "react";
2
+ import { Text, type TextPresetProps } from "../text";
3
+ import { useOptionalTooltipContent } from "./tooltip.context";
4
+ import { TOOLTIP_DEFAULTS, tooltipVariants } from "./tooltip.variants";
5
+
6
+ export type TooltipTitleProps = TextPresetProps;
7
+
8
+ /**
9
+ * A heading over a description, for the surface variant — a `Text.Label`, the
10
+ * size a popover's title takes. Not announced as a header: the panel is hidden
11
+ * from assistive technology, and its words reach the trigger through `label`.
12
+ *
13
+ * @example
14
+ * <Tooltip.Title>Sync</Tooltip.Title>
15
+ */
16
+ export function TooltipTitle({ className, ...props }: TooltipTitleProps): ReactElement {
17
+ const variant = useOptionalTooltipContent()?.variant ?? TOOLTIP_DEFAULTS.variant;
18
+ return <Text.Label className={tooltipVariants({ variant }).title({ className })} {...props} />;
19
+ }
20
+ TooltipTitle.displayName = "DelacourUI.Tooltip.Title";
@@ -0,0 +1,103 @@
1
+ import { isValidElement, type ReactElement } from "react";
2
+ import type { GestureResponderEvent } from "react-native";
3
+ import { Slot } from "../../lib/slot";
4
+ import { Pressable, type PressableProps } from "../pressable";
5
+ import { useTooltipContext } from "./tooltip.context";
6
+ import { readableTextOf, resolveTooltipAccessibility } from "./tooltip.variants";
7
+
8
+ export type TooltipTriggerProps = PressableProps;
9
+
10
+ /** The `accessibilityLabel` an `asChild` trigger's child already carries, if any. */
11
+ function childLabel(children: PressableProps["children"]): string | undefined {
12
+ if (!isValidElement<{ accessibilityLabel?: unknown }>(children)) return undefined;
13
+ const label = children.props.accessibilityLabel;
14
+ return typeof label === "string" ? label : undefined;
15
+ }
16
+
17
+ /**
18
+ * What the trigger is already called: its own label, the child's when it is
19
+ * donated to, or else its visible text — which is what a screen reader names
20
+ * a control by when nothing else does.
21
+ */
22
+ function triggerName(
23
+ accessibilityLabel: string | undefined,
24
+ asChild: boolean,
25
+ children: PressableProps["children"]
26
+ ): string | undefined {
27
+ return accessibilityLabel ?? (asChild ? childLabel(children) : undefined) ?? readableTextOf(children);
28
+ }
29
+
30
+ /**
31
+ * The control the tooltip names, and the view the panel is anchored to.
32
+ *
33
+ * On its own it is this library's `Pressable`. **`asChild` donates the
34
+ * gesture** rather than wrapping the child, for `Popover.Trigger`'s reason —
35
+ * a `Button` inside a pressable trigger would win the touch. With the default
36
+ * `openOn="longPress"` the trigger hands the child an `onLongPress`; with
37
+ * `"press"`, an `onPress`. Either is chained ahead of the child's own, so the
38
+ * child's handler still runs, and a long press never costs the child its tap:
39
+ * a tap held past the long-press delay fails, so `onPress` does not fire for
40
+ * it. The measuring ref is composed onto the child's own, so the child has to
41
+ * be built on `Pressable`.
42
+ *
43
+ * The tooltip's `label` becomes the trigger's accessibility label when it has
44
+ * none, or its hint when it does — and visible text counts as a name, so a
45
+ * `<Button>Sync now</Button>` keeps saying "Sync now".
46
+ *
47
+ * @example
48
+ * <Tooltip.Trigger asChild>
49
+ * <Button size="icon-md" variant="ghost"><Icon icon={IconShare} /></Button>
50
+ * </Tooltip.Trigger>
51
+ */
52
+ export function TooltipTrigger({
53
+ asChild = false,
54
+ accessibilityLabel,
55
+ children,
56
+ onPress,
57
+ onLongPress,
58
+ onTouchStart,
59
+ ...props
60
+ }: TooltipTriggerProps): ReactElement {
61
+ const { openOn, label, activate, onTriggerTouchStart, triggerRef } = useTooltipContext();
62
+
63
+ const accessibility = resolveTooltipAccessibility({
64
+ label,
65
+ triggerLabel: triggerName(accessibilityLabel, asChild, children),
66
+ });
67
+
68
+ const handlers = {
69
+ onTouchStart: (event: GestureResponderEvent) => {
70
+ onTriggerTouchStart();
71
+ onTouchStart?.(event);
72
+ },
73
+ onPress:
74
+ openOn === "press"
75
+ ? () => {
76
+ activate("press");
77
+ onPress?.();
78
+ }
79
+ : onPress,
80
+ onLongPress:
81
+ openOn === "longPress"
82
+ ? () => {
83
+ activate("longPress");
84
+ onLongPress?.();
85
+ }
86
+ : onLongPress,
87
+ };
88
+
89
+ if (asChild) {
90
+ return (
91
+ <Slot {...props} accessibilityLabel={accessibilityLabel} {...accessibility} {...handlers} ref={triggerRef}>
92
+ {children}
93
+ </Slot>
94
+ );
95
+ }
96
+
97
+ return (
98
+ <Pressable {...props} accessibilityLabel={accessibilityLabel} {...accessibility} {...handlers} ref={triggerRef}>
99
+ {children}
100
+ </Pressable>
101
+ );
102
+ }
103
+ TooltipTrigger.displayName = "DelacourUI.Tooltip.Trigger";
@@ -0,0 +1,68 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { AnchoredSize, AnchorRect, PopoverPlacement } from "../popover/popover.position";
3
+ import type { MeasurableNode } from "../popover/use-anchor-measure";
4
+ import type { TooltipGesture, TooltipOpenOn, TooltipVariant } from "./tooltip.variants";
5
+
6
+ /** What `Tooltip` shares with its parts. */
7
+ export type TooltipContextValue = {
8
+ isOpen: boolean;
9
+ setOpen: (isOpen: boolean) => void;
10
+ close: () => void;
11
+ openOn: TooltipOpenOn;
12
+ /** The tooltip's words, for the trigger's label or hint. */
13
+ label: string | undefined;
14
+ /** ms after the entrance settles before it hides; 0 for never. Already resolved against the screen reader. */
15
+ duration: number;
16
+ /** The trigger reports a gesture; the root decides whether it toggles. */
17
+ activate: (gesture: TooltipGesture) => void;
18
+ /** The trigger reports that a touch started on it — before the provider hears the same touch. */
19
+ onTriggerTouchStart: () => void;
20
+ /** The panel reports that a touch started on it, so a scrollable body is not an outside tap. */
21
+ onContentTouchStart: () => void;
22
+ /** The trigger's frame in window coordinates, `null` until it is measured on open. */
23
+ anchorRect: AnchorRect | null;
24
+ /** The callback ref `Tooltip.Trigger` measures through. */
25
+ triggerRef: (node: MeasurableNode | null) => void;
26
+ };
27
+
28
+ export const TooltipContext = createContext<TooltipContextValue | null>(null);
29
+ TooltipContext.displayName = "DelacourUI.Tooltip.Context";
30
+
31
+ /**
32
+ * The tooltip a part sits in — open state and the close. Throws outside a
33
+ * `<Tooltip>`.
34
+ *
35
+ * @example
36
+ * function Hint() {
37
+ * const { isOpen } = useTooltip();
38
+ * return isOpen ? <Text.Caption>Shown</Text.Caption> : null;
39
+ * }
40
+ */
41
+ export function useTooltip(): Pick<TooltipContextValue, "isOpen" | "setOpen" | "close"> {
42
+ const context = useContext(TooltipContext);
43
+ if (context === null) throw new Error("useTooltip must be used inside a <Tooltip>.");
44
+ return context;
45
+ }
46
+
47
+ /** The full context, for the parts. Throws outside a `<Tooltip>`. */
48
+ export function useTooltipContext(): TooltipContextValue {
49
+ const context = useContext(TooltipContext);
50
+ if (context === null) throw new Error("Tooltip parts must be used inside a <Tooltip>.");
51
+ return context;
52
+ }
53
+
54
+ /** What `Tooltip.Content` shares with the parts drawn inside the panel. */
55
+ export type TooltipContentContextValue = {
56
+ variant: TooltipVariant;
57
+ placement: PopoverPlacement;
58
+ arrowOffset: number;
59
+ size: AnchoredSize;
60
+ };
61
+
62
+ export const TooltipContentContext = createContext<TooltipContentContextValue | null>(null);
63
+ TooltipContentContext.displayName = "DelacourUI.Tooltip.ContentContext";
64
+
65
+ /** The panel's variant and placement, or `null` outside `Tooltip.Content`. */
66
+ export function useOptionalTooltipContent(): TooltipContentContextValue | null {
67
+ return useContext(TooltipContentContext);
68
+ }
@@ -0,0 +1,213 @@
1
+ import { type ReactElement, type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
2
+ import { AccessibilityInfo } from "react-native";
3
+ import { scheduleOnUI } from "react-native-worklets";
4
+ import { useControllableState } from "../../hooks/use-controllable-state";
5
+ import { useOptionalOverlay } from "../overlay/overlay.context";
6
+ import { useAnchorMeasure } from "../popover/use-anchor-measure";
7
+ import { playHaptic } from "../pressable";
8
+ import { TooltipContext, type TooltipContextValue } from "./tooltip.context";
9
+ import {
10
+ resolveTooltipDuration,
11
+ shouldTooltipActivate,
12
+ TOOLTIP_DEFAULTS,
13
+ type TooltipGesture,
14
+ type TooltipOpenOn,
15
+ } from "./tooltip.variants";
16
+ import { TooltipArrow } from "./tooltip-arrow";
17
+ import { TooltipContent } from "./tooltip-content";
18
+ import { TooltipDescription } from "./tooltip-description";
19
+ import { TooltipText } from "./tooltip-text";
20
+ import { TooltipTitle } from "./tooltip-title";
21
+ import { TooltipTrigger } from "./tooltip-trigger";
22
+
23
+ export type TooltipProps = {
24
+ isOpen?: boolean;
25
+ /** Default false. */
26
+ defaultOpen?: boolean;
27
+ onOpenChange?: (isOpen: boolean) => void;
28
+ /** `"longPress"` leaves the trigger's tap alone; `"press"` is for a trigger with no tap of its own. Default `"longPress"`. */
29
+ openOn?: TooltipOpenOn;
30
+ /** Auto-hide this many ms after the entrance settles. 0 = until an outside tap or the trigger again. Default 1500. */
31
+ duration?: number;
32
+ /** The text a screen reader reads for the trigger — the tooltip's words without opening it. */
33
+ label?: string;
34
+ children: ReactNode;
35
+ };
36
+
37
+ /** The one tooltip on screen. Opening another closes it. */
38
+ let current: { id: string; close: () => void } | null = null;
39
+
40
+ /** Whether VoiceOver or TalkBack is running, kept current. */
41
+ function useScreenReaderEnabled(): boolean {
42
+ const [isEnabled, setEnabled] = useState(false);
43
+ useEffect(() => {
44
+ let isMounted = true;
45
+ AccessibilityInfo.isScreenReaderEnabled().then((value) => {
46
+ if (isMounted) setEnabled(value);
47
+ });
48
+ const subscription = AccessibilityInfo.addEventListener("screenReaderChanged", setEnabled);
49
+ return () => {
50
+ isMounted = false;
51
+ subscription.remove();
52
+ };
53
+ }, []);
54
+ return isEnabled;
55
+ }
56
+
57
+ function TooltipRoot({
58
+ isOpen: isOpenProp,
59
+ defaultOpen = false,
60
+ onOpenChange,
61
+ openOn = TOOLTIP_DEFAULTS.openOn,
62
+ duration: durationProp,
63
+ label,
64
+ children,
65
+ }: TooltipProps): ReactElement {
66
+ const id = useId();
67
+ const [isOpen, setOpen] = useControllableState({
68
+ value: isOpenProp,
69
+ defaultValue: defaultOpen,
70
+ onChange: onOpenChange,
71
+ });
72
+ const isScreenReaderEnabled = useScreenReaderEnabled();
73
+ const duration = resolveTooltipDuration(durationProp, { isScreenReaderEnabled });
74
+ const trigger = useAnchorMeasure({ isEnabled: isOpen });
75
+ const subscribeTouchStart = useOptionalOverlay()?.subscribeTouchStart;
76
+
77
+ // Set by the trigger's own `onTouchStart`, which bubbles to it before the
78
+ // provider hears the same touch and closes the tooltip — so the activation
79
+ // that follows knows this touch began on an open tooltip and leaves it shut.
80
+ const wasOpenAtTriggerTouch = useRef(false);
81
+ const isTouchInsideContent = useRef(false);
82
+
83
+ const close = useCallback(() => setOpen(false), [setOpen]);
84
+
85
+ const activate = useCallback(
86
+ (gesture: TooltipGesture) => {
87
+ if (!shouldTooltipActivate({ openOn, gesture, isScreenReaderEnabled })) return;
88
+ const wasOpen = wasOpenAtTriggerTouch.current || isOpen;
89
+ wasOpenAtTriggerTouch.current = false;
90
+ if (wasOpen) {
91
+ setOpen(false);
92
+ return;
93
+ }
94
+ if (gesture === "longPress") scheduleOnUI(playHaptic, "selection");
95
+ setOpen(true);
96
+ },
97
+ [openOn, isScreenReaderEnabled, isOpen, setOpen]
98
+ );
99
+
100
+ const onTriggerTouchStart = useCallback(() => {
101
+ wasOpenAtTriggerTouch.current = isOpen;
102
+ }, [isOpen]);
103
+
104
+ const onContentTouchStart = useCallback(() => {
105
+ isTouchInsideContent.current = true;
106
+ }, []);
107
+
108
+ useEffect(() => {
109
+ if (!isOpen || subscribeTouchStart === undefined) return;
110
+ return subscribeTouchStart(() => {
111
+ if (isTouchInsideContent.current) {
112
+ isTouchInsideContent.current = false;
113
+ return;
114
+ }
115
+ setOpen(false);
116
+ });
117
+ }, [isOpen, subscribeTouchStart, setOpen]);
118
+
119
+ useEffect(() => {
120
+ if (!isOpen) return;
121
+ if (current !== null && current.id !== id) current.close();
122
+ current = { id, close };
123
+ return () => {
124
+ if (current?.id === id) current = null;
125
+ };
126
+ }, [isOpen, id, close]);
127
+
128
+ const value = useMemo<TooltipContextValue>(
129
+ () => ({
130
+ isOpen,
131
+ setOpen,
132
+ close,
133
+ openOn,
134
+ label,
135
+ duration,
136
+ activate,
137
+ onTriggerTouchStart,
138
+ onContentTouchStart,
139
+ anchorRect: trigger.rect,
140
+ triggerRef: trigger.ref,
141
+ }),
142
+ [
143
+ isOpen,
144
+ setOpen,
145
+ close,
146
+ openOn,
147
+ label,
148
+ duration,
149
+ activate,
150
+ onTriggerTouchStart,
151
+ onContentTouchStart,
152
+ trigger.rect,
153
+ trigger.ref,
154
+ ]
155
+ );
156
+
157
+ return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
158
+ }
159
+
160
+ /**
161
+ * A short label naming the control under the finger — what an icon-only
162
+ * button does, a shortcut, a one-line hint.
163
+ *
164
+ * Mobile has no hover, so it opens on a **long press** by default, with a
165
+ * selection haptic, and the control's own tap still does what it did;
166
+ * `openOn="press"` opens it on a tap instead, for a trigger with no tap of its
167
+ * own — an info glyph. It hides itself `duration` ms after it appears, on the
168
+ * trigger again, or on a tap anywhere else — and that tap still lands on what
169
+ * it was aimed at. Opening one tooltip closes any other.
170
+ *
171
+ * It is not interactive: anything with a button in it is a `Popover`. The
172
+ * panel is hidden from assistive technology; `label` reaches the trigger as
173
+ * its accessibility label, or its hint when it already has a label, so
174
+ * VoiceOver reads the words without anything opening.
175
+ *
176
+ * It draws in the overlay layer, so it needs `OverlayProvider` at the app root
177
+ * — which is also what hears the outside tap.
178
+ *
179
+ * @example
180
+ * <Tooltip label="Share">
181
+ * <Tooltip.Trigger asChild>
182
+ * <Button size="icon-md" variant="ghost"><Icon icon={IconShare} /></Button>
183
+ * </Tooltip.Trigger>
184
+ * <Tooltip.Content>
185
+ * <Tooltip.Arrow />
186
+ * <Tooltip.Text>Share</Tooltip.Text>
187
+ * </Tooltip.Content>
188
+ * </Tooltip>
189
+ *
190
+ * @example
191
+ * <Tooltip openOn="press" duration={0}>
192
+ * <Tooltip.Trigger accessibilityLabel="About sync"><Icon icon={IconCircleInfo} /></Tooltip.Trigger>
193
+ * <Tooltip.Content variant="surface">
194
+ * <Tooltip.Title>Sync</Tooltip.Title>
195
+ * <Tooltip.Description>Changes reach your other devices within a minute.</Tooltip.Description>
196
+ * </Tooltip.Content>
197
+ * </Tooltip>
198
+ */
199
+ export const Tooltip = Object.assign(TooltipRoot, {
200
+ /** The control the tooltip names — `Pressable`, or `asChild` to donate the gesture. */
201
+ Trigger: TooltipTrigger,
202
+ /** The panel — teleported, measured, placed and animated; never interactive. */
203
+ Content: TooltipContent,
204
+ /** The arrow pointing from the panel at the trigger. */
205
+ Arrow: TooltipArrow,
206
+ /** The one-line label. */
207
+ Text: TooltipText,
208
+ /** A heading, for the surface variant. */
209
+ Title: TooltipTitle,
210
+ /** Supporting copy under the title, for the surface variant. */
211
+ Description: TooltipDescription,
212
+ displayName: "DelacourUI.Tooltip",
213
+ });
@@ -0,0 +1,231 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createElement } from "react";
3
+ import { declaredTokens } from "../../styles/theme-tokens.test";
4
+ import {
5
+ readableTextOf,
6
+ resolveTooltipAccessibility,
7
+ resolveTooltipDuration,
8
+ shouldTooltipActivate,
9
+ TOOLTIP_DEFAULTS,
10
+ TOOLTIP_DURATION,
11
+ TOOLTIP_ENTER_DISTANCE,
12
+ TOOLTIP_VARIANTS,
13
+ tooltipVariants,
14
+ } from "./tooltip.variants";
15
+
16
+ const LIGHT = declaredTokens("light");
17
+ const DARK = declaredTokens("dark");
18
+
19
+ /** `border-t` and friends set a width, not a colour, and name no token. */
20
+ const STRUCTURAL_BORDER_SUFFIXES = new Set(["t", "b", "l", "r", "x", "y", "s", "e"]);
21
+
22
+ /** Tailwind's own size steps share the `text-` prefix with colours and name no token. */
23
+ const TEXT_SIZES = new Set(["xs", "sm", "base", "lg", "xl", "2xl", "3xl", "4xl"]);
24
+
25
+ /** Every theme token a class string paints with, with any `/alpha` suffix dropped. */
26
+ function colorTokens(cls: string): string[] {
27
+ const tokens: string[] = [];
28
+ for (const [, utility, token] of cls.matchAll(/\b(bg|border|text)-([a-z][\w-]*)(?:\/\d+)?\b/g)) {
29
+ if (utility === "border" && STRUCTURAL_BORDER_SUFFIXES.has(token)) continue;
30
+ if (utility === "text" && TEXT_SIZES.has(token)) continue;
31
+ tokens.push(token);
32
+ }
33
+ return tokens;
34
+ }
35
+
36
+ /** The slots this component declares, pinned so a new one has to be added before the sweeps can miss it. */
37
+ const SLOT_NAMES = ["content", "arrow", "text", "title", "description"] as const;
38
+
39
+ describe("tooltipVariants — slots", () => {
40
+ test("declares every slot in both variants", () => {
41
+ for (const variant of TOOLTIP_VARIANTS) {
42
+ const slots = tooltipVariants({ variant });
43
+ for (const name of SLOT_NAMES) expect(typeof slots[name]).toBe("function");
44
+ }
45
+ });
46
+
47
+ test("every token a slot paints with exists in both themes", () => {
48
+ for (const variant of TOOLTIP_VARIANTS) {
49
+ const slots = tooltipVariants({ variant });
50
+ for (const name of SLOT_NAMES) {
51
+ for (const token of colorTokens(slots[name]() ?? "")) {
52
+ expect(LIGHT.has(token)).toBe(true);
53
+ expect(DARK.has(token)).toBe(true);
54
+ }
55
+ }
56
+ }
57
+ });
58
+
59
+ test("the default variant is inverted", () => {
60
+ expect(tooltipVariants().content()).toBe(tooltipVariants({ variant: "inverted" }).content());
61
+ });
62
+
63
+ test("a caller's className wins on the panel", () => {
64
+ const content = tooltipVariants().content({ className: "px-4" });
65
+ expect(content).toContain("px-4");
66
+ expect(content).not.toMatch(/\bpx-2\b/);
67
+ });
68
+ });
69
+
70
+ describe("tooltipVariants — inverted", () => {
71
+ const slots = tooltipVariants({ variant: "inverted" });
72
+
73
+ test("the panel is the foreground colour, a small corner and compact padding, with no border", () => {
74
+ const content = slots.content();
75
+ expect(content).toContain("bg-foreground");
76
+ expect(content).toContain("rounded-md");
77
+ expect(content).toMatch(/\bpx-2\b/);
78
+ expect(content).toMatch(/\bpy-1\b/);
79
+ expect(content).not.toMatch(/\bborder\b/);
80
+ });
81
+
82
+ test("the arrow wears the panel's fill and no border", () => {
83
+ const arrow = slots.arrow();
84
+ expect(arrow).toContain("bg-foreground");
85
+ expect(arrow).not.toMatch(/\bborder-border\b/);
86
+ });
87
+
88
+ test("every piece of text is drawn in the background colour, so it reads on the inverted fill", () => {
89
+ expect(slots.text()).toContain("text-background");
90
+ expect(slots.title()).toContain("text-background");
91
+ expect(colorTokens(slots.description())).toEqual(["background"]);
92
+ });
93
+ });
94
+
95
+ describe("tooltipVariants — surface", () => {
96
+ const slots = tooltipVariants({ variant: "surface" });
97
+
98
+ test("the panel is a popover surface with a hairline, the card corner and room for two lines", () => {
99
+ const content = slots.content();
100
+ expect(content).toContain("bg-popover");
101
+ expect(content).toContain("border");
102
+ expect(content).toContain("border-border");
103
+ expect(content).toContain("rounded-lg");
104
+ expect(content).toMatch(/\bgap-\d/);
105
+ });
106
+
107
+ test("the arrow is the panel's fill with the panel's border on two edges", () => {
108
+ const arrow = slots.arrow();
109
+ expect(arrow).toContain("bg-popover");
110
+ expect(arrow).toContain("border-border");
111
+ expect(arrow).toContain("border-b");
112
+ expect(arrow).toContain("border-r");
113
+ });
114
+
115
+ test("text and title sit on the popover foreground; the description is muted", () => {
116
+ expect(slots.text()).toContain("text-popover-foreground");
117
+ expect(slots.title()).toContain("text-popover-foreground");
118
+ expect(slots.description()).toContain("text-muted-foreground");
119
+ });
120
+ });
121
+
122
+ describe("resolveTooltipDuration", () => {
123
+ test("passes a positive duration through", () => {
124
+ expect(resolveTooltipDuration(800, { isScreenReaderEnabled: false })).toBe(800);
125
+ });
126
+
127
+ test("0 means stay until dismissed", () => {
128
+ expect(resolveTooltipDuration(0, { isScreenReaderEnabled: false })).toBe(0);
129
+ });
130
+
131
+ test("an omitted duration is the default", () => {
132
+ expect(resolveTooltipDuration(undefined, { isScreenReaderEnabled: false })).toBe(TOOLTIP_DURATION);
133
+ });
134
+
135
+ test("a negative or non-finite duration falls back to the default rather than hiding at once", () => {
136
+ expect(resolveTooltipDuration(-1, { isScreenReaderEnabled: false })).toBe(TOOLTIP_DURATION);
137
+ expect(resolveTooltipDuration(Number.NaN, { isScreenReaderEnabled: false })).toBe(TOOLTIP_DURATION);
138
+ expect(resolveTooltipDuration(Number.POSITIVE_INFINITY, { isScreenReaderEnabled: false })).toBe(TOOLTIP_DURATION);
139
+ });
140
+
141
+ test("with a screen reader on it never times out — whoever opened it reads at their own pace", () => {
142
+ expect(resolveTooltipDuration(800, { isScreenReaderEnabled: true })).toBe(0);
143
+ expect(resolveTooltipDuration(undefined, { isScreenReaderEnabled: true })).toBe(0);
144
+ });
145
+ });
146
+
147
+ describe("shouldTooltipActivate", () => {
148
+ test("a long-press tooltip opens on a long press and ignores the tap", () => {
149
+ expect(shouldTooltipActivate({ openOn: "longPress", gesture: "longPress", isScreenReaderEnabled: false })).toBe(
150
+ true
151
+ );
152
+ expect(shouldTooltipActivate({ openOn: "longPress", gesture: "press", isScreenReaderEnabled: false })).toBe(false);
153
+ });
154
+
155
+ test("a press tooltip opens on a tap and ignores the long press", () => {
156
+ expect(shouldTooltipActivate({ openOn: "press", gesture: "press", isScreenReaderEnabled: false })).toBe(true);
157
+ expect(shouldTooltipActivate({ openOn: "press", gesture: "longPress", isScreenReaderEnabled: false })).toBe(false);
158
+ });
159
+
160
+ test("with a screen reader on, a long press never opens it — the label already reached the trigger", () => {
161
+ expect(shouldTooltipActivate({ openOn: "longPress", gesture: "longPress", isScreenReaderEnabled: true })).toBe(
162
+ false
163
+ );
164
+ });
165
+
166
+ test("with a screen reader on, a press tooltip still opens, for a partially sighted user", () => {
167
+ expect(shouldTooltipActivate({ openOn: "press", gesture: "press", isScreenReaderEnabled: true })).toBe(true);
168
+ });
169
+ });
170
+
171
+ describe("resolveTooltipAccessibility", () => {
172
+ test("no label, nothing to add", () => {
173
+ expect(resolveTooltipAccessibility({})).toEqual({});
174
+ expect(resolveTooltipAccessibility({ label: "", triggerLabel: "Share" })).toEqual({});
175
+ });
176
+
177
+ test("a trigger with no label of its own is named by the tooltip", () => {
178
+ expect(resolveTooltipAccessibility({ label: "Share" })).toEqual({ accessibilityLabel: "Share" });
179
+ expect(resolveTooltipAccessibility({ label: "Share", triggerLabel: "" })).toEqual({ accessibilityLabel: "Share" });
180
+ });
181
+
182
+ test("a trigger that already has a label keeps it, and the tooltip becomes its hint", () => {
183
+ expect(resolveTooltipAccessibility({ label: "Copies a link to the clipboard", triggerLabel: "Share" })).toEqual({
184
+ accessibilityHint: "Copies a link to the clipboard",
185
+ });
186
+ });
187
+
188
+ test("a label identical to the trigger's is not read twice", () => {
189
+ expect(resolveTooltipAccessibility({ label: "Share", triggerLabel: "Share" })).toEqual({});
190
+ });
191
+ });
192
+
193
+ describe("readableTextOf", () => {
194
+ test("reads a bare string or number", () => {
195
+ expect(readableTextOf("Sync now")).toBe("Sync now");
196
+ expect(readableTextOf(3)).toBe("3");
197
+ });
198
+
199
+ test("reads text nested in elements, the way a screen reader names a control from its content", () => {
200
+ const child = createElement("Label", null, "Sync ", createElement("Strong", null, "now"));
201
+ expect(readableTextOf(child)).toBe("Sync now");
202
+ });
203
+
204
+ test("an icon-only child has no text", () => {
205
+ expect(readableTextOf(createElement("Icon", { icon: "share" }))).toBeUndefined();
206
+ expect(readableTextOf(undefined)).toBeUndefined();
207
+ });
208
+
209
+ test("whitespace alone is no text", () => {
210
+ expect(readableTextOf(" ")).toBeUndefined();
211
+ });
212
+ });
213
+
214
+ describe("constants", () => {
215
+ test("defaults match the documented API", () => {
216
+ expect(TOOLTIP_DEFAULTS).toEqual({
217
+ placement: "top",
218
+ align: "center",
219
+ offset: 6,
220
+ alignOffset: 0,
221
+ width: "content-fit",
222
+ variant: "inverted",
223
+ openOn: "longPress",
224
+ });
225
+ expect(TOOLTIP_DURATION).toBe(1500);
226
+ });
227
+
228
+ test("the entrance is a short slide from the resolved side", () => {
229
+ expect(TOOLTIP_ENTER_DISTANCE).toBe(4);
230
+ });
231
+ });
@@ -0,0 +1,177 @@
1
+ import { Children, isValidElement, type ReactNode } from "react";
2
+ import type { VariantProps } from "tailwind-variants";
3
+ import { tv } from "../../lib/tv";
4
+ import type { PopoverAlign, PopoverPlacement, PopoverWidth } from "../popover/popover.position";
5
+
6
+ /** The two looks: a dark chip that reads over anything, or a popover card for a title and a line. */
7
+ export const TOOLTIP_VARIANTS = ["inverted", "surface"] as const;
8
+ export type TooltipVariant = (typeof TOOLTIP_VARIANTS)[number];
9
+
10
+ /** What opens it: a long press leaves the trigger's own tap alone; a press is for a trigger with no tap of its own. */
11
+ export type TooltipOpenOn = "longPress" | "press";
12
+
13
+ /** The gesture that reached the trigger. */
14
+ export type TooltipGesture = "longPress" | "press";
15
+
16
+ /**
17
+ * What `Tooltip` and `Tooltip.Content` do when a prop is left out.
18
+ *
19
+ * Above the trigger and centred on it, 6pt clear — closer than a popover's 8,
20
+ * because a one-line label belongs to its control — inverted, as wide as its
21
+ * words, opened by a long press.
22
+ */
23
+ export const TOOLTIP_DEFAULTS = {
24
+ placement: "top",
25
+ align: "center",
26
+ offset: 6,
27
+ alignOffset: 0,
28
+ width: "content-fit",
29
+ variant: "inverted",
30
+ openOn: "longPress",
31
+ } as const satisfies {
32
+ placement: PopoverPlacement;
33
+ align: PopoverAlign;
34
+ offset: number;
35
+ alignOffset: number;
36
+ width: PopoverWidth;
37
+ variant: TooltipVariant;
38
+ openOn: TooltipOpenOn;
39
+ };
40
+
41
+ /** How long a tooltip stays after its entrance settles, in ms. */
42
+ export const TOOLTIP_DURATION = 1500;
43
+
44
+ /** How far the panel travels on its way in, toward its resolved side. No scale — a label just appears. */
45
+ export const TOOLTIP_ENTER_DISTANCE = 4;
46
+
47
+ /**
48
+ * How long the tooltip stays, in ms — `0` for until it is dismissed.
49
+ *
50
+ * A negative or non-finite value is a mistake, not a request to vanish at
51
+ * once, so it falls back to the default. With a screen reader on it never
52
+ * times out: whoever opened it — a press tooltip, for someone using zoom
53
+ * alongside VoiceOver — reads at their own pace, and dismisses it with the
54
+ * trigger or a tap elsewhere.
55
+ */
56
+ export function resolveTooltipDuration(
57
+ duration: number | undefined,
58
+ { isScreenReaderEnabled }: { isScreenReaderEnabled: boolean }
59
+ ): number {
60
+ if (isScreenReaderEnabled) return 0;
61
+ if (duration === undefined || !Number.isFinite(duration) || duration < 0) return TOOLTIP_DURATION;
62
+ return duration;
63
+ }
64
+
65
+ /**
66
+ * Whether a gesture on the trigger toggles the tooltip.
67
+ *
68
+ * Only the gesture `openOn` names does. With a screen reader on, a long press
69
+ * never opens it: the tooltip's words already reached the trigger as its label
70
+ * or hint, and VoiceOver's double-tap-and-hold is an action, not a request to
71
+ * see a label that is hidden from it anyway. A press tooltip still opens, for a
72
+ * partially sighted user who reads the screen as well as hearing it.
73
+ */
74
+ export function shouldTooltipActivate({
75
+ openOn,
76
+ gesture,
77
+ isScreenReaderEnabled,
78
+ }: {
79
+ openOn: TooltipOpenOn;
80
+ gesture: TooltipGesture;
81
+ isScreenReaderEnabled: boolean;
82
+ }): boolean {
83
+ if (gesture !== openOn) return false;
84
+ return !(isScreenReaderEnabled && gesture === "longPress");
85
+ }
86
+
87
+ /**
88
+ * The text a screen reader would name a control by when it has no
89
+ * `accessibilityLabel` — every string and number in its children, at any depth.
90
+ *
91
+ * `resolveTooltipAccessibility` needs it: a `<Button>Sync now</Button>` already
92
+ * has a name, and treating it as unnamed would replace "Sync now" with the
93
+ * tooltip's words instead of adding them as a hint.
94
+ */
95
+ export function readableTextOf(node: ReactNode): string | undefined {
96
+ const parts: string[] = [];
97
+ const visit = (child: ReactNode): void => {
98
+ if (typeof child === "string" || typeof child === "number") {
99
+ parts.push(String(child));
100
+ return;
101
+ }
102
+ if (isValidElement<{ children?: ReactNode }>(child)) Children.forEach(child.props.children, visit);
103
+ };
104
+ Children.forEach(node, visit);
105
+ const text = parts.join("").trim();
106
+ return text === "" ? undefined : text;
107
+ }
108
+
109
+ export type TooltipAccessibility = { accessibilityLabel?: string; accessibilityHint?: string };
110
+
111
+ /**
112
+ * What the trigger tells assistive technology, so VoiceOver says the
113
+ * tooltip's words without anything opening.
114
+ *
115
+ * A trigger with no label of its own — an icon button — is named by the
116
+ * tooltip. One that has a label keeps it, and the tooltip becomes its hint;
117
+ * unless the two say the same thing, which would only be read twice.
118
+ */
119
+ export function resolveTooltipAccessibility({
120
+ label,
121
+ triggerLabel,
122
+ }: {
123
+ label?: string;
124
+ triggerLabel?: string;
125
+ }): TooltipAccessibility {
126
+ if (label === undefined || label === "") return {};
127
+ if (triggerLabel === undefined || triggerLabel === "") return { accessibilityLabel: label };
128
+ if (triggerLabel === label) return {};
129
+ return { accessibilityHint: label };
130
+ }
131
+
132
+ /**
133
+ * Styling for every part of a tooltip.
134
+ *
135
+ * `inverted` is the default: the foreground colour as the fill and the
136
+ * background colour as the ink, so it reads over anything in either theme,
137
+ * with a small corner and padding sized for one caption-sized line. `surface`
138
+ * is the popover card — fill, hairline and card corner — with room for a title
139
+ * over a description.
140
+ *
141
+ * The arrow is drawn by Popover's `AnchoredArrow` with its own paint stripped,
142
+ * so the slot here carries all of it: the inverted chip has no border, so its
143
+ * arrow is fill alone; the surface arrow wears the border on its two outer
144
+ * edges, the ones that show past the panel.
145
+ */
146
+ export const tooltipVariants = tv({
147
+ slots: {
148
+ content: "",
149
+ arrow: "",
150
+ text: "",
151
+ title: "",
152
+ description: "",
153
+ },
154
+ variants: {
155
+ variant: {
156
+ inverted: {
157
+ content: "gap-0.5 rounded-md bg-foreground px-2 py-1",
158
+ arrow: "bg-foreground",
159
+ text: "text-background",
160
+ title: "text-background",
161
+ description: "text-background/80",
162
+ },
163
+ surface: {
164
+ content: "gap-1 rounded-lg border border-border bg-popover px-3 py-2",
165
+ arrow: "border-b border-r border-border bg-popover",
166
+ text: "text-popover-foreground",
167
+ title: "text-popover-foreground",
168
+ description: "text-muted-foreground",
169
+ },
170
+ },
171
+ },
172
+ defaultVariants: {
173
+ variant: "inverted",
174
+ },
175
+ });
176
+
177
+ export type TooltipVariantProps = VariantProps<typeof tooltipVariants>;