panelui-native 0.25.0 → 0.28.1

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 (193) hide show
  1. package/lib/module/components/accordion/index.js +8 -8
  2. package/lib/module/components/accordion/index.js.map +1 -1
  3. package/lib/module/components/alert/index.js +2 -2
  4. package/lib/module/components/alert/index.js.map +1 -1
  5. package/lib/module/components/area-chart/index.js +182 -50
  6. package/lib/module/components/area-chart/index.js.map +1 -1
  7. package/lib/module/components/attachment/index.js +2 -1
  8. package/lib/module/components/attachment/index.js.map +1 -1
  9. package/lib/module/components/avatar/index.js +3 -3
  10. package/lib/module/components/avatar/index.js.map +1 -1
  11. package/lib/module/components/badge/index.js +4 -4
  12. package/lib/module/components/badge/index.js.map +1 -1
  13. package/lib/module/components/bar-chart/index.js +197 -56
  14. package/lib/module/components/bar-chart/index.js.map +1 -1
  15. package/lib/module/components/bottom-sheet/index.js +15 -10
  16. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  17. package/lib/module/components/breadcrumb/index.js +6 -6
  18. package/lib/module/components/breadcrumb/index.js.map +1 -1
  19. package/lib/module/components/button/index.js +11 -6
  20. package/lib/module/components/button/index.js.map +1 -1
  21. package/lib/module/components/calendar/index.js +557 -0
  22. package/lib/module/components/calendar/index.js.map +1 -0
  23. package/lib/module/components/carousel/index.js +680 -0
  24. package/lib/module/components/carousel/index.js.map +1 -0
  25. package/lib/module/components/chip/index.js +4 -4
  26. package/lib/module/components/chip/index.js.map +1 -1
  27. package/lib/module/components/date-picker/index.js +158 -0
  28. package/lib/module/components/date-picker/index.js.map +1 -0
  29. package/lib/module/components/dialog/index.js +7 -2
  30. package/lib/module/components/dialog/index.js.map +1 -1
  31. package/lib/module/components/direction/index.js +2 -1
  32. package/lib/module/components/direction/index.js.map +1 -1
  33. package/lib/module/components/empty-state/index.js +2 -2
  34. package/lib/module/components/empty-state/index.js.map +1 -1
  35. package/lib/module/components/field/index.js +5 -5
  36. package/lib/module/components/field/index.js.map +1 -1
  37. package/lib/module/components/flow/index.js +3 -3
  38. package/lib/module/components/flow/index.js.map +1 -1
  39. package/lib/module/components/frame/index.js +5 -5
  40. package/lib/module/components/frame/index.js.map +1 -1
  41. package/lib/module/components/input-group/index.js +3 -2
  42. package/lib/module/components/input-group/index.js.map +1 -1
  43. package/lib/module/components/item/index.js +9 -9
  44. package/lib/module/components/item/index.js.map +1 -1
  45. package/lib/module/components/label/index.js +4 -4
  46. package/lib/module/components/label/index.js.map +1 -1
  47. package/lib/module/components/line-chart/index.js +167 -44
  48. package/lib/module/components/line-chart/index.js.map +1 -1
  49. package/lib/module/components/map/index.js +7 -7
  50. package/lib/module/components/map/index.js.map +1 -1
  51. package/lib/module/components/marker/index.js +3 -3
  52. package/lib/module/components/marker/index.js.map +1 -1
  53. package/lib/module/components/message/index.js +7 -7
  54. package/lib/module/components/message/index.js.map +1 -1
  55. package/lib/module/components/message-scroller/index.js +5 -4
  56. package/lib/module/components/message-scroller/index.js.map +1 -1
  57. package/lib/module/components/popover/index.js +39 -7
  58. package/lib/module/components/popover/index.js.map +1 -1
  59. package/lib/module/components/radio-group/index.js +2 -2
  60. package/lib/module/components/radio-group/index.js.map +1 -1
  61. package/lib/module/components/section-rail/index.js +4 -4
  62. package/lib/module/components/section-rail/index.js.map +1 -1
  63. package/lib/module/components/select/index.js +10 -4
  64. package/lib/module/components/select/index.js.map +1 -1
  65. package/lib/module/components/steps/index.js +4 -4
  66. package/lib/module/components/steps/index.js.map +1 -1
  67. package/lib/module/components/tabs/index.js +7 -7
  68. package/lib/module/components/tabs/index.js.map +1 -1
  69. package/lib/module/components/timeline/index.js +4 -4
  70. package/lib/module/components/timeline/index.js.map +1 -1
  71. package/lib/module/components/toast/index.js +2 -2
  72. package/lib/module/components/toast/index.js.map +1 -1
  73. package/lib/module/components/toggle-button/index.js +5 -5
  74. package/lib/module/components/toggle-button/index.js.map +1 -1
  75. package/lib/module/components/tooltip/index.js +492 -0
  76. package/lib/module/components/tooltip/index.js.map +1 -0
  77. package/lib/module/components/typography/index.js +2 -2
  78. package/lib/module/components/typography/index.js.map +1 -1
  79. package/lib/module/hooks/index.js +1 -0
  80. package/lib/module/hooks/index.js.map +1 -1
  81. package/lib/module/hooks/use-back-handler.js +37 -0
  82. package/lib/module/hooks/use-back-handler.js.map +1 -0
  83. package/lib/module/icons/index.js +63 -0
  84. package/lib/module/icons/index.js.map +1 -1
  85. package/lib/module/index.js +5 -1
  86. package/lib/module/index.js.map +1 -1
  87. package/lib/module/primitives/keyboard-avoider.js +2 -1
  88. package/lib/module/primitives/keyboard-avoider.js.map +1 -1
  89. package/lib/module/primitives/text.js +25 -1
  90. package/lib/module/primitives/text.js.map +1 -1
  91. package/lib/module/utils/date.js +386 -0
  92. package/lib/module/utils/date.js.map +1 -0
  93. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  94. package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
  95. package/lib/typescript/src/components/area-chart/index.d.ts +41 -1
  96. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  97. package/lib/typescript/src/components/attachment/index.d.ts.map +1 -1
  98. package/lib/typescript/src/components/bar-chart/index.d.ts +44 -2
  99. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
  100. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  101. package/lib/typescript/src/components/breadcrumb/index.d.ts.map +1 -1
  102. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  103. package/lib/typescript/src/components/calendar/index.d.ts +108 -0
  104. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -0
  105. package/lib/typescript/src/components/carousel/index.d.ts +179 -0
  106. package/lib/typescript/src/components/carousel/index.d.ts.map +1 -0
  107. package/lib/typescript/src/components/chip/index.d.ts.map +1 -1
  108. package/lib/typescript/src/components/date-picker/index.d.ts +91 -0
  109. package/lib/typescript/src/components/date-picker/index.d.ts.map +1 -0
  110. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  111. package/lib/typescript/src/components/direction/index.d.ts.map +1 -1
  112. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
  113. package/lib/typescript/src/components/field/index.d.ts.map +1 -1
  114. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  115. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
  116. package/lib/typescript/src/components/item/index.d.ts.map +1 -1
  117. package/lib/typescript/src/components/label/index.d.ts.map +1 -1
  118. package/lib/typescript/src/components/line-chart/index.d.ts +41 -2
  119. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  120. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  121. package/lib/typescript/src/components/marker/index.d.ts.map +1 -1
  122. package/lib/typescript/src/components/message/index.d.ts.map +1 -1
  123. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -1
  124. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  125. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  126. package/lib/typescript/src/components/steps/index.d.ts.map +1 -1
  127. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  128. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  129. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  130. package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -1
  131. package/lib/typescript/src/components/tooltip/index.d.ts +125 -0
  132. package/lib/typescript/src/components/tooltip/index.d.ts.map +1 -0
  133. package/lib/typescript/src/components/typography/index.d.ts.map +1 -1
  134. package/lib/typescript/src/hooks/index.d.ts +1 -0
  135. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  136. package/lib/typescript/src/hooks/use-back-handler.d.ts +19 -0
  137. package/lib/typescript/src/hooks/use-back-handler.d.ts.map +1 -0
  138. package/lib/typescript/src/icons/index.d.ts +3 -0
  139. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  140. package/lib/typescript/src/index.d.ts +8 -4
  141. package/lib/typescript/src/index.d.ts.map +1 -1
  142. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
  143. package/lib/typescript/src/primitives/text.d.ts +20 -0
  144. package/lib/typescript/src/primitives/text.d.ts.map +1 -1
  145. package/lib/typescript/src/utils/date.d.ts +116 -0
  146. package/lib/typescript/src/utils/date.d.ts.map +1 -0
  147. package/package.json +1 -1
  148. package/src/components/accordion/index.tsx +8 -12
  149. package/src/components/alert/index.tsx +2 -2
  150. package/src/components/area-chart/index.tsx +208 -55
  151. package/src/components/attachment/index.tsx +2 -2
  152. package/src/components/avatar/index.tsx +3 -3
  153. package/src/components/badge/index.tsx +6 -6
  154. package/src/components/bar-chart/index.tsx +214 -54
  155. package/src/components/bottom-sheet/index.tsx +17 -16
  156. package/src/components/breadcrumb/index.tsx +6 -8
  157. package/src/components/button/index.tsx +12 -8
  158. package/src/components/calendar/index.tsx +703 -0
  159. package/src/components/carousel/index.tsx +788 -0
  160. package/src/components/chip/index.tsx +4 -6
  161. package/src/components/date-picker/index.tsx +237 -0
  162. package/src/components/dialog/index.tsx +7 -2
  163. package/src/components/direction/index.tsx +2 -1
  164. package/src/components/empty-state/index.tsx +2 -2
  165. package/src/components/field/index.tsx +3 -5
  166. package/src/components/flow/index.tsx +3 -3
  167. package/src/components/frame/index.tsx +5 -7
  168. package/src/components/input-group/index.tsx +3 -2
  169. package/src/components/item/index.tsx +9 -9
  170. package/src/components/label/index.tsx +2 -2
  171. package/src/components/line-chart/index.tsx +189 -50
  172. package/src/components/map/index.tsx +7 -9
  173. package/src/components/marker/index.tsx +3 -3
  174. package/src/components/message/index.tsx +7 -7
  175. package/src/components/message-scroller/index.tsx +5 -4
  176. package/src/components/popover/index.tsx +43 -7
  177. package/src/components/radio-group/index.tsx +2 -2
  178. package/src/components/section-rail/index.tsx +4 -4
  179. package/src/components/select/index.tsx +10 -4
  180. package/src/components/steps/index.tsx +4 -4
  181. package/src/components/tabs/index.tsx +7 -9
  182. package/src/components/timeline/index.tsx +4 -4
  183. package/src/components/toast/index.tsx +2 -2
  184. package/src/components/toggle-button/index.tsx +5 -7
  185. package/src/components/tooltip/index.tsx +610 -0
  186. package/src/components/typography/index.tsx +2 -2
  187. package/src/hooks/index.ts +1 -0
  188. package/src/hooks/use-back-handler.ts +37 -0
  189. package/src/icons/index.tsx +45 -0
  190. package/src/index.ts +45 -0
  191. package/src/primitives/keyboard-avoider.tsx +2 -1
  192. package/src/primitives/text.tsx +28 -1
  193. package/src/utils/date.ts +446 -0
