diff --git a/client/scripts/theme-baseline.json b/client/scripts/theme-baseline.json
index 9e39ea653..af357029a 100644
--- a/client/scripts/theme-baseline.json
+++ b/client/scripts/theme-baseline.json
@@ -182,7 +182,7 @@
"mobile/screens/collections/MCollShareSheet.tsx": 14,
"mobile/screens/collections/MCollSheetKit.tsx": 5,
"mobile/screens/collections/MCollTripPickerSheet.tsx": 4,
- "mobile/screens/collections/MCollections.tsx": 14,
+ "mobile/screens/collections/MCollections.tsx": 13,
"mobile/screens/dashboard/MDashWidgets.tsx": 22,
"mobile/screens/dashboard/MDashboard.tsx": 30,
"mobile/screens/dashboard/MNewTripSheet.tsx": 12,
diff --git a/client/src/mobile/screens/collections/MCollections.tsx b/client/src/mobile/screens/collections/MCollections.tsx
index c9e0a7edc..41e4c0bda 100644
--- a/client/src/mobile/screens/collections/MCollections.tsx
+++ b/client/src/mobile/screens/collections/MCollections.tsx
@@ -2,7 +2,7 @@ import { ReactNode, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router'
import {
Bookmark, Check, CheckCheck, CheckSquare, ChevronDown, Copy, CopyPlus,
- FolderInput, Layers, List, Map as MapIcon, Pencil, Plus, Search, Share2,
+ FolderInput, Layers, List, Map as MapIcon, Pencil, Search, Settings2, Shapes, Share2,
Tag, Tags, Trash2, X,
} from 'lucide-react'
import type { CollectionStatus } from '@trek/shared'
@@ -22,7 +22,7 @@ import MCollTripPickerSheet from './MCollTripPickerSheet'
import MCollListPickerSheet from './MCollListPickerSheet'
import MCollTours from './MCollTours'
import MCollInvites from './MCollInvites'
-import { STATUS_SPEC } from './collectionsMobileModel'
+import { STATUS_SPEC, categoryMeta } from './collectionsMobileModel'
import MConfirmSheet from '../settings/MConfirmSheet'
import MCollListNav from './MCollListNav'
import { useIsSplit } from '../../../hooks/sizeClass'
@@ -46,6 +46,30 @@ const CHIP_ON = 'border border-transparent bg-m-act text-m-actfg'
const DROP_PANEL =
'rounded-[14px] border border-[color:var(--m-rowbr)] bg-m-sheetop shadow-[0_20px_44px_-18px_rgba(0,0,0,.45)]'
+/** The filter chips that open a dropdown; at most one is open at a time. */
+type FilterDrop = 'status' | 'category' | 'label'
+
+/** One row of the status or category dropdown: icon, name, count. */
+function DropRow({ icon, label, count, on, onPick }: {
+ icon: ReactNode
+ label: string
+ count: number
+ on: boolean
+ onPick: () => void
+}) {
+ return (
+
+ )
+}
+
function EmptyNote({ icon, scene, title }: { icon?: ReactNode; scene?: TrekScene; title: string }) {
return (
@@ -73,8 +97,8 @@ export default function MCollections() {
// With room for two panes the lists stand in a column beside the places
// (MCollListNav) instead of behind the header's dropdown.
const split = useIsSplit()
- const [statusDrop, setStatusDrop] = useState(false)
- const [labelDrop, setLabelDrop] = useState(false)
+ const [filterDrop, setFilterDrop] = useState
(null)
+ const toggleDrop = (drop: FilterDrop) => setFilterDrop(cur => (cur === drop ? null : drop))
// The bottom-nav "+" hands off via the ?create= contract.
const [searchParams, setSearchParams] = useSearchParams()
@@ -102,18 +126,17 @@ export default function MCollections() {
// Close transient popovers when the active list changes under them.
useEffect(() => {
setDrop(false)
- setStatusDrop(false)
- setLabelDrop(false)
+ setFilterDrop(null)
}, [c.activeId])
- // The mobile toolbar has no category or rating filter control, but the shared
- // hook still applies whatever was set on desktop — keep both cleared so
- // resizing to phone doesn't silently filter the list and map (#1435).
- const { categoryFilter, setCategoryFilter, ratingFilter, setRatingFilter } = c
+ // The mobile toolbar has no rating filter control, but the shared hook still
+ // applies whatever was set on desktop: keep it cleared so resizing to phone
+ // doesn't silently filter the list and map (#1435). The category filter has
+ // its own chip here (#2618), so it carries over.
+ const { ratingFilter, setRatingFilter } = c
useEffect(() => {
- if (categoryFilter !== 'all') setCategoryFilter('all')
if (ratingFilter !== 'all') setRatingFilter('all')
- }, [categoryFilter, setCategoryFilter, ratingFilter, setRatingFilter])
+ }, [ratingFilter, setRatingFilter])
const title = c.isAllSaved ? t('collections.allSaved') : (c.activeCollection?.name ?? t('collections.title'))
const isRealList = !c.isAllSaved && typeof c.activeId === 'number'
@@ -127,6 +150,15 @@ export default function MCollections() {
const statusLabel = c.statusFilter === 'all'
? t('collections.status.filterAll')
: t(STATUS_SPEC[c.statusFilter].labelKey)
+ // A category picked on another list (or on desktop) stays set when this list
+ // has none of it, so the chip stays reachable to clear it again.
+ const showCategoryChip = c.categoryOptions.length > 0 || c.categoryFilter !== 'all'
+ const categoryLabel = c.categoryFilter === 'all'
+ ? t('collections.status.filterAll')
+ : (c.categoryOptions.find(o => o.id === c.categoryFilter)?.name
+ ?? c.categories.find(o => o.id === c.categoryFilter)?.name
+ ?? t('collections.status.filterAll'))
+ const categoryTotal = c.categoryOptions.reduce((n, o) => n + o.count, 0)
const labelLabel = c.labelFilter.length === 0
? t('collections.status.filterAll')
: c.labelFilter.length === 1
@@ -148,7 +180,17 @@ export default function MCollections() {
const pickStatusFilter = (f: StatusFilter) => {
c.setStatusFilter(f)
- setStatusDrop(false)
+ setFilterDrop(null)
+ }
+
+ const pickCategoryFilter = (f: number | 'all') => {
+ c.setCategoryFilter(f)
+ setFilterDrop(null)
+ }
+
+ const openLabelManager = () => {
+ setFilterDrop(null)
+ c.setShowLabelManager(true)
}
const toggleLabelFilter = (id: number) => {
@@ -280,19 +322,32 @@ export default function MCollections() {
+ {showCategoryChip && (
+
+ )}
{isRealList && (
)}
- {canManageLabels && (
-
- )}
{showSelect && (
)}
- {(statusDrop || labelDrop) && (
+ {filterDrop && (