feat(app): grouped project settings with docs links + scrollbar fix (#837)
* feat(app): group project settings into Worktree / Scripts / Metadata Adds SettingsGroup component (title + info tooltip + trailing slot) and restructures the project settings screen into three categorized groups with explanatory tooltips. Metadata prompt labels drop the "prompt" suffix now that they live under a "Metadata generation" group, and placeholders read as example instructions a real user would write. Applies useWebScrollbarStyle to the settings ScrollViews. * Add ExternalLink component and docs links to project settings
This commit is contained in:
@@ -0,0 +1,77 @@
|
|||||||
|
import { useCallback, type ReactNode } from "react";
|
||||||
|
import { Pressable, Text } from "react-native";
|
||||||
|
import { ArrowUpRight } from "lucide-react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
|
||||||
|
interface ExternalLinkProps {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
tooltip?: ReactNode;
|
||||||
|
testID?: string;
|
||||||
|
accessibilityLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline "Docs ↗" affordance — muted text + arrow-top-right icon, opens the
|
||||||
|
* URL via the platform's external opener. Wrap in a Tooltip when there's a
|
||||||
|
* one-line hint worth surfacing on hover/tap.
|
||||||
|
*/
|
||||||
|
export function ExternalLink({
|
||||||
|
href,
|
||||||
|
label,
|
||||||
|
tooltip,
|
||||||
|
testID,
|
||||||
|
accessibilityLabel,
|
||||||
|
}: ExternalLinkProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
void openExternalUrl(href);
|
||||||
|
}, [href]);
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityRole="link"
|
||||||
|
accessibilityLabel={accessibilityLabel ?? label}
|
||||||
|
testID={testID}
|
||||||
|
style={styles.trigger}
|
||||||
|
>
|
||||||
|
<Text style={styles.label}>{label}</Text>
|
||||||
|
<ArrowUpRight size={12} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tooltip) {
|
||||||
|
return trigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||||
|
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="end" offset={6}>
|
||||||
|
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
trigger: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
|
tooltipText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
maxWidth: 280,
|
||||||
|
lineHeight: theme.fontSize.sm * 1.4,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -19,9 +19,11 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Alert } from "@/components/ui/alert";
|
import { Alert } from "@/components/ui/alert";
|
||||||
|
import { ExternalLink } from "@/components/ui/external-link";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||||
|
import { SettingsGroup } from "@/screens/settings/settings-group";
|
||||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
import { useProjects } from "@/hooks/use-projects";
|
import { useProjects } from "@/hooks/use-projects";
|
||||||
@@ -53,31 +55,41 @@ interface MetadataPromptField {
|
|||||||
|
|
||||||
const METADATA_PROMPT_FIELDS: Record<MetadataPromptKey, MetadataPromptField> = {
|
const METADATA_PROMPT_FIELDS: Record<MetadataPromptKey, MetadataPromptField> = {
|
||||||
agentTitle: {
|
agentTitle: {
|
||||||
title: "Agent title prompt",
|
title: "Agent titles",
|
||||||
placeholder: "Keep titles short and action-oriented.",
|
placeholder: "Keep titles imperative and under 40 characters",
|
||||||
sectionTestID: "metadata-prompt-agent-title-section",
|
sectionTestID: "metadata-prompt-agent-title-section",
|
||||||
inputTestID: "metadata-prompt-agent-title-input",
|
inputTestID: "metadata-prompt-agent-title-input",
|
||||||
},
|
},
|
||||||
branchName: {
|
branchName: {
|
||||||
title: "Branch name prompt",
|
title: "Branch names",
|
||||||
placeholder: "Prefix branches with feat/ or fix/.",
|
placeholder: "Prefix branches with feat/ or fix/, mb/ for personal branches",
|
||||||
sectionTestID: "metadata-prompt-branch-name-section",
|
sectionTestID: "metadata-prompt-branch-name-section",
|
||||||
inputTestID: "metadata-prompt-branch-name-input",
|
inputTestID: "metadata-prompt-branch-name-input",
|
||||||
},
|
},
|
||||||
commitMessage: {
|
commitMessage: {
|
||||||
title: "Commit message prompt",
|
title: "Commit messages",
|
||||||
placeholder: "Use Conventional Commits.",
|
placeholder: "Use Conventional Commits with a scope",
|
||||||
sectionTestID: "metadata-prompt-commit-message-section",
|
sectionTestID: "metadata-prompt-commit-message-section",
|
||||||
inputTestID: "metadata-prompt-commit-message-input",
|
inputTestID: "metadata-prompt-commit-message-input",
|
||||||
},
|
},
|
||||||
pullRequest: {
|
pullRequest: {
|
||||||
title: "Pull request prompt",
|
title: "Pull requests",
|
||||||
placeholder: "Include risk notes and a test plan.",
|
placeholder: "Lead with a one-paragraph summary, include a Test plan section",
|
||||||
sectionTestID: "metadata-prompt-pull-request-section",
|
sectionTestID: "metadata-prompt-pull-request-section",
|
||||||
inputTestID: "metadata-prompt-pull-request-input",
|
inputTestID: "metadata-prompt-pull-request-input",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WORKTREE_GROUP_INFO =
|
||||||
|
"Commands that run when a worktree is created or torn down for this project";
|
||||||
|
const WORKTREE_DOCS_URL = "https://paseo.sh/docs/worktrees";
|
||||||
|
const WORKTREE_DOCS_TOOLTIP =
|
||||||
|
"See docs for more details and the environment variables available to these commands";
|
||||||
|
const SCRIPTS_GROUP_INFO =
|
||||||
|
"Long-running services and one-off commands you can launch from any agent in this project";
|
||||||
|
const METADATA_GROUP_INFO =
|
||||||
|
"Project-specific instructions injected into the AI prompts Paseo uses to generate metadata — use them to enforce your team's conventions like branch naming, commit style, or PR format";
|
||||||
|
|
||||||
const NO_TARGET_MESSAGE = "We don't have an editable copy of this project on any connected host.";
|
const NO_TARGET_MESSAGE = "We don't have an editable copy of this project on any connected host.";
|
||||||
|
|
||||||
const HOST_SWITCHER_LABEL = "Switch host";
|
const HOST_SWITCHER_LABEL = "Switch host";
|
||||||
@@ -574,43 +586,82 @@ function ProjectConfigForm({
|
|||||||
[handleAddScript],
|
[handleAddScript],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setupDocsLink = useMemo(
|
||||||
|
() => (
|
||||||
|
<ExternalLink
|
||||||
|
href={WORKTREE_DOCS_URL}
|
||||||
|
label="Docs"
|
||||||
|
tooltip={WORKTREE_DOCS_TOOLTIP}
|
||||||
|
testID="worktree-setup-docs-link"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const teardownDocsLink = useMemo(
|
||||||
|
() => (
|
||||||
|
<ExternalLink
|
||||||
|
href={WORKTREE_DOCS_URL}
|
||||||
|
label="Docs"
|
||||||
|
tooltip={WORKTREE_DOCS_TOOLTIP}
|
||||||
|
testID="worktree-teardown-docs-link"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const isStale = writeError?.code === "stale_project_config";
|
const isStale = writeError?.code === "stale_project_config";
|
||||||
const isWriteFailed = writeError?.code === "write_failed";
|
const isWriteFailed = writeError?.code === "write_failed";
|
||||||
const saveDisabled = saveMutation.isPending || isStale || hasInvalidScripts;
|
const saveDisabled = saveMutation.isPending || isStale || hasInvalidScripts;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<SettingsSection title="Worktree setup" testID="worktree-setup-section">
|
<SettingsGroup
|
||||||
<View style={settingsStyles.card}>
|
title="Worktree lifecycle hooks"
|
||||||
<TextInput
|
info={WORKTREE_GROUP_INFO}
|
||||||
testID="worktree-setup-input"
|
testID="worktree-group"
|
||||||
accessibilityLabel="Worktree setup commands"
|
>
|
||||||
multiline
|
<SettingsSection title="Setup" testID="worktree-setup-section" trailing={setupDocsLink}>
|
||||||
value={draft.setupText}
|
<View style={settingsStyles.card}>
|
||||||
onChangeText={handleSetupChange}
|
<TextInput
|
||||||
placeholder="npm install"
|
testID="worktree-setup-input"
|
||||||
placeholderTextColor={styles.placeholderColor.color}
|
accessibilityLabel="Worktree setup commands"
|
||||||
style={styles.lifecycleInput}
|
multiline
|
||||||
/>
|
value={draft.setupText}
|
||||||
</View>
|
onChangeText={handleSetupChange}
|
||||||
</SettingsSection>
|
placeholder="npm install"
|
||||||
|
placeholderTextColor={styles.placeholderColor.color}
|
||||||
|
style={styles.lifecycleInput}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection title="Worktree teardown" testID="worktree-teardown-section">
|
<SettingsSection
|
||||||
<View style={settingsStyles.card}>
|
title="Teardown"
|
||||||
<TextInput
|
testID="worktree-teardown-section"
|
||||||
testID="worktree-teardown-input"
|
trailing={teardownDocsLink}
|
||||||
accessibilityLabel="Worktree teardown commands"
|
flush
|
||||||
multiline
|
>
|
||||||
value={draft.teardownText}
|
<View style={settingsStyles.card}>
|
||||||
onChangeText={handleTeardownChange}
|
<TextInput
|
||||||
placeholder="docker compose down"
|
testID="worktree-teardown-input"
|
||||||
placeholderTextColor={styles.placeholderColor.color}
|
accessibilityLabel="Worktree teardown commands"
|
||||||
style={styles.lifecycleInput}
|
multiline
|
||||||
/>
|
value={draft.teardownText}
|
||||||
</View>
|
onChangeText={handleTeardownChange}
|
||||||
</SettingsSection>
|
placeholder="docker compose down"
|
||||||
|
placeholderTextColor={styles.placeholderColor.color}
|
||||||
|
style={styles.lifecycleInput}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
<SettingsSection title="Scripts" testID="scripts-section" trailing={scriptsTrailing}>
|
<SettingsGroup
|
||||||
|
title="Scripts"
|
||||||
|
info={SCRIPTS_GROUP_INFO}
|
||||||
|
trailing={scriptsTrailing}
|
||||||
|
testID="scripts-group"
|
||||||
|
>
|
||||||
<View style={settingsStyles.card} testID="scripts-list">
|
<View style={settingsStyles.card} testID="scripts-list">
|
||||||
{draft.scripts.length === 0 ? (
|
{draft.scripts.length === 0 ? (
|
||||||
<View style={settingsStyles.row}>
|
<View style={settingsStyles.row}>
|
||||||
@@ -628,16 +679,19 @@ function ProjectConfigForm({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</SettingsSection>
|
</SettingsGroup>
|
||||||
|
|
||||||
{METADATA_PROMPT_KEYS.map((key) => (
|
<SettingsGroup title="Metadata generation" info={METADATA_GROUP_INFO} testID="metadata-group">
|
||||||
<MetadataPromptSection
|
{METADATA_PROMPT_KEYS.map((key, index) => (
|
||||||
key={key}
|
<MetadataPromptSection
|
||||||
promptKey={key}
|
key={key}
|
||||||
value={draft.metadataPrompts[key]}
|
promptKey={key}
|
||||||
onChange={handleMetadataPromptChange}
|
value={draft.metadataPrompts[key]}
|
||||||
/>
|
onChange={handleMetadataPromptChange}
|
||||||
))}
|
flush={index === METADATA_PROMPT_KEYS.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
{isStale ? (
|
{isStale ? (
|
||||||
<View style={styles.calloutWrap}>
|
<View style={styles.calloutWrap}>
|
||||||
@@ -825,16 +879,17 @@ interface MetadataPromptSectionProps {
|
|||||||
promptKey: MetadataPromptKey;
|
promptKey: MetadataPromptKey;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (key: MetadataPromptKey, text: string) => void;
|
onChange: (key: MetadataPromptKey, text: string) => void;
|
||||||
|
flush?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetadataPromptSection({ promptKey, value, onChange }: MetadataPromptSectionProps) {
|
function MetadataPromptSection({ promptKey, value, onChange, flush }: MetadataPromptSectionProps) {
|
||||||
const meta = METADATA_PROMPT_FIELDS[promptKey];
|
const meta = METADATA_PROMPT_FIELDS[promptKey];
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(text: string) => onChange(promptKey, text),
|
(text: string) => onChange(promptKey, text),
|
||||||
[onChange, promptKey],
|
[onChange, promptKey],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={meta.title} testID={meta.sectionTestID}>
|
<SettingsSection title={meta.title} testID={meta.sectionTestID} flush={flush}>
|
||||||
<View style={settingsStyles.card}>
|
<View style={settingsStyles.card}>
|
||||||
<TextInput
|
<TextInput
|
||||||
testID={meta.inputTestID}
|
testID={meta.inputTestID}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import ProjectsScreen from "@/screens/projects-screen";
|
|||||||
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||||
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||||
import {
|
import {
|
||||||
buildHostOpenProjectRoute,
|
buildHostOpenProjectRoute,
|
||||||
buildProjectsSettingsRoute,
|
buildProjectsSettingsRoute,
|
||||||
@@ -812,6 +813,11 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
|||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const insetBottomStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]);
|
const insetBottomStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]);
|
||||||
|
const webScrollbarStyle = useWebScrollbarStyle();
|
||||||
|
const scrollViewStyle = useMemo(
|
||||||
|
() => [styles.scrollView, webScrollbarStyle],
|
||||||
|
[webScrollbarStyle],
|
||||||
|
);
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||||
const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds);
|
const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds);
|
||||||
@@ -1079,7 +1085,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<BackHeader title="Settings" onBack={handleBackToWorkspace} />
|
<BackHeader title="Settings" onBack={handleBackToWorkspace} />
|
||||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
<ScrollView style={scrollViewStyle} contentContainerStyle={insetBottomStyle}>
|
||||||
<SettingsSidebar
|
<SettingsSidebar
|
||||||
view={view}
|
view={view}
|
||||||
onSelectSection={handleSelectSection}
|
onSelectSection={handleSelectSection}
|
||||||
@@ -1108,7 +1114,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
|||||||
titleAccessory={detailHeader?.titleAccessory}
|
titleAccessory={detailHeader?.titleAccessory}
|
||||||
onBack={detailBackHandler}
|
onBack={detailBackHandler}
|
||||||
/>
|
/>
|
||||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
<ScrollView style={scrollViewStyle} contentContainerStyle={insetBottomStyle}>
|
||||||
<View style={styles.content}>{content}</View>
|
<View style={styles.content}>{content}</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
{addHostModals}
|
{addHostModals}
|
||||||
@@ -1153,7 +1159,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
|||||||
}
|
}
|
||||||
leftStyle={desktopStyles.detailLeft}
|
leftStyle={desktopStyles.detailLeft}
|
||||||
/>
|
/>
|
||||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
<ScrollView style={scrollViewStyle} contentContainerStyle={insetBottomStyle}>
|
||||||
<View style={styles.content}>{content}</View>
|
<View style={styles.content}>{content}</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useMemo, type ReactNode } from "react";
|
||||||
|
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
|
import { Info } from "lucide-react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
interface SettingsGroupProps {
|
||||||
|
title: string;
|
||||||
|
info?: ReactNode;
|
||||||
|
trailing?: ReactNode;
|
||||||
|
testID?: string;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level grouping above one or more SettingsSection blocks. Use when a
|
||||||
|
* settings screen has more than one logical area — the group title carries the
|
||||||
|
* category, the optional info tooltip explains it, and the inner sections keep
|
||||||
|
* their muted iOS-style labels.
|
||||||
|
*/
|
||||||
|
export function SettingsGroup({
|
||||||
|
title,
|
||||||
|
info,
|
||||||
|
trailing,
|
||||||
|
testID,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
}: SettingsGroupProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const groupStyle = useMemo(() => [styles.group, style], [style]);
|
||||||
|
return (
|
||||||
|
<View style={groupStyle} testID={testID}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
{info ? (
|
||||||
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`About ${title}`}
|
||||||
|
testID={testID ? `${testID}-info` : undefined}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.infoButton}
|
||||||
|
>
|
||||||
|
<Info size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="start" offset={8}>
|
||||||
|
<Text style={styles.tooltipText}>{info}</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{trailing}
|
||||||
|
</View>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
group: {
|
||||||
|
marginBottom: theme.spacing[8],
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
marginBottom: theme.spacing[4],
|
||||||
|
},
|
||||||
|
titleRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
infoButton: {
|
||||||
|
padding: theme.spacing[1],
|
||||||
|
marginLeft: -theme.spacing[1],
|
||||||
|
},
|
||||||
|
tooltipText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
maxWidth: 280,
|
||||||
|
lineHeight: theme.fontSize.sm * 1.4,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -8,6 +8,11 @@ interface SettingsSectionProps {
|
|||||||
trailing?: ReactNode;
|
trailing?: ReactNode;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
|
/**
|
||||||
|
* Drops the section's bottom margin. Use when the section is the last child
|
||||||
|
* of a SettingsGroup, so the group's own bottom margin owns the trailing gap.
|
||||||
|
*/
|
||||||
|
flush?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,9 +26,13 @@ export function SettingsSection({
|
|||||||
trailing,
|
trailing,
|
||||||
testID,
|
testID,
|
||||||
style,
|
style,
|
||||||
|
flush,
|
||||||
children,
|
children,
|
||||||
}: SettingsSectionProps) {
|
}: SettingsSectionProps) {
|
||||||
const sectionStyle = useMemo(() => [settingsStyles.section, style], [style]);
|
const sectionStyle = useMemo(
|
||||||
|
() => [settingsStyles.section, flush ? styles.flush : null, style],
|
||||||
|
[flush, style],
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<View style={sectionStyle} testID={testID}>
|
<View style={sectionStyle} testID={testID}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
@@ -47,4 +56,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
content: {
|
content: {
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
},
|
},
|
||||||
|
flush: {
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user