mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user