@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.
Files changed (111) hide show
  1. package/README.md +42 -21
  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 +23 -13
  40. package/src/ai/conversation.tsx +394 -57
  41. package/src/ai/icon-button.tsx +41 -6
  42. package/src/ai/index.ts +55 -4
  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/subagent-cards.tsx +84 -14
  49. package/src/ai/tool-approval-card.tsx +139 -8
  50. package/src/ai/trace.tsx +54 -17
  51. package/src/ai/unified-diff.tsx +182 -0
  52. package/src/auth/GoogleSignInButton.tsx +6 -3
  53. package/src/auth/SignInScreen.tsx +11 -2
  54. package/src/brand/ProviderIcon.tsx +136 -0
  55. package/src/brand/index.ts +2 -0
  56. package/src/configureTheme.ts +39 -11
  57. package/src/controlStyles.ts +46 -30
  58. package/src/entries/gesture-handler.ts +4 -0
  59. package/src/entries/keyboard-controller.ts +4 -0
  60. package/src/entries/markdown.ts +1 -1
  61. package/src/entries/primitives.ts +24 -3
  62. package/src/entries/theme.ts +2 -1
  63. package/src/entries/video.ts +4 -0
  64. package/src/entries/webview.ts +6 -0
  65. package/src/examples/AiChatGallery.tsx +70 -0
  66. package/src/floating.ts +25 -4
  67. package/src/haptics.ts +30 -4
  68. package/src/highlight.ts +302 -1
  69. package/src/index.ts +27 -5
  70. package/src/loadingGrid.ts +75 -0
  71. package/src/markdown/Markdown.tsx +234 -32
  72. package/src/markdown/MarkdownTable.tsx +188 -0
  73. package/src/markdown/tableText.ts +58 -0
  74. package/src/marketing/UpgradeToProCard.tsx +4 -3
  75. package/src/media/ImageGallery.tsx +224 -0
  76. package/src/media/MediaCard.tsx +100 -0
  77. package/src/media/imageGrid.ts +51 -0
  78. package/src/motion.ts +33 -0
  79. package/src/pagerSwipe.ts +102 -0
  80. package/src/palette.ts +8 -1
  81. package/src/primitives/button.tsx +4 -1
  82. package/src/primitives/keyboard-controller.tsx +106 -0
  83. package/src/primitives/keyboard-parts.tsx +104 -0
  84. package/src/primitives/overlay.tsx +133 -45
  85. package/src/primitives/pager-gesture.tsx +131 -0
  86. package/src/primitives/sheet-drag.tsx +305 -0
  87. package/src/primitives/sheet-gesture.tsx +181 -0
  88. package/src/primitives/swipe-row-gesture.tsx +290 -0
  89. package/src/primitives/toast-drag-gesture.tsx +92 -0
  90. package/src/primitives/web-click-guard.ts +50 -0
  91. package/src/relativeTime.ts +94 -0
  92. package/src/router/RouterStackPageHeader.tsx +2 -2
  93. package/src/settings/SettingsEmptyState.tsx +20 -10
  94. package/src/settings/SettingsField.tsx +11 -5
  95. package/src/settings/SettingsSection.tsx +8 -5
  96. package/src/settings/SettingsToggleRow.tsx +55 -22
  97. package/src/settings/relaxedSizeStyle.ts +11 -0
  98. package/src/sheetDrag.ts +67 -0
  99. package/src/swipeRow.ts +169 -0
  100. package/src/tabBarRoutes.ts +18 -0
  101. package/src/tabIndicator.ts +71 -0
  102. package/src/tailwind-preset.cjs +13 -6
  103. package/src/tailwind-preset.d.cts +5 -0
  104. package/src/theme.tsx +6 -2
  105. package/src/toastDrag.ts +58 -0
  106. package/src/typography.ts +20 -3
  107. package/src/useScheme.ts +10 -1
  108. package/src/video/InlineVideo.tsx +91 -0
  109. package/src/webview/HtmlView.tsx +91 -0
  110. package/src/webview/HtmlView.web.tsx +21 -0
  111. 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
- return (
21
- <View
22
- className={cn(
23
- "flex-row items-center justify-between gap-4 px-4 py-3",
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
- "font-instrument-sans-semibold",
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
- "mt-1 font-instrument-sans",
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 className={cn("shrink-0 items-center justify-center", "min-h-12")}>
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
- </View>
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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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 Instrument Sans font classes, the colour keys native-ui
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: [INSTRUMENT_SANS.regular],
219
- "instrument-sans": [INSTRUMENT_SANS.regular],
220
- "instrument-sans-medium": [INSTRUMENT_SANS.medium],
221
- "instrument-sans-semibold": [INSTRUMENT_SANS.semibold],
222
- "instrument-sans-bold": [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
  }
@@ -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 loads the font files and maps
3
- * these classes to them (see README). Literal strings, so Tailwind's source
4
- * scan generates them.
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
- /** Active colour scheme as a `tokens` / `palette` key. */
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
  }