From 900e4806ad8bbe5cedb70539225aeaa985e476d6 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 8 Sep 2026 17:16:48 +0200 Subject: [PATCH] fix(places): centre the empty states, and give the details column one too Five states, one shape. EmptyState grows a `fill` option: the stack already centres what it holds, but with no height of its own there was nothing to centre inside, so every one of them sat pinned under the header of its column. Optically centred rather than geometrically. The extra room goes underneath, which puts the mascot slightly above the middle: in a column you can scroll, dead centre reads as having fallen to the bottom the moment the list is short. The details column's own placeholder joins them, with the idle look rather than the sad one, because nothing being picked yet is a state and not a fault. Its line is shortened to fit on one: "Pick a result to see more", in all 23 languages. The corridor panel, the saved-places list and the place details all dropped `compact`, so the five now read at the same size as the road trip rail. --- .../Collections/CollectionPicker.tsx | 2 +- .../components/Planner/PlaceDetailsColumn.tsx | 14 +++++++-- .../components/Planner/PlacesSidebarList.tsx | 29 ++++++++++++++----- .../Roadtrip/RoadtripCorridorPanel.tsx | 2 +- .../components/Roadtrip/RoadtripSidebar.tsx | 5 +++- client/src/components/shared/EmptyState.tsx | 18 +++++++++++- shared/src/i18n/ar/places.ts | 2 +- shared/src/i18n/br/places.ts | 2 +- shared/src/i18n/ca/places.ts | 2 +- shared/src/i18n/cs/places.ts | 2 +- shared/src/i18n/de/places.ts | 2 +- shared/src/i18n/en/places.ts | 2 +- shared/src/i18n/es/places.ts | 2 +- shared/src/i18n/fr/places.ts | 2 +- shared/src/i18n/gr/places.ts | 2 +- shared/src/i18n/hu/places.ts | 2 +- shared/src/i18n/id/places.ts | 2 +- shared/src/i18n/it/places.ts | 2 +- shared/src/i18n/ja/places.ts | 2 +- shared/src/i18n/ko/places.ts | 2 +- shared/src/i18n/nl/places.ts | 2 +- shared/src/i18n/pl/places.ts | 2 +- shared/src/i18n/ru/places.ts | 2 +- shared/src/i18n/sv/places.ts | 2 +- shared/src/i18n/tr/places.ts | 2 +- shared/src/i18n/uk/places.ts | 2 +- shared/src/i18n/vi/places.ts | 2 +- shared/src/i18n/zh-TW/places.ts | 2 +- shared/src/i18n/zh/places.ts | 2 +- 29 files changed, 79 insertions(+), 37 deletions(-) diff --git a/client/src/components/Collections/CollectionPicker.tsx b/client/src/components/Collections/CollectionPicker.tsx index bf2a5d81d..1b51e9490 100644 --- a/client/src/components/Collections/CollectionPicker.tsx +++ b/client/src/components/Collections/CollectionPicker.tsx @@ -215,8 +215,8 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker scene="search" title={t('collections.picker.empty')} size={84} + fill surface="var(--bg-secondary)" - compact /> ) : (
diff --git a/client/src/components/Planner/PlaceDetailsColumn.tsx b/client/src/components/Planner/PlaceDetailsColumn.tsx index db9db194f..c992a98ce 100644 --- a/client/src/components/Planner/PlaceDetailsColumn.tsx +++ b/client/src/components/Planner/PlaceDetailsColumn.tsx @@ -205,7 +205,17 @@ export default function PlaceDetailsColumn({
- {!selection &&

{t('places.details.empty')}

} + {/* Nothing picked yet, which is a state rather than a fault: the mascot + waits with an idle look instead of a sad one. */} + {!selection && ( + + )} {selection && state === 'loading' && (
@@ -231,8 +241,8 @@ export default function PlaceDetailsColumn({ mood="sad" title={t('places.details.nothing')} size={92} + fill surface="var(--bg-secondary)" - compact /> )} diff --git a/client/src/components/Planner/PlacesSidebarList.tsx b/client/src/components/Planner/PlacesSidebarList.tsx index b5acc4d07..1c0f60799 100644 --- a/client/src/components/Planner/PlacesSidebarList.tsx +++ b/client/src/components/Planner/PlacesSidebarList.tsx @@ -1,4 +1,5 @@ import { Fragment } from 'react' +import EmptyState from '../shared/EmptyState' import { MemoPlaceRow } from './PlacesSidebarRow' import type { SidebarState } from './usePlacesSidebar' import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions' @@ -14,14 +15,26 @@ export function PlacesList(S: SidebarState) { return (
onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}> {filtered.length === 0 ? ( -
- - {filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')} - - {canEditPlaces && } -
+ /* The mascot and one line, the shape every other empty state in TREK has. + The add link stays as the state's action: an empty list of places is + one of the few that has an obvious next step. */ + + {t('places.addPlace')} + + ) : undefined} + /> ) : ( filtered.map(place => { const cat = categories.find(c => c.id === place.category_id) diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index 10428dcbd..b4dd2ac26 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -509,7 +509,7 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro scene={search.loading ? 'search' : 'idle'} mood={filteredToNothing ? 'confused' : undefined} size={88} - compact + fill surface="var(--bg-secondary)" title={ search.loading diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index cc748c624..c68e90026 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -1044,8 +1044,11 @@ export default function RoadtripSidebar({ // Nothing to total up, so nothing pretends to: no "0 km" standing above "No route yet". if (routes.days.length === 0) { + // Centred in the rail rather than sitting at its top: the state is about the + // whole column being empty, and a mascot pinned under the header reads as a + // header decoration. return ( -
+
{/* The mascot rather than a pin and two lines of instructions. The rail is empty because the trip has no days with places yet, which is a state, not a task list: the same look every other empty state in TREK diff --git a/client/src/components/shared/EmptyState.tsx b/client/src/components/shared/EmptyState.tsx index 89745ac79..c48112937 100644 --- a/client/src/components/shared/EmptyState.tsx +++ b/client/src/components/shared/EmptyState.tsx @@ -23,6 +23,7 @@ export default function EmptyState({ surface = 'var(--bg-card)', layout = 'stack', compact = false, + fill = false, className = '', action, }: { @@ -40,13 +41,28 @@ export default function EmptyState({ * about why the list below is blank. */ compact?: boolean + /** + * Fill the parent and sit in the middle of it. + * + * The stack already centres what it contains, but with no height of its own + * `justify-center` has nothing to centre inside, so the state lands under + * whatever sits above it. A state that stands for a whole empty column wants + * the middle of that column; one that sits inline in a page does not, which is + * why this is a choice rather than the default. The parent has to be the one + * with the height, which every scroll container here already is. + * + * Optically centred rather than geometrically: the extra room goes underneath, + * which lands the mascot slightly above the middle. In a column you can scroll, + * dead centre reads as "fell to the bottom" the moment the list is short. + */ + fill?: boolean className?: string /** Optional call to action under the title, for states that have an obvious next step. */ action?: ReactNode }) { const layoutClasses = layout === 'row' ? 'flex flex-row items-center justify-center gap-3 px-6 py-3' - : 'flex flex-col items-center justify-center gap-3 px-6 py-12 text-center' + : `flex flex-col items-center justify-center gap-3 px-6 text-center ${fill ? 'min-h-full pt-4 pb-14' : 'py-12'}` return (