@vvedantb/native-ui 0.17.1 → 0.17.2

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 CHANGED
@@ -59,6 +59,20 @@ To hide a tab, give its screen Expo Router's `href: null`. From 0.3.9 `FloatingT
59
59
 
60
60
  From 0.3.16 the focused tab sits on a rounded pill with the `TabsTrigger` pills outline, with the foreground label and icon; the other tabs are muted. From 0.3.17 the pill fill is `floatingTabPillColors(scheme)`: the muted `palette.default` (`--default`, `#ebebeb`) in light, so it shows on the near-white glass bar, and `palette.tabBarPill` (`#474747`, the kit `--segment`) in dark. `palette.tabBarPill` is its own key, so an app that themes `segment` (the segmented `Tabs` thumb) keeps a pill that shows on the dark glass bar. On the web the `--segment` pill sits on a `--default` track; here the bar is the raised surface, so the light pill takes the track fill. A route's `tabBarActiveTintColor` and `tabBarInactiveTintColor` still override the label and icon colours. The pill is one tab wide, inset 4 from the bar on every side, and slides to a newly focused tab over the web `--tabs-dur` (200 ms) and `--tabs-ease`. It counts only the tabs drawn, and there is none while a hidden route is focused.
61
61
 
62
+ ## Gallery, Maestro and CI
63
+
64
+ `apps/native-gallery` (at the repository root) is an Expo SDK 57 app that renders every component from every entry point, with a layering stress screen (a tab bar over glass, with dropdowns, selects, sheets, dialogs and toasts over it) and a keyboard screen (`PromptInput`, inline and stacked). Clerk is mocked there, so it needs no keys. Run it:
65
+
66
+ ```sh
67
+ pnpm install
68
+ pnpm --filter native-gallery start
69
+ # or cd apps/native-gallery && npx expo start
70
+ ```
71
+
72
+ `tests/galleryRegistry.test.ts` fails when a React component export has no entry in `apps/native-gallery/src/galleryRegistry.ts`. Maestro flows in `apps/native-gallery/.maestro/` drive the dropdown, composer, sheets and select. The `native-ui-gallery-android` GitHub Actions job builds the gallery, boots an API 34 emulator and runs them for pull requests that touch this package or the gallery. A new or changed component needs a gallery entry, a Maestro flow and a hand test on an Android emulator.
73
+
74
+ The `Select` note stays: mount one `<PortalHost />` from `@rn-primitives/portal` at the app root, outside any `Modal`. The gallery does so in `app/_layout.tsx`.
75
+
62
76
  ## Entry points
63
77
 
64
78
  The root entry exports everything. It still imports `BottomSheetContainer` and `DateTimeField`, so Metro resolves `@gorhom/bottom-sheet`, `@react-native-community/datetimepicker` and `dayjs` for any import from it. Apps without those peers, such as V Store, should import from the smaller entries below. Between them, they export every name the root exports.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/native-ui",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
4
4
  "license": "MIT",
5
5
  "description": "Shared React Native / Expo UI primitives. Floating surfaces, forms, overlays, toasts, settings chrome, tab bar, AI chat parts, the family app marks, a NativeWind preset, the Expo web root and Clerk + Convex auth screens.",
6
6
  "type": "module",
