fix: correct ModalHeaderSubmitButton naming and enhance UI components

- Renamed `ModalHeaderShubmitButton` to `ModalHeaderSubmitButton` for consistency.
- Updated `SwipeableItem` to include animated text for action labels.
- Refactored `TextField` to support an input postfix element and improved styling.
- Enhanced `GroupedInsetListCard` to conditionally show separators between children.
- Adjusted `MarkdownWeb` to prevent vertical overflow.
- Updated `FollowFeed`, `FollowList`, and `rsshub-form` to use the corrected `ModalHeaderSubmitButton`.
- Added navigation functionality to the `AddListButton` in `ListsScreen`.
- Improved color validation in utility functions.

These changes improve code consistency and enhance the user interface across various components.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-01-23 18:35:55 +08:00
parent a084c2bc7d
commit 55dcfb06df
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
13 changed files with 282 additions and 62 deletions

View File

@ -30,16 +30,16 @@ const ModalHeaderCloseButtonImpl = () => {
)
}
export interface ModalHeaderShubmitButtonProps {
export interface ModalHeaderSubmitButtonProps {
isValid: boolean
onPress: () => void
isLoading?: boolean
}
export const ModalHeaderShubmitButton = ({
export const ModalHeaderSubmitButton = ({
isValid,
onPress,
isLoading,
}: ModalHeaderShubmitButtonProps) => {
}: ModalHeaderSubmitButtonProps) => {
return <ModalHeaderShubmitButtonImpl isValid={isValid} onPress={onPress} isLoading={isLoading} />
}
@ -47,8 +47,9 @@ const ModalHeaderShubmitButtonImpl = ({
isValid,
onPress,
isLoading,
}: ModalHeaderShubmitButtonProps) => {
}: ModalHeaderSubmitButtonProps) => {
const label = useColor("label")
return (
<TouchableOpacity onPress={onPress} disabled={!isValid || isLoading}>
{isLoading ? (

View File

@ -118,6 +118,10 @@ export const SwipeableItem: React.FC<SwipeableItemProps> = ({
const renderRightActions = (progress: Animated.AnimatedInterpolation<number>) => {
const width = rightActions?.length ? rightActions.length * 74 : 74
const parallaxX = progress.interpolate({
inputRange: [0, 1, 1.2],
outputRange: [0, 0, 10],
})
return (
<>
<View
@ -167,7 +171,15 @@ export const SwipeableItem: React.FC<SwipeableItemProps> = ({
onPress={action.onPress}
>
{action.icon}
<Text style={styles.actionText}>{action.label}</Text>
<Animated.Text
style={[
styles.actionText,
{ color: action.color ?? "#fff" },
{ transform: [{ translateX: parallaxX }] },
]}
>
{action.label}
</Animated.Text>
</RectButton>
</Animated.View>
)

View File

@ -7,25 +7,36 @@ import { accentColor } from "@/src/theme/colors"
import { FormLabel } from "./Label"
interface TextFieldProps {
interface BaseFieldProps {
wrapperClassName?: string
wrapperStyle?: StyleProp<ViewStyle>
label?: string
description?: string
required?: boolean
inputPostfixElement?: React.ReactNode
}
export const TextField = forwardRef<TextInput, TextInputProps & TextFieldProps>(
const BaseField = forwardRef<TextInput, TextInputProps & BaseFieldProps>(
(
{ className, style, wrapperClassName, wrapperStyle, label, description, required, ...rest },
{
className,
style,
wrapperClassName,
wrapperStyle,
label,
description,
required,
inputPostfixElement,
...rest
},
ref,
) => {
return (
<>
{!!label && <FormLabel className="pl-1" label={label} optional={!required} />}
<View className="flex-1">
{!!label && <FormLabel className="pl-2.5" label={label} optional={!required} />}
{!!description && (
<Text className="text-secondary-label mb-1 pl-1 text-sm">{description}</Text>
<Text className="text-secondary-label mb-1 pl-2.5 text-sm">{description}</Text>
)}
<View
className={cn(
@ -37,16 +48,42 @@ export const TextField = forwardRef<TextInput, TextInputProps & TextFieldProps>(
<TextInput
selectionColor={accentColor}
ref={ref}
className={cn("text-text placeholder:text-secondary-label w-full flex-1", className)}
className={cn("text-label placeholder:text-secondary-label w-full flex-1", className)}
style={StyleSheet.flatten([styles.textField, style])}
{...rest}
/>
{inputPostfixElement}
</View>
</>
</View>
)
},
)
export const TextField = forwardRef<TextInput, TextInputProps & BaseFieldProps>((props, ref) => (
<BaseField {...props} ref={ref} />
))
interface NumberFieldProps extends BaseFieldProps {
value?: number
onChangeNumber?: (value: number) => void
defaultValue?: number
}
export const NumberField = forwardRef<
TextInput,
Omit<TextInputProps, "keyboardType" | "value" | "onChangeText" | "defaultValue"> &
NumberFieldProps
>(({ value, onChangeNumber, defaultValue, ...rest }, ref) => (
<BaseField
{...rest}
ref={ref}
keyboardType="number-pad"
value={value?.toString()}
onChangeText={(text) => onChangeNumber?.(Math.min(Number(text), Number.MAX_SAFE_INTEGER))}
defaultValue={defaultValue?.toString()}
/>
))
const styles = StyleSheet.create({
textField: {
fontSize: 16,

View File

@ -9,11 +9,13 @@ import { MingcuteRightLine } from "@/src/icons/mingcute_right_line"
import { RightCuteReIcon } from "@/src/icons/right_cute_re"
import { useColor } from "@/src/theme/colors"
export const GroupedInsetListCard: FC<PropsWithChildren & ViewProps> = ({
children,
className,
...props
}) => {
type GroupedInsetListCardProps = {
showSeparator?: boolean
}
export const GroupedInsetListCard: FC<
PropsWithChildren & ViewProps & GroupedInsetListCardProps
> = ({ children, className, showSeparator = true, ...props }) => {
return (
<View
{...props}
@ -22,26 +24,28 @@ export const GroupedInsetListCard: FC<PropsWithChildren & ViewProps> = ({
className,
)}
>
{React.Children.map(children, (child, index) => {
const isLast = index === React.Children.count(children) - 1
{showSeparator
? React.Children.map(children, (child, index) => {
const isLast = index === React.Children.count(children) - 1
const isNavigationLink =
React.isValidElement(child) &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
(child.type as Function).name === GroupedInsetListNavigationLink.name
const isNavigationLink =
React.isValidElement(child) &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
(child.type as Function).name === GroupedInsetListNavigationLink.name
return (
<Fragment key={index}>
{child}
{!isLast && (
<View
className={cn("bg-opaque-separator", isNavigationLink ? "ml-16" : "mx-4")}
style={{ height: StyleSheet.hairlineWidth }}
/>
)}
</Fragment>
)
})}
return (
<Fragment key={index}>
{child}
{!isLast && (
<View
className={cn("bg-opaque-separator", isNavigationLink ? "ml-16" : "mx-4")}
style={{ height: StyleSheet.hairlineWidth }}
/>
)}
</Fragment>
)
})
: children}
</View>
)
}

View File

@ -34,7 +34,7 @@ const MarkdownWeb: WebComponent<{
overscroll-behavior: none;
}
body, html {
overflow: hidden;
overflow-y: hidden;
}
`,
}}

View File

@ -10,7 +10,7 @@ import { z } from "zod"
import {
ModalHeaderCloseButton,
ModalHeaderShubmitButton,
ModalHeaderSubmitButton,
} from "@/src/components/common/ModalSharedComponents"
import { FormProvider } from "@/src/components/ui/form/FormProvider"
import { FormLabel } from "@/src/components/ui/form/Label"
@ -108,7 +108,7 @@ function FollowImpl() {
headerLeft: ModalHeaderCloseButton,
gestureEnabled: !isDirty,
headerRight: () => (
<ModalHeaderShubmitButton
<ModalHeaderSubmitButton
isValid={isValid}
onPress={form.handleSubmit(submit)}
isLoading={isLoading}

View File

@ -1,5 +1,4 @@
import type { FeedViewType } from "@follow/constants"
import { cn } from "@follow/utils"
import { Text, TouchableOpacity, View } from "react-native"
import { Grid } from "@/src/components/ui/grid"
@ -34,10 +33,10 @@ export const FeedViewSelector = ({ value, onChange, className, readOnly }: Props
width={18}
/>
<Text
className={cn(
"mt-1 whitespace-nowrap text-[8px] font-medium",
isSelected ? "text-accent" : "text-secondary-label",
)}
className={"mt-1 whitespace-nowrap text-[8px] font-medium"}
style={{
color: isSelected ? view.activeColor : secondaryLabelColor,
}}
>
{view.name}
</Text>

View File

@ -8,7 +8,7 @@ import { z } from "zod"
import {
ModalHeaderCloseButton,
ModalHeaderShubmitButton,
ModalHeaderSubmitButton,
} from "@/src/components/common/ModalSharedComponents"
import { FormProvider } from "@/src/components/ui/form/FormProvider"
import { FormLabel } from "@/src/components/ui/form/Label"
@ -118,7 +118,7 @@ const Impl = (props: { id: string }) => {
headerLeft: ModalHeaderCloseButton,
gestureEnabled: !isDirty,
headerRight: () => (
<ModalHeaderShubmitButton
<ModalHeaderSubmitButton
isValid={isValid}
onPress={form.handleSubmit(submit)}
isLoading={isLoading}

View File

@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query"
import { router } from "expo-router"
import { createElement, useCallback } from "react"
import type { ListRenderItem } from "react-native"
import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"
@ -84,7 +85,7 @@ export const ListsScreen = () => {
const AddListButton = () => {
const labelColor = useColor("label")
return (
<TouchableOpacity hitSlop={10}>
<TouchableOpacity hitSlop={10} onPress={() => router.push("/list")}>
<AddCuteReIcon height={20} width={20} color={labelColor} />
</TouchableOpacity>
)
@ -109,7 +110,7 @@ const ListItemCell: ListRenderItem<HonoApiClient.List_List_Get> = ({ item: list
{
label: "Edit",
onPress: () => {
// TODO
router.push(`/list?id=${list.id}`)
},
backgroundColor: "#0ea5e9",
},

View File

@ -21,6 +21,12 @@ export default function ModalLayout() {
title: "Follow",
}}
/>
<Stack.Screen
name="list"
options={{
title: "List",
}}
/>
</Stack>
)
}

View File

@ -0,0 +1,162 @@
import { FeedViewType } from "@follow/constants"
import { zodResolver } from "@hookform/resolvers/zod"
import { Stack, useLocalSearchParams } from "expo-router"
import { memo } from "react"
import { Controller, useForm } from "react-hook-form"
import { View } from "react-native"
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"
import { z } from "zod"
import {
ModalHeaderCloseButton,
ModalHeaderSubmitButton,
} from "@/src/components/common/ModalSharedComponents"
import { FormProvider, useFormContext } from "@/src/components/ui/form/FormProvider"
import { FormLabel } from "@/src/components/ui/form/Label"
import { NumberField, TextField } from "@/src/components/ui/form/TextField"
import { GroupedInsetListCard } from "@/src/components/ui/grouped/GroupedList"
import { PowerIcon } from "@/src/icons/power"
import { FeedViewSelector } from "@/src/modules/feed/view-selector"
import { useList } from "@/src/store/list/hooks"
import { accentColor } from "@/src/theme/colors"
const listSchema = z.object({
title: z.string().min(1),
description: z.string().min(1),
image: z.string().url(),
fee: z.number().min(0),
view: z.nativeEnum(FeedViewType),
})
export default function ListScreen() {
const listId = useLocalSearchParams<{ id?: string }>().id
const list = useList(listId || "")
const form = useForm({
defaultValues: list,
resolver: zodResolver(listSchema),
mode: "all",
})
return (
<FormProvider form={form}>
<KeyboardAwareScrollView className="bg-system-grouped-background flex-1 pb-safe">
<ScreenOptions title={list?.title} />
<GroupedInsetListCard showSeparator={false} className="mt-6 px-3 py-6">
<Controller
name="title"
control={form.control}
rules={{
required: true,
}}
render={({ field: { onChange, onBlur, ref, value } }) => (
<TextField
label="Title"
required={true}
wrapperClassName="mt-2"
placeholder=""
onBlur={onBlur}
onChangeText={onChange}
defaultValue={list?.title ?? ""}
value={value ?? ""}
ref={ref}
/>
)}
/>
<View className="mt-4">
<Controller
name="description"
control={form.control}
render={({ field: { onChange, onBlur, ref, value } }) => (
<TextField
label="Description"
wrapperClassName="mt-2"
placeholder=""
onBlur={onBlur}
onChangeText={onChange}
defaultValue={list?.description ?? ""}
value={value ?? ""}
ref={ref}
/>
)}
/>
</View>
<View className="mt-4">
<Controller
name="image"
control={form.control}
render={({ field: { onChange, onBlur, ref, value } }) => (
<TextField
label="Image"
wrapperClassName="mt-2"
placeholder="https://"
onBlur={onBlur}
onChangeText={onChange}
defaultValue={list?.image ?? ""}
value={value ?? ""}
ref={ref}
/>
)}
/>
</View>
<View className="mt-4">
<FormLabel label="View" className="mb-4 pl-2.5" optional />
<Controller
name="view"
control={form.control}
render={({ field: { onChange, value } }) => (
<FeedViewSelector value={value as any as FeedViewType} onChange={onChange} />
)}
/>
</View>
<View className="mt-4">
<Controller
name="fee"
control={form.control}
render={({ field: { onChange, onBlur, ref, value } }) => (
<NumberField
label="Fee"
wrapperClassName="mt-2"
placeholder="0"
onBlur={onBlur}
onChangeNumber={onChange}
defaultValue={list?.fee ?? 0}
value={value ?? 0}
inputPostfixElement={<PowerIcon color={accentColor} />}
ref={ref}
/>
)}
/>
</View>
</GroupedInsetListCard>
</KeyboardAwareScrollView>
</FormProvider>
)
}
interface ScreenOptionsProps {
title?: string
}
const ScreenOptions = memo(({ title }: ScreenOptionsProps) => {
const form = useFormContext()
return (
<Stack.Screen
options={{
headerLeft: ModalHeaderCloseButton,
gestureEnabled: !form.formState.isDirty,
headerRight: () => (
<FormProvider form={form}>
<ModalHeaderSubmitButton isValid onPress={() => {}} />
</FormProvider>
),
headerTitle: title ? `Edit List - ${title}` : "Create List",
}}
/>
)
})

View File

@ -18,7 +18,7 @@ import { z } from "zod"
import { HeaderTitleExtra } from "@/src/components/common/HeaderTitleExtra"
import {
ModalHeaderCloseButton,
ModalHeaderShubmitButton,
ModalHeaderSubmitButton,
} from "@/src/components/common/ModalSharedComponents"
import { FormProvider, useFormContext } from "@/src/components/ui/form/FormProvider"
import { Select } from "@/src/components/ui/form/Select"
@ -247,7 +247,7 @@ const ScreenOptions = memo(({ name, routeName, route, routePrefix }: ScreenOptio
headerRight: () => (
<FormProvider form={form}>
<ModalHeaderSubmitButton routePrefix={routePrefix} route={route} />
<ModalHeaderSubmitButtonImpl routePrefix={routePrefix} route={route} />
</FormProvider>
),
@ -267,17 +267,15 @@ const Title = ({ name, routeName, route, routePrefix }: ScreenOptionsProps) => {
)
}
type ModalHeaderSubmitButtonProps = {
routePrefix: string
route: string
}
const ModalHeaderSubmitButton = ({ routePrefix, route }: ModalHeaderSubmitButtonProps) => {
return <ModalHeaderSubmitButtonImpl routePrefix={routePrefix} route={route} />
}
const routeParamsKeyPrefix = "route-params-"
const ModalHeaderSubmitButtonImpl = ({ routePrefix, route }: ModalHeaderSubmitButtonProps) => {
const ModalHeaderSubmitButtonImpl = ({
routePrefix,
route,
}: {
routePrefix: string
route: string
}) => {
const form = useFormContext()
const { isValid } = form.formState
@ -339,5 +337,5 @@ const ModalHeaderSubmitButtonImpl = ({ routePrefix, route }: ModalHeaderSubmitBu
}
})
return <ModalHeaderShubmitButton isLoading={isLoading} isValid={isValid} onPress={submit} />
return <ModalHeaderSubmitButton isLoading={isLoading} isValid={isValid} onPress={submit} />
}

View File

@ -148,7 +148,7 @@ export const isRGBColor = (color: string) => {
return /^rgb\(\d{1,3},\s*\d{1,3},\s*\d{1,3}\)$/.test(color)
}
export const isRGBAColor = (color: string) => {
return /^rgba\(\d{1,3},\s*\d{1,3},\s*\d{1,3},\s*0?\.\d+\)$/.test(color)
return /^rgba\(\d{1,3},\s*\d{1,3},\s*\d{1,3},\s*(?:0?\.\d+|1(?:\.0+)?)\)$/.test(color)
}
export const withOpacity = (color: string, opacity: number) => {