fix(mobile): align the places browser's filter panel with the map control

The panel button is named "Filters" like the map control, its badge counts
kinds of filter through countActivePlacesFilters (the pool, shown as chips
above it, stays out), and picking a rating floor clears the selection as
picking a pool does.
This commit is contained in:
EvKoh
2026-10-03 15:53:57 +02:00
committed by Maurice
parent 3d99e569ea
commit 4b263c8e4b
3 changed files with 45 additions and 13 deletions
@@ -23,6 +23,7 @@ import MPlacesBulkCategorySheet from './MPlacesBulkCategorySheet'
import MPlacesSaveToCollectionSheet from './MPlacesSaveToCollectionSheet'
import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBrowserModel'
import { MCategoryFilterList, MRatingFloorChips, SquareCheck } from './MPlacesFilterControls'
import { countActivePlacesFilters } from '../../../../utils/placesFilter'
/**
* Fullscreen places pool (mode === 'browse'): All/Unplanned/Tracks filter
@@ -142,8 +143,10 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
shell.openSheet('bract', { placeId: place.id, dayPicker: false })
}
// The panel holds the categories and the rating floor; the badge counts both.
const panelFilterCount = categoryFilters.size + (ratingFilter === 'all' ? 0 : 1)
// The panel holds the categories and the rating floor; its badge counts them the
// way the map's Filters badge does (one per kind of filter). The pool is left out:
// its chips sit in plain sight above the panel.
const panelFilterCount = countActivePlacesFilters({ filter: 'all', categoryFilters, ratingFilter })
return (
<div className="flex h-full flex-col">
@@ -196,7 +199,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
type="button"
onClick={() => setCatOpen(v => !v)}
aria-expanded={catOpen}
aria-label={t('places.allCategories')}
aria-label={t('places.filters')}
className="relative flex w-[42px] flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted"
>
<SlidersHorizontal size={15} strokeWidth={2} />
@@ -289,7 +292,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
{catOpen && (
<div className="mt-[6px] overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-glass)]">
<div className="border-b border-[color:var(--m-rowbr)] px-[13px] py-[10px]">
<MRatingFloorChips />
<MRatingFloorChips onPick={() => setSelectedIds(new Set())} />
</div>
<MCategoryFilterList categories={categories} places={places} />
</div>
@@ -86,15 +86,19 @@ export function MCategoryFilterList({ categories, places }: { categories: Catego
)
}
/** A minimum of stars, the same floors as the desktop list and the collections bar (#1435). */
export function MRatingFloorChips() {
/**
* A minimum of stars, the same floors as the desktop list and the collections bar
* (#1435). `onPick` runs after a floor is chosen: the places browser drops its
* selection then, as it does for a new pool.
*/
export function MRatingFloorChips({ onPick }: { onPick?: () => void } = {}) {
const { t } = useTranslation()
const ratingFilter = useTripStore(s => s.placesRatingFilter)
const setRatingFilter = useTripStore(s => s.setPlacesRatingFilter)
return (
<div role="group" aria-label={t('places.filterByRating')} className="flex flex-wrap gap-[6px]">
{RATING_FLOORS.map(floor => (
<MChip key={String(floor)} size="tap" pressable active={ratingFilter === floor} onClick={() => setRatingFilter(floor)}>
<MChip key={String(floor)} size="tap" pressable active={ratingFilter === floor} onClick={() => { setRatingFilter(floor); onPick?.() }}>
{floor === 'all' ? t('common.all') : (
<>
<Star size={12} strokeWidth={2.2} fill="currentColor" className="flex-none" />
@@ -10,7 +10,7 @@ import { resetAllStores, seedStore } from '../../../helpers/store'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { AssignmentsMap, Category, Day, Place } from '../../../../src/types'
// FE-MOB-PBROW-001 to FE-MOB-PBROW-034
// FE-MOB-PBROW-001 to FE-MOB-PBROW-036
const CATEGORIES = [
{ id: 1, name: 'Sights', color: '#123456', icon: 'landmark' },
@@ -152,7 +152,7 @@ describe('MPlacesBrowser', () => {
it('FE-MOB-PBROW-010: the category panel toggles the shared filter set and badges its size', () => {
renderBrowser()
const catBtn = screen.getByRole('button', { name: 'places.allCategories' })
const catBtn = screen.getByRole('button', { name: 'places.filters' })
expect(catBtn).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(catBtn)
expect(catBtn).toHaveAttribute('aria-expanded', 'true')
@@ -173,7 +173,7 @@ describe('MPlacesBrowser', () => {
places: [{ ...LOUVRE, rating_avg: 4.5 }, { ...EIFFEL, rating_avg: 3 }, SEINE],
} as Partial<TripPlanner>)
renderBrowser(planner)
const panelBtn = screen.getByRole('button', { name: 'places.allCategories' })
const panelBtn = screen.getByRole('button', { name: 'places.filters' })
fireEvent.click(panelBtn)
const floors = screen.getByRole('group', { name: 'Filter by rating' })
fireEvent.click(within(floors).getByRole('button', { name: '4+' }))
@@ -189,6 +189,31 @@ describe('MPlacesBrowser', () => {
expect(screen.getByText('Seine Track')).toBeInTheDocument()
})
it('FE-MOB-PBROW-035: the panel badge counts kinds of filter, like the map badge', () => {
renderBrowser()
const panelBtn = screen.getByRole('button', { name: 'places.filters' })
fireEvent.click(panelBtn)
fireEvent.click(screen.getByRole('checkbox', { name: 'Sights' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Food' }))
// Two categories are one kind of filter.
expect(panelBtn).toHaveTextContent('1')
fireEvent.click(within(screen.getByRole('group', { name: 'Filter by rating' })).getByRole('button', { name: '4+' }))
expect(panelBtn).toHaveTextContent('2')
// The pool has its own chips in plain sight, so the panel badge leaves it out.
fireEvent.click(screen.getByRole('button', { name: 'places.unplanned' }))
expect(panelBtn).toHaveTextContent('2')
})
it('FE-MOB-PBROW-036: a new rating floor starts a fresh selection, as a new pool does', () => {
renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
fireEvent.click(row('Louvre'))
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'places.filters' }))
fireEvent.click(within(screen.getByRole('group', { name: 'Filter by rating' })).getByRole('button', { name: '1+' }))
expect(screen.queryByText('places.selectionCount:1')).not.toBeInTheDocument()
})
it('FE-MOB-PBROW-033: a floor set from the map sheet already thins the list on arrival', () => {
seedStore(useTripStore, { placesRatingFilter: 5 })
renderBrowser(makePlanner({ places: [{ ...LOUVRE, rating_avg: 5 }, EIFFEL] } as Partial<TripPlanner>))
@@ -198,7 +223,7 @@ describe('MPlacesBrowser', () => {
it('FE-MOB-PBROW-011: the uncategorized row filters the places without a category', () => {
renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'places.allCategories' }))
fireEvent.click(screen.getByRole('button', { name: 'places.filters' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'No Category' }))
expect([...useTripStore.getState().placesCategoryFilter]).toEqual(['uncategorized'])
expect(screen.getByText('Eiffel Tower')).toBeInTheDocument()
@@ -208,7 +233,7 @@ describe('MPlacesBrowser', () => {
it('FE-MOB-PBROW-012: the uncategorized row is dropped when every place has a category', () => {
const planner = makePlanner({ places: [LOUVRE, SEINE] } as Partial<TripPlanner>)
renderBrowser(planner)
fireEvent.click(screen.getByRole('button', { name: 'places.allCategories' }))
fireEvent.click(screen.getByRole('button', { name: 'places.filters' }))
expect(screen.queryByRole('checkbox', { name: 'No Category' })).not.toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'Food' })).toBeInTheDocument()
})
@@ -433,7 +458,7 @@ describe('MPlacesBrowser', () => {
// Narrow to the two categorised places: two visible, two selected — but not
// the same two, so the toolbar must still offer select-all.
fireEvent.click(screen.getByRole('button', { name: 'places.allCategories' }))
fireEvent.click(screen.getByRole('button', { name: 'places.filters' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Sights' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Food' }))