panelui-native 0.25.0 → 0.30.0

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.
Files changed (242) hide show
  1. package/README.md +18 -1
  2. package/lib/module/components/accordion/index.js +8 -8
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/alert/index.js +2 -2
  5. package/lib/module/components/alert/index.js.map +1 -1
  6. package/lib/module/components/area-chart/index.js +182 -50
  7. package/lib/module/components/area-chart/index.js.map +1 -1
  8. package/lib/module/components/attachment/index.js +2 -1
  9. package/lib/module/components/attachment/index.js.map +1 -1
  10. package/lib/module/components/avatar/index.js +3 -3
  11. package/lib/module/components/avatar/index.js.map +1 -1
  12. package/lib/module/components/badge/index.js +4 -4
  13. package/lib/module/components/badge/index.js.map +1 -1
  14. package/lib/module/components/bar-chart/index.js +197 -56
  15. package/lib/module/components/bar-chart/index.js.map +1 -1
  16. package/lib/module/components/bottom-sheet/index.js +15 -10
  17. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  18. package/lib/module/components/breadcrumb/index.js +6 -6
  19. package/lib/module/components/breadcrumb/index.js.map +1 -1
  20. package/lib/module/components/button/index.js +11 -6
  21. package/lib/module/components/button/index.js.map +1 -1
  22. package/lib/module/components/calendar/index.js +792 -0
  23. package/lib/module/components/calendar/index.js.map +1 -0
  24. package/lib/module/components/carousel/index.js +680 -0
  25. package/lib/module/components/carousel/index.js.map +1 -0
  26. package/lib/module/components/chip/index.js +4 -4
  27. package/lib/module/components/chip/index.js.map +1 -1
  28. package/lib/module/components/code-block/highlight.js +363 -0
  29. package/lib/module/components/code-block/highlight.js.map +1 -0
  30. package/lib/module/components/code-block/index.js +313 -0
  31. package/lib/module/components/code-block/index.js.map +1 -0
  32. package/lib/module/components/date-picker/index.js +165 -0
  33. package/lib/module/components/date-picker/index.js.map +1 -0
  34. package/lib/module/components/dialog/index.js +7 -2
  35. package/lib/module/components/dialog/index.js.map +1 -1
  36. package/lib/module/components/direction/index.js +2 -1
  37. package/lib/module/components/direction/index.js.map +1 -1
  38. package/lib/module/components/empty-state/index.js +2 -2
  39. package/lib/module/components/empty-state/index.js.map +1 -1
  40. package/lib/module/components/field/index.js +5 -5
  41. package/lib/module/components/field/index.js.map +1 -1
  42. package/lib/module/components/flow/index.js +3 -3
  43. package/lib/module/components/flow/index.js.map +1 -1
  44. package/lib/module/components/frame/index.js +5 -5
  45. package/lib/module/components/frame/index.js.map +1 -1
  46. package/lib/module/components/input-group/index.js +3 -2
  47. package/lib/module/components/input-group/index.js.map +1 -1
  48. package/lib/module/components/item/index.js +9 -9
  49. package/lib/module/components/item/index.js.map +1 -1
  50. package/lib/module/components/label/index.js +4 -4
  51. package/lib/module/components/label/index.js.map +1 -1
  52. package/lib/module/components/line-chart/index.js +167 -44
  53. package/lib/module/components/line-chart/index.js.map +1 -1
  54. package/lib/module/components/map/index.js +7 -7
  55. package/lib/module/components/map/index.js.map +1 -1
  56. package/lib/module/components/marker/index.js +3 -3
  57. package/lib/module/components/marker/index.js.map +1 -1
  58. package/lib/module/components/menu/index.js +714 -0
  59. package/lib/module/components/menu/index.js.map +1 -0
  60. package/lib/module/components/message/index.js +7 -7
  61. package/lib/module/components/message/index.js.map +1 -1
  62. package/lib/module/components/message-scroller/index.js +5 -4
  63. package/lib/module/components/message-scroller/index.js.map +1 -1
  64. package/lib/module/components/plan/index.js +296 -0
  65. package/lib/module/components/plan/index.js.map +1 -0
  66. package/lib/module/components/popover/index.js +73 -10
  67. package/lib/module/components/popover/index.js.map +1 -1
  68. package/lib/module/components/radio-group/index.js +15 -10
  69. package/lib/module/components/radio-group/index.js.map +1 -1
  70. package/lib/module/components/reasoning/index.js +244 -0
  71. package/lib/module/components/reasoning/index.js.map +1 -0
  72. package/lib/module/components/section-rail/index.js +4 -4
  73. package/lib/module/components/section-rail/index.js.map +1 -1
  74. package/lib/module/components/select/index.js +10 -4
  75. package/lib/module/components/select/index.js.map +1 -1
  76. package/lib/module/components/sources/index.js +221 -0
  77. package/lib/module/components/sources/index.js.map +1 -0
  78. package/lib/module/components/steps/index.js +4 -4
  79. package/lib/module/components/steps/index.js.map +1 -1
  80. package/lib/module/components/tabs/index.js +7 -7
  81. package/lib/module/components/tabs/index.js.map +1 -1
  82. package/lib/module/components/task/index.js +300 -0
  83. package/lib/module/components/task/index.js.map +1 -0
  84. package/lib/module/components/textarea/index.js +295 -0
  85. package/lib/module/components/textarea/index.js.map +1 -0
  86. package/lib/module/components/timeline/index.js +4 -4
  87. package/lib/module/components/timeline/index.js.map +1 -1
  88. package/lib/module/components/toast/index.js +2 -2
  89. package/lib/module/components/toast/index.js.map +1 -1
  90. package/lib/module/components/toggle-button/index.js +5 -5
  91. package/lib/module/components/toggle-button/index.js.map +1 -1
  92. package/lib/module/components/tooltip/index.js +648 -0
  93. package/lib/module/components/tooltip/index.js.map +1 -0
  94. package/lib/module/components/typography/index.js +2 -2
  95. package/lib/module/components/typography/index.js.map +1 -1
  96. package/lib/module/hooks/index.js +1 -0
  97. package/lib/module/hooks/index.js.map +1 -1
  98. package/lib/module/hooks/use-back-handler.js +37 -0
  99. package/lib/module/hooks/use-back-handler.js.map +1 -0
  100. package/lib/module/icons/index.js +202 -0
  101. package/lib/module/icons/index.js.map +1 -1
  102. package/lib/module/index.js +13 -1
  103. package/lib/module/index.js.map +1 -1
  104. package/lib/module/primitives/collapse.js +112 -0
  105. package/lib/module/primitives/collapse.js.map +1 -0
  106. package/lib/module/primitives/keyboard-avoider.js +2 -1
  107. package/lib/module/primitives/keyboard-avoider.js.map +1 -1
  108. package/lib/module/primitives/text.js +25 -1
  109. package/lib/module/primitives/text.js.map +1 -1
  110. package/lib/module/utils/date.js +386 -0
  111. package/lib/module/utils/date.js.map +1 -0
  112. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  113. package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
  114. package/lib/typescript/src/components/area-chart/index.d.ts +41 -1
  115. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  116. package/lib/typescript/src/components/attachment/index.d.ts.map +1 -1
  117. package/lib/typescript/src/components/bar-chart/index.d.ts +44 -2
  118. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
  119. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  120. package/lib/typescript/src/components/breadcrumb/index.d.ts.map +1 -1
  121. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  122. package/lib/typescript/src/components/calendar/index.d.ts +173 -0
  123. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -0
  124. package/lib/typescript/src/components/carousel/index.d.ts +179 -0
  125. package/lib/typescript/src/components/carousel/index.d.ts.map +1 -0
  126. package/lib/typescript/src/components/chip/index.d.ts.map +1 -1
  127. package/lib/typescript/src/components/code-block/highlight.d.ts +39 -0
  128. package/lib/typescript/src/components/code-block/highlight.d.ts.map +1 -0
  129. package/lib/typescript/src/components/code-block/index.d.ts +113 -0
  130. package/lib/typescript/src/components/code-block/index.d.ts.map +1 -0
  131. package/lib/typescript/src/components/date-picker/index.d.ts +97 -0
  132. package/lib/typescript/src/components/date-picker/index.d.ts.map +1 -0
  133. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  134. package/lib/typescript/src/components/direction/index.d.ts.map +1 -1
  135. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
  136. package/lib/typescript/src/components/field/index.d.ts.map +1 -1
  137. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  138. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
  139. package/lib/typescript/src/components/item/index.d.ts.map +1 -1
  140. package/lib/typescript/src/components/label/index.d.ts.map +1 -1
  141. package/lib/typescript/src/components/line-chart/index.d.ts +41 -2
  142. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  143. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  144. package/lib/typescript/src/components/marker/index.d.ts.map +1 -1
  145. package/lib/typescript/src/components/menu/index.d.ts +261 -0
  146. package/lib/typescript/src/components/menu/index.d.ts.map +1 -0
  147. package/lib/typescript/src/components/message/index.d.ts.map +1 -1
  148. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -1
  149. package/lib/typescript/src/components/plan/index.d.ts +127 -0
  150. package/lib/typescript/src/components/plan/index.d.ts.map +1 -0
  151. package/lib/typescript/src/components/popover/index.d.ts +35 -1
  152. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  153. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  154. package/lib/typescript/src/components/reasoning/index.d.ts +97 -0
  155. package/lib/typescript/src/components/reasoning/index.d.ts.map +1 -0
  156. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  157. package/lib/typescript/src/components/sources/index.d.ts +85 -0
  158. package/lib/typescript/src/components/sources/index.d.ts.map +1 -0
  159. package/lib/typescript/src/components/steps/index.d.ts.map +1 -1
  160. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  161. package/lib/typescript/src/components/task/index.d.ts +103 -0
  162. package/lib/typescript/src/components/task/index.d.ts.map +1 -0
  163. package/lib/typescript/src/components/textarea/index.d.ts +175 -0
  164. package/lib/typescript/src/components/textarea/index.d.ts.map +1 -0
  165. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  166. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  167. package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -1
  168. package/lib/typescript/src/components/tooltip/index.d.ts +180 -0
  169. package/lib/typescript/src/components/tooltip/index.d.ts.map +1 -0
  170. package/lib/typescript/src/components/typography/index.d.ts.map +1 -1
  171. package/lib/typescript/src/hooks/index.d.ts +1 -0
  172. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  173. package/lib/typescript/src/hooks/use-back-handler.d.ts +19 -0
  174. package/lib/typescript/src/hooks/use-back-handler.d.ts.map +1 -0
  175. package/lib/typescript/src/icons/index.d.ts +17 -0
  176. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  177. package/lib/typescript/src/index.d.ts +16 -4
  178. package/lib/typescript/src/index.d.ts.map +1 -1
  179. package/lib/typescript/src/primitives/collapse.d.ts +42 -0
  180. package/lib/typescript/src/primitives/collapse.d.ts.map +1 -0
  181. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
  182. package/lib/typescript/src/primitives/text.d.ts +20 -0
  183. package/lib/typescript/src/primitives/text.d.ts.map +1 -1
  184. package/lib/typescript/src/utils/date.d.ts +116 -0
  185. package/lib/typescript/src/utils/date.d.ts.map +1 -0
  186. package/package.json +1 -1
  187. package/src/components/accordion/index.tsx +8 -12
  188. package/src/components/alert/index.tsx +2 -2
  189. package/src/components/area-chart/index.tsx +208 -55
  190. package/src/components/attachment/index.tsx +2 -2
  191. package/src/components/avatar/index.tsx +3 -3
  192. package/src/components/badge/index.tsx +6 -6
  193. package/src/components/bar-chart/index.tsx +214 -54
  194. package/src/components/bottom-sheet/index.tsx +17 -16
  195. package/src/components/breadcrumb/index.tsx +6 -8
  196. package/src/components/button/index.tsx +12 -8
  197. package/src/components/calendar/index.tsx +970 -0
  198. package/src/components/carousel/index.tsx +788 -0
  199. package/src/components/chip/index.tsx +4 -6
  200. package/src/components/code-block/highlight.ts +278 -0
  201. package/src/components/code-block/index.tsx +341 -0
  202. package/src/components/date-picker/index.tsx +255 -0
  203. package/src/components/dialog/index.tsx +7 -2
  204. package/src/components/direction/index.tsx +2 -1
  205. package/src/components/empty-state/index.tsx +2 -2
  206. package/src/components/field/index.tsx +3 -5
  207. package/src/components/flow/index.tsx +3 -3
  208. package/src/components/frame/index.tsx +5 -7
  209. package/src/components/input-group/index.tsx +3 -2
  210. package/src/components/item/index.tsx +9 -9
  211. package/src/components/label/index.tsx +2 -2
  212. package/src/components/line-chart/index.tsx +189 -50
  213. package/src/components/map/index.tsx +7 -9
  214. package/src/components/marker/index.tsx +3 -3
  215. package/src/components/menu/index.tsx +812 -0
  216. package/src/components/message/index.tsx +7 -7
  217. package/src/components/message-scroller/index.tsx +5 -4
  218. package/src/components/plan/index.tsx +325 -0
  219. package/src/components/popover/index.tsx +111 -8
  220. package/src/components/radio-group/index.tsx +13 -4
  221. package/src/components/reasoning/index.tsx +297 -0
  222. package/src/components/section-rail/index.tsx +4 -4
  223. package/src/components/select/index.tsx +10 -4
  224. package/src/components/sources/index.tsx +278 -0
  225. package/src/components/steps/index.tsx +4 -4
  226. package/src/components/tabs/index.tsx +7 -9
  227. package/src/components/task/index.tsx +329 -0
  228. package/src/components/textarea/index.tsx +381 -0
  229. package/src/components/timeline/index.tsx +4 -4
  230. package/src/components/toast/index.tsx +2 -2
  231. package/src/components/toggle-button/index.tsx +5 -7
  232. package/src/components/tooltip/index.tsx +793 -0
  233. package/src/components/typography/index.tsx +2 -2
  234. package/src/hooks/index.ts +1 -0
  235. package/src/hooks/use-back-handler.ts +37 -0
  236. package/src/icons/index.tsx +147 -0
  237. package/src/index.ts +116 -0
  238. package/src/primitives/collapse.tsx +128 -0
  239. package/src/primitives/keyboard-avoider.tsx +2 -1
  240. package/src/primitives/text.tsx +28 -1
  241. package/src/utils/date.ts +446 -0
  242. package/theme.css +112 -0
