Files
paseo/packages/app/src/components/settings/index.tsx
T
Mohamed Boudra d831c7bf33 Make Usage settings discoverable above reports (#5921)
* feat(usage): replace options cog with a collapsible Settings row

* test(usage): find Settings by accessible button name
2026-10-02 20:59:16 +01:00

208 lines
5.8 KiB
TypeScript

import {
Children,
isValidElement,
useCallback,
useImperativeHandle,
useMemo,
useRef,
type ReactNode,
} from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type {
SettingsRowProps,
SettingsSwitchProps,
SettingsSelectProps,
SettingsInputProps,
SettingsActionProps,
} from "@getpaseo/plugin/client/ui";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { FormTextInput } from "@/components/ui/form-field";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
export { SettingsGroup } from "./headings/settings-group";
export { SettingsSection } from "./headings/settings-section";
export { SettingsCollapsibleRow } from "./collapsible-row";
interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint"> {
hint?: ReactNode;
labelAccessory?: ReactNode;
}
export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) {
return (
<View style={settingsStyles.card} testID={testID}>
{Children.toArray(children).map((child, index) => (
<View
key={isValidElement(child) ? child.key : index}
style={index ? settingsStyles.rowBorder : undefined}
>
{child}
</View>
))}
</View>
);
}
export function SettingsRow({
label,
labelAccessory,
hint,
error,
children,
testID,
}: AppSettingsRowProps) {
const compact = useIsCompactFormFactor();
const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]);
return (
<View style={rowStyle} testID={testID}>
<View style={styles.label}>
{labelAccessory ? (
<View style={styles.labelRow}>
<Text style={[settingsStyles.rowTitle, styles.accessoryLabel]}>{label}</Text>
{labelAccessory}
</View>
) : (
<Text style={settingsStyles.rowTitle}>{label}</Text>
)}
{typeof hint === "string" ? <Text style={settingsStyles.rowHint}>{hint}</Text> : hint}
{error ? (
<Text accessibilityRole="alert" style={settingsStyles.rowError}>
{error}
</Text>
) : null}
</View>
{children ? <View style={styles.control}>{children}</View> : null}
</View>
);
}
export function SettingsSwitch({ value, onValueChange, disabled, ...row }: SettingsSwitchProps) {
return (
<SettingsRow {...row}>
<Switch
value={value}
onValueChange={onValueChange}
disabled={disabled}
accessibilityLabel={row.label}
/>
</SettingsRow>
);
}
function SettingsOption<Value extends string>({
option,
selected,
onValueChange,
}: {
option: { label: string; value: Value };
selected: boolean;
onValueChange(value: Value): void;
}) {
const select = useCallback(() => onValueChange(option.value), [onValueChange, option.value]);
return (
<DropdownMenuItem selected={selected} onSelect={select}>
{option.label}
</DropdownMenuItem>
);
}
export function SettingsSelect<Value extends string>({
value,
options,
onValueChange,
disabled,
...row
}: SettingsSelectProps<Value>) {
const selectedLabel = options.find((option) => option.value === value)?.label ?? value;
return (
<SettingsRow {...row}>
<DropdownMenu>
<DropdownTrigger
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={`${row.label}: ${selectedLabel}`}
>
{selectedLabel}
</DropdownTrigger>
<DropdownMenuContent side="bottom" align="end" width={220}>
{options.map((option) => (
<SettingsOption
key={option.value}
option={option}
selected={option.value === value}
onValueChange={onValueChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</SettingsRow>
);
}
export function SettingsInput({
initialValue,
onChangeText,
placeholder,
disabled,
secureTextEntry,
ref,
...row
}: SettingsInputProps) {
const compact = useIsCompactFormFactor();
const input = useRef<EditingTextInputHandle>(null);
useImperativeHandle(
ref,
() => ({
focus: () => input.current?.focus(),
blur: () => input.current?.blur(),
getText: () => input.current?.getText() ?? "",
replaceText: (text) => input.current?.replaceText(text),
}),
[],
);
return (
<SettingsRow {...row}>
<FormTextInput
ref={input}
initialValue={initialValue}
onChangeText={onChangeText}
placeholder={placeholder}
editable={!disabled}
secureTextEntry={secureTextEntry}
accessibilityLabel={row.label}
size={compact ? "md" : "sm"}
style={styles.input}
/>
</SettingsRow>
);
}
export function SettingsAction({ actionLabel, onPress, disabled, ...row }: SettingsActionProps) {
return (
<SettingsRow {...row}>
<Button variant="outline" size="sm" onPress={onPress} disabled={disabled}>
{actionLabel}
</Button>
</SettingsRow>
);
}
const styles = StyleSheet.create((theme) => ({
compactRow: { flexWrap: "wrap", gap: theme.spacing[3] },
label: { flexGrow: 1, flexShrink: 1, flexBasis: 160, marginRight: theme.spacing[3] },
labelRow: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
gap: theme.spacing[2],
},
accessoryLabel: { flexShrink: 1, minWidth: 0 },
control: { flexShrink: 1, maxWidth: "100%" },
input: { minWidth: 180 },
}));