@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.
- package/README.md +40 -19
- 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 +19 -13
- package/src/ai/conversation.tsx +390 -57
- package/src/ai/icon-button.tsx +41 -6
- package/src/ai/index.ts +38 -1
- 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/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/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
|
@@ -1,38 +1,53 @@
|
|
|
1
|
-
import { View, Text } from "react-native";
|
|
1
|
+
import { Pressable, View, Text } from "react-native";
|
|
2
2
|
import { cn } from "../cn";
|
|
3
|
-
import { textStyle, typeStyle } from "../controlStyles";
|
|
3
|
+
import { MIN_TOUCH_TARGET, sizeStyle, textStyle, typeStyle } from "../controlStyles";
|
|
4
4
|
import { useLargerText } from "../theme";
|
|
5
5
|
import { useScheme } from "../useScheme";
|
|
6
|
+
import { relaxedSizeStyle } from "./relaxedSizeStyle";
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* One row in a `SettingsSection` list, the web `SettingsToggleRow`: title
|
|
10
|
+
* `text-sm` medium (14 / 20 / 500), description `text-xs leading-relaxed`
|
|
11
|
+
* (12 / 19.5), muted; one step up each with larger text. The web action column
|
|
12
|
+
* is `min-h-10` (40); this one keeps the 44 touch target.
|
|
13
|
+
*
|
|
14
|
+
* With `onCheckedChange` the whole row is the switch, as `LabeledSwitchRow`
|
|
15
|
+
* (the web `<label htmlFor>`): at least 56 high, one `switch` for TalkBack
|
|
16
|
+
* named by the title, with the `action` switch hidden from it.
|
|
17
|
+
*/
|
|
7
18
|
export function SettingsToggleRow({
|
|
8
19
|
title,
|
|
9
20
|
description,
|
|
10
21
|
action,
|
|
22
|
+
checked,
|
|
23
|
+
onCheckedChange,
|
|
24
|
+
disabled = false,
|
|
25
|
+
accessibilityLabel,
|
|
11
26
|
className,
|
|
12
27
|
}: {
|
|
13
28
|
title: React.ReactNode;
|
|
14
29
|
description?: React.ReactNode;
|
|
15
30
|
action: React.ReactNode;
|
|
31
|
+
/** The switch state, for the row's `switch` role. */
|
|
32
|
+
checked?: boolean;
|
|
33
|
+
/** Makes the whole row toggle `checked`. */
|
|
34
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
/** The row's name when `title` is not a string. */
|
|
37
|
+
accessibilityLabel?: string;
|
|
16
38
|
className?: string;
|
|
17
39
|
}) {
|
|
18
40
|
const largerText = useLargerText();
|
|
19
41
|
const scheme = useScheme();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
largerText ? "min-h-[72px]" : "min-h-16",
|
|
25
|
-
className,
|
|
26
|
-
)}
|
|
27
|
-
>
|
|
42
|
+
const titleSize = largerText ? "text-base" : "text-sm";
|
|
43
|
+
const descriptionSize = largerText ? "text-sm" : "text-xs";
|
|
44
|
+
const content = (
|
|
45
|
+
<>
|
|
28
46
|
<View className="min-w-0 flex-1">
|
|
29
47
|
{typeof title === "string" ? (
|
|
30
48
|
<Text
|
|
31
|
-
className={cn(
|
|
32
|
-
|
|
33
|
-
largerText ? "text-xl" : "text-lg",
|
|
34
|
-
)}
|
|
35
|
-
style={[textStyle("foreground", scheme), typeStyle("semibold")]}
|
|
49
|
+
className={cn("font-instrument-sans-medium", titleSize)}
|
|
50
|
+
style={[textStyle("foreground", scheme), sizeStyle(titleSize), typeStyle("medium")]}
|
|
36
51
|
>
|
|
37
52
|
{title}
|
|
38
53
|
</Text>
|
|
@@ -42,11 +57,8 @@ export function SettingsToggleRow({
|
|
|
42
57
|
{description != null ? (
|
|
43
58
|
typeof description === "string" ? (
|
|
44
59
|
<Text
|
|
45
|
-
className={cn(
|
|
46
|
-
|
|
47
|
-
largerText ? "text-lg" : "text-base",
|
|
48
|
-
)}
|
|
49
|
-
style={textStyle("muted", scheme)}
|
|
60
|
+
className={cn("mt-1 font-instrument-sans", descriptionSize, "leading-relaxed")}
|
|
61
|
+
style={[textStyle("muted", scheme), relaxedSizeStyle(descriptionSize)]}
|
|
50
62
|
>
|
|
51
63
|
{description}
|
|
52
64
|
</Text>
|
|
@@ -55,9 +67,30 @@ export function SettingsToggleRow({
|
|
|
55
67
|
)
|
|
56
68
|
) : null}
|
|
57
69
|
</View>
|
|
58
|
-
<View
|
|
70
|
+
<View
|
|
71
|
+
className="shrink-0 items-center justify-center"
|
|
72
|
+
style={{ minHeight: MIN_TOUCH_TARGET }}
|
|
73
|
+
// The row is the accessible switch, so the one inside is hidden from assistive tech.
|
|
74
|
+
accessibilityElementsHidden={onCheckedChange != null}
|
|
75
|
+
importantForAccessibility={onCheckedChange ? "no-hide-descendants" : "auto"}
|
|
76
|
+
>
|
|
59
77
|
{action}
|
|
60
78
|
</View>
|
|
61
|
-
|
|
79
|
+
</>
|
|
80
|
+
);
|
|
81
|
+
const rowClass = "flex-row items-center justify-between gap-4 px-4 py-3";
|
|
82
|
+
if (!onCheckedChange) return <View className={cn(rowClass, className)}>{content}</View>;
|
|
83
|
+
return (
|
|
84
|
+
<Pressable
|
|
85
|
+
accessibilityRole="switch"
|
|
86
|
+
accessibilityLabel={typeof title === "string" ? title : accessibilityLabel}
|
|
87
|
+
accessibilityHint={typeof description === "string" ? description : undefined}
|
|
88
|
+
accessibilityState={{ checked: Boolean(checked), disabled }}
|
|
89
|
+
disabled={disabled}
|
|
90
|
+
onPress={() => onCheckedChange(!checked)}
|
|
91
|
+
className={cn(rowClass, "min-h-14", className)}
|
|
92
|
+
>
|
|
93
|
+
{content}
|
|
94
|
+
</Pressable>
|
|
62
95
|
);
|
|
63
96
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { sizeStyle, type TextSize } from "../controlStyles";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `sizeStyle(size)` with the Tailwind `leading-relaxed` line height (1.625 ×
|
|
5
|
+
* the size), for the web settings descriptions (`text-xs leading-relaxed` is
|
|
6
|
+
* 12 / 19.5). Set beside the `leading-relaxed` class. Kept for the settings
|
|
7
|
+
* files; the number lives in `sizeStyle`.
|
|
8
|
+
*/
|
|
9
|
+
export function relaxedSizeStyle(size: TextSize) {
|
|
10
|
+
return sizeStyle(size, "relaxed");
|
|
11
|
+
}
|
package/src/sheetDrag.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules of a bottom sheet's drag to dismiss, as plain numbers, so they
|
|
3
|
+
* stay testable apart from the gesture. Distances are in points, `vy` in
|
|
4
|
+
* points per millisecond (the React Native `PanResponder` unit). Each is a
|
|
5
|
+
* worklet, so the Gesture Handler drag runs them on the UI thread.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** How far ahead (ms) the release velocity carries the sheet when deciding to close. */
|
|
9
|
+
const PROJECTION_MS = 200;
|
|
10
|
+
|
|
11
|
+
/** A flick down at this speed (pt/ms, so 1000 pt/s) closes the sheet however short the drag. */
|
|
12
|
+
const FLICK_VELOCITY = 1;
|
|
13
|
+
|
|
14
|
+
/** How far an upward pull may lift the sheet: less than the corner radius hidden below the screen. */
|
|
15
|
+
export const SHEET_OVERPULL = 12;
|
|
16
|
+
|
|
17
|
+
/** A drag that starts the sheet gesture: down, more vertical than sideways, past a small slop. */
|
|
18
|
+
export function isSheetDragDown(dx: number, dy: number): boolean {
|
|
19
|
+
"worklet";
|
|
20
|
+
return dy > 4 && Math.abs(dy) > Math.abs(dx);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Where the sheet sits for a drag that would put it `dy` below rest (where
|
|
25
|
+
* the drag started plus the finger's travel): it follows the finger down, and
|
|
26
|
+
* past rest upward it meets growing resistance, up to `SHEET_OVERPULL`.
|
|
27
|
+
*/
|
|
28
|
+
export function sheetDragOffset(dy: number): number {
|
|
29
|
+
"worklet";
|
|
30
|
+
if (dy >= 0) return dy;
|
|
31
|
+
return -SHEET_OVERPULL * (1 - 1 / (1 - dy / (SHEET_OVERPULL * 4)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The finger position (window `y`) a drag's travel counts from, so the point
|
|
36
|
+
* the finger holds stays under it: where it touched down, moved down by any
|
|
37
|
+
* scroll the same touch spent bringing the body back to its top before the
|
|
38
|
+
* sheet took over (`scrollAtDown` to `scrollNow`). Without that, the sheet
|
|
39
|
+
* would jump by the scroll when it takes over.
|
|
40
|
+
*/
|
|
41
|
+
export function sheetDragAnchor(downY: number, scrollAtDown: number, scrollNow: number): number {
|
|
42
|
+
"worklet";
|
|
43
|
+
return downY + Math.max(0, scrollAtDown - scrollNow);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether a released drag closes the sheet: a quick flick down, or a drag
|
|
48
|
+
* whose position (`dy` below rest), carried on by its speed, passes half the
|
|
49
|
+
* sheet. A slow, short drag springs back.
|
|
50
|
+
*/
|
|
51
|
+
export function sheetDragDismisses({ dy, vy, height }: { dy: number; vy: number; height: number }): boolean {
|
|
52
|
+
"worklet";
|
|
53
|
+
if (dy <= 0) return false;
|
|
54
|
+
if (vy >= FLICK_VELOCITY) return true;
|
|
55
|
+
return dy + Math.max(0, vy) * PROJECTION_MS > height / 2;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The velocity (pt/s) a sheet springs home with after a release at
|
|
60
|
+
* `position` (its offset from rest, up negative). Speed toward rest carries
|
|
61
|
+
* on; speed away from rest above the top is dropped, so a flick up does not
|
|
62
|
+
* lift the sheet off the bottom edge.
|
|
63
|
+
*/
|
|
64
|
+
export function sheetSnapVelocity(position: number, velocity: number): number {
|
|
65
|
+
"worklet";
|
|
66
|
+
return position < 0 ? Math.max(0, velocity) : velocity;
|
|
67
|
+
}
|
package/src/swipeRow.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { IconProps } from "@tabler/icons-react-native";
|
|
2
|
+
import type { ComponentType } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The rules of a swipeable list row (`SwipeableRow`), as plain numbers, so
|
|
6
|
+
* they stay testable apart from the gesture. The offset is in points:
|
|
7
|
+
* positive when the row moves right (it reveals the leading actions),
|
|
8
|
+
* negative when it moves left (the trailing actions). Velocities are in
|
|
9
|
+
* points per second, the Gesture Handler unit. Each rule is a worklet, so the
|
|
10
|
+
* drag runs it on the UI thread.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** One action a swipe reveals behind a row. */
|
|
14
|
+
export type SwipeAction = {
|
|
15
|
+
/** Unique in the row; also the accessibility action name. */
|
|
16
|
+
key: string;
|
|
17
|
+
/** What the action does ("Archive"): the accessibility label of its button and of its row action. */
|
|
18
|
+
label: string;
|
|
19
|
+
/** A Tabler icon, drawn in the tone's foreground. */
|
|
20
|
+
icon: ComponentType<IconProps>;
|
|
21
|
+
/** The fill: `default` the primary fill, `warning` (such as Archive), `danger` for a destructive action. */
|
|
22
|
+
tone?: "default" | "warning" | "danger";
|
|
23
|
+
/**
|
|
24
|
+
* Runs the action. With `exits`, a returned promise that settles while
|
|
25
|
+
* the row is still on screen (it failed, or the row stays) brings the row
|
|
26
|
+
* back.
|
|
27
|
+
*/
|
|
28
|
+
onPress: () => void | Promise<unknown>;
|
|
29
|
+
/** The action takes the row out of the list (Archive): a full swipe slides it out. Else the row springs back. */
|
|
30
|
+
exits?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type SwipeSide = "leading" | "trailing";
|
|
34
|
+
|
|
35
|
+
/** The revealed widths of each side (0 for a side with no actions) and the row's width. */
|
|
36
|
+
export type SwipeSides = { leading: number; trailing: number; width: number };
|
|
37
|
+
|
|
38
|
+
/** The width of one revealed action button. */
|
|
39
|
+
export const SWIPE_ACTION_WIDTH = 72;
|
|
40
|
+
|
|
41
|
+
/** Sideways travel (pt) before the drag takes the row: a tap, or a scroll, never moves it. */
|
|
42
|
+
export const SWIPE_ACTIVE_OFFSET_X = 12;
|
|
43
|
+
|
|
44
|
+
/** Vertical travel (pt) that hands the touch to the list's scroll first: a small diagonal drag scrolls. */
|
|
45
|
+
export const SWIPE_FAIL_OFFSET_Y = 8;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The pan's sideways thresholds. A row takes a sideways drag past
|
|
49
|
+
* `SWIPE_ACTIVE_OFFSET_X` either way. Inside a `Pager` it takes only the
|
|
50
|
+
* directions it has actions for, and a drag the other way fails it at once,
|
|
51
|
+
* so the pager behind moves instead (a Sessions row swiped right).
|
|
52
|
+
*/
|
|
53
|
+
export function swipeRowActivation(
|
|
54
|
+
sides: Pick<SwipeSides, "leading" | "trailing">,
|
|
55
|
+
insidePager: boolean,
|
|
56
|
+
): { activeOffsetX: number | [number, number]; failOffsetX?: number } {
|
|
57
|
+
if (!insidePager || (sides.leading > 0 && sides.trailing > 0)) {
|
|
58
|
+
return { activeOffsetX: [-SWIPE_ACTIVE_OFFSET_X, SWIPE_ACTIVE_OFFSET_X] };
|
|
59
|
+
}
|
|
60
|
+
// Left (negative) reveals the trailing actions; right the leading ones.
|
|
61
|
+
return sides.trailing > 0
|
|
62
|
+
? { activeOffsetX: -SWIPE_ACTIVE_OFFSET_X, failOffsetX: SWIPE_ACTIVE_OFFSET_X }
|
|
63
|
+
: { activeOffsetX: SWIPE_ACTIVE_OFFSET_X, failOffsetX: -SWIPE_ACTIVE_OFFSET_X };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** How far ahead (s) the release velocity carries the row when deciding where it settles. */
|
|
67
|
+
const PROJECTION_S = 0.2;
|
|
68
|
+
|
|
69
|
+
/** How far a row moves past its reach: less and less the further the finger goes. */
|
|
70
|
+
function rubberband(overshoot: number): number {
|
|
71
|
+
"worklet";
|
|
72
|
+
const constant = 0.55;
|
|
73
|
+
return (overshoot * SWIPE_ACTION_WIDTH * constant) / (SWIPE_ACTION_WIDTH + constant * overshoot);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Where the row sits for a finger `raw` points from where the drag caught
|
|
78
|
+
* it. Toward a side with actions it follows 1:1, out to its own width; toward
|
|
79
|
+
* a side with none, and past its width, it meets growing resistance.
|
|
80
|
+
*/
|
|
81
|
+
export function swipeRowOffset(raw: number, sides: SwipeSides): number {
|
|
82
|
+
"worklet";
|
|
83
|
+
const reveal = raw >= 0 ? sides.leading : sides.trailing;
|
|
84
|
+
const reach = reveal > 0 ? sides.width : 0;
|
|
85
|
+
const distance = Math.abs(raw);
|
|
86
|
+
if (distance <= reach) return raw;
|
|
87
|
+
return Math.sign(raw) * (reach + rubberband(distance - reach));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** How far the row must travel for a full swipe to commit the first action: half the row, and always past the revealed buttons. */
|
|
91
|
+
export function swipeFullAt(reveal: number, width: number): number {
|
|
92
|
+
"worklet";
|
|
93
|
+
return Math.max(reveal + SWIPE_ACTION_WIDTH, width / 2);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The side whose first action a release here would commit (the full swipe is armed), or `null`. */
|
|
97
|
+
export function swipeRowArmed(x: number, sides: SwipeSides): SwipeSide | null {
|
|
98
|
+
"worklet";
|
|
99
|
+
const side: SwipeSide = x >= 0 ? "leading" : "trailing";
|
|
100
|
+
const reveal = sides[side];
|
|
101
|
+
if (reveal === 0 || Math.abs(x) < swipeFullAt(reveal, sides.width)) return null;
|
|
102
|
+
return side;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type SwipeSettle = { kind: "close" } | { kind: "open"; side: SwipeSide } | { kind: "commit"; side: SwipeSide };
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Where a released row goes, from where it is (`x`) carried on by its speed
|
|
109
|
+
* (`vx`): past the full-swipe point, with its buttons already in view, it
|
|
110
|
+
* commits the first action; past half its buttons it opens; else it closes.
|
|
111
|
+
* A flick the other way closes it however far it was.
|
|
112
|
+
*/
|
|
113
|
+
export function swipeRowSettle(x: number, vx: number, sides: SwipeSides): SwipeSettle {
|
|
114
|
+
"worklet";
|
|
115
|
+
if (x === 0) return { kind: "close" };
|
|
116
|
+
const side: SwipeSide = x > 0 ? "leading" : "trailing";
|
|
117
|
+
const reveal = sides[side];
|
|
118
|
+
if (reveal === 0) return { kind: "close" };
|
|
119
|
+
// The projected travel toward this side: negative after a flick back past rest.
|
|
120
|
+
const reach = Math.sign(x) * (x + vx * PROJECTION_S);
|
|
121
|
+
if (Math.abs(x) > reveal && reach >= swipeFullAt(reveal, sides.width)) return { kind: "commit", side };
|
|
122
|
+
if (reach > reveal / 2) return { kind: "open", side };
|
|
123
|
+
return { kind: "close" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A row that can be closed from outside: the open row closes when another opens, a list scrolls or a row is tapped. */
|
|
127
|
+
export type SwipeRowHandle = { close: () => void };
|
|
128
|
+
|
|
129
|
+
let openRow: SwipeRowHandle | null = null;
|
|
130
|
+
|
|
131
|
+
/** Marks `row` as the open row, closing the one open before: one row is open at a time. */
|
|
132
|
+
export function markSwipeRowOpen(row: SwipeRowHandle) {
|
|
133
|
+
if (openRow && openRow !== row) openRow.close();
|
|
134
|
+
openRow = row;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Forgets `row` once it has closed itself. */
|
|
138
|
+
export function markSwipeRowClosed(row: SwipeRowHandle) {
|
|
139
|
+
if (openRow === row) openRow = null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Whether a row is open. A touch on any row then only closes it. */
|
|
143
|
+
export function isSwipeRowOpen(): boolean {
|
|
144
|
+
return openRow !== null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Closes the open row, if any. Pass it to a list's `onScrollBeginDrag`, so a scroll closes the row. */
|
|
148
|
+
export function closeSwipeRows() {
|
|
149
|
+
const row = openRow;
|
|
150
|
+
openRow = null;
|
|
151
|
+
row?.close();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The row's actions for VoiceOver and TalkBack, which cannot swipe a row:
|
|
156
|
+
* spread on the row's main pressable, so the actions rotor (iOS) or the
|
|
157
|
+
* actions menu (Android) offers each one, with its label.
|
|
158
|
+
*/
|
|
159
|
+
export function swipeActionsAccessibility(actions: readonly SwipeAction[]): {
|
|
160
|
+
accessibilityActions: { name: string; label: string }[];
|
|
161
|
+
onAccessibilityAction: (event: { nativeEvent: { actionName: string } }) => void;
|
|
162
|
+
} {
|
|
163
|
+
return {
|
|
164
|
+
accessibilityActions: actions.map((action) => ({ name: action.key, label: action.label })),
|
|
165
|
+
onAccessibilityAction: (event) => {
|
|
166
|
+
void actions.find((action) => action.key === event.nativeEvent.actionName)?.onPress();
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
package/src/tabBarRoutes.ts
CHANGED
|
@@ -23,6 +23,14 @@ export type FloatingTabOptions = {
|
|
|
23
23
|
href?: string | null;
|
|
24
24
|
/** Only `display: "none"` is read: it hides the tab, as in React Navigation's own bar. */
|
|
25
25
|
tabBarItemStyle?: StyleProp<ViewStyle>;
|
|
26
|
+
/**
|
|
27
|
+
* Only `display: "none"` is read: on the focused route it hides the whole
|
|
28
|
+
* bar, as in React Navigation's own bar (for example while a screen is in
|
|
29
|
+
* select mode and shows its `BulkActionBar` in the bar's place). Typed
|
|
30
|
+
* loosely: React Navigation's own type allows animated styles, any of
|
|
31
|
+
* which is an object or a falsy value.
|
|
32
|
+
*/
|
|
33
|
+
tabBarStyle?: object | false | "" | null;
|
|
26
34
|
};
|
|
27
35
|
|
|
28
36
|
/** The `display` a style resolves to: in an array the last one set wins, as in `StyleSheet.flatten`. */
|
|
@@ -50,3 +58,13 @@ export function isTabBarRouteVisible(
|
|
|
50
58
|
): options is FloatingTabOptions {
|
|
51
59
|
return options !== undefined && options.href !== null && displayOf(options.tabBarItemStyle) !== "none";
|
|
52
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether `FloatingTabBar` hides itself: the focused route's `tabBarStyle`
|
|
64
|
+
* resolves to `display: "none"`, the React Navigation way to hide the bar
|
|
65
|
+
* from a screen (`navigation.setOptions`, or Expo Router's `<Tabs.Screen
|
|
66
|
+
* options>` inside the screen).
|
|
67
|
+
*/
|
|
68
|
+
export function isTabBarHidden(options: FloatingTabOptions | undefined): boolean {
|
|
69
|
+
return displayOf(options?.tabBarStyle) === "none";
|
|
70
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { cssInterop } from "nativewind";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import type { LayoutChangeEvent } from "react-native";
|
|
4
|
+
import Animated, { interpolate, interpolateColor, useAnimatedStyle, type SharedValue } from "react-native-reanimated";
|
|
5
|
+
|
|
6
|
+
// The tab labels are `Animated.Text`s (their colour follows `progress`).
|
|
7
|
+
// NativeWind maps `className` only for components it knows, so it is told
|
|
8
|
+
// about this one, or the labels lose their font and size classes.
|
|
9
|
+
cssInterop(Animated.Text, { className: "style" });
|
|
10
|
+
|
|
11
|
+
/** A tab's box in its row, from its layout. */
|
|
12
|
+
export type TabBox = { x: number; y: number; width: number; height: number };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The selected fill of a tab row linked to a `Pager`'s `progress` (`PillTabs`,
|
|
16
|
+
* `TabsList`): one indicator that slides with the pages between the measured
|
|
17
|
+
* tabs. The tabs sit on one line, so it moves on x and width only; its top
|
|
18
|
+
* and height are the first tab's. Until every tab has reported its layout,
|
|
19
|
+
* `measured` is false and each tab paints its own fill, so the first frame
|
|
20
|
+
* already shows the selection. `boxes` is every tab's box once all are
|
|
21
|
+
* measured (with or without `progress`), else empty.
|
|
22
|
+
*/
|
|
23
|
+
export function useTabIndicator(progress: SharedValue<number> | undefined, count: number) {
|
|
24
|
+
// Each tab's box, measured on layout (not per frame).
|
|
25
|
+
const [layouts, setLayouts] = useState<ReadonlyArray<TabBox | undefined>>([]);
|
|
26
|
+
const found = layouts.slice(0, count).filter((box) => box !== undefined);
|
|
27
|
+
const boxes = found.length === count ? found : [];
|
|
28
|
+
const measured = progress !== undefined && boxes.length > 0;
|
|
29
|
+
const style = useAnimatedStyle(() => {
|
|
30
|
+
const first = boxes[0];
|
|
31
|
+
if (!progress || !measured || !first) return { opacity: 0 };
|
|
32
|
+
const pages = boxes.map((_, index) => index);
|
|
33
|
+
const at = (pick: (box: TabBox) => number) =>
|
|
34
|
+
boxes.length === 1 ? pick(first) : interpolate(progress.get(), pages, boxes.map(pick), "clamp");
|
|
35
|
+
return {
|
|
36
|
+
opacity: 1,
|
|
37
|
+
top: first.y,
|
|
38
|
+
height: first.height,
|
|
39
|
+
width: at((box) => box.width),
|
|
40
|
+
transform: [{ translateX: at((box) => box.x) }],
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
/** The `onLayout` of tab `index`. */
|
|
44
|
+
const onLayout = (index: number) => (event: LayoutChangeEvent) => {
|
|
45
|
+
const box = event.nativeEvent.layout;
|
|
46
|
+
setLayouts((current) => {
|
|
47
|
+
const next = [...current];
|
|
48
|
+
next[index] = box;
|
|
49
|
+
return next;
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
return { measured, style, boxes, onLayout };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A tab label's colour. With `progress` it blends from `muted` to `selected`
|
|
57
|
+
* as the pages reach tab `index`, so the label turns with the sliding fill
|
|
58
|
+
* under a swipe, not when the swipe settles. Without it, `fallback`.
|
|
59
|
+
*/
|
|
60
|
+
export function useTabLabelStyle(
|
|
61
|
+
progress: SharedValue<number> | undefined,
|
|
62
|
+
index: number,
|
|
63
|
+
colors: { selected: string; muted: string },
|
|
64
|
+
fallback: string,
|
|
65
|
+
) {
|
|
66
|
+
const { selected, muted } = colors;
|
|
67
|
+
return useAnimatedStyle(() => {
|
|
68
|
+
if (!progress) return { color: fallback };
|
|
69
|
+
return { color: interpolateColor(progress.get(), [index - 1, index, index + 1], [muted, selected, muted]) };
|
|
70
|
+
});
|
|
71
|
+
}
|
package/src/tailwind-preset.cjs
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* };
|
|
11
11
|
*
|
|
12
12
|
* It nests `nativewind/preset`, so callers pass only this one. It sets
|
|
13
|
-
* `darkMode`, the
|
|
13
|
+
* `darkMode`, the `font-instrument-sans*` font classes (Instrument Sans unless
|
|
14
|
+
* `font` names other families), the colour keys native-ui
|
|
14
15
|
* classes use, `borderWidth.hairline` and, when asked, `borderRadius` from
|
|
15
16
|
* `--radius`.
|
|
16
17
|
* App colours, display fonts, keyframes and font-weight plugins stay in the
|
|
@@ -34,6 +35,7 @@ const hslVar = (name) => `hsl(var(--${name}))`;
|
|
|
34
35
|
/** `rgb(var(--name) / <alpha-value>)`, for variables that hold `r g b` channels. Keeps opacity modifiers. */
|
|
35
36
|
const rgbAlpha = (name) => `rgb(var(--${name}) / <alpha-value>)`;
|
|
36
37
|
|
|
38
|
+
/** The default families of the four font classes, as `fontFamily` in `typography.ts`. */
|
|
37
39
|
const INSTRUMENT_SANS = {
|
|
38
40
|
regular: "InstrumentSans_400Regular",
|
|
39
41
|
medium: "InstrumentSans_500Medium",
|
|
@@ -202,9 +204,14 @@ function cssVariablesPlugin(darkMode) {
|
|
|
202
204
|
* - `injectCssVariables`: write the colour variables and `--radius` from `tokens.ts` and `palette.ts`. Default `false`.
|
|
203
205
|
* - `radiusVariable`: `rounded-lg`, `-md` and `-sm` from `var(--radius)`, as vibot and Velth. Default: on with
|
|
204
206
|
* `injectCssVariables`, else off, so an app without `--radius` keeps the Tailwind radii.
|
|
207
|
+
* - `font`: `{ regular, medium, semibold, bold }` family names for `sans` and the four `font-instrument-sans*`
|
|
208
|
+
* classes. Keys not given keep Instrument Sans. Pass the same names to `configureTheme({ font })`, which sets
|
|
209
|
+
* the JS styles; the app loads the font files.
|
|
205
210
|
*/
|
|
206
211
|
function nativeUiPreset(options = {}) {
|
|
207
212
|
const darkMode = options.darkMode === "media" ? "media" : "class";
|
|
213
|
+
const font = { ...INSTRUMENT_SANS };
|
|
214
|
+
for (const [key, value] of Object.entries(options.font || {})) if (value !== undefined) font[key] = value;
|
|
208
215
|
const inject = options.injectCssVariables === true;
|
|
209
216
|
const color = options.color || (inject ? rgbAlpha : cssVar);
|
|
210
217
|
const radius = options.radiusVariable ?? inject;
|
|
@@ -215,11 +222,11 @@ function nativeUiPreset(options = {}) {
|
|
|
215
222
|
extend: {
|
|
216
223
|
colors: themeColors(color),
|
|
217
224
|
fontFamily: {
|
|
218
|
-
sans: [
|
|
219
|
-
"instrument-sans": [
|
|
220
|
-
"instrument-sans-medium": [
|
|
221
|
-
"instrument-sans-semibold": [
|
|
222
|
-
"instrument-sans-bold": [
|
|
225
|
+
sans: [font.regular],
|
|
226
|
+
"instrument-sans": [font.regular],
|
|
227
|
+
"instrument-sans-medium": [font.medium],
|
|
228
|
+
"instrument-sans-semibold": [font.semibold],
|
|
229
|
+
"instrument-sans-bold": [font.bold],
|
|
223
230
|
},
|
|
224
231
|
borderRadius: radius
|
|
225
232
|
? {
|
|
@@ -14,6 +14,11 @@ export interface NativeUiPresetOptions {
|
|
|
14
14
|
injectCssVariables?: boolean;
|
|
15
15
|
/** `rounded-lg`, `-md` and `-sm` from `var(--radius)`. Default: the value of `injectCssVariables`. */
|
|
16
16
|
radiusVariable?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Family names for `sans` and the four `font-instrument-sans*` classes. Keys not given keep Instrument Sans.
|
|
19
|
+
* Pass the same names to `configureTheme({ font })`; the app loads the font files.
|
|
20
|
+
*/
|
|
21
|
+
font?: { regular?: string; medium?: string; semibold?: string; bold?: string };
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
export interface NativeUiPreset {
|
package/src/theme.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { createContext, useContext, type ReactNode } from "react";
|
|
1
|
+
import { createContext, useContext, useSyncExternalStore, type ReactNode } from "react";
|
|
2
|
+
import { getThemeVersion, subscribeTheme } from "./configureTheme";
|
|
2
3
|
import { tokens } from "./tokens";
|
|
4
|
+
import { ThemeVersionContext } from "./useScheme";
|
|
3
5
|
|
|
4
6
|
export const DEFAULT_ACCENT = "#db2777";
|
|
5
7
|
|
|
@@ -22,9 +24,11 @@ export function NativeUiProvider({
|
|
|
22
24
|
accentColor?: string;
|
|
23
25
|
children: ReactNode;
|
|
24
26
|
}) {
|
|
27
|
+
// Each `configureTheme` call redraws every part below that reads the scheme.
|
|
28
|
+
const themeVersion = useSyncExternalStore(subscribeTheme, getThemeVersion, getThemeVersion);
|
|
25
29
|
return (
|
|
26
30
|
<NativeUiContext.Provider value={{ largerText, accentColor }}>
|
|
27
|
-
{children}
|
|
31
|
+
<ThemeVersionContext.Provider value={themeVersion}>{children}</ThemeVersionContext.Provider>
|
|
28
32
|
</NativeUiContext.Provider>
|
|
29
33
|
);
|
|
30
34
|
}
|
package/src/toastDrag.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createContext, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The rules of a toast's drag to dismiss, as plain numbers, so they stay
|
|
5
|
+
* testable apart from the gesture. A toast enters down from the top edge, so
|
|
6
|
+
* it leaves the same way: up. `dy` is the drag's travel in points (up
|
|
7
|
+
* negative) and `vy` the finger's speed in points per second (the Gesture
|
|
8
|
+
* Handler unit). Each rule is a worklet, so the drag runs it on the UI thread.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Vertical travel (pt) before a drag takes the toast: a tap still presses it. */
|
|
12
|
+
export const TOAST_DRAG_ACTIVE_OFFSET_Y = 8;
|
|
13
|
+
|
|
14
|
+
/** A flick up at this speed (pt/s) dismisses the toast, however short the drag. */
|
|
15
|
+
const FLICK_VELOCITY = 500;
|
|
16
|
+
|
|
17
|
+
/** How far ahead (s) the release velocity carries the toast when deciding to dismiss it. */
|
|
18
|
+
const PROJECTION_S = 0.2;
|
|
19
|
+
|
|
20
|
+
/** How far a downward pull may move the toast: it is already as low as it goes. */
|
|
21
|
+
const DOWN_REACH = 24;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Where the toast sits for a drag of `dy`: it follows the finger up 1:1, and
|
|
25
|
+
* down it meets growing resistance, up to `DOWN_REACH`.
|
|
26
|
+
*/
|
|
27
|
+
export function toastDragOffset(dy: number): number {
|
|
28
|
+
"worklet";
|
|
29
|
+
if (dy <= 0) return dy;
|
|
30
|
+
return DOWN_REACH * (1 - 1 / (1 + dy / (DOWN_REACH * 2)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether a released drag dismisses the toast: a quick flick up, or a drag
|
|
35
|
+
* whose position, carried on by its speed, passes half the toast's height.
|
|
36
|
+
* A slow, short drag springs back.
|
|
37
|
+
*/
|
|
38
|
+
export function toastDragDismisses(dy: number, vy: number, height: number): boolean {
|
|
39
|
+
"worklet";
|
|
40
|
+
if (dy >= 0) return false;
|
|
41
|
+
if (vy <= -FLICK_VELOCITY) return true;
|
|
42
|
+
return dy + Math.min(0, vy) * PROJECTION_S < -height / 2;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ToastDragProps = {
|
|
46
|
+
/** The drag dismissed the toast: it has left the screen. */
|
|
47
|
+
onDismiss: () => void;
|
|
48
|
+
/** A finger is on the toast (`true`), or has lifted: the toast's timer pauses meanwhile. */
|
|
49
|
+
onHoldChange: (held: boolean) => void;
|
|
50
|
+
children: ReactNode;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The Gesture Handler toast drag, from `@vvedantb/native-ui/gesture-handler`
|
|
55
|
+
* (`GestureHandlerProvider`). A context, so only that entry imports
|
|
56
|
+
* `react-native-gesture-handler`; without it a toast closes on a tap only.
|
|
57
|
+
*/
|
|
58
|
+
export const ToastDragContext = createContext<ComponentType<ToastDragProps> | null>(null);
|
package/src/typography.ts
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
|
+
/** The four font families: one per weight, as the app loads them. */
|
|
2
|
+
export type ThemeFonts = { regular: string; medium: string; semibold: string; bold: string };
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The font family names JS styles use, Instrument Sans by default (the
|
|
6
|
+
* Expo Google Fonts names, as `INSTRUMENT_SANS` in tailwind-preset.cjs).
|
|
7
|
+
* `configureTheme({ font })` writes into this object, and components read it at
|
|
8
|
+
* render, so do not copy a value out at import.
|
|
9
|
+
*/
|
|
10
|
+
export const fontFamily: ThemeFonts = {
|
|
11
|
+
regular: "InstrumentSans_400Regular",
|
|
12
|
+
medium: "InstrumentSans_500Medium",
|
|
13
|
+
semibold: "InstrumentSans_600SemiBold",
|
|
14
|
+
bold: "InstrumentSans_700Bold",
|
|
15
|
+
};
|
|
16
|
+
|
|
1
17
|
/**
|
|
2
|
-
* NativeWind classes for Instrument Sans. The app
|
|
3
|
-
* these classes to them (see README
|
|
4
|
-
*
|
|
18
|
+
* NativeWind classes for the four faces (Instrument Sans by default). The app
|
|
19
|
+
* loads the font files and maps these classes to them (see README, and the
|
|
20
|
+
* preset's `font` option). Literal strings, so Tailwind's source scan
|
|
21
|
+
* generates them.
|
|
5
22
|
*/
|
|
6
23
|
export const fontClass = {
|
|
7
24
|
regular: "font-instrument-sans",
|
package/src/useScheme.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { useColorScheme } from "nativewind";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* The number of `configureTheme` calls, given by `NativeUiProvider`. A part
|
|
6
|
+
* that reads it redraws after each call, so a palette set at run time shows
|
|
7
|
+
* at once, even under a memoised parent.
|
|
8
|
+
*/
|
|
9
|
+
export const ThemeVersionContext = createContext(0);
|
|
10
|
+
|
|
11
|
+
/** Active colour scheme as a `tokens` / `palette` key. Redraws the caller after each `configureTheme`. */
|
|
4
12
|
export function useScheme(): "light" | "dark" {
|
|
13
|
+
useContext(ThemeVersionContext);
|
|
5
14
|
return useColorScheme().colorScheme === "dark" ? "dark" : "light";
|
|
6
15
|
}
|