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.
This commit is contained in:
Maurice
2026-09-21 22:18:51 +02:00
parent e12ae950df
commit b6fcaced2e
2 changed files with 24 additions and 40 deletions
@@ -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<typeof userEvent.setup>, 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(<PlacesSidebar {...defaultProps} />);
@@ -251,7 +257,7 @@ describe('Filter tabs', () => {
const unplanned = buildPlace({ name: 'Unplanned Place' });
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
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(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
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(<PlacesSidebar {...defaultProps} places={[place]} assignments={assignments} />);
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(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
await pickFilter(user, 'Unplanned');
expect(screen.queryByText('Planned Place')).not.toBeInTheDocument();
unmount();
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
@@ -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.
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'nowrap' }}>
{tabs.map(f => {
const active = filter === f.id
return (
<button type="button"
key={f.id}
onClick={() => { setFilter(f.id); setSelectedIds(new Set()) }}
className={active ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
flex: '1 1 auto', minWidth: 0, overflow: 'hidden',
padding: compact ? '4px 5px' : '4px 9px', borderRadius: 99,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, whiteSpace: 'nowrap',
boxShadow: active ? 'none' : '0 1px 2px rgba(0,0,0,0.06)',
transition: 'background 0.15s, color 0.15s, box-shadow 0.15s',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.label}</span>
<span className={active ? 'text-accent-text' : 'text-content-faint'} style={{
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, lineHeight: 1,
background: active ? 'color-mix(in srgb, var(--accent-text) 22%, transparent)' : 'var(--bg-tertiary)',
padding: '1px 5px', borderRadius: 99, minWidth: 14, textAlign: 'center',
flexShrink: 0,
}}>
{counts[f.id]}
</span>
</button>
)
})}
// 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.
<div data-testid="places-filter" style={{ marginBottom: 8 }}>
<CustomSelect
size="sm"
value={filter}
onChange={v => { 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]) }))}
/>
</div>
)
})()}