Add the track colour picker to the place inspector

New shared component rather than a ninth copy of the swatch grid that is
already inline in eight places. Selection is drawn with var(--accent) so
it follows whatever accent the user runs.

The custom-colour cell listens for the native change event instead of
React's onChange: for a colour input React maps onChange to input, which
fires on every drag inside the OS picker — dozens of writes, each one a
409 candidate against the base-version header. The auto cell previews the
colour you would get back, not the one currently set, which is a
different value as soon as anything is picked.

The row sits next to the track stats block, not inside it: that block
returns null on unparsable geometry and the colour control has no
business disappearing with it. The elevation chart and the distance pin
follow the track colour too, otherwise the panel and the map read as two
unrelated objects.

In the places list the track icon becomes a stroke in the colour the line
is drawn in. The map has no legend of its own, so that stroke is the only
thing tying a line back to a row. Undo after deleting a track now
restores its geometry and colour instead of bringing back a bare point.
This commit is contained in:
Maurice
2026-07-27 17:35:54 +02:00
committed by Maurice
parent fcabb4c645
commit 3c575a06f4
7 changed files with 282 additions and 9 deletions
@@ -732,4 +732,35 @@ describe('PlaceInspector', () => {
expect(screen.queryByRole('button', { name: 'Change image' })).toBeNull();
});
// ── Track colour (#776) ──────────────────────────────────────────────────────
it('FE-PLANNER-INSPECTOR-051: the colour row only exists for a place with geometry', () => {
const { rerender } = render(<PlaceInspector {...defaultProps} />);
expect(screen.queryByText('Track color')).toBeNull();
rerender(<PlaceInspector {...defaultProps} place={{ ...place, route_geometry: '[[48.0,2.0],[49.0,3.0]]' }} />);
expect(screen.getAllByText('Track color').length).toBeGreaterThan(0);
});
it('FE-PLANNER-INSPECTOR-052: picking a swatch saves that colour', () => {
const onUpdatePlace = vi.fn();
const track = { ...place, route_geometry: '[[48.0,2.0],[49.0,3.0]]' };
render(<PlaceInspector {...defaultProps} place={track} onUpdatePlace={onUpdatePlace} />);
fireEvent.click(screen.getAllByText('Track color')[0]);
fireEvent.click(screen.getByRole('button', { name: '#059669' }));
expect(onUpdatePlace).toHaveBeenCalledWith(track.id, { route_color: '#059669' });
});
it('FE-PLANNER-INSPECTOR-053: the auto cell saves null, not undefined', () => {
const onUpdatePlace = vi.fn();
const track = { ...place, route_geometry: '[[48.0,2.0],[49.0,3.0]]', route_color: '#059669' };
render(<PlaceInspector {...defaultProps} place={track} onUpdatePlace={onUpdatePlace} />);
fireEvent.click(screen.getAllByText('Track color')[0]);
fireEvent.click(screen.getByRole('button', { name: 'Automatic color' }));
// null is what clears the column; undefined would leave the colour in place.
expect(onUpdatePlace).toHaveBeenCalledWith(track.id, { route_color: null });
});
});
@@ -4,10 +4,12 @@ import { openFile } from '../../utils/fileDownload'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy } from 'lucide-react'
import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy, Route } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import PlaceAvatarUpload from '../shared/PlaceAvatarUpload'
import PlaceRating from '../shared/StarRating'
import TrackColorPicker from '../shared/TrackColorPicker'
import { resolveTrackColor, inheritedTrackColor } from '../Map/trackColors'
import GuestBadge from '../shared/GuestBadge'
import StatusBadge from '../Collections/StatusBadge'
import { mapsApi, pluginsApi } from '../../api/client'
@@ -392,7 +394,7 @@ export default function PlaceInspector({
setHoursExpanded={setHoursExpanded} timeFormat={timeFormat} t={t} place={place} placeFiles={placeFiles}
onFileUpload={onFileUpload} filesExpanded={filesExpanded} setFilesExpanded={setFilesExpanded}
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading}
distanceUnit={distanceUnit} />
distanceUnit={distanceUnit} onUpdatePlace={onUpdatePlace} />
{/* Extra native rows from placeDetailProvider plugins (#1429). */}
{mode === 'trip' && providerDetails.length > 0 && (
@@ -856,8 +858,51 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi
)
}
/**
* Track colour row (#776) — only for places that carry GPX geometry. Sits next
* to the stats block rather than inside it: that block bails out on unparsable
* geometry, and the colour control has no business disappearing with it.
*/
function TrackColorRow({ place, trackColor, onUpdatePlace, t }: any) {
const [open, setOpen] = useState(false)
return (
<div className="bg-surface-hover" style={{ borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<button
onClick={() => setOpen(o => !o)}
aria-expanded={open}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
background: 'none', border: 'none', padding: 0, cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Route size={13} color="#9ca3af" />
<span className="text-content-secondary" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500 }}>
{t('inspector.trackColor')}
</span>
</div>
<span
className="ring-1 ring-inset ring-black/10 dark:ring-white/15"
style={{ width: 20, height: 20, borderRadius: 999, backgroundColor: trackColor, flexShrink: 0 }}
/>
</button>
{open && (
<div className="border-edge" style={{ borderTopWidth: 1, paddingTop: 8 }}>
<TrackColorPicker
value={place.route_color ?? null}
inheritedColor={inheritedTrackColor(place)}
onChange={color => onUpdatePlace?.(place.id, { route_color: color })}
/>
</div>
)}
</div>
)
}
function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpanded, timeFormat, t, place,
placeFiles, onFileUpload, filesExpanded, setFilesExpanded, fileInputRef, handleFileUpload, isUploading, distanceUnit }: any) {
placeFiles, onFileUpload, filesExpanded, setFilesExpanded, fileInputRef, handleFileUpload, isUploading, distanceUnit,
onUpdatePlace }: any) {
const trackColor = resolveTrackColor(place)
return (
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
{openingHours && openingHours.length > 0 && (
@@ -893,6 +938,10 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand
)}
{place.route_geometry && (
<TrackColorRow place={place} trackColor={trackColor} onUpdatePlace={onUpdatePlace} t={t} />
)}
{/* GPX Track stats */}
{place.route_geometry && (() => {
try {
@@ -949,7 +998,7 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
<MapPin size={12} color="#3b82f6" />
<MapPin size={12} color={trackColor} />
{formatDistance(distKm, distanceUnit)}
</div>
{hasEle && (
@@ -972,12 +1021,12 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand
<svg width="100%" viewBox={`0 0 ${chartW} ${chartH}`} preserveAspectRatio="none" className="bg-surface-tertiary" style={{ display: 'block', borderRadius: 6 }}>
<defs>
<linearGradient id={`ele-grad-${place.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.25" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.02" />
<stop offset="0%" stopColor={trackColor} stopOpacity="0.25" />
<stop offset="100%" stopColor={trackColor} stopOpacity="0.02" />
</linearGradient>
</defs>
<path d={`${pathD} L${chartW},${chartH} L0,${chartH} Z`} fill={`url(#ele-grad-${place.id})`} />
<path d={pathD} fill="none" stroke="#3b82f6" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
<path d={pathD} fill="none" stroke={trackColor} strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
</svg>
)}
</div>
@@ -650,3 +650,23 @@ describe('touch device at desktop width (#1432)', () => {
expect(screen.getByText('Drop to import')).toBeInTheDocument();
});
});
// The map draws no legend of its own, so the stroke in the row is the only
// thing tying a coloured line back to a place (#776).
describe('track colour legend (#776)', () => {
it('FE-PLANNER-SIDEBAR-049: a track row carries a stroke in the colour the map draws', () => {
const track = buildPlace({ id: 11, name: 'Coloured Track', route_geometry: '[[48.0,2.0],[49.0,3.0]]', route_color: '#e11d48' });
render(<PlacesSidebar {...defaultProps} places={[track]} />);
const row = screen.getByText('Coloured Track').closest('div[draggable]')!;
const strokes = Array.from(row.querySelectorAll('span')).filter(el => (el as HTMLElement).style.borderRadius === '999px');
expect(strokes.some(el => (el as HTMLElement).style.background.includes('225, 29, 72') || (el as HTMLElement).style.background.includes('#e11d48'))).toBe(true);
});
it('FE-PLANNER-SIDEBAR-050: a place without geometry gets no stroke at all', () => {
const plain = buildPlace({ id: 12, name: 'Plain Place' });
render(<PlacesSidebar {...defaultProps} places={[plain]} />);
const row = screen.getByText('Plain Place').closest('div[draggable]')!;
const strokes = Array.from(row.querySelectorAll('span')).filter(el => (el as HTMLElement).style.borderRadius === '999px');
expect(strokes).toHaveLength(0);
});
});
@@ -1,7 +1,8 @@
import React from 'react'
import { Plus, Check, Route, Star } from 'lucide-react'
import { Plus, Check, Star } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import { resolveTrackColor } from '../Map/trackColors'
import type { Place, Category } from '../../types'
interface MemoPlaceRowProps {
@@ -81,7 +82,15 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
<PlaceAvatar place={place} category={cat} size={34} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden' }}>
{hasGeometry && <span title="Track / Route" style={{ display: 'inline-flex', flexShrink: 0 }}><Route size={11} strokeWidth={2} color="var(--text-faint)" /></span>}
{/* A stroke of the colour the track is drawn in — the map has no legend
of its own, so this is what tells you which line is this row (#776).
A line rather than another 11px icon, so it doesn't read as a second
category glyph next to the one below. */}
{hasGeometry && (
<span title={t('places.trackIndicator')} style={{ display: 'inline-flex', flexShrink: 0 }}>
<span style={{ display: 'block', width: 14, height: 3, borderRadius: 999, background: resolveTrackColor(place) }} />
</span>
)}
{cat && (() => {
const CatIcon = getCategoryIcon(cat.icon)
return <span title={cat.name} style={{ display: 'inline-flex', flexShrink: 0 }}><CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} /></span>
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import { fireEvent } from '@testing-library/react'
import { resetAllStores } from '../../../tests/helpers/store'
import TrackColorPicker from './TrackColorPicker'
afterEach(() => {
vi.clearAllMocks()
resetAllStores()
})
function setup(props: Partial<React.ComponentProps<typeof TrackColorPicker>> = {}) {
const onChange = vi.fn()
render(<TrackColorPicker value={null} inheritedColor="#16a34a" onChange={onChange} {...props} />)
return { onChange }
}
describe('TrackColorPicker (#776)', () => {
it('FE-COMP-TRACKCOLOR-001: a swatch commits its colour', () => {
const { onChange } = setup()
fireEvent.click(screen.getByRole('button', { name: '#059669' }))
expect(onChange).toHaveBeenCalledWith('#059669')
})
it('FE-COMP-TRACKCOLOR-002: the auto cell commits null, not undefined', () => {
const { onChange } = setup({ value: '#059669' })
fireEvent.click(screen.getByRole('button', { name: 'Automatic color' }))
expect(onChange).toHaveBeenCalledWith(null)
})
it('FE-COMP-TRACKCOLOR-003: the auto cell previews what clearing would give, not the current colour', () => {
setup({ value: '#e11d48' })
const auto = screen.getByRole('button', { name: 'Automatic color' })
// jsdom rewrites the hex to rgb(). #16a34a is the inherited colour; showing
// the picked #e11d48 here would promise the opposite of what the button does.
expect(auto.getAttribute('style')).toContain('rgb(22, 163, 74)')
expect(auto.getAttribute('style')).not.toContain('rgb(225, 29, 72)')
})
it('FE-COMP-TRACKCOLOR-004: dragging inside the native picker does not commit', () => {
const { onChange } = setup()
const input = document.querySelector('input[type="color"]') as HTMLInputElement
expect(input).toBeTruthy()
// React maps onChange on a colour input to the native `input` event, which
// fires on every drag — each one would be a PUT and a 409 candidate.
input.value = '#123456'
fireEvent.input(input)
expect(onChange).not.toHaveBeenCalled()
// The native `change` fires once, when the picker closes.
fireEvent.change(input)
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith('#123456')
})
})
@@ -0,0 +1,104 @@
import { useEffect, useRef } from 'react'
import { Pipette, RotateCcw } from 'lucide-react'
import { TRACK_COLORS } from '@trek/shared'
import { useTranslation } from '../../i18n'
interface TrackColorPickerProps {
/** The picked colour, or null while the track still inherits one. */
value: string | null
/** What the track is drawn in when nothing is picked — shown in the auto cell. */
inheritedColor: string
onChange: (color: string | null) => void
}
/**
* Swatch row for a GPX track's line colour (#776).
*
* First cell resets to the inherited colour, then the shared palette, then a
* native picker for anything else. Selection is drawn with `--accent` so the
* row follows whatever accent the user runs rather than pinning one hue.
*/
export default function TrackColorPicker({ value, inheritedColor, onChange }: TrackColorPickerProps) {
const { t } = useTranslation()
const customInputRef = useRef<HTMLInputElement>(null)
const isPreset = value !== null && (TRACK_COLORS as readonly string[]).includes(value)
const selected = 'outline outline-2 outline-[var(--accent)] outline-offset-2 scale-110'
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
// Native `change`, deliberately not React's onChange — see the input below.
useEffect(() => {
const input = customInputRef.current
if (!input) return
const commit = () => onChangeRef.current(input.value)
input.addEventListener('change', commit)
return () => input.removeEventListener('change', commit)
}, [])
return (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => onChange(null)}
aria-pressed={value === null}
aria-label={t('inspector.trackColorAuto')}
title={t('inspector.trackColorAuto')}
className={`w-7 h-7 rounded-full grid place-items-center border-2 border-dashed transition-transform duration-150 hover:scale-110 motion-reduce:hover:scale-100 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-[var(--accent)] ${
value === null ? `border-solid border-transparent ${selected}` : 'border-edge-secondary hover:border-edge'
}`}
// A tint of the inherited colour so it is obvious what "auto" would give you.
style={{ backgroundColor: `color-mix(in srgb, ${inheritedColor} 25%, transparent)` }}
>
<RotateCcw size={12} className="text-content-muted" />
</button>
{TRACK_COLORS.map(color => (
<button
key={color}
type="button"
onClick={() => onChange(color)}
aria-pressed={value === color}
aria-label={color}
title={color}
className={`w-7 h-7 rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15 transition-transform duration-150 hover:scale-110 motion-reduce:hover:scale-100 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-[var(--accent)] ${
value === color ? selected : ''
}`}
style={{ backgroundColor: color }}
/>
))}
{/* No React onChange here: for a colour input React maps it to the native
`input` event, which fires on every drag inside the OS picker — dozens
of PUTs, each one a 409 candidate against the base-version header. The
native `change` event fires once, when the picker closes. */}
<input
ref={customInputRef}
type="color"
defaultValue={value ?? inheritedColor}
className="sr-only"
tabIndex={-1}
aria-hidden="true"
/>
<button
type="button"
onClick={() => {
const input = customInputRef.current
if (!input) return
// Uncontrolled input, so seed it with the current colour before opening
// — otherwise the OS picker starts from whatever it was last mounted with.
input.value = value ?? inheritedColor
input.click()
}}
aria-label={t('inspector.trackColorCustom')}
title={t('inspector.trackColorCustom')}
className={`w-7 h-7 rounded-full grid place-items-center border-2 transition-transform duration-150 hover:scale-110 motion-reduce:hover:scale-100 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-[var(--accent)] ${
value !== null && !isPreset ? `border-transparent ${selected}` : 'border-dashed border-edge-secondary hover:border-edge'
}`}
style={value !== null && !isPreset ? { backgroundColor: value } : undefined}
>
{(value === null || isPreset) && <Pipette size={12} className="text-content-muted" />}
</button>
</div>
)
}
@@ -565,6 +565,9 @@ export function useTripPlanner() {
address: capturedPlace.address,
category_id: capturedPlace.category_id,
price: capturedPlace.price,
// An undone track has to come back as a track, not a bare point.
route_geometry: capturedPlace.route_geometry,
route_color: capturedPlace.route_color,
})
for (const { dayId, orderIndex } of capturedAssignments) {
await tripActions.assignPlaceToDay(tripId, dayId, newPlace.id, orderIndex)
@@ -595,6 +598,7 @@ export function useTripPlanner() {
name: place.name, description: place.description,
lat: place.lat, lng: place.lng, address: place.address,
category_id: place.category_id, price: place.price,
route_geometry: place.route_geometry, route_color: place.route_color,
})
for (const a of capturedAssignments.filter(x => x.placeId === place.id)) {
await tripActions.assignPlaceToDay(tripId, a.dayId, newPlace.id, a.orderIndex)