fix(filters): route FilterButton through the global bottom sheet

FilterButton previously rendered its own per-button gorhom
BottomSheetModal (FilterSheet), which silently failed to present —
tapping the library filter badges did nothing. It now pushes a new
FilterSheetContent into the app-level GlobalModal instead, the same
sheet infrastructure PlatformDropdown already uses.

FilterSheetContent renders a virtualized BottomSheetFlatList (filter
lists can hold thousands of genres/tags/years), auto-shows search for
lists over 15 items matching against the visible label, and owns its
selection state locally since GlobalModal content is a static snapshot.

The searchFilter/disableSearch props are gone (search is derived from
renderItemLabel, which is now typed as returning a string), so all call
sites are updated accordingly.

Claude-Session: https://claude.ai/code/session_01GVcoCm9p5pLBXS9v8jDkHp
This commit is contained in:
Fredrik Burmester
2026-07-19 10:42:13 +02:00
parent e481f22e10
commit 5623a5cddb
6 changed files with 187 additions and 92 deletions

View File

@@ -127,7 +127,6 @@ export default function Page() {
values={[order]}
title={t("library.filters.sort_order")}
renderItemLabel={(order) => t(`library.filters.${order}`)}
disableSearch={true}
/>
<FilterButton
id={levelsFilterId}
@@ -137,7 +136,6 @@ export default function Page() {
values={levels}
title={t("home.settings.logs.level")}
renderItemLabel={(level) => level}
disableSearch={true}
multiple={true}
/>
</View>

View File

@@ -349,9 +349,6 @@ const page: React.FC = () => {
values={selectedGenres}
title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
@@ -376,7 +373,6 @@ const page: React.FC = () => {
values={selectedYears}
title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)}
/>
),
},
@@ -401,9 +397,6 @@ const page: React.FC = () => {
values={selectedTags}
title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
@@ -421,9 +414,6 @@ const page: React.FC = () => {
renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
@@ -441,9 +431,6 @@ const page: React.FC = () => {
renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},

View File

@@ -568,9 +568,6 @@ const Page = () => {
values={selectedGenres}
title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
@@ -595,7 +592,6 @@ const Page = () => {
values={selectedYears}
title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)}
/>
),
},
@@ -620,9 +616,6 @@ const Page = () => {
values={selectedTags}
title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
@@ -640,9 +633,6 @@ const Page = () => {
renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
@@ -660,9 +650,6 @@ const Page = () => {
renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
@@ -680,9 +667,6 @@ const Page = () => {
renderItemLabel={(item) =>
generalFilters.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},

View File

@@ -1,20 +1,18 @@
import { FontAwesome, Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { FilterSheet } from "./FilterSheet";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { FilterSheetContent } from "./FilterSheetContent";
interface FilterButtonProps<T> extends ViewProps {
id: string;
disableSearch?: boolean;
queryKey: string;
values: T[];
title: string;
set: (value: T[]) => void;
queryFn: (params: any) => Promise<any>;
searchFilter?: (item: T, query: string) => boolean;
renderItemLabel: (item: T) => React.ReactNode;
renderItemLabel: (item: T) => string;
multiple?: boolean;
icon?: "filter" | "sort";
}
@@ -27,13 +25,11 @@ export const FilterButton = <T,>({
values, // selected values
title,
renderItemLabel,
searchFilter,
disableSearch = false,
multiple = false,
icon = "filter",
...props
}: FilterButtonProps<T>) => {
const [open, setOpen] = useState(false);
const { showModal, hideModal } = useGlobalModal();
const { data: filters } = useQuery<T[]>({
queryKey: ["filters", title, queryKey, id],
@@ -42,61 +38,59 @@ export const FilterButton = <T,>({
enabled: !!id && !!queryFn && !!queryKey,
});
return (
<>
<TouchableOpacity
onPress={() => {
filters?.length && setOpen(true);
}}
>
<View
className={`
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
${
values.length > 0
? "bg-purple-600 border border-purple-700"
: "bg-neutral-900 border border-neutral-900"
}
${filters?.length === 0 && "opacity-50"}
`}
{...props}
>
<Text
className={`
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
text-xs font-semibold`}
>
{title}
</Text>
{icon === "filter" ? (
<Ionicons
name='filter'
size={14}
color='white'
style={{ opacity: 0.5 }}
/>
) : (
<FontAwesome
name='sort'
size={14}
color='white'
style={{ opacity: 0.5 }}
/>
)}
</View>
</TouchableOpacity>
<FilterSheet<T>
const openSheet = () => {
if (!filters?.length) return;
showModal(
<FilterSheetContent<T>
title={title}
open={open}
setOpen={setOpen}
data={filters}
values={values}
initialValues={values}
set={set}
renderItemLabel={renderItemLabel}
searchFilter={searchFilter}
disableSearch={disableSearch}
multiple={multiple}
/>
</>
onClose={hideModal}
/>,
{ snapPoints: ["85%"] },
);
};
return (
<TouchableOpacity onPress={openSheet}>
<View
className={`
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
${
values.length > 0
? "bg-purple-600 border border-purple-700"
: "bg-neutral-900 border border-neutral-900"
}
${filters?.length === 0 ? "opacity-50" : ""}
`}
{...props}
>
<Text
className={`
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
text-xs font-semibold`}
>
{title}
</Text>
{icon === "filter" ? (
<Ionicons
name='filter'
size={14}
color='white'
style={{ opacity: 0.5 }}
/>
) : (
<FontAwesome
name='sort'
size={14}
color='white'
style={{ opacity: 0.5 }}
/>
)}
</View>
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,134 @@
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
import { isEqual } from "lodash";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
interface Props<T> {
title: string;
data: T[];
initialValues: T[];
set: (value: T[]) => void;
renderItemLabel: (item: T) => string;
multiple?: boolean;
onClose: () => void;
}
const SEARCH_THRESHOLD = 15;
/**
* Sheet content for FilterButton, rendered inside the GlobalModal bottom
* sheet. The GlobalModal stores its content as a static snapshot, so this
* component owns the selection state locally and mirrors changes back to the
* caller through `set`.
*
* Uses a virtualized BottomSheetFlatList — filter lists (genres, tags, years)
* can contain thousands of entries.
*/
export const FilterSheetContent = <T,>({
title,
data,
initialValues,
set,
renderItemLabel,
multiple = false,
onClose,
}: Props<T>) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [values, setValues] = useState<T[]>(initialValues);
const [search, setSearch] = useState("");
const showSearch = data.length > SEARCH_THRESHOLD;
const filteredData = useMemo(() => {
if (!search || !showSearch) return data;
const query = search.toLowerCase();
return data.filter((item) =>
renderItemLabel(item).toLowerCase().includes(query),
);
}, [data, search, showSearch, renderItemLabel]);
const select = (item: T) => {
const selected = values.some((v) => isEqual(v, item));
if (multiple) {
const next = selected
? values.filter((v) => !isEqual(v, item))
: values.concat(item);
setValues(next);
set(next);
} else {
if (!selected) {
setValues([item]);
set([item]);
}
setTimeout(() => onClose(), 250);
}
};
return (
<View
style={{
flex: 1,
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
<Text className='font-bold text-2xl mt-2'>{title}</Text>
<Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: data.length })}
</Text>
{showSearch && (
<Input
placeholder={t("search.search")}
className='my-2 border-neutral-800 border'
value={search}
onChangeText={setSearch}
returnKeyType='done'
/>
)}
<View
style={{
flex: 1,
borderRadius: 20,
overflow: "hidden",
marginBottom: Math.max(16, insets.bottom),
}}
>
<BottomSheetFlatList
data={filteredData}
keyExtractor={(item, index) => `${renderItemLabel(item)}-${index}`}
keyboardShouldPersistTaps='handled'
initialNumToRender={20}
ItemSeparatorComponent={() => (
<View
style={{ height: StyleSheet.hairlineWidth }}
className='bg-neutral-700'
/>
)}
renderItem={({ item }) => {
const selected = values.some((v) => isEqual(v, item));
return (
<TouchableOpacity
onPress={() => select(item)}
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
<Ionicons
name={selected ? "radio-button-on" : "radio-button-off"}
size={24}
color='white'
/>
</TouchableOpacity>
);
}}
/>
</View>
</View>
);
};

View File

@@ -118,7 +118,6 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
renderItemLabel={(item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
}
disableSearch={true}
/>
<FilterButton
id={orderFilterId}
@@ -128,7 +127,6 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
values={[jellyseerrSortOrder]}
title={t("library.filters.sort_order")}
renderItemLabel={(item) => t(`library.filters.${item}`)}
disableSearch={true}
/>
</View>
);