@vvedantb/native-ui 0.13.0 → 0.17.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 (108) hide show
  1. package/README.md +40 -19
  2. package/package.json +36 -3
  3. package/src/BottomSheetContainer.tsx +2 -2
  4. package/src/BulkActionBar.tsx +240 -105
  5. package/src/Button.tsx +11 -7
  6. package/src/ConfirmDialog.tsx +25 -35
  7. package/src/Dialog.tsx +47 -35
  8. package/src/DividedList.tsx +11 -7
  9. package/src/DropdownMenu.tsx +22 -8
  10. package/src/FloatingSurface.tsx +5 -2
  11. package/src/FloatingTabBar.tsx +13 -22
  12. package/src/HeaderIconButton.tsx +4 -3
  13. package/src/Input.tsx +2 -2
  14. package/src/LoadingState.tsx +132 -0
  15. package/src/PageTitle.tsx +9 -2
  16. package/src/Pager.tsx +62 -0
  17. package/src/PasswordInput.tsx +1 -1
  18. package/src/PillTabs.tsx +144 -34
  19. package/src/Progress.tsx +9 -7
  20. package/src/Select.tsx +4 -4
  21. package/src/Sheet.tsx +29 -22
  22. package/src/SheetRow.tsx +27 -9
  23. package/src/Skeleton.tsx +91 -9
  24. package/src/SurfaceRow.tsx +111 -0
  25. package/src/SwipeableRow.tsx +43 -0
  26. package/src/Switch.tsx +6 -4
  27. package/src/Tabs.tsx +68 -13
  28. package/src/Toast.tsx +107 -65
  29. package/src/ai/activity-step-detail.tsx +1 -3
  30. package/src/ai/activity-step-label.ts +981 -0
  31. package/src/ai/activity-step-types.ts +107 -0
  32. package/src/ai/activity-tasks-utils.ts +199 -0
  33. package/src/ai/activity-timeline.ts +45 -0
  34. package/src/ai/attachments.tsx +3 -1
  35. package/src/ai/chat-text.ts +13 -7
  36. package/src/ai/chat-thread.tsx +34 -27
  37. package/src/ai/choice-question.tsx +154 -35
  38. package/src/ai/code-block.tsx +189 -49
  39. package/src/ai/collapsible.tsx +19 -13
  40. package/src/ai/conversation.tsx +390 -57
  41. package/src/ai/icon-button.tsx +41 -6
  42. package/src/ai/index.ts +38 -1
  43. package/src/ai/message-action-sheet.tsx +4 -1
  44. package/src/ai/message.tsx +60 -31
  45. package/src/ai/prompt-input-menu.tsx +197 -0
  46. package/src/ai/prompt-input.tsx +301 -75
  47. package/src/ai/shimmer.tsx +4 -6
  48. package/src/ai/trace.tsx +54 -17
  49. package/src/ai/unified-diff.tsx +182 -0
  50. package/src/auth/GoogleSignInButton.tsx +6 -3
  51. package/src/auth/SignInScreen.tsx +11 -2
  52. package/src/brand/ProviderIcon.tsx +136 -0
  53. package/src/brand/index.ts +2 -0
  54. package/src/configureTheme.ts +39 -11
  55. package/src/controlStyles.ts +46 -30
  56. package/src/entries/gesture-handler.ts +4 -0
  57. package/src/entries/keyboard-controller.ts +4 -0
  58. package/src/entries/markdown.ts +1 -1
  59. package/src/entries/primitives.ts +24 -3
  60. package/src/entries/theme.ts +2 -1
  61. package/src/entries/video.ts +4 -0
  62. package/src/entries/webview.ts +6 -0
  63. package/src/floating.ts +25 -4
  64. package/src/haptics.ts +30 -4
  65. package/src/highlight.ts +302 -1
  66. package/src/index.ts +27 -5
  67. package/src/loadingGrid.ts +75 -0
  68. package/src/markdown/Markdown.tsx +234 -32
  69. package/src/markdown/MarkdownTable.tsx +188 -0
  70. package/src/markdown/tableText.ts +58 -0
  71. package/src/marketing/UpgradeToProCard.tsx +4 -3
  72. package/src/media/ImageGallery.tsx +224 -0
  73. package/src/media/MediaCard.tsx +100 -0
  74. package/src/media/imageGrid.ts +51 -0
  75. package/src/motion.ts +33 -0
  76. package/src/pagerSwipe.ts +102 -0
  77. package/src/palette.ts +8 -1
  78. package/src/primitives/button.tsx +4 -1
  79. package/src/primitives/keyboard-controller.tsx +106 -0
  80. package/src/primitives/keyboard-parts.tsx +104 -0
  81. package/src/primitives/overlay.tsx +133 -45
  82. package/src/primitives/pager-gesture.tsx +131 -0
  83. package/src/primitives/sheet-drag.tsx +305 -0
  84. package/src/primitives/sheet-gesture.tsx +181 -0
  85. package/src/primitives/swipe-row-gesture.tsx +290 -0
  86. package/src/primitives/toast-drag-gesture.tsx +92 -0
  87. package/src/primitives/web-click-guard.ts +50 -0
  88. package/src/relativeTime.ts +94 -0
  89. package/src/router/RouterStackPageHeader.tsx +2 -2
  90. package/src/settings/SettingsEmptyState.tsx +20 -10
  91. package/src/settings/SettingsField.tsx +11 -5
  92. package/src/settings/SettingsSection.tsx +8 -5
  93. package/src/settings/SettingsToggleRow.tsx +55 -22
  94. package/src/settings/relaxedSizeStyle.ts +11 -0
  95. package/src/sheetDrag.ts +67 -0
  96. package/src/swipeRow.ts +169 -0
  97. package/src/tabBarRoutes.ts +18 -0
  98. package/src/tabIndicator.ts +71 -0
  99. package/src/tailwind-preset.cjs +13 -6
  100. package/src/tailwind-preset.d.cts +5 -0
  101. package/src/theme.tsx +6 -2
  102. package/src/toastDrag.ts +58 -0
  103. package/src/typography.ts +20 -3
  104. package/src/useScheme.ts +10 -1
  105. package/src/video/InlineVideo.tsx +91 -0
  106. package/src/webview/HtmlView.tsx +91 -0
  107. package/src/webview/HtmlView.web.tsx +21 -0
  108. package/src/webview/htmlView.ts +45 -0
