@vvedantb/native-ui 0.3.15 → 0.3.16
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 +7 -3
- package/package.json +1 -1
- package/src/FloatingTabBar.tsx +95 -10
- package/src/ThemeToggle.tsx +36 -0
- package/src/entries/primitives.ts +2 -0
- package/src/examples/ParityGallery.tsx +3 -0
- package/src/index.ts +2 -0
package/README.md
CHANGED
|
@@ -50,6 +50,8 @@ To hide a tab, give its screen Expo Router's `href: null`. From 0.3.9 `FloatingT
|
|
|
50
50
|
</Tabs>
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
+
From 0.3.16 the focused tab sits on a rounded pill in the web segmented pill fill (`tabColors("segmented", …)`: `--tabs-pill-bg`, `palette.segment`, with the `TabsTrigger` pills outline), with the foreground label and icon; the other tabs are muted. 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.
|
|
54
|
+
|
|
53
55
|
## Entry points
|
|
54
56
|
|
|
55
57
|
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.
|
|
@@ -59,7 +61,7 @@ The root entry exports everything. It still imports `BottomSheetContainer` and `
|
|
|
59
61
|
| `@vvedantb/native-ui/tab-bar` | `FloatingTabBar`, `FloatingTabBarProps`, `FLOATING_INSET`, `FLOATING_TAB_BAR_HEIGHT`, `FLOATING_TAB_BAR_HEIGHT_LARGE`, `tabBarReserve` | `expo-blur`, `expo-haptics` (loaders) |
|
|
60
62
|
| `@vvedantb/native-ui/brand` | `AppMark`, `AppWordmark`, `GoogleMark` (from 0.3.15), `APP_ICONS`, `IconVibot` and the other `Icon*`, the SVG helpers (`APP_MARKS`, `appMarkSvg`, `appTileSvg` and the rest), `FAMILY_APPS`, `familyApp`, `V_PRO_APP_BENEFITS` | none |
|
|
61
63
|
| `@vvedantb/native-ui/button` | `Button`, `ButtonProps`, `ButtonSize`, `ButtonVariant` | none |
|
|
62
|
-
| `@vvedantb/native-ui/primitives` | `Button`, `Input`, `SearchInput`, `Textarea`, `Label`, `Select`, `Switch`, `LabeledSwitchRow`, `Checkbox`, `Card` and its parts, `Badge`, `StatusDot`, `Avatar`, `Separator`, `Progress`, `Spinner`, `Skeleton`, `EmptyState`, `ListRow`, `Chip`, `HeaderIconButton`, `Tabs`, `PillTabs`, `Dialog`, `AlertDialog`, `ConfirmDialog`, `Sheet`, `toast`, `Toaster` | `expo-blur`, `expo-haptics` (loaders) |
|
|
64
|
+
| `@vvedantb/native-ui/primitives` | `Button`, `Input`, `SearchInput`, `Textarea`, `Label`, `Select`, `Switch`, `LabeledSwitchRow`, `Checkbox`, `Card` and its parts, `Badge`, `StatusDot`, `Avatar`, `Separator`, `Progress`, `Spinner`, `Skeleton`, `EmptyState`, `ListRow`, `Chip`, `HeaderIconButton`, `ThemeToggle` (from 0.3.16), `Tabs`, `PillTabs`, `Dialog`, `AlertDialog`, `ConfirmDialog`, `Sheet`, `toast`, `Toaster` | `expo-blur`, `expo-haptics` (loaders) |
|
|
63
65
|
| `@vvedantb/native-ui/search-input` | `SearchInput`, `SearchInputProps` | none |
|
|
64
66
|
| `@vvedantb/native-ui/page-title` | `PageTitle`, `PageHeader`, `PageTitleProps`, `PageHeaderProps` (from 0.3.13) | none |
|
|
65
67
|
| `@vvedantb/native-ui/toast` | `toast`, `Toaster`, `ToastOptions`, `ToastVariant` | none (from 0.3.12) |
|
|
@@ -135,7 +137,7 @@ import { NativeUiProvider, Button, Card } from "@vvedantb/native-ui";
|
|
|
135
137
|
| --- | --- |
|
|
136
138
|
| Actions and fields | `Button` (`default`/`primary`, `secondary`, `outline`, `destructive`, `ghost`, `link`, `tinted`; `sm`/`small`, `default`/`medium`, `lg`/`large`, `icon`), `Input`, `SearchInput` (0.3.9), `Textarea`, `Label`, `Select`, `Switch`, `Checkbox`, `LabeledSwitchRow`, `DateTimeField` |
|
|
137
139
|
| Display | `Card` (+ `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`), `Badge`, `StatusDot`, `Avatar` (+ `AvatarImage`, `AvatarFallback`, `initials()`), `Separator`, `Progress`, `Spinner`, `CenteredSpinner`, `Skeleton`, `EmptyState` (+ `EmptyState.Root` and parts), `ListRow`, `Chip` |
|
|
138
|
-
| Navigation | `Tabs` (+ `TabsList`, `TabsTrigger`, `TabsContent`), `PillTabs`, `FloatingTabBar`, `FloatingFAB`, `HeaderIconButton` |
|
|
140
|
+
| Navigation | `Tabs` (+ `TabsList`, `TabsTrigger`, `TabsContent`), `PillTabs`, `FloatingTabBar`, `FloatingFAB`, `HeaderIconButton`, `ThemeToggle` (0.3.16) |
|
|
139
141
|
| Overlays | `Dialog`, `AlertDialog`, `ConfirmDialog`, `Sheet` (RN `Modal`, each with the web part names), `BottomSheetContainer` (`@gorhom/bottom-sheet`) |
|
|
140
142
|
| Feedback | `toast()` and `<Toaster />` |
|
|
141
143
|
| Brand and upgrade | `APP_ICONS`, `IconVibot` and the other `Icon*`, `AppMark`, `AppWordmark`, `GoogleMark` (0.3.15), `FAMILY_APPS`, `V_PRO_APP_BENEFITS`, `UpgradeToProCard`, `PricingModal` |
|
|
@@ -195,10 +197,12 @@ From 0.3.12 a toast looks like the web `SonnerToaster` at its default density wi
|
|
|
195
197
|
|
|
196
198
|
The components keep the web sizes on screen and reach a 44 pt touch target with `hitSlop` (`touchSlop` in `src/controlStyles.ts`): `Checkbox` (16), `Switch` (36 × 20), tab triggers (30 high), the `Select` trigger (40 high), the `SearchInput` clear button (24), the `UpgradeToProCard` pill (32 high, slop 8) and `Button` (the slop brings each size to at least 48). Controls set their role and state for VoiceOver and TalkBack: `Button` (`disabled`, `busy` while loading), `Checkbox` (`checked`, `mixed`), `Switch` (`checked`), `Tabs` and `PillTabs` (`tablist`, `tab`, `selected`) and the `Select` trigger (`combobox`, with an `accessibilityLabel` prop). Give a `Button` with no text child an `accessibilityLabel`.
|
|
197
199
|
|
|
198
|
-
Text scales with the system font size. Labelled controls have minimum heights, so a large Dynamic Type size grows them instead of clipping the label (the `SearchInput` lock grows with the font scale); only the `FloatingTabBar` labels are capped (1.5×), to fit the fixed bar. With Reduce Motion on, dialogs, sheets, toasts, the `Select` popover, `PricingModal`, `Progress` and `
|
|
200
|
+
Text scales with the system font size. Labelled controls have minimum heights, so a large Dynamic Type size grows them instead of clipping the label (the `SearchInput` lock grows with the font scale); only the `FloatingTabBar` labels are capped (1.5×), to fit the fixed bar. With Reduce Motion on, dialogs, sheets, toasts, the `Select` popover, `PricingModal`, `Progress`, `Skeleton` and the `FloatingTabBar` pill do not animate. Dialogs, sheets, toasts, `FloatingTabBar` and `FloatingFAB` stay inside the safe-area insets, and dialog and sheet fields stay above the keyboard on iOS and Android.
|
|
199
201
|
|
|
200
202
|
The brand marks (`APP_ICONS`, the `Icon*` exports, `AppMark` and `GoogleMark`) are images named by `title`, and hidden from assistive tech without one. From 0.3.14 they set the props each platform reads: `accessibilityRole="image"` and `accessibilityLabel` on iOS and Android, `role="img"` and `aria-label` on web. Without a title they set `accessibilityElementsHidden` and `importantForAccessibility="no-hide-descendants"` on native and `aria-hidden` on web. `testID` is `data-testid` on web. On web `react-native-svg` passes its props straight to the DOM `<svg>`, and React Native Web does not map `accessible`, the `accessibility*` props or `testID` there. So do not put them on an `Svg` or on a Tabler icon: put them on the parent `View` or `Pressable`. The package draws no Lucide icon, as a Lucide icon always sets `testID` on its `Svg`. On web the `Select` options are Radix DOM nodes, so from 0.3.14 they take their name and test id as `aria-label` and `data-testid` there, and as `accessibilityLabel` and `testID` on iOS and Android.
|
|
201
203
|
|
|
204
|
+
`ThemeToggle` (from 0.3.16) is a `HeaderIconButton` that switches between light and dark with NativeWind's `setColorScheme`, the scheme every component reads through `useColorScheme`. It shows and names the scheme it switches to: a moon and "Switch to dark mode" in light, a sun and "Switch to light mode" in dark. It stores nothing: to restore the choice on the next launch, store `useColorScheme().colorScheme` in the app. It takes `testID`.
|
|
205
|
+
|
|
202
206
|
`GoogleMark` (from 0.3.15) is the four-colour Google "G", 18 by default, for a "Continue with Google" button: `<Button variant="outline" leftIcon={<GoogleMark />}>Continue with Google</Button>`. Its colours are fixed, so it takes no `color`. Leave it without a `title` there, as the label names the button.
|
|
203
207
|
|
|
204
208
|
## Floating UI / Android shadows
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/native-ui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.16",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Shared React Native / Expo UI primitives. Floating surfaces, forms, overlays, toasts, settings chrome, tab bar and the family app marks.",
|
|
6
6
|
"type": "module",
|
package/src/FloatingTabBar.tsx
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
1
2
|
import { Pressable, Text, View } from "react-native";
|
|
3
|
+
import Animated, {
|
|
4
|
+
Easing,
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
useSharedValue,
|
|
7
|
+
withTiming,
|
|
8
|
+
} from "react-native-reanimated";
|
|
2
9
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
3
|
-
import { typeStyle } from "./controlStyles";
|
|
10
|
+
import { tabColors, typeStyle } from "./controlStyles";
|
|
4
11
|
import { selectionHaptic } from "./haptics";
|
|
5
12
|
import { FloatingSurface } from "./FloatingSurface";
|
|
6
13
|
import { isTabBarRouteVisible, type FloatingTabOptions } from "./tabBarRoutes";
|
|
7
14
|
import { useLargerText } from "./theme";
|
|
15
|
+
import { useReduceMotion } from "./useReduceMotion";
|
|
16
|
+
import { useScheme } from "./useScheme";
|
|
8
17
|
import {
|
|
9
18
|
FLOATING_INSET,
|
|
10
19
|
FLOATING_PILL_RADIUS,
|
|
@@ -12,6 +21,15 @@ import {
|
|
|
12
21
|
FLOATING_TAB_BAR_HEIGHT_LARGE,
|
|
13
22
|
} from "./floating";
|
|
14
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Space between the bar edge and the tabs, on every side of the active pill,
|
|
26
|
+
* in points. The same all round, so the pill follows the bar's curve.
|
|
27
|
+
*/
|
|
28
|
+
const TAB_BAR_PADDING = 4;
|
|
29
|
+
|
|
30
|
+
/** The web `--tabs-dur` (200ms) and `--tabs-ease` (`tokens.css`). */
|
|
31
|
+
const PILL_SLIDE = { duration: 200, easing: Easing.bezier(0.22, 1, 0.36, 1) };
|
|
32
|
+
|
|
15
33
|
/**
|
|
16
34
|
* The fields this bar reads from the props Expo Router's `Tabs` passes to
|
|
17
35
|
* `tabBar` (React Navigation's `BottomTabBarProps`, Expo SDK 57). Declared
|
|
@@ -35,9 +53,12 @@ export type FloatingTabBarProps = {
|
|
|
35
53
|
|
|
36
54
|
/**
|
|
37
55
|
* The floating pill tab bar, for the `tabBar` prop of Expo Router's `Tabs`.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
56
|
+
* The focused tab sits on a pill in the web segmented pill fill
|
|
57
|
+
* (`tabColors`), with the foreground label; the others are muted. A route's
|
|
58
|
+
* `tabBarActiveTintColor` and `tabBarInactiveTintColor` override the label
|
|
59
|
+
* and icon colours. Hide a tab with `options={{ href: null }}` on its
|
|
60
|
+
* `Tabs.Screen`: the bar draws no tab for it (`isTabBarRouteVisible`), and
|
|
61
|
+
* the other tabs keep their focus and navigation.
|
|
41
62
|
*/
|
|
42
63
|
export function FloatingTabBar({
|
|
43
64
|
state,
|
|
@@ -46,9 +67,17 @@ export function FloatingTabBar({
|
|
|
46
67
|
}: FloatingTabBarProps) {
|
|
47
68
|
const insets = useSafeAreaInsets();
|
|
48
69
|
const largerText = useLargerText();
|
|
70
|
+
const scheme = useScheme();
|
|
49
71
|
const height = largerText
|
|
50
72
|
? FLOATING_TAB_BAR_HEIGHT_LARGE
|
|
51
73
|
: FLOATING_TAB_BAR_HEIGHT;
|
|
74
|
+
// The pill counts only the tabs drawn. No pill while a hidden route is focused.
|
|
75
|
+
const visible = state.routes.filter((route) =>
|
|
76
|
+
isTabBarRouteVisible(descriptors[route.key]?.options),
|
|
77
|
+
);
|
|
78
|
+
const active = visible.findIndex(
|
|
79
|
+
(route) => route.key === state.routes[state.index]?.key,
|
|
80
|
+
);
|
|
52
81
|
|
|
53
82
|
return (
|
|
54
83
|
<View
|
|
@@ -62,9 +91,12 @@ export function FloatingTabBar({
|
|
|
62
91
|
}}
|
|
63
92
|
>
|
|
64
93
|
<FloatingSurface radius={FLOATING_PILL_RADIUS}>
|
|
94
|
+
{active >= 0 ? (
|
|
95
|
+
<ActiveTabPill index={active} count={visible.length} />
|
|
96
|
+
) : null}
|
|
65
97
|
<View
|
|
66
|
-
className="flex-row items-center justify-around
|
|
67
|
-
style={{ height }}
|
|
98
|
+
className="flex-row items-center justify-around"
|
|
99
|
+
style={{ height, paddingHorizontal: TAB_BAR_PADDING }}
|
|
68
100
|
accessibilityRole="tablist"
|
|
69
101
|
>
|
|
70
102
|
{state.routes.map((route, index) => {
|
|
@@ -80,9 +112,11 @@ export function FloatingTabBar({
|
|
|
80
112
|
: typeof options.title === "string"
|
|
81
113
|
? options.title
|
|
82
114
|
: route.name;
|
|
83
|
-
const color =
|
|
84
|
-
|
|
85
|
-
|
|
115
|
+
const color =
|
|
116
|
+
(focused
|
|
117
|
+
? options.tabBarActiveTintColor
|
|
118
|
+
: options.tabBarInactiveTintColor) ??
|
|
119
|
+
tabColors("segmented", focused, scheme).color;
|
|
86
120
|
const onPress = () => {
|
|
87
121
|
const event = navigation.emit({
|
|
88
122
|
type: "tabPress",
|
|
@@ -109,7 +143,7 @@ export function FloatingTabBar({
|
|
|
109
143
|
{options.tabBarIcon
|
|
110
144
|
? options.tabBarIcon({
|
|
111
145
|
focused,
|
|
112
|
-
color
|
|
146
|
+
color,
|
|
113
147
|
size: largerText ? 28 : 24,
|
|
114
148
|
})
|
|
115
149
|
: null}
|
|
@@ -133,3 +167,54 @@ export function FloatingTabBar({
|
|
|
133
167
|
</View>
|
|
134
168
|
);
|
|
135
169
|
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The pill behind tab `index` of `count` drawn tabs: one tab wide (they share
|
|
173
|
+
* the row equally), `TAB_BAR_PADDING` inside the bar, in the web segmented
|
|
174
|
+
* pill fill (`tabColors`, `--tabs-pill-bg`) and the `TabsTrigger` pills
|
|
175
|
+
* outline. It slides to a newly focused tab, as the web segmented pill does,
|
|
176
|
+
* and moves at once with Reduce Motion. Behind the tablist, so assistive tech
|
|
177
|
+
* reads only the tabs.
|
|
178
|
+
*/
|
|
179
|
+
function ActiveTabPill({ index, count }: { index: number; count: number }) {
|
|
180
|
+
const scheme = useScheme();
|
|
181
|
+
const reduceMotion = useReduceMotion();
|
|
182
|
+
const left = (100 * index) / count;
|
|
183
|
+
const offset = useSharedValue(left);
|
|
184
|
+
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
offset.value = reduceMotion ? left : withTiming(left, PILL_SLIDE);
|
|
187
|
+
}, [left, reduceMotion, offset]);
|
|
188
|
+
|
|
189
|
+
const slide = useAnimatedStyle(() => ({
|
|
190
|
+
left: `${offset.value}%` as const,
|
|
191
|
+
}));
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<View
|
|
195
|
+
pointerEvents="none"
|
|
196
|
+
style={{
|
|
197
|
+
position: "absolute",
|
|
198
|
+
top: TAB_BAR_PADDING,
|
|
199
|
+
bottom: TAB_BAR_PADDING,
|
|
200
|
+
left: TAB_BAR_PADDING,
|
|
201
|
+
right: TAB_BAR_PADDING,
|
|
202
|
+
}}
|
|
203
|
+
>
|
|
204
|
+
<Animated.View
|
|
205
|
+
className="border border-white/40 dark:border-white/10"
|
|
206
|
+
style={[
|
|
207
|
+
{
|
|
208
|
+
position: "absolute",
|
|
209
|
+
top: 0,
|
|
210
|
+
bottom: 0,
|
|
211
|
+
width: `${100 / count}%` as const,
|
|
212
|
+
borderRadius: FLOATING_PILL_RADIUS,
|
|
213
|
+
backgroundColor: tabColors("segmented", true, scheme).backgroundColor,
|
|
214
|
+
},
|
|
215
|
+
slide,
|
|
216
|
+
]}
|
|
217
|
+
/>
|
|
218
|
+
</View>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { IconMoon, IconSun } from "@tabler/icons-react-native";
|
|
2
|
+
import { useColorScheme } from "nativewind";
|
|
3
|
+
import { HeaderIconButton } from "./HeaderIconButton";
|
|
4
|
+
import { useLargerText } from "./theme";
|
|
5
|
+
import { tokens } from "./tokens";
|
|
6
|
+
import { useScheme } from "./useScheme";
|
|
7
|
+
|
|
8
|
+
export type ThemeToggleProps = {
|
|
9
|
+
testID?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A `HeaderIconButton` that switches between light and dark with NativeWind's
|
|
14
|
+
* `setColorScheme`, the scheme `useScheme` reads. It shows and names the
|
|
15
|
+
* scheme it switches to: a moon and "Switch to dark mode" in light, a sun and
|
|
16
|
+
* "Switch to light mode" in dark. The choice lasts only as long as NativeWind
|
|
17
|
+
* keeps it; an app that restores it on the next launch stores
|
|
18
|
+
* `useColorScheme().colorScheme` itself.
|
|
19
|
+
*/
|
|
20
|
+
export function ThemeToggle({ testID }: ThemeToggleProps) {
|
|
21
|
+
const { setColorScheme } = useColorScheme();
|
|
22
|
+
const scheme = useScheme();
|
|
23
|
+
const largerText = useLargerText();
|
|
24
|
+
const next = scheme === "dark" ? "light" : "dark";
|
|
25
|
+
const Icon = next === "dark" ? IconMoon : IconSun;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<HeaderIconButton
|
|
29
|
+
label={`Switch to ${next} mode`}
|
|
30
|
+
testID={testID}
|
|
31
|
+
onPress={() => setColorScheme(next)}
|
|
32
|
+
>
|
|
33
|
+
<Icon size={largerText ? 26 : 22} color={tokens.color.foreground[scheme]} />
|
|
34
|
+
</HeaderIconButton>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -44,6 +44,8 @@ export type {
|
|
|
44
44
|
} from "../Tabs";
|
|
45
45
|
export { ListRow } from "../ListRow";
|
|
46
46
|
export { HeaderIconButton } from "../HeaderIconButton";
|
|
47
|
+
export { ThemeToggle } from "../ThemeToggle";
|
|
48
|
+
export type { ThemeToggleProps } from "../ThemeToggle";
|
|
47
49
|
export {
|
|
48
50
|
Card,
|
|
49
51
|
CardHeader,
|
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
TabsList,
|
|
72
72
|
TabsTrigger,
|
|
73
73
|
Textarea,
|
|
74
|
+
ThemeToggle,
|
|
74
75
|
Toaster,
|
|
75
76
|
UpgradeToProCard,
|
|
76
77
|
initials,
|
|
@@ -203,6 +204,8 @@ function GalleryScreen() {
|
|
|
203
204
|
</Button>
|
|
204
205
|
}
|
|
205
206
|
/>
|
|
207
|
+
{/* A header icon action: switches the gallery between light and dark. */}
|
|
208
|
+
<PageHeader title="Settings" safeTop={false} className="px-0" action={<ThemeToggle />} />
|
|
206
209
|
</Section>
|
|
207
210
|
|
|
208
211
|
<Section title="Brand">
|
package/src/index.ts
CHANGED
|
@@ -80,6 +80,8 @@ export type {
|
|
|
80
80
|
} from "./Tabs";
|
|
81
81
|
export { ListRow } from "./ListRow";
|
|
82
82
|
export { HeaderIconButton } from "./HeaderIconButton";
|
|
83
|
+
export { ThemeToggle } from "./ThemeToggle";
|
|
84
|
+
export type { ThemeToggleProps } from "./ThemeToggle";
|
|
83
85
|
export {
|
|
84
86
|
Card,
|
|
85
87
|
CardHeader,
|