From a7439c8e56cbf01925c4c2c60e34924651883adb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 29 Mar 2026 23:10:35 +0700 Subject: [PATCH] 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. --- packages/app/src/components/ui/button.tsx | 53 +++++++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index cb4162f8f..11105236e 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -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 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 ButtonSize = "sm" | "md" | "lg"; +type LeftIcon = + | ReactElement + | ComponentType<{ color: string; size: number }> + | ((color: string) => ReactElement) + | null; + +const ICON_SIZE: Record = { sm: 14, md: 16, lg: 20 }; + const styles = StyleSheet.create((theme) => ({ base: { flexDirection: "row", @@ -67,6 +75,12 @@ const styles = StyleSheet.create((theme) => ({ textDestructive: { color: theme.colors.palette.white, }, + textGhost: { + color: theme.colors.foregroundMuted, + }, + textGhostHovered: { + color: theme.colors.foreground, + }, })); export function Button({ @@ -83,11 +97,14 @@ export function Button({ Omit & { variant?: ButtonVariant; size?: ButtonSize; - leftIcon?: ReactElement | null; + leftIcon?: LeftIcon; style?: StyleProp; textStyle?: StyleProp; } >) { + const [hovered, setHovered] = useState(false); + const { theme } = useUnistyles(); + const variantStyle = variant === "default" ? styles.default @@ -100,19 +117,47 @@ export function Button({ : styles.destructive; const sizeStyle = size === "sm" ? styles.sm : size === "lg" ? styles.lg : styles.md; + const isGhostHovered = hovered && variant === "ghost"; const resolvedTextStyle = [ styles.text, variant === "default" ? styles.textDefault : null, variant === "destructive" ? styles.textDestructive : null, + variant === "ghost" ? styles.textGhost : null, textStyle, + isGhostHovered ? styles.textGhostHovered : null, ]; + function renderIcon() { + if (!leftIcon) return null; + + // Pre-rendered element — pass through + if (typeof leftIcon === "object" && "type" in leftIcon) { + return {leftIcon}; + } + + 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 {(leftIcon as (color: string) => ReactElement)(color)}; + } + + // Component type + const Icon = leftIcon as ComponentType<{ color: string; size: number }>; + return ; + } + return ( setHovered(true)} + onHoverOut={() => setHovered(false)} style={({ pressed }) => [ styles.base, sizeStyle, @@ -122,7 +167,7 @@ export function Button({ style, ]} > - {leftIcon ? {leftIcon} : null} + {renderIcon()} {children} );