321 lines
9.4 KiB
TypeScript
321 lines
9.4 KiB
TypeScript
|
|
import { FC, ReactElement, useCallback, useEffect, useRef, useState } from "react"
|
||
|
|
import {
|
||
|
|
FlatList,
|
||
|
|
Image,
|
||
|
|
ImageStyle,
|
||
|
|
Platform,
|
||
|
|
SectionList,
|
||
|
|
TextStyle,
|
||
|
|
View,
|
||
|
|
ViewStyle,
|
||
|
|
} from "react-native"
|
||
|
|
import { Link, RouteProp, useRoute } from "@react-navigation/native"
|
||
|
|
import { Drawer } from "react-native-drawer-layout"
|
||
|
|
|
||
|
|
import { ListItem } from "@/components/ListItem"
|
||
|
|
import { Screen } from "@/components/Screen"
|
||
|
|
import { Text } from "@/components/Text"
|
||
|
|
import { TxKeyPath, isRTL } from "@/i18n"
|
||
|
|
import { translate } from "@/i18n/translate"
|
||
|
|
import { MainTabParamList, MainTabScreenProps } from "@/navigators/navigationTypes"
|
||
|
|
import { useAppTheme } from "@/theme/context"
|
||
|
|
import { $styles } from "@/theme/styles"
|
||
|
|
import type { ThemedStyle } from "@/theme/types"
|
||
|
|
import { s, fs } from "@/utils/responsive"
|
||
|
|
import { useSafeAreaInsetsStyle } from "@/utils/useSafeAreaInsetsStyle"
|
||
|
|
|
||
|
|
import * as Demos from "./demos"
|
||
|
|
import { DrawerIconButton } from "./DrawerIconButton"
|
||
|
|
import SectionListWithKeyboardAwareScrollView from "./SectionListWithKeyboardAwareScrollView"
|
||
|
|
|
||
|
|
const logo = require("@assets/images/logo.png")
|
||
|
|
|
||
|
|
interface DemoListItem {
|
||
|
|
item: { name: string; useCases: string[] }
|
||
|
|
sectionIndex: number
|
||
|
|
handleScroll?: (sectionIndex: number, itemIndex?: number) => void
|
||
|
|
}
|
||
|
|
|
||
|
|
const slugify = (str: string) =>
|
||
|
|
str
|
||
|
|
.toLowerCase()
|
||
|
|
.trim()
|
||
|
|
.replace(/[^\w\s-]/g, "")
|
||
|
|
.replace(/[\s_-]+/g, "-")
|
||
|
|
.replace(/^-+|-+$/g, "")
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Type-safe utility to check if an unknown object has a valid string property.
|
||
|
|
* This is particularly useful in React 19 where props are typed as unknown by default.
|
||
|
|
* The function safely narrows down the type by checking both property existence and type.
|
||
|
|
* @param props - The unknown props to check.
|
||
|
|
* @param propName - The name of the property to check.
|
||
|
|
* @returns Whether the property is a valid string.
|
||
|
|
*/
|
||
|
|
function hasValidStringProp(props: unknown, propName: string): boolean {
|
||
|
|
return (
|
||
|
|
props !== null &&
|
||
|
|
typeof props === "object" &&
|
||
|
|
propName in props &&
|
||
|
|
typeof (props as Record<string, unknown>)[propName] === "string"
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
const WebListItem: FC<DemoListItem> = ({ item, sectionIndex }) => {
|
||
|
|
const sectionSlug = item.name.toLowerCase()
|
||
|
|
const { themed } = useAppTheme()
|
||
|
|
return (
|
||
|
|
<View>
|
||
|
|
<Link screen="Showroom" params={{ queryIndex: sectionSlug }} style={themed($menuContainer)}>
|
||
|
|
<Text preset="bold">{item.name}</Text>
|
||
|
|
</Link>
|
||
|
|
{item.useCases.map((u) => {
|
||
|
|
const itemSlug = slugify(u)
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Link
|
||
|
|
key={`section${sectionIndex}-${u}`}
|
||
|
|
screen="Showroom"
|
||
|
|
params={{ queryIndex: sectionSlug, itemIndex: itemSlug }}
|
||
|
|
>
|
||
|
|
<Text>{u}</Text>
|
||
|
|
</Link>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</View>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
const NativeListItem: FC<DemoListItem> = ({ item, sectionIndex, handleScroll }) => {
|
||
|
|
const { themed } = useAppTheme()
|
||
|
|
return (
|
||
|
|
<View>
|
||
|
|
<Text
|
||
|
|
onPress={() => handleScroll?.(sectionIndex)}
|
||
|
|
preset="bold"
|
||
|
|
style={themed($menuContainer)}
|
||
|
|
>
|
||
|
|
{item.name}
|
||
|
|
</Text>
|
||
|
|
{item.useCases.map((u, index) => (
|
||
|
|
<ListItem
|
||
|
|
key={`section${sectionIndex}-${u}`}
|
||
|
|
onPress={() => handleScroll?.(sectionIndex, index)}
|
||
|
|
text={u}
|
||
|
|
rightIcon={isRTL ? "caretLeft" : "caretRight"}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</View>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
const ShowroomListItem = Platform.select({ web: WebListItem, default: NativeListItem })
|
||
|
|
const isAndroid = Platform.OS === "android"
|
||
|
|
|
||
|
|
export const ShowroomScreen: FC<MainTabScreenProps<"Showroom">> = function ShowroomScreen(_props) {
|
||
|
|
const [open, setOpen] = useState(false)
|
||
|
|
const timeout = useRef<ReturnType<typeof setTimeout>>(null)
|
||
|
|
const listRef = useRef<SectionList>(null)
|
||
|
|
const menuRef = useRef<FlatList<DemoListItem["item"]>>(null)
|
||
|
|
const route = useRoute<RouteProp<MainTabParamList, "Showroom">>()
|
||
|
|
const params = route.params
|
||
|
|
|
||
|
|
const { themed, theme } = useAppTheme()
|
||
|
|
|
||
|
|
const toggleDrawer = useCallback(() => {
|
||
|
|
if (!open) {
|
||
|
|
setOpen(true)
|
||
|
|
} else {
|
||
|
|
setOpen(false)
|
||
|
|
}
|
||
|
|
}, [open])
|
||
|
|
|
||
|
|
const handleScroll = useCallback((sectionIndex: number, itemIndex = 0) => {
|
||
|
|
try {
|
||
|
|
listRef.current?.scrollToLocation({
|
||
|
|
animated: true,
|
||
|
|
itemIndex,
|
||
|
|
sectionIndex,
|
||
|
|
viewPosition: 0.25,
|
||
|
|
})
|
||
|
|
} catch (e) {
|
||
|
|
console.error(e)
|
||
|
|
}
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
// handle Web links
|
||
|
|
useEffect(() => {
|
||
|
|
if (params !== undefined && Object.keys(params).length > 0) {
|
||
|
|
const demoValues = Object.values(Demos)
|
||
|
|
const findSectionIndex = demoValues.findIndex(
|
||
|
|
(x) => x.name.toLowerCase() === params.queryIndex,
|
||
|
|
)
|
||
|
|
let findItemIndex = 0
|
||
|
|
if (params.itemIndex) {
|
||
|
|
try {
|
||
|
|
findItemIndex = demoValues[findSectionIndex].data({ themed, theme }).findIndex((u) => {
|
||
|
|
if (hasValidStringProp(u.props, "name")) {
|
||
|
|
return slugify(translate((u.props as { name: TxKeyPath }).name)) === params.itemIndex
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
})
|
||
|
|
} catch (err) {
|
||
|
|
console.error(err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
handleScroll(findSectionIndex, findItemIndex)
|
||
|
|
}
|
||
|
|
}, [handleScroll, params, theme, themed])
|
||
|
|
|
||
|
|
const scrollToIndexFailed = (info: {
|
||
|
|
index: number
|
||
|
|
highestMeasuredFrameIndex: number
|
||
|
|
averageItemLength: number
|
||
|
|
}) => {
|
||
|
|
listRef.current?.getScrollResponder()?.scrollToEnd()
|
||
|
|
timeout.current = setTimeout(
|
||
|
|
() =>
|
||
|
|
listRef.current?.scrollToLocation({
|
||
|
|
animated: true,
|
||
|
|
itemIndex: info.index,
|
||
|
|
sectionIndex: 0,
|
||
|
|
}),
|
||
|
|
50,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
return () => {
|
||
|
|
if (timeout.current) {
|
||
|
|
clearTimeout(timeout.current)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
const $drawerInsets = useSafeAreaInsetsStyle(["top"])
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Drawer
|
||
|
|
open={open}
|
||
|
|
onOpen={() => setOpen(true)}
|
||
|
|
onClose={() => setOpen(false)}
|
||
|
|
drawerType="back"
|
||
|
|
drawerPosition={isRTL ? "right" : "left"}
|
||
|
|
renderDrawerContent={() => (
|
||
|
|
<View style={themed([$drawer, $drawerInsets])}>
|
||
|
|
<View style={themed($logoContainer)}>
|
||
|
|
<Image source={logo} style={$logoImage} />
|
||
|
|
</View>
|
||
|
|
<FlatList<DemoListItem["item"]>
|
||
|
|
ref={menuRef}
|
||
|
|
contentContainerStyle={themed($listContentContainer)}
|
||
|
|
data={Object.values(Demos).map((d) => ({
|
||
|
|
name: d.name,
|
||
|
|
useCases: d.data({ theme, themed }).map((u) => {
|
||
|
|
if (hasValidStringProp(u.props, "name")) {
|
||
|
|
return translate((u.props as { name: TxKeyPath }).name)
|
||
|
|
}
|
||
|
|
return ""
|
||
|
|
}),
|
||
|
|
}))}
|
||
|
|
keyExtractor={(item) => item.name}
|
||
|
|
renderItem={({ item, index: sectionIndex }) => (
|
||
|
|
<ShowroomListItem {...{ item, sectionIndex, handleScroll }} />
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
</View>
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<Screen
|
||
|
|
preset="fixed"
|
||
|
|
contentContainerStyle={$styles.flex1}
|
||
|
|
{...(isAndroid ? { KeyboardAvoidingViewProps: { behavior: undefined } } : {})}
|
||
|
|
>
|
||
|
|
<DrawerIconButton onPress={toggleDrawer} />
|
||
|
|
|
||
|
|
<SectionListWithKeyboardAwareScrollView
|
||
|
|
ref={listRef}
|
||
|
|
contentContainerStyle={themed($sectionListContentContainer)}
|
||
|
|
stickySectionHeadersEnabled={false}
|
||
|
|
sections={Object.values(Demos).map((d) => ({
|
||
|
|
name: d.name,
|
||
|
|
description: d.description,
|
||
|
|
data: [d.data({ theme, themed })],
|
||
|
|
}))}
|
||
|
|
renderItem={({ item, index: sectionIndex }) => (
|
||
|
|
<View>
|
||
|
|
{item.map((demo: ReactElement, demoIndex: number) => (
|
||
|
|
<View key={`${sectionIndex}-${demoIndex}`}>{demo}</View>
|
||
|
|
))}
|
||
|
|
</View>
|
||
|
|
)}
|
||
|
|
renderSectionFooter={() => <View style={themed($demoUseCasesSpacer)} />}
|
||
|
|
ListHeaderComponent={
|
||
|
|
<View style={themed($heading)}>
|
||
|
|
<Text preset="heading" tx="showroomScreen:jumpStart" />
|
||
|
|
</View>
|
||
|
|
}
|
||
|
|
onScrollToIndexFailed={scrollToIndexFailed}
|
||
|
|
renderSectionHeader={({ section }) => {
|
||
|
|
return (
|
||
|
|
<View>
|
||
|
|
<Text preset="heading" style={themed($demoItemName)}>
|
||
|
|
{section.name}
|
||
|
|
</Text>
|
||
|
|
<Text style={themed($demoItemDescription)}>{translate(section.description)}</Text>
|
||
|
|
</View>
|
||
|
|
)
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</Screen>
|
||
|
|
</Drawer>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
const $drawer: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||
|
|
backgroundColor: colors.background,
|
||
|
|
flex: 1,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $listContentContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
|
|
paddingHorizontal: spacing.lg,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $sectionListContentContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
|
|
paddingHorizontal: spacing.lg,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $heading: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
|
|
marginBottom: spacing.xxxl,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $logoImage: ImageStyle = {
|
||
|
|
height: s(42),
|
||
|
|
width: s(77),
|
||
|
|
}
|
||
|
|
|
||
|
|
const $logoContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
|
|
alignSelf: "flex-start",
|
||
|
|
justifyContent: "center",
|
||
|
|
height: s(56),
|
||
|
|
paddingHorizontal: spacing.lg,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $menuContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
|
|
paddingBottom: spacing.xs,
|
||
|
|
paddingTop: spacing.lg,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $demoItemName: ThemedStyle<TextStyle> = ({ spacing }) => ({
|
||
|
|
fontSize: fs(24),
|
||
|
|
marginBottom: spacing.md,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $demoItemDescription: ThemedStyle<TextStyle> = ({ spacing }) => ({
|
||
|
|
marginBottom: spacing.xxl,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $demoUseCasesSpacer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
|
|
paddingBottom: spacing.xxl,
|
||
|
|
})
|