@@ -0,0 +1,793 @@
1
+ /**
2
+ * Tooltip — a small label that names the thing under your finger.
3
+ *
4
+ * A popover is a panel you open and deal with; a tooltip is a whisper. It
5
+ * carries a word or two about what a control does, appears without taking the
6
+ * screen, and goes away on its own. That is why it is inverted by default, why
7
+ * it is not dismissible with a scrim, and why it closes after a beat instead of
8
+ * waiting to be told.
9
+ *
10
+ * The inversion is a default rather than a rule, because the whisper has a
11
+ * larger sibling: a tooltip that carries a heading and a sentence stops reading
12
+ * as a different layer and starts reading as a panel with the wrong colours.
13
+ * `variant="surface"` makes it one, and the sizing props — `width`,
14
+ * `minWidth`, `maxHeight`, `scrollable` — are what let it hold that much.
15
+ *
16
+ * On touch there is no hover to open it, so the gesture is a long press by
17
+ * default — the platform's own "tell me more" gesture — with `openOn="press"`
18
+ * for the cases where a tap should reveal it instead.
19
+ *
20
+ * ```tsx
21
+ * <Tooltip label="Copy link">
22
+ * <Tooltip.Trigger>
23
+ * <IconButton icon={<LinkIcon />} />
24
+ * </Tooltip.Trigger>
25
+ * <Tooltip.Content>
26
+ * <Tooltip.Arrow />
27
+ * Copy link
28
+ * </Tooltip.Content>
29
+ * </Tooltip>
30
+ * ```
31
+ *
32
+ * Placement is a preference, not a promise: the trigger is measured when it is
33
+ * pressed, the label measures itself on its first layout, and the two are
34
+ * reconciled against the safe area — so `placement="top"` means *above, if
35
+ * above fits*, and a trigger near the top edge shows its tooltip below instead.
36
+ */
37
+ import {
38
+ Children,
39
+ cloneElement,
40
+ createContext,
41
+ isValidElement,
42
+ useCallback,
43
+ useContext,
44
+ useEffect,
45
+ useMemo,
46
+ useRef,
47
+ useState,
48
+ type ReactElement,
49
+ type ReactNode,
50
+ } from 'react';
51
+ import {
52
+ Pressable,
53
+ ScrollView,
54
+ useWindowDimensions,
55
+ View,
56
+ type LayoutChangeEvent,
57
+ type ViewProps,
58
+ } from 'react-native';
59
+ import Animated, {
60
+ FadeOut,
61
+ useAnimatedStyle,
62
+ useReducedMotion,
63
+ useSharedValue,
64
+ withSpring,
65
+ withTiming,
66
+ } from 'react-native-reanimated';
67
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
68
+ import { tv } from 'tailwind-variants';
69
+ import { Portal } from '../../primitives/portal';
70
+ import { Text, type TextProps } from '../../primitives/text';
71
+ import { cn } from '../../utils/cn';
72
+
73
+ /** Gap between the trigger and the label. */
74
+ const DEFAULT_OFFSET = 6;
75
+ /** Smallest gap allowed between the label and the edge of the safe area. */
76
+ const SCREEN_MARGIN = 12;
77
+ /** Side of the arrow square before it is rotated 45°. */
78
+ const ARROW_SIZE = 10;
79
+ /** How long the label stays up before hiding itself, in milliseconds. */
80
+ const DEFAULT_DURATION = 1500;
81
+
82
+ export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';
83
+ export type TooltipAlign = 'start' | 'center' | 'end';
84
+ export type TooltipVariant = 'inverted' | 'surface';
85
+
86
+ /**
87
+ * Every colour the tooltip has, in one place.
88
+ *
89
+ * They were three literals at three call sites — the panel, the arrow and the
90
+ * default text — which meant retheming a tooltip took three `className`
91
+ * overrides that each had to be kept in step with the others. As a variant it
92
+ * is one prop, and the arrow and the text read it off the context rather than
93
+ * being told again.
94
+ */
95
+ const tooltipVariants = tv({
96
+ slots: {
97
+ content: 'rounded-lg px-2.5 py-1.5 shadow-md',
98
+ text: 'text-sm font-medium',
99
+ title: 'text-sm font-semibold',
100
+ description: 'text-sm',
101
+ arrow: 'rounded-[1px]',
102
+ },
103
+ variants: {
104
+ variant: {
105
+ /*
106
+ * The default, and deliberately not a surface colour: a whisper over the
107
+ * page should read as a different layer rather than as another panel of
108
+ * it. It is the treatment a one-line label wants.
109
+ */
110
+ inverted: {
111
+ content: 'bg-foreground',
112
+ text: 'text-background',
113
+ title: 'text-background',
114
+ description: 'text-background/70',
115
+ arrow: 'bg-foreground',
116
+ },
117
+ /*
118
+ * For a tooltip carrying more than a label — a heading, a sentence, a row
119
+ * of controls. At that size the inversion stops reading as a whisper and
120
+ * starts reading as a panel with the wrong colours, so it becomes one.
121
+ */
122
+ surface: {
123
+ content: 'border border-border bg-popover',
124
+ text: 'text-popover-foreground',
125
+ title: 'text-popover-foreground',
126
+ description: 'text-muted-foreground',
127
+ arrow: 'border-border bg-popover',
128
+ },
129
+ },
130
+ },
131
+ defaultVariants: {
132
+ variant: 'inverted',
133
+ },
134
+ });
135
+
136
+ interface TriggerRect {
137
+ x: number;
138
+ y: number;
139
+ width: number;
140
+ height: number;
141
+ }
142
+
143
+ interface TooltipContextValue {
144
+ open: boolean;
145
+ show: () => void;
146
+ hide: () => void;
147
+ trigger: TriggerRect | null;
148
+ setTrigger: (rect: TriggerRect | null) => void;
149
+ /** Resolved placement, published by Content so Arrow knows which way to point. */
150
+ placement: TooltipPlacement;
151
+ setPlacement: (placement: TooltipPlacement) => void;
152
+ /** Trigger centre along the cross axis, relative to the label origin. */
153
+ arrowOffset: number;
154
+ setArrowOffset: (offset: number) => void;
155
+ /** How the trigger opens the tooltip. */
156
+ openOn: TooltipOpenOn;
157
+ /** Accessibility label carried onto the trigger. */
158
+ label?: string;
159
+ /** Which set of colours the panel, arrow and text draw from. */
160
+ variant: TooltipVariant;
161
+ setVariant: (variant: TooltipVariant) => void;
162
+ }
163
+
164
+ export type TooltipOpenOn = 'longPress' | 'press';
165
+
166
+ const TooltipContext = createContext<TooltipContextValue | null>(null);
167
+
168
+ function useTooltip(component: string): TooltipContextValue {
169
+ const context = useContext(TooltipContext);
170
+ if (!context) {
171
+ throw new Error(`${component} must be used within a <Tooltip>`);
172
+ }
173
+ return context;
174
+ }
175
+
176
+ export interface TooltipProps {
177
+ children: ReactNode;
178
+ /** Controlled open state. */
179
+ open?: boolean;
180
+ onOpenChange?: (open: boolean) => void;
181
+ /** Initial state when uncontrolled. */
182
+ defaultOpen?: boolean;
183
+ /**
184
+ * Whether a long press or a plain press reveals the label. Long press is the
185
+ * default because it does not steal a tappable control's own press.
186
+ */
187
+ openOn?: TooltipOpenOn;
188
+ /**
189
+ * How long the label stays up before hiding itself, in milliseconds. `0`
190
+ * keeps it up until it is dismissed by a tap outside or the trigger again.
191
+ */
192
+ duration?: number;
193
+ /**
194
+ * The label's text, mirrored onto the trigger as its accessibility label so
195
+ * a screen reader announces what the tooltip says without opening it. Set it
196
+ * whenever the trigger has no text of its own — an icon-only button.
197
+ */
198
+ label?: string;
199
+ }
200
+
201
+ function TooltipRoot({
202
+ children,
203
+ open,
204
+ onOpenChange,
205
+ defaultOpen = false,
206
+ openOn = 'longPress',
207
+ duration = DEFAULT_DURATION,
208
+ label,
209
+ }: TooltipProps) {
210
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
211
+ const [trigger, setTrigger] = useState<TriggerRect | null>(null);
212
+ const [placement, setPlacement] = useState<TooltipPlacement>('top');
213
+ const [arrowOffset, setArrowOffset] = useState(0);
214
+ /*
215
+ * Published by Content rather than passed to it, because the arrow and the
216
+ * text sit beside the panel in the tree and would otherwise each have to be
217
+ * told which colours to use.
218
+ */
219
+ const [variant, setVariant] = useState<TooltipVariant>('inverted');
220
+
221
+ const isControlled = open !== undefined;
222
+ const resolvedOpen = isControlled ? open : internalOpen;
223
+
224
+ const setOpen = useCallback(
225
+ (next: boolean) => {
226
+ if (!isControlled) setInternalOpen(next);
227
+ onOpenChange?.(next);
228
+ },
229
+ [isControlled, onOpenChange]
230
+ );
231
+
232
+ const show = useCallback(() => setOpen(true), [setOpen]);
233
+ const hide = useCallback(() => setOpen(false), [setOpen]);
234
+
235
+ // A tooltip is not a thing you deal with, so it hides itself after a beat.
236
+ // The timer is armed on open and cleared on close, and `duration` of 0 opts
237
+ // out — for a tooltip that stays until the next tap.
238
+ useEffect(() => {
239
+ if (!resolvedOpen || duration <= 0) return;
240
+ const timer = setTimeout(() => setOpen(false), duration);
241
+ return () => clearTimeout(timer);
242
+ }, [resolvedOpen, duration, setOpen]);
243
+
244
+ const context = useMemo(
245
+ () => ({
246
+ open: resolvedOpen,
247
+ show,
248
+ hide,
249
+ trigger,
250
+ setTrigger,
251
+ placement,
252
+ setPlacement,
253
+ arrowOffset,
254
+ setArrowOffset,
255
+ openOn,
256
+ label,
257
+ variant,
258
+ setVariant,
259
+ }),
260
+ [resolvedOpen, show, hide, trigger, placement, arrowOffset, openOn, label, variant]
261
+ );
262
+
263
+ return <TooltipContext.Provider value={context}>{children}</TooltipContext.Provider>;
264
+ }
265
+
266
+ export interface TooltipTriggerProps {
267
+ /**
268
+ * Classes on the wrapper the child is measured through. It shrinks to the
269
+ * child by default; widen it only if the label should be anchored to
270
+ * something bigger than the control.
271
+ */
272
+ className?: string;
273
+ children: ReactElement<{
274
+ onPress?: (...args: unknown[]) => void;
275
+ onLongPress?: (...args: unknown[]) => void;
276
+ accessibilityLabel?: string;
277
+ accessibilityHint?: string;
278
+ }>;
279
+ }
280
+
281
+ /**
282
+ * Wraps its child and reveals the label on it — a long press by default, a
283
+ * press when the root asks for one. It is also what gets measured, so the
284
+ * label knows where to sit.
285
+ *
286
+ * The child is wrapped in a view rather than handed a ref: the ref has to
287
+ * survive whatever the child is — a button, a plain Pressable, an icon — and
288
+ * only a wrapper we own is guaranteed to be measurable.
289
+ *
290
+ * That wrapper shrinks to its child on purpose. A view stretches to its parent
291
+ * by default, and a wrapper that filled the row would be measured as the whole
292
+ * row — putting a centred label over the middle of the screen rather than over
293
+ * the control it names.
294
+ */
295
+ function TooltipTrigger({ className, children }: TooltipTriggerProps) {
296
+ const { open, show, hide, setTrigger, openOn, label } = useTooltip('Tooltip.Trigger');
297
+ const ref = useRef<View>(null);
298
+
299
+ const measureThenShow = () => {
300
+ // Measured on every open rather than on layout: the trigger may have
301
+ // scrolled since it was laid out, and a stale rect anchors the label to
302
+ // where the trigger used to be.
303
+ ref.current?.measureInWindow((x, y, width, height) => {
304
+ setTrigger({ x, y, width, height });
305
+ show();
306
+ });
307
+ };
308
+
309
+ const handleLongPress = (...args: unknown[]) => {
310
+ if (isValidElement(children)) children.props.onLongPress?.(...args);
311
+ if (openOn === 'longPress') measureThenShow();
312
+ };
313
+
314
+ const handlePress = (...args: unknown[]) => {
315
+ if (isValidElement(children)) children.props.onPress?.(...args);
316
+ if (openOn !== 'press') return;
317
+ if (open) hide();
318
+ else measureThenShow();
319
+ };
320
+
321
+ // A trigger that is not an element has nothing to clone handlers onto, so it
322
+ // is passed through — wrapped, if it is bare text, since a string cannot be
323
+ // a child of a view.
324
+ if (!isValidElement(children)) {
325
+ return typeof children === 'string' || typeof children === 'number' ? (
326
+ <Text>{children}</Text>
327
+ ) : (
328
+ <>{children}</>
329
+ );
330
+ }
331
+
332
+ return (
333
+ <View ref={ref} collapsable={false} className={cn('self-start', className)}>
334
+ {cloneElement(children, {
335
+ onPress: handlePress,
336
+ onLongPress: handleLongPress,
337
+ // An icon-only trigger has nothing for a screen reader to read; the
338
+ // label fills that in without the sighted user having to open it.
339
+ accessibilityLabel: children.props.accessibilityLabel ?? label,
340
+ })}
341
+ </View>
342
+ );
343
+ }
344
+
345
+ export interface TooltipContentProps extends ViewProps {
346
+ className?: string;
347
+ /** Preferred side of the trigger. Flipped when that side does not fit. */
348
+ placement?: TooltipPlacement;
349
+ /** Where the label sits along the trigger's other axis. */
350
+ align?: TooltipAlign;
351
+ /** Gap between the trigger and the label, in pixels. */
352
+ offset?: number;
353
+ /** Nudge along the alignment axis, in pixels. */
354
+ alignOffset?: number;
355
+ /**
356
+ * Which set of colours the panel, its arrow and its text draw from.
357
+ *
358
+ * `inverted` is the default and right for a label: a whisper over the page
359
+ * should read as a different layer rather than as another panel of it.
360
+ * `surface` matches the popover — reach for it once the tooltip carries a
361
+ * heading and a sentence, where the inversion stops reading as a whisper.
362
+ */
363
+ variant?: TooltipVariant;
364
+ /**
365
+ * `content-fit` sizes to the content, `trigger` matches the trigger's width,
366
+ * `full` spans the safe area, and a number is that many pixels. Worth setting
367
+ * for anything longer than a label, which would otherwise run to whatever
368
+ * width the sentence happens to want.
369
+ */
370
+ width?: number | 'trigger' | 'full' | 'content-fit';
371
+ /** Floor for the panel's width, in pixels. */
372
+ minWidth?: number;
373
+ /**
374
+ * Ceiling for the panel's height, in pixels. Always clamped to the room
375
+ * inside the safe area, which is also the default.
376
+ */
377
+ maxHeight?: number;
378
+ /**
379
+ * Scroll the body when it is taller than `maxHeight`. Off by default — a
380
+ * label has nothing to scroll, and a scroller around one only adds a bounce.
381
+ */
382
+ scrollable?: boolean;
383
+ children?: ReactNode;
384
+ }
385
+
386
+ function TooltipContent({
387
+ className,
388
+ placement = 'top',
389
+ align = 'center',
390
+ offset = DEFAULT_OFFSET,
391
+ alignOffset = 0,
392
+ variant = 'inverted',
393
+ width = 'content-fit',
394
+ minWidth,
395
+ maxHeight,
396
+ scrollable = false,
397
+ children,
398
+ onLayout: onLayoutProp,
399
+ style,
400
+ ...props
401
+ }: TooltipContentProps) {
402
+ const context = useTooltip('Tooltip.Content');
403
+ const { open, hide, trigger, setPlacement, setArrowOffset, setVariant } = context;
404
+ const slots = tooltipVariants({ variant });
405
+
406
+ // The arrow and the text sit beside this panel in the tree, so the chosen
407
+ // colours are published rather than passed down.
408
+ useEffect(() => {
409
+ setVariant(variant);
410
+ }, [variant, setVariant]);
411
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
412
+ const insets = useSafeAreaInsets();
413
+ const [size, setSize] = useState<{ width: number; height: number } | null>(null);
414
+
415
+ // Label size changes with its text, so it is re-measured rather than measured
416
+ // once — a tooltip whose text is swapped should not stay the old size.
417
+ const onLayout = (event: LayoutChangeEvent) => {
418
+ const { width: w, height: h } = event.nativeEvent.layout;
419
+ setSize((current) =>
420
+ current && Math.abs(current.width - w) < 1 && Math.abs(current.height - h) < 1
421
+ ? current
422
+ : { width: w, height: h }
423
+ );
424
+ onLayoutProp?.(event);
425
+ };
426
+
427
+ /*
428
+ * Forgetting the size on close is what makes the second open behave like the
429
+ * first. Held, it would place the reopened label using the last label's
430
+ * dimensions for a frame — and, because the entrance is driven off whether a
431
+ * position exists at all, a size that never went away means a tooltip that
432
+ * animates in once and afterwards just appears.
433
+ */
434
+ useEffect(() => {
435
+ if (!open) setSize(null);
436
+ }, [open]);
437
+
438
+ const bounds = {
439
+ left: insets.left + SCREEN_MARGIN,
440
+ right: screenWidth - insets.right - SCREEN_MARGIN,
441
+ top: insets.top + SCREEN_MARGIN,
442
+ bottom: screenHeight - insets.bottom - SCREEN_MARGIN,
443
+ };
444
+
445
+ const available = bounds.right - bounds.left;
446
+ const requestedWidth =
447
+ width === 'content-fit'
448
+ ? undefined
449
+ : width === 'trigger'
450
+ ? trigger?.width
451
+ : width === 'full'
452
+ ? available
453
+ : width;
454
+
455
+ // The floor never wins past the space there actually is — a panel wider than
456
+ // the screen is worse than a cramped one.
457
+ const resolvedWidth =
458
+ requestedWidth === undefined
459
+ ? minWidth === undefined
460
+ ? undefined
461
+ : Math.min(minWidth, available)
462
+ : Math.min(Math.max(requestedWidth, minWidth ?? 0), available);
463
+
464
+ const room = bounds.bottom - bounds.top;
465
+ const resolvedMaxHeight = Math.min(maxHeight ?? room, room);
466
+
467
+ const position =
468
+ trigger && size
469
+ ? place({ trigger, size, placement, align, offset, alignOffset, bounds })
470
+ : null;
471
+
472
+ // Publish the side actually used and where the trigger centre landed, so the
473
+ // arrow points at the trigger even after a flip or a clamp.
474
+ const resolvedPlacement = position?.placement;
475
+ const resolvedArrow = position?.arrowOffset;
476
+ useEffect(() => {
477
+ if (resolvedPlacement) setPlacement(resolvedPlacement);
478
+ if (resolvedArrow !== undefined) setArrowOffset(resolvedArrow);
479
+ }, [resolvedPlacement, resolvedArrow, setPlacement, setArrowOffset]);
480
+
481
+ /*
482
+ * The entrance is driven by hand rather than by an `entering` preset, and the
483
+ * reason is the measuring frame. A layout animation fires on mount — which
484
+ * here is the frame *before* the label knows where it goes, so the whole
485
+ * animation would play at the origin, invisibly, and the label would then
486
+ * snap into place fully formed. Holding the values until a position exists is
487
+ * the only way to have both the animation and the correct position.
488
+ */
489
+ const appear = useSharedValue(0);
490
+ const settle = useSharedValue(0);
491
+ const reducedMotion = useReducedMotion();
492
+ const placed = !!position;
493
+
494
+ useEffect(() => {
495
+ if (!placed) {
496
+ appear.value = 0;
497
+ settle.value = 0;
498
+ return;
499
+ }
500
+ if (reducedMotion) {
501
+ appear.value = 1;
502
+ settle.value = 1;
503
+ return;
504
+ }
505
+ appear.value = withTiming(1, { duration: 100 });
506
+ settle.value = withSpring(1, { damping: 20, stiffness: 300, mass: 0.5 });
507
+ }, [placed, reducedMotion, appear, settle]);
508
+
509
+ // Starts slightly small and shifted towards the trigger, so the label
510
+ // appears to unfold from it rather than fade in over it.
511
+ const origin = ENTRY_SHIFT[resolvedPlacement ?? placement];
512
+ const labelStyle = useAnimatedStyle(() => ({
513
+ opacity: appear.value,
514
+ transform: [
515
+ { translateX: origin.x * (1 - settle.value) },
516
+ { translateY: origin.y * (1 - settle.value) },
517
+ { scale: 0.9 + 0.1 * settle.value },
518
+ ],
519
+ }));
520
+
521
+ if (!open || !trigger) return null;
522
+
523
+ return (
524
+ <Portal>
525
+ {/* Portal content mounts under PortalHost, outside this provider's
526
+ subtree — re-provide the context so Tooltip.Arrow keeps working. */}
527
+ <TooltipContext.Provider value={context}>
528
+ <View className="absolute inset-0">
529
+ {/* No scrim — a tooltip does not dim the screen. The transparent
530
+ catcher only lets a tap anywhere dismiss the label. */}
531
+ <Pressable
532
+ accessibilityLabel="Dismiss"
533
+ className="absolute inset-0"
534
+ onPress={hide}
535
+ />
536
+ {/*
537
+ Two views, not one: a Reanimated rule forbids a layout animation and
538
+ an animated style driving the same property on one component, or the
539
+ layout animation silently wins. The exit fade and `labelStyle`'s
540
+ opacity both want it — so the outer view owns the position and the
541
+ exit, the inner one the entrance and the label's own surface.
542
+ */}
543
+ <Animated.View
544
+ exiting={FadeOut.duration(100)}
545
+ onLayout={onLayout}
546
+ style={{
547
+ position: 'absolute',
548
+ pointerEvents: 'box-none',
549
+ // Until it has measured itself the label has no honest position,
550
+ // so it is laid out off-screen rather than at the origin.
551
+ top: position?.top ?? -9999,
552
+ left: position?.left ?? -9999,
553
+ maxWidth: available,
554
+ maxHeight: resolvedMaxHeight,
555
+ width: resolvedWidth,
556
+ }}
557
+ >
558
+ <Animated.View
559
+ // A panel with a heading and a paragraph is a group, not a run of
560
+ // text — the flat role would have a screen reader read it as one
561
+ // string with no structure.
562
+ accessibilityRole={scrollable || width !== 'content-fit' ? undefined : 'text'}
563
+ style={[labelStyle, style]}
564
+ className={cn(
565
+ slots.content(),
566
+ scrollable ? 'overflow-hidden' : undefined,
567
+ className
568
+ )}
569
+ {...props}
570
+ >
571
+ {/*
572
+ Wrapped one child at a time, not all-or-nothing. The label is
573
+ almost always written as an arrow followed by its text, which
574
+ makes `children` an array — so a check against the whole of it
575
+ is never a string, and the text underneath would reach this
576
+ view bare. Only the text nodes need the treatment; an element
577
+ is already responsible for itself.
578
+ */}
579
+ {scrollable ? (
580
+ <ScrollView bounces={false} showsVerticalScrollIndicator={false}>
581
+ {wrapTooltipText(children)}
582
+ </ScrollView>
583
+ ) : (
584
+ wrapTooltipText(children)
585
+ )}
586
+ </Animated.View>
587
+ </Animated.View>
588
+ </View>
589
+ </TooltipContext.Provider>
590
+ </Portal>
591
+ );
592
+ }
593
+
594
+ /** Bare strings become the label's default text; elements speak for themselves. */
595
+ function wrapTooltipText(children: ReactNode) {
596
+ return Children.map(children, (child) =>
597
+ typeof child === 'string' || typeof child === 'number' ? (
598
+ <TooltipText>{child}</TooltipText>
599
+ ) : (
600
+ child
601
+ )
602
+ );
603
+ }
604
+
605
+ /** The label's default text, coloured to whatever the panel is made of. */
606
+ function TooltipText({ className, ...props }: TextProps) {
607
+ const { variant } = useTooltip('Tooltip.Text');
608
+ const { text } = tooltipVariants({ variant });
609
+
610
+ return <Text className={cn(text(), className)} {...props} />;
611
+ }
612
+ TooltipText.displayName = 'Tooltip.Text';
613
+ TooltipTitle.displayName = 'Tooltip.Title';
614
+ TooltipDescription.displayName = 'Tooltip.Description';
615
+
616
+ export interface TooltipTitleProps extends TextProps {
617
+ className?: string;
618
+ }
619
+
620
+ /** A heading, for a tooltip carrying more than a label. */
621
+ function TooltipTitle({ className, ...props }: TooltipTitleProps) {
622
+ const { variant } = useTooltip('Tooltip.Title');
623
+ const { title } = tooltipVariants({ variant });
624
+
625
+ return (
626
+ <Text accessibilityRole="header" className={cn(title(), className)} {...props} />
627
+ );
628
+ }
629
+
630
+ export interface TooltipDescriptionProps extends TextProps {
631
+ className?: string;
632
+ }
633
+
634
+ /** The sentence under a `Tooltip.Title`, in the panel's secondary colour. */
635
+ function TooltipDescription({ className, ...props }: TooltipDescriptionProps) {
636
+ const { variant } = useTooltip('Tooltip.Description');
637
+ const { description } = tooltipVariants({ variant });
638
+
639
+ return <Text className={cn(description(), className)} {...props} />;
640
+ }
641
+
642
+ export interface TooltipArrowProps extends ViewProps {
643
+ className?: string;
644
+ }
645
+
646
+ /**
647
+ * A small square rotated into a diamond, half-buried under the label so only
648
+ * the point shows. It shares the label's fill and points at the trigger's
649
+ * centre, which Content resolves and publishes — so when `align` shifts the
650
+ * label off-centre, or a clamp slides it back on screen, the arrow stays over
651
+ * the trigger rather than over the label's middle.
652
+ */
653
+ function TooltipArrow({ className, style, ...props }: TooltipArrowProps) {
654
+ const { trigger, placement, arrowOffset, variant } = useTooltip('Tooltip.Arrow');
655
+ const { arrow } = tooltipVariants({ variant });
656
+ if (!trigger) return null;
657
+
658
+ const vertical = placement === 'top' || placement === 'bottom';
659
+ // The arrow sits on the edge facing the trigger, the opposite edge to the
660
+ // placement: a label placed above the trigger has its arrow on the bottom.
661
+ const edge = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[placement];
662
+
663
+ return (
664
+ <View
665
+ accessibilityElementsHidden
666
+ importantForAccessibility="no-hide-descendants"
667
+ style={[
668
+ {
669
+ position: 'absolute',
670
+ pointerEvents: 'none',
671
+ [edge]: -ARROW_SIZE / 2,
672
+ width: ARROW_SIZE,
673
+ height: ARROW_SIZE,
674
+ transform: [{ rotate: '45deg' }],
675
+ // Centred on the trigger along the cross axis. `marginLeft/Top` backs
676
+ // it off by half its own size so `arrowOffset` lands on its centre.
677
+ ...(vertical
678
+ ? { left: arrowOffset, marginLeft: -ARROW_SIZE / 2 }
679
+ : { top: arrowOffset, marginTop: -ARROW_SIZE / 2 }),
680
+ },
681
+ style,
682
+ ]}
683
+ className={cn(arrow(), className)}
684
+ {...props}
685
+ />
686
+ );
687
+ }
688
+
689
+ /* -------------------------------------------------------------------------- */
690
+ /* Placement */
691
+ /* -------------------------------------------------------------------------- */
692
+
693
+ interface PlaceArgs {
694
+ trigger: TriggerRect;
695
+ size: { width: number; height: number };
696
+ placement: TooltipPlacement;
697
+ align: TooltipAlign;
698
+ offset: number;
699
+ alignOffset: number;
700
+ bounds: { left: number; right: number; top: number; bottom: number };
701
+ }
702
+
703
+ /**
704
+ * Resolves the label's window position.
705
+ *
706
+ * Two passes, in this order: the flip picks a *side* and only fires when the
707
+ * preferred one genuinely has less room than its opposite; the clamp then
708
+ * slides the label along the other axis to keep it on screen. Doing the clamp
709
+ * first would let a label be nudged inside the bounds and so look like it fits,
710
+ * hiding the fact that the wrong side was chosen.
711
+ */
712
+ function place({ trigger, size, placement, align, offset, alignOffset, bounds }: PlaceArgs) {
713
+ const roomAfter = {
714
+ bottom: bounds.bottom - (trigger.y + trigger.height + offset),
715
+ top: trigger.y - offset - bounds.top,
716
+ right: bounds.right - (trigger.x + trigger.width + offset),
717
+ left: trigger.x - offset - bounds.left,
718
+ };
719
+ const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' } as const;
720
+
721
+ const needed = placement === 'top' || placement === 'bottom' ? size.height : size.width;
722
+ const resolved =
723
+ roomAfter[placement] < needed && roomAfter[opposite[placement]] > roomAfter[placement]
724
+ ? opposite[placement]
725
+ : placement;
726
+
727
+ const clamp = (value: number, min: number, max: number) =>
728
+ Math.max(min, Math.min(value, Math.max(min, max)));
729
+
730
+ if (resolved === 'top' || resolved === 'bottom') {
731
+ const top =
732
+ resolved === 'bottom'
733
+ ? trigger.y + trigger.height + offset
734
+ : trigger.y - size.height - offset;
735
+
736
+ const left =
737
+ align === 'start'
738
+ ? trigger.x + alignOffset
739
+ : align === 'end'
740
+ ? trigger.x + trigger.width - size.width + alignOffset
741
+ : trigger.x + trigger.width / 2 - size.width / 2 + alignOffset;
742
+
743
+ const clampedLeft = clamp(left, bounds.left, bounds.right - size.width);
744
+ const triggerCentreX = trigger.x + trigger.width / 2;
745
+
746
+ return {
747
+ placement: resolved,
748
+ top: clamp(top, bounds.top, bounds.bottom - size.height),
749
+ left: clampedLeft,
750
+ arrowOffset: clamp(triggerCentreX - clampedLeft, ARROW_SIZE, size.width - ARROW_SIZE),
751
+ };
752
+ }
753
+
754
+ const left =
755
+ resolved === 'right' ? trigger.x + trigger.width + offset : trigger.x - size.width - offset;
756
+
757
+ const top =
758
+ align === 'start'
759
+ ? trigger.y + alignOffset
760
+ : align === 'end'
761
+ ? trigger.y + trigger.height - size.height + alignOffset
762
+ : trigger.y + trigger.height / 2 - size.height / 2 + alignOffset;
763
+
764
+ const clampedTop = clamp(top, bounds.top, bounds.bottom - size.height);
765
+ const triggerCentreY = trigger.y + trigger.height / 2;
766
+
767
+ return {
768
+ placement: resolved,
769
+ top: clampedTop,
770
+ left: clamp(left, bounds.left, bounds.right - size.width),
771
+ arrowOffset: clamp(triggerCentreY - clampedTop, ARROW_SIZE, size.height - ARROW_SIZE),
772
+ };
773
+ }
774
+
775
+ /**
776
+ * Where the label starts, relative to where it ends: towards the trigger, on
777
+ * whichever side was resolved, so it appears to grow out of the control.
778
+ */
779
+ const ENTRY_SHIFT: Record<TooltipPlacement, { x: number; y: number }> = {
780
+ bottom: { x: 0, y: -6 },
781
+ top: { x: 0, y: 6 },
782
+ right: { x: -6, y: 0 },
783
+ left: { x: 6, y: 0 },
784
+ };
785
+
786
+ export const Tooltip = Object.assign(TooltipRoot, {
787
+ Trigger: TooltipTrigger,
788
+ Content: TooltipContent,
789
+ Arrow: TooltipArrow,
790
+ Title: TooltipTitle,
791
+ Description: TooltipDescription,
792
+ Text: TooltipText,
793
+ });