mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(planner): give narrow screens one panel at a time, and unclip the public language picker
Three reports from the same week, all "the layout is in my way". #2247 — the Plan tab shows both side panels from 768px up, unconditionally. A Pixel 10 Pro Fold unfolded is ~860 CSS px, so 340 + 300 of panel left the map a 200px strip; those same widths became fit padding, shrinking the framing window to 140px and opening the camera two zoom levels out over half of East Asia. The floating POI/compass cluster is centred on the viewport at z-25, which at that width put it on top of both collapse tabs (z-1 inside z-20 containers) and on the Add Place/Activity button — and its pills set pointer-events: auto, so it ate the taps meant for them. The resize handles are mousedown-only, so a touch user could not narrow anything either. Between 768 and 1023px only one panel is open now, the day plan first, the places list one tap away — the Google Maps shape the reporter asked for. The collapse flags stay the wide-layout state, so a fold back restores what was there, and a marker tap reopening "both" no longer re-crowds the screen. The control cluster centres on the corridor the panels leave (the formula PlaceInspector and DayDetailPanel already use), the tabs finally have accessible names, the dead resize handles go, and a panel dragged to 520px on a desktop is clamped so the map keeps 360px without touching the stored width. computeMapViewport refuses to frame for less than a third of the box, so absurd padding can never pick the zoom again. #2248 — the public journey hero clips its own overflow to keep its decorative circles from widening the document, which also amputated the language menu at the hero's bottom edge: 8 of 23 locales, the rest unreachable. Both share pages inlined the same menu, so it is one component now, portalled to the body and anchored to its trigger the way CustomSelect is, with a height cap and its own scroll. It closes on Escape and on an outside click, which it never did, and `zh-TW` stops labelling itself 简体中文. #2249 — the note row's pencil/trash pill is absolutely positioned over the row's own reorder chevrons, and a coarse-pointer `opacity: 1 !important` pinned it permanently visible on tablets. On a mouse it was no better: the chevrons are revealed on hover, which is exactly when the opaque pill covers them, so note reordering has never been clickable on any pointer. The pill is gone; the row opens the note the way the phone shell already does, and delete lives in that dialog behind the same confirmation. A link in the note body keeps its own tap, on both shells. Closes #2247 Closes #2248 Closes #2249
This commit is contained in:
@@ -41,6 +41,10 @@ test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) =>
|
||||
}
|
||||
await page.reload()
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// An iPad is inside the 768-1023px band, where only one side panel is open at a
|
||||
// time so the map keeps a usable width (#2247). The places list is one tap away.
|
||||
await page.getByRole('button', { name: 'Places' }).click()
|
||||
await expect(page.getByText('Place 1').first()).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// The context must really be the one from the bug report: coarse pointer, desktop
|
||||
@@ -74,6 +78,8 @@ test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) =>
|
||||
})
|
||||
expect(['1', 'absent']).toContain(arrowOpacity)
|
||||
|
||||
// 4. The iPad must still get the desktop two-pane layout — isMobile stayed width-based.
|
||||
// 4. The iPad must still get the desktop shell — isMobile stayed width-based. One
|
||||
// panel at a time (#2247), so opening Places closed the day plan.
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Plan' })).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -1388,39 +1388,90 @@ describe('DayPlanSidebar', () => {
|
||||
expect(onDeletePlace).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
// ── Note card edit/delete buttons ─────────────────────────────────────
|
||||
// ── Note card editing (#2249) ─────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064: note card edit button calls openEditNote', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-064: clicking a note row opens its edit modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' })
|
||||
mockDayNotesState.dayNotes = { '10': [note] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
// Find note edit button (Pencil in note-edit-buttons)
|
||||
const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
|
||||
if (noteEditBtns.length > 0) {
|
||||
await user.click(noteEditBtns[0] as HTMLElement)
|
||||
expect(mockDayNotesState.openEditNote).toHaveBeenCalled()
|
||||
}
|
||||
await user.click(cardRow(screen.getByText('My note')))
|
||||
expect(mockDayNotesState.openEditNote).toHaveBeenCalledWith(10, expect.objectContaining({ id: 55 }))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-065: deleting a note asks for confirmation before calling deleteNote', async () => {
|
||||
// The bug: an absolutely-positioned pencil/trash pill sat on the note's own
|
||||
// reorder chevrons, and a coarse-pointer `opacity: 1 !important` in index.css
|
||||
// made it permanent on tablets. Nothing may float over that column again.
|
||||
it('FE-PLANNER-DAYPLAN-064b: the note row carries no floating action pill over its reorder buttons', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
const row = cardRow(screen.getByText('My note'))
|
||||
expect(document.querySelector('.note-edit-buttons')).toBeNull()
|
||||
expect(row.querySelectorAll('[style*="position: absolute"]')).toHaveLength(0)
|
||||
expect(row.querySelectorAll('.reorder-buttons button')).toHaveLength(2)
|
||||
// Positional, not just by class: the reorder column owns the row's right edge.
|
||||
expect(row.lastElementChild).toHaveClass('reorder-buttons')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064e: the keyboard reaches the note and opens it with Enter', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
const row = cardRow(screen.getByText('My note'))
|
||||
expect(row).toHaveAttribute('tabindex', '0')
|
||||
// A press-scale on a draggable row fights the drag, so the row opts out (#2158).
|
||||
expect(row).toHaveAttribute('data-no-press')
|
||||
fireEvent.keyDown(row, { key: 'Enter' })
|
||||
expect(mockDayNotesState.openEditNote).toHaveBeenCalledWith(10, expect.objectContaining({ id: 55 }))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064c: a link inside the note body opens the link, not the edit modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' })
|
||||
mockDayNotesState.dayNotes = { '10': [note] }
|
||||
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note', time: '[docs](https://example.com)' })] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
// Find note delete button (Trash2 in note-edit-buttons)
|
||||
const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
|
||||
if (noteEditBtns.length > 1) {
|
||||
await user.click(noteEditBtns[1] as HTMLElement)
|
||||
// Clicking delete opens a confirmation dialog rather than deleting immediately.
|
||||
expect(mockDayNotesState.deleteNote).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Delete note?')).toBeInTheDocument()
|
||||
// Confirming triggers the actual delete.
|
||||
await user.click(screen.getByRole('button', { name: /^delete$/i }))
|
||||
expect(mockDayNotesState.deleteNote).toHaveBeenCalled()
|
||||
}
|
||||
await user.click(screen.getByRole('link', { name: 'docs' }))
|
||||
expect(mockDayNotesState.openEditNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064d: the reorder chevrons still move the note instead of editing it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [
|
||||
buildDayNote({ id: 55, day_id: 10, text: 'First note', sort_order: 0 }),
|
||||
buildDayNote({ id: 56, day_id: 10, text: 'Second note', sort_order: 1 }),
|
||||
] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
const row = cardRow(screen.getByText('Second note'))
|
||||
await user.click(row.querySelectorAll('.reorder-buttons button')[0] as HTMLElement)
|
||||
expect(mockDayNotesState.moveNote).toHaveBeenCalled()
|
||||
expect(mockDayNotesState.openEditNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-065: the note modal deletes only through the confirmation dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
|
||||
mockDayNotesState.noteUi = { '10': { mode: 'edit', noteId: 55, text: 'My note', time: '', icon: 'FileText', color: null } }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
await user.click(screen.getByRole('button', { name: /^delete$/i }))
|
||||
expect(mockDayNotesState.deleteNote).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Delete note?')).toBeInTheDocument()
|
||||
const confirm = within(screen.getByText('Delete note?').closest('[class*="z-[10000]"]') as HTMLElement)
|
||||
await user.click(confirm.getByRole('button', { name: /^delete$/i }))
|
||||
expect(mockDayNotesState.deleteNote).toHaveBeenCalledWith(10, 55)
|
||||
// Otherwise the edit modal is left open on a note that no longer exists.
|
||||
expect(mockDayNotesState.cancelNote).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-065b: a note being created offers no delete', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [] }
|
||||
mockDayNotesState.noteUi = { '10': { mode: 'add', text: '', time: '', icon: 'FileText', color: null } }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
expect(screen.queryByRole('button', { name: /^delete$/i })).toBeNull()
|
||||
})
|
||||
|
||||
// ── Drop on assignment: same-day reorder ─────────────────────────────
|
||||
@@ -2940,6 +2991,10 @@ describe('DayPlanSidebar', () => {
|
||||
expect(screen.queryByLabelText('Add Note')).not.toBeInTheDocument()
|
||||
expect(dragRow(screen.getByText('Read only place'))).toBeNull()
|
||||
expect(screen.getByText('A note')).toBeInTheDocument()
|
||||
// A viewer must not reach the edit modal they could never save (#2249).
|
||||
const noteRow = cardRow(screen.getByText('A note'))
|
||||
expect(noteRow).not.toHaveAttribute('role', 'button')
|
||||
expect(noteRow.querySelectorAll('.reorder-buttons')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-132: the read-only chevron still collapses and expands the day', async () => {
|
||||
|
||||
@@ -696,19 +696,13 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const openEditNote = (dayId: number, note: DayNote, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
_openEditNote(dayId, note)
|
||||
}
|
||||
const openEditNote = (dayId: number, note: DayNote) => _openEditNote(dayId, note)
|
||||
|
||||
// Deleting a note asks for confirmation first — the edit/delete icons sit close together and are
|
||||
// easy to mis-tap on touch devices, where an accidental delete was previously unrecoverable.
|
||||
const [pendingDeleteNote, setPendingDeleteNote] = useState<{ dayId: number; noteId: number } | null>(null)
|
||||
|
||||
const deleteNote = async (dayId: number, noteId: number, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
await _deleteNote(dayId, noteId)
|
||||
}
|
||||
const deleteNote = (dayId: number, noteId: number) => _deleteNote(dayId, noteId)
|
||||
|
||||
// Unified reorder: assigns positions to ALL item types based on new visual order
|
||||
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
|
||||
@@ -2614,17 +2608,33 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
{ divider: true },
|
||||
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => setPendingDeleteNote({ dayId: day.id, noteId: note.id }) },
|
||||
]) : undefined}
|
||||
{...(canEditDays ? {
|
||||
role: 'button' as const,
|
||||
// No press-scale: index.css shrinks every [role=button] on
|
||||
// :active, which on a draggable row fights the drag (#2158).
|
||||
'data-no-press': '',
|
||||
tabIndex: 0,
|
||||
// The row is the note's edit affordance — the same gesture the
|
||||
// phone shell already uses (#2249). Links inside the rendered
|
||||
// body and the reorder buttons keep their own click.
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('a, button')) return
|
||||
openEditNote(day.id, note)
|
||||
},
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
openEditNote(day.id, note)
|
||||
},
|
||||
} : {})}
|
||||
onMouseEnter={e => {
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '1'
|
||||
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
|
||||
if (editBtns) editBtns.style.opacity = '1'
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '0.3'
|
||||
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
|
||||
if (editBtns) editBtns.style.opacity = '0'
|
||||
}}
|
||||
style={{
|
||||
position: 'relative',
|
||||
@@ -2636,7 +2646,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
|
||||
background: noteSkin.background,
|
||||
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
|
||||
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
||||
transition: 'background 0.1s', cursor: canEditDays ? 'pointer' : 'default', userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{canEditDays && !dragDisabled && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
@@ -2658,19 +2668,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Stacked in a capsule of their own, set off from the text:
|
||||
a note card is as tall as its body, so the height is free
|
||||
while the width next to the text is not, and a 10px icon
|
||||
in a 14px box was barely a target. */}
|
||||
{/* Floated over the text rather than sharing the row with
|
||||
it: a note is mostly text, and reserving a column for
|
||||
two buttons that are invisible until you hover costs
|
||||
every note a shorter line for the whole time you are
|
||||
not hovering. */}
|
||||
{canEditDays && <div className="note-edit-buttons bg-surface-card" style={{ position: 'absolute', right: 5, top: '50%', transform: 'translateY(-50%)', display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, padding: 4, borderRadius: 999, border: '1px solid var(--border-faint)', boxShadow: '0 4px 14px rgba(0,0,0,0.10)', opacity: 0, transition: 'opacity 0.15s' }}>
|
||||
<button type="button" onClick={e => openEditNote(day.id, note, e)} aria-label={t('dayplan.noteEdit')} title={t('dayplan.noteEdit')} className="text-content-faint note-edit-btn" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Pencil size={15} /></button>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }} aria-label={t('dayplan.noteDelete')} title={t('dayplan.noteDelete')} className="text-content-faint note-edit-btn note-edit-btn-danger" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Trash2 size={15} /></button>
|
||||
</div>}
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} className={noteIdx === merged.length - 1 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
|
||||
@@ -2877,6 +2874,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
noteInputRef={noteInputRef}
|
||||
cancelNote={cancelNote}
|
||||
saveNote={saveNote}
|
||||
onRequestDelete={(dayId, noteId) => setPendingDeleteNote({ dayId, noteId })}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -2892,7 +2890,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<ConfirmDialog
|
||||
isOpen={!!pendingDeleteNote}
|
||||
onClose={() => setPendingDeleteNote(null)}
|
||||
onConfirm={() => { if (pendingDeleteNote) deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId) }}
|
||||
onConfirm={() => {
|
||||
if (!pendingDeleteNote) return
|
||||
// Close the edit modal behind the confirm; leaving it open would go on
|
||||
// editing a note that no longer exists.
|
||||
cancelNote(pendingDeleteNote.dayId)
|
||||
deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId)
|
||||
}}
|
||||
title={t('dayplan.confirmDeleteNoteTitle')}
|
||||
message={t('dayplan.confirmDeleteNoteBody')}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { markdownLinkComponents } from '../shared/markdownLink'
|
||||
|
||||
interface NoteModalUi {
|
||||
mode: 'add' | 'edit'
|
||||
noteId?: number
|
||||
icon: string
|
||||
text: string
|
||||
time: string
|
||||
@@ -25,6 +26,8 @@ interface DayPlanSidebarNoteModalProps {
|
||||
noteInputRef: React.RefObject<HTMLInputElement>
|
||||
cancelNote: (dayId: number) => void
|
||||
saveNote: (dayId: number) => void
|
||||
/** Asks the planner to confirm and then delete the note being edited (#2249). */
|
||||
onRequestDelete: (dayId: number, noteId: number) => void
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}
|
||||
|
||||
@@ -38,19 +41,19 @@ const BODY_MAX = 2000
|
||||
* right. The preview is the reason the colour picker is worth having — a swatch
|
||||
* row tells you nothing about what a tinted card looks like next to a place.
|
||||
*/
|
||||
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, t }: DayPlanSidebarNoteModalProps) {
|
||||
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, onRequestDelete, t }: DayPlanSidebarNoteModalProps) {
|
||||
return (
|
||||
<>
|
||||
{Object.entries(noteUi).map(([dayId, ui]) => ui && createPortal(
|
||||
<NoteDialog key={dayId} dayId={dayId} ui={ui} setNoteUi={setNoteUi} noteInputRef={noteInputRef}
|
||||
cancelNote={cancelNote} saveNote={saveNote} t={t} />,
|
||||
cancelNote={cancelNote} saveNote={saveNote} onRequestDelete={onRequestDelete} t={t} />,
|
||||
document.body
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote, t }: Omit<DayPlanSidebarNoteModalProps, 'noteUi'> & {
|
||||
function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote, onRequestDelete, t }: Omit<DayPlanSidebarNoteModalProps, 'noteUi'> & {
|
||||
dayId: string
|
||||
ui: NoteModalUi
|
||||
}) {
|
||||
@@ -174,7 +177,17 @@ function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{/* Delete lives here rather than on the row: the row's floating pencil/trash
|
||||
pill covered the reorder chevrons and, on a coarse pointer, never went
|
||||
away (#2249). The planner still confirms before it deletes. */}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||
{ui.mode === 'edit' && ui.noteId != null && (
|
||||
<button type="button" onClick={() => onRequestDelete(Number(dayId), ui.noteId!)}
|
||||
className="text-danger"
|
||||
style={{ marginRight: 'auto', fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button type="button" onClick={() => saveNote(Number(dayId))} disabled={!canSave}
|
||||
className={!canSave ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { SUPPORTED_LANGUAGES } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import PublicLanguagePicker from './PublicLanguagePicker'
|
||||
|
||||
describe('PublicLanguagePicker', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, language: 'en' } }))
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-001: the trigger keeps the current language as its accessible name', () => {
|
||||
render(<PublicLanguagePicker locale="de-DE" open={false} onOpenChange={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Deutsch' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// `br` and `zh-TW` are whole values, not prefixes: matching the prefix first
|
||||
// labelled Brazilian Portuguese "Language" and zh-TW "简体中文".
|
||||
it('FE-PUBLANG-001b: a hyphenated or non-ISO stored value labels itself correctly', () => {
|
||||
const { unmount } = render(<PublicLanguagePicker locale="br" open={false} onOpenChange={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Português (Brasil)' })).toBeInTheDocument()
|
||||
unmount()
|
||||
render(<PublicLanguagePicker locale="zh-TW" open={false} onOpenChange={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: '繁體中文' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-002: an unknown locale falls back to the generic label', () => {
|
||||
render(<PublicLanguagePicker locale="xx" open={false} onOpenChange={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Language' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-003: closed renders no options at all', () => {
|
||||
render(<PublicLanguagePicker locale="en" open={false} onOpenChange={vi.fn()} />)
|
||||
expect(screen.queryByTestId('public-language-menu')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Deutsch' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-004: open lists every supported language', () => {
|
||||
render(<PublicLanguagePicker locale="en" open onOpenChange={vi.fn()} />)
|
||||
const menu = within(screen.getByTestId('public-language-menu'))
|
||||
for (const lang of SUPPORTED_LANGUAGES) {
|
||||
expect(menu.getByRole('button', { name: lang.label })).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
// The regression guard for #2248: the journey hero clips its own overflow, so a
|
||||
// menu rendered inside the page tree is amputated at the hero's bottom edge.
|
||||
it('FE-PUBLANG-005: the menu is portalled straight to the document body', () => {
|
||||
const { container } = render(<PublicLanguagePicker locale="en" open onOpenChange={vi.fn()} />)
|
||||
const menu = screen.getByTestId('public-language-menu')
|
||||
expect(menu.parentElement).toBe(document.body)
|
||||
expect(container.contains(menu)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-006: the menu is fixed, capped in height and scrolls its own overflow', () => {
|
||||
render(<PublicLanguagePicker locale="en" open onOpenChange={vi.fn()} />)
|
||||
const menu = screen.getByTestId('public-language-menu')
|
||||
expect(menu.style.position).toBe('fixed')
|
||||
expect(menu.style.overflowY).toBe('auto')
|
||||
expect(menu.style.overscrollBehavior).toBe('contain')
|
||||
expect(Number.parseInt(menu.style.maxHeight, 10)).toBeLessThanOrEqual(320)
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-007: the trigger toggles through an updater so a controlled parent stays in sync', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<PublicLanguagePicker locale="en" open={false} onOpenChange={onOpenChange} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'English' }))
|
||||
const toggle = onOpenChange.mock.calls[0][0] as (v: boolean) => boolean
|
||||
expect(toggle(false)).toBe(true)
|
||||
expect(toggle(true)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-008: picking a language stores it locally and closes the menu', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<PublicLanguagePicker locale="en" open onOpenChange={onOpenChange} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Deutsch' }))
|
||||
expect(useSettingsStore.getState().settings.language).toBe('de')
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-009: Escape closes the menu', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<PublicLanguagePicker locale="en" open onOpenChange={onOpenChange} />)
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-010: a pointer press outside closes it, one on the trigger or the menu does not', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
const { container } = render(<PublicLanguagePicker locale="en" open onOpenChange={onOpenChange} />)
|
||||
fireEvent.mouseDown(within(container).getByRole('button', { name: 'English' }))
|
||||
fireEvent.mouseDown(screen.getByRole('button', { name: 'Deutsch' }))
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
fireEvent.mouseDown(document.body)
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-011: hovering an option paints and clears its background', () => {
|
||||
render(<PublicLanguagePicker locale="en" open onOpenChange={vi.fn()} />)
|
||||
const option = screen.getByRole('button', { name: 'Deutsch' })
|
||||
fireEvent.mouseEnter(option)
|
||||
expect(option.style.background).toBe('var(--bg-hover)')
|
||||
fireEvent.mouseLeave(option)
|
||||
expect(option.style.background).toBe('none')
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-012: the current language is marked for assistive tech', () => {
|
||||
render(<PublicLanguagePicker locale="de" open onOpenChange={vi.fn()} />)
|
||||
const menu = within(screen.getByTestId('public-language-menu'))
|
||||
expect(menu.getByRole('button', { name: 'Deutsch' })).toHaveAttribute('aria-current', 'true')
|
||||
expect(menu.getByRole('button', { name: 'English' })).toHaveAttribute('aria-current', 'false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { SUPPORTED_LANGUAGES } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
|
||||
|
||||
/** Wide enough for the longest label ("Português (Brasil)") at the default text size. */
|
||||
const MENU_MIN_WIDTH = 190
|
||||
const MENU_MAX_HEIGHT = 320
|
||||
|
||||
interface PublicLanguagePickerProps {
|
||||
/** Current UI locale, e.g. `de` or `pt-BR`. */
|
||||
locale?: string | null
|
||||
open: boolean
|
||||
onOpenChange: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
/**
|
||||
* The language pill both anonymous share pages carry in their top-right corner (#2248).
|
||||
*
|
||||
* Each page used to inline the same absolutely-positioned menu. On the journey
|
||||
* page that menu sits inside a hero with `overflow: hidden` — the clip that keeps
|
||||
* the hero's decorative circles from widening the document — so the list of 23
|
||||
* languages was amputated at the hero's bottom edge and most of it could never be
|
||||
* picked. The menu is portalled to `document.body` and positioned against the
|
||||
* trigger now, which no ancestor can clip, and it caps its own height at the room
|
||||
* the viewport actually has: the treatment `CustomSelect` already gets.
|
||||
*/
|
||||
export default function PublicLanguagePicker({ locale, open, onOpenChange }: PublicLanguagePickerProps) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
// matchWidth stays on: the anchored box's `width` is the trigger's own width,
|
||||
// which is what the right-edge alignment below needs.
|
||||
const anchored = useAnchoredPosition(wrapRef, open, { estimatedHeight: MENU_MAX_HEIGHT, offset: 6 })
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
if (wrapRef.current?.contains(e.target as Node)) return
|
||||
if (menuRef.current?.contains(e.target as Node)) return
|
||||
onOpenChange(false)
|
||||
}
|
||||
const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onOpenChange(false) }
|
||||
document.addEventListener('mousedown', onPointerDown)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [open, onOpenChange])
|
||||
|
||||
// The two pages hand in different shapes: the journey page passes the stored
|
||||
// setting (`br`, `zh-TW`), the trip page the resolved locale (`de-DE`). Try the
|
||||
// whole string before its prefix, or `zh-TW` would label itself 简体中文.
|
||||
const raw = locale || 'en'
|
||||
const current = SUPPORTED_LANGUAGES.some(l => l.value === raw) ? raw : raw.split('-')[0]
|
||||
// Pinned by its RIGHT edge, the way `right: 0` did inside the trigger's box. That
|
||||
// also lets the panel size to its content: a reader on the largest text setting
|
||||
// gets a wider menu instead of "Português (Brasil)" wrapping to two lines.
|
||||
const right = anchored
|
||||
? Math.max(8, (typeof window === 'undefined' ? 0 : window.innerWidth) - anchored.left - anchored.width)
|
||||
: 8
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} style={{ position: 'absolute', top: 12, right: 12, zIndex: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
onClick={() => onOpenChange(v => !v)}
|
||||
className="bg-[rgba(255,255,255,0.1)] text-[rgba(255,255,255,0.7)]"
|
||||
style={{
|
||||
padding: '5px 12px',
|
||||
borderRadius: 20,
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.find(l => l.value === current)?.label || 'Language'}
|
||||
</button>
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
data-testid="public-language-menu"
|
||||
className="bg-surface-card border border-edge"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
...(anchored?.flipped ? { bottom: anchored.bottom } : { top: anchored?.top ?? 0 }),
|
||||
right,
|
||||
minWidth: MENU_MIN_WIDTH,
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
// 23 languages do not fit beside a short viewport; scroll them there
|
||||
// rather than letting the list run past the bottom of the screen.
|
||||
maxHeight: Math.min(MENU_MAX_HEIGHT, anchored?.maxHeight ?? MENU_MAX_HEIGHT),
|
||||
overflowY: 'auto',
|
||||
// Portalled and fixed, so the scroll chain runs to the viewport: without
|
||||
// this a flick past either end scrolls the page behind it instead (#2078).
|
||||
overscrollBehavior: 'contain',
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||||
padding: 4,
|
||||
zIndex: 99999,
|
||||
}}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map(lang => (
|
||||
<button
|
||||
type="button"
|
||||
key={lang.value}
|
||||
aria-current={lang.value === current}
|
||||
onClick={() => {
|
||||
// No API call: a share-link visitor has no account to save it to.
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, language: lang.value } }))
|
||||
onOpenChange(false)
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-hover)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'none')}
|
||||
className="text-content-secondary"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '7px 12px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
fontWeight: lang.value === current ? 600 : 400,
|
||||
borderRadius: 6,
|
||||
fontFamily: 'inherit',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
const MIN_SIDEBAR = 200
|
||||
const MAX_SIDEBAR = 520
|
||||
/**
|
||||
* The band where the desktop planner renders but two panels leave the map a
|
||||
* useless strip. Below 768 the phone shell takes over, so the squeeze lives
|
||||
* entirely between there and Tailwind's `lg`: a Pixel 10 Pro Fold unfolded is
|
||||
* ~860 CSS px, where 340 + 300 of panel left the map 200 px (#2247).
|
||||
*/
|
||||
const NARROW_QUERY = '(min-width: 768px) and (max-width: 1023px)'
|
||||
/** However wide a panel was dragged on a big screen, the map keeps this much. */
|
||||
const MIN_MAP = 360
|
||||
|
||||
export function useResizablePanels() {
|
||||
const [leftWidth, setLeftWidth] = useState<number>(() => Number.parseInt(localStorage.getItem('sidebarLeftWidth') || '') || 340)
|
||||
@@ -11,6 +20,34 @@ export function useResizablePanels() {
|
||||
const isResizingLeft = useRef(false)
|
||||
const isResizingRight = useRef(false)
|
||||
|
||||
const [narrow, setNarrow] = useState(
|
||||
() => typeof window !== 'undefined' && window.matchMedia(NARROW_QUERY).matches,
|
||||
)
|
||||
// Which panel owns the screen while only one of them fits. The day plan is what
|
||||
// the Plan tab is about, so it starts open and the places list is one tap away;
|
||||
// null is map-only. Deliberately separate from the two collapse flags, which stay
|
||||
// the wide-layout state — folding back to a wide window restores what was there.
|
||||
const [narrowPanel, setNarrowPanel] = useState<'left' | 'right' | null>('left')
|
||||
const [maxPanel, setMaxPanel] = useState(Number.POSITIVE_INFINITY)
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(NARROW_QUERY)
|
||||
const handler = (e: MediaQueryListEvent) => setNarrow(e.matches)
|
||||
setNarrow(mq.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
// Only measured inside the narrow band: that is the only place the clamp can
|
||||
// bite, and a resize listener on every planner mount is not worth the rest.
|
||||
useEffect(() => {
|
||||
if (!narrow) { setMaxPanel(Number.POSITIVE_INFINITY); return }
|
||||
const measure = () => setMaxPanel(Math.max(MIN_SIDEBAR, window.innerWidth - MIN_MAP - 20))
|
||||
measure()
|
||||
window.addEventListener('resize', measure)
|
||||
return () => window.removeEventListener('resize', measure)
|
||||
}, [narrow])
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (isResizingLeft.current) {
|
||||
@@ -41,5 +78,27 @@ export function useResizablePanels() {
|
||||
const startResizeLeft = () => { isResizingLeft.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }
|
||||
const startResizeRight = () => { isResizingRight.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }
|
||||
|
||||
return { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight }
|
||||
// What the layout actually shows. The collapse flags stay the caller's intent —
|
||||
// handlePlaceClick reopening "both" must not re-crowd a narrow screen.
|
||||
const leftHidden = narrow ? narrowPanel !== 'left' : leftCollapsed
|
||||
const rightHidden = narrow ? narrowPanel !== 'right' : rightCollapsed
|
||||
|
||||
const toggleLeft = useCallback(() => {
|
||||
if (narrow) setNarrowPanel(p => (p === 'left' ? null : 'left'))
|
||||
else setLeftCollapsed(c => !c)
|
||||
}, [narrow])
|
||||
const toggleRight = useCallback(() => {
|
||||
if (narrow) setNarrowPanel(p => (p === 'right' ? null : 'right'))
|
||||
else setRightCollapsed(c => !c)
|
||||
}, [narrow])
|
||||
|
||||
return {
|
||||
// Clamped for the layout; the stored width is left alone, so a 520 px panel
|
||||
// dragged on a desktop comes back untouched once there is room for it again.
|
||||
leftWidth: Math.min(leftWidth, maxPanel),
|
||||
rightWidth: Math.min(rightWidth, maxPanel),
|
||||
leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed,
|
||||
leftHidden, rightHidden, toggleLeft, toggleRight, narrow,
|
||||
startResizeLeft, startResizeRight,
|
||||
}
|
||||
}
|
||||
|
||||
+3
-18
@@ -232,8 +232,9 @@ html.dark .border-dashed.border-gray-300 { border-color: var(--border-primary) !
|
||||
html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color: transparent !important; }
|
||||
|
||||
/* Reorder buttons: desktop = original style; touch = always visible, larger touch targets.
|
||||
Keyed on pointer as well as width — a tablet is a wide touch device, and drag reorder is
|
||||
off wherever the pointer is coarse (#1432), so these buttons are the only affordance left. */
|
||||
Keyed on pointer as well as width — a tablet is a wide touch device with no hover, so
|
||||
without this the buttons would never appear there. (Drag reorder itself is no longer off
|
||||
on a coarse pointer: #1616 gave tablets the long-press bridge.) */
|
||||
.reorder-buttons {
|
||||
flex-direction: column;
|
||||
opacity: 0;
|
||||
@@ -270,9 +271,6 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
.note-edit-buttons {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Packing list rows: on phones, keep the item name readable and move the dense
|
||||
@@ -1290,19 +1288,6 @@ img[alt="TREK"] {
|
||||
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
|
||||
.collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; }
|
||||
|
||||
/* Note card actions: stacked beside the card, and big enough to aim at. */
|
||||
.note-edit-btn {
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.note-edit-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.note-edit-btn-danger:hover {
|
||||
background: rgba(220, 38, 38, 0.13);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Day-plan header action grid (edit / +transport / note / collapse) */
|
||||
.dp-day-actions button {
|
||||
color: var(--text-faint);
|
||||
|
||||
@@ -547,7 +547,8 @@ export function NoteRow({ note, chrome, reorder, drag, onEdit }: {
|
||||
{...dragProps(drag)}
|
||||
role={chrome.editing ? 'button' : undefined}
|
||||
tabIndex={chrome.editing ? 0 : undefined}
|
||||
onClick={chrome.editing ? onEdit : undefined}
|
||||
// A link in the rendered body keeps its own tap; the rest of the row edits.
|
||||
onClick={chrome.editing ? (e => { if (!(e.target as HTMLElement).closest('a')) onEdit() }) : undefined}
|
||||
onKeyDown={chrome.editing ? (e => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onEdit() } }) : undefined}
|
||||
className={`my-[2px] flex items-center gap-2.5 ${chrome.editing ? 'cursor-pointer' : ''} ${dragClass(drag)}`}
|
||||
>
|
||||
|
||||
@@ -28,8 +28,8 @@ import MobileEntryView from '../components/Journey/MobileEntryView';
|
||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline';
|
||||
import PhotoLightbox from '../components/Journey/PhotoLightbox';
|
||||
import EmptyState from '../components/shared/EmptyState';
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import PublicLanguagePicker from '../components/shared/PublicLanguagePicker';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { formatLocationName } from '../utils/formatters';
|
||||
import { useJourneyPublic } from './journeyPublic/useJourneyPublic';
|
||||
|
||||
@@ -616,70 +616,7 @@ export default function JourneyPublicPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Language picker */}
|
||||
<div style={{ position: 'absolute', top: 12, right: 12, zIndex: 10 }}>
|
||||
<button type="button"
|
||||
onClick={() => setShowLangPicker((v) => !v)}
|
||||
style={{
|
||||
padding: '5px 12px',
|
||||
borderRadius: 20,
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.find((l) => l.value === (locale?.split('-')[0] || 'en'))?.label || 'Language'}
|
||||
</button>
|
||||
{showLangPicker && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
marginTop: 6,
|
||||
background: 'white',
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
|
||||
padding: 4,
|
||||
zIndex: 50,
|
||||
minWidth: 150,
|
||||
}}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<button
|
||||
type="button"
|
||||
key={lang.value}
|
||||
onClick={() => {
|
||||
useSettingsStore.setState((s) => ({ settings: { ...s.settings, language: lang.value } }));
|
||||
setShowLangPicker(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '6px 12px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
color: '#374151',
|
||||
borderRadius: 6,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#f3f4f6')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PublicLanguagePicker locale={locale} open={showLangPicker} onOpenChange={setShowLangPicker} />
|
||||
|
||||
{/* Logo */}
|
||||
<div
|
||||
|
||||
@@ -237,7 +237,7 @@ describe('JourneyPublicPage wiring', () => {
|
||||
const { hook } = setup({ showLangPicker: true });
|
||||
const german = screen.getByRole('button', { name: 'Deutsch' });
|
||||
fireEvent.mouseEnter(german);
|
||||
expect(german).toHaveStyle({ background: 'rgb(243, 244, 246)' });
|
||||
expect(german).toHaveStyle({ background: 'var(--bg-hover)' });
|
||||
fireEvent.mouseLeave(german);
|
||||
|
||||
fireEvent.click(german);
|
||||
|
||||
@@ -1317,7 +1317,7 @@ describe('SharedTripPage', () => {
|
||||
const option = screen.getByRole('button', { name: /deutsch/i });
|
||||
|
||||
fireEvent.mouseEnter(option);
|
||||
expect(option.style.background).toBe('rgb(243, 244, 246)');
|
||||
expect(option.style.background).toBe('var(--bg-hover)');
|
||||
fireEvent.mouseLeave(option);
|
||||
expect(option.style.background).toBe('none');
|
||||
});
|
||||
|
||||
@@ -19,10 +19,10 @@ import { createElement, useEffect, useRef } from 'react';
|
||||
import { renderIconMarkup } from '../utils/iconMarkup';
|
||||
import { MapContainer, Marker, Polyline, TileLayer, Tooltip, useMap } from 'react-leaflet';
|
||||
import { getCategoryIcon } from '../components/shared/categoryIcons';
|
||||
import PublicLanguagePicker from '../components/shared/PublicLanguagePicker';
|
||||
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, attributionForTile } from '../constants/mapDefaults';
|
||||
import VectorBasemap from '../components/Map/VectorBasemap';
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { avatarSrc } from '../utils/avatarSrc';
|
||||
import { safeHexColor } from '../utils/safeColor';
|
||||
import { getMergedItems, getTransportForDay, hidesOnMiddleDay } from '../utils/dayMerge';
|
||||
@@ -335,70 +335,7 @@ export default function SharedTripPage() {
|
||||
{t('shared.readOnly')}
|
||||
</div>
|
||||
|
||||
{/* Language picker - top right */}
|
||||
<div style={{ position: 'absolute', top: 12, right: 12, zIndex: 10 }}>
|
||||
<button type="button"
|
||||
onClick={() => setShowLangPicker((v) => !v)}
|
||||
className="bg-[rgba(255,255,255,0.1)] text-[rgba(255,255,255,0.7)]"
|
||||
style={{
|
||||
padding: '5px 12px',
|
||||
borderRadius: 20,
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.find((l) => l.value === (locale?.split('-')[0] || 'en'))?.label || 'Language'}
|
||||
</button>
|
||||
{showLangPicker && (
|
||||
<div
|
||||
className="bg-white"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
marginTop: 6,
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
|
||||
padding: 4,
|
||||
zIndex: 50,
|
||||
minWidth: 150,
|
||||
}}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<button
|
||||
type="button"
|
||||
key={lang.value}
|
||||
onClick={() => {
|
||||
// Set language locally without API call (shared page has no auth)
|
||||
useSettingsStore.setState((s) => ({ settings: { ...s.settings, language: lang.value } }));
|
||||
setShowLangPicker(false);
|
||||
}}
|
||||
className="text-[#374151]"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '6px 12px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
borderRadius: 6,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#f3f4f6')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PublicLanguagePicker locale={locale} open={showLangPicker} onOpenChange={setShowLangPicker} />
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 900, margin: '0 auto', padding: '20px 16px' }}>
|
||||
|
||||
@@ -38,7 +38,6 @@ import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsAp
|
||||
import { accommodationRepo } from '../repo/accommodationRepo'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import { useResizablePanels } from '../hooks/useResizablePanels'
|
||||
import { useTripWebSocket } from '../hooks/useTripWebSocket'
|
||||
import { useRouteCalculation } from '../hooks/useRouteCalculation'
|
||||
import { usePlaceSelection } from '../hooks/usePlaceSelection'
|
||||
@@ -246,7 +245,9 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
||||
leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight,
|
||||
leftWidth, rightWidth,
|
||||
leftHidden, rightHidden, toggleLeft, toggleRight, narrowPanels,
|
||||
startResizeLeft, startResizeRight,
|
||||
selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment,
|
||||
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
|
||||
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
|
||||
@@ -327,6 +328,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
}
|
||||
if (!trip) return null
|
||||
|
||||
// What each panel actually occupies right now, and where that leaves the
|
||||
// strip of map between them. The panels float 10px inside the map, so the
|
||||
// corridor starts past that margin.
|
||||
const leftPanelPx = leftHidden ? 0 : leftWidth
|
||||
const rightPanelPx = rightHidden ? 0 : rightWidth
|
||||
const mapInsetLeft = leftPanelPx ? leftPanelPx + 10 : 0
|
||||
const mapInsetRight = rightPanelPx ? rightPanelPx + 10 : 0
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
|
||||
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
|
||||
@@ -381,8 +390,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
tileUrl={mapTileUrl}
|
||||
fitKey={fitKey}
|
||||
dayOrderMap={dayOrderMap}
|
||||
leftWidth={leftCollapsed ? 0 : leftWidth}
|
||||
rightWidth={rightCollapsed ? 0 : rightWidth}
|
||||
leftWidth={leftPanelPx}
|
||||
rightWidth={rightPanelPx}
|
||||
hasInspector={!!selectedPlace}
|
||||
hasDayDetail={!!showDayDetail && !selectedPlace}
|
||||
reservations={reservations}
|
||||
@@ -399,7 +408,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
/>
|
||||
|
||||
{(poiPillEnabled || glMap) && (
|
||||
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none', alignItems: 'flex-start', gap: 8 }}>
|
||||
<div className="hidden md:flex" style={{
|
||||
position: 'absolute', top: 14,
|
||||
// Centred on the corridor the panels leave, not on the viewport: at
|
||||
// 860px the viewport centre sits under the Places panel, where this
|
||||
// cluster covered both collapse tabs and Add Place/Activity (#2247).
|
||||
left: `calc(${mapInsetLeft}px + (100% - ${mapInsetLeft}px - ${mapInsetRight}px) / 2)`,
|
||||
transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none', alignItems: 'flex-start', gap: 8,
|
||||
}}>
|
||||
{poiPillEnabled && (
|
||||
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} errorKeys={poi.errorKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
|
||||
)}
|
||||
@@ -425,30 +441,32 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
)}
|
||||
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
<button type="button" onClick={() => setLeftCollapsed(c => !c)}
|
||||
<button type="button" onClick={toggleLeft}
|
||||
aria-label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')}
|
||||
title={leftHidden ? t('trip.mobilePlan') : t('common.collapse')}
|
||||
style={{
|
||||
position: leftCollapsed ? 'fixed' : 'absolute', top: leftCollapsed ? 'calc(var(--nav-h) + 44px + 14px)' : 14, left: leftCollapsed ? 10 : undefined, right: leftCollapsed ? undefined : -28, zIndex: -1,
|
||||
width: 36, height: 36, borderRadius: leftCollapsed ? 10 : '0 10px 10px 0',
|
||||
background: leftCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow: leftCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
||||
position: leftHidden ? 'fixed' : 'absolute', top: leftHidden ? 'calc(var(--nav-h) + 44px + 14px)' : 14, left: leftHidden ? 10 : undefined, right: leftHidden ? undefined : -28, zIndex: -1,
|
||||
width: 36, height: 36, borderRadius: leftHidden ? 10 : '0 10px 10px 0',
|
||||
background: leftHidden ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow: leftHidden ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: leftCollapsed ? '#fff' : 'var(--text-faint)', transition: 'color 0.15s',
|
||||
color: leftHidden ? '#fff' : 'var(--text-faint)', transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!leftCollapsed) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!leftCollapsed) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
{leftCollapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
|
||||
onMouseEnter={e => { if (!leftHidden) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!leftHidden) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
{leftHidden ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
|
||||
</button>
|
||||
|
||||
<div style={{
|
||||
width: leftCollapsed ? 0 : leftWidth, height: '100%',
|
||||
width: leftHidden ? 0 : leftWidth, height: '100%',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
boxShadow: leftCollapsed ? 'none' : 'var(--sidebar-shadow)',
|
||||
boxShadow: leftHidden ? 'none' : 'var(--sidebar-shadow)',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
transition: 'width 0.25s ease',
|
||||
opacity: leftCollapsed ? 0 : 1,
|
||||
opacity: leftHidden ? 0 : 1,
|
||||
}}>
|
||||
<DayPlanSidebar
|
||||
isMobile={isMobile}
|
||||
@@ -501,7 +519,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onRouteRefresh={() => { if (selectedDayId) updateRouteForDay(selectedDayId) }}
|
||||
onAddBookingToAssignment={can('day_edit', trip) ? (dayId, assignmentId) => { tripActions.setSelectedDay(dayId); setBookingForAssignmentId(assignmentId); setEditingReservation(null); setShowReservationModal(true) } : undefined}
|
||||
/>
|
||||
{!leftCollapsed && (
|
||||
{!leftHidden && !narrowPanels && (
|
||||
<div
|
||||
role="presentation"
|
||||
onMouseDown={startResizeLeft}
|
||||
@@ -514,32 +532,34 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block" style={{ position: 'absolute', right: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
<button type="button" onClick={() => setRightCollapsed(c => !c)}
|
||||
<button type="button" onClick={toggleRight}
|
||||
aria-label={rightHidden ? t('trip.mobilePlaces') : t('common.collapse')}
|
||||
title={rightHidden ? t('trip.mobilePlaces') : t('common.collapse')}
|
||||
style={{
|
||||
position: rightCollapsed ? 'fixed' : 'absolute', top: rightCollapsed ? 'calc(var(--nav-h) + 44px + 14px)' : 14, right: rightCollapsed ? 10 : undefined, left: rightCollapsed ? undefined : -28, zIndex: -1,
|
||||
width: 36, height: 36, borderRadius: rightCollapsed ? 10 : '10px 0 0 10px',
|
||||
background: rightCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow: rightCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
||||
position: rightHidden ? 'fixed' : 'absolute', top: rightHidden ? 'calc(var(--nav-h) + 44px + 14px)' : 14, right: rightHidden ? 10 : undefined, left: rightHidden ? undefined : -28, zIndex: -1,
|
||||
width: 36, height: 36, borderRadius: rightHidden ? 10 : '10px 0 0 10px',
|
||||
background: rightHidden ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow: rightHidden ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: rightCollapsed ? '#fff' : 'var(--text-faint)', transition: 'color 0.15s',
|
||||
color: rightHidden ? '#fff' : 'var(--text-faint)', transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!rightCollapsed) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!rightCollapsed) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
{rightCollapsed ? <PanelRightOpen size={16} /> : <PanelRightClose size={16} />}
|
||||
onMouseEnter={e => { if (!rightHidden) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!rightHidden) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
{rightHidden ? <PanelRightOpen size={16} /> : <PanelRightClose size={16} />}
|
||||
</button>
|
||||
|
||||
<div style={{
|
||||
width: rightCollapsed ? 0 : rightWidth, height: '100%',
|
||||
width: rightHidden ? 0 : rightWidth, height: '100%',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
boxShadow: rightCollapsed ? 'none' : 'var(--sidebar-shadow)',
|
||||
boxShadow: rightHidden ? 'none' : 'var(--sidebar-shadow)',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
transition: 'width 0.25s ease',
|
||||
opacity: rightCollapsed ? 0 : 1,
|
||||
opacity: rightHidden ? 0 : 1,
|
||||
}}>
|
||||
{!rightCollapsed && (
|
||||
{!rightHidden && !narrowPanels && (
|
||||
<div
|
||||
role="presentation"
|
||||
onMouseDown={startResizeRight}
|
||||
@@ -614,8 +634,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
weatherPlaceName={weatherPlaceName}
|
||||
onClose={() => { setShowDayDetail(null); handleSelectDay(null) }}
|
||||
onAccommodationChange={loadAccommodations}
|
||||
leftWidth={isMobile ? 0 : (leftCollapsed ? 0 : leftWidth)}
|
||||
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
||||
leftWidth={isMobile ? 0 : leftPanelPx}
|
||||
rightWidth={isMobile ? 0 : rightPanelPx}
|
||||
collapsed={dayDetailCollapsed}
|
||||
onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
|
||||
mobile={isMobile}
|
||||
@@ -659,8 +679,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }}
|
||||
onUploadImage={async (placeId, file) => { await tripActions.uploadPlaceImage(tripId, placeId, file) }}
|
||||
onRate={async (placeId, rating) => { try { await tripActions.ratePlace(tripId, placeId, rating) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }}
|
||||
leftWidth={(isMobile || window.innerWidth < 900) ? 0 : (leftCollapsed ? 0 : leftWidth)}
|
||||
rightWidth={(isMobile || window.innerWidth < 900) ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
||||
leftWidth={isMobile ? 0 : leftPanelPx}
|
||||
rightWidth={isMobile ? 0 : rightPanelPx}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -165,6 +165,11 @@ function baseState(): HookState {
|
||||
rightCollapsed: false,
|
||||
setLeftCollapsed: vi.fn(),
|
||||
setRightCollapsed: vi.fn(),
|
||||
leftHidden: false,
|
||||
rightHidden: false,
|
||||
toggleLeft: vi.fn(),
|
||||
toggleRight: vi.fn(),
|
||||
narrowPanels: false,
|
||||
startResizeLeft: vi.fn(),
|
||||
startResizeRight: vi.fn(),
|
||||
selectedPlaceId: null,
|
||||
@@ -371,7 +376,7 @@ describe('TripPlannerPage — plan tab', () => {
|
||||
})
|
||||
|
||||
it('FE-PAGE-TPW-009: collapsed panels report a zero width to the map', () => {
|
||||
renderPage({ leftCollapsed: true, rightCollapsed: true })
|
||||
renderPage({ leftHidden: true, rightHidden: true })
|
||||
|
||||
expect(props('map').leftWidth).toBe(0)
|
||||
expect(props('map').rightWidth).toBe(0)
|
||||
@@ -410,19 +415,42 @@ describe('TripPlannerPage — plan tab', () => {
|
||||
|
||||
const [leftBtn, rightBtn] = screen.getAllByRole('button')
|
||||
fireEvent.click(leftBtn)
|
||||
expect(updaterOf<boolean>(hookState.setLeftCollapsed)(false)).toBe(true)
|
||||
expect(hookState.toggleLeft).toHaveBeenCalled()
|
||||
|
||||
fireEvent.mouseEnter(leftBtn)
|
||||
fireEvent.mouseLeave(leftBtn)
|
||||
|
||||
fireEvent.click(rightBtn)
|
||||
expect(updaterOf<boolean>(hookState.setRightCollapsed)(false)).toBe(true)
|
||||
expect(hookState.toggleRight).toHaveBeenCalled()
|
||||
fireEvent.mouseEnter(rightBtn)
|
||||
fireEvent.mouseLeave(rightBtn)
|
||||
})
|
||||
|
||||
// The tabs are the only way back to a panel on a touch device, and they used to
|
||||
// announce themselves with nothing but a hover colour (#2247).
|
||||
it('FE-PAGE-TPW-013b: each collapse tab is named for what it does', () => {
|
||||
renderPage({ leftHidden: true, rightHidden: false })
|
||||
const [leftBtn, rightBtn] = screen.getAllByRole('button')
|
||||
expect(leftBtn).toHaveAttribute('aria-label', 'trip.mobilePlan')
|
||||
expect(rightBtn).toHaveAttribute('aria-label', 'common.collapse')
|
||||
})
|
||||
|
||||
// At a foldable's ~860px the viewport centre sits under the Places panel, so the
|
||||
// floating POI/compass cluster covered both tabs and the Add Place button (#2247).
|
||||
it('FE-PAGE-TPW-013c: the floating map controls centre on the map corridor, not the viewport', () => {
|
||||
const { container } = renderPage({ leftWidth: 340, rightWidth: 300 })
|
||||
const cluster = container.querySelector('div[style*="translateX(-50%)"][style*="z-index: 25"]') as HTMLElement
|
||||
expect(cluster.style.left).toBe('calc(350px + 0.5 * (100% - 350px - 310px))')
|
||||
})
|
||||
|
||||
it('FE-PAGE-TPW-013d: a hidden panel takes no corridor away from the map controls', () => {
|
||||
const { container } = renderPage({ leftWidth: 340, rightWidth: 300, rightHidden: true })
|
||||
const cluster = container.querySelector('div[style*="translateX(-50%)"][style*="z-index: 25"]') as HTMLElement
|
||||
expect(cluster.style.left).toBe('calc(350px + 0.5 * (100% - 350px - 0px))')
|
||||
})
|
||||
|
||||
it('FE-PAGE-TPW-014: a collapsed panel keeps its toggle but drops the hover highlight', () => {
|
||||
renderPage({ leftCollapsed: true, rightCollapsed: true })
|
||||
renderPage({ leftHidden: true, rightHidden: true })
|
||||
|
||||
const [leftBtn, rightBtn] = screen.getAllByRole('button')
|
||||
fireEvent.mouseEnter(leftBtn)
|
||||
@@ -433,6 +461,13 @@ describe('TripPlannerPage — plan tab', () => {
|
||||
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Mouse-only handles (mousedown + document mousemove) are inert on a tablet, and
|
||||
// a 4px invisible strip on the panel edge is a trap for a fat finger (#2247).
|
||||
it('FE-PAGE-TPW-015b: the narrow layout drops the mouse-only resize handles', () => {
|
||||
const { container } = renderPage({ narrowPanels: true })
|
||||
expect(container.querySelectorAll('div[style*="col-resize"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-PAGE-TPW-015: the resize handles start a drag on mouse-down and highlight on hover', () => {
|
||||
const { container } = renderPage()
|
||||
const handles = container.querySelectorAll('div[style*="col-resize"]')
|
||||
|
||||
@@ -194,7 +194,11 @@ export function useTripPlanner() {
|
||||
if (activeTab === 'finanzplan') tripActions.loadBudgetItems?.(tripId)
|
||||
if (activeTab === 'dateien' && (!files || files.length === 0)) tripActions.loadFiles?.(tripId)
|
||||
}, [tripId])
|
||||
const { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight } = useResizablePanels()
|
||||
const {
|
||||
leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed,
|
||||
leftHidden, rightHidden, toggleLeft, toggleRight, narrow: narrowPanels,
|
||||
startResizeLeft, startResizeRight,
|
||||
} = useResizablePanels()
|
||||
const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection()
|
||||
const [showDayDetail, setShowDayDetail] = useState<Day | null>(null)
|
||||
const [dayDetailCollapsed, setDayDetailCollapsed] = useState(false)
|
||||
@@ -1081,7 +1085,9 @@ export function useTripPlanner() {
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
||||
leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight,
|
||||
leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed,
|
||||
leftHidden, rightHidden, toggleLeft, toggleRight, narrowPanels,
|
||||
startResizeLeft, startResizeRight,
|
||||
selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment,
|
||||
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
|
||||
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
|
||||
|
||||
@@ -223,4 +223,43 @@ describe('computeMapViewport', () => {
|
||||
expect(x).toBeLessThanOrEqual(VIEW.width)
|
||||
}
|
||||
})
|
||||
|
||||
// Two 300px planner panels on an 860px foldable left the framing window 140px
|
||||
// wide, and the camera then opened on half of East Asia instead of the trip
|
||||
// (#2247). The layout fix stops it happening; this stops it mattering.
|
||||
it('never frames the camera for less than a third of the box, however wide the padding', () => {
|
||||
const sane = computeMapViewport([PARIS, LYON], {
|
||||
...VIEW,
|
||||
tileSize: TILE_SIZE_RASTER,
|
||||
padding: { left: 380, right: 340 },
|
||||
})!
|
||||
const absurd = computeMapViewport([PARIS, LYON], {
|
||||
...VIEW,
|
||||
tileSize: TILE_SIZE_RASTER,
|
||||
padding: { left: 480, right: 480 },
|
||||
})!
|
||||
// 1000 - 960 = 40px of window would zoom ~2.5 levels further out than the
|
||||
// third the clamp guarantees.
|
||||
const floored = computeMapViewport([PARIS, LYON], {
|
||||
width: VIEW.width / 3,
|
||||
height: VIEW.height,
|
||||
tileSize: TILE_SIZE_RASTER,
|
||||
})!
|
||||
expect(absurd.zoom).toBe(floored.zoom)
|
||||
expect(absurd.zoom).toBeGreaterThan(sane.zoom - 3)
|
||||
})
|
||||
|
||||
it('leaves a sane padding untouched', () => {
|
||||
const withPadding = computeMapViewport([PARIS, LYON], {
|
||||
...VIEW,
|
||||
tileSize: TILE_SIZE_RASTER,
|
||||
padding: { left: 200, right: 100 },
|
||||
})!
|
||||
const equivalent = computeMapViewport([PARIS, LYON], {
|
||||
width: VIEW.width - 300,
|
||||
height: VIEW.height,
|
||||
tileSize: TILE_SIZE_RASTER,
|
||||
})!
|
||||
expect(withPadding.zoom).toBe(equivalent.zoom)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -215,8 +215,14 @@ export function computeMapViewport(
|
||||
const wrapsWorld = options.wrapsWorld ?? isGl
|
||||
|
||||
const padding: ViewportPadding = { ...NO_PADDING, ...options.padding }
|
||||
const width = Math.max(1, (options.width ?? defaultWidth()) - padding.left - padding.right)
|
||||
const height = Math.max(1, (options.height ?? defaultHeight()) - padding.top - padding.bottom)
|
||||
const rawWidth = options.width ?? defaultWidth()
|
||||
const rawHeight = options.height ?? defaultHeight()
|
||||
// Padding wider than the box leaves a sliver, and the camera is then framed for
|
||||
// THAT — a continent instead of the trip. Two 300px planner panels on an 860px
|
||||
// foldable did exactly this (#2247). Never surrender more than two thirds; the
|
||||
// centre offset still uses the real padding, so the framing stays where it was.
|
||||
const width = Math.max(1, rawWidth / 3, rawWidth - padding.left - padding.right)
|
||||
const height = Math.max(1, rawHeight / 3, rawHeight - padding.top - padding.bottom)
|
||||
|
||||
const lats = pts.map(p => p[0])
|
||||
const south = Math.min(...lats)
|
||||
|
||||
@@ -1,14 +1,38 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { useResizablePanels } from '../../../src/hooks/useResizablePanels';
|
||||
|
||||
/**
|
||||
* The suite's jsdom stub answers `matches: false` to everything, so the hook is
|
||||
* in its wide layout unless a test says otherwise. `narrow()` puts it in the
|
||||
* 768-1023 band the foldable lands in (#2247).
|
||||
*/
|
||||
function setViewport(width: number, narrow: boolean) {
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: width });
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes('min-width: 768px') ? narrow : false,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
describe('useResizablePanels', () => {
|
||||
const realMatchMedia = window.matchMedia;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = realMatchMedia;
|
||||
});
|
||||
|
||||
it('FE-HOOK-PANELS-001: default leftWidth is 340 when localStorage is empty', () => {
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
expect(result.current.leftWidth).toBe(340);
|
||||
@@ -165,4 +189,85 @@ describe('useResizablePanels', () => {
|
||||
expect(result.current.setLeftCollapsed).toBeTypeOf('function');
|
||||
expect(result.current.setRightCollapsed).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
// ── The narrow band, 768-1023px (#2247) ──────────────────────────────────
|
||||
|
||||
it('FE-HOOK-PANELS-015: a wide layout shows whatever the collapse flags say', () => {
|
||||
setViewport(1440, false);
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
expect(result.current.narrow).toBe(false);
|
||||
expect(result.current.leftHidden).toBe(false);
|
||||
expect(result.current.rightHidden).toBe(false);
|
||||
|
||||
act(() => { result.current.toggleRight(); });
|
||||
expect(result.current.rightHidden).toBe(true);
|
||||
expect(result.current.leftHidden).toBe(false);
|
||||
expect(result.current.rightCollapsed).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PANELS-016: the narrow band opens the day plan and hides the places list', () => {
|
||||
setViewport(860, true);
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
expect(result.current.narrow).toBe(true);
|
||||
expect(result.current.leftHidden).toBe(false);
|
||||
expect(result.current.rightHidden).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PANELS-017: opening one panel in the narrow band closes the other', () => {
|
||||
setViewport(860, true);
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
|
||||
act(() => { result.current.toggleRight(); });
|
||||
expect(result.current.rightHidden).toBe(false);
|
||||
expect(result.current.leftHidden).toBe(true);
|
||||
|
||||
act(() => { result.current.toggleLeft(); });
|
||||
expect(result.current.leftHidden).toBe(false);
|
||||
expect(result.current.rightHidden).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PANELS-018: tapping the open panel in the narrow band leaves the map alone', () => {
|
||||
setViewport(860, true);
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
|
||||
act(() => { result.current.toggleLeft(); });
|
||||
expect(result.current.leftHidden).toBe(true);
|
||||
expect(result.current.rightHidden).toBe(true);
|
||||
});
|
||||
|
||||
// A marker tap calls both setters with false; in the narrow band that used to
|
||||
// re-crowd the screen the user had just cleared.
|
||||
it('FE-HOOK-PANELS-019: reopening both panels does not re-crowd the narrow layout', () => {
|
||||
setViewport(860, true);
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
|
||||
act(() => { result.current.toggleRight(); });
|
||||
act(() => { result.current.setLeftCollapsed(false); result.current.setRightCollapsed(false); });
|
||||
expect(result.current.rightHidden).toBe(false);
|
||||
expect(result.current.leftHidden).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PANELS-020: a panel dragged wide on a desktop is clamped so the map keeps 360px', () => {
|
||||
localStorage.setItem('sidebarLeftWidth', '520');
|
||||
setViewport(800, true);
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
// 800 - 360 map - 20 margins = 420
|
||||
expect(result.current.leftWidth).toBe(420);
|
||||
// The stored value is untouched, so a wide window gets its 520 back.
|
||||
expect(localStorage.getItem('sidebarLeftWidth')).toBe('520');
|
||||
});
|
||||
|
||||
it('FE-HOOK-PANELS-021: the clamp never squeezes a panel below the drag minimum', () => {
|
||||
localStorage.setItem('sidebarLeftWidth', '520');
|
||||
setViewport(500, true);
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
expect(result.current.leftWidth).toBe(200);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PANELS-022: a wide layout leaves the stored widths alone', () => {
|
||||
localStorage.setItem('sidebarLeftWidth', '520');
|
||||
setViewport(1100, false);
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
expect(result.current.leftWidth).toBe(520);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -519,4 +519,15 @@ describe('NoteRow', () => {
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('reorder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-041: a link in the note body opens the link instead of the sheet', () => {
|
||||
const onEdit = vi.fn()
|
||||
render(<NoteRow {...base} note={note({ time: 'See [the map](https://example.com)' })} chrome={chrome(true)} onEdit={onEdit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'the map' }))
|
||||
expect(onEdit).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByText('Buy museum tickets'))
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user