@vvedantb/native-ui 0.2.1 → 0.3.12
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 +160 -9
- package/package.json +80 -2
- package/src/AlertDialog.tsx +73 -0
- package/src/AmbientBackground.tsx +26 -11
- package/src/Avatar.tsx +141 -0
- package/src/Badge.tsx +108 -0
- package/src/BottomSheetContainer.tsx +7 -1
- package/src/Button.tsx +153 -52
- package/src/Card.tsx +111 -36
- package/src/Checkbox.tsx +78 -0
- package/src/Chip.tsx +5 -3
- package/src/ConfirmDialog.tsx +105 -0
- package/src/DateTimeField.tsx +6 -2
- package/src/Dialog.tsx +253 -0
- package/src/EmptyState.tsx +122 -7
- package/src/FloatingFAB.tsx +2 -4
- package/src/FloatingSurface.tsx +31 -9
- package/src/FloatingTabBar.tsx +39 -11
- package/src/Input.tsx +15 -4
- package/src/Label.tsx +31 -0
- package/src/LabeledSwitchRow.tsx +96 -0
- package/src/LiquidGlass.tsx +2 -10
- package/src/ListRow.tsx +7 -2
- package/src/PillTabs.tsx +28 -14
- package/src/Progress.tsx +67 -0
- package/src/SearchInput.tsx +163 -0
- package/src/Select.tsx +32 -6
- package/src/Separator.tsx +38 -0
- package/src/Sheet.tsx +107 -0
- package/src/Skeleton.tsx +25 -5
- package/src/Spinner.tsx +92 -0
- package/src/StatusDot.tsx +66 -0
- package/src/Switch.tsx +24 -7
- package/src/Tabs.tsx +190 -0
- package/src/Textarea.tsx +47 -0
- package/src/Toast.tsx +170 -0
- package/src/blur.ts +48 -0
- package/src/brand/AppIcons.tsx +106 -0
- package/src/brand/AppMark.tsx +83 -0
- package/src/brand/AppWordmark.tsx +97 -0
- package/src/brand/familyApps.ts +24 -0
- package/src/brand/index.ts +34 -0
- package/src/brand/svg.ts +263 -0
- package/src/brand/vProAppBenefits.ts +22 -0
- package/src/controlStyles.ts +360 -0
- package/src/dialogLayout.ts +20 -0
- package/src/entries/ambient.ts +3 -0
- package/src/entries/bottom-sheet.ts +2 -0
- package/src/entries/brand.ts +2 -0
- package/src/entries/button.ts +3 -0
- package/src/entries/date-time.ts +3 -0
- package/src/entries/floating.ts +24 -0
- package/src/entries/layout.ts +7 -0
- package/src/entries/marketing.ts +10 -0
- package/src/entries/primitives.ts +99 -0
- package/src/entries/search-input.ts +4 -0
- package/src/entries/tab-bar.ts +10 -0
- package/src/entries/theme.ts +15 -0
- package/src/entries/toast.ts +6 -0
- package/src/examples/ParityGallery.tsx +496 -0
- package/src/floating.ts +5 -1
- package/src/glassBlur.tsx +95 -0
- package/src/gradient.ts +21 -0
- package/src/haptics.ts +22 -2
- package/src/index.ts +99 -2
- package/src/initials.ts +10 -0
- package/src/marketing/PricingModal.tsx +410 -0
- package/src/marketing/UpgradeToProCard.tsx +103 -0
- package/src/marketing/pricing.ts +76 -0
- package/src/palette.ts +43 -0
- package/src/primitives/input.tsx +9 -2
- package/src/primitives/native-only-animated-view.tsx +10 -3
- package/src/primitives/overlay.tsx +179 -0
- package/src/primitives/select.tsx +25 -12
- package/src/primitives/switch.tsx +12 -13
- package/src/primitives/use-controllable-state.ts +24 -0
- package/src/settings/SettingsField.tsx +11 -2
- package/src/settings/SettingsSection.tsx +20 -6
- package/src/settings/SettingsToggleRow.tsx +7 -2
- package/src/tabBarRoutes.ts +52 -0
- package/src/toastChrome.ts +103 -0
- package/src/toastStore.ts +86 -0
- package/src/tokens.ts +19 -14
- package/src/typography.ts +24 -0
- package/src/useReduceMotion.ts +24 -0
- package/src/useScheme.ts +6 -0
package/src/Select.tsx
CHANGED
|
@@ -9,7 +9,16 @@ import {
|
|
|
9
9
|
SelectValue,
|
|
10
10
|
} from "./primitives/select";
|
|
11
11
|
import { cn } from "./cn";
|
|
12
|
+
import {
|
|
13
|
+
MIN_TOUCH_TARGET,
|
|
14
|
+
fieldStyle,
|
|
15
|
+
fieldTextStyle,
|
|
16
|
+
touchSlop,
|
|
17
|
+
typeStyle,
|
|
18
|
+
} from "./controlStyles";
|
|
19
|
+
import { palette } from "./palette";
|
|
12
20
|
import { useLargerText } from "./theme";
|
|
21
|
+
import { useScheme } from "./useScheme";
|
|
13
22
|
|
|
14
23
|
interface SelectOption<TValue = string | number | null> {
|
|
15
24
|
label: string;
|
|
@@ -27,6 +36,8 @@ interface SelectProps<TValue = string | number | null> {
|
|
|
27
36
|
triggerClassName?: string;
|
|
28
37
|
contentClassName?: string;
|
|
29
38
|
testID?: string;
|
|
39
|
+
/** Names the field for VoiceOver and TalkBack, which otherwise read only the value or placeholder. */
|
|
40
|
+
accessibilityLabel?: string;
|
|
30
41
|
}
|
|
31
42
|
|
|
32
43
|
const NULL_SENTINEL = "__null__";
|
|
@@ -62,8 +73,10 @@ function Select<TValue = string | number | null>({
|
|
|
62
73
|
triggerClassName,
|
|
63
74
|
contentClassName,
|
|
64
75
|
testID,
|
|
76
|
+
accessibilityLabel,
|
|
65
77
|
}: SelectProps<TValue>) {
|
|
66
78
|
const largerText = useLargerText();
|
|
79
|
+
const scheme = useScheme();
|
|
67
80
|
const primitiveValue = toPrimitiveValue(value, options);
|
|
68
81
|
|
|
69
82
|
return (
|
|
@@ -89,19 +102,32 @@ function Select<TValue = string | number | null>({
|
|
|
89
102
|
>
|
|
90
103
|
<SelectTrigger
|
|
91
104
|
testID={testID}
|
|
105
|
+
accessibilityRole="combobox"
|
|
106
|
+
accessibilityLabel={accessibilityLabel}
|
|
107
|
+
accessibilityState={{ disabled: Boolean(disabled) }}
|
|
108
|
+
// 40 high on screen, as on the web; a 44 target to touch.
|
|
109
|
+
hitSlop={touchSlop(MIN_TOUCH_TARGET, largerText ? 64 : 40)}
|
|
92
110
|
className={cn(
|
|
93
|
-
|
|
94
|
-
|
|
111
|
+
// The web trigger: h-10 px-3.5, 16 px type (its phone size). A
|
|
112
|
+
// minimum height, so a large Dynamic Type size does not clip the value.
|
|
113
|
+
"h-auto min-h-10 w-full border px-3.5 py-0 shadow-none",
|
|
114
|
+
largerText && "min-h-16 px-4",
|
|
95
115
|
triggerClassName,
|
|
96
116
|
className,
|
|
97
117
|
)}
|
|
118
|
+
style={fieldStyle(scheme)}
|
|
98
119
|
>
|
|
99
120
|
<SelectValue
|
|
100
121
|
placeholder={placeholder ?? "Select option"}
|
|
101
|
-
className={
|
|
102
|
-
"font-instrument-sans-medium text-
|
|
103
|
-
|
|
104
|
-
|
|
122
|
+
className={
|
|
123
|
+
largerText ? "font-instrument-sans-medium text-xl" : "font-instrument-sans text-base"
|
|
124
|
+
}
|
|
125
|
+
style={[
|
|
126
|
+
primitiveValue
|
|
127
|
+
? fieldTextStyle(scheme)
|
|
128
|
+
: { color: palette.fieldPlaceholder[scheme] },
|
|
129
|
+
largerText ? typeStyle("medium") : null,
|
|
130
|
+
]}
|
|
105
131
|
/>
|
|
106
132
|
</SelectTrigger>
|
|
107
133
|
<SelectContent className={cn("w-full", contentClassName)}>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { StyleSheet, View, type ViewProps } from "react-native";
|
|
2
|
+
import { cn } from "./cn";
|
|
3
|
+
import { tokens } from "./tokens";
|
|
4
|
+
import { useScheme } from "./useScheme";
|
|
5
|
+
|
|
6
|
+
export type SeparatorProps = ViewProps & {
|
|
7
|
+
/** Default `"horizontal"`. */
|
|
8
|
+
orientation?: "horizontal" | "vertical";
|
|
9
|
+
/** Hidden from assistive tech. Default `true`, as on the web. */
|
|
10
|
+
decorative?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Hairline in `tokens.color.separator`. Vertical separators stretch to the row height. */
|
|
15
|
+
export function Separator({
|
|
16
|
+
orientation = "horizontal",
|
|
17
|
+
decorative = true,
|
|
18
|
+
className,
|
|
19
|
+
style,
|
|
20
|
+
...props
|
|
21
|
+
}: SeparatorProps) {
|
|
22
|
+
const scheme = useScheme();
|
|
23
|
+
return (
|
|
24
|
+
<View
|
|
25
|
+
accessibilityElementsHidden={decorative}
|
|
26
|
+
importantForAccessibility={decorative ? "no-hide-descendants" : "auto"}
|
|
27
|
+
className={cn("shrink-0", className)}
|
|
28
|
+
style={[
|
|
29
|
+
orientation === "horizontal"
|
|
30
|
+
? { height: StyleSheet.hairlineWidth, width: "100%" }
|
|
31
|
+
: { width: StyleSheet.hairlineWidth, alignSelf: "stretch" },
|
|
32
|
+
{ backgroundColor: tokens.color.separator[scheme] },
|
|
33
|
+
style,
|
|
34
|
+
]}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
package/src/Sheet.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { View, type ViewProps } from "react-native";
|
|
2
|
+
import { SlideInDown } from "react-native-reanimated";
|
|
3
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
4
|
+
import { FloatingSurface } from "./FloatingSurface";
|
|
5
|
+
import { cn } from "./cn";
|
|
6
|
+
import { withAlpha } from "./palette";
|
|
7
|
+
import { tokens } from "./tokens";
|
|
8
|
+
import { useScheme } from "./useScheme";
|
|
9
|
+
import { DialogBody, DialogDescription, DialogTitle } from "./Dialog";
|
|
10
|
+
import { NativeOnlyAnimatedView } from "./primitives/native-only-animated-view";
|
|
11
|
+
import {
|
|
12
|
+
OverlayClose,
|
|
13
|
+
OverlayModal,
|
|
14
|
+
OverlayRoot,
|
|
15
|
+
OverlayTrigger,
|
|
16
|
+
type OverlayPressableProps,
|
|
17
|
+
type OverlayRootProps,
|
|
18
|
+
} from "./primitives/overlay";
|
|
19
|
+
|
|
20
|
+
const SHEET_RADIUS = tokens.radius.card;
|
|
21
|
+
|
|
22
|
+
export type SheetProps = OverlayRootProps;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Bottom sheet in an RN `Modal`, with no `@gorhom/bottom-sheet`. Same open
|
|
26
|
+
* state API as `Dialog`. Use `BottomSheetContainer` for drag-to-dismiss.
|
|
27
|
+
*/
|
|
28
|
+
export function Sheet(props: SheetProps) {
|
|
29
|
+
return <OverlayRoot {...props} />;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function SheetTrigger(props: OverlayPressableProps) {
|
|
33
|
+
return <OverlayTrigger part="SheetTrigger" {...props} />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function SheetClose(props: OverlayPressableProps) {
|
|
37
|
+
return <OverlayClose part="SheetClose" {...props} />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type SheetContentProps = ViewProps & {
|
|
41
|
+
className?: string;
|
|
42
|
+
/** Close on a backdrop tap. Default `true`. */
|
|
43
|
+
dismissible?: boolean;
|
|
44
|
+
/** Grab handle above the content. Default `true`. */
|
|
45
|
+
showHandle?: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Panel pinned to the bottom edge: opaque surface, top corners at the card
|
|
50
|
+
* radius, safe-area bottom padding, slides in. Its bottom corners sit below the
|
|
51
|
+
* screen edge, so only the top corners show.
|
|
52
|
+
*/
|
|
53
|
+
export function SheetContent({
|
|
54
|
+
children,
|
|
55
|
+
className,
|
|
56
|
+
dismissible = true,
|
|
57
|
+
showHandle = true,
|
|
58
|
+
style,
|
|
59
|
+
...props
|
|
60
|
+
}: SheetContentProps) {
|
|
61
|
+
const insets = useSafeAreaInsets();
|
|
62
|
+
return (
|
|
63
|
+
<OverlayModal part="SheetContent" placement="bottom" dismissible={dismissible}>
|
|
64
|
+
<NativeOnlyAnimatedView
|
|
65
|
+
entering={SlideInDown.duration(240)}
|
|
66
|
+
style={{ marginBottom: -SHEET_RADIUS }}
|
|
67
|
+
>
|
|
68
|
+
<FloatingSurface blur={false} radius={SHEET_RADIUS}>
|
|
69
|
+
<View
|
|
70
|
+
accessibilityViewIsModal
|
|
71
|
+
className={cn("gap-4 px-5 pt-3", className)}
|
|
72
|
+
{...props}
|
|
73
|
+
style={[{ paddingBottom: insets.bottom + 20 + SHEET_RADIUS }, style]}
|
|
74
|
+
>
|
|
75
|
+
{showHandle ? <SheetHandle /> : null}
|
|
76
|
+
{children}
|
|
77
|
+
</View>
|
|
78
|
+
</FloatingSurface>
|
|
79
|
+
</NativeOnlyAnimatedView>
|
|
80
|
+
</OverlayModal>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function SheetHandle({ className, style, ...props }: ViewProps & { className?: string }) {
|
|
85
|
+
const scheme = useScheme();
|
|
86
|
+
return (
|
|
87
|
+
<View
|
|
88
|
+
accessibilityElementsHidden
|
|
89
|
+
importantForAccessibility="no-hide-descendants"
|
|
90
|
+
className={cn("h-1.5 w-10 self-center rounded-full", className)}
|
|
91
|
+
style={[{ backgroundColor: withAlpha(tokens.color.muted[scheme], 0.4) }, style]}
|
|
92
|
+
{...props}
|
|
93
|
+
/>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function SheetHeader({ className, ...props }: ViewProps & { className?: string }) {
|
|
98
|
+
return <View className={cn("gap-1.5", className)} {...props} />;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const SheetTitle = DialogTitle;
|
|
102
|
+
export const SheetDescription = DialogDescription;
|
|
103
|
+
export const SheetBody = DialogBody;
|
|
104
|
+
/** Buttons stack full width, the last child on top: the web footer on a phone. */
|
|
105
|
+
export function SheetFooter({ className, ...props }: ViewProps & { className?: string }) {
|
|
106
|
+
return <View className={cn("flex-col-reverse gap-2", className)} {...props} />;
|
|
107
|
+
}
|
package/src/Skeleton.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { View } from "react-native";
|
|
1
|
+
import { View, type ViewProps } from "react-native";
|
|
2
2
|
import Animated, {
|
|
3
|
+
cancelAnimation,
|
|
3
4
|
useAnimatedStyle,
|
|
4
5
|
useSharedValue,
|
|
5
6
|
withRepeat,
|
|
@@ -7,13 +8,27 @@ import Animated, {
|
|
|
7
8
|
} from "react-native-reanimated";
|
|
8
9
|
import { useEffect } from "react";
|
|
9
10
|
import { cn } from "./cn";
|
|
11
|
+
import { cardStyle } from "./controlStyles";
|
|
12
|
+
import { useReduceMotion } from "./useReduceMotion";
|
|
13
|
+
import { useScheme } from "./useScheme";
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
/** Pulsing placeholder block. Holds still with Reduce Motion. Size it with `className` (for example `h-4 w-32`). */
|
|
16
|
+
export function Skeleton({
|
|
17
|
+
className,
|
|
18
|
+
style,
|
|
19
|
+
...props
|
|
20
|
+
}: ViewProps & { className?: string }) {
|
|
21
|
+
const reduceMotion = useReduceMotion();
|
|
12
22
|
const opacity = useSharedValue(0.4);
|
|
13
23
|
|
|
14
24
|
useEffect(() => {
|
|
25
|
+
if (reduceMotion) {
|
|
26
|
+
opacity.value = 0.7;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
15
29
|
opacity.value = withRepeat(withTiming(1, { duration: 800 }), -1, true);
|
|
16
|
-
|
|
30
|
+
return () => cancelAnimation(opacity);
|
|
31
|
+
}, [opacity, reduceMotion]);
|
|
17
32
|
|
|
18
33
|
const animatedStyle = useAnimatedStyle(() => ({
|
|
19
34
|
opacity: opacity.value,
|
|
@@ -21,15 +36,20 @@ export function Skeleton({ className }: { className?: string }) {
|
|
|
21
36
|
|
|
22
37
|
return (
|
|
23
38
|
<Animated.View
|
|
24
|
-
|
|
39
|
+
accessibilityElementsHidden
|
|
40
|
+
importantForAccessibility="no-hide-descendants"
|
|
41
|
+
{...props}
|
|
42
|
+
style={[animatedStyle, style]}
|
|
25
43
|
className={cn("bg-neutral-200 dark:bg-neutral-700 rounded-lg", className)}
|
|
26
44
|
/>
|
|
27
45
|
);
|
|
28
46
|
}
|
|
29
47
|
|
|
48
|
+
/** A `Card`-shaped placeholder: the flat card fill and radius. */
|
|
30
49
|
export function CardSkeleton() {
|
|
50
|
+
const scheme = useScheme();
|
|
31
51
|
return (
|
|
32
|
-
<View className="
|
|
52
|
+
<View className="gap-3 p-5" style={cardStyle(scheme)}>
|
|
33
53
|
<Skeleton className="h-4 w-32" />
|
|
34
54
|
<Skeleton className="h-16 w-full" />
|
|
35
55
|
</View>
|
package/src/Spinner.tsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ActivityIndicator,
|
|
3
|
+
Platform,
|
|
4
|
+
View,
|
|
5
|
+
type ActivityIndicatorProps,
|
|
6
|
+
type ViewProps,
|
|
7
|
+
} from "react-native";
|
|
8
|
+
import { cn } from "./cn";
|
|
9
|
+
import { tokens } from "./tokens";
|
|
10
|
+
import { useScheme } from "./useScheme";
|
|
11
|
+
|
|
12
|
+
export type SpinnerProps = Omit<ActivityIndicatorProps, "size" | "color"> & {
|
|
13
|
+
/** `sm` is 16, the web `sm` and the `Button` loading ring. `md` is the small system spinner (20), `lg` the large one. A number sets the size on Android; iOS picks small below 30. Default `"md"`. */
|
|
14
|
+
size?: "sm" | "md" | "lg" | number;
|
|
15
|
+
/** `"muted"` (default) uses the muted text colour, `"inherit"` the foreground. Native has no `currentColor`. */
|
|
16
|
+
tone?: "muted" | "inherit";
|
|
17
|
+
/** Overrides `tone`. */
|
|
18
|
+
color?: string;
|
|
19
|
+
/** Hide from assistive tech, for example inside a labelled button. Default `false`. */
|
|
20
|
+
decorative?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The system `"small"` spinner box on iOS, Android and React Native Web. */
|
|
24
|
+
const SYSTEM_SMALL = 20;
|
|
25
|
+
/** Web `size-4`. No platform has a 16 spinner, so `sm` is the small one scaled down in a 16 box. */
|
|
26
|
+
const SM = 16;
|
|
27
|
+
|
|
28
|
+
function systemSize(size: NonNullable<SpinnerProps["size"]>) {
|
|
29
|
+
if (typeof size === "number") {
|
|
30
|
+
return Platform.OS === "android" ? size : size >= 30 ? "large" : "small";
|
|
31
|
+
}
|
|
32
|
+
return size === "lg" ? "large" : "small";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The platform `ActivityIndicator` with the web `Spinner` size and tone names.
|
|
37
|
+
* The web `mark`, `circle` and `ring` variants (animated SVG strokes) are not ported.
|
|
38
|
+
*/
|
|
39
|
+
export function Spinner({
|
|
40
|
+
size = "md",
|
|
41
|
+
tone = "muted",
|
|
42
|
+
color,
|
|
43
|
+
decorative = false,
|
|
44
|
+
accessibilityLabel,
|
|
45
|
+
style,
|
|
46
|
+
...props
|
|
47
|
+
}: SpinnerProps) {
|
|
48
|
+
const scheme = useScheme();
|
|
49
|
+
const small = size === "sm";
|
|
50
|
+
const indicator = (
|
|
51
|
+
<ActivityIndicator
|
|
52
|
+
size={systemSize(size)}
|
|
53
|
+
color={
|
|
54
|
+
color ??
|
|
55
|
+
(tone === "muted"
|
|
56
|
+
? tokens.color.muted[scheme]
|
|
57
|
+
: tokens.color.foreground[scheme])
|
|
58
|
+
}
|
|
59
|
+
accessibilityElementsHidden={decorative}
|
|
60
|
+
importantForAccessibility={decorative ? "no-hide-descendants" : "auto"}
|
|
61
|
+
accessibilityRole={decorative ? undefined : "progressbar"}
|
|
62
|
+
accessibilityLabel={decorative ? undefined : (accessibilityLabel ?? "Loading")}
|
|
63
|
+
style={small ? { transform: [{ scale: SM / SYSTEM_SMALL }] } : style}
|
|
64
|
+
{...props}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
if (!small) return indicator;
|
|
68
|
+
return (
|
|
69
|
+
<View
|
|
70
|
+
style={[{ width: SM, height: SM, alignItems: "center", justifyContent: "center" }, style]}
|
|
71
|
+
>
|
|
72
|
+
{indicator}
|
|
73
|
+
</View>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A `Spinner` centred in whatever box it is given. `label` names it for assistive tech. */
|
|
78
|
+
export function CenteredSpinner({
|
|
79
|
+
label,
|
|
80
|
+
className,
|
|
81
|
+
...props
|
|
82
|
+
}: ViewProps & { label: string; className?: string }) {
|
|
83
|
+
return (
|
|
84
|
+
<View
|
|
85
|
+
accessibilityState={{ busy: true }}
|
|
86
|
+
className={cn("min-h-0 w-full flex-1 items-center justify-center p-6", className)}
|
|
87
|
+
{...props}
|
|
88
|
+
>
|
|
89
|
+
<Spinner accessibilityLabel={label} />
|
|
90
|
+
</View>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { View, type ViewProps } from "react-native";
|
|
2
|
+
import { cn } from "./cn";
|
|
3
|
+
import { palette, withAlpha } from "./palette";
|
|
4
|
+
import { tokens } from "./tokens";
|
|
5
|
+
import { useScheme } from "./useScheme";
|
|
6
|
+
|
|
7
|
+
/** The web `StatusTone`s. */
|
|
8
|
+
export type StatusTone =
|
|
9
|
+
| "progress"
|
|
10
|
+
| "business-review"
|
|
11
|
+
| "code-review"
|
|
12
|
+
| "done"
|
|
13
|
+
| "cancelled"
|
|
14
|
+
| "active"
|
|
15
|
+
| "critical"
|
|
16
|
+
| "neutral";
|
|
17
|
+
|
|
18
|
+
export type StatusDotProps = ViewProps & {
|
|
19
|
+
tone: StatusTone;
|
|
20
|
+
/** `"sm"` (6) or `"md"` (8). Default `"sm"`. */
|
|
21
|
+
size?: "sm" | "md";
|
|
22
|
+
className?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function toneColor(tone: StatusTone, scheme: "light" | "dark") {
|
|
26
|
+
switch (tone) {
|
|
27
|
+
case "progress":
|
|
28
|
+
return palette.warning[scheme];
|
|
29
|
+
case "business-review":
|
|
30
|
+
return palette.primary[scheme];
|
|
31
|
+
case "code-review":
|
|
32
|
+
return withAlpha(palette.primary[scheme], 0.7);
|
|
33
|
+
case "done":
|
|
34
|
+
case "active":
|
|
35
|
+
return palette.success[scheme];
|
|
36
|
+
case "cancelled":
|
|
37
|
+
return withAlpha(tokens.color.muted[scheme], 0.4);
|
|
38
|
+
case "critical":
|
|
39
|
+
return palette.danger[scheme];
|
|
40
|
+
default:
|
|
41
|
+
return withAlpha(tokens.color.muted[scheme], 0.6);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Small coloured dot that carries a status, so the label beside it stays neutral text. Hidden from assistive tech. */
|
|
46
|
+
export function StatusDot({ tone, size = "sm", className, style, ...props }: StatusDotProps) {
|
|
47
|
+
const scheme = useScheme();
|
|
48
|
+
const diameter = size === "md" ? 8 : 6;
|
|
49
|
+
return (
|
|
50
|
+
<View
|
|
51
|
+
accessibilityElementsHidden
|
|
52
|
+
importantForAccessibility="no-hide-descendants"
|
|
53
|
+
className={cn("shrink-0", className)}
|
|
54
|
+
style={[
|
|
55
|
+
{
|
|
56
|
+
width: diameter,
|
|
57
|
+
height: diameter,
|
|
58
|
+
borderRadius: diameter / 2,
|
|
59
|
+
backgroundColor: toneColor(tone, scheme),
|
|
60
|
+
},
|
|
61
|
+
style,
|
|
62
|
+
]}
|
|
63
|
+
{...props}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
}
|
package/src/Switch.tsx
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { Switch as PrimitiveSwitch } from "./primitives/switch";
|
|
3
3
|
import { cn } from "./cn";
|
|
4
|
+
import { switchColors, switchSize, touchSlop } from "./controlStyles";
|
|
5
|
+
import { useLargerText } from "./theme";
|
|
6
|
+
import { useScheme } from "./useScheme";
|
|
4
7
|
|
|
5
8
|
type SwitchProps = Omit<
|
|
6
9
|
React.ComponentPropsWithoutRef<typeof PrimitiveSwitch>,
|
|
@@ -10,13 +13,19 @@ type SwitchProps = Omit<
|
|
|
10
13
|
checked?: boolean;
|
|
11
14
|
onValueChange?: (value: boolean) => void;
|
|
12
15
|
onCheckedChange?: (value: boolean) => void;
|
|
16
|
+
/** Track fill when on or off, in place of the `palette` colours. */
|
|
17
|
+
onColor?: string;
|
|
18
|
+
offColor?: string;
|
|
13
19
|
};
|
|
14
20
|
|
|
15
21
|
const Switch = React.forwardRef<
|
|
16
22
|
React.ElementRef<typeof PrimitiveSwitch>,
|
|
17
23
|
SwitchProps
|
|
18
|
-
>(({ value, checked, onValueChange, onCheckedChange, ...props }, ref) => {
|
|
24
|
+
>(({ value, checked, onValueChange, onCheckedChange, onColor, offColor, ...props }, ref) => {
|
|
19
25
|
const resolvedValue = checked ?? value ?? false;
|
|
26
|
+
const colors = switchColors(resolvedValue, useScheme());
|
|
27
|
+
const trackColor = resolvedValue ? onColor : offColor;
|
|
28
|
+
const size = switchSize(useLargerText());
|
|
20
29
|
|
|
21
30
|
const handleCheckedChange = (nextValue: boolean) => {
|
|
22
31
|
onCheckedChange?.(nextValue);
|
|
@@ -28,13 +37,21 @@ const Switch = React.forwardRef<
|
|
|
28
37
|
ref={ref}
|
|
29
38
|
checked={resolvedValue}
|
|
30
39
|
onCheckedChange={handleCheckedChange}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
// The web size on screen, a 44 target to touch.
|
|
41
|
+
hitSlop={touchSlop(size.width, size.height)}
|
|
42
|
+
accessibilityRole="switch"
|
|
43
|
+
className={cn(size.track, "justify-center")}
|
|
44
|
+
trackStyle={
|
|
45
|
+
trackColor ? { backgroundColor: trackColor, borderColor: trackColor } : colors.track
|
|
46
|
+
}
|
|
47
|
+
thumbStyle={colors.thumb}
|
|
48
|
+
thumbClassName={cn(size.thumb, resolvedValue ? size.on : "translate-x-0")}
|
|
37
49
|
{...props}
|
|
50
|
+
accessibilityState={{
|
|
51
|
+
...props.accessibilityState,
|
|
52
|
+
checked: resolvedValue,
|
|
53
|
+
disabled: Boolean(props.disabled),
|
|
54
|
+
}}
|
|
38
55
|
/>
|
|
39
56
|
);
|
|
40
57
|
});
|
package/src/Tabs.tsx
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import { Pressable, Text, View, type ViewProps } from "react-native";
|
|
3
|
+
import { cn } from "./cn";
|
|
4
|
+
import {
|
|
5
|
+
MIN_TOUCH_TARGET,
|
|
6
|
+
tabColors,
|
|
7
|
+
tabTrackStyle,
|
|
8
|
+
tabTriggerSize,
|
|
9
|
+
touchSlop,
|
|
10
|
+
typeStyle,
|
|
11
|
+
type TabsSize,
|
|
12
|
+
type TabsVariant,
|
|
13
|
+
} from "./controlStyles";
|
|
14
|
+
import { selectionHaptic } from "./haptics";
|
|
15
|
+
import { useControllableState } from "./primitives/use-controllable-state";
|
|
16
|
+
import { useLargerText } from "./theme";
|
|
17
|
+
import { useScheme } from "./useScheme";
|
|
18
|
+
|
|
19
|
+
/** `pills` (default): separate pills, the `PillTabs` look. `segmented`: one track with the active segment raised. `soft`: wrapping pills, the active one filled. `pills` and `segmented` are as wide as their tabs, at the start, as on the web. */
|
|
20
|
+
export type TabsListVariant = TabsVariant;
|
|
21
|
+
|
|
22
|
+
const TabsContext = createContext<{
|
|
23
|
+
value: string;
|
|
24
|
+
setValue: (value: string) => void;
|
|
25
|
+
} | null>(null);
|
|
26
|
+
|
|
27
|
+
const TabsListContext = createContext<{
|
|
28
|
+
variant: TabsListVariant;
|
|
29
|
+
size: TabsSize;
|
|
30
|
+
}>({ variant: "pills", size: "default" });
|
|
31
|
+
|
|
32
|
+
function useTabs(part: string) {
|
|
33
|
+
const context = useContext(TabsContext);
|
|
34
|
+
if (!context) throw new Error(`${part} must be inside Tabs.`);
|
|
35
|
+
return context;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type TabsProps = ViewProps & {
|
|
39
|
+
/** Active tab when the parent controls it. */
|
|
40
|
+
value?: string;
|
|
41
|
+
/** Initial tab when `value` is not set. */
|
|
42
|
+
defaultValue?: string;
|
|
43
|
+
onValueChange?: (value: string) => void;
|
|
44
|
+
className?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Composable tabs with the web part names (`Tabs`, `TabsList`, `TabsTrigger`, `TabsContent`). For a flat options array use `PillTabs`. */
|
|
48
|
+
export function Tabs({
|
|
49
|
+
value,
|
|
50
|
+
defaultValue = "",
|
|
51
|
+
onValueChange,
|
|
52
|
+
className,
|
|
53
|
+
...props
|
|
54
|
+
}: TabsProps) {
|
|
55
|
+
const [current, setValue] = useControllableState({
|
|
56
|
+
value,
|
|
57
|
+
defaultValue,
|
|
58
|
+
onChange: onValueChange,
|
|
59
|
+
});
|
|
60
|
+
return (
|
|
61
|
+
<TabsContext.Provider value={{ value: current, setValue }}>
|
|
62
|
+
<View className={cn("gap-3", className)} {...props} />
|
|
63
|
+
</TabsContext.Provider>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type TabsListProps = ViewProps & {
|
|
68
|
+
variant?: TabsListVariant;
|
|
69
|
+
/** `sm` makes the triggers smaller. */
|
|
70
|
+
size?: TabsSize;
|
|
71
|
+
className?: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function TabsList({
|
|
75
|
+
variant = "pills",
|
|
76
|
+
size = "default",
|
|
77
|
+
className,
|
|
78
|
+
style,
|
|
79
|
+
...props
|
|
80
|
+
}: TabsListProps) {
|
|
81
|
+
const scheme = useScheme();
|
|
82
|
+
return (
|
|
83
|
+
<TabsListContext.Provider value={{ variant, size }}>
|
|
84
|
+
<View
|
|
85
|
+
accessibilityRole="tablist"
|
|
86
|
+
className={cn(
|
|
87
|
+
"flex-row",
|
|
88
|
+
// The web lists: `segmented` and `pills` are `inline-flex` (as wide
|
|
89
|
+
// as their tabs), `soft` a full-width row of centred, wrapping pills.
|
|
90
|
+
// Pills wrap rather than run off a phone screen.
|
|
91
|
+
variant === "segmented"
|
|
92
|
+
? "max-w-full gap-[3px] self-start rounded-full p-[3px]"
|
|
93
|
+
: variant === "soft"
|
|
94
|
+
? "flex-wrap items-center justify-center gap-1.5"
|
|
95
|
+
: "max-w-full flex-wrap gap-1.5 self-start",
|
|
96
|
+
className,
|
|
97
|
+
)}
|
|
98
|
+
style={variant === "segmented" ? [tabTrackStyle(scheme), style] : style}
|
|
99
|
+
{...props}
|
|
100
|
+
/>
|
|
101
|
+
</TabsListContext.Provider>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type TabsTriggerProps = {
|
|
106
|
+
value: string;
|
|
107
|
+
children: ReactNode;
|
|
108
|
+
disabled?: boolean;
|
|
109
|
+
className?: string;
|
|
110
|
+
testID?: string;
|
|
111
|
+
accessibilityLabel?: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export function TabsTrigger({
|
|
115
|
+
value,
|
|
116
|
+
children,
|
|
117
|
+
disabled,
|
|
118
|
+
className,
|
|
119
|
+
testID,
|
|
120
|
+
accessibilityLabel,
|
|
121
|
+
}: TabsTriggerProps) {
|
|
122
|
+
const { value: current, setValue } = useTabs("TabsTrigger");
|
|
123
|
+
const { variant, size } = useContext(TabsListContext);
|
|
124
|
+
const largerText = useLargerText();
|
|
125
|
+
const scheme = useScheme();
|
|
126
|
+
const selected = current === value;
|
|
127
|
+
// Fill and label are styles from `tabColors`: apps' `bg-secondary` and
|
|
128
|
+
// `bg-surface` can stay light in dark mode, under a light label.
|
|
129
|
+
const colors = tabColors(variant, selected, scheme);
|
|
130
|
+
const trigger = tabTriggerSize(variant, size, largerText);
|
|
131
|
+
// The web `.t-tab` is `font-medium`; the soft trigger is regular.
|
|
132
|
+
const medium = variant !== "soft";
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<Pressable
|
|
136
|
+
testID={testID}
|
|
137
|
+
accessibilityRole="tab"
|
|
138
|
+
accessibilityLabel={
|
|
139
|
+
accessibilityLabel ?? (typeof children === "string" ? children : undefined)
|
|
140
|
+
}
|
|
141
|
+
accessibilityState={{ selected, disabled: Boolean(disabled) }}
|
|
142
|
+
// The web height (30) on screen, a 44 target to touch.
|
|
143
|
+
hitSlop={touchSlop(MIN_TOUCH_TARGET, trigger.minHeight)}
|
|
144
|
+
disabled={disabled}
|
|
145
|
+
onPress={() => {
|
|
146
|
+
if (selected) return;
|
|
147
|
+
selectionHaptic();
|
|
148
|
+
setValue(value);
|
|
149
|
+
}}
|
|
150
|
+
className={cn(
|
|
151
|
+
"shrink items-center justify-center rounded-full",
|
|
152
|
+
trigger.className,
|
|
153
|
+
variant === "pills" && "border border-white/40 dark:border-white/10",
|
|
154
|
+
disabled ? "opacity-50" : "active:opacity-70",
|
|
155
|
+
className,
|
|
156
|
+
)}
|
|
157
|
+
// A plain object: NativeWind puts the class styles and `style` in an
|
|
158
|
+
// array, where a `({ pressed }) => ...` style is dropped.
|
|
159
|
+
style={{ backgroundColor: colors.backgroundColor, minHeight: trigger.minHeight }}
|
|
160
|
+
>
|
|
161
|
+
{typeof children === "string" || typeof children === "number" ? (
|
|
162
|
+
<Text
|
|
163
|
+
numberOfLines={1}
|
|
164
|
+
className={cn(
|
|
165
|
+
"text-center",
|
|
166
|
+
medium ? "font-instrument-sans-medium" : "font-instrument-sans",
|
|
167
|
+
trigger.textClassName,
|
|
168
|
+
)}
|
|
169
|
+
style={[{ color: colors.color }, medium ? typeStyle("medium") : null]}
|
|
170
|
+
>
|
|
171
|
+
{children}
|
|
172
|
+
</Text>
|
|
173
|
+
) : (
|
|
174
|
+
children
|
|
175
|
+
)}
|
|
176
|
+
</Pressable>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export type TabsContentProps = ViewProps & {
|
|
181
|
+
value: string;
|
|
182
|
+
className?: string;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** Renders only while its tab is active. */
|
|
186
|
+
export function TabsContent({ value, className, ...props }: TabsContentProps) {
|
|
187
|
+
const { value: current } = useTabs("TabsContent");
|
|
188
|
+
if (current !== value) return null;
|
|
189
|
+
return <View className={className} {...props} />;
|
|
190
|
+
}
|