@vvedantb/native-ui 0.13.0 → 0.17.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.
- package/README.md +42 -21
- package/package.json +36 -3
- package/src/BottomSheetContainer.tsx +2 -2
- package/src/BulkActionBar.tsx +240 -105
- package/src/Button.tsx +11 -7
- package/src/ConfirmDialog.tsx +25 -35
- package/src/Dialog.tsx +47 -35
- package/src/DividedList.tsx +11 -7
- package/src/DropdownMenu.tsx +22 -8
- package/src/FloatingSurface.tsx +5 -2
- package/src/FloatingTabBar.tsx +13 -22
- package/src/HeaderIconButton.tsx +4 -3
- package/src/Input.tsx +2 -2
- package/src/LoadingState.tsx +132 -0
- package/src/PageTitle.tsx +9 -2
- package/src/Pager.tsx +62 -0
- package/src/PasswordInput.tsx +1 -1
- package/src/PillTabs.tsx +144 -34
- package/src/Progress.tsx +9 -7
- package/src/Select.tsx +4 -4
- package/src/Sheet.tsx +29 -22
- package/src/SheetRow.tsx +27 -9
- package/src/Skeleton.tsx +91 -9
- package/src/SurfaceRow.tsx +111 -0
- package/src/SwipeableRow.tsx +43 -0
- package/src/Switch.tsx +6 -4
- package/src/Tabs.tsx +68 -13
- package/src/Toast.tsx +107 -65
- package/src/ai/activity-step-detail.tsx +1 -3
- package/src/ai/activity-step-label.ts +981 -0
- package/src/ai/activity-step-types.ts +107 -0
- package/src/ai/activity-tasks-utils.ts +199 -0
- package/src/ai/activity-timeline.ts +45 -0
- package/src/ai/attachments.tsx +3 -1
- package/src/ai/chat-text.ts +13 -7
- package/src/ai/chat-thread.tsx +34 -27
- package/src/ai/choice-question.tsx +154 -35
- package/src/ai/code-block.tsx +189 -49
- package/src/ai/collapsible.tsx +23 -13
- package/src/ai/conversation.tsx +394 -57
- package/src/ai/icon-button.tsx +41 -6
- package/src/ai/index.ts +55 -4
- package/src/ai/message-action-sheet.tsx +4 -1
- package/src/ai/message.tsx +60 -31
- package/src/ai/prompt-input-menu.tsx +197 -0
- package/src/ai/prompt-input.tsx +301 -75
- package/src/ai/shimmer.tsx +4 -6
- package/src/ai/subagent-cards.tsx +84 -14
- package/src/ai/tool-approval-card.tsx +139 -8
- package/src/ai/trace.tsx +54 -17
- package/src/ai/unified-diff.tsx +182 -0
- package/src/auth/GoogleSignInButton.tsx +6 -3
- package/src/auth/SignInScreen.tsx +11 -2
- package/src/brand/ProviderIcon.tsx +136 -0
- package/src/brand/index.ts +2 -0
- package/src/configureTheme.ts +39 -11
- package/src/controlStyles.ts +46 -30
- package/src/entries/gesture-handler.ts +4 -0
- package/src/entries/keyboard-controller.ts +4 -0
- package/src/entries/markdown.ts +1 -1
- package/src/entries/primitives.ts +24 -3
- package/src/entries/theme.ts +2 -1
- package/src/entries/video.ts +4 -0
- package/src/entries/webview.ts +6 -0
- package/src/examples/AiChatGallery.tsx +70 -0
- package/src/floating.ts +25 -4
- package/src/haptics.ts +30 -4
- package/src/highlight.ts +302 -1
- package/src/index.ts +27 -5
- package/src/loadingGrid.ts +75 -0
- package/src/markdown/Markdown.tsx +234 -32
- package/src/markdown/MarkdownTable.tsx +188 -0
- package/src/markdown/tableText.ts +58 -0
- package/src/marketing/UpgradeToProCard.tsx +4 -3
- package/src/media/ImageGallery.tsx +224 -0
- package/src/media/MediaCard.tsx +100 -0
- package/src/media/imageGrid.ts +51 -0
- package/src/motion.ts +33 -0
- package/src/pagerSwipe.ts +102 -0
- package/src/palette.ts +8 -1
- package/src/primitives/button.tsx +4 -1
- package/src/primitives/keyboard-controller.tsx +106 -0
- package/src/primitives/keyboard-parts.tsx +104 -0
- package/src/primitives/overlay.tsx +133 -45
- package/src/primitives/pager-gesture.tsx +131 -0
- package/src/primitives/sheet-drag.tsx +305 -0
- package/src/primitives/sheet-gesture.tsx +181 -0
- package/src/primitives/swipe-row-gesture.tsx +290 -0
- package/src/primitives/toast-drag-gesture.tsx +92 -0
- package/src/primitives/web-click-guard.ts +50 -0
- package/src/relativeTime.ts +94 -0
- package/src/router/RouterStackPageHeader.tsx +2 -2
- package/src/settings/SettingsEmptyState.tsx +20 -10
- package/src/settings/SettingsField.tsx +11 -5
- package/src/settings/SettingsSection.tsx +8 -5
- package/src/settings/SettingsToggleRow.tsx +55 -22
- package/src/settings/relaxedSizeStyle.ts +11 -0
- package/src/sheetDrag.ts +67 -0
- package/src/swipeRow.ts +169 -0
- package/src/tabBarRoutes.ts +18 -0
- package/src/tabIndicator.ts +71 -0
- package/src/tailwind-preset.cjs +13 -6
- package/src/tailwind-preset.d.cts +5 -0
- package/src/theme.tsx +6 -2
- package/src/toastDrag.ts +58 -0
- package/src/typography.ts +20 -3
- package/src/useScheme.ts +10 -1
- package/src/video/InlineVideo.tsx +91 -0
- package/src/webview/HtmlView.tsx +91 -0
- package/src/webview/HtmlView.web.tsx +21 -0
- 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
|
|
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<
|
|
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
|
-
*
|
|
21
|
-
* `SettingsEmptyState`):
|
|
22
|
-
*
|
|
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
|
|
29
|
-
{Icon ?
|
|
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-
|
|
32
|
-
style={[textStyle("foreground", scheme), typeStyle("
|
|
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
|
|
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-
|
|
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-
|
|
20
|
-
style={[textStyle("foreground", scheme), typeStyle("
|
|
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-
|
|
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-
|
|
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-
|
|
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>
|