refactor(roadtrip): the limits as badges, and a quieter empty state

The three figures under the button were one run-on string joined by dots, which
says nothing about which number is which. Each takes the badge shell the rail
already uses, with its own icon: a clock, a calendar, a pump. The icon tile in
front of the title goes, because it was eating exactly the width the three
badges need to sit on one line.

Distances there go through formatDistance, so an imperial traveller reads miles
on the button and types miles in the dialog while storage stays kilometres.

EmptyState gains a compact variant. A sidebar state sits under its own controls,
where a line at page size competes with them; at the caption tier it reads as
what it is, a note about why the list below is blank.
This commit is contained in:
Maurice
2026-09-04 22:55:05 +02:00
committed by Maurice
parent 95c173a8a8
commit 4215db2908
3 changed files with 49 additions and 15 deletions
@@ -348,6 +348,7 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
scene={search.loading ? 'search' : 'idle'}
mood={filteredToNothing ? 'confused' : undefined}
size={88}
compact
surface="var(--bg-secondary)"
title={
search.loading
@@ -79,13 +79,16 @@ export default function RoadtripLimitsCard({ onSave }: {
onSave?.('roadtrip_range_km', Math.round(km))
}
// What is set, on the button itself, so the form does not have to be open to see it.
// Nothing set reads as "off" rather than as an empty string, which would look broken.
const summary = [
legMinutes ? formatDurationShort(legMinutes * 60) : null,
dayMinutes ? formatDurationShort(dayMinutes * 60) : null,
rangeKm ? formatDistance(rangeKm, distanceUnit) : null,
].filter(Boolean).join(' · ')
// What is set, on the button itself, so the form does not have to be open to read it.
// Each one keeps its own icon, because three numbers in a row say nothing about which
// is which: a clock, a calendar and a pump do. Distances go through formatDistance, so
// an imperial traveller reads miles here and types miles in the dialog, while what is
// stored stays kilometres either way.
const badges = [
legMinutes ? { key: 'leg', Icon: Clock, text: formatDurationShort(legMinutes * 60) } : null,
dayMinutes ? { key: 'day', Icon: CalendarClock, text: formatDurationShort(dayMinutes * 60) } : null,
rangeKm ? { key: 'range', Icon: Fuel, text: formatDistance(rangeKm, distanceUnit) } : null,
].filter(Boolean) as { key: string; Icon: typeof Clock; text: string }[]
return (
<>
@@ -98,16 +101,37 @@ export default function RoadtripLimitsCard({ onSave }: {
onClick={() => setOpen(true)}
className="flex w-full items-center gap-3 rounded-xl border border-edge-faint bg-surface-card px-3.5 py-3 text-start transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-surface-tertiary text-content-secondary">
<SlidersHorizontal size={16} aria-hidden />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
{/* No icon tile on the trigger: the three badges below already carry a clock, a
calendar and a pump, and a fourth mark in front of the title only ate the width
they need to sit on one line. */}
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate text-body font-semibold text-content">
{t('roadtrip.limit.title')}
</span>
<span className={`truncate tabular-nums ${summary ? 'text-content-secondary' : 'text-content-faint'}`} style={{ fontSize: FS.label }}>
{summary || t('roadtrip.limit.none')}
</span>
{badges.length ? (
<span className="flex flex-wrap items-center gap-1">
{badges.map(({ key, Icon, text }) => (
<span
key={key}
className="inline-flex h-[16px] items-stretch overflow-hidden rounded border border-edge"
>
<span className="flex items-center bg-surface-tertiary px-1 text-content-faint">
<Icon size={9} aria-hidden />
</span>
<span
className="flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary"
style={{ fontSize: FS.label }}
>
{text}
</span>
</span>
))}
</span>
) : (
<span className="truncate text-content-faint" style={{ fontSize: FS.label }}>
{t('roadtrip.limit.none')}
</span>
)}
</span>
<ChevronRight size={16} className="shrink-0 text-content-faint" aria-hidden />
</button>
+10 -1
View File
@@ -22,6 +22,7 @@ export default function EmptyState({
size = 104,
surface = 'var(--bg-card)',
layout = 'stack',
compact = false,
className = '',
action,
}: {
@@ -31,6 +32,14 @@ export default function EmptyState({
size?: number
surface?: string
layout?: 'stack' | 'row'
/**
* Quieter type for a state inside a narrow column rather than on a page.
*
* The sidebar's empty state sits under its own controls, so a line at the page size
* competes with them for the eye; at the caption tier it reads as what it is, a note
* about why the list below is blank.
*/
compact?: boolean
className?: string
/** Optional call to action under the title, for states that have an obvious next step. */
action?: ReactNode
@@ -44,7 +53,7 @@ export default function EmptyState({
style={{ '--m-ink': 'var(--text-primary)', '--m-bg': surface } as CSSProperties}
>
<MDancingTrek scene={scene} mood={mood} size={size} />
<p className="text-[15px] font-semibold text-content-secondary">{title}</p>
<p className={compact ? 'text-caption text-content-muted' : 'text-[15px] font-semibold text-content-secondary'}>{title}</p>
{action}
</div>
)