@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 +4 -3
- package/src/components/tooltip/AGENTS.md +133 -0
- package/src/components/tooltip/index.ts +28 -0
- package/src/components/tooltip/tooltip-arrow.tsx +40 -0
- package/src/components/tooltip/tooltip-content.tsx +179 -0
- package/src/components/tooltip/tooltip-description.tsx +19 -0
- package/src/components/tooltip/tooltip-text.tsx +21 -0
- package/src/components/tooltip/tooltip-title.tsx +20 -0
- package/src/components/tooltip/tooltip-trigger.tsx +103 -0
- package/src/components/tooltip/tooltip.context.tsx +68 -0
- package/src/components/tooltip/tooltip.tsx +213 -0
- package/src/components/tooltip/tooltip.variants.test.ts +231 -0
- package/src/components/tooltip/tooltip.variants.ts +177 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
110
|
-
"@delacour/react-native-charts": "0.1.0-alpha.
|
|
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>;
|