feat(mobile): new trip sheet and shared pickers in the sheets' style

New trip: the icon tile header, sections with labels above round fields
instead of floating labels in boxes, the round date fields, the shared
footer buttons, and no box within a box for the reminder and the cover
search.

The shared select, date, time and airport pickers style themselves inline
for the desktop and looked out of place in phone sheets, white and
squarer than the fields beside them. Inside the phone UI they now take the
sheets' 42 px round field on the field surface; the desktop is unchanged.
This commit is contained in:
Maurice
2026-11-06 16:28:06 +01:00
parent 2aa25e3fe7
commit 92fc8ef28f
7 changed files with 121 additions and 109 deletions
@@ -44,6 +44,7 @@ export function SuggestionInputBox({
}: SuggestionInputBoxProps) {
return (
<div
data-field-trigger={floating ? undefined : 'pad'}
className={floating ? 'focus-within:ring-2 focus-within:ring-accent' : 'bg-surface-tertiary'}
style={
floating
@@ -298,6 +298,7 @@ export function CustomDatePicker({
<div style={{ display: 'flex', gap: 4, alignItems: 'stretch' }}>
{/* Calendar trigger */}
<button
data-field-trigger={compact || borderless ? undefined : ''}
type="button"
onClick={() => setOpen((o) => !o)}
disabled={disabled}
@@ -345,6 +346,7 @@ export function CustomDatePicker({
{/* Keyboard / text-input trigger (non-compact only; compact gets it in the popup footer) */}
{!compact && !borderless && (
<button
data-field-trigger=""
type="button"
onClick={() => {
setTextInput(inputValue || '');
@@ -186,6 +186,7 @@ export default function CustomSelect({
<button
ref={triggerRef}
id={id}
data-field-trigger=""
type="button"
aria-label={ariaLabel}
aria-expanded={open}
@@ -129,7 +129,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
return (
<div ref={ref} style={{ position: 'relative', ...style }}>
<div style={{
<div data-field-trigger="" style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 0,
borderRadius: 10, border: '1px solid var(--border-primary)',
background: 'var(--bg-input)', overflow: 'hidden',
+14
View File
@@ -485,3 +485,17 @@
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 14px, black calc(100% - 14px), transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0, black 14px, black calc(100% - 14px), transparent 100%);
}
/* The shared pickers (select, date, time) inside the phone UI look like the
sheets' own fields: a 42 px round field on the field surface. They style
themselves inline for the desktop, hence the !important. */
.m-root [data-field-trigger] {
min-height: 42px;
border-radius: 9999px !important;
border: 1px solid var(--m-rowbr) !important;
background: var(--m-ic) !important;
}
.m-root button[data-field-trigger],
.m-root [data-field-trigger='pad'] {
padding-inline: 14px !important;
}
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'
import { Archive, ArchiveRestore, Bell, Camera, Search, X } from 'lucide-react'
import { Archive, ArchiveRestore, Bell, Camera, Luggage, Search, X } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import { useCanDo } from '../../../store/permissionsStore'
import { useSettingsStore } from '../../../store/settingsStore'
@@ -11,8 +11,8 @@ import { currenciesWith, SYMBOLS } from '../../../components/Budget/BudgetPanel.
import type { DashboardTrip } from '../../../pages/dashboard/dashboardModel'
import { MAX_TRIP_DAYS, type Trip, type TripCreateRequest } from '@trek/shared'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MListRow from '../../components/MListRow'
import { FIELD_AREA_CLS } from '../trip/sheets/PlSheetChrome'
import { Eyebrow, SHEET_FIELD, SHEET_PRIMARY, SHEET_ROUND_PRIMARY, SHEET_SECONDARY, SheetSection, TileHeader } from '../trip/sheets/MTripSheetUi'
import MDayImpactList from '../../components/MDayImpactList'
import MConfirmSheet from '../settings/MConfirmSheet'
import { useTripRangeGuard, type RangeRemoval } from '../../../hooks/useTripRangeGuard'
@@ -31,17 +31,12 @@ interface MNewTripSheetProps {
onArchive?: () => void
}
const BOX_CLS = 'rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px_12px]'
const INPUT_CLS = 'w-full border-none bg-transparent pt-[2px] font-[inherit] text-[0.9375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint'
/** A date picker in the sheets' round field. */
const DATE_FIELD = 'flex h-[42px] items-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px]'
/** The reminder presets the desktop form offers; anything else is a custom number of days. */
const REMINDER_PRESETS = [0, 1, 3, 9]
function FieldLabel({ children }: { children: React.ReactNode }): React.ReactElement {
return (
<div className="font-geist text-[0.5625rem] font-bold uppercase tracking-[.1em] text-m-faint">{children}</div>
)
}
/**
* Create/edit trip sheet — the mobile counterpart of TripFormModal's core flow:
@@ -151,79 +146,78 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
}
}
const boxCls = BOX_CLS
const inputCls = INPUT_CLS
const removalList = pendingRemoval && pendingRemoval.removal !== 'unknown' ? pendingRemoval.removal : null
return (
<>
<MSheet open={open} onClose={onClose} variant="card" material="opaque" ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
<div className="flex items-center gap-[11px] p-[16px_16px_0]">
<div className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold">
{isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
</div>
<MIconBtn ariaLabel={t('common.cancel')} variant="neutral" size={34} onClick={onClose}>
<X size={16} strokeWidth={2.2} />
</MIconBtn>
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
<div className="flex-none px-[18px] pt-4">
<TileHeader
icon={<Luggage size={19} strokeWidth={1.8} />}
title={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
sub={isEditing ? trip?.title : undefined}
onClose={onClose}
closeLabel={t('common.cancel')}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<div className="m-scroll-fade mt-2 flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
{error && (
<div className="mb-3 rounded-[14px] bg-[color:var(--m-ic)] p-[11px_12px] text-[0.75rem] font-semibold text-[color:var(--m-st-danger)]">
<div className="flex-none rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px] text-caption font-semibold text-[color:var(--m-st-danger)]">
{error}
</div>
)}
<div className={boxCls}>
<FieldLabel>{t('dashboard.tripTitle')}</FieldLabel>
<SheetSection title={t('dashboard.tripTitle')}>
<input
value={title}
onChange={e => { if (canEditTrip) { setError(''); setTitle(e.target.value) } }}
readOnly={!canEditTrip}
placeholder={t('dashboard.tripTitlePlaceholder')}
className={inputCls}
aria-label={t('dashboard.tripTitle')}
className={`${SHEET_FIELD} w-full flex-none font-semibold`}
/>
</div>
<div className={`${boxCls} mt-2`}>
<FieldLabel>{t('dashboard.tripDescription')}</FieldLabel>
<Eyebrow className="mt-1 uppercase">{t('dashboard.tripDescription')}</Eyebrow>
<textarea
value={description}
onChange={e => { if (canEditTrip) { setError(''); setDescription(e.target.value) } }}
readOnly={!canEditTrip}
placeholder={t('dashboard.tripDescriptionPlaceholder')}
aria-label={t('dashboard.tripDescription')}
rows={2}
className={`${inputCls} resize-none text-[0.8125rem] font-medium`}
className={FIELD_AREA_CLS}
/>
</div>
</SheetSection>
<div className="mt-2 flex gap-2">
<div className={`${boxCls} min-w-0 flex-1`}>
<FieldLabel>{t('dashboard.startDate')}</FieldLabel>
<CustomDatePicker
value={startDate}
onChange={v => { if (canEditTrip) changeStart(v) }}
placeholder={t('dashboard.startDate')}
borderless
style={{ marginTop: 3 }}
/>
<SheetSection>
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('dashboard.startDate')}</Eyebrow>
<div className={DATE_FIELD}>
<CustomDatePicker
value={startDate}
onChange={v => { if (canEditTrip) changeStart(v) }}
placeholder={t('dashboard.startDate')}
borderless
/>
</div>
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('dashboard.endDate')}</Eyebrow>
<div className={DATE_FIELD}>
<CustomDatePicker
value={endDate}
onChange={v => { if (canEditTrip) { setError(''); setEndDate(v) } }}
placeholder={t('dashboard.endDate')}
borderless
/>
</div>
</div>
</div>
<div className={`${boxCls} min-w-0 flex-1`}>
<FieldLabel>{t('dashboard.endDate')}</FieldLabel>
<CustomDatePicker
value={endDate}
onChange={v => { if (canEditTrip) { setError(''); setEndDate(v) } }}
placeholder={t('dashboard.endDate')}
borderless
style={{ marginTop: 3 }}
/>
</div>
</div>
{!startDate && !endDate && (
<>
<div className={`${boxCls} mt-2`}>
<FieldLabel>{t('dashboard.dayCount')}</FieldLabel>
{!startDate && !endDate && (
<>
<Eyebrow className="mt-1 uppercase">{t('dashboard.dayCount')}</Eyebrow>
<input
type="number"
inputMode="numeric"
@@ -237,15 +231,15 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
setDayCount(raw === '' ? '' : Math.min(MAX_TRIP_DAYS, Math.max(1, Math.floor(Number(raw)) || 1)))
}}
readOnly={!canEditTrip}
className={inputCls}
aria-label={t('dashboard.dayCount')}
className={`${SHEET_FIELD} w-full flex-none`}
/>
</div>
{!isEditing && <div className="mt-[6px] px-1 font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-m-faint">{t('dashboard.noDateHint')}</div>}
</>
)}
{!isEditing && <p className="px-1 font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-m-faint">{t('dashboard.noDateHint')}</p>}
</>
)}
</SheetSection>
<div className="mt-2">
<FieldLabel>{t('dashboard.currency')}</FieldLabel>
<SheetSection title={t('dashboard.currency')}>
<CustomSelect
value={currency}
onChange={v => { if (canEditTrip) { setError(''); setCurrency(String(v)) } }}
@@ -253,48 +247,46 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
searchable
size="sm"
options={currenciesWith(currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
style={{ width: '100%', marginTop: 5 }}
style={{ width: '100%' }}
/>
</div>
</SheetSection>
{showReminder && (
<ReminderField
enabled={remindersEnabled}
days={reminderDays}
custom={customReminder}
onPreset={days => { setError(''); setReminderDays(days); setCustomReminder(false) }}
onCustom={() => { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}
onDays={setReminderDays}
/>
<SheetSection>
<ReminderField
enabled={remindersEnabled}
days={reminderDays}
custom={customReminder}
onPreset={days => { setError(''); setReminderDays(days); setCustomReminder(false) }}
onCustom={() => { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}
onDays={setReminderDays}
/>
</SheetSection>
)}
{canUploadCover && <CoverField cover={cover} title={title} />}
{canUploadCover && (
<SheetSection>
<CoverField cover={cover} title={title} />
</SheetSection>
)}
{isEditing && onArchive && (
<div className="mt-2 rounded-[14px] bg-[color:var(--m-ic)]">
<MListRow
icon={trip?.is_archived ? ArchiveRestore : Archive}
label={trip?.is_archived ? t('dashboard.restore') : t('dashboard.archive')}
onClick={() => { onArchive(); onClose() }}
/>
</div>
<button
type="button"
onClick={() => { onArchive(); onClose() }}
className={`${SHEET_SECONDARY} flex-none`}
>
{trip?.is_archived ? <ArchiveRestore size={14} strokeWidth={2.2} /> : <Archive size={14} strokeWidth={2.2} />}
{trip?.is_archived ? t('dashboard.restore') : t('dashboard.archive')}
</button>
)}
</div>
<div className="flex gap-2 p-[0_16px_16px]">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-full bg-[color:var(--m-ic)] py-[10px] text-[0.8125rem] font-semibold text-m-ink"
>
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
<button type="button" onClick={onClose} className={`${SHEET_SECONDARY} ms-auto`}>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSave}
disabled={busy}
className="flex-1 rounded-full bg-m-act py-[10px] text-[0.8125rem] font-semibold text-m-actfg disabled:opacity-50"
>
<button type="button" onClick={handleSave} disabled={busy} className={SHEET_PRIMARY}>
{busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
</button>
</div>
@@ -340,10 +332,10 @@ function ReminderField({ enabled, days, custom, onPreset, onCustom, onDays }: {
}): React.ReactElement {
const { t } = useTranslation()
return (
<div className={`${BOX_CLS} mt-2`}>
<FieldLabel>
<div>
<Eyebrow className="uppercase">
<span className="inline-flex items-center gap-1"><Bell size={10} strokeWidth={2.4} />{t('trips.reminder')}</span>
</FieldLabel>
</Eyebrow>
{!enabled ? (
<p className="mt-1 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] leading-snug text-m-muted">{t('trips.reminderDisabledHint')}</p>
) : (
@@ -429,21 +421,20 @@ function CoverField({ cover, title }: { cover: ReturnType<typeof useTripCoverPic
)}
<div className="mt-2 flex gap-2">
<div className={`${BOX_CLS} min-w-0 flex-1 py-[9px]`}>
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void cover.search() } }}
placeholder={t('dashboard.unsplashSearchPlaceholder')}
className={`${INPUT_CLS} pt-0 text-[0.8125rem] font-medium`}
/>
</div>
<input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void cover.search() } }}
placeholder={t('dashboard.unsplashSearchPlaceholder')}
aria-label={t('dashboard.unsplashSearchPlaceholder')}
className={SHEET_FIELD}
/>
<button
type="button"
onClick={cover.search}
disabled={searching || (!searchQuery.trim() && !title.trim())}
aria-label={t('dashboard.searchUnsplash')}
className="flex h-auto w-[42px] flex-none items-center justify-center rounded-[14px] bg-m-act text-m-actfg disabled:opacity-50"
className={SHEET_ROUND_PRIMARY}
>
<Search size={15} strokeWidth={2.2} />
</button>
@@ -43,7 +43,8 @@ export function SheetSection({
hint,
children,
}: {
title: string
/** Left out where each field below carries its own label. */
title?: string
/** How many there are, as a small badge next to the title rather than glued onto it. */
count?: number
hint?: string
@@ -51,6 +52,7 @@ export function SheetSection({
}) {
return (
<section className={`rounded-[18px] p-3 ${INNER_CLS}`}>
{title && (
<div className="flex items-center gap-1.5">
<Eyebrow className="uppercase">{title}</Eyebrow>
{count != null && (
@@ -59,12 +61,13 @@ export function SheetSection({
</span>
)}
</div>
)}
{hint && (
<p className="mt-1 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] leading-snug text-m-muted">
{hint}
</p>
)}
<div className="mt-2.5 flex flex-col gap-2">{children}</div>
<div className={`${title || hint ? 'mt-2.5 ' : ''}flex flex-col gap-2`}>{children}</div>
</section>
)
}