mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
fix(planner): steady satellite pill, responsive places rail, day-scoped add
The satellite toggle sat on an offset that included --day-panel-h, so it hopped every time the day panel was collapsed or expanded. On the desktop map that panel is a centred card between the sidebars and never reaches the pill at the left edge, so the lift only applies on a phone now. In the places rail the two add buttons split the width evenly once a day is open, and every button drops to its icon when the rail is dragged narrow. The filter tabs span the rail and keep their own text widths. The count is a badge across the rail; the search field got the outline the controls around it use. An empty day offers a button that opens the place form pointed at that day instead of only stating that nothing is planned. The day is cleared again when the form is opened to edit, which it was not before. The trip tab bar no longer carries a native title repeating its own label; the full name moved to aria-label for the tabs whose label is abbreviated. Three maps tests drove the index switch through app_settings, which no longer holds it, and now move TREK_PLACES_ENABLED instead.
This commit is contained in:
@@ -937,7 +937,12 @@ export const MapView = memo(function MapView({
|
||||
// Store flips state synchronously (instant, works offline); a failed save is logged there.
|
||||
updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {})
|
||||
}, [isSatellite, updateSetting])
|
||||
const switcherBottom = hasDayDetail
|
||||
// The day panel is a centred card between the two sidebars, so on the desktop
|
||||
// map it never reaches this pill sitting hard against the left sidebar. Lifting
|
||||
// it by the panel height only made it hop on every collapse and expand, since
|
||||
// --day-panel-h changes while hasDayDetail stays true. On a phone the panel is
|
||||
// full width and does cover the pill, so the lift stays there.
|
||||
const switcherBottom = isMobile && hasDayDetail
|
||||
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
|
||||
: 'calc(var(--bottom-nav-h, 0px) + 12px)'
|
||||
|
||||
|
||||
@@ -95,6 +95,8 @@ interface DayPlanSidebarProps {
|
||||
onSetRouteProfile?: (profile: string) => void
|
||||
onAddPlace?: () => void
|
||||
onAddPlaceToDay?: (placeId: number, dayId: number) => void
|
||||
/** Open the place form already pointed at this day, to create a new place there. */
|
||||
onCreatePlaceForDay?: (dayId: number) => void
|
||||
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
canUndo?: boolean
|
||||
@@ -146,6 +148,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onAddReservation,
|
||||
onAddPlace,
|
||||
onAddPlaceToDay,
|
||||
onCreatePlaceForDay,
|
||||
onNavigateToFiles,
|
||||
routeShown = false,
|
||||
routeProfile = 'driving',
|
||||
@@ -1073,6 +1076,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onAddReservation,
|
||||
onAddPlace,
|
||||
onAddPlaceToDay,
|
||||
onCreatePlaceForDay,
|
||||
onNavigateToFiles,
|
||||
routeShown,
|
||||
routeProfile,
|
||||
@@ -1244,6 +1248,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onAddReservation,
|
||||
onAddPlace,
|
||||
onAddPlaceToDay,
|
||||
onCreatePlaceForDay,
|
||||
onNavigateToFiles,
|
||||
routeShown,
|
||||
routeProfile,
|
||||
@@ -1892,7 +1897,25 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
border: dragOverDayId === day.id ? '2px dashed rgba(17,24,39,0.2)' : '2px dashed transparent',
|
||||
}}
|
||||
>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('dayplan.emptyDay')}</span>
|
||||
{/* An empty day is where somebody wants to add something, so
|
||||
the slot offers to do it instead of only stating the fact.
|
||||
Without the handler (no edit rights) it stays the sentence. */}
|
||||
{onCreatePlaceForDay ? (
|
||||
<button type="button"
|
||||
onClick={e => { e.stopPropagation(); onCreatePlaceForDay(day.id) }}
|
||||
className="border border-dashed border-edge text-content-muted"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '6px 12px', borderRadius: 8,
|
||||
background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Plus size={13} strokeWidth={2} /> {t('dayplan.addPlaceHere')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('dayplan.emptyDay')}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
merged.map((item, idx) => {
|
||||
|
||||
@@ -43,7 +43,19 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
|
||||
<PlacesSelectionBar {...S} />
|
||||
) : (
|
||||
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
|
||||
{/* A badge across the whole rail rather than a line of text hugging the
|
||||
left edge: it reads as the list's header instead of as a stray label.
|
||||
Outlined rather than filled, because the tertiary surface is a slate
|
||||
tone and put a blue cast on the panel. */}
|
||||
<div className="text-content-faint" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '4px 10px', borderRadius: 99,
|
||||
background: 'transparent', border: '1px solid var(--border-faint)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||
}}>
|
||||
{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
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 { useElementSize } from '../../hooks/useElementSize'
|
||||
import type { SidebarState } from './usePlacesSidebar'
|
||||
|
||||
/**
|
||||
* Below this the two labels stop fitting side by side and both buttons fall back
|
||||
* to their icon. Measured on the button row itself rather than derived from the
|
||||
* sidebar width: the labels are translated, so how much room they need differs
|
||||
* per locale, and the row is what actually runs out of space. The rail is
|
||||
* draggable down to 200px, which leaves the row 168.
|
||||
*/
|
||||
const COMPACT_BUTTONS_WIDTH = 232
|
||||
|
||||
export function PlacesDropOverlay({ t }: SidebarState) {
|
||||
return (
|
||||
<div style={{
|
||||
@@ -29,25 +39,43 @@ export function PlacesHeader(S: SidebarState) {
|
||||
starDropOpen, setStarDropOpen,
|
||||
} = S
|
||||
const dayOpen = selectedDayId != null
|
||||
const { ref: buttonRowRef, width: buttonRowWidth } = useElementSize<HTMLDivElement>()
|
||||
// Zero is the first paint, before the observer has measured anything — treat
|
||||
// that as roomy so the labels do not flash away and back on every mount.
|
||||
const compact = buttonRowWidth > 0 && buttonRowWidth < COMPACT_BUTTONS_WIDTH
|
||||
const addLabel = t(dayOpen ? 'places.addPlaceShort' : 'places.addPlace')
|
||||
const fileImportLabel = t('places.importFile')
|
||||
const listImportLabel = t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')
|
||||
return (
|
||||
<div className="border-b border-edge-faint" style={{ padding: '14px 16px 10px', flexShrink: 0 }}>
|
||||
{canEditPlaces && <div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
|
||||
{canEditPlaces && <div ref={buttonRowRef} style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
|
||||
{/* The label shortens while the second button is out, so both fit side by
|
||||
side in a default rail without either one truncating; a squeezed rail
|
||||
drops it entirely and the aria-label carries the name instead. */}
|
||||
<button type="button"
|
||||
onClick={onAddPlace}
|
||||
className="bg-accent text-accent-text"
|
||||
aria-label={compact ? addLabel : undefined}
|
||||
title={compact ? addLabel : undefined}
|
||||
className="bg-accent text-accent-text motion-reduce:transition-none"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
flex: 1, minWidth: 0, padding: '8px 12px', borderRadius: 12, border: 'none',
|
||||
// Both halves are driven by flex-basis rather than by grow, so the
|
||||
// pair lands on an exact 50/50 and the handover animates in both
|
||||
// directions. Half of the 6px gap comes off each side.
|
||||
flexGrow: 0, flexShrink: 1, flexBasis: dayOpen ? 'calc(50% - 3px)' : '100%',
|
||||
minWidth: 0, padding: '8px 12px', borderRadius: 12, border: 'none',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
overflow: 'hidden', whiteSpace: 'nowrap',
|
||||
transition: 'flex-basis 180ms ease',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{!compact && (
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{addLabel}
|
||||
</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
|
||||
@@ -64,48 +92,57 @@ export function PlacesHeader(S: SidebarState) {
|
||||
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,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
flexGrow: 0, flexShrink: 1, flexBasis: dayOpen ? 'calc(50% - 3px)' : '0%',
|
||||
minWidth: 0,
|
||||
opacity: dayOpen ? 1 : 0,
|
||||
padding: dayOpen ? '8px 12px' : 0,
|
||||
borderRadius: 12, border: 'none', flexShrink: 0,
|
||||
borderRadius: 12, border: 'none',
|
||||
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',
|
||||
transition: 'flex-basis 180ms ease, opacity 140ms ease, padding 180ms ease',
|
||||
}}
|
||||
>
|
||||
<CalendarPlus size={14} strokeWidth={2} style={{ flexShrink: 0 }} />
|
||||
{t('places.addToDayShort')}
|
||||
{!compact && t('places.addToDayShort')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>}
|
||||
{canEditPlaces && <>
|
||||
{/* Same squeeze rule as the row above, measured off the same rail: once the
|
||||
add buttons lose their labels these two would not fit either. */}
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
|
||||
<button type="button"
|
||||
onClick={() => setFileImportOpen(true)}
|
||||
aria-label={compact ? fileImportLabel : undefined}
|
||||
title={compact ? fileImportLabel : undefined}
|
||||
className="border border-dashed border-edge text-content-faint"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
flex: 1, minWidth: 0, padding: '5px 12px', borderRadius: 8,
|
||||
background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
overflow: 'hidden', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<FileDown size={11} strokeWidth={2} /> {t('places.importFile')}
|
||||
<FileDown size={11} strokeWidth={2} /> {!compact && fileImportLabel}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => setListImportOpen(true)}
|
||||
aria-label={compact ? listImportLabel : undefined}
|
||||
title={compact ? listImportLabel : undefined}
|
||||
className="border border-dashed border-edge text-content-faint"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
flex: 1, minWidth: 0, padding: '5px 12px', borderRadius: 8,
|
||||
background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
overflow: 'hidden', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
|
||||
<MapPin size={11} strokeWidth={2} /> {!compact && listImportLabel}
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-edge" style={{ height: 1, margin: '2px 0 10px' }} />
|
||||
@@ -136,7 +173,11 @@ 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 (
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
// 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 (
|
||||
@@ -146,18 +187,20 @@ export function PlacesHeader(S: SidebarState) {
|
||||
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', gap: 5,
|
||||
padding: '4px 9px', borderRadius: 99,
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
<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>
|
||||
@@ -176,10 +219,14 @@ export function PlacesHeader(S: SidebarState) {
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); if (selectMode) setSelectedIds(new Set()) }}
|
||||
placeholder={t('places.search')}
|
||||
className="bg-surface-tertiary text-content"
|
||||
className="bg-surface-card text-content"
|
||||
style={{
|
||||
width: '100%', padding: '7px 30px 7px 30px', borderRadius: 10,
|
||||
border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
// Borderless on the tertiary surface it sat almost flush with the
|
||||
// panel; the outline is the one the category dropdown below already
|
||||
// uses, so the two read as the same kind of control.
|
||||
border: '1px solid var(--border-primary)',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,18 @@ import React, { useLayoutEffect, useRef, useState, type CSSProperties } from 're
|
||||
export interface SlidingTab<T extends string> {
|
||||
id: T
|
||||
label: React.ReactNode
|
||||
/**
|
||||
* Native tooltip. Opt-in and never derived from the label: a tooltip that only
|
||||
* repeats the text next to the cursor is noise, so a caller sets this solely
|
||||
* when the tab shows something the label does not spell out.
|
||||
*/
|
||||
title?: string
|
||||
/**
|
||||
* Accessible name, for a tab whose visible label is an abbreviation. Silent on
|
||||
* screen, unlike `title`, so it carries the full name without putting a
|
||||
* tooltip under the cursor.
|
||||
*/
|
||||
ariaLabel?: string
|
||||
icon?: React.ComponentType<{ size?: number; className?: string }>
|
||||
count?: number
|
||||
}
|
||||
@@ -130,7 +141,8 @@ export function SlidingTabs<T extends string>({
|
||||
ref={el => { tabRefs.current.set(tab.id, el) }}
|
||||
onClick={() => onChange(tab.id)}
|
||||
style={btnStyle}
|
||||
title={tab.title ?? (typeof tab.label === 'string' ? tab.label : undefined)}
|
||||
title={tab.title}
|
||||
aria-label={tab.ariaLabel}
|
||||
>
|
||||
{Icon && <Icon size={size === 'sm' ? 13 : 15} />}
|
||||
{tab.label}
|
||||
|
||||
Reference in New Issue
Block a user