@vvedantb/native-ui 0.12.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +82 -19
  2. package/package.json +36 -3
  3. package/src/BottomSheetContainer.tsx +2 -2
  4. package/src/BulkActionBar.tsx +240 -105
  5. package/src/Button.tsx +11 -7
  6. package/src/ConfirmDialog.tsx +25 -35
  7. package/src/Dialog.tsx +47 -35
  8. package/src/DividedList.tsx +11 -7
  9. package/src/DropdownMenu.tsx +22 -8
  10. package/src/FloatingSurface.tsx +5 -2
  11. package/src/FloatingTabBar.tsx +13 -22
  12. package/src/HeaderIconButton.tsx +4 -3
  13. package/src/Input.tsx +2 -2
  14. package/src/LoadingState.tsx +132 -0
  15. package/src/PageTitle.tsx +9 -2
  16. package/src/Pager.tsx +62 -0
  17. package/src/PasswordInput.tsx +1 -1
  18. package/src/PillTabs.tsx +144 -34
  19. package/src/Progress.tsx +9 -7
  20. package/src/Select.tsx +4 -4
  21. package/src/Sheet.tsx +29 -22
  22. package/src/SheetRow.tsx +27 -9
  23. package/src/Skeleton.tsx +91 -9
  24. package/src/SurfaceRow.tsx +111 -0
  25. package/src/SwipeableRow.tsx +43 -0
  26. package/src/Switch.tsx +6 -4
  27. package/src/Tabs.tsx +68 -13
  28. package/src/Toast.tsx +107 -65
  29. package/src/ai/activity-step-detail.tsx +1 -3
  30. package/src/ai/activity-step-label.ts +981 -0
  31. package/src/ai/activity-step-types.ts +107 -0
  32. package/src/ai/activity-tasks-utils.ts +199 -0
  33. package/src/ai/activity-timeline.ts +45 -0
  34. package/src/ai/attachments.tsx +3 -1
  35. package/src/ai/card-button.tsx +49 -0
  36. package/src/ai/chat-text.ts +13 -7
  37. package/src/ai/chat-thread.tsx +34 -27
  38. package/src/ai/choice-question.tsx +154 -35
  39. package/src/ai/code-block.tsx +189 -49
  40. package/src/ai/collapsible.tsx +19 -13
  41. package/src/ai/conversation.tsx +390 -57
  42. package/src/ai/icon-button.tsx +41 -6
  43. package/src/ai/index.ts +52 -1
  44. package/src/ai/message-action-sheet.tsx +168 -0
  45. package/src/ai/message-quote.tsx +98 -0
  46. package/src/ai/message.tsx +226 -31
  47. package/src/ai/prompt-input-menu.tsx +197 -0
  48. package/src/ai/prompt-input.tsx +308 -73
  49. package/src/ai/reply-quote-chip.tsx +90 -0
  50. package/src/ai/shimmer.tsx +4 -6
  51. package/src/ai/subagent-cards.tsx +209 -0
  52. package/src/ai/tool-approval-card.tsx +162 -0
  53. package/src/ai/trace.tsx +54 -17
  54. package/src/ai/unified-diff.tsx +182 -0
  55. package/src/auth/GoogleSignInButton.tsx +6 -3
  56. package/src/auth/SignInScreen.tsx +11 -2
  57. package/src/brand/ProviderIcon.tsx +136 -0
  58. package/src/brand/index.ts +2 -0
  59. package/src/configureTheme.ts +39 -11
  60. package/src/controlStyles.ts +46 -30
  61. package/src/entries/gesture-handler.ts +4 -0
  62. package/src/entries/keyboard-controller.ts +4 -0
  63. package/src/entries/markdown.ts +1 -1
  64. package/src/entries/primitives.ts +24 -3
  65. package/src/entries/theme.ts +2 -1
  66. package/src/entries/video.ts +4 -0
  67. package/src/entries/webview.ts +6 -0
  68. package/src/floating.ts +25 -4
  69. package/src/haptics.ts +30 -4
  70. package/src/highlight.ts +302 -1
  71. package/src/index.ts +27 -5
  72. package/src/loadingGrid.ts +75 -0
  73. package/src/markdown/Markdown.tsx +234 -32
  74. package/src/markdown/MarkdownTable.tsx +188 -0
  75. package/src/markdown/tableText.ts +58 -0
  76. package/src/marketing/UpgradeToProCard.tsx +4 -3
  77. package/src/media/ImageGallery.tsx +224 -0
  78. package/src/media/MediaCard.tsx +100 -0
  79. package/src/media/imageGrid.ts +51 -0
  80. package/src/motion.ts +33 -0
  81. package/src/pagerSwipe.ts +102 -0
  82. package/src/palette.ts +8 -1
  83. package/src/primitives/button.tsx +4 -1
  84. package/src/primitives/keyboard-controller.tsx +106 -0
  85. package/src/primitives/keyboard-parts.tsx +104 -0
  86. package/src/primitives/overlay.tsx +133 -45
  87. package/src/primitives/pager-gesture.tsx +131 -0
  88. package/src/primitives/sheet-drag.tsx +305 -0
  89. package/src/primitives/sheet-gesture.tsx +181 -0
  90. package/src/primitives/swipe-row-gesture.tsx +290 -0
  91. package/src/primitives/toast-drag-gesture.tsx +92 -0
  92. package/src/primitives/web-click-guard.ts +50 -0
  93. package/src/relativeTime.ts +94 -0
  94. package/src/router/RouterStackPageHeader.tsx +2 -2
  95. package/src/settings/SettingsEmptyState.tsx +20 -10
  96. package/src/settings/SettingsField.tsx +11 -5
  97. package/src/settings/SettingsSection.tsx +8 -5
  98. package/src/settings/SettingsToggleRow.tsx +55 -22
  99. package/src/settings/relaxedSizeStyle.ts +11 -0
  100. package/src/sheetDrag.ts +67 -0
  101. package/src/swipeRow.ts +169 -0
  102. package/src/tabBarRoutes.ts +18 -0
  103. package/src/tabIndicator.ts +71 -0
  104. package/src/tailwind-preset.cjs +13 -6
  105. package/src/tailwind-preset.d.cts +5 -0
  106. package/src/theme.tsx +6 -2
  107. package/src/toastDrag.ts +58 -0
  108. package/src/typography.ts +20 -3
  109. package/src/useScheme.ts +10 -1
  110. package/src/video/InlineVideo.tsx +91 -0
  111. package/src/webview/HtmlView.tsx +91 -0
  112. package/src/webview/HtmlView.web.tsx +21 -0
  113. package/src/webview/htmlView.ts +45 -0
