Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/demo/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const App = () => {
<Stack.Screen name="components/Surface" options={{ title: "Surface" }} />
<Stack.Screen name="components/Modal" options={{ title: "Modal" }} />
<Stack.Screen name="components/Switch" options={{ title: "Switch" }} />
<Stack.Screen name="components/Toggle" options={{ title: "Toggle" }} />
<Stack.Screen name="components/Text" options={{ title: "Text" }} />
<Stack.Screen
name="components/TextArea"
Expand Down
50 changes: 50 additions & 0 deletions apps/demo/app/components/Toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { ScrollView, View } from "react-native";
import { Toggle, type ToggleVariant, type ToggleSize, Text } from "@leshi/ui-rn";
import { useState } from "react";

const variants: ToggleVariant[] = ["default", "outline"];
const sizes: ToggleSize[] = ["sm", "default", "lg"];

export default function ToggleScreen() {
const [pressed, setPressed] = useState(false);

return (
<ScrollView contentContainerStyle={{ padding: 16 }}>
<Text weight="bold" size="xl" style={{ marginBottom: 4 }}>
Toggle
</Text>
<Text style={{ marginBottom: 12 }}>
Small button that toggles between pressed and unpressed states.
</Text>

<Text weight="bold" size="lg" style={{ marginBottom: 8 }}>
Controlled Toggle
</Text>
<Toggle pressed={pressed} onPressedChange={setPressed} style={{ marginBottom: 16 }}>
{pressed ? "On" : "Off"}
</Toggle>

<Text weight="bold" size="lg" style={{ marginBottom: 8 }}>
Variants
</Text>
<View style={{ flexDirection: "row", gap: 12, marginBottom: 16 }}>
{variants.map((v) => (
<Toggle key={v} variant={v}>
{v}
</Toggle>
))}
</View>

<Text weight="bold" size="lg" style={{ marginBottom: 8 }}>
Sizes
</Text>
<View style={{ flexDirection: "row", gap: 12 }}>
{sizes.map((s) => (
<Toggle key={s} size={s}>
{s}
</Toggle>
))}
</View>
</ScrollView>
);
}
1 change: 1 addition & 0 deletions apps/demo/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const componentScreens = [
{ name: "Skeleton", href: "/components/Skeleton" },
{ name: "Surface", href: "/components/Surface" },
{ name: "Switch", href: "/components/Switch" },
{ name: "Toggle", href: "/components/Toggle" },
{ name: "Text", href: "/components/Text" },
{ name: "TextArea", href: "/components/TextArea" },
{ name: "TextInput", href: "/components/TextInput" },
Expand Down
6 changes: 6 additions & 0 deletions component-notes.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@
],
"example": "import { Switch } from './components/ui/switch';\n\n<Switch\n checked={enabled}\n onCheckedChange={setEnabled}\n label=\"Enable notifications\"\n/>"
},
"toggle": {
"dependencies": [],
"externalDeps": [],
"setup": [],
"example": "import { Toggle } from './components/ui/toggle';\n\n<Toggle onPressedChange={setOn}>\n Press me\n</Toggle>"
},
"slot": {
"dependencies": [],
"externalDeps": [],
Expand Down
132 changes: 132 additions & 0 deletions packages/rn/components/ui/toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import React, { useCallback, useMemo, useState } from "react";
import {
Pressable,
type PressableProps,
StyleSheet,
type StyleProp,
type ViewStyle,
} from "react-native";
import { useTheme } from "../../styles/theme";
import type { Theme } from "../../styles/theme";
import { Text } from "./text";

export type ToggleVariant = "default" | "outline";
export type ToggleSize = "default" | "sm" | "lg";

export interface ToggleProps extends Omit<PressableProps, "style"> {
pressed?: boolean;
defaultPressed?: boolean;
onPressedChange?: (pressed: boolean) => void;
variant?: ToggleVariant;
size?: ToggleSize;
style?: StyleProp<ViewStyle>;
children?: React.ReactNode;
}

export const Toggle = React.memo<ToggleProps>(({
pressed: pressedProp,
defaultPressed = false,
onPressedChange,
variant = "default",
size = "default",
style,
children,
disabled,
onPress,
...rest
}) => {
const theme = useTheme();
const [internalPressed, setInternalPressed] = useState(defaultPressed);
const isControlled = pressedProp !== undefined;
const pressed = isControlled ? pressedProp : internalPressed;

const styles = useMemo(() => createStyles(theme), [theme]);

const handlePress = useCallback(
(e: any) => {
const newPressed = !pressed;
if (!isControlled) setInternalPressed(newPressed);
onPressedChange?.(newPressed);
onPress?.(e);
},
[pressed, isControlled, onPressedChange, onPress]
);

const containerStyle = useMemo(() => {
const arr: StyleProp<ViewStyle>[] = [
styles.container,
styles.variant[variant],
styles.size[size],
pressed ? styles.state.on : styles.state.off,
];
if (disabled) arr.push(styles.disabled);
if (style) arr.push(style);
return arr;
}, [styles, variant, size, pressed, disabled, style]);

return (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled, selected: pressed } as any}
onPress={handlePress}
disabled={disabled}
style={({ pressed: isPressed }) => [
...containerStyle,
isPressed && !disabled && styles.pressed,
]}
{...rest}
>
{typeof children === "string" ? <Text>{children}</Text> : children}
</Pressable>
);
});