@@ -0,0 +1,290 @@
1
+ import { useContext, useRef, useState } from "react";
2
+ import { Pressable, StyleSheet, View } from "react-native";
3
+ import { Gesture, GestureDetector } from "react-native-gesture-handler";
4
+ import Animated, {
5
+ cancelAnimation,
6
+ ReduceMotion,
7
+ runOnJS,
8
+ useAnimatedReaction,
9
+ useAnimatedStyle,
10
+ useSharedValue,
11
+ withSpring,
12
+ withTiming,
13
+ type SharedValue,
14
+ } from "react-native-reanimated";
15
+ import { impactHaptic } from "../haptics";
16
+ import { InsidePagerContext } from "../Pager";
17
+ import { EASE_OUT } from "../motion";
18
+ import { palette } from "../palette";
19
+ import type { SwipeableRowProps } from "../SwipeableRow";
20
+ import {
21
+ closeSwipeRows,
22
+ isSwipeRowOpen,
23
+ markSwipeRowClosed,
24
+ markSwipeRowOpen,
25
+ SWIPE_ACTION_WIDTH,
26
+ SWIPE_FAIL_OFFSET_Y,
27
+ swipeRowActivation,
28
+ swipeRowArmed,
29
+ swipeRowOffset,
30
+ swipeRowSettle,
31
+ type SwipeAction,
32
+ type SwipeRowHandle,
33
+ type SwipeSide,
34
+ } from "../swipeRow";
35
+ import { tokens } from "../tokens";
36
+ import { useScheme } from "../useScheme";
37
+ import { useWebClickGuard } from "./web-click-guard";
38
+
39
+ // The swipeable row on Gesture Handler: the track phase and the release run
40
+ // as worklets on the UI thread, so the row never lags the finger. The rules
41
+ // are plain worklets in `swipeRow`.
42
+
43
+ /** The release: springs on at the finger's speed, no bounce (a row overshooting rest would flash the other side). */
44
+ const ROW_SPRING = { duration: 250, dampingRatio: 1, reduceMotion: ReduceMotion.System } as const;
45
+
46
+ /** The armed full swipe: the first action's icon grows a little, with the haptic, so the commit shows without it. */
47
+ const ARMED_SCALE = 1.15;
48
+ const ARMED_TIMING = { duration: 150, easing: EASE_OUT, reduceMotion: ReduceMotion.System } as const;
49
+
50
+ const ICON_SIZE = 20;
51
+
52
+ /** Each tone's fill and the icon colour on it. */
53
+ const TONES = {
54
+ default: { fill: palette.primary, icon: palette.primaryForeground },
55
+ warning: { fill: palette.warning, icon: palette.warningForeground },
56
+ danger: { fill: palette.danger, icon: palette.dangerForeground },
57
+ } as const;
58
+
59
+ export function GestureSwipeableRow({
60
+ leadingActions = [],
61
+ trailingActions = [],
62
+ enabled = true,
63
+ backgroundColor,
64
+ children,
65
+ }: SwipeableRowProps) {
66
+ const scheme = useScheme();
67
+ const x = useSharedValue(0);
68
+ const width = useSharedValue(0);
69
+ // Where the row was when the finger caught it.
70
+ const start = useSharedValue(0);
71
+ const dragging = useSharedValue(false);
72
+ const armed = useSharedValue<SwipeSide | null>(null);
73
+ const armedScale = useSharedValue(1);
74
+ // Plain values for the worklets: they must not capture the actions' functions.
75
+ const leading = leadingActions.length * SWIPE_ACTION_WIDTH;
76
+ const trailing = trailingActions.length * SWIPE_ACTION_WIDTH;
77
+ const leadingExits = leadingActions[0]?.exits === true;
78
+ const trailingExits = trailingActions[0]?.exits === true;
79
+ const [handle] = useState<SwipeRowHandle>(() => ({ close: () => x.set(withSpring(0, ROW_SPRING)) }));
80
+
81
+ // Whether this touch began while a row was open, so its release only closes that row. Nothing renders from it.
82
+ const closesOnRelease = useRef(false);
83
+ const webClicks = useWebClickGuard(isSwipeRowOpen);
84
+ const insidePager = useContext(InsidePagerContext);
85
+ const opened = () => {
86
+ closesOnRelease.current = false;
87
+ webClicks.swallowNext();
88
+ markSwipeRowOpen(handle);
89
+ };
90
+ const closed = () => markSwipeRowClosed(handle);
91
+ const firstOf = (side: SwipeSide) => (side === "leading" ? leadingActions[0] : trailingActions[0]);
92
+ /** Runs an action once the row slid out: if it fails, the row comes back. */
93
+ const runExited = (action: SwipeAction | undefined) => {
94
+ const result = action?.onPress();
95
+ if (result instanceof Promise) result.catch(() => x.set(withSpring(0, ROW_SPRING)));
96
+ };
97
+ // The worklets take sides, never the actions (their functions stay on the JavaScript thread).
98
+ const runFirstExited = (side: SwipeSide) => runExited(firstOf(side));
99
+ const runFirstInPlace = (side: SwipeSide) => void firstOf(side)?.onPress();
100
+ /** A tapped button: the row closes, or slides out for an action that exits, then the action runs. */
101
+ const press = (action: SwipeAction, side: SwipeSide) => {
102
+ closed();
103
+ if (!action.exits) {
104
+ x.set(withSpring(0, ROW_SPRING));
105
+ void action.onPress();
106
+ return;
107
+ }
108
+ const run = () => runExited(action);
109
+ x.set(
110
+ withSpring(side === "leading" ? width.get() : -width.get(), { ...ROW_SPRING, overshootClamping: true }, (finished) => {
111
+ "worklet";
112
+ if (finished) runOnJS(run)();
113
+ }),
114
+ );
115
+ };
116
+
117
+ // The full swipe arms and disarms as the row crosses the point: the icon
118
+ // grows, and a light haptic fires as it arms under the finger.
119
+ useAnimatedReaction(
120
+ () => swipeRowArmed(x.get(), { leading, trailing, width: width.get() }),
121
+ (side, previous) => {
122
+ if (side === previous) return;
123
+ armed.set(side);
124
+ armedScale.set(withTiming(side ? ARMED_SCALE : 1, ARMED_TIMING));
125
+ if (side && dragging.get()) runOnJS(impactHaptic)("light");
126
+ },
127
+ [leading, trailing],
128
+ );
129
+
130
+ // Built on each render (for `enabled` and the actions): the detector keeps
131
+ // the same handler, and a drag in flight, and only takes the new config.
132
+ const activation = swipeRowActivation({ leading, trailing }, insidePager);
133
+ const pan = Gesture.Pan()
134
+ .enabled(enabled && leading + trailing > 0)
135
+ // Sideways intent first; a drag that goes vertical first is the list's scroll.
136
+ .activeOffsetX(activation.activeOffsetX)
137
+ .failOffsetY([-SWIPE_FAIL_OFFSET_Y, SWIPE_FAIL_OFFSET_Y]);
138
+ if (activation.failOffsetX !== undefined) pan.failOffsetX(activation.failOffsetX);
139
+ pan
140
+ .onStart(() => {
141
+ cancelAnimation(x);
142
+ start.set(x.get());
143
+ dragging.set(true);
144
+ runOnJS(opened)();
145
+ })
146
+ // Track phase: one shared value write per frame, on the UI thread.
147
+ .onUpdate((event) => {
148
+ x.set(swipeRowOffset(start.get() + event.translationX, { leading, trailing, width: width.get() }));
149
+ })
150
+ .onEnd((event, success) => {
151
+ dragging.set(false);
152
+ const rowWidth = width.get();
153
+ const settle = swipeRowSettle(x.get(), event.velocityX, { leading, trailing, width: rowWidth });
154
+ const velocity = event.velocityX;
155
+ if (success && settle.kind === "commit") {
156
+ // Armed by the fling rather than the position: the haptic fires now, with the commit.
157
+ if (armed.get() === null) runOnJS(impactHaptic)("light");
158
+ runOnJS(closed)();
159
+ const side = settle.side;
160
+ if (side === "leading" ? leadingExits : trailingExits) {
161
+ x.set(
162
+ withSpring(side === "leading" ? rowWidth : -rowWidth, { ...ROW_SPRING, velocity, overshootClamping: true }, (finished) => {
163
+ "worklet";
164
+ if (finished) runOnJS(runFirstExited)(side);
165
+ }),
166
+ );
167
+ } else {
168
+ x.set(withSpring(0, { ...ROW_SPRING, velocity }));
169
+ runOnJS(runFirstInPlace)(side);
170
+ }
171
+ return;
172
+ }
173
+ if (settle.kind === "open") {
174
+ x.set(withSpring(settle.side === "leading" ? leading : -trailing, { ...ROW_SPRING, velocity }));
175
+ return;
176
+ }
177
+ x.set(withSpring(0, { ...ROW_SPRING, velocity }));
178
+ runOnJS(closed)();
179
+ })
180
+ .onFinalize(() => {
181
+ dragging.set(false);
182
+ });
183
+
184
+ const rowStyle = useAnimatedStyle(() => ({ transform: [{ translateX: x.get() }] }));
185
+ return (
186
+ <View onLayout={(event) => width.set(event.nativeEvent.layout.width)} style={styles.clip}>
187
+ {leadingActions.length > 0 ? (
188
+ <ActionStrip side="leading" actions={leadingActions} x={x} armedScale={armedScale} onPress={press} />
189
+ ) : null}
190
+ {trailingActions.length > 0 ? (
191
+ <ActionStrip side="trailing" actions={trailingActions} x={x} armedScale={armedScale} onPress={press} />
192
+ ) : null}
193
+ <GestureDetector gesture={pan}>
194
+ <Animated.View style={[{ backgroundColor: backgroundColor ?? tokens.color.background[scheme] }, rowStyle]}>
195
+ <View
196
+ ref={webClicks.ref}
197
+ // While a row is open, a tap on any row only closes it: the row
198
+ // takes the touch from the pressables inside.
199
+ onStartShouldSetResponderCapture={() => {
200
+ closesOnRelease.current = isSwipeRowOpen();
201
+ return closesOnRelease.current;
202
+ }}
203
+ onResponderRelease={() => {
204
+ if (closesOnRelease.current) closeSwipeRows();
205
+ }}
206
+ >
207
+ {children}
208
+ </View>
209
+ </Animated.View>
210
+ </GestureDetector>
211
+ </View>
212
+ );
213
+ }
214
+
215
+ /**
216
+ * One side's actions behind the row, the first at the screen edge, on the
217
+ * first action's fill (which stretches with a full swipe). Shown only while
218
+ * the row moves toward that side. Hidden from screen readers: they reach the
219
+ * actions on the row itself (`swipeActionsAccessibility`).
220
+ */
221
+ function ActionStrip({
222
+ side,
223
+ actions,
224
+ x,
225
+ armedScale,
226
+ onPress,
227
+ }: {
228
+ side: SwipeSide;
229
+ actions: readonly SwipeAction[];
230
+ x: SharedValue<number>;
231
+ armedScale: SharedValue<number>;
232
+ onPress: (action: SwipeAction, side: SwipeSide) => void;
233
+ }) {
234
+ const scheme = useScheme();
235
+ const reveal = actions.length * SWIPE_ACTION_WIDTH;
236
+ const direction = side === "leading" ? 1 : -1;
237
+ const stripStyle = useAnimatedStyle(() => ({ opacity: x.get() * direction > 0 ? 1 : 0 }));
238
+ // Past its slot the first icon keeps beside the row's edge, so it stays in view.
239
+ const firstIconStyle = useAnimatedStyle(() => ({
240
+ transform: [
241
+ { translateX: direction * Math.max(0, Math.abs(x.get()) - reveal) },
242
+ { scale: armedScale.get() },
243
+ ],
244
+ }));
245
+ return (
246
+ <Animated.View
247
+ aria-hidden
248
+ importantForAccessibility="no-hide-descendants"
249
+ style={[
250
+ StyleSheet.absoluteFill,
251
+ { flexDirection: side === "leading" ? "row" : "row-reverse" },
252
+ { backgroundColor: TONES[actions[0]?.tone ?? "default"].fill[scheme] },
253
+ stripStyle,
254
+ ]}
255
+ >
256
+ {actions.map((action, index) => {
257
+ const tone = TONES[action.tone ?? "default"];
258
+ const Icon = action.icon;
259
+ return (
260
+ <Pressable
261
+ key={action.key}
262
+ accessibilityRole="button"
263
+ accessibilityLabel={action.label}
264
+ onPress={() => onPress(action, side)}
265
+ style={[styles.action, { backgroundColor: tone.fill[scheme] }]}
266
+ >
267
+ {index === 0 ? (
268
+ <Animated.View style={firstIconStyle}>
269
+ <Icon size={ICON_SIZE} color={tone.icon[scheme]} />
270
+ </Animated.View>
271
+ ) : (
272
+ <Icon size={ICON_SIZE} color={tone.icon[scheme]} />
273
+ )}
274
+ </Pressable>
275
+ );
276
+ })}
277
+ </Animated.View>
278
+ );
279
+ }
280
+
281
+ const styles = StyleSheet.create({
282
+ clip: {
283
+ overflow: "hidden",
284
+ },
285
+ action: {
286
+ width: SWIPE_ACTION_WIDTH,
287
+ alignItems: "center",
288
+ justifyContent: "center",
289
+ },
290
+ });
@@ -0,0 +1,92 @@
1
+ import { View } from "react-native";
2
+ import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
3
+ import Animated, {
4
+ cancelAnimation,
5
+ interpolate,
6
+ ReduceMotion,
7
+ runOnJS,
8
+ useAnimatedStyle,
9
+ useSharedValue,
10
+ withSpring,
11
+ } from "react-native-reanimated";
12
+ import { impactHaptic } from "../haptics";
13
+ import {
14
+ TOAST_DRAG_ACTIVE_OFFSET_Y,
15
+ toastDragDismisses,
16
+ toastDragOffset,
17
+ type ToastDragProps,
18
+ } from "../toastDrag";
19
+ import { useWebClickGuard } from "./web-click-guard";
20
+
21
+ // The toast drag on Gesture Handler: the track phase and the release run as
22
+ // worklets on the UI thread. The rules are plain worklets in `toastDrag`.
23
+
24
+ /** Back home after a short drag: a little bounce, as the finger applied force. */
25
+ const HOME_SPRING = { duration: 250, dampingRatio: 0.8, reduceMotion: ReduceMotion.System } as const;
26
+ /** Away up the screen: no overshoot back into view. */
27
+ const AWAY_SPRING = { duration: 250, dampingRatio: 1, overshootClamping: true, reduceMotion: ReduceMotion.System } as const;
28
+
29
+ export function GestureToastDrag({ onDismiss, onHoldChange, children }: ToastDragProps) {
30
+ const y = useSharedValue(0);
31
+ const height = useSharedValue(0);
32
+ // Where the toast was when the finger caught it, and the finger's travel then.
33
+ const start = useSharedValue(0);
34
+ const startY = useSharedValue(0);
35
+ const webClicks = useWebClickGuard();
36
+ const dismissed = () => {
37
+ impactHaptic("light");
38
+ onDismiss();
39
+ };
40
+
41
+ // Built on each render: the detector keeps the same handler, and a drag in flight.
42
+ const pan = Gesture.Pan()
43
+ .activeOffsetY([-TOAST_DRAG_ACTIVE_OFFSET_Y, TOAST_DRAG_ACTIVE_OFFSET_Y])
44
+ // The timer holds from the touch, a tap included (a tap closes the toast anyway).
45
+ .onBegin(() => {
46
+ runOnJS(onHoldChange)(true);
47
+ })
48
+ .onStart((event) => {
49
+ cancelAnimation(y);
50
+ start.set(y.get());
51
+ // Count from here, so the toast does not jump by the slop the drag needed to start.
52
+ startY.set(event.translationY);
53
+ runOnJS(webClicks.swallowNext)();
54
+ })
55
+ // Track phase: one shared value write per frame, on the UI thread.
56
+ .onUpdate((event) => {
57
+ y.set(toastDragOffset(start.get() + event.translationY - startY.get()));
58
+ })
59
+ .onEnd((event) => {
60
+ const position = y.get();
61
+ if (toastDragDismisses(position, event.velocityY, height.get())) {
62
+ // Up past its own height, fading on the way (`toastStyle`), at the finger's speed.
63
+ y.set(
64
+ withSpring(-height.get() * 1.5, { ...AWAY_SPRING, velocity: event.velocityY }, (finished) => {
65
+ "worklet";
66
+ if (finished) runOnJS(dismissed)();
67
+ }),
68
+ );
69
+ return;
70
+ }
71
+ y.set(withSpring(0, { ...HOME_SPRING, velocity: event.velocityY }));
72
+ })
73
+ .onFinalize(() => {
74
+ runOnJS(onHoldChange)(false);
75
+ });
76
+
77
+ // Linked to the same value: the toast fades as it rises, gone one height up.
78
+ const toastStyle = useAnimatedStyle(() => ({
79
+ opacity: height.get() > 0 ? interpolate(y.get(), [-height.get(), 0], [0, 1], "clamp") : 1,
80
+ transform: [{ translateY: y.get() }],
81
+ }));
82
+ return (
83
+ // Its own root: on iOS the toasts draw in a separate window (`FullWindowOverlay`).
84
+ <GestureHandlerRootView>
85
+ <GestureDetector gesture={pan}>
86
+ <Animated.View style={toastStyle} onLayout={(event) => height.set(event.nativeEvent.layout.height)}>
87
+ <View ref={webClicks.ref}>{children}</View>
88
+ </Animated.View>
89
+ </GestureDetector>
90
+ </GestureHandlerRootView>
91
+ );
92
+ }
@@ -0,0 +1,50 @@
1
+ import { useRef } from "react";
2
+ import { Platform, type View } from "react-native";
3
+
4
+ /** The part of a DOM element the web click guard uses: on the web a `View` is a DOM element. */
5
+ type DomNode = {
6
+ addEventListener: (type: string, listener: (event: DomEvent) => void, capture: boolean) => void;
7
+ removeEventListener: (type: string, listener: (event: DomEvent) => void, capture: boolean) => void;
8
+ };
9
+ type DomEvent = { stopPropagation: () => void; preventDefault: () => void };
10
+
11
+ function isDomNode(node: object): node is DomNode {
12
+ return "addEventListener" in node && typeof node.addEventListener === "function";
13
+ }
14
+
15
+ /**
16
+ * On the web a pressable presses on the DOM click, which neither the touch
17
+ * responder nor Gesture Handler cancels (it captures the pointer on the
18
+ * element pressed). So, in the capture phase on the dragged content, this
19
+ * drops the click that ends a drag (`swallowNext`, at the drag's start).
20
+ * `swallowOnDown` decides afresh at each touch whether its click is dropped
21
+ * (a tap made while a swiped row is open). On Android and iOS it does
22
+ * nothing: there the drag cancels the press.
23
+ */
24
+ export function useWebClickGuard(swallowOnDown: () => boolean = () => false) {
25
+ const swallow = useRef(false);
26
+ return {
27
+ swallowNext: () => {
28
+ swallow.current = true;
29
+ },
30
+ ref: (node: View | null) => {
31
+ if (Platform.OS !== "web" || !node || !isDomNode(node)) return;
32
+ const onClick = (event: DomEvent) => {
33
+ if (!swallow.current) return;
34
+ swallow.current = false;
35
+ event.stopPropagation();
36
+ event.preventDefault();
37
+ };
38
+ // Each touch starts afresh (a touch drag clicks nothing, so its flag must not outlive it).
39
+ const onPointerDown = () => {
40
+ swallow.current = swallowOnDown();
41
+ };
42
+ node.addEventListener("click", onClick, true);
43
+ node.addEventListener("pointerdown", onPointerDown, true);
44
+ return () => {
45
+ node.removeEventListener("click", onClick, true);
46
+ node.removeEventListener("pointerdown", onPointerDown, true);
47
+ };
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,94 @@
1
+ // The twin of `@vvedantb/ui`'s `formatCompactRelativeTime`
2
+ // (`ui/app/src/time/compactRelativeTime.ts`), which `RelativeDateTime` shows.
3
+ // The web file pulls no DOM, but the web package does, so this is a copy;
4
+ // tests/relativeTime.test.ts checks both give the same strings.
5
+
6
+ export type CompactRelativeTimeOptions = {
7
+ /** Clock in ms. Defaults to `Date.now()`. */
8
+ now?: number;
9
+ /**
10
+ * Returned for anything under a minute old, including future times. When
11
+ * unset the result is whole seconds (`"42s"`), and a future time gives zero
12
+ * or a negative count (`"0s"`, `"-300s"`).
13
+ */
14
+ justNow?: string;
15
+ /**
16
+ * `false` (default): elapsed time only. A day is 24 hours, a month 30 days,
17
+ * a year 12 of those months.
18
+ * `true`: days count local calendar days across a DST change, and months and
19
+ * years are calendar months, as `dayjs().diff(date, unit)` counts them.
20
+ */
21
+ calendar?: boolean;
22
+ };
23
+
24
+ const MS_PER_MINUTE = 60_000;
25
+ const MS_PER_DAY = 86_400_000;
26
+
27
+ /** Rounds toward zero; `-0` becomes `0`. */
28
+ function truncate(n: number): number {
29
+ return n < 0 ? Math.ceil(n) || 0 : Math.floor(n);
30
+ }
31
+
32
+ /** Local UTC offset in minutes, rounded to 15 minutes. */
33
+ function utcOffset(ms: number): number {
34
+ return -Math.round(new Date(ms).getTimezoneOffset() / 15) * 15;
35
+ }
36
+
37
+ /** Adds calendar months in local time and clamps the day to the target month. */
38
+ function addMonths(ms: number, months: number): number {
39
+ const date = new Date(ms);
40
+ const day = date.getDate();
41
+ date.setDate(1);
42
+ date.setMonth(date.getMonth() + months);
43
+ const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
44
+ date.setDate(Math.min(day, daysInMonth));
45
+ return date.getTime();
46
+ }
47
+
48
+ /** Fractional calendar months from `a` to `b`, negated (the moment.js formula). */
49
+ function monthDiff(a: number, b: number): number {
50
+ if (new Date(a).getDate() < new Date(b).getDate()) return -monthDiff(b, a);
51
+ const from = new Date(a);
52
+ const to = new Date(b);
53
+ const whole = (to.getFullYear() - from.getFullYear()) * 12 + (to.getMonth() - from.getMonth());
54
+ const anchor = addMonths(a, whole);
55
+ const before = b - anchor < 0;
56
+ const anchor2 = addMonths(a, whole + (before ? -1 : 1));
57
+ return +(-(whole + (b - anchor) / (before ? anchor - anchor2 : anchor2 - anchor)) || 0);
58
+ }
59
+
60
+ /**
61
+ * Time since `date` as one compact unit: `"now"` or `"42s"`, then `"5m"`,
62
+ * `"3h"`, `"2d"`, `"4mo"`, `"1y"`. Strings are parsed with `new Date(string)`.
63
+ * An invalid date gives `"NaNy"`, or `"0mo"` with `calendar`.
64
+ */
65
+ export function formatCompactRelativeTime(
66
+ date: string | number | Date,
67
+ options: CompactRelativeTimeOptions = {},
68
+ ): string {
69
+ const now = options.now ?? Date.now();
70
+ const then = new Date(date).getTime();
71
+ const diff = now - then;
72
+
73
+ const seconds = truncate(diff / 1000);
74
+ if (seconds < 60) return options.justNow ?? `${seconds}s`;
75
+ const minutes = truncate(diff / MS_PER_MINUTE);
76
+ if (minutes < 60) return `${minutes}m`;
77
+ const hours = truncate(diff / (60 * MS_PER_MINUTE));
78
+ if (hours < 24) return `${hours}h`;
79
+
80
+ if (!options.calendar) {
81
+ const days = Math.floor(hours / 24);
82
+ if (days < 30) return `${days}d`;
83
+ const months = Math.floor(days / 30);
84
+ if (months < 12) return `${months}mo`;
85
+ return `${Math.floor(months / 12)}y`;
86
+ }
87
+
88
+ const zoneDelta = (utcOffset(then) - utcOffset(now)) * MS_PER_MINUTE;
89
+ const days = truncate((diff - zoneDelta) / MS_PER_DAY);
90
+ if (days < 30) return `${days}d`;
91
+ const months = monthDiff(now, then);
92
+ if (truncate(months) < 12) return `${truncate(months)}mo`;
93
+ return `${truncate(months / 12)}y`;
94
+ }
@@ -1,4 +1,4 @@
1
- import { useRouter, type Href, type Router } from "expo-router";
1
+ import { useRouter, type Href } from "expo-router";
2
2
  import { StackPageHeader, type StackPageHeaderProps } from "../StackPageHeader";
3
3
 
4
4
  export type RouterStackPageHeaderProps = Omit<StackPageHeaderProps, "onBack"> & {
@@ -7,7 +7,7 @@ export type RouterStackPageHeaderProps = Omit<StackPageHeaderProps, "onBack"> &
7
7
  };
8
8
 
9
9
  /** Goes back when the stack has a screen below, else replaces the route with `fallbackHref`. */
10
- export function goBackOrReplace(router: Pick<Router, "canGoBack" | "back" | "replace">, fallbackHref: Href = "/") {
10
+ export function goBackOrReplace(router: Pick<ReturnType<typeof useRouter>, "canGoBack" | "back" | "replace">, fallbackHref: Href = "/") {
11
11
  if (router.canGoBack()) router.back();
12
12
  else router.replace(fallbackHref);
13
13
  }
@@ -1,10 +1,11 @@
1
1
  import type { ComponentType, ReactNode } from "react";
2
2
  import { Text, View } from "react-native";
3
3
  import type { IconProps } from "@tabler/icons-react-native";
4
- import { textStyle, typeStyle } from "../controlStyles";
4
+ import { sizeStyle, textStyle, typeStyle } from "../controlStyles";
5
5
  import { useLargerText } from "../theme";
6
6
  import { tokens } from "../tokens";
7
7
  import { useScheme } from "../useScheme";
8
+ import { relaxedSizeStyle } from "./relaxedSizeStyle";
8
9
 
9
10
  export type SettingsEmptyStateProps = {
10
11
  title: string;
@@ -17,28 +18,37 @@ export type SettingsEmptyStateProps = {
17
18
  };
18
19
 
19
20
  /**
20
- * A compact empty note for the inside of a `SettingsSection` (web
21
- * `SettingsEmptyState`): centred title, muted description and an optional
22
- * action, with no large icon. Use `EmptyState` for a whole screen.
21
+ * The empty note for the inside of a `SettingsSection` (web
22
+ * `SettingsEmptyState`): an optional muted icon (28, half opacity), a centred
23
+ * `text-sm` medium title (14 / 20 / 500), a muted `text-xs leading-relaxed`
24
+ * description (12 / 19.5) and an optional action, in `px-4 py-16`. Use
25
+ * `EmptyState` for a whole screen.
23
26
  */
24
27
  export function SettingsEmptyState({ title, description, icon: Icon, action, testID }: SettingsEmptyStateProps) {
25
28
  const scheme = useScheme();
26
29
  const largerText = useLargerText();
27
30
  return (
28
- <View testID={testID} className="items-center gap-1 px-2 py-4">
29
- {Icon ? <Icon size={largerText ? 24 : 20} color={tokens.color.muted[scheme]} /> : null}
31
+ <View testID={testID} className="items-center justify-center px-4 py-16">
32
+ {Icon ? (
33
+ <View className="mb-5 opacity-50">
34
+ <Icon size={largerText ? 32 : 28} color={tokens.color.muted[scheme]} />
35
+ </View>
36
+ ) : null}
30
37
  <Text
31
- className="text-center font-instrument-sans-semibold text-base"
32
- style={[textStyle("foreground", scheme), typeStyle("semibold")]}
38
+ className="text-center font-instrument-sans-medium text-sm"
39
+ style={[textStyle("foreground", scheme), sizeStyle("text-sm"), typeStyle("medium")]}
33
40
  >
34
41
  {title}
35
42
  </Text>
36
43
  {description ? (
37
- <Text className="text-center font-instrument-sans text-sm" style={textStyle("muted", scheme)}>
44
+ <Text
45
+ className="mt-2 max-w-sm text-center font-instrument-sans text-xs leading-relaxed"
46
+ style={[textStyle("muted", scheme), relaxedSizeStyle("text-xs")]}
47
+ >
38
48
  {description}
39
49
  </Text>
40
50
  ) : null}
41
- {action ? <View className="mt-2">{action}</View> : null}
51
+ {action ? <View className="mt-6">{action}</View> : null}
42
52
  </View>
43
53
  );
44
54
  }
@@ -1,7 +1,13 @@
1
1
  import { View, Text } from "react-native";
2
- import { textStyle, typeStyle } from "../controlStyles";
2
+ import { sizeStyle, textStyle, typeStyle } from "../controlStyles";
3
3
  import { useScheme } from "../useScheme";
4
+ import { relaxedSizeStyle } from "./relaxedSizeStyle";
4
5
 
6
+ /**
7
+ * One labelled control in a `SettingsSection` body, the web stacked
8
+ * `SettingsField`: label `text-sm` medium (14 / 20 / 500) above the control,
9
+ * help text `text-xs leading-relaxed` (12 / 19.5), muted, below it.
10
+ */
5
11
  export function SettingsField({
6
12
  label,
7
13
  description,
@@ -16,8 +22,8 @@ export function SettingsField({
16
22
  <View>
17
23
  {typeof label === "string" ? (
18
24
  <Text
19
- className="mb-2 text-base font-instrument-sans-semibold"
20
- style={[textStyle("foreground", scheme), typeStyle("semibold")]}
25
+ className="mb-2 text-sm font-instrument-sans-medium"
26
+ style={[textStyle("foreground", scheme), sizeStyle("text-sm"), typeStyle("medium")]}
21
27
  >
22
28
  {label}
23
29
  </Text>
@@ -28,8 +34,8 @@ export function SettingsField({
28
34
  {description != null ? (
29
35
  typeof description === "string" ? (
30
36
  <Text
31
- className="mt-1.5 text-sm font-instrument-sans"
32
- style={textStyle("muted", scheme)}
37
+ className="mt-1.5 text-xs leading-relaxed font-instrument-sans"
38
+ style={[textStyle("muted", scheme), relaxedSizeStyle("text-xs")]}
33
39
  >
34
40
  {description}
35
41
  </Text>
@@ -1,8 +1,9 @@
1
1
  import { View, Text } from "react-native";
2
2
  import { cn } from "../cn";
3
- import { cardStyle, textStyle, typeStyle } from "../controlStyles";
3
+ import { cardStyle, sizeStyle, textStyle, typeStyle } from "../controlStyles";
4
4
  import { tokens } from "../tokens";
5
5
  import { useScheme } from "../useScheme";
6
+ import { relaxedSizeStyle } from "./relaxedSizeStyle";
6
7
 
7
8
  type SettingsSectionBodyVariant = "form" | "list" | "compact";
8
9
 
@@ -32,10 +33,11 @@ export function SettingsSection({
32
33
  <View className="flex-row items-start justify-between gap-4 px-4">
33
34
  <View className="min-w-0 flex-1">
34
35
  {typeof title === "string" ? (
36
+ // Web `text-sm font-semibold`: 14 / 20, 600.
35
37
  <Text
36
38
  accessibilityRole="header"
37
- className="text-lg font-instrument-sans-semibold"
38
- style={[textStyle("foreground", scheme), typeStyle("semibold")]}
39
+ className="text-sm font-instrument-sans-semibold"
40
+ style={[textStyle("foreground", scheme), sizeStyle("text-sm"), typeStyle("semibold")]}
39
41
  >
40
42
  {title}
41
43
  </Text>
@@ -44,9 +46,10 @@ export function SettingsSection({
44
46
  )}
45
47
  {description != null ? (
46
48
  typeof description === "string" ? (
49
+ // Web `text-sm leading-relaxed`: 14 / 22.75, muted.
47
50
  <Text
48
- className="mt-0.5 text-base font-instrument-sans"
49
- style={textStyle("muted", scheme)}
51
+ className="mt-0.5 text-sm leading-relaxed font-instrument-sans"
52
+ style={[textStyle("muted", scheme), relaxedSizeStyle("text-sm")]}
50
53
  >
51
54
  {description}
52
55
  </Text>