@@ -0,0 +1,610 @@
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 rather than
7
+ * surface-coloured, why it is not dismissible with a scrim, and why it closes
8
+ * after a beat instead of waiting to be told.
9
+ *
10
+ * On touch there is no hover to open it, so the gesture is a long press by
11
+ * default — the platform's own "tell me more" gesture — with `openOn="press"`
12
+ * for the cases where a tap should reveal it instead.
13
+ *
14
+ * ```tsx
15
+ * <Tooltip label="Copy link">
16
+ * <Tooltip.Trigger>
17
+ * <IconButton icon={<LinkIcon />} />
18
+ * </Tooltip.Trigger>
19
+ * <Tooltip.Content>
20
+ * <Tooltip.Arrow />
21
+ * Copy link
22
+ * </Tooltip.Content>
23
+ * </Tooltip>
24
+ * ```
25
+ *
26
+ * Placement is a preference, not a promise: the trigger is measured when it is
27
+ * pressed, the label measures itself on its first layout, and the two are
28
+ * reconciled against the safe area — so `placement="top"` means *above, if
29
+ * above fits*, and a trigger near the top edge shows its tooltip below instead.
30
+ */
31
+ import {
32
+ Children,
33
+ cloneElement,
34
+ createContext,
35
+ isValidElement,
36
+ useCallback,
37
+ useContext,
38
+ useEffect,
39
+ useMemo,
40
+ useRef,
41
+ useState,
42
+ type ReactElement,
43
+ type ReactNode,
44
+ } from 'react';
45
+ import {
46
+ Pressable,
47
+ useWindowDimensions,
48
+ View,
49
+ type LayoutChangeEvent,
50
+ type ViewProps,
51
+ } from 'react-native';
52
+ import Animated, {
53
+ FadeOut,
54
+ useAnimatedStyle,
55
+ useReducedMotion,
56
+ useSharedValue,
57
+ withSpring,
58
+ withTiming,
59
+ } from 'react-native-reanimated';
60
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
61
+ import { Portal } from '../../primitives/portal';
62
+ import { Text, type TextProps } from '../../primitives/text';
63
+ import { cn } from '../../utils/cn';
64
+
65
+ /** Gap between the trigger and the label. */
66
+ const DEFAULT_OFFSET = 6;
67
+ /** Smallest gap allowed between the label and the edge of the safe area. */
68
+ const SCREEN_MARGIN = 12;
69
+ /** Side of the arrow square before it is rotated 45°. */
70
+ const ARROW_SIZE = 10;
71
+ /** How long the label stays up before hiding itself, in milliseconds. */
72
+ const DEFAULT_DURATION = 1500;
73
+
74
+ export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';
75
+ export type TooltipAlign = 'start' | 'center' | 'end';
76
+
77
+ interface TriggerRect {
78
+ x: number;
79
+ y: number;
80
+ width: number;
81
+ height: number;
82
+ }
83
+
84
+ interface TooltipContextValue {
85
+ open: boolean;
86
+ show: () => void;
87
+ hide: () => void;
88
+ trigger: TriggerRect | null;
89
+ setTrigger: (rect: TriggerRect | null) => void;
90
+ /** Resolved placement, published by Content so Arrow knows which way to point. */
91
+ placement: TooltipPlacement;
92
+ setPlacement: (placement: TooltipPlacement) => void;
93
+ /** Trigger centre along the cross axis, relative to the label origin. */
94
+ arrowOffset: number;
95
+ setArrowOffset: (offset: number) => void;
96
+ /** How the trigger opens the tooltip. */
97
+ openOn: TooltipOpenOn;
98
+ /** Accessibility label carried onto the trigger. */
99
+ label?: string;
100
+ }
101
+
102
+ export type TooltipOpenOn = 'longPress' | 'press';
103
+
104
+ const TooltipContext = createContext<TooltipContextValue | null>(null);
105
+
106
+ function useTooltip(component: string): TooltipContextValue {
107
+ const context = useContext(TooltipContext);
108
+ if (!context) {
109
+ throw new Error(`${component} must be used within a <Tooltip>`);
110
+ }
111
+ return context;
112
+ }
113
+
114
+ export interface TooltipProps {
115
+ children: ReactNode;
116
+ /** Controlled open state. */
117
+ open?: boolean;
118
+ onOpenChange?: (open: boolean) => void;
119
+ /** Initial state when uncontrolled. */
120
+ defaultOpen?: boolean;
121
+ /**
122
+ * Whether a long press or a plain press reveals the label. Long press is the
123
+ * default because it does not steal a tappable control's own press.
124
+ */
125
+ openOn?: TooltipOpenOn;
126
+ /**
127
+ * How long the label stays up before hiding itself, in milliseconds. `0`
128
+ * keeps it up until it is dismissed by a tap outside or the trigger again.
129
+ */
130
+ duration?: number;
131
+ /**
132
+ * The label's text, mirrored onto the trigger as its accessibility label so
133
+ * a screen reader announces what the tooltip says without opening it. Set it
134
+ * whenever the trigger has no text of its own — an icon-only button.
135
+ */
136
+ label?: string;
137
+ }
138
+
139
+ function TooltipRoot({
140
+ children,
141
+ open,
142
+ onOpenChange,
143
+ defaultOpen = false,
144
+ openOn = 'longPress',
145
+ duration = DEFAULT_DURATION,
146
+ label,
147
+ }: TooltipProps) {
148
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
149
+ const [trigger, setTrigger] = useState<TriggerRect | null>(null);
150
+ const [placement, setPlacement] = useState<TooltipPlacement>('top');
151
+ const [arrowOffset, setArrowOffset] = useState(0);
152
+
153
+ const isControlled = open !== undefined;
154
+ const resolvedOpen = isControlled ? open : internalOpen;
155
+
156
+ const setOpen = useCallback(
157
+ (next: boolean) => {
158
+ if (!isControlled) setInternalOpen(next);
159
+ onOpenChange?.(next);
160
+ },
161
+ [isControlled, onOpenChange]
162
+ );
163
+
164
+ const show = useCallback(() => setOpen(true), [setOpen]);
165
+ const hide = useCallback(() => setOpen(false), [setOpen]);
166
+
167
+ // A tooltip is not a thing you deal with, so it hides itself after a beat.
168
+ // The timer is armed on open and cleared on close, and `duration` of 0 opts
169
+ // out — for a tooltip that stays until the next tap.
170
+ useEffect(() => {
171
+ if (!resolvedOpen || duration <= 0) return;
172
+ const timer = setTimeout(() => setOpen(false), duration);
173
+ return () => clearTimeout(timer);
174
+ }, [resolvedOpen, duration, setOpen]);
175
+
176
+ const context = useMemo(
177
+ () => ({
178
+ open: resolvedOpen,
179
+ show,
180
+ hide,
181
+ trigger,
182
+ setTrigger,
183
+ placement,
184
+ setPlacement,
185
+ arrowOffset,
186
+ setArrowOffset,
187
+ openOn,
188
+ label,
189
+ }),
190
+ [resolvedOpen, show, hide, trigger, placement, arrowOffset, openOn, label]
191
+ );
192
+
193
+ return <TooltipContext.Provider value={context}>{children}</TooltipContext.Provider>;
194
+ }
195
+
196
+ export interface TooltipTriggerProps {
197
+ /**
198
+ * Classes on the wrapper the child is measured through. It shrinks to the
199
+ * child by default; widen it only if the label should be anchored to
200
+ * something bigger than the control.
201
+ */
202
+ className?: string;
203
+ children: ReactElement<{
204
+ onPress?: (...args: unknown[]) => void;
205
+ onLongPress?: (...args: unknown[]) => void;
206
+ accessibilityLabel?: string;
207
+ accessibilityHint?: string;
208
+ }>;
209
+ }
210
+
211
+ /**
212
+ * Wraps its child and reveals the label on it — a long press by default, a
213
+ * press when the root asks for one. It is also what gets measured, so the
214
+ * label knows where to sit.
215
+ *
216
+ * The child is wrapped in a view rather than handed a ref: the ref has to
217
+ * survive whatever the child is — a button, a plain Pressable, an icon — and
218
+ * only a wrapper we own is guaranteed to be measurable.
219
+ *
220
+ * That wrapper shrinks to its child on purpose. A view stretches to its parent
221
+ * by default, and a wrapper that filled the row would be measured as the whole
222
+ * row — putting a centred label over the middle of the screen rather than over
223
+ * the control it names.
224
+ */
225
+ function TooltipTrigger({ className, children }: TooltipTriggerProps) {
226
+ const { open, show, hide, setTrigger, openOn, label } = useTooltip('Tooltip.Trigger');
227
+ const ref = useRef<View>(null);
228
+
229
+ const measureThenShow = () => {
230
+ // Measured on every open rather than on layout: the trigger may have
231
+ // scrolled since it was laid out, and a stale rect anchors the label to
232
+ // where the trigger used to be.
233
+ ref.current?.measureInWindow((x, y, width, height) => {
234
+ setTrigger({ x, y, width, height });
235
+ show();
236
+ });
237
+ };
238
+
239
+ const handleLongPress = (...args: unknown[]) => {
240
+ if (isValidElement(children)) children.props.onLongPress?.(...args);
241
+ if (openOn === 'longPress') measureThenShow();
242
+ };
243
+
244
+ const handlePress = (...args: unknown[]) => {
245
+ if (isValidElement(children)) children.props.onPress?.(...args);
246
+ if (openOn !== 'press') return;
247
+ if (open) hide();
248
+ else measureThenShow();
249
+ };
250
+
251
+ // A trigger that is not an element has nothing to clone handlers onto, so it
252
+ // is passed through — wrapped, if it is bare text, since a string cannot be
253
+ // a child of a view.
254
+ if (!isValidElement(children)) {
255
+ return typeof children === 'string' || typeof children === 'number' ? (
256
+ <Text>{children}</Text>
257
+ ) : (
258
+ <>{children}</>
259
+ );
260
+ }
261
+
262
+ return (
263
+ <View ref={ref} collapsable={false} className={cn('self-start', className)}>
264
+ {cloneElement(children, {
265
+ onPress: handlePress,
266
+ onLongPress: handleLongPress,
267
+ // An icon-only trigger has nothing for a screen reader to read; the
268
+ // label fills that in without the sighted user having to open it.
269
+ accessibilityLabel: children.props.accessibilityLabel ?? label,
270
+ })}
271
+ </View>
272
+ );
273
+ }
274
+
275
+ export interface TooltipContentProps extends ViewProps {
276
+ className?: string;
277
+ /** Preferred side of the trigger. Flipped when that side does not fit. */
278
+ placement?: TooltipPlacement;
279
+ /** Where the label sits along the trigger's other axis. */
280
+ align?: TooltipAlign;
281
+ /** Gap between the trigger and the label, in pixels. */
282
+ offset?: number;
283
+ /** Nudge along the alignment axis, in pixels. */
284
+ alignOffset?: number;
285
+ children?: ReactNode;
286
+ }
287
+
288
+ function TooltipContent({
289
+ className,
290
+ placement = 'top',
291
+ align = 'center',
292
+ offset = DEFAULT_OFFSET,
293
+ alignOffset = 0,
294
+ children,
295
+ onLayout: onLayoutProp,
296
+ style,
297
+ ...props
298
+ }: TooltipContentProps) {
299
+ const context = useTooltip('Tooltip.Content');
300
+ const { open, hide, trigger, setPlacement, setArrowOffset } = context;
301
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
302
+ const insets = useSafeAreaInsets();
303
+ const [size, setSize] = useState<{ width: number; height: number } | null>(null);
304
+
305
+ // Label size changes with its text, so it is re-measured rather than measured
306
+ // once — a tooltip whose text is swapped should not stay the old size.
307
+ const onLayout = (event: LayoutChangeEvent) => {
308
+ const { width: w, height: h } = event.nativeEvent.layout;
309
+ setSize((current) =>
310
+ current && Math.abs(current.width - w) < 1 && Math.abs(current.height - h) < 1
311
+ ? current
312
+ : { width: w, height: h }
313
+ );
314
+ onLayoutProp?.(event);
315
+ };
316
+
317
+ /*
318
+ * Forgetting the size on close is what makes the second open behave like the
319
+ * first. Held, it would place the reopened label using the last label's
320
+ * dimensions for a frame — and, because the entrance is driven off whether a
321
+ * position exists at all, a size that never went away means a tooltip that
322
+ * animates in once and afterwards just appears.
323
+ */
324
+ useEffect(() => {
325
+ if (!open) setSize(null);
326
+ }, [open]);
327
+
328
+ const bounds = {
329
+ left: insets.left + SCREEN_MARGIN,
330
+ right: screenWidth - insets.right - SCREEN_MARGIN,
331
+ top: insets.top + SCREEN_MARGIN,
332
+ bottom: screenHeight - insets.bottom - SCREEN_MARGIN,
333
+ };
334
+
335
+ const position =
336
+ trigger && size
337
+ ? place({ trigger, size, placement, align, offset, alignOffset, bounds })
338
+ : null;
339
+
340
+ // Publish the side actually used and where the trigger centre landed, so the
341
+ // arrow points at the trigger even after a flip or a clamp.
342
+ const resolvedPlacement = position?.placement;
343
+ const resolvedArrow = position?.arrowOffset;
344
+ useEffect(() => {
345
+ if (resolvedPlacement) setPlacement(resolvedPlacement);
346
+ if (resolvedArrow !== undefined) setArrowOffset(resolvedArrow);
347
+ }, [resolvedPlacement, resolvedArrow, setPlacement, setArrowOffset]);
348
+
349
+ /*
350
+ * The entrance is driven by hand rather than by an `entering` preset, and the
351
+ * reason is the measuring frame. A layout animation fires on mount — which
352
+ * here is the frame *before* the label knows where it goes, so the whole
353
+ * animation would play at the origin, invisibly, and the label would then
354
+ * snap into place fully formed. Holding the values until a position exists is
355
+ * the only way to have both the animation and the correct position.
356
+ */
357
+ const appear = useSharedValue(0);
358
+ const settle = useSharedValue(0);
359
+ const reducedMotion = useReducedMotion();
360
+ const placed = !!position;
361
+
362
+ useEffect(() => {
363
+ if (!placed) {
364
+ appear.value = 0;
365
+ settle.value = 0;
366
+ return;
367
+ }
368
+ if (reducedMotion) {
369
+ appear.value = 1;
370
+ settle.value = 1;
371
+ return;
372
+ }
373
+ appear.value = withTiming(1, { duration: 100 });
374
+ settle.value = withSpring(1, { damping: 20, stiffness: 300, mass: 0.5 });
375
+ }, [placed, reducedMotion, appear, settle]);
376
+
377
+ // Starts slightly small and shifted towards the trigger, so the label
378
+ // appears to unfold from it rather than fade in over it.
379
+ const origin = ENTRY_SHIFT[resolvedPlacement ?? placement];
380
+ const labelStyle = useAnimatedStyle(() => ({
381
+ opacity: appear.value,
382
+ transform: [
383
+ { translateX: origin.x * (1 - settle.value) },
384
+ { translateY: origin.y * (1 - settle.value) },
385
+ { scale: 0.9 + 0.1 * settle.value },
386
+ ],
387
+ }));
388
+
389
+ if (!open || !trigger) return null;
390
+
391
+ return (
392
+ <Portal>
393
+ {/* Portal content mounts under PortalHost, outside this provider's
394
+ subtree — re-provide the context so Tooltip.Arrow keeps working. */}
395
+ <TooltipContext.Provider value={context}>
396
+ <View className="absolute inset-0">
397
+ {/* No scrim — a tooltip does not dim the screen. The transparent
398
+ catcher only lets a tap anywhere dismiss the label. */}
399
+ <Pressable
400
+ accessibilityLabel="Dismiss"
401
+ className="absolute inset-0"
402
+ onPress={hide}
403
+ />
404
+ {/*
405
+ Two views, not one: a Reanimated rule forbids a layout animation and
406
+ an animated style driving the same property on one component, or the
407
+ layout animation silently wins. The exit fade and `labelStyle`'s
408
+ opacity both want it — so the outer view owns the position and the
409
+ exit, the inner one the entrance and the label's own surface.
410
+ */}
411
+ <Animated.View
412
+ exiting={FadeOut.duration(100)}
413
+ onLayout={onLayout}
414
+ style={{
415
+ position: 'absolute',
416
+ pointerEvents: 'box-none',
417
+ // Until it has measured itself the label has no honest position,
418
+ // so it is laid out off-screen rather than at the origin.
419
+ top: position?.top ?? -9999,
420
+ left: position?.left ?? -9999,
421
+ maxWidth: bounds.right - bounds.left,
422
+ }}
423
+ >
424
+ <Animated.View
425
+ accessibilityRole="text"
426
+ style={[labelStyle, style]}
427
+ className={cn(
428
+ 'rounded-lg bg-foreground px-2.5 py-1.5 shadow-md',
429
+ className
430
+ )}
431
+ {...props}
432
+ >
433
+ {/*
434
+ Wrapped one child at a time, not all-or-nothing. The label is
435
+ almost always written as an arrow followed by its text, which
436
+ makes `children` an array — so a check against the whole of it
437
+ is never a string, and the text underneath would reach this
438
+ view bare. Only the text nodes need the treatment; an element
439
+ is already responsible for itself.
440
+ */}
441
+ {Children.map(children, (child) =>
442
+ typeof child === 'string' || typeof child === 'number' ? (
443
+ <TooltipText>{child}</TooltipText>
444
+ ) : (
445
+ child
446
+ )
447
+ )}
448
+ </Animated.View>
449
+ </Animated.View>
450
+ </View>
451
+ </TooltipContext.Provider>
452
+ </Portal>
453
+ );
454
+ }
455
+
456
+ /** The label's default text: small and inverted against the dark surface. */
457
+ const TooltipText = ({ className, ...props }: TextProps) => (
458
+ <Text size="sm" weight="medium" className={cn('text-background', className)} {...props} />
459
+ );
460
+ TooltipText.displayName = 'Tooltip.Text';
461
+
462
+ export interface TooltipArrowProps extends ViewProps {
463
+ className?: string;
464
+ }
465
+
466
+ /**
467
+ * A small square rotated into a diamond, half-buried under the label so only
468
+ * the point shows. It shares the label's fill and points at the trigger's
469
+ * centre, which Content resolves and publishes — so when `align` shifts the
470
+ * label off-centre, or a clamp slides it back on screen, the arrow stays over
471
+ * the trigger rather than over the label's middle.
472
+ */
473
+ function TooltipArrow({ className, style, ...props }: TooltipArrowProps) {
474
+ const { trigger, placement, arrowOffset } = useTooltip('Tooltip.Arrow');
475
+ if (!trigger) return null;
476
+
477
+ const vertical = placement === 'top' || placement === 'bottom';
478
+ // The arrow sits on the edge facing the trigger, the opposite edge to the
479
+ // placement: a label placed above the trigger has its arrow on the bottom.
480
+ const edge = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[placement];
481
+
482
+ return (
483
+ <View
484
+ accessibilityElementsHidden
485
+ importantForAccessibility="no-hide-descendants"
486
+ style={[
487
+ {
488
+ position: 'absolute',
489
+ pointerEvents: 'none',
490
+ [edge]: -ARROW_SIZE / 2,
491
+ width: ARROW_SIZE,
492
+ height: ARROW_SIZE,
493
+ transform: [{ rotate: '45deg' }],
494
+ // Centred on the trigger along the cross axis. `marginLeft/Top` backs
495
+ // it off by half its own size so `arrowOffset` lands on its centre.
496
+ ...(vertical
497
+ ? { left: arrowOffset, marginLeft: -ARROW_SIZE / 2 }
498
+ : { top: arrowOffset, marginTop: -ARROW_SIZE / 2 }),
499
+ },
500
+ style,
501
+ ]}
502
+ className={cn('rounded-[1px] bg-foreground', className)}
503
+ {...props}
504
+ />
505
+ );
506
+ }
507
+
508
+ /* -------------------------------------------------------------------------- */
509
+ /* Placement */
510
+ /* -------------------------------------------------------------------------- */
511
+
512
+ interface PlaceArgs {
513
+ trigger: TriggerRect;
514
+ size: { width: number; height: number };
515
+ placement: TooltipPlacement;
516
+ align: TooltipAlign;
517
+ offset: number;
518
+ alignOffset: number;
519
+ bounds: { left: number; right: number; top: number; bottom: number };
520
+ }
521
+
522
+ /**
523
+ * Resolves the label's window position.
524
+ *
525
+ * Two passes, in this order: the flip picks a *side* and only fires when the
526
+ * preferred one genuinely has less room than its opposite; the clamp then
527
+ * slides the label along the other axis to keep it on screen. Doing the clamp
528
+ * first would let a label be nudged inside the bounds and so look like it fits,
529
+ * hiding the fact that the wrong side was chosen.
530
+ */
531
+ function place({ trigger, size, placement, align, offset, alignOffset, bounds }: PlaceArgs) {
532
+ const roomAfter = {
533
+ bottom: bounds.bottom - (trigger.y + trigger.height + offset),
534
+ top: trigger.y - offset - bounds.top,
535
+ right: bounds.right - (trigger.x + trigger.width + offset),
536
+ left: trigger.x - offset - bounds.left,
537
+ };
538
+ const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' } as const;
539
+
540
+ const needed = placement === 'top' || placement === 'bottom' ? size.height : size.width;
541
+ const resolved =
542
+ roomAfter[placement] < needed && roomAfter[opposite[placement]] > roomAfter[placement]
543
+ ? opposite[placement]
544
+ : placement;
545
+
546
+ const clamp = (value: number, min: number, max: number) =>
547
+ Math.max(min, Math.min(value, Math.max(min, max)));
548
+
549
+ if (resolved === 'top' || resolved === 'bottom') {
550
+ const top =
551
+ resolved === 'bottom'
552
+ ? trigger.y + trigger.height + offset
553
+ : trigger.y - size.height - offset;
554
+
555
+ const left =
556
+ align === 'start'
557
+ ? trigger.x + alignOffset
558
+ : align === 'end'
559
+ ? trigger.x + trigger.width - size.width + alignOffset
560
+ : trigger.x + trigger.width / 2 - size.width / 2 + alignOffset;
561
+
562
+ const clampedLeft = clamp(left, bounds.left, bounds.right - size.width);
563
+ const triggerCentreX = trigger.x + trigger.width / 2;
564
+
565
+ return {
566
+ placement: resolved,
567
+ top: clamp(top, bounds.top, bounds.bottom - size.height),
568
+ left: clampedLeft,
569
+ arrowOffset: clamp(triggerCentreX - clampedLeft, ARROW_SIZE, size.width - ARROW_SIZE),
570
+ };
571
+ }
572
+
573
+ const left =
574
+ resolved === 'right' ? trigger.x + trigger.width + offset : trigger.x - size.width - offset;
575
+
576
+ const top =
577
+ align === 'start'
578
+ ? trigger.y + alignOffset
579
+ : align === 'end'
580
+ ? trigger.y + trigger.height - size.height + alignOffset
581
+ : trigger.y + trigger.height / 2 - size.height / 2 + alignOffset;
582
+
583
+ const clampedTop = clamp(top, bounds.top, bounds.bottom - size.height);
584
+ const triggerCentreY = trigger.y + trigger.height / 2;
585
+
586
+ return {
587
+ placement: resolved,
588
+ top: clampedTop,
589
+ left: clamp(left, bounds.left, bounds.right - size.width),
590
+ arrowOffset: clamp(triggerCentreY - clampedTop, ARROW_SIZE, size.height - ARROW_SIZE),
591
+ };
592
+ }
593
+
594
+ /**
595
+ * Where the label starts, relative to where it ends: towards the trigger, on
596
+ * whichever side was resolved, so it appears to grow out of the control.
597
+ */
598
+ const ENTRY_SHIFT: Record<TooltipPlacement, { x: number; y: number }> = {
599
+ bottom: { x: 0, y: -6 },
600
+ top: { x: 0, y: 6 },
601
+ right: { x: -6, y: 0 },
602
+ left: { x: 6, y: 0 },
603
+ };
604
+
605
+ export const Tooltip = Object.assign(TooltipRoot, {
606
+ Trigger: TooltipTrigger,
607
+ Content: TooltipContent,
608
+ Arrow: TooltipArrow,
609
+ Text: TooltipText,
610
+ });
@@ -25,7 +25,7 @@
25
25
  import { Children, forwardRef, type ReactNode } from 'react';
