aboutsummaryrefslogtreecommitdiffstats
path: root/apps/mobile/components/ui/List.tsx
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-08-26 15:47:05 +0300
committerGitHub <noreply@github.com>2025-08-26 13:47:05 +0100
commited86f7ef012fb558fe8a8974e1e162ce75cbfd15 (patch)
treea3470b0e1a01aede90b75bc61eeba2545e51fe83 /apps/mobile/components/ui/List.tsx
parentec56ea33b5e37d02e87e480da305038a5ce7de49 (diff)
downloadkarakeep-ed86f7ef012fb558fe8a8974e1e162ce75cbfd15.tar.zst
feat(mobile): Retheme the mobile app (#1872)
* Add nativewindui * migrate to nativewindui text * Replace buttons with nativewindui buttons * Use nativewindui search input * fix the divider color * More changes * fix manage tag icon * fix styling of bookmark card * fix ios compilation * fix search clear * fix tag pill border color * Store theme setting in app settings * fix setting color appearance * fix coloring of search input * fix following system theme * add a save button to info * fix the grey colors on android * fix icon active tint color * drop the use of TextField
Diffstat (limited to 'apps/mobile/components/ui/List.tsx')
-rw-r--r--apps/mobile/components/ui/List.tsx469
1 files changed, 469 insertions, 0 deletions
diff --git a/apps/mobile/components/ui/List.tsx b/apps/mobile/components/ui/List.tsx
new file mode 100644
index 00000000..52ff5779
--- /dev/null
+++ b/apps/mobile/components/ui/List.tsx
@@ -0,0 +1,469 @@
+import type {
+ FlashListProps,
+ ListRenderItem as FlashListRenderItem,
+ ListRenderItemInfo,
+} from "@shopify/flash-list";
+import * as React from "react";
+import {
+ Platform,
+ PressableProps,
+ StyleProp,
+ TextStyle,
+ View,
+ ViewProps,
+ ViewStyle,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Button } from "@/components/ui/Button";
+import { Text, TextClassContext } from "@/components/ui/Text";
+import { cn } from "@/lib/utils";
+import { FlashList } from "@shopify/flash-list";
+import { cva } from "class-variance-authority";
+import { cssInterop } from "nativewind";
+
+cssInterop(FlashList, {
+ className: "style",
+ contentContainerClassName: "contentContainerStyle",
+});
+
+type ListDataItem = string | { title: string; subTitle?: string };
+type ListVariant = "insets" | "full-width";
+
+type ListRef<T extends ListDataItem> = React.Ref<typeof FlashList<T>>;
+
+type ListRenderItemProps<T extends ListDataItem> = ListRenderItemInfo<T> & {
+ variant?: ListVariant;
+ isFirstInSection?: boolean;
+ isLastInSection?: boolean;
+ sectionHeaderAsGap?: boolean;
+};
+
+type ListProps<T extends ListDataItem> = Omit<
+ FlashListProps<T>,
+ "renderItem"
+> & {
+ renderItem?: ListRenderItem<T>;
+ variant?: ListVariant;
+ sectionHeaderAsGap?: boolean;
+ rootClassName?: string;
+ rootStyle?: StyleProp<ViewStyle>;
+};
+type ListRenderItem<T extends ListDataItem> = (
+ props: ListRenderItemProps<T>,
+) => ReturnType<FlashListRenderItem<T>>;
+
+const rootVariants = cva("min-h-2 flex-1", {
+ variants: {
+ variant: {
+ insets: "ios:px-4",
+ "full-width": "ios:bg-card ios:dark:bg-background",
+ },
+ sectionHeaderAsGap: {
+ true: "",
+ false: "",
+ },
+ },
+ compoundVariants: [
+ {
+ variant: "full-width",
+ sectionHeaderAsGap: true,
+ className: "bg-card dark:bg-background",
+ },
+ ],
+ defaultVariants: {
+ variant: "full-width",
+ sectionHeaderAsGap: false,
+ },
+});
+
+function ListComponent<T extends ListDataItem>({
+ variant = "full-width",
+ rootClassName,
+ rootStyle,
+ contentContainerClassName,
+ renderItem,
+ data,
+ sectionHeaderAsGap = false,
+ contentInsetAdjustmentBehavior = "automatic",
+ ...props
+}: ListProps<T>) {
+ const insets = useSafeAreaInsets();
+ return (
+ <View
+ className={cn(
+ rootVariants({
+ variant,
+ sectionHeaderAsGap,
+ }),
+ rootClassName,
+ )}
+ style={rootStyle}
+ >
+ <FlashList
+ data={data}
+ contentInsetAdjustmentBehavior={contentInsetAdjustmentBehavior}
+ renderItem={renderItemWithVariant(
+ renderItem,
+ variant,
+ data,
+ sectionHeaderAsGap,
+ )}
+ contentContainerClassName={cn(
+ variant === "insets" &&
+ (!data || (typeof data?.[0] !== "string" && "pt-4")),
+ contentContainerClassName,
+ )}
+ contentContainerStyle={{
+ paddingBottom: Platform.select({
+ ios:
+ !contentInsetAdjustmentBehavior ||
+ contentInsetAdjustmentBehavior === "never"
+ ? insets.bottom + 16
+ : 0,
+ default: insets.bottom,
+ }),
+ }}
+ getItemType={getItemType}
+ showsVerticalScrollIndicator={false}
+ {...props}
+ />
+ </View>
+ );
+}
+
+function getItemType<T>(item: T) {
+ return typeof item === "string" ? "sectioHeader" : "row";
+}
+
+function renderItemWithVariant<T extends ListDataItem>(
+ renderItem: ListRenderItem<T> | null | undefined,
+ variant: ListVariant,
+ data: readonly T[] | null | undefined,
+ sectionHeaderAsGap?: boolean,
+) {
+ return (args: ListRenderItemProps<T>) => {
+ const previousItem = data?.[args.index - 1];
+ const nextItem = data?.[args.index + 1];
+ return renderItem
+ ? renderItem({
+ ...args,
+ variant,
+ isFirstInSection: !previousItem || typeof previousItem === "string",
+ isLastInSection: !nextItem || typeof nextItem === "string",
+ sectionHeaderAsGap,
+ })
+ : null;
+ };
+}
+
+const List = React.forwardRef(ListComponent) as <T extends ListDataItem>(
+ props: ListProps<T> & { ref?: ListRef<T> },
+) => React.ReactElement;
+
+function isPressable(props: PressableProps) {
+ return (
+ ("onPress" in props && props.onPress) ||
+ ("onLongPress" in props && props.onLongPress) ||
+ ("onPressIn" in props && props.onPressIn) ||
+ ("onPressOut" in props && props.onPressOut) ||
+ ("onLongPress" in props && props.onLongPress)
+ );
+}
+
+type ListItemProps<T extends ListDataItem> = PressableProps &
+ ListRenderItemProps<T> & {
+ androidRootClassName?: string;
+ titleClassName?: string;
+ titleStyle?: StyleProp<TextStyle>;
+ textNumberOfLines?: number;
+ subTitleClassName?: string;
+ subTitleStyle?: StyleProp<TextStyle>;
+ subTitleNumberOfLines?: number;
+ textContentClassName?: string;
+ leftView?: React.ReactNode;
+ rightView?: React.ReactNode;
+ removeSeparator?: boolean;
+ };
+type ListItemRef = React.Ref<View>;
+
+const itemVariants = cva("ios:gap-0 flex-row gap-0 bg-card", {
+ variants: {
+ variant: {
+ insets: "ios:bg-card bg-card/70",
+ "full-width": "bg-card dark:bg-background",
+ },
+ sectionHeaderAsGap: {
+ true: "",
+ false: "",
+ },
+ isFirstItem: {
+ true: "",
+ false: "",
+ },
+ isFirstInSection: {
+ true: "",
+ false: "",
+ },
+ removeSeparator: {
+ true: "",
+ false: "",
+ },
+ isLastInSection: {
+ true: "",
+ false: "",
+ },
+ disabled: {
+ true: "opacity-70",
+ false: "opacity-100",
+ },
+ },
+ compoundVariants: [
+ {
+ variant: "insets",
+ sectionHeaderAsGap: true,
+ className: "ios:dark:bg-card dark:bg-card/70",
+ },
+ {
+ variant: "insets",
+ isFirstInSection: true,
+ className: "ios:rounded-t-[10px]",
+ },
+ {
+ variant: "insets",
+ isLastInSection: true,
+ className: "ios:rounded-b-[10px]",
+ },
+ {
+ removeSeparator: false,
+ isLastInSection: true,
+ className:
+ "ios:border-b-0 border-b border-border/25 dark:border-border/80",
+ },
+ {
+ variant: "insets",
+ isFirstItem: true,
+ className: "border-t border-border/40",
+ },
+ ],
+ defaultVariants: {
+ variant: "insets",
+ sectionHeaderAsGap: false,
+ isFirstInSection: false,
+ isLastInSection: false,
+ disabled: false,
+ },
+});
+
+function ListItemComponent<T extends ListDataItem>(
+ {
+ item,
+ isFirstInSection,
+ isLastInSection,
+ index: _index,
+ variant,
+ className,
+ androidRootClassName,
+ titleClassName,
+ titleStyle,
+ textNumberOfLines,
+ subTitleStyle,
+ subTitleClassName,
+ subTitleNumberOfLines,
+ textContentClassName,
+ sectionHeaderAsGap,
+ removeSeparator = false,
+ leftView,
+ rightView,
+ disabled,
+ ...props
+ }: ListItemProps<T>,
+ ref: ListItemRef,
+) {
+ if (typeof item === "string") {
+ console.log(
+ "List.tsx",
+ "ListItemComponent",
+ "Invalid item of type 'string' was provided. Use ListSectionHeader instead.",
+ );
+ return null;
+ }
+ return (
+ <>
+ <Button
+ disabled={disabled || !isPressable(props)}
+ variant="plain"
+ size="none"
+ unstable_pressDelay={100}
+ androidRootClassName={androidRootClassName}
+ className={itemVariants({
+ variant,
+ sectionHeaderAsGap,
+ isFirstInSection,
+ isLastInSection,
+ disabled,
+ className,
+ removeSeparator,
+ })}
+ {...props}
+ ref={ref}
+ >
+ <TextClassContext.Provider value="font-normal leading-5">
+ {!!leftView && <View>{leftView}</View>}
+ <View
+ className={cn(
+ "h-full flex-1 flex-row",
+ !item.subTitle ? "ios:py-3 py-[18px]" : "ios:py-2 py-2",
+ !leftView && "ml-4",
+ !rightView && "pr-4",
+ !removeSeparator &&
+ (!isLastInSection || variant === "full-width") &&
+ "ios:border-b ios:border-border/80",
+ !removeSeparator &&
+ isFirstInSection &&
+ variant === "full-width" &&
+ "ios:border-t ios:border-border/80",
+ )}
+ >
+ <View className={cn("flex-1", textContentClassName)}>
+ <Text
+ numberOfLines={textNumberOfLines}
+ style={titleStyle}
+ className={titleClassName}
+ >
+ {item.title}
+ </Text>
+ {!!item.subTitle && (
+ <Text
+ numberOfLines={subTitleNumberOfLines}
+ variant="subhead"
+ style={subTitleStyle}
+ className={cn("text-muted-foreground", subTitleClassName)}
+ >
+ {item.subTitle}
+ </Text>
+ )}
+ </View>
+ {!!rightView && <View>{rightView}</View>}
+ </View>
+ </TextClassContext.Provider>
+ </Button>
+ {!removeSeparator && Platform.OS !== "ios" && !isLastInSection && (
+ <View className={cn(variant === "insets" && "px-4")}>
+ <View className="h-px bg-border/25 dark:bg-border/80" />
+ </View>
+ )}
+ </>
+ );
+}
+
+const ListItem = React.forwardRef(ListItemComponent) as <
+ T extends ListDataItem,
+>(
+ props: ListItemProps<T> & { ref?: ListItemRef },
+) => React.ReactElement;
+
+type ListSectionHeaderProps<T extends ListDataItem> = ViewProps &
+ ListRenderItemProps<T> & {
+ textClassName?: string;
+ };
+type ListSectionHeaderRef = React.Ref<View>;
+
+function ListSectionHeaderComponent<T extends ListDataItem>(
+ {
+ item,
+ isFirstInSection: _isFirstInSection,
+ isLastInSection: _isLastInSection,
+ index: _index,
+ variant,
+ className,
+ textClassName,
+ sectionHeaderAsGap,
+ ...props
+ }: ListSectionHeaderProps<T>,
+ ref: ListSectionHeaderRef,
+) {
+ if (typeof item !== "string") {
+ console.log(
+ "List.tsx",
+ "ListSectionHeaderComponent",
+ "Invalid item provided. Expected type 'string'. Use ListItem instead.",
+ );
+ return null;
+ }
+
+ if (sectionHeaderAsGap) {
+ return (
+ <View
+ className={cn(
+ "bg-background",
+ Platform.OS !== "ios" &&
+ "border-b border-border/25 dark:border-border/80",
+ className,
+ )}
+ {...props}
+ ref={ref}
+ >
+ <View className="h-8" />
+ </View>
+ );
+ }
+ return (
+ <View
+ className={cn(
+ "ios:pb-1 pb-4 pl-4 pt-4",
+ Platform.OS !== "ios" &&
+ "border-b border-border/25 dark:border-border/80",
+ variant === "full-width"
+ ? "bg-card dark:bg-background"
+ : "bg-background",
+ className,
+ )}
+ {...props}
+ ref={ref}
+ >
+ <Text
+ variant={Platform.select({ ios: "footnote", default: "body" })}
+ className={cn("ios:uppercase ios:text-muted-foreground", textClassName)}
+ >
+ {item}
+ </Text>
+ </View>
+ );
+}
+
+const ListSectionHeader = React.forwardRef(ListSectionHeaderComponent) as <
+ T extends ListDataItem,
+>(
+ props: ListSectionHeaderProps<T> & { ref?: ListSectionHeaderRef },
+) => React.ReactElement;
+
+const ESTIMATED_ITEM_HEIGHT = {
+ titleOnly: Platform.select({ ios: 45, default: 57 }),
+ withSubTitle: 56,
+};
+
+function getStickyHeaderIndices<T extends ListDataItem>(data: T[]) {
+ if (!data) return [];
+ const indices: number[] = [];
+ for (let i = 0; i < data.length; i++) {
+ if (typeof data[i] === "string") {
+ indices.push(i);
+ }
+ }
+ return indices;
+}
+
+export {
+ ESTIMATED_ITEM_HEIGHT,
+ List,
+ ListItem,
+ ListSectionHeader,
+ getStickyHeaderIndices,
+};
+export type {
+ ListDataItem,
+ ListItemProps,
+ ListProps,
+ ListRenderItemInfo,
+ ListSectionHeaderProps,
+};