mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 07:03:18 +00:00
Controls had four sizes picked per call site, so a button in a dialog, a settings row and a setup step came out at different heights. Controls now have two: md (default; 32px with a pointer, 44px in the compact layout so phone controls are full touch targets) and sm (28px, for tight chrome and nested settings rows). The breakpoint picks md's height, not the caller. Setup: - Orchestration is three switches (Paseo tools, agent skills, CLI), all on, applied on Continue. Turning one off undoes it; the desktop CLI uninstall removes only the file this app installed. - The Mobile step has store buttons and a "Choose how to connect" section: Relay opens the existing pairing modal, Tailscale opens the docs. Settings section docs links are one pattern, SettingsSection's `link` prop, and modals sit on surface0 like a screen. The vitest unistyles stub now resolves breakpoint values by window width, as Unistyles does at runtime; without it md controls had no height in browser tests.
280 lines
7.2 KiB
TypeScript
280 lines
7.2 KiB
TypeScript
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
import { useContainedControlSize } from "@/components/ui/control-size";
|
|
import {
|
|
default as React,
|
|
useCallback,
|
|
useMemo,
|
|
useState,
|
|
type ComponentType,
|
|
type PropsWithChildren,
|
|
type ReactElement,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { Pressable, Text, View } from "react-native";
|
|
import type {
|
|
PressableProps,
|
|
PressableStateCallbackType,
|
|
StyleProp,
|
|
TextStyle,
|
|
ViewStyle,
|
|
} from "react-native";
|
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
import {
|
|
controlIconSize,
|
|
createControlGeometry,
|
|
type ControlSize,
|
|
} from "@/components/ui/control-geometry";
|
|
import type { Theme } from "@/styles/theme";
|
|
|
|
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
|
|
|
|
type LeftIcon =
|
|
| ReactElement
|
|
| ComponentType<{ color: string; size: number }>
|
|
| ((color: string) => ReactElement)
|
|
| null;
|
|
|
|
interface ButtonIconProps {
|
|
loading: boolean;
|
|
leftIcon?: LeftIcon;
|
|
iconSize: number;
|
|
iconColor: string;
|
|
}
|
|
|
|
function ButtonIcon({ loading, leftIcon, iconSize, iconColor }: ButtonIconProps) {
|
|
if (loading) {
|
|
return (
|
|
<View>
|
|
<LoadingSpinner size="small" color={iconColor} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (!leftIcon) return null;
|
|
|
|
if (typeof leftIcon === "object" && "type" in leftIcon) {
|
|
return <View>{leftIcon}</View>;
|
|
}
|
|
|
|
if (
|
|
typeof leftIcon === "function" &&
|
|
!leftIcon.prototype?.isReactComponent &&
|
|
leftIcon.length > 0
|
|
) {
|
|
return <View>{(leftIcon as (color: string) => ReactElement)(iconColor)}</View>;
|
|
}
|
|
|
|
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
|
|
return (
|
|
<View>
|
|
<Icon color={iconColor} size={iconSize} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const ThemedButtonIcon = withUnistyles(ButtonIcon);
|
|
|
|
const foregroundIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground });
|
|
const foregroundMutedIconMapping = (theme: Theme) => ({
|
|
iconColor: theme.colors.foregroundMuted,
|
|
});
|
|
const accentForegroundIconMapping = (theme: Theme) => ({
|
|
iconColor: theme.colors.accentForeground,
|
|
});
|
|
const destructiveForegroundIconMapping = (theme: Theme) => ({
|
|
iconColor: theme.colors.destructiveForeground,
|
|
});
|
|
|
|
const styles = StyleSheet.create((theme) => {
|
|
const geometry = createControlGeometry(theme);
|
|
|
|
return {
|
|
base: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: theme.spacing[2],
|
|
borderRadius: theme.borderRadius.lg,
|
|
borderWidth: 1,
|
|
borderColor: "transparent",
|
|
},
|
|
md: {
|
|
...geometry.buttonMd,
|
|
},
|
|
sm: {
|
|
...geometry.buttonSm,
|
|
},
|
|
iconOnlySm: geometry.buttonIconOnlySm,
|
|
iconOnlyMd: geometry.buttonIconOnlyMd,
|
|
default: {
|
|
backgroundColor: theme.colors.accent,
|
|
borderColor: theme.colors.accent,
|
|
},
|
|
secondary: {
|
|
backgroundColor: theme.colors.surface3,
|
|
borderColor: theme.colors.surface3,
|
|
},
|
|
outline: {
|
|
backgroundColor: "transparent",
|
|
borderColor: theme.colors.borderAccent,
|
|
},
|
|
ghost: {
|
|
backgroundColor: "transparent",
|
|
borderColor: "transparent",
|
|
},
|
|
destructive: {
|
|
backgroundColor: theme.colors.destructive,
|
|
borderColor: theme.colors.destructive,
|
|
},
|
|
pressed: {
|
|
opacity: 0.85,
|
|
},
|
|
disabled: {
|
|
opacity: theme.opacity[50],
|
|
},
|
|
text: {
|
|
color: theme.colors.foreground,
|
|
...geometry.buttonTextMd,
|
|
fontWeight: theme.fontWeight.medium,
|
|
},
|
|
textSm: {
|
|
...geometry.buttonTextSm,
|
|
},
|
|
textDefault: {
|
|
color: theme.colors.accentForeground,
|
|
},
|
|
textDestructive: {
|
|
color: theme.colors.destructiveForeground,
|
|
},
|
|
textGhost: {
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
textGhostHovered: {
|
|
color: theme.colors.foreground,
|
|
},
|
|
};
|
|
});
|
|
|
|
export function Button({
|
|
children,
|
|
variant = "secondary",
|
|
size: sizeProp,
|
|
leftIcon,
|
|
trailing,
|
|
style,
|
|
textStyle,
|
|
numberOfLines,
|
|
disabled,
|
|
loading = false,
|
|
accessibilityRole,
|
|
accessibilityState: accessibilityStateProp,
|
|
...props
|
|
}: PropsWithChildren<
|
|
Omit<PressableProps, "style"> & {
|
|
ref?: React.Ref<View>;
|
|
variant?: ButtonVariant;
|
|
size?: ControlSize;
|
|
leftIcon?: LeftIcon;
|
|
trailing?: ReactNode;
|
|
style?: StyleProp<ViewStyle>;
|
|
textStyle?: StyleProp<TextStyle>;
|
|
numberOfLines?: number;
|
|
loading?: boolean;
|
|
}
|
|
>) {
|
|
const [hovered, setHovered] = useState(false);
|
|
const containedSize = useContainedControlSize();
|
|
const size = sizeProp ?? containedSize ?? "md";
|
|
const isDisabled = disabled || loading;
|
|
|
|
let variantStyle: ViewStyle;
|
|
if (variant === "default") {
|
|
variantStyle = styles.default;
|
|
} else if (variant === "secondary") {
|
|
variantStyle = styles.secondary;
|
|
} else if (variant === "outline") {
|
|
variantStyle = styles.outline;
|
|
} else if (variant === "ghost") {
|
|
variantStyle = styles.ghost;
|
|
} else {
|
|
variantStyle = styles.destructive;
|
|
}
|
|
|
|
const isIconOnly = children == null && leftIcon != null;
|
|
const sizeStyle = size === "sm" ? styles.sm : styles.md;
|
|
const iconOnlyStyle = size === "sm" ? styles.iconOnlySm : styles.iconOnlyMd;
|
|
const isGhostHovered = hovered && variant === "ghost";
|
|
|
|
const handleHoverIn = useCallback(() => setHovered(true), []);
|
|
const handleHoverOut = useCallback(() => setHovered(false), []);
|
|
|
|
const pressableStyle = useCallback(
|
|
({ pressed }: PressableStateCallbackType): StyleProp<ViewStyle> => [
|
|
styles.base,
|
|
sizeStyle,
|
|
isIconOnly ? iconOnlyStyle : null,
|
|
variantStyle,
|
|
pressed ? styles.pressed : null,
|
|
isDisabled ? styles.disabled : null,
|
|
style,
|
|
],
|
|
[sizeStyle, isIconOnly, iconOnlyStyle, variantStyle, isDisabled, style],
|
|
);
|
|
|
|
const resolvedTextStyle = useMemo(
|
|
() => [
|
|
styles.text,
|
|
size === "sm" ? styles.textSm : null,
|
|
variant === "default" ? styles.textDefault : null,
|
|
variant === "destructive" ? styles.textDestructive : null,
|
|
variant === "ghost" ? styles.textGhost : null,
|
|
textStyle,
|
|
isGhostHovered ? styles.textGhostHovered : null,
|
|
],
|
|
[size, variant, textStyle, isGhostHovered],
|
|
);
|
|
|
|
const accessibilityState = useMemo(
|
|
() => ({ ...accessibilityStateProp, disabled: isDisabled, busy: loading }),
|
|
[accessibilityStateProp, isDisabled, loading],
|
|
);
|
|
|
|
function resolveIconMapping() {
|
|
if (variant === "default") {
|
|
return accentForegroundIconMapping;
|
|
}
|
|
if (variant === "destructive") {
|
|
return destructiveForegroundIconMapping;
|
|
}
|
|
if (variant === "ghost") {
|
|
return isGhostHovered ? foregroundIconMapping : foregroundMutedIconMapping;
|
|
}
|
|
return foregroundIconMapping;
|
|
}
|
|
|
|
return (
|
|
<Pressable
|
|
{...props}
|
|
accessibilityRole={accessibilityRole ?? "button"}
|
|
accessibilityState={accessibilityState}
|
|
disabled={isDisabled}
|
|
onHoverIn={handleHoverIn}
|
|
onHoverOut={handleHoverOut}
|
|
style={pressableStyle}
|
|
>
|
|
<ThemedButtonIcon
|
|
loading={loading}
|
|
leftIcon={leftIcon}
|
|
iconSize={controlIconSize[size]}
|
|
uniProps={resolveIconMapping()}
|
|
/>
|
|
{children != null ? (
|
|
<Text numberOfLines={numberOfLines} style={resolvedTextStyle}>
|
|
{children}
|
|
</Text>
|
|
) : null}
|
|
{trailing}
|
|
</Pressable>
|
|
);
|
|
}
|