26
26
  import { View, type Text as RNText, type ViewProps } from 'react-native';
27
27
  import { tv, type VariantProps } from 'tailwind-variants';
28
- import { Text, type TextProps } from '../../primitives/text';
28
+ import { Text, type TextProps, textChildren } from '../../primitives/text';
29
29
  import { cn } from '../../utils/cn';
30
30
 
31
31
  const typographyVariants = tv({
@@ -300,7 +300,7 @@ const TypographyList = forwardRef<View, TypographyListProps>(
300
300
  // platform font, and a view does not.
301
301
  <View className="mt-2.5 size-1.5 shrink-0 rounded-full bg-muted-foreground" />
302
302
  )}
303
- <View className="flex-1">{child}</View>
303
+ <View className="flex-1">{textChildren(child)}</View>
304
304
  </View>
305
305
  ))}
306
306
  </View>
@@ -31,5 +31,6 @@ export {
31
31
  type UseScrollSectionsOptions,
32
32
  type UseScrollSectionsResult,
33
33
  } from './use-scroll-sections';
34
+ export { useBackHandler } from './use-back-handler';
34
35
  export { useDebouncedValue } from './use-debounced-value';
35
36
  export { usePrevious } from './use-previous';
@@ -0,0 +1,37 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { BackHandler, Platform } from 'react-native';
3
+
4
+ /**
5
+ * Runs a handler when the Android hardware back button is pressed.
6
+ *
7
+ * An open overlay owns the back button while it is up: pressing back should
8
+ * dismiss the overlay, not pop the screen behind it. Without this, the back
9
+ * gesture navigates away and the overlay is orphaned — the same mistake as a
10
+ * modal that stays put when you tap outside it.
11
+ *
12
+ * The listener is Android-only and gated on `enabled`, so an overlay wires it
13
+ * to `open && dismissible` and nothing runs while the overlay is closed or
14
+ * pinned. Returning `true` from the handler tells the platform the press was
15
+ * consumed, which is what stops the navigation.
16
+ *
17
+ * ```tsx
18
+ * useBackHandler(open && dismissible, () => setOpen(false));
19
+ * ```
20
+ */
21
+ export function useBackHandler(enabled: boolean, onBack: () => void): void {
22
+ // Held in a ref so a caller passing an inline `() => setOpen(false)` does not
23
+ // resubscribe the listener on every render — only a change in `enabled` does.
24
+ const onBackRef = useRef(onBack);
25
+ onBackRef.current = onBack;
26
+
27
+ useEffect(() => {
28
+ if (Platform.OS !== 'android' || !enabled) return;
29
+
30
+ const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
31
+ onBackRef.current();
32
+ return true;
33
+ });
34
+
35
+ return () => subscription.remove();
36
+ }, [enabled]);
37
+ }