fix(mobile): forms on the shared form sheet, and the details the review found

Expense, payment, booking, transport, day note, place edit, new trip,
journey entry, collab note, poll, task, packing item and packing import
use the form sheet: one header with its icon tile, a body that scrolls
with a fade, the footer, and full height with the actions beside the
title on a short window. Create New Trip loses its nested section cards.

Details:
- Add payment gets its icon tile and the currency inside the amount field.
- The expense's category field has the same fill as every other field.
- Day and date fields keep their year at phone width; From and To stack
  in a narrow form.
- The place sheet's category chip sits under the name, uncut, the photo
  on the cards' leading edge, labels in the sheets' small capitals.
- Stars no longer keep a hover preview after a tap.
- A hotel booking shows its stay's check-in and check-out when it has
  none of its own, instead of the grey hint reading as a saved time.
- The collab note's title field is labelled Title (common.title).
- Shorter airport hint in German and Russian; long hints end in an ellipsis.
- The journey story grows with its lines (useAutoGrow).
- Member avatars show the member's initial, yours too, and turn over on
  a selected chip.
- The trip's More sheet scrolls, dims a short window and is marked in
  the dock while open; long titles wrap to two lines.
This commit is contained in:
Maurice
2026-11-07 23:19:51 +01:00
parent b7a91fc84e
commit 18a16d2aa2
59 changed files with 427 additions and 272 deletions
@@ -112,6 +112,46 @@ describe('reservation form fields', () => {
});
});
it('FE-PLANNER-RESFORM-017: a hotel booking without its own times shows the ones its stay keeps', () => {
// The stay's check-in and check-out live on the accommodation; a booking that
// never had them written into its own metadata showed the "15:00" hint as if
// it were the saved time.
const acc = {
id: 9,
place_id: 4,
start_day_id: 2,
end_day_id: 3,
check_in: '15:00',
check_in_end: '22:00',
check_out: '11:00',
} as Accommodation;
const res = buildReservation({
type: 'hotel',
accommodation_id: 9,
metadata: JSON.stringify({ price: '2184' }),
} as Partial<Reservation>);
expect(reservationFieldsFrom(res, [acc], [], false)).toMatchObject({
meta_check_in_time: '15:00',
meta_check_out_time: '11:00',
});
expect(reservationFieldsFrom(res, [acc], [], true)).toMatchObject({ meta_check_in_end_time: '22:00' });
// The booking's own times still win.
const own = buildReservation({
type: 'hotel',
accommodation_id: 9,
metadata: JSON.stringify({ check_in_time: '14:00', check_out_time: '10:00' }),
} as Partial<Reservation>);
expect(reservationFieldsFrom(own, [acc], [], false)).toMatchObject({
meta_check_in_time: '14:00',
meta_check_out_time: '10:00',
});
// Nothing anywhere stays empty, so the hint shows as a hint.
expect(reservationFieldsFrom(buildReservation({ type: 'hotel' }), [], [], false)).toMatchObject({
meta_check_in_time: '',
meta_check_out_time: '',
});
});
it('FE-PLANNER-RESFORM-004: an import under review resolves its stay days and keeps its source details', () => {
const pf = {
title: 'Inn',
@@ -97,9 +97,13 @@ export function reservationFieldsFrom(
url: res.url || '',
place_id: res.place_id || '',
accommodation_id: res.accommodation_id || '',
meta_check_in_time: meta.check_in_time || '',
...(withCheckInEnd ? { meta_check_in_end_time: meta.check_in_end_time || '' } : {}),
meta_check_out_time: meta.check_out_time || '',
// The stay's own times when the booking carries none: a hotel booked before
// the times were written on it, or whose stay was edited from the plan,
// keeps them on the accommodation, and the empty field showed the
// "15:00" hint as if it were the saved check-in.
meta_check_in_time: meta.check_in_time || acc?.check_in || '',
...(withCheckInEnd ? { meta_check_in_end_time: meta.check_in_end_time || acc?.check_in_end || '' } : {}),
meta_check_out_time: meta.check_out_time || acc?.check_out || '',
hotel_place_id: acc?.place_id || '',
hotel_start_day: acc?.start_day_id || '',
hotel_end_day: acc?.end_day_id || '',
@@ -70,13 +70,23 @@ describe('Stars', () => {
const filled = () => Array.from(container.querySelectorAll('span > span')).filter(o => (o as HTMLElement).style.width).length
expect(filled()).toBe(1)
fireEvent.mouseEnter(screen.getByRole('radio', { name: '4' }))
fireEvent.pointerEnter(screen.getByRole('radio', { name: '4' }), { pointerType: 'mouse' })
expect(filled()).toBe(4)
fireEvent.mouseLeave(container.querySelector('[role="radiogroup"]')!)
fireEvent.pointerLeave(container.querySelector('[role="radiogroup"]')!, { pointerType: 'mouse' })
expect(filled()).toBe(1)
})
it('FE-W4STAR-016: a finger previews nothing, so a tap cannot leave the stars filled', () => {
// A tap fires an enter with no leave after it: the preview used to stick,
// and an unrated place showed five full stars beside "Not rated yet".
const { container } = render(<Stars value={0} onRate={() => {}} />)
const filled = () => Array.from(container.querySelectorAll('span > span')).filter(o => (o as HTMLElement).style.width).length
fireEvent.pointerEnter(screen.getByRole('radio', { name: '5' }), { pointerType: 'touch' })
expect(filled()).toBe(0)
})
it('FE-W4STAR-008: focus previews and blur clears the preview', () => {
const { container } = render(<Stars value={0} onRate={() => {}} />)
const filled = () => Array.from(container.querySelectorAll('span > span')).filter(o => (o as HTMLElement).style.width).length
+6 -2
View File
@@ -29,7 +29,7 @@ export function Stars({ value, size = 15, onRate, myRating = null, ariaLabel }:
role={onRate ? 'radiogroup' : undefined}
aria-label={ariaLabel}
style={{ display: 'inline-flex', gap: 2, cursor: onRate ? 'pointer' : 'default' }}
onMouseLeave={() => setHover(null)}
onPointerLeave={() => setHover(null)}
>
{[1, 2, 3, 4, 5].map(i => {
// Fractional fill for averages: full below, empty above, clipped between.
@@ -52,7 +52,11 @@ export function Stars({ value, size = 15, onRate, myRating = null, ariaLabel }:
role="radio"
aria-checked={myRating === i}
aria-label={`${i}`}
onMouseEnter={() => setHover(i)}
// A preview for a pointer that hovers (a mouse, a trackpad, a pen held
// above the screen). A finger has no hover: the enter it fires on a
// tap has no leave after it, so the preview stuck and an unrated
// place showed five full stars beside "Not rated yet".
onPointerEnter={e => { if (e.pointerType !== 'touch') setHover(i) }}
onFocus={() => setHover(i)}
onBlur={() => setHover(null)}
onClick={() => onRate(myRating === i ? null : i)}
@@ -5,14 +5,13 @@ import { useCanDo } from '../../../store/permissionsStore'
import { useSettingsStore } from '../../../store/settingsStore'
import { useAuthStore } from '../../../store/authStore'
import MChip from '../../components/MChip'
import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'
import CustomSelect from '../../../components/shared/CustomSelect'
import { currenciesWith, SYMBOLS } from '../../../components/Budget/BudgetPanel.constants'
import type { DashboardTrip } from '../../../pages/dashboard/dashboardModel'
import { MAX_TRIP_DAYS, type Trip, type TripCreateRequest } from '@trek/shared'
import MSheet from '../../components/MSheet'
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 { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader, SheetDateField } from '../trip/sheets/PlSheetChrome'
import { SHEET_FIELD, SHEET_ROUND_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi'
import MDayImpactList from '../../components/MDayImpactList'
import MConfirmSheet from '../settings/MConfirmSheet'
import { useTripRangeGuard, type RangeRemoval } from '../../../hooks/useTripRangeGuard'
@@ -31,9 +30,6 @@ interface MNewTripSheetProps {
onArchive?: () => void
}
/** 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]
@@ -151,108 +147,100 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
return (
<>
<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>
<MSheet open={open} onClose={onClose} form ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
<FormSheetHeader
icon={Luggage}
title={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
subtitle={isEditing ? trip?.title : undefined}
onClose={onClose}
closeLabel={t('common.cancel')}
/>
<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]">
{/* Flat fields under their labels, as in every other form sheet: no
cards inside the card. */}
<FormSheetBody>
{error && (
<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)]">
<div className="mt-2 rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px] text-caption font-semibold text-[color:var(--m-st-danger)]">
{error}
</div>
)}
<SheetSection title={t('dashboard.tripTitle')}>
<input
value={title}
onChange={e => { if (canEditTrip) { setError(''); setTitle(e.target.value) } }}
readOnly={!canEditTrip}
placeholder={t('dashboard.tripTitlePlaceholder')}
aria-label={t('dashboard.tripTitle')}
className={`${SHEET_FIELD} w-full flex-none font-semibold`}
/>
<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={FIELD_AREA_CLS}
/>
</SheetSection>
<Eyebrow className="mb-[5px] mt-2 uppercase">{t('dashboard.tripTitle')}</Eyebrow>
<input
value={title}
onChange={e => { if (canEditTrip) { setError(''); setTitle(e.target.value) } }}
readOnly={!canEditTrip}
placeholder={t('dashboard.tripTitlePlaceholder')}
aria-label={t('dashboard.tripTitle')}
className={`${FIELD_CLS} font-semibold`}
/>
<Eyebrow className="mb-[5px] mt-3 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={FIELD_AREA_CLS}
/>
<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>
{/* Side by side. A placeholder longer than its field ("Дата окончания")
ends in an ellipsis inside it rather than running past its edge. */}
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="min-w-0">
<Eyebrow className="mb-[5px] truncate uppercase">{t('dashboard.startDate')}</Eyebrow>
<SheetDateField
value={startDate}
onChange={v => { if (canEditTrip) changeStart(v) }}
placeholder={t('dashboard.startDate')}
/>
</div>
{!startDate && !endDate && (
<>
<Eyebrow className="mt-1 uppercase">{t('dashboard.dayCount')}</Eyebrow>
<input
type="number"
inputMode="numeric"
min={1}
max={MAX_TRIP_DAYS}
value={dayCount}
onChange={e => {
if (!canEditTrip) return
setError('')
const raw = e.target.value
setDayCount(raw === '' ? '' : Math.min(MAX_TRIP_DAYS, Math.max(1, Math.floor(Number(raw)) || 1)))
}}
readOnly={!canEditTrip}
aria-label={t('dashboard.dayCount')}
className={`${SHEET_FIELD} w-full flex-none`}
/>
{!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="min-w-0">
<Eyebrow className="mb-[5px] truncate uppercase">{t('dashboard.endDate')}</Eyebrow>
<SheetDateField
value={endDate}
onChange={v => { if (canEditTrip) { setError(''); setEndDate(v) } }}
placeholder={t('dashboard.endDate')}
/>
</div>
</div>
{!startDate && !endDate && (
<>
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('dashboard.dayCount')}</Eyebrow>
<input
type="number"
inputMode="numeric"
min={1}
max={MAX_TRIP_DAYS}
value={dayCount}
onChange={e => {
if (!canEditTrip) return
setError('')
const raw = e.target.value
setDayCount(raw === '' ? '' : Math.min(MAX_TRIP_DAYS, Math.max(1, Math.floor(Number(raw)) || 1)))
}}
readOnly={!canEditTrip}
aria-label={t('dashboard.dayCount')}
className={FIELD_CLS}
/>
{!isEditing && <p className="mt-[6px] px-1 font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-m-faint">{t('dashboard.noDateHint')}</p>}
</>
)}
<SheetSection title={t('dashboard.currency')}>
<CustomSelect
value={currency}
onChange={v => { if (canEditTrip) { setError(''); setCurrency(String(v)) } }}
disabled={!canEditTrip}
searchable
size="sm"
options={currenciesWith(currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
style={{ width: '100%' }}
/>
</SheetSection>
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('dashboard.currency')}</Eyebrow>
<CustomSelect
value={currency}
onChange={v => { if (canEditTrip) { setError(''); setCurrency(String(v)) } }}
disabled={!canEditTrip}
searchable
size="sm"
options={currenciesWith(currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
style={{ width: '100%' }}
/>
{showReminder && (
<SheetSection>
<div className="mt-3">
<ReminderField
enabled={remindersEnabled}
days={reminderDays}
@@ -261,35 +249,35 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
onCustom={() => { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}
onDays={setReminderDays}
/>
</SheetSection>
</div>
)}
{canUploadCover && (
<SheetSection>
<>
<Eyebrow className="mt-3 uppercase">{t('dashboard.coverImage')}</Eyebrow>
<CoverField cover={cover} title={title} />
</SheetSection>
</>
)}
{isEditing && onArchive && (
<button
type="button"
onClick={() => { onArchive(); onClose() }}
className={`${SHEET_SECONDARY} flex-none`}
className={`${SHEET_SECONDARY} mt-4 w-full`}
>
{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>
</FormSheetBody>
<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={SHEET_PRIMARY}>
{busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
</button>
</div>
<FormSheetFooter
onCancel={onClose}
cancelLabel={t('common.cancel')}
onSubmit={handleSave}
submitLabel={busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
submitDisabled={busy}
/>
</MSheet>
{/* After the sheet, so it opens on top of it. */}
@@ -4,9 +4,10 @@ import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle'
import { SHEET_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi'
import { FormSheetBody, SheetDateField } from '../trip/sheets/PlSheetChrome'
import { useAutoGrow } from '../../../hooks/useAutoGrow'
import { useTranslation } from '../../../i18n'
import CustomTimePicker from '../../../components/shared/CustomTimePicker'
import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'
import { isVideoFile } from '../../../utils/videoPoster'
import type { ResilientResult, UploadProgress } from '../../../utils/uploadQueue'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../../store/journeyStore'
@@ -108,6 +109,9 @@ export default function MJourneyEntrySheet({
const [captureOnly, setCaptureOnly] = useState(quickCapture)
const fileRef = useRef<HTMLInputElement>(null)
const cameraRef = useRef<HTMLInputElement>(null)
// The story grows with its lines instead of slicing the fourth one in half.
const storyRef = useRef<HTMLTextAreaElement>(null)
useAutoGrow(storyRef, story)
const activeProvider = externalProvider || providers[0]?.id || null
const providerExistingAssetIds = providerAssetIds(activeProvider)
@@ -125,15 +129,15 @@ export default function MJourneyEntrySheet({
open
onClose={handleClose}
variant="card"
material="glass"
form
ariaLabel={entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}
>
{/* TileHeader's shape, with the move buttons between the title and the close. */}
<div className="flex flex-none items-center gap-3 px-[18px] pb-1 pt-4">
{/* FormSheetHeader's shape, with the move buttons between the title and the close. */}
<div className="m-fs-head flex flex-none items-center gap-3 px-[18px] pb-2 pt-4">
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
<NotebookPen size={19} strokeWidth={1.8} />
</span>
<span className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold leading-tight">
<span className="line-clamp-2 min-w-0 flex-1 break-words text-[1.0625rem] font-bold leading-tight">
{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}
</span>
{(onMoveEarlier || onMoveLater) && (
@@ -146,12 +150,12 @@ export default function MJourneyEntrySheet({
</MIconBtn>
</span>
)}
<MIconBtn variant="neutral" size={34} onClick={handleClose} ariaLabel={t('common.cancel')}>
<MIconBtn variant="neutral" size={34} onClick={handleClose} ariaLabel={t('common.cancel')} className="m-fs-close">
<X size={15} strokeWidth={2.2} />
</MIconBtn>
</div>
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
<FormSheetBody>
{/* Quick capture had no title field at all: it asked for a note and nothing
else, so every entry caught on the move arrived nameless and the day's
list read as a column of identical placeholders (discussion #2299). The
@@ -416,6 +420,7 @@ export default function MJourneyEntrySheet({
)
) : (
<textarea
ref={storyRef}
rows={3}
value={story}
onChange={e => setStory(e.target.value)}
@@ -512,7 +517,7 @@ export default function MJourneyEntrySheet({
{/* 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} />
<SheetDateField value={entryDate} onChange={setEntryDate} disabled={readOnly} />
</div>
<div className="min-w-0">
<div className={`${eyebrow} mb-[5px]`}>{t('mobileJourney.time')}</div>
@@ -693,27 +698,26 @@ export default function MJourneyEntrySheet({
</div>
</>
)}
</div>
{/* A suggestion is not deleted, it is put down: the row survives so the trip
sync does not offer the same place again (discussion #2299).
{/* A suggestion is not deleted, it is put down: the row survives so the trip
sync does not offer the same place again (discussion #2299).
Its own row at the foot of the fields rather than among the buttons: the
sentence is longer than a button label and wrapped into two lines between
Delete and Cancel. Full width, it reads as what it is, an alternative to
saving this suggestion rather than a fourth thing competing with them. */}
{!readOnly && onDismiss && (
<button
type="button"
onClick={onDismiss}
className="mt-4 flex w-full items-center gap-[9px] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[13px] py-[10px] text-start"
>
<EyeOff size={15} strokeWidth={2} className="flex-none text-m-muted" />
<span className="min-w-0 flex-1 text-[0.8125rem] font-semibold">{t('journey.suggestions.dismiss')}</span>
</button>
)}
</FormSheetBody>
Its own row above the buttons rather than among them: the sentence is
longer than a button label and wrapped into two lines between Delete and
Cancel. Full width, it reads as what it is, an alternative to saving this
suggestion rather than a fourth thing competing with them. */}
{!readOnly && onDismiss && (
<button
type="button"
onClick={onDismiss}
className="mx-[18px] mt-1 mb-[10px] flex flex-none items-center gap-[9px] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[13px] py-[10px] text-start"
>
<EyeOff size={15} strokeWidth={2} className="flex-none text-m-muted" />
<span className="min-w-0 flex-1 text-[0.8125rem] font-semibold">{t('journey.suggestions.dismiss')}</span>
</button>
)}
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
<div className="m-fs-foot flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
{!readOnly && onDelete && (
<button
type="button"
@@ -46,7 +46,9 @@ export default function MNotifications() {
<ArrowLeft size={18} strokeWidth={2} />
</MIconBtn>
<div className="flex min-w-0 flex-1 items-center gap-2">
<h1 className="truncate text-[0.9375rem] font-bold text-m-ink">{t('notifications.title')}</h1>
{/* Two lines, hyphenated, before an ellipsis: "Benachrichtigungen" was
cut to "Benachrichtigu…" beside the badge and two buttons. */}
<h1 className="line-clamp-2 min-w-0 break-words text-[0.9375rem] font-bold leading-tight text-m-ink [hyphens:auto]">{t('notifications.title')}</h1>
{unreadCount > 0 && (
<span className="flex-none rounded-full bg-m-act px-[7px] py-[2px] font-geist text-[0.625rem] font-bold text-m-actfg">
{unreadCount}
@@ -893,7 +893,8 @@ export default function MTripShell({
{/* ── Bottom dock (replaces the global bottom nav on this screen) ── */}
{!wide && <nav className="absolute inset-x-4 z-40 flex h-[62px] items-center justify-around rounded-[31px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-[14px] shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[30px] backdrop-saturate-[1.8] bottom-[calc(env(safe-area-inset-bottom,0px)+12px)]">
{dockTabs.map(({ id, icon: Icon }) => {
const active = trTab === id
// While More is open the dock points at More, not at the tab under it.
const active = trTab === id && sheet?.id !== 'mehr'
return (
<button
key={id}
@@ -913,7 +914,10 @@ export default function MTripShell({
type="button"
onClick={() => openSheet('mehr')}
aria-label={t('mobileTrip.more')}
className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full text-m-muted"
aria-expanded={sheet?.id === 'mehr'}
className={`flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full ${
sheet?.id === 'mehr' ? 'bg-m-act text-m-actfg' : 'text-m-muted'
}`}
>
<MoreHorizontal size={19} strokeWidth={2.2} />
</button>
@@ -2,9 +2,8 @@ import { useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, Plus, Trash2, Wallet, Receipt, Paperclip } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import CustomSelect from '../../../../components/shared/CustomSelect'
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
import { NumericInput } from '../../../../components/shared/NumericInput'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import { AMOUNT_FIELD_CLS, Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader, SELECT_FIELD_CLS, SheetDateField } from './PlSheetChrome'
import { useTranslation } from '../../../../i18n'
import { useToast } from '../../../../components/shared/Toast'
import { useTripStore } from '../../../../store/tripStore'
@@ -99,7 +98,9 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
}
}
const initialOf = (p: TripMember) => (p.id === me ? t('costs.youShort') : (p.username || '?').charAt(0)).toUpperCase()
// The member's own initial, yours too: "You" is the label beside it, and the
// word squeezed into the circle read "Du Du" or ran out of it ("أنت").
const initialOf = (p: TripMember) => (p.username || '?').charAt(0).toUpperCase()
const Avatar = ({ p, idx, size = 22, dim = false }: { p: TripMember; idx: number; size?: number; dim?: boolean }) =>
p.avatar_url
@@ -122,7 +123,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
<MSheet
open={open}
onClose={requestClose}
material="glass"
form
ariaLabel={editing ? t('costs.editExpense') : t('costs.addExpense')}
>
<FormSheetHeader
@@ -132,7 +133,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
closeLabel={t('common.close')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-[2px]">
<FormSheetBody>
{/* NAME */}
<Eyebrow className="mb-[5px] mt-2 uppercase">{t('costs.whatFor')} *</Eyebrow>
<input
@@ -145,8 +146,8 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
{/* TOTAL AMOUNT */}
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('costs.totalAmount')}</Eyebrow>
<div className={`flex items-center gap-1 rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-[10px] ${isTicketMode ? 'opacity-60' : ''}`}>
<span className="text-[0.84375rem] font-medium text-m-faint">{sym(currency)}</span>
<div className={`${AMOUNT_FIELD_CLS} ${isTicketMode ? 'opacity-60' : ''}`}>
<span className="flex-none text-[0.84375rem] font-medium text-m-faint">{sym(currency)}</span>
<NumericInput
mode="signed-decimal"
signToggleLabel={t('costs.toggleSign')}
@@ -158,9 +159,10 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
/>
</div>
{/* CURRENCY + DAY */}
<div className="mt-3 flex gap-2">
<div className="min-w-0 flex-1">
{/* CURRENCY + DAY. The day gets the room: the currency only ever shows a
code and a symbol, and a date cut to "Nov 7, 20…" says nothing. */}
<div className="mt-3 grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)] gap-2">
<div className="min-w-0">
<Eyebrow className="mb-[5px] uppercase">{t('costs.currency')}</Eyebrow>
<CustomSelect
value={currency}
@@ -171,9 +173,9 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
style={{ width: '100%' }}
/>
</div>
<div className="min-w-0 flex-1">
<div className="min-w-0">
<Eyebrow className="mb-[5px] uppercase">{t('costs.day')}</Eyebrow>
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
<SheetDateField value={day} onChange={setDay} />
</div>
</div>
@@ -201,7 +203,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
type="button"
aria-expanded={catOpen}
onClick={() => setCatOpen(v => !v)}
className="flex w-full items-center gap-[10px] overflow-hidden rounded-xl border border-[color:var(--m-rowbr)] bg-m-card px-[13px] py-[11px] text-start"
className={`${SELECT_FIELD_CLS} gap-[10px] text-start`}
>
{(() => {
const meta = catMeta(cat)
@@ -521,7 +523,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
))}
</div>
)}
</div>
</FormSheetBody>
<FormSheetFooter
onDelete={editing ? handleDelete : undefined}
@@ -1,6 +1,8 @@
import { ChevronRight, FileDown, Settings, Settings2, Sparkles } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useState } from 'react'
import MSheet from '../../../components/MSheet'
import { edgeFadeClass, useEdgeFade } from '../../../components/useEdgeFade'
import type { MTripSheetsProps } from '../MTripShell'
import { dockTabIds } from '../dockTabs'
import { useIsWide } from '../../../../hooks/sizeClass'
@@ -32,6 +34,8 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
const assistant = useAssistantAsk(planner.tripId)
// Warnings not shown yet turn the tile's line into a count; the dock gets no dot.
const hints = useAssistantHints(planner.tripId, { withStays: false })
const [list, setList] = useState<HTMLDivElement | null>(null)
const fade = useEdgeFade(list, 'y')
// Derived from the same priority list the dock seats itself from, so a section
// can never show up in both places at once.
@@ -67,7 +71,9 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
return (
<MSheet open={open} onClose={shell.closeSheet} variant="bottom" dimTransparent wideWidth="compact" wideAnchor="rail-foot" ariaLabel={t('mobileTrip.more')}>
<div className="overflow-y-auto p-[10px]">
{/* min-h-0: without it the list keeps its full height inside the panel
and the panel cuts its last row instead of letting it scroll. */}
<div ref={setList} className={`min-h-0 overflow-y-auto overscroll-contain p-[10px] ${edgeFadeClass(fade, 'y')}`}>
{/* The assistant leads the sheet: the one way into it on the phone besides "Ask" on a place. */}
{assistant.available && (
<button
@@ -112,7 +118,9 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
>
{Icon && <Icon size={19} strokeWidth={1.9} />}
</div>
<div className="mt-3 truncate text-[0.90625rem] font-bold">{tab.label}</div>
{/* Two lines before an ellipsis: "Совместная работа" was cut to
"Совместная раб…" in half a phone's width. */}
<div className="mt-3 line-clamp-2 break-words text-[0.90625rem] font-bold leading-tight [hyphens:auto]">{tab.label}</div>
{stat && <div className="truncate font-geist text-[0.65625rem] text-m-muted">{stat}</div>}
</button>
)
@@ -7,7 +7,7 @@ import { noteSurface } from '../../../../components/Planner/noteSurface'
import NoteFormatToolbar from '../../../../components/shared/NoteFormatToolbar'
import { useTripStore } from '../../../../store/tripStore'
import { dayNoteDraft, writeDayNote } from '../../../../hooks/useDayNotes'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import type { DayNote } from '../../../../types'
import type { TripPlanner } from '../MTripShell'
@@ -103,14 +103,14 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe
}
return (
<MSheet open={open} onClose={onClose} ariaLabel={note ? t('dayplan.noteEdit') : t('dayplan.noteAdd')}>
<MSheet open={open} onClose={onClose} form ariaLabel={note ? t('dayplan.noteEdit') : t('dayplan.noteAdd')}>
<FormSheetHeader
title={note ? t('dayplan.noteEdit') : t('dayplan.noteAdd')}
onClose={onClose}
closeLabel={t('common.close')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
<FormSheetBody>
<Eyebrow className="mb-[7px] uppercase">{t('notes.appearance')}</Eyebrow>
<div className="flex items-center gap-[10px]">
{/* The chosen icon in the chosen colour: the picker and the preview
@@ -206,7 +206,7 @@ export default function MNoteSheet({ planner, open, payload, onClose }: MNoteShe
{detail.length}/{DAY_NOTE_DETAIL_MAX}
</span>
</div>
</div>
</FormSheetBody>
<FormSheetFooter
onDelete={note ? handleDelete : undefined}
@@ -12,7 +12,7 @@ import {
type PlaceFormData,
} from '../../../../components/Planner/PlaceFormModal.helpers'
import { usePlaceForm } from '../../../../components/Planner/usePlaceForm'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import { useAddonStore } from '../../../../store/addonStore'
import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types'
import PlPlaceSearch, { type PlSearchPick } from './PlPlaceSearch'
@@ -233,7 +233,7 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
<MSheet
open={showPlaceForm}
onClose={handleClose}
material="glass"
form
ariaLabel={sheetPlace ? t('places.editPlace') : t('places.addPlace')}
discardGuard={showPlaceForm ? { form, files: pendingFiles.length } : undefined}
>
@@ -244,7 +244,7 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
closeLabel={t('common.close')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-[2px]" onPaste={handlePaste}>
<FormSheetBody onPaste={handlePaste}>
<PlPlaceSearch
planner={planner}
locationBias={locationBias}
@@ -387,7 +387,7 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
onEdit={item => onOpenExpense({ editItem: item })}
/>
)}
</div>
</FormSheetBody>
<FormSheetFooter
onDelete={sheetPlace ? handleDelete : undefined}
@@ -29,6 +29,7 @@ import { getPlaceBookings } from '../../../../utils/dayMerge'
import type { Assignment, Day, Reservation, TripMember } from '../../../../types'
import PlacePhotoGallery from '../../../../components/Planner/PlacePhotoGallery'
import { ActionCircle, Eyebrow, INNER_CLS } from './MTripSheetUi'
import { FormSheetBody } from './PlSheetChrome'
/**
* Place inspector sheet (glass card), opened by the current place selection —
@@ -190,13 +191,14 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
}
return (
<MSheet open={open} onClose={close} variant="card" material="glass" ariaLabel={place?.name}>
<MSheet open={open} onClose={close} variant="card" form ariaLabel={place?.name}>
{place && (
<>
<div className="flex-none px-[18px] pt-4">
<div className="flex items-start gap-3">
<div className="flex flex-none flex-col items-center gap-[5px]">
{/* The picture opens the place's photos; it is changed in the editor. */}
{/* The picture opens the place's photos; it is changed in the editor.
It stands on the same leading edge as the cards below. */}
<div className="flex-none">
<PlacePhotoGallery place={place} language={language} label={t('places.openPhotos')} className="rounded-[16px]">
{place.image_url ? (
<div
@@ -209,12 +211,6 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
</div>
)}
</PlacePhotoGallery>
{category && (
<span className="flex max-w-[76px] items-center gap-1 rounded-full border border-[color:var(--m-faint)] px-2 py-[2px] font-geist text-[0.625rem] font-semibold text-m-muted wide:max-w-[140px]">
<CatIcon size={10} strokeWidth={2} className="flex-none" />
<span className="truncate">{category.name}</span>
</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
@@ -230,6 +226,14 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
{place.address && (
<div className="mt-[2px] font-geist text-[0.6875rem] leading-[1.4] text-m-muted">{place.address}</div>
)}
{/* The category under the name and address, as wide as its name:
squeezed under the photo it was cut to "Shoppi…". */}
{category && (
<span className="mt-[6px] inline-flex max-w-full items-center gap-1 rounded-full border border-[color:var(--m-faint)] px-2 py-[2px] font-geist text-[0.625rem] font-semibold text-m-muted">
<CatIcon size={10} strokeWidth={2} className="flex-none" />
<span className="truncate">{category.name}</span>
</span>
)}
</div>
<button
type="button"
@@ -242,7 +246,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
<FormSheetBody>
{place.phone && (
<a href={`tel:${place.phone}`} className="mt-3 flex items-center gap-[7px] text-[0.78125rem] font-medium">
<Phone size={13} strokeWidth={2} className="flex-none text-m-muted" />
@@ -263,7 +267,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
{place.notes && (
<>
<Eyebrow className="mb-[6px] mt-3">{t('mobileTrip.notes')}</Eyebrow>
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('mobileTrip.notes')}</Eyebrow>
<div className={`rounded-[14px] px-3 py-[10px] ${INNER_CLS}`}>
<MarkdownText className="font-geist text-[0.75rem] leading-[1.5] text-m-muted [overflow-wrap:anywhere]">{place.notes}</MarkdownText>
</div>
@@ -273,7 +277,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
{/* ── Track colour (#776) ── */}
{place.route_geometry && (
<>
<Eyebrow className="mb-[6px] mt-3">{t('inspector.trackColor')}</Eyebrow>
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('inspector.trackColor')}</Eyebrow>
<div className={`rounded-[14px] px-3 py-[10px] ${INNER_CLS}`}>
{isTourPlace ? (
<span aria-label={t('inspector.trackColor')} style={{ display: 'block', width: 24, height: 24, borderRadius: 12, background: resolveTrackColor(place) }} />
@@ -312,7 +316,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
)}
{/* ── Day assignments ── */}
<Eyebrow className="mb-[6px] mt-3">{t('mobileTrip.assignedDays')}</Eyebrow>
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('mobileTrip.assignedDays')}</Eyebrow>
<div className="flex flex-wrap items-center gap-[6px]">
{placeAssignments.map(({ day, assignment }) => (
<span
@@ -382,7 +386,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
only appears when the place sits on more than one day. ── */}
{placeAssignments.some(r => r.assignment.notes) && (
<>
<Eyebrow className="mb-[6px] mt-3">{t('mobileTrip.assignmentNotes')}</Eyebrow>
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('mobileTrip.assignmentNotes')}</Eyebrow>
<div className="flex flex-col gap-[6px]">
{placeAssignments.filter(r => r.assignment.notes).map(({ day, assignment }) => (
<div key={assignment.id} className={`rounded-[14px] px-3 py-[10px] ${INNER_CLS}`}>
@@ -401,7 +405,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
{/* ── The bookings attached to this stop (#2012, #2201) ── */}
{linkedReservations.length > 0 && (
<>
<Eyebrow className="mb-[6px] mt-3">{t('reservations.title')}</Eyebrow>
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('reservations.title')}</Eyebrow>
<div className="flex flex-col gap-[6px]">
{linkedReservations.map(res => (
<button
@@ -430,7 +434,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
{/* ── Participants of the selected day's assignment ── */}
{!isTourPlace && assignmentInDay && members.length > 1 && (
<>
<Eyebrow className="mb-[6px] mt-3">{t('inspector.participants')}</Eyebrow>
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('inspector.participants')}</Eyebrow>
<div className="flex flex-wrap items-center gap-[6px]">
{activeMembers.map(m => (
<button
@@ -591,7 +595,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
</ActionCircle>
)}
</div>
</div>
</FormSheetBody>
</>
)}
</MSheet>
@@ -7,8 +7,7 @@ import { useAddonStore } from '../../../../store/addonStore'
import { useTranslation } from '../../../../i18n'
import CustomSelect from '../../../../components/shared/CustomSelect'
import CustomTimePicker from '../../../../components/shared/CustomTimePicker'
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import { Eyebrow, FIELD_CLS, FORM_PAIR_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader, SheetDateField } from './PlSheetChrome'
import MBookingFilesCosts from './MBookingFilesCosts'
import { expenseRequestAfterSave, travelerIdsOf, filesNeedConnection } from '../../../../components/Planner/bookingFormModel'
import { useBookingFollowUp } from '../../../../components/Planner/useBookingFollowUp'
@@ -179,7 +178,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
<MSheet
open={showReservationModal}
onClose={handleClose}
material="glass"
form
ariaLabel={res ? t('reservations.editTitle') : t('reservations.newTitle')}
discardGuard={showReservationModal ? { form, files: pendingFiles.length, travelers: [...travelerIds] } : undefined}
>
@@ -190,7 +189,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
closeLabel={t('common.close')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-[2px]">
<FormSheetBody>
{/* A saved booking goes into Apple Wallet (iOS app only). */}
<MWalletButton tripId={tripId} reservationId={savedId(res)} onError={toast.error} className="mb-1 mt-2 flex" />
@@ -230,7 +229,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
<div className="mt-3 flex gap-2">
<div className="min-w-0 flex-[1.2]">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.date')}</Eyebrow>
<CustomDatePicker
<SheetDateField
value={startDate}
onChange={setStartDate}
min={tripMinDate}
@@ -248,7 +247,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
<div className="mt-2 flex gap-2">
<div className="min-w-0 flex-[1.2]">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.endDate')}</Eyebrow>
<CustomDatePicker value={form.end_date} onChange={d => set('end_date', d || '')} min={tripMinDate} max={tripMaxDate} />
<SheetDateField value={form.end_date} onChange={d => set('end_date', d || '')} min={tripMinDate} max={tripMaxDate} />
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.endTime')}</Eyebrow>
@@ -301,8 +300,8 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
size="sm"
/>
<div className="mt-3 flex gap-2">
<div className="min-w-0 flex-1">
<div className={`mt-3 ${FORM_PAIR_CLS}`}>
<div className="min-w-0">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.fromDay')}</Eyebrow>
<CustomSelect
value={form.hotel_start_day}
@@ -312,7 +311,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
size="sm"
/>
</div>
<div className="min-w-0 flex-1">
<div className="min-w-0">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.toDay')}</Eyebrow>
<CustomSelect
value={form.hotel_end_day}
@@ -414,7 +413,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
onCreate={expense.create}
onEdit={item => onOpenExpense({ editItem: item })}
/>
</div>
</FormSheetBody>
<FormSheetFooter
onCancel={handleClose}
@@ -19,7 +19,7 @@ import { useTransportForm } from '../../../../components/Planner/useTransportFor
import { expenseRequestAfterSave, travelerIdsOf, filesNeedConnection, type BookingSaveSteps } from '../../../../components/Planner/bookingFormModel'
import { useBookingFollowUp } from '../../../../components/Planner/useBookingFollowUp'
import { useBookingExpenseIntent } from '../../../../components/Planner/useBookingExpenseIntent'
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import { Eyebrow, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import MBookingFilesCosts from './MBookingFilesCosts'
import type { Place, Reservation } from '../../../../types'
import type { BookingReviewDraft } from '../../../../components/Planner/parsedItemToDraft'
@@ -163,7 +163,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
<MSheet
open={showTransportModal}
onClose={handleClose}
material="glass"
form
ariaLabel={headerTitle}
discardGuard={showTransportModal ? { form, waypoints, trainWaypoints, carStops, files: pendingFiles.length, travelers: [...travelerIds] } : undefined}
>
@@ -199,7 +199,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-[2px]">
<FormSheetBody>
{automated ? (
/* ── Automated: public transit search ── */
<>
@@ -307,15 +307,15 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
>
<div className="mt-2 flex gap-2">
<div className="min-w-0 flex-[1.2]">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.airline')}</Eyebrow>
<Eyebrow className="mb-[5px] truncate uppercase">{t('reservations.meta.airline')}</Eyebrow>
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={FIELD_CLS} />
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.flightNumber')}</Eyebrow>
<Eyebrow className="mb-[5px] truncate uppercase">{t('reservations.meta.flightNumber')}</Eyebrow>
<input type="text" value={wp.flight_number} onChange={e => updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={FIELD_CLS} />
</div>
<div className="min-w-0 flex-[0.7]">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.seat')}</Eyebrow>
<Eyebrow className="mb-[5px] truncate uppercase">{t('reservations.meta.seat')}</Eyebrow>
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={FIELD_CLS} />
</div>
</div>
@@ -372,15 +372,15 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
>
{!cruise && <div className="mt-2 flex gap-2">
<div className="min-w-0 flex-[1.2]">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.trainNumber')}</Eyebrow>
<Eyebrow className="mb-[5px] truncate uppercase">{t('reservations.meta.trainNumber')}</Eyebrow>
<input type="text" value={wp.train_number} onChange={e => updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={FIELD_CLS} />
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.platform')}</Eyebrow>
<Eyebrow className="mb-[5px] truncate uppercase">{t('reservations.meta.platform')}</Eyebrow>
<input type="text" value={wp.platform} onChange={e => updateWp({ platform: e.target.value })} placeholder="12" className={FIELD_CLS} />
</div>
<div className="min-w-0 flex-[0.7]">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.seat')}</Eyebrow>
<Eyebrow className="mb-[5px] truncate uppercase">{t('reservations.meta.seat')}</Eyebrow>
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="42A" className={FIELD_CLS} />
</div>
</div>}
@@ -540,10 +540,10 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
/>
</>
)}
</div>
</FormSheetBody>
{automated ? (
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
<div className="m-fs-foot flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
<button
type="button"
onClick={handleClose}
@@ -10,7 +10,7 @@ import { announceNoteFilesChanged, useCollabNotesData } from '../../../../compon
import { openFile } from '../../../../utils/fileDownload'
import { safeExternalHref } from '../../../../utils/safeUrl'
import MSheet from '../../../components/MSheet'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import MConfirmSheet from '../../settings/MConfirmSheet'
import type { TripPlanner } from '../MTripShell'
import { TabScroller } from './tabChrome'
@@ -423,7 +423,7 @@ function NoteFormSheet({ open, target, categories, colorMap, canUploadFiles, onC
}
return (
<MSheet open={open} onClose={onClose} ariaLabel={isEdit ? t('collab.notes.edit') : t('collab.notes.new')}>
<MSheet open={open} onClose={onClose} form ariaLabel={isEdit ? t('collab.notes.edit') : t('collab.notes.new')}>
<FormSheetHeader
icon={StickyNote}
title={isEdit ? t('collab.notes.edit') : t('collab.notes.new')}
@@ -431,8 +431,8 @@ function NoteFormSheet({ open, target, categories, colorMap, canUploadFiles, onC
closeLabel={t('common.close')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
<Eyebrow className="mb-[5px] uppercase">{t('collab.notes.title')} *</Eyebrow>
<FormSheetBody>
<Eyebrow className="mb-[5px] mt-2 uppercase">{t('common.title')} *</Eyebrow>
<input
type="text"
value={title}
@@ -573,7 +573,7 @@ function NoteFormSheet({ open, target, categories, colorMap, canUploadFiles, onC
</div>
</>
)}
</div>
</FormSheetBody>
<FormSheetFooter
onCancel={onClose}
@@ -11,7 +11,7 @@ import ToggleSwitch from '../../../../components/Settings/ToggleSwitch'
import { CustomDateTimePicker } from '../../../../components/shared/CustomDateTimePicker'
import { localIsoDate } from '../../../../utils/localDate'
import MSheet from '../../../components/MSheet'
import { Eyebrow, FIELD_AREA_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import { Eyebrow, FIELD_AREA_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import MConfirmSheet from '../../settings/MConfirmSheet'
import type { TripPlanner } from '../MTripShell'
import { CardGrid, SectionHeader, TabScroller } from './tabChrome'
@@ -360,10 +360,10 @@ function PollFormSheet({ open, onClose, onSubmit, t }: {
}
return (
<MSheet open={open} onClose={onClose} ariaLabel={t('collab.polls.new')}>
<MSheet open={open} onClose={onClose} form ariaLabel={t('collab.polls.new')}>
<FormSheetHeader icon={BarChart3} title={t('collab.polls.new')} onClose={onClose} closeLabel={t('common.close')} />
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
<FormSheetBody>
<Eyebrow className="mb-[5px] uppercase">{t('collab.polls.question')} *</Eyebrow>
<textarea
rows={4}
@@ -432,7 +432,7 @@ function PollFormSheet({ open, onClose, onSubmit, t }: {
</button>
)}
</div>
</div>
</FormSheetBody>
<FormSheetFooter
onCancel={onClose}
@@ -1,34 +1,51 @@
import { ArrowLeftRight } from 'lucide-react';
import { NOTE_MAX } from '../../../../components/Budget/CostsPanel.helpers';
import { currencyOptions, type CostsSettlement } from '../../../../components/Budget/costsModel';
import { useSettlementForm } from '../../../../components/Budget/useSettlementForm';
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker';
import CustomSelect from '../../../../components/shared/CustomSelect';
import type { TripMember } from '../../../../types';
import { localizeAmountInput, zeroAmountInput } from '../../../../utils/formatters';
import MChip from '../../../components/MChip';
import MSheet from '../../../components/MSheet';
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome';
import {
AMOUNT_FIELD_CLS,
Eyebrow,
FIELD_AREA_CLS,
FormSheetBody,
FormSheetFooter,
FormSheetHeader,
SheetDateField,
} from '../sheets/PlSheetChrome';
type TFn = (key: string, params?: Record<string, string | number>) => string;
// The costs tab's member avatar and its payment sheet, apart from the tab so it
// keeps to the size a source file may have.
/** Avatar or initials circle; `variant` picks the accent (member chips) or neutral (balances) tone. */
const AVATAR_TONE = {
accent: 'bg-m-act text-m-actfg',
neutral: 'bg-[color:var(--m-ic)] text-m-ink',
// On a chip that is itself filled with the accent: the circle turns over, or
// it vanishes into the chip.
onAccent: 'bg-m-actfg text-m-act',
} as const;
/**
* Avatar or initials circle; `variant` picks the accent (member chips), the
* neutral (balances) tone, or the inverse one for a selected chip. The initial
* is the member's own, yours included: "You" is the label beside the circle,
* and the word squeezed into it read "Du Du" or ran out of it in Arabic.
*/
export function MemberAvatar({
name,
avatarUrl,
isMe,
variant,
size,
t,
}: {
name: string;
avatarUrl?: string | null;
isMe: boolean;
variant: 'accent' | 'neutral';
variant: keyof typeof AVATAR_TONE;
size: number;
t: TFn;
}) {
if (avatarUrl) {
return (
@@ -40,11 +57,10 @@ export function MemberAvatar({
/>
);
}
const initial = isMe ? t('costs.youShort') : (name || '?').charAt(0).toUpperCase();
const toneCls = variant === 'accent' ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-ink';
const initial = (name || '?').charAt(0).toUpperCase();
return (
<span
className={`flex flex-none items-center justify-center rounded-full font-geist font-extrabold ${toneCls}`}
className={`flex flex-none items-center justify-center rounded-full font-geist font-extrabold ${AVATAR_TONE[variant]}`}
style={{ width: size, height: size, fontSize: Math.max(8, size * 0.42) }}
>
{initial}
@@ -122,20 +138,18 @@ export function AddPaymentSheet({
const title = editing ? t('costs.editPayment') : t('costs.addPayment');
return (
<MSheet open={open} onClose={onClose} ariaLabel={title}>
<FormSheetHeader title={title} onClose={onClose} closeLabel={t('common.close')} />
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
<Eyebrow className="mb-[7px] uppercase">{t('costs.from')}</Eyebrow>
<MSheet open={open} onClose={onClose} ariaLabel={title} form>
<FormSheetHeader icon={ArrowLeftRight} title={title} onClose={onClose} closeLabel={t('common.close')} />
<FormSheetBody>
<Eyebrow className="mb-[7px] mt-2 uppercase">{t('costs.from')}</Eyebrow>
<div className="flex flex-wrap gap-[6px]">
{people.map((p) => (
<MChip key={p.id} active={fromId === p.id} onClick={() => setFromId(p.id)}>
<MemberAvatar
name={p.username}
avatarUrl={p.avatar_url}
isMe={p.id === me}
variant={fromId === p.id ? 'accent' : 'neutral'}
variant={fromId === p.id ? 'onAccent' : 'neutral'}
size={16}
t={t}
/>
{p.id === me ? t('costs.you') : p.username}
</MChip>
@@ -149,10 +163,8 @@ export function AddPaymentSheet({
<MemberAvatar
name={p.username}
avatarUrl={p.avatar_url}
isMe={p.id === me}
variant={toId === p.id ? 'accent' : 'neutral'}
variant={toId === p.id ? 'onAccent' : 'neutral'}
size={16}
t={t}
/>
{p.id === me ? t('costs.you') : p.username}
</MChip>
@@ -160,20 +172,23 @@ export function AddPaymentSheet({
</div>
<Eyebrow className="mb-[7px] mt-[14px] uppercase">{t('costs.amount')}</Eyebrow>
<div className="flex items-center gap-2">
{/* The currency sits inside the field, after the amount, like the
expense's total: one box, not a field with a code floating beside it. */}
<div className={AMOUNT_FIELD_CLS}>
<input
type="text"
inputMode="decimal"
value={localizeAmountInput(amount, currency)}
onChange={(e) => setAmount(e.target.value)}
placeholder={zeroAmountInput(currency)}
className={FIELD_CLS}
aria-label={t('costs.amount')}
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-[0.84375rem] font-semibold text-m-ink outline-none [font-variant-numeric:tabular-nums] placeholder:font-normal placeholder:text-m-faint"
/>
<span className="flex-none font-geist text-caption font-bold text-m-faint">{currency}</span>
</div>
<div className="mt-[14px] flex gap-2">
<div className="min-w-0 flex-1">
<div className="mt-[14px] grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)] gap-2">
<div className="min-w-0">
<Eyebrow className="mb-[7px] uppercase">{t('costs.currency')}</Eyebrow>
<CustomSelect
value={currency}
@@ -184,9 +199,9 @@ export function AddPaymentSheet({
style={{ width: '100%' }}
/>
</div>
<div className="min-w-0 flex-1">
<div className="min-w-0">
<Eyebrow className="mb-[7px] uppercase">{t('costs.day')}</Eyebrow>
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
<SheetDateField value={day} onChange={setDay} />
</div>
</div>
@@ -199,7 +214,7 @@ export function AddPaymentSheet({
placeholder={t('costs.paymentNotePlaceholder')}
className={FIELD_AREA_CLS}
/>
</div>
</FormSheetBody>
<FormSheetFooter
onCancel={onClose}
cancelLabel={t('common.cancel')}
@@ -243,7 +243,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
const neg = balance < -0.01
return (
<div key={p.id} className="flex items-center gap-[9px] border-b border-[color:var(--m-rowbr)] py-[7px] last:border-b-0">
<MemberAvatar name={p.username} avatarUrl={p.avatar_url} isMe={p.id === me} variant="neutral" size={24} t={t} />
<MemberAvatar name={p.username} avatarUrl={p.avatar_url} variant="neutral" size={24} />
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold text-m-ink">{p.id === me ? t('costs.you') : p.username}</span>
<span className="ms-auto flex-none font-geist text-[0.75rem] font-extrabold tabular-nums" style={{ color: pos ? STATUS_COLOR.confirmed : neg ? STATUS_COLOR.danger : 'var(--m-faint)' }}>
{pos ? '+' : neg ? '−' : ''}
@@ -272,7 +272,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
onClick={() => setExpandedFinalId(open ? null : p.id)}
className="flex w-full items-center gap-[9px] py-[7px] text-start font-[inherit]"
>
<MemberAvatar name={p.username} avatarUrl={p.avatar_url} isMe={p.id === me} variant="neutral" size={24} t={t} />
<MemberAvatar name={p.username} avatarUrl={p.avatar_url} variant="neutral" size={24} />
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold text-m-ink">{personName(p.id)}</span>
<span className="ms-auto flex-none font-geist text-[0.75rem] font-extrabold tabular-nums text-m-ink">{formatMoney(row.final, base, locale)}</span>
{open
@@ -637,7 +637,7 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
m.paid ? 'border-2 border-[color:var(--m-st-confirmed)]' : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]'
}`}
>
<MemberAvatar name={m.username} avatarUrl={m.avatar_url} isMe={m.user_id === ctx.me} variant="accent" size={14} t={t} />
<MemberAvatar name={m.username} avatarUrl={m.avatar_url} variant="accent" size={14} />
<span className="font-geist text-[0.5625rem] font-bold tabular-nums text-m-ink">
{formatMoney(memberShareOf(item, m.user_id, ctx), base, locale)}
</span>
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { Check, Copy, HandHelping, Share2, UserRound, Users } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import { Eyebrow, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import { avatarSrc } from '../../../../utils/avatarSrc'
import type { PackingItem, TripMember } from '../../../../types'
import type { TripPlanner } from '../MTripShell'
@@ -109,7 +109,7 @@ export default function MPackItemSheet({
}`
return (
<MSheet open={open} onClose={onClose} ariaLabel={t('packing.editItem')}>
<MSheet open={open} onClose={onClose} form ariaLabel={t('packing.editItem')}>
<FormSheetHeader
title={t('packing.editItem')}
subtitle={item.category || t('packing.defaultCategory')}
@@ -117,7 +117,7 @@ export default function MPackItemSheet({
closeLabel={t('common.close')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
<FormSheetBody>
<Eyebrow className="mb-[5px] uppercase">{t('packing.itemName')}</Eyebrow>
<input
type="text"
@@ -228,7 +228,7 @@ export default function MPackItemSheet({
)}
</>
)}
</div>
</FormSheetBody>
<FormSheetFooter
onCancel={onClose}
@@ -1,7 +1,7 @@
import { useRef } from 'react'
import { FileDown } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import { Eyebrow, FIELD_AREA_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import { Eyebrow, FIELD_AREA_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import { usePackingImport } from '../../../../components/Packing/usePackingImport'
import { PACKING_IMPORT_ACCEPT } from '../../../../components/Packing/packingListPanel.constants'
import type { TripPlanner } from '../MTripShell'
@@ -25,10 +25,10 @@ export default function MPackingImportSheet({ planner, open, onClose }: MPacking
usePackingImport({ tripId, t, toast, onImported: onClose, oneAtATime: true })
return (
<MSheet open={open} onClose={onClose} ariaLabel={t('packing.importTitle')}>
<MSheet open={open} onClose={onClose} form ariaLabel={t('packing.importTitle')}>
<FormSheetHeader title={t('packing.importTitle')} onClose={onClose} closeLabel={t('common.close')} />
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
<FormSheetBody>
<p className="mb-1 font-geist text-[0.71875rem] leading-[1.5] text-m-muted">{t('packing.importHint')}</p>
<p className="mb-3 font-geist text-[0.71875rem] leading-[1.5] text-m-muted">{t('packing.importHintMarkdown')}</p>
@@ -53,7 +53,7 @@ export default function MPackingImportSheet({ planner, open, onClose }: MPacking
{parsed.length === 0 && text.trim() !== '' && (
<Eyebrow className="mt-3">{t('packing.importEmpty')}</Eyebrow>
)}
</div>
</FormSheetBody>
<FormSheetFooter
onCancel={onClose}
@@ -4,7 +4,7 @@ import type { TodoCreateItemRequest } from '@trek/shared'
import MSheet from '../../../components/MSheet'
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
import MarkdownEditable from '../../../../components/shared/MarkdownEditable'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import { avatarSrc } from '../../../../utils/avatarSrc'
import type { TodoItem, TripMember } from '../../../../types'
import type { TripPlanner } from '../MTripShell'
@@ -91,10 +91,10 @@ export default function MTaskSheet({ planner, open, itemId, categories, members,
}`
return (
<MSheet open={open} onClose={onClose} ariaLabel={item ? t('todo.detail.title') : t('todo.newItem')}>
<MSheet open={open} onClose={onClose} form ariaLabel={item ? t('todo.detail.title') : t('todo.newItem')}>
<FormSheetHeader title={item ? t('todo.detail.title') : t('todo.newItem')} onClose={onClose} closeLabel={t('common.close')} />
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
<FormSheetBody>
<input
type="text"
value={name}
@@ -220,7 +220,7 @@ export default function MTaskSheet({ planner, open, itemId, categories, members,
)
})}
</div>
</div>
</FormSheetBody>
<FormSheetFooter
onDelete={item ? handleDelete : undefined}
@@ -97,6 +97,18 @@ describe('MNewTripSheet', () => {
expect(screen.getByText('Add cover image')).toBeInTheDocument();
});
it('FE-MOB-NTSH-095: the form uses the shared form chrome: icon header, flat fields, no frame on the focused sheet', () => {
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={() => {}} />);
const dialog = screen.getByRole('dialog', { name: 'Create New Trip' });
expect(dialog).toHaveClass('m-form-sheet');
expect(dialog.querySelector('.m-fs-head')).not.toBeNull();
expect(dialog.querySelector('.m-fs-foot')).not.toBeNull();
// No section cards inside the card.
expect(dialog.querySelector('section')).toBeNull();
// Each date has its field to itself: the keyboard entry waits in the picker.
expect(screen.queryByRole('button', { name: /enter date manually/i })).toBeNull();
});
it('FE-MOB-NTSH-001b: create mode defaults the currency to the user\'s default_currency (#1784)', () => {
useSettingsStore.setState(s => ({ settings: { ...s.settings, default_currency: 'USD' } }));
render(<MNewTripSheet open trip={null} onClose={() => {}} onSave={() => {}} />);
@@ -392,9 +392,11 @@ 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');
// Through the picker's manual entry — the calendar's own path would depend
// on which month it opens on.
// on which month it opens on. The keyboard button sits in the picker's
// popup, so the date keeps the field's width: open the calendar first.
// 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.getAllByRole('button').find(b => b.getAttribute('aria-haspopup') === 'dialog')!);
fireEvent.click(screen.getByLabelText('Enter date manually'));
const manual = screen.getByLabelText('Enter date manually');
fireEvent.change(manual, { target: { value: '18.03.2026' } });
@@ -244,6 +244,9 @@ describe('MCollabNotes', () => {
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
const dialog = screen.getByRole('dialog', { name: 'collab.notes.new' })
expect(screen.getByRole('button', { name: 'collab.notes.create' })).toBeDisabled()
// The title field is labelled "Title", not "Notes" as if it were a second content field.
expect(within(dialog).getByText('common.title *')).toBeInTheDocument()
expect(within(dialog).queryByText('collab.notes.title *')).not.toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: ' Insurance ' } })
fireEvent.change(screen.getByPlaceholderText('collab.notes.contentPlaceholder'), { target: { value: ' call the hotline ' } })
@@ -102,7 +102,8 @@ describe('MCostSheet', () => {
expect(screen.getByRole('dialog', { name: 'Add expense' })).toBeInTheDocument()
expect(nameField()).toHaveValue('')
expect(catTrigger()).toHaveTextContent('Food & drink')
expect(screen.getByRole('button', { name: 'Y You' })).toBeInTheDocument()
// Your row shows your own initial ("alice"), the label beside it says "You".
expect(screen.getByRole('button', { name: 'A You' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'B bob' })).toBeInTheDocument()
expect(submit()).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
@@ -224,7 +225,7 @@ describe('MCostSheet', () => {
it('FE-MOB-COSTSH-005: an expense nobody shares is saved without members but keeps its payer', async () => {
renderSheet()
fillBasics('Souvenir', '12')
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
fireEvent.click(screen.getByRole('button', { name: 'A You' }))
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
fireEvent.click(submit())
@@ -332,7 +333,7 @@ describe('MCostSheet', () => {
fillBasics('Group ticket', '100')
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
fireEvent.click(screen.getAllByRole('button', { name: 'Y You' })[0])
fireEvent.click(screen.getAllByRole('button', { name: 'A You' })[0])
fireEvent.click(screen.getByRole('button', { name: 'One person paid' }))
expect(screen.getByRole('button', { name: 'bob' })).toBeInTheDocument()
@@ -371,7 +372,7 @@ describe('MCostSheet', () => {
fireEvent.change(share, { target: { value: '30,5' } })
expect(screen.getByDisplayValue('30,5')).toBe(share)
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
fireEvent.click(screen.getByRole('button', { name: 'A You' }))
fireEvent.click(screen.getByRole('button', { name: 'Tap to include' }))
expect(screen.getAllByPlaceholderText('50,00')[0]).toHaveValue('')
})
@@ -412,7 +413,7 @@ describe('MCostSheet', () => {
expect(footerSubmit()).toBeEnabled()
// Un-ticking everyone makes the item unsplittable again.
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
fireEvent.click(screen.getByRole('button', { name: 'A You' }))
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
expect(footerSubmit()).toBeDisabled()
})
@@ -299,7 +299,8 @@ describe('MCostsTab', () => {
expect(chips).toHaveLength(2)
expect(chips[0]).toHaveAttribute('aria-pressed', 'true')
expect(chips[1]).toHaveAttribute('aria-pressed', 'false')
expect(chips.map(c => c.textContent)).toEqual(['costs.youShort$40.00', '$40.00'])
// Your own chip carries your initial too, not the word "You" squeezed into the circle.
expect(chips.map(c => c.textContent)).toEqual(['M$40.00', '$40.00'])
})
it('FE-MOB-COSTT-017: toggles a member chip to paid', async () => {
@@ -173,10 +173,19 @@ describe('MPlaceSheet', () => {
expect(screen.queryByRole('button', { name: 'Change image' })).not.toBeInTheDocument()
})
it('FE-MOB-PLSH-033: the category pill keeps to the photo on a phone and takes the room a wide sheet has', () => {
it('FE-MOB-PLSH-033: the category pill stands under the name and address, as wide as its name', () => {
renderSheet()
const pill = screen.getByText('Museum').parentElement as HTMLElement
expect(pill).toHaveClass('max-w-[76px]', 'wide:max-w-[140px]')
// No cap of its own any more: under the photo it was cut to "Shoppi…".
expect(pill.className).not.toMatch(/max-w-\[/)
expect(pill).toHaveClass('max-w-full')
// In the column with the name, so the photo keeps the leading edge of the cards below.
expect(pill.parentElement).toContainElement(screen.getByText('Kunsthistorisches Museum'))
})
it('FE-MOB-PLSH-034: the section labels are the small capitals every sheet uses', () => {
renderSheet()
expect(screen.getByText('Assigned Days')).toHaveClass('uppercase')
})
it('FE-MOB-PLSH-003: clears the place selection when closed', () => {
@@ -413,6 +413,21 @@ describe('MTripShell', () => {
expect(shellApi.sheet).toBeNull()
})
it('FE-MOB-SHELL-097: while More is open the dock marks More, not the tab under it', () => {
renderShell()
const more = screen.getByRole('button', { name: 'mobileTrip.more' })
const plan = screen.getByRole('button', { name: 'Plan' })
expect(plan).toHaveAttribute('aria-current', 'page')
expect(more).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(more)
expect(more).toHaveAttribute('aria-expanded', 'true')
expect(more).toHaveClass('bg-m-act')
expect(plan).not.toHaveAttribute('aria-current')
act(() => { shellApi.closeSheet() })
expect(plan).toHaveAttribute('aria-current', 'page')
expect(more).not.toHaveClass('bg-m-act')
})
it('FE-MOB-SHELL-026: a non-plan tab replaces the plan chrome with its panel', () => {
renderShell({ activeTab: 'dateien' } as Partial<TripPlanner>)
expect(screen.getByTestId('tab-panel')).toHaveTextContent('dateien')
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'ما أدخلته هنا لم يُحفظ بعد.',
'common.keepEditing': 'متابعة التعديل',
'common.name': 'الاسم',
'common.title': 'العنوان',
'common.email': 'البريد الإلكتروني',
'common.password': 'كلمة المرور',
'common.saving': 'جارٍ الحفظ...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'Burada daxil etdikləriniz hələ yadda saxlanmayıb.',
'common.keepEditing': 'Redaktəyə davam et',
'common.name': 'Ad',
'common.title': 'Başlıq',
'common.email': 'E-poçt',
'common.password': 'Parol',
'common.saving': 'Saxlanılır...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'O que você inseriu aqui ainda não foi salvo.',
'common.keepEditing': 'Continuar editando',
'common.name': 'Nome',
'common.title': 'Título',
'common.email': 'E-mail',
'common.password': 'Senha',
'common.saving': 'Salvando...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': "El que has introduït aquí encara no s'ha desat.",
'common.keepEditing': 'Continua editant',
'common.name': 'Nom',
'common.title': 'Títol',
'common.email': 'Correu',
'common.password': 'Contrasenya',
'common.saving': 'Desant...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'To, co jste zde zadali, ještě není uložené.',
'common.keepEditing': 'Pokračovat v úpravách',
'common.name': 'Jméno',
'common.title': 'Název',
'common.email': 'E-mail',
'common.password': 'Heslo',
'common.saving': 'Ukládání...',
+1 -1
View File
@@ -1,6 +1,6 @@
import type { TranslationStrings } from '../types';
const airport: TranslationStrings = {
'airport.searchPlaceholder': 'Flughafencode oder Stadt (z. B. FRA)',
'airport.searchPlaceholder': 'Flughafen oder Stadt',
};
export default airport;
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'Was du hier eingegeben hast, ist noch nicht gespeichert.',
'common.keepEditing': 'Weiter bearbeiten',
'common.name': 'Name', // same-as-en
'common.title': 'Titel',
'common.email': 'E-Mail',
'common.password': 'Passwort',
'common.saving': 'Speichern...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'What you entered here has not been saved yet.',
'common.keepEditing': 'Keep editing',
'common.name': 'Name',
'common.title': 'Title',
'common.email': 'Email',
'common.password': 'Password',
'common.saving': 'Saving...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'Lo que has introducido aquí aún no se ha guardado.',
'common.keepEditing': 'Seguir editando',
'common.name': 'Nombre',
'common.title': 'Título',
'common.email': 'Correo',
'common.password': 'Contraseña',
'common.saving': 'Guardando...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'Siia sisestatu pole veel salvestatud.',
'common.keepEditing': 'Jätka muutmist',
'common.name': 'Nimi',
'common.title': 'Pealkiri',
'common.email': 'E-post',
'common.password': 'Parool',
'common.saving': 'Salvestamine...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': "Ce que vous avez saisi ici n'est pas encore enregistré.",
'common.keepEditing': 'Continuer la modification',
'common.name': 'Nom',
'common.title': 'Titre',
'common.email': 'E-mail',
'common.password': 'Mot de passe',
'common.saving': 'Enregistrement…',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'Όσα καταχωρίσατε εδώ δεν έχουν αποθηκευτεί ακόμη.',
'common.keepEditing': 'Συνέχεια επεξεργασίας',
'common.name': 'Όνομα',
'common.title': 'Τίτλος',
'common.email': 'Email',
'common.password': 'Κωδικός',
'common.saving': 'Αποθήκευση...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'מה שהזנתם כאן עדיין לא נשמר.',
'common.keepEditing': 'להמשיך לערוך',
'common.name': 'שם',
'common.title': 'כותרת',
'common.email': 'אימייל',
'common.password': 'סיסמה',
'common.saving': 'שומר...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'Amit itt megadtál, még nincs elmentve.',
'common.keepEditing': 'Szerkesztés folytatása',
'common.name': 'Név',
'common.title': 'Cím',
'common.email': 'E-mail',
'common.password': 'Jelszó',
'common.saving': 'Mentés...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'Yang kamu masukkan di sini belum disimpan.',
'common.keepEditing': 'Lanjut mengedit',
'common.name': 'Nama',
'common.title': 'Judul',
'common.email': 'Email', // same-as-en
'common.password': 'Kata sandi',
'common.saving': 'Menyimpan...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'Quello che hai inserito qui non è ancora stato salvato.',
'common.keepEditing': 'Continua a modificare',
'common.name': 'Nome',
'common.title': 'Titolo',
'common.email': 'Email', // same-as-en
'common.password': 'Password', // same-as-en
'common.saving': 'Salvataggio...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'ここで入力した内容はまだ保存されていません。',
'common.keepEditing': '編集を続ける',
'common.name': '名前',
'common.title': 'タイトル',
'common.email': 'メールアドレス',
'common.password': 'パスワード',
'common.saving': '保存中…',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': '여기에 입력한 내용은 아직 저장되지 않았습니다.',
'common.keepEditing': '계속 편집',
'common.name': '이름',
'common.title': '제목',
'common.email': '이메일',
'common.password': '비밀번호',
'common.saving': '저장 중...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'Wat je hier hebt ingevuld, is nog niet opgeslagen.',
'common.keepEditing': 'Verder bewerken',
'common.name': 'Naam',
'common.title': 'Titel',
'common.email': 'E-mail',
'common.password': 'Wachtwoord',
'common.saving': 'Opslaan...',
+1
View File
@@ -36,6 +36,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'To, co tu wpisano, nie zostało jeszcze zapisane.',
'common.keepEditing': 'Kontynuuj edycję',
'common.name': 'Nazwa',
'common.title': 'Tytuł',
'common.email': 'E-mail',
'common.password': 'Hasło',
'common.saving': 'Zapisywanie...',
+1 -1
View File
@@ -1,6 +1,6 @@
import type { TranslationStrings } from '../types';
const airport: TranslationStrings = {
'airport.searchPlaceholder': 'Код аэропорта или город (напр. FRA)',
'airport.searchPlaceholder': 'Аэропорт или город',
};
export default airport;
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'Введённое здесь ещё не сохранено.',
'common.keepEditing': 'Продолжить',
'common.name': 'Имя',
'common.title': 'Название',
'common.email': 'Эл. почта',
'common.password': 'Пароль',
'common.saving': 'Сохранение...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'To, čo ste tu zadali, ešte nie je uložené.',
'common.keepEditing': 'Pokračovať v úpravách',
'common.name': 'Meno',
'common.title': 'Názov',
'common.email': 'E-mail',
'common.password': 'Heslo',
'common.saving': 'Ukladá sa...',
+1
View File
@@ -52,6 +52,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'Det du har fyllt i här är inte sparat än.',
'common.keepEditing': 'Fortsätt redigera',
'common.name': 'Namn',
'common.title': 'Titel',
'common.email': 'E-post',
'common.password': 'Lösenord',
'common.saving': 'Sparar...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'สิ่งที่คุณกรอกไว้ที่นี่ยังไม่ได้บันทึก',
'common.keepEditing': 'แก้ไขต่อ',
'common.name': 'ชื่อ',
'common.title': 'ชื่อเรื่อง',
'common.email': 'อีเมล',
'common.password': 'รหัสผ่าน',
'common.saving': 'กำลังบันทึก...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'Buraya girdikleriniz henüz kaydedilmedi.',
'common.keepEditing': 'Düzenlemeye devam et',
'common.name': 'Ad',
'common.title': 'Başlık',
'common.email': 'E-posta',
'common.password': 'Şifre',
'common.saving': 'Kaydediliyor...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'Введене тут ще не збережено.',
'common.keepEditing': 'Продовжити редагування',
'common.name': "Ім'я",
'common.title': 'Назва',
'common.email': 'Ел. пошта',
'common.password': 'Пароль',
'common.saving': 'Збереження...',
+1
View File
@@ -52,6 +52,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': 'Những gì bạn nhập ở đây chưa được lưu.',
'common.keepEditing': 'Tiếp tục chỉnh sửa',
'common.name': 'Tên',
'common.title': 'Tiêu đề',
'common.email': 'Email', // same-as-en
'common.password': 'Mật khẩu',
'common.saving': 'Đang lưu...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': '你在這裡輸入的內容尚未儲存。',
'common.keepEditing': '繼續編輯',
'common.name': '名稱',
'common.title': '標題',
'common.email': '郵箱',
'common.password': '密碼',
'common.saving': '儲存中...',
+1
View File
@@ -40,6 +40,7 @@ const common: TranslationStrings = {
'common.unsavedMessage': '你在这里输入的内容尚未保存。',
'common.keepEditing': '继续编辑',
'common.name': '名称',
'common.title': '标题',
'common.email': '邮箱',
'common.password': '密码',
'common.saving': '保存中...',