feat(collections): filter by category on the phone (#2618)

The phone toolbar gets a category chip beside the status and label chips,
fed by the same categoryOptions the desktop dropdown uses, and no longer
clears a category picked on desktop. Managing labels moves into the label
panel so the row keeps four chips at phone width.
This commit is contained in:
Maurice
2026-11-13 09:17:49 +01:00
parent 208af03212
commit 73ac4b29ad
3 changed files with 197 additions and 54 deletions
+1 -1
View File
@@ -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,
@@ -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 (
<button
type="button"
onClick={onPick}
className={`flex w-full items-center gap-[9px] rounded-[11px] p-[10px] text-[0.8125rem] font-semibold text-m-ink ${on ? 'bg-[color:var(--m-ic)]' : ''}`}
>
{icon}
<span dir="auto" className="min-w-0 flex-1 truncate text-start">{label}</span>
<span className="font-geist text-[0.6875rem] font-bold text-m-faint">{count}</span>
</button>
)
}
function EmptyNote({ icon, scene, title }: { icon?: ReactNode; scene?: TrekScene; title: string }) {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-[6px] px-6 text-center">
@@ -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<FilterDrop | null>(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() {
<div className="relative mt-2 flex items-center gap-[6px]">
<button
type="button"
onClick={() => { setStatusDrop(v => !v); setLabelDrop(false) }}
aria-expanded={statusDrop}
onClick={() => toggleDrop('status')}
aria-expanded={filterDrop === 'status'}
className={`${FILTER_CHIP} ${c.statusFilter !== 'all' ? CHIP_ON : CHIP_IDLE}`}
>
<Layers size={13} strokeWidth={2} className="flex-none" />
<span className="truncate">{statusLabel}</span>
<ChevronDown size={12} strokeWidth={2} className={`flex-none ${c.statusFilter !== 'all' ? '' : 'text-m-faint'}`} />
</button>
{showCategoryChip && (
<button
type="button"
onClick={() => toggleDrop('category')}
aria-expanded={filterDrop === 'category'}
aria-label={`${t('categories.title')}: ${categoryLabel}`}
className={`${FILTER_CHIP} ${c.categoryFilter !== 'all' ? CHIP_ON : CHIP_IDLE}`}
>
<Shapes size={13} strokeWidth={2} className="flex-none" />
<span dir="auto" className="truncate">{categoryLabel}</span>
<ChevronDown size={12} strokeWidth={2} className={`flex-none ${c.categoryFilter !== 'all' ? '' : 'text-m-faint'}`} />
</button>
)}
{isRealList && (
<button
type="button"
onClick={() => { setLabelDrop(v => !v); setStatusDrop(false) }}
aria-expanded={labelDrop}
onClick={() => toggleDrop('label')}
aria-expanded={filterDrop === 'label'}
className={`${FILTER_CHIP} ${c.labelFilter.length > 0 ? CHIP_ON : CHIP_IDLE}`}
>
<Tag size={13} strokeWidth={2} className="flex-none" />
@@ -300,12 +355,6 @@ export default function MCollections() {
<ChevronDown size={12} strokeWidth={2} className={`flex-none ${c.labelFilter.length > 0 ? '' : 'text-m-faint'}`} />
</button>
)}
{canManageLabels && (
<button type="button" onClick={() => c.setShowLabelManager(true)} className={`${FILTER_CHIP} ${CHIP_IDLE}`}>
<Plus size={12} strokeWidth={2.2} className="flex-none" />
<span className="truncate">{t('collections.labels.title')}</span>
</button>
)}
{showSelect && (
<button type="button" onClick={() => c.setSelectMode(true)} className={`${FILTER_CHIP} ${CHIP_IDLE}`}>
<CheckSquare size={13} strokeWidth={2} className="flex-none" />
@@ -313,48 +362,69 @@ export default function MCollections() {
</button>
)}
{(statusDrop || labelDrop) && (
{filterDrop && (
<button
type="button"
aria-label={t('common.close')}
className="fixed inset-0 z-10 cursor-default"
onClick={() => { setStatusDrop(false); setLabelDrop(false) }}
onClick={() => setFilterDrop(null)}
/>
)}
{statusDrop && (
{filterDrop === 'status' && (
<div className={`absolute inset-x-0 top-[calc(100%+6px)] z-20 p-[6px] ${DROP_PANEL}`}>
<button
type="button"
onClick={() => pickStatusFilter('all')}
className={`flex w-full items-center gap-[9px] rounded-[11px] p-[10px] text-[0.8125rem] font-semibold text-m-ink ${c.statusFilter === 'all' ? 'bg-[color:var(--m-ic)]' : ''}`}
>
<Layers size={15} strokeWidth={2} />
<span className="flex-1 text-start">{t('collections.status.filterAll')}</span>
<span className="font-geist text-[0.6875rem] font-bold text-m-faint">{c.counts.all}</span>
</button>
<DropRow
icon={<Layers size={15} strokeWidth={2} className="flex-none" />}
label={t('collections.status.filterAll')}
count={c.counts.all}
on={c.statusFilter === 'all'}
onPick={() => pickStatusFilter('all')}
/>
{STATUS_ORDER.map(s => {
const meta = STATUS_SPEC[s]
const Icon = meta.icon
return (
<button
<DropRow
key={s}
type="button"
onClick={() => pickStatusFilter(s)}
className={`flex w-full items-center gap-[9px] rounded-[11px] p-[10px] text-[0.8125rem] font-semibold text-m-ink ${c.statusFilter === s ? 'bg-[color:var(--m-ic)]' : ''}`}
>
<Icon size={15} strokeWidth={2} style={{ color: meta.color }} />
<span className="flex-1 text-start">{t(meta.labelKey)}</span>
<span className="font-geist text-[0.6875rem] font-bold text-m-faint">{c.counts[s]}</span>
</button>
icon={<Icon size={15} strokeWidth={2} className="flex-none" style={{ color: meta.color }} />}
label={t(meta.labelKey)}
count={c.counts[s]}
on={c.statusFilter === s}
onPick={() => pickStatusFilter(s)}
/>
)
})}
</div>
)}
{labelDrop && (
<div className={`absolute inset-x-0 top-[calc(100%+6px)] z-20 max-h-[240px] overflow-y-auto p-[6px] ${DROP_PANEL}`}>
{filterDrop === 'category' && (
<div className={`absolute inset-x-0 top-[calc(100%+6px)] z-20 max-h-[300px] overflow-y-auto p-[6px] ${DROP_PANEL}`}>
<DropRow
icon={<Shapes size={15} strokeWidth={2} className="flex-none" />}
label={t('collections.status.filterAll')}
count={categoryTotal}
on={c.categoryFilter === 'all'}
onPick={() => pickCategoryFilter('all')}
/>
{c.categoryOptions.map(o => {
const meta = categoryMeta(o)
const Icon = meta?.icon ?? Shapes
return (
<DropRow
key={o.id}
icon={<Icon size={15} strokeWidth={2} className="flex-none" style={{ color: meta?.color }} />}
label={o.name}
count={o.count}
on={c.categoryFilter === o.id}
onPick={() => pickCategoryFilter(o.id)}
/>
)
})}
</div>
)}
{filterDrop === 'label' && (
<div className={`absolute inset-x-0 top-[calc(100%+6px)] z-20 max-h-[300px] overflow-y-auto p-[6px] ${DROP_PANEL}`}>
<button
type="button"
onClick={() => { c.setLabelFilter([]); setLabelDrop(false) }}
onClick={() => { c.setLabelFilter([]); setFilterDrop(null) }}
className={`flex w-full items-center gap-[9px] rounded-[11px] p-[10px] text-[0.8125rem] font-semibold text-m-ink ${c.labelFilter.length === 0 ? 'bg-[color:var(--m-ic)]' : ''}`}
>
<Tag size={15} strokeWidth={2} />
@@ -379,6 +449,18 @@ export default function MCollections() {
{c.labelOptions.length === 0 && (
<div className="p-[10px] text-center font-geist text-[0.71875rem] text-m-faint">{t('collections.labels.empty')}</div>
)}
{/* Managing the labels sits under their list, which keeps the row at
four chips now that the category chip joined it (#2618). */}
{canManageLabels && (
<button
type="button"
onClick={openLabelManager}
className="mt-1 flex w-full items-center gap-[9px] rounded-[11px] border-t border-[color:var(--m-rowbr)] p-[10px] text-[0.8125rem] font-semibold text-m-ink"
>
<Settings2 size={15} strokeWidth={2} className="flex-none" />
<span className="flex-1 text-start">{c.labelOptions.length > 0 ? t('collections.labels.manage') : t('collections.labels.add')}</span>
</button>
)}
</div>
)}
</div>
@@ -1,4 +1,4 @@
// FE-MOB-COLSCR-001 to FE-MOB-COLSCR-034
// FE-MOB-COLSCR-001 to FE-MOB-COLSCR-038
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render'
@@ -169,6 +169,7 @@ function makeHook(over: Record<string, unknown> = {}): Record<string, unknown> {
view: 'list',
statusFilter: 'all',
categoryFilter: 'all',
categoryOptions: [],
ratingFilter: 'all',
search: '',
selectedPlaceId: null,
@@ -366,7 +367,7 @@ describe('MCollections', () => {
expect(fn('setLabelFilter')).toHaveBeenCalledWith([2, 1])
})
it('FE-MOB-COLSCR-013: a list without labels says so, and the manage chip opens the manager', () => {
it('FE-MOB-COLSCR-013: a list without labels says so, and the label panel opens the manager', () => {
render(<MCollections />)
// Status chip and label chip both read "all" while nothing is filtered.
@@ -375,8 +376,10 @@ describe('MCollections', () => {
fireEvent.click(chips[1])
expect(screen.getByText('collections.labels.empty')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /collections.labels.title/ }))
fireEvent.click(screen.getByRole('button', { name: 'collections.labels.add' }))
expect(fn('setShowLabelManager')).toHaveBeenCalledWith(true)
// Opening the manager closes the panel.
expect(screen.queryByText('collections.labels.empty')).not.toBeInTheDocument()
})
it('FE-MOB-COLSCR-014: the backdrop under an open dropdown closes it again', () => {
@@ -653,12 +656,12 @@ describe('MCollections', () => {
expect(fn('setShowAddPlace')).not.toHaveBeenCalled()
})
it('FE-MOB-COLSCR-029: desktop-only category and rating filters are cleared on phone (#1435)', async () => {
it('FE-MOB-COLSCR-029: the desktop-only rating filter is cleared on phone, the category filter is kept (#1435, #2618)', async () => {
mocks.coll = makeHook({ categoryFilter: 3, ratingFilter: 4 })
render(<MCollections />)
await waitFor(() => expect(fn('setCategoryFilter')).toHaveBeenCalledWith('all'))
expect(fn('setRatingFilter')).toHaveBeenCalledWith('all')
await waitFor(() => expect(fn('setRatingFilter')).toHaveBeenCalledWith('all'))
expect(fn('setCategoryFilter')).not.toHaveBeenCalled()
})
it('FE-MOB-COLSCR-030: the add sheet has no fixed target while the union is active', () => {
@@ -671,6 +674,64 @@ describe('MCollections', () => {
})
})
// #2618: the phone could filter by status and label, but not by category.
describe('MCollections: category filter (#2618)', () => {
const categoryOptions = [
{ id: 3, name: 'Restaurant', color: '#E8843D', icon: 'utensils', count: 2 },
{ id: 5, name: 'Museum', color: null, icon: null, count: 1 },
]
it('FE-MOB-COLSCR-035: the chip lists the categories present with their counts and picks one', () => {
mocks.coll = makeHook({ categoryOptions })
render(<MCollections />)
const chip = screen.getByRole('button', { name: 'categories.title: collections.status.filterAll' })
expect(chip).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(chip)
expect(chip).toHaveAttribute('aria-expanded', 'true')
// The "all" row counts every categorised place.
expect(screen.getByRole('button', { name: /^collections.status.filterAll\s*3$/ })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /^Restaurant\s*2$/ }))
expect(fn('setCategoryFilter')).toHaveBeenCalledWith(3)
// Picking closes the panel.
expect(screen.queryByRole('button', { name: /^Museum\s*1$/ })).not.toBeInTheDocument()
})
it('FE-MOB-COLSCR-036: an active category names itself and the "all" row clears it', () => {
mocks.coll = makeHook({ categoryOptions, categoryFilter: 5 })
render(<MCollections />)
fireEvent.click(screen.getByRole('button', { name: 'categories.title: Museum' }))
fireEvent.click(screen.getByRole('button', { name: /^collections.status.filterAll\s*3$/ }))
expect(fn('setCategoryFilter')).toHaveBeenCalledWith('all')
})
it('FE-MOB-COLSCR-037: without categories the chip stays away, unless a category is still set from another list', () => {
const { unmount } = render(<MCollections />)
expect(screen.queryByRole('button', { name: /^categories.title/ })).not.toBeInTheDocument()
unmount()
mocks.coll = makeHook({ categoryFilter: 7, categories: [{ id: 7, name: 'Beach' }] })
render(<MCollections />)
fireEvent.click(screen.getByRole('button', { name: 'categories.title: Beach' }))
fireEvent.click(screen.getByRole('button', { name: /^collections.status.filterAll\s*0$/ }))
expect(fn('setCategoryFilter')).toHaveBeenCalledWith('all')
})
it('FE-MOB-COLSCR-038: opening one filter panel closes the other', () => {
mocks.coll = makeHook({ categoryOptions })
render(<MCollections />)
fireEvent.click(screen.getByRole('button', { name: 'categories.title: collections.status.filterAll' }))
expect(screen.getByRole('button', { name: /^Museum\s*1$/ })).toBeInTheDocument()
// The status chip is the first chip reading "all".
fireEvent.click(screen.getAllByRole('button', { name: 'collections.status.filterAll' })[0])
expect(screen.queryByRole('button', { name: /^Museum\s*1$/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /collections.status.idea/ })).toBeInTheDocument()
})
})
// #2645: the phone had no way to answer a list invite, and the notification's
// link landed on "Start your first collection".
describe('MCollections: invites (#2645)', () => {