feat(places): add a place straight into the day that is open

The places pool sits one click away from the plan, and a place you already know
belongs to today had to go through the day picker afterwards to get there. With
a day open, the add button splits: the left half still opens an empty form, the
right half opens one that assigns the place to that day on save.

The plumbing was already there. placeFormDayId has assigned a freshly created
place to a day since #1998; nothing in the places rail ever set it.

The main label shortens to "New place" while the split is out, so both buttons
fit side by side in the rail without either one truncating. The second button
stays mounted and collapsed rather than unmounted, so it has something to
animate out of: width, padding and opacity over 180ms, and nothing at all under
prefers-reduced-motion. Collapsed it is also out of the tab order and hidden
from screen readers, because a zero-width button is still focusable otherwise.

Both columns of the place form are 320px now. Two panels of different widths
flanking one form read as a mistake rather than as a hierarchy, and neither of
them is the more important one.
This commit is contained in:
Maurice
2026-09-08 17:35:40 +02:00
committed by Maurice
parent 900e4806ad
commit 9fa0ceaf49
29 changed files with 172 additions and 17 deletions
@@ -183,7 +183,10 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
]
return (
<aside className="w-full sm:w-64 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
// Same 320px as the details column on the other side of the form: two panels
// of different widths flanking one form read as a mistake rather than a
// hierarchy, and neither of them is the more important one.
<aside className="w-full sm:w-80 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
<Bookmark size={15} className="text-accent" />
<span className="text-[13px] font-semibold text-content">{t('collections.picker.title')}</span>
@@ -88,6 +88,39 @@ describe('PlacesSidebar', () => {
expect(onAddPlace).toHaveBeenCalled();
});
/**
* The split add button (a day is open).
*
* Two buttons rather than one, because the pool sits a click away from the plan
* and a place you already know belongs to today should not need a second trip
* through the day picker. The second one stays mounted and collapsed so it has
* something to animate out of.
*/
it('FE-COMP-PLACES-005b: with no day open there is one add button, at full length', () => {
render(<PlacesSidebar {...defaultProps} selectedDayId={null} onAddPlaceToSelectedDay={vi.fn()} />);
expect(screen.getAllByText(/Add Place\/Activity/i).length).toBeGreaterThan(0);
expect(screen.queryByText('New place')).not.toBeInTheDocument();
// Present in the DOM so it can animate, but out of reach until a day is open.
const quick = screen.getByTestId('add-place-to-day');
expect(quick).toHaveAttribute('aria-hidden', 'true');
expect(quick).toHaveAttribute('tabindex', '-1');
});
it('FE-COMP-PLACES-005c: an open day splits the button and shortens the main label', async () => {
const user = userEvent.setup();
const onAddPlaceToSelectedDay = vi.fn();
render(<PlacesSidebar {...defaultProps} selectedDayId={7} onAddPlaceToSelectedDay={onAddPlaceToSelectedDay} />);
// Shortened so both fit side by side without either truncating.
expect(screen.getByText('New place')).toBeInTheDocument();
const quick = screen.getByTestId('add-place-to-day');
expect(quick).toHaveAttribute('tabindex', '0');
await user.click(quick);
expect(onAddPlaceToSelectedDay).toHaveBeenCalled();
});
it('FE-COMP-PLACES-006: clicking a place calls onPlaceClick with place id', async () => {
const user = userEvent.setup();
const onPlaceClick = vi.fn();
@@ -1,4 +1,4 @@
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star } from 'lucide-react'
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star, CalendarPlus } from 'lucide-react'
import { getCategoryIcon } from '../shared/categoryIcons'
import Tooltip from '../shared/Tooltip'
import type { SidebarState } from './usePlacesSidebar'
@@ -21,27 +21,66 @@ export function PlacesDropOverlay({ t }: SidebarState) {
export function PlacesHeader(S: SidebarState) {
const {
canEditPlaces, onAddPlace, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders,
canEditPlaces, onAddPlace, onAddPlaceToSelectedDay, selectedDayId, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders,
places, categories, categoryFilters, search, setSearch, plannedIds, hasTracks,
filter, setFilter, setSelectedIds, selectMode, setSelectMode,
catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFilters,
ratingFilter, setRatingFilter,
starDropOpen, setStarDropOpen,
} = S
const dayOpen = selectedDayId != null
return (
<div className="border-b border-edge-faint" style={{ padding: '14px 16px 10px', flexShrink: 0 }}>
{canEditPlaces && <button type="button"
onClick={onAddPlace}
className="bg-accent text-accent-text"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
width: '100%', padding: '8px 12px', borderRadius: 12, border: 'none',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit', marginBottom: 10,
}}
>
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
</button>}
{canEditPlaces && <div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
<button type="button"
onClick={onAddPlace}
className="bg-accent text-accent-text"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
flex: 1, minWidth: 0, padding: '8px 12px', borderRadius: 12, border: 'none',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<Plus size={14} strokeWidth={2} />
{/* Shortened while the second button is out, so both fit side by side
in a 320px rail without either one truncating. */}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t(dayOpen ? 'places.addPlaceShort' : 'places.addPlace')}
</span>
</button>
{/* Kept mounted and collapsed rather than unmounted, so it has something
to animate out of: a button that only exists while a day is open
would appear and vanish on the spot. Hidden from the tab order and
from screen readers in the same breath, because a zero-width button
is still focusable otherwise. */}
{onAddPlaceToSelectedDay && (
<Tooltip label={t('places.addToSelectedDay')} disabled={!dayOpen}>
<button type="button"
onClick={onAddPlaceToSelectedDay}
aria-label={t('places.addToSelectedDay')}
data-testid="add-place-to-day"
aria-hidden={!dayOpen}
tabIndex={dayOpen ? 0 : -1}
className="bg-accent text-accent-text motion-reduce:transition-none"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
maxWidth: dayOpen ? 160 : 0,
opacity: dayOpen ? 1 : 0,
padding: dayOpen ? '8px 12px' : 0,
borderRadius: 12, border: 'none', flexShrink: 0,
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit', overflow: 'hidden', whiteSpace: 'nowrap',
pointerEvents: dayOpen ? 'auto' : 'none',
transition: 'max-width 180ms ease, opacity 140ms ease, padding 180ms ease',
}}
>
<CalendarPlus size={14} strokeWidth={2} style={{ flexShrink: 0 }} />
{t('places.addToDayShort')}
</button>
</Tooltip>
)}
</div>}
{canEditPlaces && <>
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
<button type="button"
@@ -31,6 +31,15 @@ export interface PlacesSidebarProps {
selectedPlaceId: number | null
onPlaceClick: (placeId: number | null) => void
onAddPlace: () => void
/**
* Create a place and drop it straight into the day that is open.
*
* Only reachable while a day is selected, which is what the split button in
* the header is about: the pool is one click away from the plan, and adding a
* place you already know belongs to today should not need a second trip
* through the day picker.
*/
onAddPlaceToSelectedDay?: () => void
onAssignToDay: (placeId: number, dayId: number) => void
onEditPlace: (place: Place) => void
onDeletePlace: (placeId: number) => void