feat(app): enhance Button ghost variant with hover and flexible leftIcon

Ghost variant text+icon are foregroundMuted by default, foreground on
hover. leftIcon now accepts a ReactElement, ComponentType, or render
function so the Button can control icon color internally.
This commit is contained in:
Mohamed Boudra
2026-03-29 23:10:35 +07:00
parent 930660074d
commit a7439c8e56
+49 -4
View File
@@ -1,11 +1,19 @@
import type { PropsWithChildren, ReactElement } from "react"; import { useState, type ComponentType, type PropsWithChildren, type ReactElement } from "react";
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import type { PressableProps, StyleProp, TextStyle, ViewStyle } from "react-native"; import type { PressableProps, StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive"; type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
type ButtonSize = "sm" | "md" | "lg"; type ButtonSize = "sm" | "md" | "lg";
type LeftIcon =
| ReactElement
| ComponentType<{ color: string; size: number }>
| ((color: string) => ReactElement)
| null;
const ICON_SIZE: Record<ButtonSize, number> = { sm: 14, md: 16, lg: 20 };
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
base: { base: {
flexDirection: "row", flexDirection: "row",
@@ -67,6 +75,12 @@ const styles = StyleSheet.create((theme) => ({
textDestructive: { textDestructive: {
color: theme.colors.palette.white, color: theme.colors.palette.white,
}, },
textGhost: {
color: theme.colors.foregroundMuted,
},
textGhostHovered: {
color: theme.colors.foreground,
},
})); }));
export function Button({ export function Button({
@@ -83,11 +97,14 @@ export function Button({
Omit<PressableProps, "style"> & { Omit<PressableProps, "style"> & {
variant?: ButtonVariant; variant?: ButtonVariant;
size?: ButtonSize; size?: ButtonSize;
leftIcon?: ReactElement | null; leftIcon?: LeftIcon;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>; textStyle?: StyleProp<TextStyle>;
} }
>) { >) {
const [hovered, setHovered] = useState(false);
const { theme } = useUnistyles();
const variantStyle = const variantStyle =
variant === "default" variant === "default"
? styles.default ? styles.default
@@ -100,19 +117,47 @@ export function Button({
: styles.destructive; : styles.destructive;
const sizeStyle = size === "sm" ? styles.sm : size === "lg" ? styles.lg : styles.md; const sizeStyle = size === "sm" ? styles.sm : size === "lg" ? styles.lg : styles.md;
const isGhostHovered = hovered && variant === "ghost";
const resolvedTextStyle = [ const resolvedTextStyle = [
styles.text, styles.text,
variant === "default" ? styles.textDefault : null, variant === "default" ? styles.textDefault : null,
variant === "destructive" ? styles.textDestructive : null, variant === "destructive" ? styles.textDestructive : null,
variant === "ghost" ? styles.textGhost : null,
textStyle, textStyle,
isGhostHovered ? styles.textGhostHovered : null,
]; ];
function renderIcon() {
if (!leftIcon) return null;
// Pre-rendered element — pass through
if (typeof leftIcon === "object" && "type" in leftIcon) {
return <View>{leftIcon}</View>;
}
const color = variant === "ghost"
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
: theme.colors.foreground;
const iconSize = ICON_SIZE[size];
// Render function
if (typeof leftIcon === "function" && !leftIcon.prototype?.isReactComponent && leftIcon.length > 0) {
return <View>{(leftIcon as (color: string) => ReactElement)(color)}</View>;
}
// Component type
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
return <View><Icon color={color} size={iconSize} /></View>;
}
return ( return (
<Pressable <Pressable
{...props} {...props}
accessibilityRole={accessibilityRole ?? "button"} accessibilityRole={accessibilityRole ?? "button"}
disabled={disabled} disabled={disabled}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
style={({ pressed }) => [ style={({ pressed }) => [
styles.base, styles.base,
sizeStyle, sizeStyle,
@@ -122,7 +167,7 @@ export function Button({
style, style,
]} ]}
> >
{leftIcon ? <View>{leftIcon}</View> : null} {renderIcon()}
<Text style={resolvedTextStyle}>{children}</Text> <Text style={resolvedTextStyle}>{children}</Text>
</Pressable> </Pressable>
); );