fix(client): use TREK's own controls for tooltips and journey dates

Three small things noticed while working nearby, none of them Dawarich:

The map's layer switcher showed the browser's own tooltip while every
control beside it showed TREK's, so hovering along the row produced two
different boxes in two different styles.

The journey entry editor mixed TREK's date picker with a native time
input, and the date, which is the longer of the two, had the narrower
column: "12. Sept. 2026" wrapped while the clock sat half empty. The row
is split in the date's favour now and the picker is disabled together
with the rest of the form instead of staying live behind it.

On the phone's journey, the dock's "+" now turns into an upload button
while the Gallery tab is open, which is what that tab's one big action
actually is.
This commit is contained in:
Maurice
2026-09-12 23:35:30 +02:00
parent 218d8baf0a
commit 5678eed55c
7 changed files with 101 additions and 44 deletions
@@ -606,13 +606,17 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{/* The date needs the room, not the clock: a long localized date
("12. Sept. 2026") wrapped onto a second line while the time field
sat half empty beside it. The row is split in the date's favour and
the location, which is a free-text search, gives some back. */}
<div className="grid grid-cols-1 sm:grid-cols-[1.3fr_1fr] gap-3">
{/* Time sat in state and went to the server, it just had no input here — so a
draft's auto-stamped clock time showed up in the timeline, the map, the PDF
and the public share, and the desktop had no way to correct it (#1614). */}
{/* 136px, not 104: the custom picker adds a clock button and, in 12h,
shows "2:30 PM" where the native input showed a fixed-width HH:MM. */}
<div className="grid grid-cols-[1fr_136px] gap-2">
{/* 112px: enough for the clock button plus "2:30 PM" in 12h, and no
more — the date column is what needed the space back. */}
<div className="grid grid-cols-[minmax(0,1fr)_112px] gap-2">
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.date')}</label>
<DatePicker value={entryDate} onChange={setEntryDate} tripDates={tripDates} />
+24 -18
View File
@@ -1,5 +1,6 @@
import { Map as MapIcon, Satellite } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
export type BaseLayer = 'default' | 'satellite'
@@ -21,24 +22,29 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}>
<button
type="button"
onClick={onToggle}
aria-label={label}
title={label}
aria-pressed={isSatellite}
className="text-content-muted"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Icon size={17} strokeWidth={2} />
</button>
{/* Same tooltip as its neighbours on the map, for the same reasons — see
TripRouteOverviewPill. To the right, though: this one sits at the
bottom left of the map, where a tooltip to the left would land on the
day sidebar and one below would land off the screen. */}
<Tooltip label={label} placement="right">
<button
type="button"
onClick={onToggle}
aria-label={label}
aria-pressed={isSatellite}
className="text-content-muted"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Icon size={17} strokeWidth={2} />
</button>
</Tooltip>
</div>
)
}
@@ -21,6 +21,8 @@ interface CustomDatePickerProps {
style?: React.CSSProperties;
compact?: boolean;
borderless?: boolean;
/** Read-only contexts (a shared journey, a locked form) — same as CustomTimePicker. */
disabled?: boolean;
// Optional inclusive ISO (YYYY-MM-DD) bounds. Dates outside the range are
// disabled in the calendar and rejected on manual entry.
min?: string;
@@ -34,6 +36,7 @@ export function CustomDatePicker({
style = {},
compact = false,
borderless = false,
disabled = false,
min,
max,
}: CustomDatePickerProps) {
@@ -299,6 +302,7 @@ export function CustomDatePicker({
<button
type="button"
onClick={() => setOpen((o) => !o)}
disabled={disabled}
aria-label={displayValue || placeholder || t('common.date')}
aria-expanded={open}
aria-haspopup="dialog"
@@ -320,7 +324,8 @@ export function CustomDatePicker({
color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontFamily: 'inherit',
cursor: 'pointer',
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.6 : 1,
outline: 'none',
transition: 'border-color 0.15s',
}}
+18 -4
View File
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'
import { useNavigate, useLocation, useMatch } from 'react-router'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { ChevronRight, MoreHorizontal, Plus, Search } from 'lucide-react'
import { useJourneyStore } from '../../store/journeyStore'
import { ChevronRight, MoreHorizontal, Plus, Search, Upload } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { normalizeAppearance } from '@trek/shared'
import { useNavItems, splitMobileNav } from '../../components/Layout/navItems'
@@ -18,9 +19,10 @@ interface NavItem { to: string; label: string; icon: LucideIcon }
// everywhere else it creates a new trip. Pages pick the intent up from the
// query params. The result is unused on /vacay: that screen draws its own centre
// FAB, so the dock yields the slot instead (see screenFabSlot below, #1811).
function useCreateAction(): { label: string; run: () => void } {
function useCreateAction(): { label: string; run: () => void; upload?: boolean } {
const navigate = useNavigate()
const { t } = useTranslation()
const galleryOpen = useJourneyStore(state => state.mobileGalleryOpen)
const inTrip = useMatch('/trips/:id')
const inJourney = useMatch('/journey/:id')
const onJourneyList = useMatch('/journey')
@@ -40,7 +42,15 @@ function useCreateAction(): { label: string; run: () => void } {
return { label: t('places.addPlace'), run: () => navigate(`/trips/${id}?create=place`) }
}
if (inJourney) {
return { label: t('journey.detail.addEntry'), run: () => navigate(`/journey/${inJourney.params.id}?create=entry`) }
// Context-aware per tab, like the trip's "+": the Gallery holds photos, so
// there the one big action is uploading one. Read from sessionStorage
// because the tab is the screen's own state and the dock is a sibling —
// exactly how the trip tabs hand theirs over.
const journeyId = inJourney.params.id
if (galleryOpen) {
return { label: t('common.upload'), run: () => navigate(`/journey/${journeyId}?create=photo`), upload: true }
}
return { label: t('journey.detail.addEntry'), run: () => navigate(`/journey/${journeyId}?create=entry`) }
}
if (onJourneyList) {
return { label: t('journey.new'), run: () => navigate('/journey?create=1') }
@@ -174,7 +184,11 @@ export default function MBottomNav() {
<span aria-hidden="true" className="mx-2 h-14 w-14 flex-none" />
) : (
<MFab onClick={create.run} ariaLabel={create.label} className="mx-2">
{searchFab ? <Search size={24} strokeWidth={2.4} /> : <Plus size={26} strokeWidth={2.4} />}
{searchFab
? <Search size={24} strokeWidth={2.4} />
: create.upload
? <Upload size={23} strokeWidth={2.4} />
: <Plus size={26} strokeWidth={2.4} />}
</MFab>
)}
@@ -7,6 +7,7 @@ import MToggle from '../../components/MToggle'
import { useTranslation } from '../../../i18n'
import { useToast } from '../../../components/shared/Toast'
import CustomTimePicker from '../../../components/shared/CustomTimePicker'
import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'
import { journeyApi, mapsApi, weatherApi } from '../../../api/client'
import { getApiErrorMessage } from '../../../types'
import { normalizeImageFiles } from '../../../utils/convertHeic'
@@ -721,21 +722,17 @@ export default function MJourneyEntrySheet({
)}
</>}
{/* Date + Time */}
<div className="mt-3 flex gap-2">
<div className="min-w-0 flex-1">
{/* Date + Time. Split in the date's favour: a localized date needs the
room ("10. Sept. 2026"), a clock never does. */}
<div className="mt-3 grid grid-cols-[minmax(0,1fr)_104px] gap-2">
<div className="min-w-0">
<div className={`${eyebrow} mb-[5px]`}>{t('journey.editor.date')}</div>
<div className={`${fieldShell} overflow-hidden`}>
<input
type="date"
value={entryDate}
disabled={readOnly}
onChange={e => setEntryDate(e.target.value)}
className="block min-w-0 w-full box-border border-0 bg-transparent px-3 py-[10px] text-center text-[0.78125rem] font-semibold text-m-ink outline-none [font-variant-numeric:tabular-nums]"
/>
</div>
{/* TREK's own picker, like the time field beside it: a native
`<input type="date">` paints itself from the OS locale and takes
no theme, so the two sat in one row looking like two apps. */}
<CustomDatePicker value={entryDate} onChange={setEntryDate} disabled={readOnly} />
</div>
<div className="min-w-0 flex-1">
<div className="min-w-0">
<div className={`${eyebrow} mb-[5px]`}>{t('mobileJourney.time')}</div>
{/* A native <input type="time"> paints 12h or 24h from the browser
locale, whatever the user picked in settings (#2067). The picker
+11
View File
@@ -131,6 +131,15 @@ interface JourneyState {
current: JourneyDetail | null
loading: boolean
notFound: boolean
/**
* The phone's journey screen is showing its Gallery tab.
*
* Lives here because the dock's FAB is a sibling of that screen: on the
* Gallery the one big action is uploading a photo, not adding an entry, and a
* sibling cannot read another component's state any other way.
*/
mobileGalleryOpen: boolean
setMobileGalleryOpen: (open: boolean) => void
loadJourneys: () => Promise<void>
loadJourney: (id: number) => Promise<void>
@@ -180,6 +189,8 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
current: null,
loading: false,
notFound: false,
mobileGalleryOpen: false,
setMobileGalleryOpen: (open: boolean) => set({ mobileGalleryOpen: open }),
loadJourneys: async () => {
set({ loading: true })
@@ -284,7 +284,20 @@ function mountSheet(sheetEntry: JourneyEntry, opts: MountOptions = {}) {
}
const multiFileInput = () => document.querySelector('input[type="file"][multiple]') as HTMLInputElement;
const dateField = () => document.querySelector('input[type="date"]') as HTMLInputElement;
/**
* The date is TREK's own picker (#2067 follow-up): a trigger button whose
* accessible name is the date it shows, plus a keyboard button that swaps it for
* a DD.MM.YYYY input. `input[type="date"]` is gone — it painted itself from the
* OS locale and took no theme.
*/
const dateTrigger = () =>
document.querySelector('[aria-haspopup="dialog"]') as HTMLButtonElement;
/** What the trigger shows, as the picker formats it: "18 Mar 2026". */
const shownDate = (iso: string) =>
new Date(iso + 'T00:00:00Z').toLocaleDateString('en', {
day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC',
});
// The time field is CustomTimePicker's text input, not a native time input — a
// native one paints 12h/24h from the browser locale and ignored the setting (#2067).
const timeField = () => document.querySelector('input[placeholder="00:00"], input[placeholder="2:30 PM"]') as HTMLInputElement;
@@ -330,7 +343,7 @@ describe('MJourneyEntrySheet full editor', () => {
expect(screen.getByText('Weather')).toBeInTheDocument();
expect(screen.getByText('Tags')).toBeInTheDocument();
// An empty entry_date falls back to today (the LOCAL date), an empty gallery locks the picker.
expect(dateField().value).toBe(localIsoDate());
expect(dateTrigger()).toHaveAccessibleName(shownDate(localIsoDate()));
expect(timeField()).toHaveValue('');
expect(screen.getByRole('button', { name: 'From Gallery' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Upload photos' })).toBeEnabled();
@@ -372,7 +385,14 @@ describe('MJourneyEntrySheet full editor', () => {
await user.type(screen.getByPlaceholderText('Give this moment a name...'), 'Rome');
await user.type(screen.getByPlaceholderText('Write your story...'), 'Great day');
fireEvent.change(dateField(), { target: { value: '2026-03-18' } });
// Through the picker's manual entry — the calendar's own path would depend
// on which month it opens on.
// The keyboard button and the input it opens share a name — they never
// exist at the same time, so the same query reaches first one, then the other.
fireEvent.click(screen.getByLabelText('Enter date manually'));
const manual = screen.getByLabelText('Enter date manually');
fireEvent.change(manual, { target: { value: '18.03.2026' } });
fireEvent.keyDown(manual, { key: 'Enter' });
fireEvent.change(timeField(), { target: { value: '18:45' } });
const [addPro, addCon] = screen.getAllByRole('button', { name: /Add another/ });
@@ -905,7 +925,7 @@ describe('MJourneyEntrySheet read-only', () => {
expect(screen.getByDisplayValue('Gelato')).toHaveAttribute('readonly');
expect(screen.getByDisplayValue('Queues')).toHaveAttribute('readonly');
expect(screen.getByDisplayValue('Rome')).toHaveAttribute('readonly');
expect(dateField()).toBeDisabled();
expect(dateTrigger()).toBeDisabled();
expect(timeField()).toBeDisabled();
expect(screen.getByRole('button', { name: 'Good' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Sunny' })).toBeDisabled();