feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
128
packages/ui/mobile/src/components/alert.tsx
Normal file
128
packages/ui/mobile/src/components/alert.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { Text, TextClassContext } from "./text";
|
||||
|
||||
import type { Icon } from "./icons";
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import type { ViewProps } from "react-native";
|
||||
|
||||
const alertVariants = cva(
|
||||
"bg-card border-border relative w-full rounded-lg border px-4 pt-3.5 pb-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card border-border",
|
||||
destructive: "border-destructive/20 bg-destructive/5",
|
||||
primary: "border-primary/20 bg-primary/5",
|
||||
success: "border-success/20 bg-success/5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const alertTextVariants = cva("text-sm", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-foreground",
|
||||
destructive: "text-destructive",
|
||||
primary: "text-primary",
|
||||
success: "text-success",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
const alertDescriptionVariants = cva("ml-0.5 pb-1.5 pl-6 text-sm", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-muted-foreground",
|
||||
destructive: "text-destructive/90",
|
||||
primary: "text-primary/90",
|
||||
success: "text-success/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
const AlertContext =
|
||||
React.createContext<VariantProps<typeof alertVariants>["variant"]>(null);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
children,
|
||||
icon: Icon,
|
||||
iconClassName,
|
||||
...props
|
||||
}: ViewProps &
|
||||
React.RefAttributes<View> &
|
||||
VariantProps<typeof alertVariants> & {
|
||||
icon?: Icon;
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<AlertContext.Provider value={variant}>
|
||||
<TextClassContext.Provider
|
||||
value={cn(alertTextVariants({ variant }), className)}
|
||||
>
|
||||
<View
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
{Icon && (
|
||||
<View className="absolute top-3 left-3.5">
|
||||
<Icon
|
||||
className={cn(alertTextVariants({ variant }), iconClassName)}
|
||||
size={16}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{children}
|
||||
</View>
|
||||
</TextClassContext.Provider>
|
||||
</AlertContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
|
||||
return (
|
||||
<Text
|
||||
className={cn(
|
||||
"font-sans-medium ml-0.5 min-h-4 pl-6 leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
|
||||
const variant = React.useContext(AlertContext);
|
||||
|
||||
return (
|
||||
<Text
|
||||
className={cn(alertDescriptionVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertDescription, AlertTitle };
|
||||
47
packages/ui/mobile/src/components/avatar.tsx
Normal file
47
packages/ui/mobile/src/components/avatar.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as AvatarPrimitive from "@rn-primitives/avatar";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.RootProps & React.RefAttributes<AvatarPrimitive.RootRef>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
className={cn(
|
||||
"relative flex size-9 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.ImageProps & React.RefAttributes<AvatarPrimitive.ImageRef>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.FallbackProps &
|
||||
React.RefAttributes<AvatarPrimitive.FallbackRef>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
className={cn(
|
||||
"bg-muted border-border flex size-full flex-row items-center justify-center rounded-full border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export { Avatar, AvatarFallback, AvatarImage };
|
||||
61
packages/ui/mobile/src/components/badge.tsx
Normal file
61
packages/ui/mobile/src/components/badge.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as Slot from "@rn-primitives/slot";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { TextClassContext } from "./text";
|
||||
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import type { ViewProps } from "react-native";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"border-border group shrink-0 flex-row items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary border-transparent active:opacity-80",
|
||||
secondary: "bg-secondary border-transparent active:opacity-80",
|
||||
destructive: "bg-destructive border-transparent active:opacity-80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const badgeTextVariants = cva("font-sans-medium text-xs", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-primary-foreground",
|
||||
secondary: "text-secondary-foreground",
|
||||
destructive: "text-destructive-foreground",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
type BadgeProps = ViewProps &
|
||||
React.RefAttributes<View> & {
|
||||
asChild?: boolean;
|
||||
} & VariantProps<typeof badgeVariants>;
|
||||
|
||||
function Badge({ className, variant, asChild, ...props }: BadgeProps) {
|
||||
const Component = asChild ? Slot.View : View;
|
||||
return (
|
||||
<TextClassContext.Provider value={badgeTextVariants({ variant })}>
|
||||
<Component
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeTextVariants, badgeVariants };
|
||||
export type { BadgeProps };
|
||||
377
packages/ui/mobile/src/components/bottom-sheet.tsx
Normal file
377
packages/ui/mobile/src/components/bottom-sheet.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetModal,
|
||||
BottomSheetFooter as GBottomSheetFooter,
|
||||
BottomSheetView as GBottomSheetView,
|
||||
useBottomSheetModal,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
SCROLLABLE_TYPE,
|
||||
createBottomSheetScrollableComponent,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useTheme } from "@react-navigation/native";
|
||||
import * as Slot from "@rn-primitives/slot";
|
||||
import * as React from "react";
|
||||
import { memo } from "react";
|
||||
import { Keyboard, Platform, Pressable, View } from "react-native";
|
||||
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
|
||||
import Reanimated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { withUniwind } from "uniwind";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { Text } from "./text";
|
||||
|
||||
import type {
|
||||
BottomSheetScrollViewMethods,
|
||||
BottomSheetScrollView as GBottomSheetScrollView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type {
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetFooterProps as GBottomSheetFooterProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
|
||||
import type { GestureResponderEvent, ViewStyle } from "react-native";
|
||||
import type { KeyboardAwareScrollViewProps } from "react-native-keyboard-controller";
|
||||
|
||||
interface BottomSheetContext {
|
||||
sheetRef: React.RefObject<BottomSheetModal | null>;
|
||||
}
|
||||
|
||||
const BottomSheetContext = React.createContext<BottomSheetContext | null>(null);
|
||||
|
||||
function BottomSheet({ ...props }: React.ComponentProps<typeof View>) {
|
||||
const sheetRef = React.useRef<BottomSheetModal>(null);
|
||||
|
||||
return (
|
||||
<BottomSheetContext.Provider value={{ sheetRef: sheetRef }}>
|
||||
<View {...props} />
|
||||
</BottomSheetContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useBottomSheetContext() {
|
||||
const context = React.useContext(BottomSheetContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"BottomSheet compound components cannot be rendered outside the BottomSheet component",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
const CLOSED_INDEX = -1;
|
||||
|
||||
type BottomSheetContentRef = React.ComponentRef<typeof BottomSheetModal>;
|
||||
|
||||
type BottomSheetContentProps = Omit<
|
||||
React.ComponentProps<typeof BottomSheetModal>,
|
||||
"backdropComponent"
|
||||
> & {
|
||||
backdropProps?: Partial<React.ComponentProps<typeof BottomSheetBackdrop>>;
|
||||
};
|
||||
|
||||
const BottomSheetContent = React.forwardRef<
|
||||
BottomSheetContentRef,
|
||||
BottomSheetContentProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
enablePanDownToClose = true,
|
||||
enableDynamicSizing = true,
|
||||
backdropProps,
|
||||
backgroundStyle,
|
||||
android_keyboardInputMode = "adjustResize",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { colors } = useTheme();
|
||||
const { sheetRef } = useBottomSheetContext();
|
||||
|
||||
React.useImperativeHandle(ref, () => {
|
||||
if (!sheetRef.current) {
|
||||
return {} as BottomSheetModalMethods;
|
||||
}
|
||||
return sheetRef.current;
|
||||
}, [sheetRef]);
|
||||
|
||||
const renderBackdrop = React.useCallback(
|
||||
(props: BottomSheetBackdropProps) => {
|
||||
const {
|
||||
pressBehavior = "close",
|
||||
disappearsOnIndex = CLOSED_INDEX,
|
||||
style,
|
||||
onPress,
|
||||
...rest
|
||||
} = {
|
||||
...props,
|
||||
...backdropProps,
|
||||
};
|
||||
return (
|
||||
<BottomSheetBackdrop
|
||||
disappearsOnIndex={disappearsOnIndex}
|
||||
pressBehavior={pressBehavior}
|
||||
style={style}
|
||||
onPress={() => {
|
||||
if (Keyboard.isVisible()) {
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
onPress?.();
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[backdropProps],
|
||||
);
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={sheetRef}
|
||||
index={0}
|
||||
enablePanDownToClose={enablePanDownToClose}
|
||||
backdropComponent={renderBackdrop}
|
||||
enableDynamicSizing={enableDynamicSizing}
|
||||
backgroundStyle={[{ backgroundColor: colors.card }, backgroundStyle]}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: colors.border,
|
||||
}}
|
||||
topInset={insets.top}
|
||||
android_keyboardInputMode={android_keyboardInputMode}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function BottomSheetOpenTrigger({
|
||||
onPress,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Pressable> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const { sheetRef } = useBottomSheetContext();
|
||||
function handleOnPress(ev: GestureResponderEvent) {
|
||||
sheetRef.current?.present();
|
||||
onPress?.(ev);
|
||||
}
|
||||
const Trigger = asChild ? Slot.Pressable : Pressable;
|
||||
return <Trigger onPress={handleOnPress} {...props} />;
|
||||
}
|
||||
|
||||
function BottomSheetCloseTrigger({
|
||||
onPress,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Pressable> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const { dismiss } = useBottomSheetModal();
|
||||
function handleOnPress(ev: GestureResponderEvent) {
|
||||
dismiss();
|
||||
if (Keyboard.isVisible()) {
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
onPress?.(ev);
|
||||
}
|
||||
const Trigger = asChild ? Slot.Pressable : Pressable;
|
||||
return <Trigger onPress={handleOnPress} {...props} />;
|
||||
}
|
||||
|
||||
const BOTTOM_SHEET_HEADER_HEIGHT = 60; // BottomSheetHeader height
|
||||
|
||||
function BottomSheetView({
|
||||
className,
|
||||
children,
|
||||
hadHeader = false,
|
||||
style,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof GBottomSheetView>, "style"> & {
|
||||
hadHeader?: boolean;
|
||||
style?: ViewStyle;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const paddingBottom =
|
||||
insets.bottom +
|
||||
(Platform.select({
|
||||
ios: 4,
|
||||
android: 16,
|
||||
}) ?? 0) +
|
||||
(hadHeader ? BOTTOM_SHEET_HEADER_HEIGHT : 0);
|
||||
|
||||
return (
|
||||
<GBottomSheetView
|
||||
style={[
|
||||
{
|
||||
paddingBottom,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
className={cn(`gap-4 px-6 pt-4`, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</GBottomSheetView>
|
||||
);
|
||||
}
|
||||
|
||||
type BottomSheetScrollViewProps = Omit<
|
||||
React.ComponentPropsWithoutRef<typeof GBottomSheetScrollView>,
|
||||
"style"
|
||||
> & {
|
||||
hadHeader?: boolean;
|
||||
className?: string;
|
||||
contentContainerClassName?: string;
|
||||
style?: ViewStyle;
|
||||
};
|
||||
|
||||
const BottomSheetKeyboardAwareScrollView = memo(
|
||||
createBottomSheetScrollableComponent<
|
||||
BottomSheetScrollViewMethods,
|
||||
BottomSheetScrollViewProps
|
||||
>(
|
||||
SCROLLABLE_TYPE.SCROLLVIEW,
|
||||
Reanimated.createAnimatedComponent<KeyboardAwareScrollViewProps>(
|
||||
KeyboardAwareScrollView,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const StyledBottomSheetKeyboardAwareScrollView = withUniwind(
|
||||
BottomSheetKeyboardAwareScrollView,
|
||||
);
|
||||
|
||||
function BottomSheetScrollView({
|
||||
children,
|
||||
hadHeader = false,
|
||||
style,
|
||||
className,
|
||||
contentContainerClassName,
|
||||
...props
|
||||
}: BottomSheetScrollViewProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const paddingBottom =
|
||||
insets.bottom +
|
||||
(Platform.select({
|
||||
ios: 8,
|
||||
android: 16,
|
||||
}) ?? 0) +
|
||||
(hadHeader ? BOTTOM_SHEET_HEADER_HEIGHT : 0);
|
||||
|
||||
return (
|
||||
<StyledBottomSheetKeyboardAwareScrollView
|
||||
className={cn("h-full px-6 pt-4", className)}
|
||||
contentContainerClassName={cn("gap-4", contentContainerClassName)}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={[
|
||||
{
|
||||
gap: 16,
|
||||
paddingBottom,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledBottomSheetKeyboardAwareScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function BottomSheetHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof View>) {
|
||||
return <View className={cn("items-start gap-0.5", className)} {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* To be used in a useCallback function as a props to BottomSheetContent
|
||||
*/
|
||||
function BottomSheetFooter({
|
||||
bottomSheetFooterProps,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof View>, "style"> & {
|
||||
bottomSheetFooterProps: GBottomSheetFooterProps;
|
||||
children?: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
return (
|
||||
<GBottomSheetFooter {...bottomSheetFooterProps}>
|
||||
<View
|
||||
style={[{ paddingBottom: insets.bottom + 6 }, style]}
|
||||
className={cn("px-6 pt-1.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</GBottomSheetFooter>
|
||||
);
|
||||
}
|
||||
|
||||
function BottomSheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Text>) {
|
||||
return (
|
||||
<Text
|
||||
role="heading"
|
||||
aria-level={3}
|
||||
className={cn(
|
||||
"font-sans-semibold text-xl leading-tight tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BottomSheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Text>) {
|
||||
return (
|
||||
<Text
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useBottomSheet() {
|
||||
const ref = React.useRef<BottomSheetContentRef>(null);
|
||||
|
||||
const open = React.useCallback(() => {
|
||||
ref.current?.present();
|
||||
}, []);
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
ref.current?.dismiss();
|
||||
}, []);
|
||||
|
||||
return { ref, open, close };
|
||||
}
|
||||
|
||||
export {
|
||||
BottomSheet,
|
||||
BottomSheetCloseTrigger,
|
||||
BottomSheetContent,
|
||||
BottomSheetFooter,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetHeader,
|
||||
BottomSheetOpenTrigger,
|
||||
BottomSheetView,
|
||||
BottomSheetTitle,
|
||||
BottomSheetDescription,
|
||||
type BottomSheetContentRef,
|
||||
useBottomSheet,
|
||||
};
|
||||
64
packages/ui/mobile/src/components/built-with.tsx
Normal file
64
packages/ui/mobile/src/components/built-with.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react";
|
||||
import { Pressable, View, Linking } from "react-native";
|
||||
import NativeSvg, { Path } from "react-native-svg";
|
||||
import { withUniwind } from "uniwind";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { buttonVariants } from "./button";
|
||||
import { Text } from "./text";
|
||||
|
||||
const Svg = withUniwind(NativeSvg);
|
||||
|
||||
export const BuiltWith = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Pressable>) => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => Linking.openURL("https://www.turbostarter.dev")}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: "outline",
|
||||
className: "flex-row items-center justify-center gap-0",
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Text className="text-sm leading-tight">{t("builtWith")}</Text>
|
||||
<View className="shrink-0 flex-row items-center gap-1.5">
|
||||
<Svg
|
||||
height={18}
|
||||
width={18}
|
||||
viewBox="0 0 512 517"
|
||||
fill="none"
|
||||
className="text-primary ml-1.5"
|
||||
>
|
||||
<Path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M383.309 10.9714C383.309 4.91208 388.221 0 394.28 0C400.339 0 405.251 4.91208 405.251 10.9714V122.57C405.251 138.493 398.332 153.631 386.292 164.051L367.707 180.134L391.678 204.105L428.239 190.516C441.132 185.724 449.686 173.419 449.686 159.664V58.5143C449.686 52.4549 454.598 47.5429 460.657 47.5429C466.717 47.5429 471.629 52.4549 471.629 58.5143V159.664C471.629 182.589 457.372 203.097 435.883 211.084L405.175 222.498C415.977 243.457 415.977 268.543 405.175 289.502L440.649 302.687C459.273 309.609 471.629 327.383 471.629 347.251V453.486C471.629 459.545 466.717 464.457 460.657 464.457C454.598 464.457 449.686 459.545 449.686 453.486V347.251C449.686 336.553 443.033 326.983 433.005 323.255L391.678 307.895L367.707 331.866L388.819 350.137C399.255 359.167 405.251 372.287 405.251 386.087V501.029C405.251 507.088 400.339 512 394.28 512C388.221 512 383.309 507.088 383.309 501.029V386.087C383.309 378.656 380.08 371.592 374.461 366.729L352.151 347.423L266.345 433.229C260.632 438.941 251.37 438.941 245.657 433.229L160.142 347.713L138.168 366.729C132.549 371.592 129.32 378.656 129.32 386.087V501.029C129.32 507.088 124.408 512 118.349 512C112.289 512 107.377 507.088 107.377 501.029V386.087C107.377 372.287 113.374 359.167 123.809 350.137L144.586 332.157L120.493 308.065L79.624 323.255C69.5957 326.983 62.9429 336.553 62.9429 347.251V453.486C62.9429 459.545 58.0308 464.457 51.9714 464.457C45.9121 464.457 41 459.545 41 453.486V347.251C41 327.383 53.3553 309.609 71.9792 302.687L106.928 289.698C95.991 268.634 95.991 243.366 106.928 222.303L76.7452 211.084C55.2562 203.097 41 182.589 41 159.664V58.5143C41 52.4549 45.9121 47.5429 51.9714 47.5429C58.0308 47.5429 62.9429 52.4549 62.9429 58.5143V159.664C62.9429 173.419 71.4966 185.724 84.39 190.516L120.494 203.935L144.586 179.843L126.337 164.051C114.296 153.631 107.377 138.493 107.377 122.57V10.9714C107.377 4.91208 112.289 0 118.349 0C124.408 0 129.32 4.91208 129.32 10.9714V122.57C129.32 132.124 133.472 141.206 140.696 147.458L160.142 164.287L245.657 78.7717C251.37 73.0588 260.632 73.0589 266.345 78.7717L352.151 164.577L371.933 147.458C379.157 141.206 383.309 132.124 383.309 122.57V10.9714ZM267.067 166.768C272.383 161.416 281.338 166.639 279.298 173.901L268.742 211.469C266.776 218.467 272.035 225.408 279.304 225.408H318.878C335.139 225.408 343.311 245.043 331.851 256.58L244.619 344.4C239.303 349.752 230.348 344.529 232.388 337.267L242.944 299.699C244.91 292.701 239.65 285.76 232.381 285.76H192.808C176.547 285.76 168.375 266.125 179.835 254.588L267.067 166.768Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</Svg>
|
||||
|
||||
<Svg
|
||||
height={12}
|
||||
width={84}
|
||||
viewBox="0 0 667 84"
|
||||
fill="none"
|
||||
className="text-foreground"
|
||||
>
|
||||
<Path
|
||||
d="M38.887 82H26.497V12.2781H0.140091V1.12708H65.2439V12.2781H38.887V82ZM106.939 57.4453V22.3027H118.315V82H107.727V72.5385C104.573 78.7335 97.5899 83.1264 89.4801 83.1264C77.428 83.1264 69.0929 76.0303 69.0929 60.7117V22.3027H80.4692V58.459C80.4692 68.8215 85.6505 73.1017 93.0845 73.1017C100.744 73.1017 106.939 66.7941 106.939 57.4453ZM144.364 51.4755V82H132.988V22.3027H143.575V34.918C147.743 26.6955 156.754 21.5143 166.553 21.5143V33.3411C153.713 32.6653 144.364 38.2971 144.364 51.4755ZM231.424 52.1514C231.424 70.0605 221.061 83.1264 204.954 83.1264C196.507 83.1264 189.298 78.8462 185.018 71.2995V82H174.43V1.12708H185.806V32.5526C189.974 25.2313 196.845 21.1764 204.954 21.1764C220.949 21.1764 231.424 34.0169 231.424 52.1514ZM219.597 52.1514C219.597 38.635 212.501 31.201 202.702 31.201C193.24 31.201 185.806 38.5224 185.806 51.9261C185.806 65.1045 193.015 72.9891 202.702 72.9891C212.501 72.9891 219.597 65.4425 219.597 52.1514ZM266.837 83.1264C249.941 83.1264 237.551 69.8353 237.551 52.1514C237.551 34.4675 249.941 21.1764 266.837 21.1764C283.732 21.1764 296.122 34.4675 296.122 52.1514C296.122 69.8353 283.732 83.1264 266.837 83.1264ZM266.837 73.1017C276.636 73.1017 284.408 65.2172 284.408 52.1514C284.408 39.0855 276.636 31.3136 266.837 31.3136C257.037 31.3136 249.378 39.0855 249.378 52.1514C249.378 65.2172 257.037 73.1017 266.837 73.1017ZM366.628 58.3464C366.628 72.4259 355.364 83.1264 335.766 83.1264C316.28 83.1264 304.453 72.4259 303.439 56.3189H316.054C316.617 66.5688 323.15 73.2144 335.54 73.2144C345.79 73.2144 353.45 68.371 353.45 60.0359C353.45 53.2777 349.057 49.8986 339.708 47.8712L327.994 45.6185C316.617 43.3657 306.255 37.6213 306.255 23.8796C306.255 10.2506 318.194 0.000719194 334.977 0.000719194C351.76 0.000719194 364.488 10.2506 365.502 26.3576H352.886C352.211 16.6709 345.227 9.91272 335.09 9.91272C324.615 9.91272 318.87 16.1077 318.87 23.0912C318.87 30.7505 325.516 33.4537 333.062 35.0306L345.002 37.396C358.856 40.2119 366.628 46.2943 366.628 58.3464ZM412.842 70.9616V80.9863C409.351 82.5632 406.309 83.1264 402.705 83.1264C391.667 83.1264 384.007 77.1566 384.007 63.9782V31.9894H370.829V22.3027H384.007V4.61881H395.384V22.3027H413.406V31.9894H395.384V61.3875C395.384 69.61 399.326 72.5385 405.408 72.5385C408.112 72.5385 410.477 72.088 412.842 70.9616ZM459.293 82V72.7638C455.576 79.4094 448.93 83.1264 440.144 83.1264C427.754 83.1264 419.645 76.0303 419.645 65.1045C419.645 53.3904 428.993 47.308 446.79 47.308C450.282 47.308 453.098 47.4206 457.941 47.9838V43.591C457.941 35.0306 453.323 30.1873 445.438 30.1873C437.103 30.1873 432.035 35.1433 431.697 43.4784H421.334C421.897 30.0746 431.471 21.1764 445.438 21.1764C460.194 21.1764 468.754 29.5115 468.754 43.7036V82H459.293ZM430.458 64.7666C430.458 70.9616 435.076 75.0165 442.397 75.0165C451.971 75.0165 457.941 69.0468 457.941 59.9233V55.0799C453.548 54.5167 450.394 54.4041 447.466 54.4041C436.089 54.4041 430.458 57.7832 430.458 64.7666ZM494.214 51.4755V82H482.838V22.3027H493.426V34.918C497.593 26.6955 506.604 21.5143 516.404 21.5143V33.3411C503.563 32.6653 494.214 38.2971 494.214 51.4755ZM561.588 70.9616V80.9863C558.097 82.5632 555.055 83.1264 551.451 83.1264C540.413 83.1264 532.753 77.1566 532.753 63.9782V31.9894H519.575V22.3027H532.753V4.61881H544.13V22.3027H562.152V31.9894H544.13V61.3875C544.13 69.61 548.072 72.5385 554.154 72.5385C556.858 72.5385 559.223 72.088 561.588 70.9616ZM594.787 83.1264C577.554 83.1264 565.952 70.6237 565.952 51.8135C565.952 34.1295 578.004 21.1764 594.449 21.1764C612.246 21.1764 624.073 35.5938 622.045 54.9673H577.554C578.455 67.132 584.537 74.2281 594.562 74.2281C603.01 74.2281 608.867 69.61 610.781 61.8381H622.045C619.117 75.1292 608.867 83.1264 594.787 83.1264ZM594.224 29.7367C585.1 29.7367 578.905 36.2696 577.666 47.4206H609.993C609.43 36.3823 603.46 29.7367 594.224 29.7367ZM644.39 51.4755V82H633.014V22.3027H643.602V34.918C647.769 26.6955 656.78 21.5143 666.58 21.5143V33.3411C653.739 32.6653 644.39 38.2971 644.39 51.4755Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</Svg>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
85
packages/ui/mobile/src/components/button.tsx
Normal file
85
packages/ui/mobile/src/components/button.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { Pressable } from "react-native";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { TextClassContext } from "./text";
|
||||
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary active:bg-primary/90 shadow-sm shadow-black/5",
|
||||
destructive:
|
||||
"bg-destructive active:bg-destructive/90 dark:bg-destructive/60 shadow-sm shadow-black/5",
|
||||
outline:
|
||||
"border-border bg-background active:bg-accent dark:bg-input/30 dark:border-input dark:active:bg-input/50 border shadow-sm shadow-black/5",
|
||||
secondary:
|
||||
"bg-secondary active:bg-secondary/80 shadow-sm shadow-black/5",
|
||||
ghost: "active:bg-accent dark:active:bg-accent/50",
|
||||
link: "",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 gap-1.5 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-6",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const buttonTextVariants = cva("text-foreground font-sans-medium text-sm", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-primary-foreground",
|
||||
destructive: "text-destructive-foreground",
|
||||
outline: "group-active:text-accent-foreground",
|
||||
secondary:
|
||||
"text-secondary-foreground group-active:text-secondary-foreground",
|
||||
ghost: "group-active:text-accent-foreground",
|
||||
link: "text-primary group-active:underline",
|
||||
},
|
||||
size: {
|
||||
default: "",
|
||||
sm: "",
|
||||
lg: "",
|
||||
icon: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
type ButtonProps = React.ComponentProps<typeof Pressable> &
|
||||
React.RefAttributes<typeof Pressable> &
|
||||
VariantProps<typeof buttonVariants>;
|
||||
|
||||
function Button({ className, variant, size, ...props }: ButtonProps) {
|
||||
return (
|
||||
<TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
|
||||
<Pressable
|
||||
className={cn(
|
||||
props.disabled && "opacity-50",
|
||||
buttonVariants({ variant, size }),
|
||||
className,
|
||||
)}
|
||||
role="button"
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonTextVariants, buttonVariants };
|
||||
export type { ButtonProps };
|
||||
86
packages/ui/mobile/src/components/card.tsx
Normal file
86
packages/ui/mobile/src/components/card.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as React from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { TextClassContext, Text } from "./text";
|
||||
|
||||
import type { TextRef, ViewRef } from "@rn-primitives/types";
|
||||
import type { ViewProps } from "react-native";
|
||||
|
||||
function Card({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
||||
return (
|
||||
<TextClassContext.Provider value="text-card-foreground">
|
||||
<View
|
||||
className={cn("border-border bg-card rounded-lg border", className)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View> & React.RefAttributes<ViewRef>) {
|
||||
return (
|
||||
<View className={cn("flex flex-col gap-1.5 p-5", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof Text> & React.RefAttributes<TextRef>) {
|
||||
return (
|
||||
<Text
|
||||
role="heading"
|
||||
aria-level={3}
|
||||
className={cn(
|
||||
"font-sans-medium text-card-foreground text-2xl leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof Text> & React.RefAttributes<TextRef>) {
|
||||
return (
|
||||
<Text
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View> & React.RefAttributes<ViewRef>) {
|
||||
return <View className={cn("p-5 pt-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View> & React.RefAttributes<ViewRef>) {
|
||||
return (
|
||||
<View
|
||||
className={cn("flex flex-row items-center p-5 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
};
|
||||
48
packages/ui/mobile/src/components/checkbox.tsx
Normal file
48
packages/ui/mobile/src/components/checkbox.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as CheckboxPrimitive from "@rn-primitives/checkbox";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { Icons } from "./icons";
|
||||
|
||||
const DEFAULT_HIT_SLOP = 24;
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
checkedClassName,
|
||||
indicatorClassName,
|
||||
iconClassName,
|
||||
...props
|
||||
}: CheckboxPrimitive.RootProps &
|
||||
React.RefAttributes<CheckboxPrimitive.RootRef> & {
|
||||
checkedClassName?: string;
|
||||
indicatorClassName?: string;
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 size-4 shrink-0 overflow-hidden rounded-[4px] border shadow-sm shadow-black/5",
|
||||
props.checked && cn("border-primary", checkedClassName),
|
||||
props.disabled && "opacity-50",
|
||||
className,
|
||||
)}
|
||||
hitSlop={DEFAULT_HIT_SLOP}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn(
|
||||
"bg-primary h-full w-full items-center justify-center",
|
||||
indicatorClassName,
|
||||
)}
|
||||
>
|
||||
<Icons.Check
|
||||
size={12}
|
||||
strokeWidth={3.5}
|
||||
className={cn("text-primary-foreground", iconClassName)}
|
||||
/>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
291
packages/ui/mobile/src/components/dropdown-menu.tsx
Normal file
291
packages/ui/mobile/src/components/dropdown-menu.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import * as DropdownMenuPrimitive from "@rn-primitives/dropdown-menu";
|
||||
import * as React from "react";
|
||||
import { Platform, StyleSheet, Text, View } from "react-native";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { FadeIn } from "react-native-reanimated";
|
||||
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { Icons } from "./icons";
|
||||
import { TextClassContext } from "./text";
|
||||
|
||||
import type { StyleProp, TextProps, ViewStyle } from "react-native";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
iconClassName,
|
||||
...props
|
||||
}: DropdownMenuPrimitive.SubTriggerProps &
|
||||
React.RefAttributes<DropdownMenuPrimitive.SubTriggerRef> & {
|
||||
children?: React.ReactNode;
|
||||
iconClassName?: string;
|
||||
inset?: boolean;
|
||||
}) {
|
||||
const { open } = DropdownMenuPrimitive.useSubContext();
|
||||
const Icon = open ? Icons.ChevronUp : Icons.ChevronDown;
|
||||
return (
|
||||
<TextClassContext.Provider
|
||||
value={cn(
|
||||
"group-active:text-accent-foreground text-sm select-none",
|
||||
open && "text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
className={cn(
|
||||
"active:bg-accent group flex flex-row items-center rounded-sm px-2 py-2 sm:py-1.5",
|
||||
open && "bg-accent",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<>{children}</>
|
||||
<Icon
|
||||
size={16}
|
||||
className={cn(
|
||||
"text-foreground ml-auto size-4 shrink-0",
|
||||
iconClassName,
|
||||
)}
|
||||
/>
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: DropdownMenuPrimitive.SubContentProps &
|
||||
React.RefAttributes<DropdownMenuPrimitive.SubContentRef>) {
|
||||
return (
|
||||
<Animated.View entering={FadeIn}>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
className={cn(
|
||||
"bg-popover border-border overflow-hidden rounded-md border p-1 shadow-lg shadow-black/5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const FullWindowOverlay =
|
||||
Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment;
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
overlayClassName,
|
||||
overlayStyle,
|
||||
portalHost,
|
||||
...props
|
||||
}: DropdownMenuPrimitive.ContentProps &
|
||||
React.RefAttributes<DropdownMenuPrimitive.ContentRef> & {
|
||||
overlayStyle?: StyleProp<ViewStyle>;
|
||||
overlayClassName?: string;
|
||||
portalHost?: string;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal hostName={portalHost}>
|
||||
<FullWindowOverlay>
|
||||
<DropdownMenuPrimitive.Overlay
|
||||
style={
|
||||
overlayStyle
|
||||
? StyleSheet.flatten([
|
||||
StyleSheet.absoluteFill,
|
||||
overlayStyle as typeof StyleSheet.absoluteFill,
|
||||
])
|
||||
: StyleSheet.absoluteFill
|
||||
}
|
||||
className={overlayClassName}
|
||||
>
|
||||
<Animated.View entering={FadeIn}>
|
||||
<TextClassContext.Provider value="text-popover-foreground">
|
||||
<DropdownMenuPrimitive.Content
|
||||
className={cn(
|
||||
"bg-popover border-border min-w-32 overflow-hidden rounded-md border p-1 shadow-lg shadow-black/5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
</Animated.View>
|
||||
</DropdownMenuPrimitive.Overlay>
|
||||
</FullWindowOverlay>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant,
|
||||
...props
|
||||
}: DropdownMenuPrimitive.ItemProps &
|
||||
React.RefAttributes<DropdownMenuPrimitive.ItemRef> & {
|
||||
className?: string;
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<TextClassContext.Provider
|
||||
value={cn(
|
||||
"text-popover-foreground group-active:text-popover-foreground text-sm select-none",
|
||||
variant === "destructive" &&
|
||||
"text-destructive group-active:text-destructive",
|
||||
)}
|
||||
>
|
||||
<DropdownMenuPrimitive.Item
|
||||
className={cn(
|
||||
"active:bg-accent group relative flex flex-row items-center gap-2 rounded-sm px-2 py-2 sm:py-1.5",
|
||||
variant === "destructive" &&
|
||||
"active:bg-destructive/10 dark:active:bg-destructive/20",
|
||||
props.disabled && "opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: DropdownMenuPrimitive.CheckboxItemProps &
|
||||
React.RefAttributes<DropdownMenuPrimitive.CheckboxItemRef> & {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<TextClassContext.Provider value="text-sm text-popover-foreground select-none group-active:text-accent-foreground">
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
className={cn(
|
||||
"active:bg-accent group relative flex flex-row items-center gap-2 rounded-sm py-2 pr-2 pl-8 sm:py-1.5",
|
||||
props.disabled && "opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<View className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Icons.Check className={cn("text-foreground size-4")} />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</View>
|
||||
<>{children}</>
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: DropdownMenuPrimitive.RadioItemProps &
|
||||
React.RefAttributes<DropdownMenuPrimitive.RadioItemRef> & {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<TextClassContext.Provider value="text-sm text-popover-foreground select-none group-active:text-accent-foreground">
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
className={cn(
|
||||
"active:bg-accent group relative flex flex-row items-center gap-2 rounded-sm py-2 pr-2 pl-8 sm:py-1.5",
|
||||
props.disabled && "opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<View className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<View className="bg-foreground h-2 w-2 rounded-full" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</View>
|
||||
<>{children}</>
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: DropdownMenuPrimitive.LabelProps &
|
||||
React.RefAttributes<DropdownMenuPrimitive.LabelRef> & {
|
||||
className?: string;
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
className={cn(
|
||||
"text-foreground font-sans-medium px-2 py-2 text-xs sm:py-1.5",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: DropdownMenuPrimitive.SeparatorProps &
|
||||
React.RefAttributes<DropdownMenuPrimitive.SeparatorRef>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: TextProps & React.RefAttributes<Text>) {
|
||||
return (
|
||||
<Text
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
509
packages/ui/mobile/src/components/form.tsx
Normal file
509
packages/ui/mobile/src/components/form.tsx
Normal file
@@ -0,0 +1,509 @@
|
||||
import * as React from "react";
|
||||
import { Controller, FormProvider, useFormContext } from "react-hook-form";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { isKey, useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { Checkbox } from "./checkbox";
|
||||
import { Input } from "./input";
|
||||
import { Label } from "./label";
|
||||
import { RadioGroup } from "./radio-group";
|
||||
import { Select } from "./select";
|
||||
import { Switch } from "./switch";
|
||||
import { Text } from "./text";
|
||||
import { Textarea } from "./textarea";
|
||||
|
||||
import type { Option } from "./select";
|
||||
import type {
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
Noop,
|
||||
} from "react-hook-form";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
interface FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> {
|
||||
name: TName;
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
function FormField<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(props: ControllerProps<TFieldValues, TName>) {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState, formState, handleSubmit } = useFormContext();
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
const { nativeID } = itemContext;
|
||||
|
||||
return {
|
||||
nativeID,
|
||||
name: fieldContext.name,
|
||||
formItemNativeID: `${nativeID}-form-item`,
|
||||
formDescriptionNativeID: `${nativeID}-form-item-description`,
|
||||
formMessageNativeID: `${nativeID}-form-item-message`,
|
||||
handleSubmit,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
interface FormItemContextValue {
|
||||
nativeID: string;
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<typeof View>) {
|
||||
const nativeID = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ nativeID }}>
|
||||
<View className={cn("gap-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
nativeID: _nativeID,
|
||||
children,
|
||||
ref,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Label>, "children"> & {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { error, formItemNativeID } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn("px-px", error && "text-destructive", className)}
|
||||
nativeID={formItemNativeID}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Text>) {
|
||||
const { formDescriptionNativeID } = useFormField();
|
||||
|
||||
return (
|
||||
<Text
|
||||
ref={ref}
|
||||
nativeID={formDescriptionNativeID}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Text>) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { error, formMessageNativeID } = useFormField();
|
||||
const body = error ? String(error.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
nativeID={formMessageNativeID}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{typeof body === "string" && isKey(body, i18n) ? t(body) : body}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
type Override<T, U> = Omit<T, keyof U> & U;
|
||||
|
||||
interface FormFieldFieldProps<T> {
|
||||
name: string;
|
||||
onBlur: Noop;
|
||||
onChange: (val: T) => void;
|
||||
value: T;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type FormItemProps<T extends React.ElementType<any>, U> = Override<
|
||||
React.ComponentPropsWithoutRef<T>,
|
||||
FormFieldFieldProps<U>
|
||||
> & {
|
||||
label?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
};
|
||||
|
||||
function FormInput({
|
||||
label,
|
||||
description,
|
||||
onChange,
|
||||
ref,
|
||||
...props
|
||||
}: FormItemProps<typeof Input, string> & {
|
||||
ref?: React.Ref<React.ComponentRef<typeof Input>>;
|
||||
}) {
|
||||
const inputRef = React.useRef<React.ComponentRef<typeof Input>>(null);
|
||||
const {
|
||||
error,
|
||||
formItemNativeID,
|
||||
formDescriptionNativeID,
|
||||
formMessageNativeID,
|
||||
} = useFormField();
|
||||
|
||||
React.useImperativeHandle(ref, () => {
|
||||
if (!inputRef.current) {
|
||||
return {} as React.ComponentRef<typeof Input>;
|
||||
}
|
||||
return inputRef.current;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inputRef.current]);
|
||||
|
||||
function handleOnLabelPress() {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
if (inputRef.current.isFocused()) {
|
||||
inputRef.current.blur();
|
||||
} else {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
{!!label && (
|
||||
<FormLabel nativeID={formItemNativeID} onPress={handleOnLabelPress}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<Input
|
||||
ref={inputRef}
|
||||
aria-labelledby={formItemNativeID}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionNativeID}`
|
||||
: `${formDescriptionNativeID} ${formMessageNativeID}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
onChangeText={onChange}
|
||||
{...props}
|
||||
/>
|
||||
{!!description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
function FormTextarea({
|
||||
label,
|
||||
description,
|
||||
onChange,
|
||||
ref,
|
||||
...props
|
||||
}: FormItemProps<typeof Textarea, string> & {
|
||||
ref?: React.Ref<React.ComponentRef<typeof Textarea>>;
|
||||
}) {
|
||||
const textareaRef = React.useRef<React.ComponentRef<typeof Textarea>>(null);
|
||||
const {
|
||||
error,
|
||||
formItemNativeID,
|
||||
formDescriptionNativeID,
|
||||
formMessageNativeID,
|
||||
} = useFormField();
|
||||
|
||||
React.useImperativeHandle(ref, () => {
|
||||
if (!textareaRef.current) {
|
||||
return {} as React.ComponentRef<typeof Textarea>;
|
||||
}
|
||||
return textareaRef.current;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [textareaRef.current]);
|
||||
|
||||
function handleOnLabelPress() {
|
||||
if (!textareaRef.current) {
|
||||
return;
|
||||
}
|
||||
if (textareaRef.current.isFocused()) {
|
||||
textareaRef.current.blur();
|
||||
} else {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
{!!label && (
|
||||
<FormLabel nativeID={formItemNativeID} onPress={handleOnLabelPress}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
aria-labelledby={formItemNativeID}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionNativeID}`
|
||||
: `${formDescriptionNativeID} ${formMessageNativeID}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
onChangeText={onChange}
|
||||
{...props}
|
||||
/>
|
||||
{!!description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
function FormCheckbox({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
ref,
|
||||
...props
|
||||
}: Omit<
|
||||
FormItemProps<typeof Checkbox, boolean>,
|
||||
"checked" | "onCheckedChange"
|
||||
> & { ref?: React.Ref<React.ComponentRef<typeof Checkbox>> }) {
|
||||
const {
|
||||
error,
|
||||
formItemNativeID,
|
||||
formDescriptionNativeID,
|
||||
formMessageNativeID,
|
||||
} = useFormField();
|
||||
|
||||
function handleOnLabelPress() {
|
||||
onChange(!value);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Checkbox
|
||||
ref={ref}
|
||||
aria-labelledby={formItemNativeID}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionNativeID}`
|
||||
: `${formDescriptionNativeID} ${formMessageNativeID}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
onCheckedChange={onChange}
|
||||
checked={value}
|
||||
{...props}
|
||||
/>
|
||||
{!!label && (
|
||||
<FormLabel nativeID={formItemNativeID} onPress={handleOnLabelPress}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
)}
|
||||
</View>
|
||||
{!!description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
function FormRadioGroup({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
ref,
|
||||
...props
|
||||
}: Omit<FormItemProps<typeof RadioGroup, string>, "onValueChange"> & {
|
||||
ref?: React.Ref<React.ComponentRef<typeof RadioGroup>>;
|
||||
}) {
|
||||
const {
|
||||
error,
|
||||
formItemNativeID,
|
||||
formDescriptionNativeID,
|
||||
formMessageNativeID,
|
||||
} = useFormField();
|
||||
|
||||
return (
|
||||
<FormItem className="gap-3">
|
||||
<View>
|
||||
{!!label && <FormLabel nativeID={formItemNativeID}>{label}</FormLabel>}
|
||||
{!!description && (
|
||||
<FormDescription className="pt-0">{description}</FormDescription>
|
||||
)}
|
||||
</View>
|
||||
<RadioGroup
|
||||
ref={ref}
|
||||
aria-labelledby={formItemNativeID}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionNativeID}`
|
||||
: `${formDescriptionNativeID} ${formMessageNativeID}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
onValueChange={onChange}
|
||||
value={value}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
function FormSelect({
|
||||
label,
|
||||
description,
|
||||
onChange,
|
||||
value,
|
||||
ref,
|
||||
...props
|
||||
}: Omit<
|
||||
FormItemProps<typeof Select, Partial<Option>>,
|
||||
"open" | "onOpenChange" | "onValueChange"
|
||||
> & { ref?: React.Ref<React.ComponentRef<typeof Select>> }) {
|
||||
const {
|
||||
error,
|
||||
formItemNativeID,
|
||||
formDescriptionNativeID,
|
||||
formMessageNativeID,
|
||||
} = useFormField();
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
{!!label && <FormLabel nativeID={formItemNativeID}>{label}</FormLabel>}
|
||||
<Select
|
||||
ref={ref}
|
||||
aria-labelledby={formItemNativeID}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionNativeID}`
|
||||
: `${formDescriptionNativeID} ${formMessageNativeID}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
value={
|
||||
value
|
||||
? { label: value.label ?? "", value: value.label ?? "" }
|
||||
: undefined
|
||||
}
|
||||
onValueChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
{!!description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
function FormSwitch({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
ref,
|
||||
...props
|
||||
}: Omit<
|
||||
FormItemProps<typeof Switch, boolean>,
|
||||
"checked" | "onCheckedChange"
|
||||
> & { ref?: React.Ref<React.ComponentRef<typeof Switch>> }) {
|
||||
const switchRef = React.useRef<React.ComponentRef<typeof Switch>>(null);
|
||||
const {
|
||||
error,
|
||||
formItemNativeID,
|
||||
formDescriptionNativeID,
|
||||
formMessageNativeID,
|
||||
} = useFormField();
|
||||
|
||||
React.useImperativeHandle(ref, () => {
|
||||
if (!switchRef.current) {
|
||||
return {} as React.ComponentRef<typeof Switch>;
|
||||
}
|
||||
return switchRef.current;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [switchRef.current]);
|
||||
|
||||
function handleOnLabelPress() {
|
||||
onChange(!value);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormItem className="px-1">
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Switch
|
||||
ref={switchRef}
|
||||
aria-labelledby={formItemNativeID}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionNativeID}`
|
||||
: `${formDescriptionNativeID} ${formMessageNativeID}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
onCheckedChange={onChange}
|
||||
checked={value}
|
||||
{...props}
|
||||
/>
|
||||
{!!label && (
|
||||
<FormLabel
|
||||
className="pb-0"
|
||||
nativeID={formItemNativeID}
|
||||
onPress={handleOnLabelPress}
|
||||
>
|
||||
{label}
|
||||
</FormLabel>
|
||||
)}
|
||||
</View>
|
||||
{!!description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Form,
|
||||
FormCheckbox,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormInput,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormRadioGroup,
|
||||
FormSwitch,
|
||||
FormSelect,
|
||||
FormTextarea,
|
||||
useFormField,
|
||||
};
|
||||
58
packages/ui/mobile/src/components/i18n.tsx
Normal file
58
packages/ui/mobile/src/components/i18n.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { View } from "react-native";
|
||||
|
||||
import { config, LocaleLabel, useTranslation } from "@turbostarter/i18n";
|
||||
import { Locale } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { Button } from "./button";
|
||||
import { Icons } from "./icons";
|
||||
import { Text } from "./text";
|
||||
|
||||
import type { Icon } from "./icons";
|
||||
|
||||
export const LocaleIcon: Record<Locale, Icon> = {
|
||||
[Locale.EN]: Icons.UnitedKingdom,
|
||||
[Locale.ES]: Icons.Spain,
|
||||
} as const;
|
||||
|
||||
interface LocaleCustomizerProps {
|
||||
readonly onChange?: (lang: Locale) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const LocaleCustomizer = ({ onChange }: LocaleCustomizerProps) => {
|
||||
const { i18n } = useTranslation("common");
|
||||
const lang = i18n.language as Locale;
|
||||
|
||||
const handleLocaleChange = async (lang: Locale) => {
|
||||
await onChange?.(lang);
|
||||
await i18n.changeLanguage(lang);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="mt-2 flex flex-1 flex-col items-center gap-4">
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{config.locales.map((locale) => {
|
||||
const Icon = LocaleIcon[locale];
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={locale}
|
||||
variant="outline"
|
||||
onPress={() => handleLocaleChange(locale)}
|
||||
className={cn(
|
||||
"grow basis-24 flex-row justify-start gap-3 px-3",
|
||||
locale === lang &&
|
||||
"border-primary dark:border-primary border-2",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
<Text className="text-sm capitalize">{LocaleLabel[locale]}</Text>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
131
packages/ui/mobile/src/components/icons.tsx
Normal file
131
packages/ui/mobile/src/components/icons.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
House,
|
||||
KeyRound,
|
||||
Wallet,
|
||||
Newspaper,
|
||||
UserRound,
|
||||
Loader2,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Settings,
|
||||
Sun,
|
||||
SunMoon,
|
||||
Moon,
|
||||
WandSparkles,
|
||||
CircleX,
|
||||
Undo2,
|
||||
ArrowUp,
|
||||
Globe2,
|
||||
GraduationCap,
|
||||
Atom,
|
||||
Brain,
|
||||
Loader,
|
||||
Plus,
|
||||
ChevronRight,
|
||||
Share2,
|
||||
Download,
|
||||
Bell,
|
||||
ThumbsUp,
|
||||
Lock,
|
||||
Building,
|
||||
ChevronLeft,
|
||||
X,
|
||||
UserRoundPlus,
|
||||
ListFilter,
|
||||
Copy,
|
||||
Languages,
|
||||
IdCard,
|
||||
AtSign,
|
||||
Key,
|
||||
ChevronsUpDown,
|
||||
RefreshCw,
|
||||
Workflow,
|
||||
LogOut,
|
||||
MailPlus,
|
||||
Trash,
|
||||
Trash2,
|
||||
ShieldCheck,
|
||||
Pencil,
|
||||
TrendingUp,
|
||||
UsersRound,
|
||||
MonitorSmartphone,
|
||||
} from "lucide-react-native";
|
||||
import { withUniwind } from "uniwind";
|
||||
|
||||
import { Icons as GlobalIcons } from "@turbostarter/ui/assets";
|
||||
|
||||
import type { LucideIcon } from "lucide-react-native";
|
||||
|
||||
const icons = {
|
||||
...GlobalIcons,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
House,
|
||||
KeyRound,
|
||||
Wallet,
|
||||
Newspaper,
|
||||
UserRound,
|
||||
Loader2,
|
||||
Check,
|
||||
Key,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Building,
|
||||
Settings,
|
||||
Sun,
|
||||
SunMoon,
|
||||
Moon,
|
||||
CircleX,
|
||||
WandSparkles,
|
||||
ChevronsUpDown,
|
||||
Undo2,
|
||||
ArrowUp,
|
||||
Globe2,
|
||||
GraduationCap,
|
||||
Atom,
|
||||
Brain,
|
||||
Loader,
|
||||
Plus,
|
||||
MailPlus,
|
||||
ChevronRight,
|
||||
Share2,
|
||||
Download,
|
||||
Bell,
|
||||
ThumbsUp,
|
||||
Lock,
|
||||
ChevronLeft,
|
||||
ListFilter,
|
||||
X,
|
||||
Languages,
|
||||
IdCard,
|
||||
AtSign,
|
||||
Workflow,
|
||||
LogOut,
|
||||
Trash,
|
||||
Trash2,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Copy,
|
||||
TrendingUp,
|
||||
UsersRound,
|
||||
UserRoundPlus,
|
||||
MonitorSmartphone,
|
||||
};
|
||||
|
||||
const Icons = {} as {
|
||||
[K in keyof typeof icons]: (typeof icons)[K];
|
||||
};
|
||||
|
||||
(Object.keys(icons) as (keyof typeof icons)[]).forEach((key) => {
|
||||
Icons[key] = withUniwind(
|
||||
icons[key] as React.FC<React.SVGProps<SVGElement>>,
|
||||
) as LucideIcon & React.FC<React.SVGProps<SVGElement>>;
|
||||
});
|
||||
|
||||
export type Icon = (typeof Icons)[keyof typeof Icons];
|
||||
|
||||
export { Icons };
|
||||
112
packages/ui/mobile/src/components/input-otp.tsx
Normal file
112
packages/ui/mobile/src/components/input-otp.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { OTPInput } from "input-otp-native";
|
||||
import React, { useEffect } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
withSequence,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { SlotProps } from "input-otp-native";
|
||||
|
||||
function InputOTPGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof View>) {
|
||||
return (
|
||||
<View
|
||||
data-slot="input-otp-group"
|
||||
className={cn("flex-row items-center justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
char,
|
||||
isActive,
|
||||
hasFakeCaret,
|
||||
className,
|
||||
index,
|
||||
max,
|
||||
...props
|
||||
}: React.ComponentProps<typeof View> &
|
||||
SlotProps & { index?: number; max?: number }) {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === (max ?? 6) - 1;
|
||||
|
||||
return (
|
||||
<View
|
||||
className={cn(
|
||||
"border-input relative flex size-12 items-center justify-center border transition-all outline-none",
|
||||
"dark:bg-input/30",
|
||||
{
|
||||
"border-ring": isActive,
|
||||
"rounded-l-md": isFirst,
|
||||
"rounded-r-md": isLast,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char !== null && (
|
||||
<Text className="text-foreground font-sans-medium text-xl">{char}</Text>
|
||||
)}
|
||||
{hasFakeCaret && <FakeCaret />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function FakeCaret() {
|
||||
const opacity = useSharedValue(1);
|
||||
const foregroundColor = useCSSVariable("--foreground");
|
||||
|
||||
useEffect(() => {
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0, { duration: 500 }),
|
||||
withTiming(1, { duration: 500 }),
|
||||
),
|
||||
-1,
|
||||
true,
|
||||
);
|
||||
}, [opacity]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
const baseStyle = {
|
||||
width: 2,
|
||||
height: 20,
|
||||
borderRadius: 1,
|
||||
...(foregroundColor && { backgroundColor: foregroundColor.toString() }),
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="absolute h-full w-full items-center justify-center">
|
||||
<Animated.View style={[baseStyle, animatedStyle]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof View>) {
|
||||
return (
|
||||
<View
|
||||
{...props}
|
||||
className={cn("w-1 items-center justify-center", className)}
|
||||
>
|
||||
<View className="bg-muted-foreground h-0.5 w-1 rounded-sm" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export { OTPInput as InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
30
packages/ui/mobile/src/components/input.tsx
Normal file
30
packages/ui/mobile/src/components/input.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { TextInput } from "react-native";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { TextInputProps } from "react-native";
|
||||
|
||||
function Input({
|
||||
className,
|
||||
placeholderTextColorClassName,
|
||||
selectionColorClassName,
|
||||
...props
|
||||
}: TextInputProps & React.RefAttributes<TextInput>) {
|
||||
return (
|
||||
<TextInput
|
||||
className={cn(
|
||||
"border-input native:leading-[1.25] text-foreground bg-background dark:bg-input/30 flex h-10 w-full min-w-0 flex-row items-center rounded-md border px-3 py-1 font-sans text-base shadow-sm shadow-black/5",
|
||||
props.editable === false && "opacity-50",
|
||||
className,
|
||||
)}
|
||||
placeholderTextColorClassName={cn(
|
||||
"accent-muted-foreground",
|
||||
placeholderTextColorClassName,
|
||||
)}
|
||||
selectionColorClassName={cn("accent-foreground", selectionColorClassName)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
37
packages/ui/mobile/src/components/label.tsx
Normal file
37
packages/ui/mobile/src/components/label.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as LabelPrimitive from "@rn-primitives/label";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onPressIn,
|
||||
onPressOut,
|
||||
disabled,
|
||||
...props
|
||||
}: LabelPrimitive.TextProps & React.RefAttributes<LabelPrimitive.TextRef>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
className={cn(
|
||||
"flex flex-row items-center gap-2 select-none",
|
||||
disabled && "opacity-50",
|
||||
)}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LabelPrimitive.Text
|
||||
className={cn(
|
||||
"text-foreground native:leading-tight font-sans-medium text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</LabelPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
94
packages/ui/mobile/src/components/progress.tsx
Normal file
94
packages/ui/mobile/src/components/progress.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as ProgressPrimitive from "@rn-primitives/progress";
|
||||
import { Platform, View } from "react-native";
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
useDerivedValue,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
indicatorClassName,
|
||||
...props
|
||||
}: ProgressPrimitive.RootProps &
|
||||
React.RefAttributes<ProgressPrimitive.RootRef> & {
|
||||
indicatorClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Indicator value={value} className={indicatorClassName} />
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
|
||||
const Indicator = Platform.select({
|
||||
web: WebIndicator,
|
||||
native: NativeIndicator,
|
||||
default: NullIndicator,
|
||||
});
|
||||
|
||||
interface IndicatorProps {
|
||||
value: number | undefined | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function WebIndicator({ value, className }: IndicatorProps) {
|
||||
if (Platform.OS !== "web") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
className={cn(
|
||||
"bg-primary h-full w-full flex-1 transition-all",
|
||||
className,
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
|
||||
>
|
||||
<ProgressPrimitive.Indicator className={cn("h-full w-full", className)} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeIndicator({ value, className }: IndicatorProps) {
|
||||
const progress = useDerivedValue(() => value ?? 0);
|
||||
|
||||
const indicator = useAnimatedStyle(() => {
|
||||
return {
|
||||
width: withSpring(
|
||||
`${interpolate(progress.value, [0, 100], [1, 100], Extrapolation.CLAMP)}%`,
|
||||
{ overshootClamping: true },
|
||||
),
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProgressPrimitive.Indicator asChild>
|
||||
<Animated.View
|
||||
style={indicator}
|
||||
className={cn("bg-primary h-full", className)}
|
||||
/>
|
||||
</ProgressPrimitive.Indicator>
|
||||
);
|
||||
}
|
||||
|
||||
function NullIndicator(_props: IndicatorProps) {
|
||||
return null;
|
||||
}
|
||||
34
packages/ui/mobile/src/components/radio-group.tsx
Normal file
34
packages/ui/mobile/src/components/radio-group.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as RadioGroupPrimitive from "@rn-primitives/radio-group";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: RadioGroupPrimitive.RootProps &
|
||||
React.RefAttributes<RadioGroupPrimitive.RootRef>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root className={cn("gap-3", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: RadioGroupPrimitive.ItemProps &
|
||||
React.RefAttributes<RadioGroupPrimitive.ItemRef>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 aspect-square size-4 shrink-0 items-center justify-center rounded-full border shadow-sm shadow-black/5",
|
||||
props.disabled && "opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="bg-primary size-2 rounded-full" />
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
185
packages/ui/mobile/src/components/select.tsx
Normal file
185
packages/ui/mobile/src/components/select.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import * as SelectPrimitive from "@rn-primitives/select";
|
||||
import * as React from "react";
|
||||
import { Platform, StyleSheet, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { Icons } from "./icons";
|
||||
import { TextClassContext } from "./text";
|
||||
|
||||
type Option = SelectPrimitive.Option;
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
function SelectValue({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.ValueProps &
|
||||
React.RefAttributes<SelectPrimitive.ValueRef> & {
|
||||
className?: string;
|
||||
}) {
|
||||
const { value } = SelectPrimitive.useRootContext();
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-foreground line-clamp-1 flex flex-row items-center gap-2 font-sans text-sm",
|
||||
!value && "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
ref,
|
||||
className,
|
||||
children,
|
||||
size = "default",
|
||||
...props
|
||||
}: SelectPrimitive.TriggerProps &
|
||||
React.RefAttributes<SelectPrimitive.TriggerRef> & {
|
||||
children?: React.ReactNode;
|
||||
size?: "default" | "sm";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 dark:active:bg-input/50 bg-background flex h-10 flex-row items-center justify-between gap-2 rounded-sm border px-3 py-2 shadow-sm shadow-black/5",
|
||||
props.disabled && "opacity-50",
|
||||
size === "sm" && "h-8 py-2 sm:py-1.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<>{children}</>
|
||||
<Icons.ChevronDown
|
||||
aria-hidden={true}
|
||||
className="text-muted-foreground"
|
||||
size={16}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
const FullWindowOverlay =
|
||||
Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment;
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
portalHost,
|
||||
...props
|
||||
}: SelectPrimitive.ContentProps &
|
||||
React.RefAttributes<SelectPrimitive.ContentRef> & {
|
||||
className?: string;
|
||||
portalHost?: string;
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Portal hostName={portalHost}>
|
||||
<FullWindowOverlay>
|
||||
<SelectPrimitive.Overlay style={StyleSheet.absoluteFill}>
|
||||
<TextClassContext.Provider value="text-popover-foreground font-sans">
|
||||
<Animated.View className="z-50" entering={FadeIn} exiting={FadeOut}>
|
||||
<SelectPrimitive.Content
|
||||
className={cn(
|
||||
"bg-popover border-border relative z-50 min-w-32 rounded-sm border p-1 shadow-md shadow-black/5",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn("p-1", position === "popper" && cn("w-full"))}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</Animated.View>
|
||||
</TextClassContext.Provider>
|
||||
</SelectPrimitive.Overlay>
|
||||
</FullWindowOverlay>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.LabelProps & React.RefAttributes<SelectPrimitive.LabelRef>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-2 text-xs sm:py-1.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.ItemProps & React.RefAttributes<SelectPrimitive.ItemRef>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
className={cn(
|
||||
"active:bg-accent group relative flex w-full flex-row items-center gap-2 rounded-sm py-2 pr-8 pl-2 sm:py-1.5",
|
||||
Platform.select({
|
||||
web: "focus:bg-accent focus:text-accent-foreground cursor-default outline-none data-disabled:pointer-events-none [&_svg]:pointer-events-none *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
}),
|
||||
props.disabled && "opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<View className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Icons.Check className="text-muted-foreground shrink-0" size={16} />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</View>
|
||||
<TextClassContext.Provider value="text-foreground font-sans group-active:text-accent-foreground text-sm select-none">
|
||||
{children as React.ReactNode}
|
||||
</TextClassContext.Provider>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.SeparatorProps &
|
||||
React.RefAttributes<SelectPrimitive.SeparatorRef>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
className={cn(
|
||||
"bg-border -mx-1 my-1 h-px",
|
||||
Platform.select({ web: "pointer-events-none" }),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
type Option,
|
||||
};
|
||||
42
packages/ui/mobile/src/components/skeleton.tsx
Normal file
42
packages/ui/mobile/src/components/skeleton.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
const duration = 1000;
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof Animated.View>) {
|
||||
const sv = useSharedValue(1);
|
||||
|
||||
React.useEffect(() => {
|
||||
sv.value = withRepeat(
|
||||
withSequence(withTiming(0.5, { duration }), withTiming(1, { duration })),
|
||||
-1,
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: sv.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[animatedStyle, style]}
|
||||
className={cn("bg-secondary dark:bg-muted rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
139
packages/ui/mobile/src/components/slider.tsx
Normal file
139
packages/ui/mobile/src/components/slider.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { Dimensions, View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedScrollHandler,
|
||||
useAnimatedStyle,
|
||||
interpolate,
|
||||
Extrapolation,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { FlatList } from "react-native";
|
||||
import type { ViewProps } from "react-native";
|
||||
import type {
|
||||
SharedValue,
|
||||
ScrollHandlerProcessed,
|
||||
FlatListPropsWithLayout,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
interface SliderContextType {
|
||||
threshold: number;
|
||||
scrollX: SharedValue<number> | null;
|
||||
onScroll: ScrollHandlerProcessed<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
const SliderContext = createContext<SliderContextType>({
|
||||
threshold: Dimensions.get("window").width,
|
||||
scrollX: null,
|
||||
onScroll: () => null,
|
||||
});
|
||||
|
||||
const Slider = ({
|
||||
className,
|
||||
threshold = Dimensions.get("window").width,
|
||||
...props
|
||||
}: ViewProps & { threshold?: number }) => {
|
||||
const scrollX = useSharedValue(0);
|
||||
|
||||
const onScroll = useAnimatedScrollHandler({
|
||||
onScroll: ({ contentOffset: { x } }) => {
|
||||
scrollX.value = x;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SliderContext.Provider value={{ scrollX, onScroll, threshold }}>
|
||||
<View {...props} className={cn("flex-1 items-center gap-4", className)} />
|
||||
</SliderContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const SliderList = <ItemT,>(
|
||||
props: FlatListPropsWithLayout<ItemT> & {
|
||||
ref?: React.ForwardedRef<FlatList>;
|
||||
},
|
||||
) => {
|
||||
const { onScroll, threshold } = useContext(SliderContext);
|
||||
const native = Gesture.Native();
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={native}>
|
||||
<Animated.FlatList
|
||||
onScroll={onScroll}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
initialNumToRender={1}
|
||||
maxToRenderPerBatch={1}
|
||||
scrollEventThrottle={16}
|
||||
decelerationRate="fast"
|
||||
snapToAlignment="start"
|
||||
pagingEnabled
|
||||
snapToInterval={threshold}
|
||||
{...props}
|
||||
/>
|
||||
</GestureDetector>
|
||||
);
|
||||
};
|
||||
|
||||
const SliderListItem = ({
|
||||
index,
|
||||
style,
|
||||
...props
|
||||
}: ViewProps & { index: number }) => {
|
||||
const { scrollX, threshold } = useContext(SliderContext);
|
||||
const opacity = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(
|
||||
scrollX?.value ?? 0,
|
||||
[(index - 1) * threshold, index * threshold, (index + 1) * threshold],
|
||||
[0, 1, 0],
|
||||
Extrapolation.CLAMP,
|
||||
),
|
||||
}));
|
||||
|
||||
return <Animated.View style={[style, opacity]} {...props} />;
|
||||
};
|
||||
|
||||
const SliderPaginationDots = ({ className, ...props }: ViewProps) => {
|
||||
return <View className={cn("flex-row gap-1.5", className)} {...props} />;
|
||||
};
|
||||
|
||||
const SliderPaginationDot = ({
|
||||
className,
|
||||
index = 0,
|
||||
...props
|
||||
}: ViewProps & { index?: number }) => {
|
||||
const { scrollX, threshold } = useContext(SliderContext);
|
||||
|
||||
const animatedDot = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(
|
||||
scrollX?.value ?? 0,
|
||||
[(index - 1) * threshold, index * threshold, (index + 1) * threshold],
|
||||
[0.7, 1, 0.7],
|
||||
),
|
||||
width: interpolate(
|
||||
scrollX?.value ?? 0,
|
||||
[(index - 1) * threshold, index * threshold, (index + 1) * threshold],
|
||||
[11, 28, 11],
|
||||
Extrapolation.CLAMP,
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
className={cn("bg-primary h-3 rounded-full opacity-50", className)}
|
||||
style={animatedDot}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
Slider,
|
||||
SliderList,
|
||||
SliderListItem,
|
||||
SliderPaginationDots,
|
||||
SliderPaginationDot,
|
||||
};
|
||||
47
packages/ui/mobile/src/components/spin.tsx
Normal file
47
packages/ui/mobile/src/components/spin.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect } from "react";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
Easing,
|
||||
cancelAnimation,
|
||||
withRepeat,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
export const Spin = ({ children }: { children: React.ReactNode }) => {
|
||||
const rotation = useSharedValue(0);
|
||||
const animatedStyles = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
rotateZ: `${rotation.value}deg`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [rotation.value]);
|
||||
|
||||
useEffect(() => {
|
||||
rotation.value = withRepeat(
|
||||
withTiming(360, {
|
||||
duration: 1000,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false,
|
||||
);
|
||||
return () => cancelAnimation(rotation);
|
||||
}, [rotation]);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyles,
|
||||
{
|
||||
alignSelf: "center",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
31
packages/ui/mobile/src/components/switch.tsx
Normal file
31
packages/ui/mobile/src/components/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as SwitchPrimitives from "@rn-primitives/switch";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: SwitchPrimitives.RootProps & React.RefAttributes<SwitchPrimitives.RootRef>) {
|
||||
return (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"flex h-7 w-12 shrink-0 flex-row items-center rounded-full border border-transparent shadow-sm shadow-black/5",
|
||||
props.checked ? "bg-primary" : "bg-input dark:bg-input/80",
|
||||
props.disabled && "opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"bg-background size-6 rounded-full transition-transform",
|
||||
props.checked
|
||||
? "dark:bg-primary-foreground translate-x-5"
|
||||
: "dark:bg-foreground translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
63
packages/ui/mobile/src/components/tabs.tsx
Normal file
63
packages/ui/mobile/src/components/tabs.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as TabsPrimitive from "@rn-primitives/tabs";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { TextClassContext } from "./text";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: TabsPrimitive.RootProps & React.RefAttributes<TabsPrimitive.RootRef>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: TabsPrimitive.ListProps & React.RefAttributes<TabsPrimitive.ListRef>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(
|
||||
"bg-muted mr-auto flex h-10 flex-row items-center justify-center rounded-lg p-[3px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: TabsPrimitive.TriggerProps & React.RefAttributes<TabsPrimitive.TriggerRef>) {
|
||||
const { value } = TabsPrimitive.useRootContext();
|
||||
return (
|
||||
<TextClassContext.Provider
|
||||
value={cn(
|
||||
"text-foreground dark:text-muted-foreground font-sans-medium text-sm",
|
||||
value === props.value && "dark:text-foreground",
|
||||
)}
|
||||
>
|
||||
<TabsPrimitive.Trigger
|
||||
className={cn(
|
||||
"flex flex-row items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1.5 shadow-none shadow-black/5",
|
||||
props.disabled && "opacity-50",
|
||||
props.value === value &&
|
||||
"bg-background dark:border-foreground/10 dark:bg-input/30 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const TabsContent = TabsPrimitive.Content;
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
31
packages/ui/mobile/src/components/text.tsx
Normal file
31
packages/ui/mobile/src/components/text.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as Slot from "@rn-primitives/slot";
|
||||
import * as React from "react";
|
||||
import { Text as RNText } from "react-native";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
const TextClassContext = React.createContext<string | undefined>(undefined);
|
||||
|
||||
const Text = ({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RNText> &
|
||||
React.RefAttributes<RNText> & {
|
||||
asChild?: boolean;
|
||||
}) => {
|
||||
const textClass = React.useContext(TextClassContext);
|
||||
const Component = asChild ? Slot.Text : RNText;
|
||||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
"text-foreground font-sans text-base",
|
||||
textClass,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Text, TextClassContext };
|
||||
35
packages/ui/mobile/src/components/textarea.tsx
Normal file
35
packages/ui/mobile/src/components/textarea.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { TextInput } from "react-native";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { TextInputProps } from "react-native";
|
||||
|
||||
function Textarea({
|
||||
className,
|
||||
multiline = true,
|
||||
numberOfLines = 8,
|
||||
placeholderTextColorClassName,
|
||||
selectionColorClassName,
|
||||
...props
|
||||
}: TextInputProps & React.RefAttributes<TextInput>) {
|
||||
return (
|
||||
<TextInput
|
||||
className={cn(
|
||||
"text-foreground border-input dark:bg-input/30 flex min-h-16 w-full flex-row rounded-md border bg-transparent px-3 py-2 font-sans text-base shadow-sm shadow-black/5",
|
||||
props.editable === false && "opacity-50",
|
||||
className,
|
||||
)}
|
||||
placeholderTextColorClassName={cn(
|
||||
"accent-muted-foreground",
|
||||
placeholderTextColorClassName,
|
||||
)}
|
||||
selectionColorClassName={cn("accent-foreground", selectionColorClassName)}
|
||||
multiline={multiline}
|
||||
numberOfLines={numberOfLines}
|
||||
textAlignVertical="top"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
122
packages/ui/mobile/src/components/theme.tsx
Normal file
122
packages/ui/mobile/src/components/theme.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { oklch, formatHex } from "culori";
|
||||
import { memo } from "react";
|
||||
import { FlatList, View } from "react-native";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn, ThemeColor, ThemeMode, themes } from "@turbostarter/ui";
|
||||
|
||||
import { Button } from "./button";
|
||||
import { Icons } from "./icons";
|
||||
import { Label } from "./label";
|
||||
import { Text } from "./text";
|
||||
|
||||
import type { ThemeConfig } from "@turbostarter/ui";
|
||||
|
||||
interface ThemeCustomizerProps {
|
||||
readonly config: ThemeConfig;
|
||||
readonly onChange: (config: ThemeConfig) => void;
|
||||
readonly resolvedTheme: Exclude<ThemeMode, "system">;
|
||||
}
|
||||
|
||||
export const MODE_ICONS = {
|
||||
[ThemeMode.LIGHT]: Icons.Sun,
|
||||
[ThemeMode.DARK]: Icons.Moon,
|
||||
[ThemeMode.SYSTEM]: Icons.SunMoon,
|
||||
} as const;
|
||||
|
||||
export const ThemeCustomizer = memo<ThemeCustomizerProps>(
|
||||
({ config, onChange, resolvedTheme }) => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<View className="mt-2 flex-1 items-center gap-4">
|
||||
<View className="w-full gap-1.5">
|
||||
<Label nativeID="color" className="text-xs">
|
||||
{t("theme.color.label")}
|
||||
</Label>
|
||||
<FlatList
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
numColumns={3}
|
||||
data={Object.values(ThemeColor)}
|
||||
columnWrapperClassName="gap-2"
|
||||
contentContainerClassName="gap-2"
|
||||
renderItem={({ item }) => {
|
||||
const [l, c, h, alpha] = themes[item][resolvedTheme].primary;
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
key={item}
|
||||
onPress={() => onChange({ ...config, color: item })}
|
||||
hitSlop={2}
|
||||
className={cn(
|
||||
"grow basis-24 flex-row justify-start gap-2.5 px-3",
|
||||
config.color === item &&
|
||||
"border-primary dark:border-primary border-2",
|
||||
)}
|
||||
>
|
||||
<View
|
||||
className="flex size-4.5 shrink-0 items-center justify-center rounded-full"
|
||||
style={{
|
||||
backgroundColor: formatHex(
|
||||
oklch({
|
||||
mode: "oklch",
|
||||
l,
|
||||
c,
|
||||
h,
|
||||
alpha,
|
||||
}),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Text className="capitalize">{t(`theme.color.${item}`)}</Text>
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View className="w-full gap-1.5">
|
||||
<Label nativeID="mode" className="text-xs">
|
||||
{t("theme.mode.label")}
|
||||
</Label>
|
||||
<FlatList
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
numColumns={3}
|
||||
data={Object.values(ThemeMode)}
|
||||
columnWrapperClassName="gap-2"
|
||||
contentContainerClassName="gap-2"
|
||||
renderItem={({ item }) => {
|
||||
const isActive = config.mode === item;
|
||||
const Icon = MODE_ICONS[item];
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
key={item}
|
||||
onPress={() => onChange({ ...config, mode: item })}
|
||||
hitSlop={2}
|
||||
className={cn(
|
||||
"grow basis-24 flex-row justify-start gap-2 px-3 capitalize",
|
||||
isActive && "border-primary dark:border-primary border-2",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className="text-foreground shrink-0"
|
||||
width={18}
|
||||
height={18}
|
||||
/>
|
||||
<Text className="text-sm capitalize">
|
||||
{t(`theme.mode.${item}`)}
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ThemeCustomizer.displayName = "ThemeCustomizer";
|
||||
1
packages/ui/mobile/src/index.ts
Normal file
1
packages/ui/mobile/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
10
packages/ui/mobile/src/typings/uniwind-types.d.ts
vendored
Normal file
10
packages/ui/mobile/src/typings/uniwind-types.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
// NOTE: This file is generated by uniwind and it should not be edited manually.
|
||||
/// <reference types="uniwind/types" />
|
||||
|
||||
declare module "uniwind" {
|
||||
export interface UniwindConfig {
|
||||
themes: readonly ["light", "dark"];
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user