package/src/Dialog.tsx CHANGED
@@ -1,5 +1,4 @@
1
1
  import {
2
- ScrollView,
3
2
  Text,
4
3
  View,
5
4
  useWindowDimensions,
@@ -7,7 +6,6 @@ import {
7
6
  type TextProps,
8
7
  type ViewProps,
9
8
  } from "react-native";
10
- import { SlideInDown } from "react-native-reanimated";
11
9
  import { useSafeAreaInsets } from "react-native-safe-area-context";
12
10
  import { IconX } from "@tabler/icons-react-native";
13
11
  import { FloatingSurface } from "./FloatingSurface";
@@ -18,7 +16,7 @@ import { palette } from "./palette";
18
16
  import { tokens } from "./tokens";
19
17
  import { useLargerText } from "./theme";
20
18
  import { useScheme } from "./useScheme";
21
- import { NativeOnlyAnimatedView } from "./primitives/native-only-animated-view";
19
+ import { SheetDragView, SheetScrollView } from "./primitives/sheet-drag";
22
20
  import {
23
21
  OverlayClose,
24
22
  OverlayModal,
@@ -57,6 +55,13 @@ export type DialogContentProps = ViewProps & {
57
55
  * at every size.
58
56
  */
59
57
  viewportAware?: boolean;
58
+ /**
59
+ * Called once the dialog is on screen. Focus its field here (`ref.focus()`)
60
+ * rather than with `autoFocus`: in a `Modal`, Android may not raise the
61
+ * keyboard for a field focused at mount, and the web focus trap moves focus
62
+ * to the first control (the backdrop) after it.
63
+ */
64
+ onShow?: () => void;
60
65
  };
61
66
 
62
67
  /** Web `rounded-t-2xl` (16) on the compact sheet. */
@@ -64,8 +69,9 @@ const SHEET_RADIUS = tokens.radius.lg;
64
69
 
65
70
  /** The centred panel: at most 480 wide. */
66
71
  const PANEL = { width: "100%", maxWidth: 480, alignSelf: "center" } as const;
67
- /** The compact sheet: its bottom corners sit below the screen edge. */
68
- const SHEET = { marginBottom: -SHEET_RADIUS };
72
+ /** The compact sheet: its bottom corners sit below the screen edge, and it shrinks to the room, as `SheetContent`. */
73
+ const SHEET = { marginBottom: -SHEET_RADIUS, flexShrink: 1 };
74
+ const SHRINK = { flexShrink: 1 };
69
75
 
70
76
  const CENTRED: DialogLayout = { compact: false, stacked: false };
71
77
  const COMPACT: DialogLayout = { compact: true, stacked: false };
@@ -79,8 +85,11 @@ const STACKED: DialogLayout = { compact: true, stacked: true };
79
85
  * In a compact window (the web `COMPACT_OVERLAY_QUERY`, from the window size)
80
86
  * it is the web phone bottom sheet: full width, top corners 16, a handle,
81
87
  * `pt-3 px-6`, the bottom safe-area inset (at least 24) below, and it slides
82
- * up unless Reduce Motion is on. Its bottom corners sit below the screen edge,
83
- * as `SheetContent`'s do. `DialogFooter` stacks its buttons there.
88
+ * up on open and down on close unless Reduce Motion is on. Drag it down to
89
+ * close, as `SheetContent`
90
+ * (when `dismissible`). Its bottom corners sit below the screen edge,
91
+ * as `SheetContent`'s do. `DialogFooter` stacks its buttons there. Taller than
92
+ * the room, it shrinks to fit, so a `DialogBody` inside scrolls.
84
93
  */
85
94
  export function DialogContent({
86
95
  children,
@@ -88,6 +97,7 @@ export function DialogContent({
88
97
  hideCloseButton = false,
89
98
  dismissible = true,
90
99
  viewportAware = true,
100
+ onShow,
91
101
  style,
92
102
  ...props
93
103
  }: DialogContentProps) {
@@ -103,27 +113,27 @@ export function DialogContent({
103
113
  part="DialogContent"
104
114
  placement={compact ? "bottom" : "center"}
105
115
  dismissible={dismissible}
116
+ onShow={onShow}
106
117
  >
107
- <NativeOnlyAnimatedView
108
- entering={compact ? SlideInDown.duration(240) : undefined}
109
- style={compact ? SHEET : PANEL}
110
- >
111
- <FloatingSurface blur={false} radius={compact ? SHEET_RADIUS : tokens.radius.card}>
112
- <View
113
- accessibilityViewIsModal
114
- className={cn(compact ? "gap-4 px-6 pt-3" : "gap-4 p-6", className)}
115
- {...props}
116
- style={[
117
- compact ? { paddingBottom: Math.max(24, insets.bottom) + SHEET_RADIUS } : null,
118
- style,
119
- ]}
120
- >
121
- {compact ? <DialogHandle /> : null}
122
- {children}
123
- </View>
124
- {hideCloseButton ? null : <DialogCloseButton />}
125
- </FloatingSurface>
126
- </NativeOnlyAnimatedView>
118
+ <View style={compact ? SHEET : PANEL}>
119
+ <SheetDragView enabled={compact && dismissible} style={compact ? SHRINK : undefined}>
120
+ <FloatingSurface blur={false} radius={compact ? SHEET_RADIUS : tokens.radius.card} style={compact ? SHRINK : undefined}>
121
+ <View
122
+ accessibilityViewIsModal
123
+ className={cn(compact ? "gap-4 px-6 pt-3" : "gap-4 p-6", className)}
124
+ {...props}
125
+ style={[
126
+ compact ? { paddingBottom: Math.max(24, insets.bottom) + SHEET_RADIUS, flexShrink: 1 } : null,
127
+ style,
128
+ ]}
129
+ >
130
+ {compact ? <DialogHandle /> : null}
131
+ {children}
132
+ </View>
133
+ {hideCloseButton ? null : <DialogCloseButton />}
134
+ </FloatingSurface>
135
+ </SheetDragView>
136
+ </View>
127
137
  </OverlayModal>
128
138
  </DialogLayoutContext.Provider>
129
139
  );
@@ -131,7 +141,7 @@ export function DialogContent({
131
141
 
132
142
  /**
133
143
  * The web compact handle: an `h-1 w-12` pill in `surface-tertiary`, in a
134
- * 44-high row. It is not a drag target, on the web either.
144
+ * 44-high row. The whole sheet drags (`SheetDragView`), the handle with it.
135
145
  */
136
146
  function DialogHandle() {
137
147
  const scheme = useScheme();
@@ -209,17 +219,19 @@ export function DialogDescription({ className, style, ...props }: TextPartProps)
209
219
  );
210
220
  }
211
221
 
212
- /** Scrolling body for long content. Height is capped at `maxHeight` (default 60% of the window). */
213
- export function DialogBody({
214
- maxHeight,
215
- style,
216
- ...props
217
- }: ScrollViewProps & { maxHeight?: number }) {
222
+ /**
223
+ * Scrolling body for long content. Height is capped at `maxHeight` (default
224
+ * 60% of the window). In a bottom sheet (`SheetScrollView`) dragging down
225
+ * scrolls it back first and moves the sheet only from the top, and it holds
226
+ * still while the sheet follows the finger.
227
+ */
228
+ export function DialogBody({ maxHeight, style, ...props }: ScrollViewProps & { maxHeight?: number }) {
218
229
  const { height } = useWindowDimensions();
219
230
  return (
220
- <ScrollView
231
+ <SheetScrollView
221
232
  bounces={false}
222
233
  keyboardShouldPersistTaps="handled"
234
+ scrollEventThrottle={16}
223
235
  style={[{ maxHeight: maxHeight ?? height * 0.6 }, style]}
224
236
  {...props}
225
237
  />
@@ -17,21 +17,25 @@ export type DividedListProps = ViewProps & {
17
17
  * no divider is left hanging.
18
18
  */
19
19
  export function DividedList({ children, inset = 0, className, style, ...props }: DividedListProps) {
20
- const scheme = useScheme();
21
20
  const rows = Children.toArray(children).filter(isValidElement);
22
21
  return (
23
22
  <View className={className} style={style} {...props}>
24
23
  {rows.map((row, index) => (
25
24
  <Fragment key={row.key ?? index}>
26
- {index > 0 ? (
27
- <View
28
- accessible={false}
29
- style={{ height: 1, marginLeft: inset, backgroundColor: tokens.color.border[scheme] }}
30
- />
31
- ) : null}
25
+ {index > 0 ? <ListDivider inset={inset} /> : null}
32
26
  {row}
33
27
  </Fragment>
34
28
  ))}
35
29
  </View>
36
30
  );
37
31
  }
32
+
33
+ /**
34
+ * The hairline `DividedList` draws between two rows (1 high, `border`
35
+ * colour), on its own: the `ItemSeparatorComponent` of a `FlatList` or
36
+ * `SectionList` whose rows should look like a `DividedList`.
37
+ */
38
+ export function ListDivider({ inset = 0 }: { inset?: number }) {
39
+ const scheme = useScheme();
40
+ return <View accessible={false} style={{ height: 1, marginLeft: inset, backgroundColor: tokens.color.border[scheme] }} />;
41
+ }
@@ -16,7 +16,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
16
16
  import { IconCheck, type IconProps } from "@tabler/icons-react-native";
17
17
  import { FloatingSurface } from "./FloatingSurface";
18
18
  import { cn } from "./cn";
19
- import { MIN_TOUCH_TARGET, sizeStyle, typeStyle } from "./controlStyles";
19
+ import { sizeStyle, typeStyle } from "./controlStyles";
20
+ import { selectionHaptic } from "./haptics";
20
21
  import { palette } from "./palette";
21
22
  import { useLargerText } from "./theme";
22
23
  import { tokens } from "./tokens";
@@ -29,7 +30,8 @@ import { useControllableState } from "./primitives/use-controllable-state";
29
30
  * p-1.5 rounded-lg`, `sideOffset` 4 and `collisionPadding` 8; items `px-2.5
30
31
  * py-2 rounded-menu-item` (8) with a 16 icon `gap-2`; radio items `pl-8` with
31
32
  * the check at `left-2.5`; the label `px-2.5 py-1.5`; the separator `-mx-1.5
32
- * my-1 h-px`. Items are at least 44 high (the web `max-sm:min-h-10` is 40).
33
+ * my-1 h-px`. Items are at least 48 high, a phone menu row (the web
34
+ * `max-sm:min-h-10` is 40).
33
35
  */
34
36
  export const DROPDOWN_MENU = {
35
37
  minWidth: 192,
@@ -37,6 +39,7 @@ export const DROPDOWN_MENU = {
37
39
  radius: tokens.radius.lg,
38
40
  sideOffset: 4,
39
41
  edge: 8,
42
+ itemHeight: 48,
40
43
  itemRadius: 8,
41
44
  itemPaddingX: 10,
42
45
  itemPaddingY: 8,
@@ -206,9 +209,11 @@ export function DropdownMenuContent({
206
209
  const window = useWindowDimensions();
207
210
  const insets = useSafeAreaInsets();
208
211
  const reduceMotion = useReduceMotion();
209
- const [contentHeight, setContentHeight] = useState(0);
212
+ // `null` until the content is measured: only then is it known whether the
213
+ // menu fits below the trigger or flips above, so it stays hidden till then.
214
+ const [contentHeight, setContentHeight] = useState<number | null>(null);
210
215
  const close = () => setOpen(false);
211
- const position = dropdownMenuPosition({ trigger, window, insets, align, sideOffset, contentHeight });
216
+ const position = dropdownMenuPosition({ trigger, window, insets, align, sideOffset, contentHeight: contentHeight ?? 0 });
212
217
  return (
213
218
  <Modal
214
219
  visible={open}
@@ -237,6 +242,7 @@ export function DropdownMenuContent({
237
242
  ...position,
238
243
  minWidth: DROPDOWN_MENU.minWidth,
239
244
  maxWidth: window.width - insets.left - insets.right - 2 * DROPDOWN_MENU.edge,
245
+ opacity: contentHeight === null ? 0 : 1,
240
246
  }}
241
247
  >
242
248
  <FloatingSurface blur={false} radius={DROPDOWN_MENU.radius} style={{ maxHeight: position.maxHeight }}>
@@ -257,11 +263,11 @@ export function DropdownMenuContent({
257
263
  );
258
264
  }
259
265
 
260
- /** Item type and icon size: the web `text-sm` with a 16 icon, or `text-lg` with a 22 icon and 56 rows with larger text. */
266
+ /** Item type and icon size: the web `text-sm` with a 16 icon in 48 rows, or `text-lg` with a 22 icon and 56 rows with larger text. */
261
267
  function itemMetrics(largerText: boolean) {
262
268
  return largerText
263
269
  ? { text: sizeStyle("text-lg"), icon: 22, minHeight: 56 }
264
- : { text: sizeStyle("text-sm"), icon: 16, minHeight: MIN_TOUCH_TARGET };
270
+ : { text: sizeStyle("text-sm"), icon: 16, minHeight: DROPDOWN_MENU.itemHeight };
265
271
  }
266
272
 
267
273
  type MenuIcon = ComponentType<IconProps>;
@@ -308,6 +314,8 @@ function MenuRow({
308
314
  disabled={disabled}
309
315
  accessibilityState={{ ...accessibilityState, disabled: Boolean(disabled), ...(checked === undefined ? null : { checked }) }}
310
316
  onPress={() => {
317
+ // A new pick from a set ticks; a plain action and the current choice do not.
318
+ if (checked === false) selectionHaptic();
311
319
  onSelect?.();
312
320
  if (closeOnSelect) setOpen(false);
313
321
  }}
@@ -346,13 +354,19 @@ export type DropdownMenuItemProps = MenuItemBaseProps & {
346
354
  icon?: MenuIcon;
347
355
  /** The web `text-destructive` item: label and icon in the danger colour. */
348
356
  destructive?: boolean;
357
+ /** The web `text-warning` item (such as Archive): label and icon in the warning colour. */
358
+ warning?: boolean;
349
359
  };
350
360
 
351
361
  /** One action row (`menuitem`): an optional icon and the label. */
352
- export function DropdownMenuItem({ icon: Icon, destructive, ...props }: DropdownMenuItemProps) {
362
+ export function DropdownMenuItem({ icon: Icon, destructive, warning, ...props }: DropdownMenuItemProps) {
353
363
  const scheme = useScheme();
354
364
  const metrics = itemMetrics(useLargerText());
355
- const color = destructive ? palette.danger[scheme] : palette.overlayForeground[scheme];
365
+ const color = destructive
366
+ ? palette.danger[scheme]
367
+ : warning
368
+ ? palette.warning[scheme]
369
+ : palette.overlayForeground[scheme];
356
370
  return (
357
371
  <MenuRow
358
372
  part="DropdownMenuItem"
@@ -1,5 +1,4 @@
1
1
  import { Platform, StyleSheet, View, type ViewProps } from "react-native";
2
- import { useColorScheme } from "nativewind";
3
2
  import { glassMode, loadBlur } from "./blur";
4
3
  import { cn } from "./cn";
5
4
  import { hasDefiniteHeight } from "./definiteSize";
@@ -12,6 +11,7 @@ import {
12
11
  } from "./floating";
13
12
  import { useGlassBlurTarget } from "./glassBlur";
14
13
  import { useReduceTransparency } from "./useReduceTransparency";
14
+ import { useScheme } from "./useScheme";
15
15
 
16
16
  type FloatingSurfaceProps = ViewProps & {
17
17
  className?: string;
@@ -29,7 +29,7 @@ export function FloatingSurface({
29
29
  elevated = true,
30
30
  ...props
31
31
  }: FloatingSurfaceProps) {
32
- const { colorScheme } = useColorScheme();
32
+ const colorScheme = useScheme();
33
33
  const reduceTransparency = useReduceTransparency();
34
34
  const blurTarget = useGlassBlurTarget();
35
35
  const BlurView = loadBlur()?.BlurView;
@@ -127,6 +127,9 @@ export function GlassSheetBackground({
127
127
  const styles = StyleSheet.create({
128
128
  clip: {
129
129
  overflow: "hidden",
130
+ // A surface held shorter than its content (a sheet in a short window)
131
+ // shrinks the clip with it, so a scroll view inside can scroll.
132
+ flexShrink: 1,
130
133
  },
131
134
  fill: {
132
135
  flex: 1,
@@ -10,16 +10,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
10
10
  import { floatingTabPillColors, tabColors, typeStyle } from "./controlStyles";
11
11
  import { selectionHaptic } from "./haptics";
12
12
  import { FloatingSurface } from "./FloatingSurface";
13
- import { isTabBarRouteVisible, type FloatingTabOptions } from "./tabBarRoutes";
13
+ import { isTabBarHidden, isTabBarRouteVisible, type FloatingTabOptions } from "./tabBarRoutes";
14
14
  import { useLargerText } from "./theme";
15
15
  import { useReduceMotion } from "./useReduceMotion";
16
16
  import { useScheme } from "./useScheme";
17
- import {
18
- FLOATING_INSET,
19
- FLOATING_PILL_RADIUS,
20
- FLOATING_TAB_BAR_HEIGHT,
21
- FLOATING_TAB_BAR_HEIGHT_LARGE,
22
- } from "./floating";
17
+ import { FLOATING_PILL_RADIUS, tabBarHeight, tabBarSlotStyle } from "./floating";
23
18
 
24
19
  /**
25
20
  * Space between the bar edge and the tabs, on every side of the active pill,
@@ -58,7 +53,8 @@ export type FloatingTabBarProps = {
58
53
  * `tabBarActiveTintColor` and `tabBarInactiveTintColor` override the label
59
54
  * and icon colours. Hide a tab with `options={{ href: null }}` on its
60
55
  * `Tabs.Screen`: the bar draws no tab for it (`isTabBarRouteVisible`), and
61
- * the other tabs keep their focus and navigation.
56
+ * the other tabs keep their focus and navigation. The focused route hides the
57
+ * whole bar with `tabBarStyle: { display: "none" }` (`isTabBarHidden`).
62
58
  */
63
59
  export function FloatingTabBar({
64
60
  state,
@@ -68,27 +64,22 @@ export function FloatingTabBar({
68
64
  const insets = useSafeAreaInsets();
69
65
  const largerText = useLargerText();
70
66
  const scheme = useScheme();
71
- const height = largerText
72
- ? FLOATING_TAB_BAR_HEIGHT_LARGE
73
- : FLOATING_TAB_BAR_HEIGHT;
67
+ const height = tabBarHeight(largerText);
68
+ const focusedRoute = state.routes[state.index];
69
+ if (focusedRoute && isTabBarHidden(descriptors[focusedRoute.key]?.options)) {
70
+ return null;
71
+ }
74
72
  // The pill counts only the tabs drawn. No pill while a hidden route is focused.
75
73
  const visible = state.routes.filter((route) =>
76
74
  isTabBarRouteVisible(descriptors[route.key]?.options),
77
75
  );
78
- const active = visible.findIndex(
79
- (route) => route.key === state.routes[state.index]?.key,
80
- );
76
+ const active = visible.findIndex((route) => route.key === focusedRoute?.key);
81
77
 
82
78
  return (
83
79
  <View
84
80
  pointerEvents="box-none"
85
81
  // Inside the home indicator and, in landscape, the notch.
86
- style={{
87
- position: "absolute",
88
- left: insets.left + FLOATING_INSET,
89
- right: insets.right + FLOATING_INSET,
90
- bottom: FLOATING_INSET + Math.max(insets.bottom, 8),
91
- }}
82
+ style={tabBarSlotStyle(insets)}
92
83
  >
93
84
  <FloatingSurface radius={FLOATING_PILL_RADIUS}>
94
85
  {active >= 0 ? (
@@ -184,11 +175,11 @@ function ActiveTabPill({ index, count }: { index: number; count: number }) {
184
175
  const offset = useSharedValue(left);
185
176
 
186
177
  useEffect(() => {
187
- offset.value = reduceMotion ? left : withTiming(left, PILL_SLIDE);
178
+ offset.set(reduceMotion ? left : withTiming(left, PILL_SLIDE));
188
179
  }, [left, reduceMotion, offset]);
189
180
 
190
181
  const slide = useAnimatedStyle(() => ({
191
- left: `${offset.value}%` as const,
182
+ left: `${offset.get()}%` as const,
192
183
  }));
193
184
 
194
185
  return (
@@ -6,13 +6,14 @@ import { useScheme } from "./useScheme";
6
6
 
7
7
  /**
8
8
  * Fill and border of `HeaderIconButton`: the surface with a white hairline
9
- * (40% in light, 10% in dark). Styles, not `bg-surface`, which an app theme
10
- * that nests `dark` under `surface` keeps white in dark mode.
9
+ * (40%) in light and, in dark, the glass `tokens.color.hairline` of the
10
+ * floating tab bar, so `configureTheme` sets both. Styles, not `bg-surface`,
11
+ * which an app theme that nests `dark` under `surface` keeps white in dark mode.
11
12
  */
12
13
  export function headerIconButtonColors(scheme: "light" | "dark") {
13
14
  return {
14
15
  backgroundColor: tokens.color.surface[scheme],
15
- borderColor: withAlpha(tokens.color.white, scheme === "dark" ? 0.1 : 0.4),
16
+ borderColor: scheme === "dark" ? tokens.color.hairline.dark : withAlpha(tokens.color.white, 0.4),
16
17
  };
17
18
  }
18
19
 
package/src/Input.tsx CHANGED
@@ -21,8 +21,8 @@ const Input = React.forwardRef<
21
21
  ref={ref}
22
22
  placeholderTextColor={placeholderTextColor ?? palette.fieldPlaceholder[scheme]}
23
23
  className={cn(
24
- // The web field: h-10 px-3.5 py-2, 16 px type (its phone size).
25
- "min-h-10 border px-3.5 py-2 text-base font-instrument-sans",
24
+ // The web field px-3.5 py-2 with 16 px type (its phone size), 44 high (the web h-10 is a desktop 40).
25
+ "min-h-11 border px-3.5 py-2 text-base font-instrument-sans",
26
26
  largerText && "min-h-16 px-4 py-4 text-xl font-instrument-sans-medium",
27
27
  className,
28
28
  )}
@@ -0,0 +1,132 @@
1
+ import { View } from "react-native";
2
+ import Animated, {
3
+ useAnimatedStyle,
4
+ useFrameCallback,
5
+ useSharedValue,
6
+ type SharedValue,
7
+ } from "react-native-reanimated";
8
+ import {
9
+ DRIVE_DELAYS,
10
+ DRIVE_SIZES,
11
+ DRIVE_STILL_FRAME_MS,
12
+ driveCellOpacity,
13
+ type LoadingStateSize,
14
+ } from "./loadingGrid";
15
+ import { tokens } from "./tokens";
16
+ import { useReduceMotion } from "./useReduceMotion";
17
+ import { useScheme } from "./useScheme";
18
+
19
+ export type { LoadingStateSize } from "./loadingGrid";
20
+
21
+ export type LoadingStateProps = {
22
+ /** What is running, for assistive tech. Default `"Working"`, as the web. */
23
+ label?: string;
24
+ /** `sm` is the web session-row grid (3 pt cells, 11 pt). Default `"default"` (4 pt cells, 15 pt). */
25
+ size?: LoadingStateSize;
26
+ /** Cell colour. Default the muted text colour. Native has no `currentColor`. */
27
+ color?: string;
28
+ /**
29
+ * One colour per column, left to right, as the web `LoadingState` `colors`
30
+ * (Eva's brand purple-to-blue sweep). Wins over `color`.
31
+ */
32
+ colors?: readonly [string, string, string];
33
+ /** Hold a still frame, for example while the screen is not focused. Default `false`. */
34
+ paused?: boolean;
35
+ };
36
+
37
+ /**
38
+ * The web `LoadingState` with `variant="Drive"` and `iconOnly`: a 3x3 pixel
39
+ * grid whose chevron wavefront drives right. Same cells, delays, 650 ms
40
+ * cycle and keyframes as the web (see `loadingGrid.ts`). One frame clock per
41
+ * grid on the UI thread, started on mount as the web WAAPI pulse is. With
42
+ * Reduce Motion, or `paused`, it holds a still chevron frame and runs no
43
+ * clock. The labelled form (label and elapsed timer) and the `Dots` and
44
+ * `Orbit` variants are not ported.
45
+ */
46
+ export function LoadingState({ label = "Working", size = "default", color, colors, paused = false }: LoadingStateProps) {
47
+ const scheme = useScheme();
48
+ const reduceMotion = useReduceMotion();
49
+ const single = color ?? tokens.color.muted[scheme];
50
+ const fill: Fills = colors ?? [single, single, single];
51
+ const still = reduceMotion || paused;
52
+ return (
53
+ <View
54
+ accessible
55
+ accessibilityRole="progressbar"
56
+ accessibilityLabel={label}
57
+ accessibilityState={{ busy: true }}
58
+ style={{ alignSelf: "flex-start" }}
59
+ >
60
+ {still ? (
61
+ <DriveGrid size={size} fill={fill} elapsed={undefined} />
62
+ ) : (
63
+ <RunningDriveGrid size={size} fill={fill} />
64
+ )}
65
+ </View>
66
+ );
67
+ }
68
+
69
+ /** Column colours, left to right. */
70
+ type Fills = readonly [string, string, string];
71
+
72
+ /** Owns the clock, so unmounting it (Reduce Motion, `paused`) stops the frame callback. */
73
+ function RunningDriveGrid({ size, fill }: { size: LoadingStateSize; fill: Fills }) {
74
+ const elapsed = useSharedValue(0);
75
+ useFrameCallback((frame) => {
76
+ "worklet";
77
+ elapsed.set(frame.timeSinceFirstFrame);
78
+ }, true);
79
+ return <DriveGrid size={size} fill={fill} elapsed={elapsed} />;
80
+ }
81
+
82
+ /** The nine cells. No `elapsed`: the still frame. */
83
+ function DriveGrid({
84
+ size,
85
+ fill,
86
+ elapsed,
87
+ }: {
88
+ size: LoadingStateSize;
89
+ fill: Fills;
90
+ elapsed: SharedValue<number> | undefined;
91
+ }) {
92
+ const { gap } = DRIVE_SIZES[size];
93
+ // Three explicit rows, not a wrapping row: a fractional gap could wrap early.
94
+ return (
95
+ <View style={{ gap }}>
96
+ {[0, 1, 2].map((row) => (
97
+ <View key={row} style={{ flexDirection: "row", gap }}>
98
+ {DRIVE_DELAYS.slice(row * 3, row * 3 + 3).map((delay, column) =>
99
+ elapsed ? (
100
+ <DriveCell key={column} size={size} fill={fill[column] ?? fill[0]} delay={delay} elapsed={elapsed} />
101
+ ) : (
102
+ <View
103
+ key={column}
104
+ style={[cellStyle(size, fill[column] ?? fill[0]), { opacity: driveCellOpacity(DRIVE_STILL_FRAME_MS, delay) }]}
105
+ />
106
+ ),
107
+ )}
108
+ </View>
109
+ ))}
110
+ </View>
111
+ );
112
+ }
113
+
114
+ function DriveCell({
115
+ size,
116
+ fill,
117
+ delay,
118
+ elapsed,
119
+ }: {
120
+ size: LoadingStateSize;
121
+ fill: string;
122
+ delay: number;
123
+ elapsed: SharedValue<number>;
124
+ }) {
125
+ const pulse = useAnimatedStyle(() => ({ opacity: driveCellOpacity(elapsed.get(), delay) }));
126
+ return <Animated.View style={[cellStyle(size, fill), pulse]} />;
127
+ }
128
+
129
+ function cellStyle(size: LoadingStateSize, fill: string) {
130
+ const { cell, radius } = DRIVE_SIZES[size];
131
+ return { width: cell, height: cell, borderRadius: radius, backgroundColor: fill };
132
+ }
package/src/PageTitle.tsx CHANGED
@@ -35,6 +35,11 @@ export function PageTitle({ className, style, ...props }: PageTitleProps) {
35
35
  export type PageHeaderProps = Omit<ViewProps, "children"> & {
36
36
  /** The page title, drawn with `PageTitle`. */
37
37
  title: string;
38
+ /**
39
+ * Content before the title, such as a close `HeaderIconButton` while the
40
+ * page is in select mode (Android's contextual action bar). It keeps its width.
41
+ */
42
+ leading?: ReactNode;
38
43
  /**
39
44
  * Content at the end of the row, such as an "Update all" `Button` (pass it
40
45
  * `fullWidth={false}`). It keeps its width; a long title is cut off instead.
@@ -52,13 +57,14 @@ export type PageHeaderProps = Omit<ViewProps, "children"> & {
52
57
  };
53
58
 
54
59
  /**
55
- * A screen's title row: `PageTitle` at the start and an optional `action` at
56
- * the end, with the `ScreenContainer` side padding (`px-4`). The row is at
60
+ * A screen's title row: an optional `leading` part, `PageTitle` and an
61
+ * optional `action` at the end, with the `ScreenContainer` side padding (`px-4`). The row is at
57
62
  * least `PAGE_HEADER_HEIGHT` (56) high below the top inset, so tab roots with
58
63
  * and without an action put their titles at the same height.
59
64
  */
60
65
  export function PageHeader({
61
66
  title,
67
+ leading,
62
68
  action,
63
69
  safeTop = true,
64
70
  className,
@@ -74,6 +80,7 @@ export function PageHeader({
74
80
  style={[{ paddingTop: top, minHeight: top + PAGE_HEADER_HEIGHT }, style]}
75
81
  {...props}
76
82
  >
83
+ {leading ? <View className="shrink-0">{leading}</View> : null}
77
84
  <PageTitle className={cn("min-w-0 flex-1", titleClassName)}>{title}</PageTitle>
78
85
  {action ? <View className="shrink-0">{action}</View> : null}
79
86
  </View>
package/src/Pager.tsx ADDED
@@ -0,0 +1,62 @@
1
+ import { createContext, useContext, useEffect, type ComponentType, type ReactNode } from "react";
2
+ import { View } from "react-native";
3
+ import type { SharedValue } from "react-native-reanimated";
4
+
5
+ export type PagerProps = {
6
+ /** The page shown, from 0. A change from outside (a tab press, a link) slides there in 220 ms, through the pages between (at once with Reduce Motion, and without the swipe). */
7
+ index: number;
8
+ count: number;
9
+ /** A swipe settled on another page. Set `index` to it. */
10
+ onIndexChange: (index: number) => void;
11
+ /**
12
+ * Where the pages are, in pages (1.5 is halfway between the second and the
13
+ * third). Create it with `useSharedValue(index)` and pass the same value to
14
+ * `PillTabs` (`progress`), so its indicator moves with the pages.
15
+ */
16
+ progress: SharedValue<number>;
17
+ /** Draws one page. A page is drawn once visited or once a swipe could reach it, and then stays, so it keeps its state. */
18
+ renderPage: (index: number) => ReactNode;
19
+ };
20
+
21
+ /**
22
+ * The Gesture Handler pager, from `@vvedantb/native-ui/gesture-handler`
23
+ * (`GestureHandlerProvider`). A context, so only that entry imports
24
+ * `react-native-gesture-handler`.
25
+ */
26
+ export const PagerGestureContext = createContext<ComponentType<PagerProps> | null>(null);
27
+
28
+ /** Set inside a swiped `Pager`: a `SwipeableRow` there leaves the directions it has no actions for to the pager. */
29
+ export const InsidePagerContext = createContext(false);
30
+
31
+ /**
32
+ * Side-by-side pages a horizontal swipe moves between, such as the sections
33
+ * under a row of `PillTabs`. The pages follow the finger 1:1 and, on release,
34
+ * spring on at the finger's speed to the nearest page (a flick turns one
35
+ * page); past the first and last page they rubber-band. The linked
36
+ * `progress` moves the `PillTabs` indicator with them. Only a clearly
37
+ * sideways drag takes the pages: a vertical drag scrolls the page, and a
38
+ * `SwipeableRow` under the finger takes its own swipe first (it lets a drag
39
+ * toward a side it has no actions for through to the pager). A settled page
40
+ * fires a selection haptic and `onIndexChange`. With Reduce Motion on the
41
+ * pages still follow the finger, and the release settles at once.
42
+ *
43
+ * The swipe needs `GestureHandlerProvider`; without it only the current page
44
+ * is drawn, with no swipe.
45
+ */
46
+ export function Pager(props: PagerProps) {
47
+ const GesturePager = useContext(PagerGestureContext);
48
+ if (GesturePager) return <GesturePager {...props} />;
49
+ return <StaticPager {...props} />;
50
+ }
51
+
52
+ function StaticPager({ index, progress, renderPage }: PagerProps) {
53
+ // The linked indicator follows a page set from outside. An effect: a shared value is never written in render.
54
+ useEffect(() => {
55
+ progress.set(index);
56
+ }, [index, progress]);
57
+ return (
58
+ <View key={index} style={{ flex: 1 }}>
59
+ {renderPage(index)}
60
+ </View>
61
+ );
62
+ }
@@ -54,7 +54,7 @@ const PasswordInput = React.forwardRef<
54
54
  // The type comes from these classes only (no style `fontSize`), so it
55
55
  // cannot diverge from NativeWind's `text-base` / `text-xl`.
56
56
  className={cn(
57
- "min-h-10 border px-3.5 py-2 text-base font-instrument-sans",
57
+ "min-h-11 border px-3.5 py-2 text-base font-instrument-sans",
58
58
  largerText && "min-h-16 px-4 py-4 text-xl font-instrument-sans-medium",
59
59
  className,
60
60
  )}