Toggle.displayName = "Toggle";

const createStyles = (theme: Theme) => {
const base = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.sizes.gap(2),
borderRadius: theme.radii.md,
borderWidth: 1,
borderColor: "transparent",
},
pressed: { opacity: 0.8 },
disabled: { opacity: 0.5 },
});

const variant = {
default: {},
outline: {
borderColor: theme.colors.input,
},
} as const;

const size = {
default: {
height: theme.sizes.height(9),
minWidth: theme.sizes.width(9),
paddingHorizontal: theme.sizes.padding(2),
},
sm: {
height: theme.sizes.height(8),
minWidth: theme.sizes.width(8),
paddingHorizontal: theme.sizes.padding(1.5),
},
lg: {
height: theme.sizes.height(10),
minWidth: theme.sizes.width(10),
paddingHorizontal: theme.sizes.padding(2.5),
},
} as const;

const state = {
off: { backgroundColor: "transparent" },
on: { backgroundColor: theme.colors.accent },
} as const;

return { ...base, variant, size, state };
};
1 change: 1 addition & 0 deletions packages/rn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export { RadioGroup, RadioGroupItem } from './components/ui/radio';
export { Text } from './components/ui/text';
export { TextArea } from './components/ui/text-area';
export { TextInput } from './components/ui/text-input';
export { Toggle } from './components/ui/toggle';
export { ThemeProvider } from './styles/context';
export { themes } from './styles/themes';
export { useTheme, useThemeMode, useThemeName } from './styles/theme';
Expand Down
113 changes: 113 additions & 0 deletions packages/unistyles/components/ui/toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React, { useCallback, useState } from "react";
import {
Pressable,
type PressableProps,
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Text } from "./text";

export type ToggleVariant = "default" | "outline";
export type ToggleSize = "default" | "sm" | "lg";
export type ToggleState = "on" | "off";

export interface ToggleProps extends Omit<PressableProps, "style"> {
pressed?: boolean;
defaultPressed?: boolean;
onPressedChange?: (pressed: boolean) => void;
variant?: ToggleVariant;
size?: ToggleSize;
style?: StyleProp<ViewStyle>;
children?: React.ReactNode;
}

export const Toggle = React.memo<ToggleProps>(({
pressed: pressedProp,
defaultPressed = false,
onPressedChange,
variant = "default",
size = "default",
style,
children,
disabled,
onPress,
...rest
}) => {
const [internalPressed, setInternalPressed] = useState(defaultPressed);
const isControlled = pressedProp !== undefined;
const pressed = isControlled ? pressedProp : internalPressed;

const handlePress = useCallback(
(e: any) => {
const newPressed = !pressed;
if (!isControlled) setInternalPressed(newPressed);
onPressedChange?.(newPressed);
onPress?.(e);
},
[pressed, isControlled, onPressedChange, onPress]
);

styles.useVariants({
variant: variant as any,
size: size as any,
state: (pressed ? "on" : "off") as any,
});

return (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled, selected: pressed } as any }
onPress={handlePress}
disabled={disabled}
style={[styles.container, disabled && styles.disabled, style]}
{...rest}
>
{typeof children === "string" ? <Text>{children}</Text> : children}
</Pressable>
);
});

Toggle.displayName = "Toggle";

const styles = StyleSheet.create((theme) => ({
container: {
alignItems: "center",
justifyContent: "center",
flexDirection: "row",
gap: theme.sizes.gap(2),
borderRadius: theme.radii.md,
borderWidth: 1,
borderColor: "transparent",
variants: {
variant: {
default: {},
outline: { borderColor: theme.colors.input },
},
size: {
default: {
height: theme.sizes.height(9),
minWidth: theme.sizes.width(9),
paddingHorizontal: theme.sizes.padding(2),
},
sm: {
height: theme.sizes.height(8),
minWidth: theme.sizes.width(8),
paddingHorizontal: theme.sizes.padding(1.5),
},
lg: {
height: theme.sizes.height(10),
minWidth: theme.sizes.width(10),
paddingHorizontal: theme.sizes.padding(2.5),
},
},
state: {
off: { backgroundColor: "transparent" },
on: { backgroundColor: theme.colors.accent },
},
},
},
disabled: {
opacity: 0.5,
},
}));
1 change: 1 addition & 0 deletions packages/unistyles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export { Progress } from "./components/ui/progress";
export { Divider } from "./components/ui/divider";
export { Surface } from "./components/ui/surface";
export { Switch } from "./components/ui/switch";
export { Toggle } from "./components/ui/toggle";
export { RadioGroup, RadioGroupItem } from "./components/ui/radio";
export { Text } from "./components/ui/text";
export { TextArea } from "./components/ui/text-area";
Expand Down
Loading