From b6fcaced2e4dff56dd98958b61617574297beac3 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 21 Sep 2026 22:18:51 +0200 Subject: [PATCH] feat(planner): the places filter is one select, not four pills With the tracks tab the pill row had grown past what the rail can hold: four pills sharing one line clipped a word as soon as the panel narrowed. The filter is the app's own select now, the count riding on the trigger the way it rode on the active pill, and every option carrying its own in the menu. --- .../components/Planner/PlacesSidebar.test.tsx | 18 +++++--- .../Planner/PlacesSidebarHeader.tsx | 46 +++++-------------- 2 files changed, 24 insertions(+), 40 deletions(-) diff --git a/client/src/components/Planner/PlacesSidebar.test.tsx b/client/src/components/Planner/PlacesSidebar.test.tsx index e5d99f6b9..ae82a874f 100644 --- a/client/src/components/Planner/PlacesSidebar.test.tsx +++ b/client/src/components/Planner/PlacesSidebar.test.tsx @@ -1,5 +1,5 @@ // FE-COMP-PLACES-001 to FE-COMP-PLACES-015 + FE-PLANNER-SIDEBAR-016 to 043 -import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render'; +import { render, screen, fireEvent, waitFor, act, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { useAuthStore } from '../../store/authStore'; @@ -50,6 +50,12 @@ beforeEach(() => { seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }); }); +/** The filter is one select now: open it, then pick the option by its label. */ +async function pickFilter(user: ReturnType, label: string) { + await user.click(within(screen.getByTestId('places-filter')).getByRole('button')); + await user.click(await screen.findByRole('button', { name: new RegExp(`^${label}`) })); +} + describe('PlacesSidebar', () => { it('FE-COMP-PLACES-001: renders without crashing', () => { render(); @@ -251,7 +257,7 @@ describe('Filter tabs', () => { const unplanned = buildPlace({ name: 'Unplanned Place' }); const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] }; render(); - await user.click(screen.getByRole('button', { name: /Unplanned/i })); + await pickFilter(user, 'Unplanned'); expect(screen.queryByText('Planned Place')).not.toBeInTheDocument(); expect(screen.getByText('Unplanned Place')).toBeInTheDocument(); }); @@ -262,8 +268,8 @@ describe('Filter tabs', () => { const unplanned = buildPlace({ name: 'Unplanned Place' }); const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] }; render(); - await user.click(screen.getByRole('button', { name: /Unplanned/i })); - await user.click(screen.getByRole('button', { name: /^All/i })); + await pickFilter(user, 'Unplanned'); + await pickFilter(user, 'All'); expect(screen.getByText('Planned Place')).toBeInTheDocument(); expect(screen.getByText('Unplanned Place')).toBeInTheDocument(); }); @@ -273,7 +279,7 @@ describe('Filter tabs', () => { const place = buildPlace({ name: 'Assigned Place' }); const assignments = { '1': [buildAssignment({ place, day_id: 1 })] }; render(); - await user.click(screen.getByRole('button', { name: /Unplanned/i })); + await pickFilter(user, 'Unplanned'); expect(screen.getByText(/All places are planned/i)).toBeInTheDocument(); }); @@ -515,7 +521,7 @@ describe('Category filter dropdown', () => { const unplanned = buildPlace({ name: 'Unplanned Place' }); const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] }; const { unmount } = render(); - await user.click(screen.getByRole('button', { name: /Unplanned/i })); + await pickFilter(user, 'Unplanned'); expect(screen.queryByText('Planned Place')).not.toBeInTheDocument(); unmount(); render(); diff --git a/client/src/components/Planner/PlacesSidebarHeader.tsx b/client/src/components/Planner/PlacesSidebarHeader.tsx index 72d03dd7e..9148bd174 100644 --- a/client/src/components/Planner/PlacesSidebarHeader.tsx +++ b/client/src/components/Planner/PlacesSidebarHeader.tsx @@ -1,6 +1,7 @@ import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star, CalendarPlus, CalendarDays } from 'lucide-react' import { getCategoryIcon } from '../shared/categoryIcons' import Tooltip from '../shared/Tooltip' +import CustomSelect from '../shared/CustomSelect' import { useElementSize } from '../../hooks/useElementSize' import type { SidebarState } from './usePlacesSidebar' @@ -177,40 +178,17 @@ export function PlacesHeader(S: SidebarState) { hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null, ] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'planned' | 'tracks'; label: string }> return ( - // The group always spans the rail and never wraps onto a second line. - // Each tab keeps its own text width and only the leftover space is - // shared out, so "Unplanned" stays wider than "All" instead of the four - // being forced to one size. -
- {tabs.map(f => { - const active = filter === f.id - return ( - - ) - })} + // One select where four pills used to fight for the rail's width: with the + // tracks tab the row had grown past what a narrow panel can hold without + // clipping a word. The count rides on the trigger the way it rode on the + // active pill, and every option carries its own in the menu. +
+ { setFilter(v as 'all' | 'unplanned' | 'planned' | 'tracks'); setSelectedIds(new Set()) }} + options={tabs.map(f => ({ value: f.id, label: f.label, badge: String(counts[f.id]) }))} + />
) })()}