mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
* feat(review): edit compact diff comments in a sheet * feat(review): send diff feedback to workspace agents Send saved reviews from the diff while retaining file navigation and unrelated composer drafts. Clear acknowledged versions only so comments edited or added during submission remain available. * fix(review): preserve drafts and coordinate feedback across surfaces Share pending submission ownership by draft while keeping recipient menus local. Retain editable text across presentation changes and acknowledgement, and reserve safe-area clearance for floating actions. * fix(review): center a small primary feedback button on desktop * fix(review): restore focus to feedback button after closing menu
282 lines
6.9 KiB
TypeScript
282 lines
6.9 KiB
TypeScript
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
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 {
|
|
buttonIconSize,
|
|
createControlGeometry,
|
|
type ButtonControlSize,
|
|
} from "@/components/ui/control-geometry";
|
|
import type { Theme } from "@/styles/theme";
|
|
|
|
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
|
|
type ButtonSize = ButtonControlSize;
|
|
|
|
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,
|
|
},
|
|
xs: {
|
|
...geometry.buttonXs,
|
|
},
|
|
sm: {
|
|
...geometry.buttonSm,
|
|
},
|
|
lg: {
|
|
...geometry.buttonLg,
|
|
},
|
|
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.buttonText,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
textXs: {
|
|
...geometry.buttonTextXs,
|
|
},
|
|
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 = "md",
|
|
leftIcon,
|
|
trailing,
|
|
style,
|
|
textStyle,
|
|
disabled,
|
|
loading = false,
|
|
accessibilityRole,
|
|
accessibilityState: accessibilityStateProp,
|
|
...props
|
|
}: PropsWithChildren<
|
|
Omit<PressableProps, "style"> & {
|
|
ref?: React.Ref<View>;
|
|
variant?: ButtonVariant;
|
|
size?: ButtonSize;
|
|
leftIcon?: LeftIcon;
|
|
trailing?: ReactNode;
|
|
style?: StyleProp<ViewStyle>;
|
|
textStyle?: StyleProp<TextStyle>;
|
|
loading?: boolean;
|
|
}
|
|
>) {
|
|
const [hovered, setHovered] = useState(false);
|
|
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;
|
|
}
|
|
|
|
let sizeStyle: ViewStyle;
|
|
if (size === "xs") {
|
|
sizeStyle = styles.xs;
|
|
} else if (size === "sm") {
|
|
sizeStyle = styles.sm;
|
|
} else if (size === "lg") {
|
|
sizeStyle = styles.lg;
|
|
} else {
|
|
sizeStyle = styles.md;
|
|
}
|
|
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,
|
|
variantStyle,
|
|
pressed ? styles.pressed : null,
|
|
isDisabled ? styles.disabled : null,
|
|
style,
|
|
],
|
|
[sizeStyle, variantStyle, isDisabled, style],
|
|
);
|
|
|
|
const resolvedTextStyle = useMemo(
|
|
() => [
|
|
styles.text,
|
|
size === "xs" ? styles.textXs : 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={buttonIconSize[size]}
|
|
uniProps={resolveIconMapping()}
|
|
/>
|
|
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
|
|
{trailing}
|
|
</Pressable>
|
|
);
|
|
}
|