fix(dropdown): use nested Menu submenus for grouped options on iOS

Render titled option groups as nested Menu submenus instead of flat
Pickers, and convert the Discover filters from ContextMenu to Menu.
Keeps single-tap-to-open behavior (ContextMenu requires a long press
and reads as a context menu) while giving the nicer nested grouping.
This commit is contained in:
Fredrik Burmester
2026-05-30 12:24:39 +02:00
parent dd3ca37108
commit d11fb3d0c0
2 changed files with 75 additions and 76 deletions

View File

@@ -1,11 +1,5 @@
import {
Button,
Host,
Menu,
Picker,
Text as SwiftUIText,
} from "@expo/ui/swift-ui";
import { disabled, tag } from "@expo/ui/swift-ui/modifiers";
import { Button, Host, Menu } from "@expo/ui/swift-ui";
import { disabled } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect, useState } from "react";
@@ -299,41 +293,40 @@ const PlatformDropdownComponent = ({
const items = [];
// Add Picker for radio options ONLY if there's a group title
// Group radio options under a submenu ONLY if there's a title
// Otherwise render as individual buttons
if (radioOptions.length > 0) {
if (group.title) {
// Use Picker for grouped options.
// Use the option index (a stable primitive) as the
// tag/selection value and React key. Option `value`s can be
// objects (e.g. bitrate / media source), which collapse to
// "[object Object]" as a key and never match the Picker's
// primitive selection.
const selectedRadioIndex = radioOptions.findIndex(
// Use a nested Menu as a submenu for grouped options. This
// reads as "Title: Selected" and expands to the choices on
// tap, keeping the nested look while staying a dropdown.
// (Menu opens on a single tap and nests cleanly; ContextMenu
// would require a long-press and read as a context menu.)
const selectedOption = radioOptions.find(
(opt) => opt.selected,
);
const displayTitle = selectedOption
? `${group.title}: ${selectedOption.label}`
: group.title;
items.push(
<Picker
key={`picker-${groupIndex}`}
label={group.title}
selection={
selectedRadioIndex >= 0 ? selectedRadioIndex : undefined
}
onSelectionChange={(index) => {
const selectedOption = radioOptions[index as number];
selectedOption?.onPress();
onOptionSelect?.(selectedOption?.value);
}}
>
{radioOptions.map((opt, optionIndex) => (
<SwiftUIText
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
{radioOptions.map((option, optionIndex) => (
<Button
key={`radio-${groupIndex}-${optionIndex}`}
modifiers={[tag(optionIndex)]}
>
{opt.label}
</SwiftUIText>
label={option.label}
systemImage={
option.selected ? "checkmark.circle.fill" : "circle"
}
modifiers={
option.disabled ? [disabled(true)] : undefined
}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
}}
/>
))}
</Picker>,
</Menu>,
);
} else {
// Render radio options as direct buttons

View File

@@ -1,11 +1,5 @@
import {
Button,
ContextMenu,
Host,
Picker,
Text as SwiftUIText,
} from "@expo/ui/swift-ui";
import { buttonStyle, tag } from "@expo/ui/swift-ui/modifiers";
import { Button, Host, Menu } from "@expo/ui/swift-ui";
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { Platform, View } from "react-native";
import { FilterButton } from "@/components/filters/FilterButton";
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
@@ -47,42 +41,54 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
marginLeft: "auto",
}}
>
<ContextMenu>
<ContextMenu.Trigger>
<Menu
label={
<Button
modifiers={[buttonStyle("glass")]}
systemImage='line.3.horizontal.decrease.circle'
></Button>
</ContextMenu.Trigger>
<ContextMenu.Items>
<Picker
label={t("library.filters.sort_by")}
selection={jellyseerrOrderBy as unknown as string}
onSelectionChange={(value) => {
setJellyseerrOrderBy(value as unknown as JellyseerrSearchSort);
}}
>
{sortOptions.map((item) => (
<SwiftUIText key={item} modifiers={[tag(item)]}>
{t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
</SwiftUIText>
))}
</Picker>
<Picker
label={t("library.filters.sort_order")}
selection={jellyseerrSortOrder}
onSelectionChange={(value) => {
setJellyseerrSortOrder(value as "asc" | "desc");
}}
>
{orderOptions.map((item) => (
<SwiftUIText key={item} modifiers={[tag(item)]}>
{t(`library.filters.${item}`)}
</SwiftUIText>
))}
</Picker>
</ContextMenu.Items>
</ContextMenu>
/>
}
>
<Menu
label={`${t("library.filters.sort_by")}: ${t(
`home.settings.plugins.jellyseerr.order_by.${jellyseerrOrderBy}`,
)}`}
>
{sortOptions.map((item) => {
const isSelected =
jellyseerrOrderBy === (item as unknown as JellyseerrSearchSort);
return (
<Button
key={item}
label={t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
systemImage={isSelected ? "checkmark.circle.fill" : "circle"}
onPress={() =>
setJellyseerrOrderBy(
item as unknown as JellyseerrSearchSort,
)
}
/>
);
})}
</Menu>
<Menu
label={`${t("library.filters.sort_order")}: ${t(
`library.filters.${jellyseerrSortOrder}`,
)}`}
>
{orderOptions.map((item) => {
const isSelected = jellyseerrSortOrder === item;
return (
<Button
key={item}
label={t(`library.filters.${item}`)}
systemImage={isSelected ? "checkmark.circle.fill" : "circle"}
onPress={() => setJellyseerrSortOrder(item)}
/>
);
})}
</Menu>
</Menu>
</Host>
);
}