@@ -33,7 +33,7 @@ export function AmbientBackground({
33
33
  <View
34
34
  pointerEvents="none"
35
35
  style={{
36
- ...StyleSheet.absoluteFillObject,
36
+ ...StyleSheet.absoluteFill,
37
37
  backgroundColor: tokens.color.background[scheme],
38
38
  }}
39
39
  />
@@ -1,18 +1,76 @@
1
- import {
1
+ import BottomSheet, {
2
2
  BottomSheetBackdrop,
3
- BottomSheetModal,
4
3
  BottomSheetView,
5
4
  } from "@gorhom/bottom-sheet";
6
- import { View, Text, TouchableOpacity } from "react-native";
7
- import { useRef, useEffect, useCallback, JSX } from "react";
5
+ import type { BottomSheetDefaultBackdropProps } from "@gorhom/bottom-sheet/lib/typescript/components/bottomSheetBackdrop/types";
6
+ import { IconX } from "@tabler/icons-react-native";
7
+ import React, { useCallback } from "react";
8
+ import {
9
+ Platform,
10
+ Text,
11
+ TouchableOpacity,
12
+ View,
13
+ type ViewProps,
14
+ } from "react-native";
8
15
  import { Colors } from "./colors";
9
16
  import { textStyle, typeStyle } from "./controlStyles";
10
- import { IconX } from "@tabler/icons-react-native";
11
- import { BottomSheetDefaultBackdropProps } from "@gorhom/bottom-sheet/lib/typescript/components/bottomSheetBackdrop/types";
17
+ import { floatingSolidFill } from "./floating";
12
18
  import { GlassSheetBackground } from "./LiquidGlass";
13
19
  import { useLargerText } from "./theme";
14
20
  import { useScheme } from "./useScheme";
15
21
 
22
+ // One snap point, set explicitly. With dynamic sizing the sheet can measure to
23
+ // height 0 on Android and never appear.
24
+ const SNAP_POINTS = ["40%"];
25
+ const SHEET_RADIUS = 20;
26
+
27
+ /**
28
+ * Opaque sheet chrome for Android. The glass background needs a measured blur
29
+ * target, which is not reliable once an RN `Modal` has been dismissed.
30
+ */
31
+ function SolidSheetBackground({
32
+ style,
33
+ pointerEvents,
34
+ }: {
35
+ style?: ViewProps["style"];
36
+ pointerEvents?: ViewProps["pointerEvents"];
37
+ }) {
38
+ const scheme = useScheme();
39
+ return (
40
+ <View
41
+ pointerEvents={pointerEvents}
42
+ style={[
43
+ style,
44
+ {
45
+ backgroundColor: floatingSolidFill[scheme],
46
+ borderTopLeftRadius: SHEET_RADIUS,
47
+ borderTopRightRadius: SHEET_RADIUS,
48
+ },
49
+ ]}
50
+ />
51
+ );
52
+ }
53
+
54
+ function SheetBackdrop(props: BottomSheetDefaultBackdropProps) {
55
+ return (
56
+ <BottomSheetBackdrop
57
+ {...props}
58
+ appearsOnIndex={0}
59
+ disappearsOnIndex={-1}
60
+ opacity={0.5}
61
+ />
62
+ );
63
+ }
64
+
65
+ /**
66
+ * A controlled bottom sheet over the screen, built on gorhom's default-export
67
+ * `BottomSheet` (the named `BottomSheet` export is undefined).
68
+ *
69
+ * It renders nothing while `visible` is false. A closed gorhom sheet still lays
70
+ * out an absolute-fill host that can cover the triggers beneath it, and
71
+ * `BottomSheetModal.present()` did nothing after an RN `Modal` had been
72
+ * dismissed on Android.
73
+ */
16
74
  export function BottomSheetContainer({
17
75
  visible,
18
76
  onClose,
@@ -26,53 +84,43 @@ export function BottomSheetContainer({
26
84
  children: React.ReactNode;
27
85
  closeTestID?: string;
28
86
  }) {
29
- const bottomSheetModalRef = useRef<BottomSheetModal>(null);
30
87
  const largerText = useLargerText();
31
- const colorScheme = useScheme();
32
-
33
- useEffect(() => {
34
- if (visible) {
35
- bottomSheetModalRef.current?.present();
36
- } else {
37
- bottomSheetModalRef.current?.dismiss();
38
- }
39
- }, [visible]);
40
-
88
+ const isDark = useScheme() === "dark";
41
89
  const renderBackdrop = useCallback(
42
- (props: JSX.IntrinsicAttributes & BottomSheetDefaultBackdropProps) => (
43
- <BottomSheetBackdrop
44
- {...props}
45
- disappearsOnIndex={-1}
46
- appearsOnIndex={0}
47
- opacity={0.5}
48
- />
49
- ),
90
+ (props: BottomSheetDefaultBackdropProps) => <SheetBackdrop {...props} />,
50
91
  [],
51
92
  );
52
93
 
94
+ if (!visible) {
95
+ return null;
96
+ }
97
+
53
98
  return (
54
- <BottomSheetModal
55
- ref={bottomSheetModalRef}
99
+ <BottomSheet
100
+ index={0}
101
+ snapPoints={SNAP_POINTS}
102
+ enableDynamicSizing={false}
103
+ enablePanDownToClose
104
+ enableContentPanningGesture={false}
105
+ enableHandlePanningGesture
106
+ keyboardBehavior="interactive"
107
+ onClose={onClose}
56
108
  backdropComponent={renderBackdrop}
57
- onDismiss={onClose}
58
- backgroundComponent={GlassSheetBackground}
109
+ backgroundComponent={
110
+ Platform.OS === "android" ? SolidSheetBackground : GlassSheetBackground
111
+ }
59
112
  handleIndicatorStyle={{
60
- backgroundColor:
61
- colorScheme === "dark" ? Colors.white : Colors.neutral[800],
113
+ backgroundColor: isDark ? Colors.white : Colors.neutral[800],
62
114
  }}
63
- enableContentPanningGesture={false}
64
- enableHandlePanningGesture={true}
65
- keyboardBehavior="interactive"
66
115
  >
67
- <BottomSheetView className="p-5 pb-12">
116
+ {/* Padding is a style, not a NativeWind class: `BottomSheetView` does not
117
+ always apply `className`, and an unpadded sheet can measure too small. */}
118
+ <BottomSheetView style={{ padding: 20, paddingBottom: 48 }}>
68
119
  <View className="mb-4 flex-row items-center justify-between gap-3">
69
120
  <Text
70
121
  accessibilityRole="header"
71
122
  className={`${largerText ? "text-2xl" : "text-xl"} flex-1 font-instrument-sans-semibold`}
72
- style={[
73
- textStyle("foreground", colorScheme === "dark" ? "dark" : "light"),
74
- typeStyle("semibold"),
75
- ]}
123
+ style={[textStyle("foreground", isDark ? "dark" : "light"), typeStyle("semibold")]}
76
124
  >
77
125
  {title}
78
126
  </Text>
@@ -80,19 +128,16 @@ export function BottomSheetContainer({
80
128
  testID={closeTestID}
81
129
  accessibilityRole="button"
82
130
  accessibilityLabel="Close"
83
- onPress={() => bottomSheetModalRef.current?.dismiss()}
131
+ onPress={onClose}
84
132
  className="-mr-2 h-12 w-12 items-center justify-center"
85
133
  hitSlop={8}
86
134
  activeOpacity={0.7}
87
135
  >
88
- <IconX
89
- size={largerText ? 32 : 28}
90
- color={colorScheme === "dark" ? "white" : "black"}
91
- />
136
+ <IconX size={largerText ? 32 : 28} color={isDark ? "white" : "black"} />
92
137
  </TouchableOpacity>
93
138
  </View>
94
139
  {children}
95
140
  </BottomSheetView>
96
- </BottomSheetModal>
141
+ </BottomSheet>
97
142
  );
98
143
  }
@@ -28,7 +28,7 @@ import { useControllableState } from "./primitives/use-controllable-state";
28
28
  /**
29
29
  * Sizes from the web menu classes (`_menu-classes.ts`): content `min-w-[12rem]
30
30
  * p-1.5 rounded-lg`, `sideOffset` 4 and `collisionPadding` 8; items `px-2.5
31
- * py-2 rounded-menu-item` (8) with a 16 icon `gap-2`; radio items `pl-8` with
31
+ * py-3 rounded-menu-item` (8) with a 16 icon `gap-2`; radio items `pl-8` with
32
32
  * the check at `left-2.5`; the label `px-2.5 py-1.5`; the separator `-mx-1.5
33
33
  * my-1 h-px`. Items are at least 48 high, a phone menu row (the web
34
34
  * `max-sm:min-h-10` is 40).
@@ -42,7 +42,7 @@ export const DROPDOWN_MENU = {
42
42
  itemHeight: 48,
43
43
  itemRadius: 8,
44
44
  itemPaddingX: 10,
45
- itemPaddingY: 8,
45
+ itemPaddingY: 12,
46
46
  itemGap: 8,
47
47
  indicatorInset: 32,
48
48
  indicatorLeft: 10,
@@ -394,6 +394,7 @@ export function PromptInput({
394
394
  </View>
395
395
  ) : null}
396
396
  <FieldInput
397
+ testID={testID ? `${testID}-input` : undefined}
397
398
  nativeID={COMPOSER_INPUT_ID}
398
399
  multiline
399
400
  numberOfLines={ONE_ROW}
@@ -440,6 +441,7 @@ export function PromptInput({
440
441
  {header ? <View className="flex-row flex-wrap gap-1 px-2 pt-2">{header}</View> : null}
441
442
  {/* The `Textarea` field without its `numberOfLines` (default 4), which on Android sets the line count: this one starts at one line and grows. */}
442
443
  <FieldInput
444
+ testID={testID ? `${testID}-input` : undefined}
443
445
  nativeID={COMPOSER_INPUT_ID}
444
446
  multiline
445
447
  numberOfLines={ONE_ROW}
@@ -0,0 +1,55 @@
1
+ import * as SelectPrimitive from "@rn-primitives/select";
2
+ import * as React from "react";
3
+ import { Modal, Platform, View } from "react-native";
4
+ import { FullWindowOverlay } from "react-native-screens";
5
+ import { useReduceMotion } from "../useReduceMotion";
6
+
7
+ /**
8
+ * Where `SelectContent` draws its options.
9
+ *
10
+ * - Android: a transparent RN `Modal` with a full-size `box-none` host, as
11
+ * `DropdownMenuContent` and `OverlayModal` use. The Modal is its own window,
12
+ * so options sit above an absolute tab bar or glass layer. `SelectContent`
13
+ * renders this host without a Portal, because a Modal inside a portal mounts
14
+ * empty. With no Portal there is no `open` gate from the primitive, so the
15
+ * host mounts the Modal only while the Select is open. Android back closes it.
16
+ * - iOS: `FullWindowOverlay`, which sits above native navigation chrome.
17
+ * - Web: no wrapper. Radix owns the layering.
18
+ *
19
+ * `Platform.OS` is read at render, not at import, so a test can switch it.
20
+ */
21
+ export function SelectHost({ children }: { children: React.ReactNode }) {
22
+ const { open, onOpenChange, setTriggerPosition, setContentLayout } =
23
+ SelectPrimitive.useRootContext();
24
+ const reduceMotion = useReduceMotion();
25
+
26
+ if (Platform.OS === "ios") {
27
+ return <FullWindowOverlay>{children}</FullWindowOverlay>;
28
+ }
29
+ if (Platform.OS !== "android") {
30
+ return <>{children}</>;
31
+ }
32
+ if (!open) {
33
+ return null;
34
+ }
35
+
36
+ const close = () => {
37
+ setTriggerPosition(null);
38
+ setContentLayout(null);
39
+ onOpenChange(false);
40
+ };
41
+
42
+ return (
43
+ <Modal
44
+ visible
45
+ transparent
46
+ statusBarTranslucent
47
+ animationType={reduceMotion ? "none" : "fade"}
48
+ onRequestClose={close}
49
+ >
50
+ <View style={{ flex: 1 }} pointerEvents="box-none" testID="SelectContent-host">
51
+ {children}
52
+ </View>
53
+ </Modal>
54
+ );
55
+ }
@@ -5,21 +5,20 @@ import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react-n
5
5
  import * as React from "react";
6
6
  import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
7
7
  import { FadeIn, FadeOut } from "react-native-reanimated";
8
- import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
9
8
  import { NativeOnlyAnimatedView } from "./native-only-animated-view";
9
+ import { SelectHost } from "./select-host";
10
10
  import { TextClassContext } from "./text";
11
11
  import { LiquidGlass } from "../LiquidGlass";
12
12
  import { cn } from "../cn";
13
13
  import { textStyle, typeStyle } from "../controlStyles";
14
14
  import { tokens } from "../tokens";
15
15
  import { useScheme } from "../useScheme";
16
+ import { useLargerText } from "../theme";
16
17
 
17
18
  type Option = SelectPrimitive.Option;
18
19
 
19
20
  const Select = SelectPrimitive.Root;
20
21
  const SelectGroup = SelectPrimitive.Group;
21
- const FullWindowOverlay =
22
- Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment;
23
22
 
24
23
  type SelectValueProps = React.ComponentPropsWithoutRef<
25
24
  typeof SelectPrimitive.Value
@@ -85,48 +84,60 @@ const SelectContent = React.forwardRef<
85
84
  React.ElementRef<typeof SelectPrimitive.Content>,
86
85
  SelectContentProps
87
86
  >(({ className, children, position = "popper", portalHost, ...props }, ref) => {
88
- return (
89
- <SelectPrimitive.Portal hostName={portalHost}>
90
- <FullWindowOverlay>
91
- <SelectPrimitive.Overlay
92
- style={Platform.select({ native: StyleSheet.absoluteFill })}
87
+ const overlayFill = Platform.select({ native: StyleSheet.absoluteFill });
88
+ const content = (
89
+ <SelectPrimitive.Overlay style={overlayFill}>
90
+ <TextClassContext.Provider value="text-popover-foreground">
91
+ <NativeOnlyAnimatedView
92
+ entering={FadeIn}
93
+ exiting={FadeOut}
94
+ className="z-50"
95
+ // Content is absolutely positioned, so a bare wrapper has zero height
96
+ // and UI tests drop its children as invisible. Fill the overlay and
97
+ // let touches fall through to it.
98
+ style={overlayFill}
99
+ pointerEvents={Platform.select({ native: "box-none" })}
93
100
  >
94
- <TextClassContext.Provider value="text-popover-foreground">
95
- <NativeOnlyAnimatedView
96
- entering={FadeIn}
97
- exiting={FadeOut}
98
- className="z-50"
101
+ <SelectPrimitive.Content
102
+ ref={ref}
103
+ position={position}
104
+ className={cn(
105
+ "z-50 min-w-[8rem] rounded-lg",
106
+ Platform.select({
107
+ native: "p-1",
108
+ web: "max-h-52 overflow-hidden",
109
+ }),
110
+ className,
111
+ )}
112
+ {...props}
113
+ >
114
+ <LiquidGlass
115
+ pointerEvents="none"
116
+ elevated={false}
117
+ className="absolute inset-0 rounded-lg"
118
+ />
119
+ <SelectScrollUpButton />
120
+ <SelectPrimitive.Viewport
121
+ className={cn("p-1", position === "popper" && "w-full")}
99
122
  >
100
- <SelectPrimitive.Content
101
- ref={ref}
102
- position={position}
103
- className={cn(
104
- "z-50 min-w-[8rem] rounded-lg",
105
- Platform.select({
106
- native: "p-1",
107
- web: "max-h-52 overflow-hidden",
108
- }),
109
- className,
110
- )}
111
- {...props}
112
- >
113
- <LiquidGlass
114
- pointerEvents="none"
115
- elevated={false}
116
- className="absolute inset-0 rounded-lg"
117
- />
118
- <SelectScrollUpButton />
119
- <SelectPrimitive.Viewport
120
- className={cn("p-1", position === "popper" && "w-full")}
121
- >
122
- {children}
123
- </SelectPrimitive.Viewport>
124
- <SelectScrollDownButton />
125
- </SelectPrimitive.Content>
126
- </NativeOnlyAnimatedView>
127
- </TextClassContext.Provider>
128
- </SelectPrimitive.Overlay>
129
- </FullWindowOverlay>
123
+ {children}
124
+ </SelectPrimitive.Viewport>
125
+ <SelectScrollDownButton />
126
+ </SelectPrimitive.Content>
127
+ </NativeOnlyAnimatedView>
128
+ </TextClassContext.Provider>
129
+ </SelectPrimitive.Overlay>
130
+ );
131
+
132
+ // Android: no Portal. A Modal inside a portal mounts empty, so `SelectHost`
133
+ // renders the Modal directly and gates it on `open`. iOS and web keep the
134
+ // Portal; `SelectHost` adds the full-window layer on iOS.
135
+ if (Platform.OS === "android") {
136
+ return <SelectHost>{content}</SelectHost>;
137
+ }
138
+ return (
139
+ <SelectPrimitive.Portal hostName={portalHost}>
140
+ <SelectHost>{content}</SelectHost>
130
141
  </SelectPrimitive.Portal>
131
142
  );
132
143
  });
@@ -166,12 +177,15 @@ const SelectItem = React.forwardRef<
166
177
  SelectItemProps
167
178
  >(({ className, disabled, ...props }, ref) => {
168
179
  const scheme = useScheme();
180
+ const largerText = useLargerText();
169
181
  return (
170
182
  <SelectPrimitive.Item
171
183
  ref={ref}
172
184
  disabled={disabled}
173
185
  className={cn(
174
- "relative flex-row items-center gap-2 rounded-sm py-3 pl-2 pr-8 active:opacity-70",
186
+ // 44 high (56 with larger text) so a row is a full touch target.
187
+ "relative min-h-[44px] flex-row items-center gap-2 rounded-sm py-3 pl-2 pr-8 active:opacity-70",
188
+ largerText && "min-h-[56px]",
175
189
  disabled && "opacity-50",
176
190
  className,
177
191
  )}