mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
feat(planner): bring the plan tab and every trip dialog into the new look
The Plan tab was the last trip tab still in the old design. It now reads like Bookings, Costs, Lists, Files and Collab, without losing a function: - Day plan: each day is a card with a head band (day tile with weather, stay and rental pills, a "+" menu for place, stay, transport, transit and note), rows with "..." menus, white badges and a white route bar. - Places column: add, search and filters in three rows, a show dropdown, category and rating filters, and a selection bar at the foot. - Place inspector, day details and the stay editor on the shared dialog frame; the place form in the editor look with the name in the head band. - Clicking a booking or transport anywhere on the plan opens the booking detail popup first; Edit leads to the editor. The popup is one host shared with the Bookings and Transports tabs. - Files and Collab dialogs, notes, polls, links and chat moved to the same frame; shared DialogShell and dialogParts hold the building blocks. - The map hover card shows the place photo and follows the theme. Every new and reworded string is translated into all locales, and a few older English leftovers (help center, cost split, import hints) with them. Phone screens keep their own look.
This commit is contained in:
@@ -901,8 +901,13 @@ describe('PackingListPanel', () => {
|
||||
|
||||
// Picker is open - find "Trolley" button inside the dropdown. The bag
|
||||
// sidebar carries the same name on its own button, so scope the query to
|
||||
// the picker (the dropdown is a sibling of the package button).
|
||||
const trolleyBtn = await within(packageBtn!.parentElement!).findByRole('button', { name: /Trolley/ });
|
||||
// the picker, which opens in a portal on the body.
|
||||
const picker = await waitFor(() => {
|
||||
const el = document.body.querySelector<HTMLElement>(':scope > .trek-menu-enter');
|
||||
if (!el) throw new Error('bag picker not open yet');
|
||||
return el;
|
||||
});
|
||||
const trolleyBtn = await within(picker).findByRole('button', { name: /Trolley/ });
|
||||
fireEvent.click(trolleyBtn);
|
||||
|
||||
await waitFor(() => expect(putBody).toMatchObject({ bag_id: 7 }));
|
||||
@@ -934,10 +939,10 @@ describe('PackingListPanel', () => {
|
||||
await user.click(importBtn!);
|
||||
await screen.findByText('Import Packing List');
|
||||
|
||||
// Click on the backdrop (the outer div that closes the modal)
|
||||
// The backdrop div has no specific identifier so we use the document.body portal
|
||||
const backdrop = document.querySelector('[style*="backdrop-filter"]') as HTMLElement;
|
||||
// A press that starts and ends on the backdrop closes the dialog.
|
||||
const backdrop = document.querySelector('.trek-modal-backdrop') as HTMLElement;
|
||||
expect(backdrop).toBeTruthy();
|
||||
fireEvent.mouseDown(backdrop!);
|
||||
fireEvent.click(backdrop!);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Import Packing List')).not.toBeInTheDocument());
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, Plus } from 'lucide-react'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
import type { TripMember } from './usePackingListPanel'
|
||||
import { PopoverItem } from './PackingPopover'
|
||||
import { POPOVER, POPOVER_CAPTION, useDismissOnOutside } from './packingPopoverStyles'
|
||||
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
|
||||
|
||||
interface BagCardProps {
|
||||
bag: PackingBag; bagItems: PackingItem[]; totalWeight: number; pct: number; tripId: number
|
||||
@@ -17,8 +19,11 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
|
||||
const [nameVal, setNameVal] = useState(bag.name)
|
||||
const [showUserPicker, setShowUserPicker] = useState(false)
|
||||
const membersRef = useRef<HTMLDivElement>(null)
|
||||
const pickerRef = useRef<HTMLDivElement>(null)
|
||||
const closePicker = useCallback(() => setShowUserPicker(false), [])
|
||||
useDismissOnOutside(membersRef, showUserPicker, closePicker)
|
||||
useDismissOnOutside(membersRef, showUserPicker, closePicker, pickerRef)
|
||||
// The bag sidebar scrolls and would clip the list, so it hangs off the body under the faces.
|
||||
const pickerBox = useAnchoredPosition(membersRef, showUserPicker, { estimatedHeight: 260, offset: 6 })
|
||||
useEffect(() => setNameVal(bag.name), [bag.name])
|
||||
|
||||
const saveName = () => {
|
||||
@@ -92,8 +97,12 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
|
||||
<Plus size={sz.avatar * 0.5} />
|
||||
</button>
|
||||
)}
|
||||
{showUserPicker && (
|
||||
<div className="trek-menu-enter" style={{ ...POPOVER, position: 'absolute', right: 0, top: '100%', marginTop: 6, zIndex: 50, width: 200 }}>
|
||||
{showUserPicker && pickerBox && createPortal(
|
||||
<div ref={pickerRef} className="trek-menu-enter" style={{
|
||||
...POPOVER, position: 'fixed', zIndex: 'var(--z-toast)', width: 200, overflowY: 'auto',
|
||||
top: pickerBox.top, bottom: pickerBox.bottom, maxHeight: pickerBox.maxHeight,
|
||||
left: Math.max(8, pickerBox.left + pickerBox.width - 200),
|
||||
}}>
|
||||
<div style={POPOVER_CAPTION}>{t('packing.assignMembers')}</div>
|
||||
{tripMembers.map(m => (
|
||||
<PopoverItem key={m.id} label={m.username} active={memberIds.includes(m.id)} onClick={() => toggleMember(m.id)}
|
||||
@@ -106,7 +115,8 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
|
||||
)} />
|
||||
))}
|
||||
{tripMembers.length === 0 && <div style={{ padding: '8px 10px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div>}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
{canEdit && <button type="button" onClick={onDelete} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}><X size={sz.icon} /></button>}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
CheckCheck, Luggage, Package, FolderPlus, Download, Trash2,
|
||||
CheckCheck, Luggage, Package, FolderPlus, Download, Trash2, ListPlus, BookmarkPlus,
|
||||
} from 'lucide-react'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
import NameDialog from '../shared/NameDialog'
|
||||
@@ -202,6 +202,7 @@ export function PackingHeader(S: PackingState) {
|
||||
<NameDialog
|
||||
open={canEdit && addingCategory}
|
||||
title={t('packing.addCategory')}
|
||||
icon={ListPlus}
|
||||
placeholder={t('packing.newCategoryPlaceholder')}
|
||||
confirmLabel={t('common.add')}
|
||||
value={newCatName}
|
||||
@@ -212,6 +213,7 @@ export function PackingHeader(S: PackingState) {
|
||||
<NameDialog
|
||||
open={canEdit && isAdmin && items.length > 0 && showSaveTemplate}
|
||||
title={t('packing.saveAsTemplate')}
|
||||
icon={BookmarkPlus}
|
||||
placeholder={t('packing.templateName')}
|
||||
confirmLabel={t('common.save')}
|
||||
value={saveTemplateName}
|
||||
|
||||
@@ -1,75 +1,62 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { FileDown } from 'lucide-react'
|
||||
import { useId } from 'react'
|
||||
import { Download, FileDown } from 'lucide-react'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
import { PACKING_IMPORT_ACCEPT } from './packingListPanel.constants'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
|
||||
/** Pasting a list or loading a file: one item per line, numbered as it is typed. */
|
||||
export function BulkImportModal(S: PackingState) {
|
||||
const { setShowImportModal, t, importText, setImportText, csvInputRef, handleCsvFile, handleBulkImport, parseImportLines } = S
|
||||
return createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||
}} role="presentation" onClick={e => { if (e.target === e.currentTarget) setShowImportModal(false) }}>
|
||||
<div style={{
|
||||
width: 420, maxHeight: '80vh', background: 'var(--bg-card)', borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 14,
|
||||
}}>
|
||||
<div style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{t('packing.importTitle')}</div>
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', lineHeight: 1.5 }}>
|
||||
{t('packing.importHint')}
|
||||
<br />
|
||||
{t('packing.importHintMarkdown')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', border: '1px solid var(--border-primary)', borderRadius: 10, overflow: 'hidden', background: 'var(--bg-input)' }}>
|
||||
<div style={{
|
||||
padding: '10px 0', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'monospace', lineHeight: 1.5,
|
||||
color: 'var(--text-faint)', textAlign: 'right', userSelect: 'none',
|
||||
background: 'var(--bg-hover)', borderRight: '1px solid var(--border-faint)',
|
||||
minWidth: 32, flexShrink: 0,
|
||||
}}>
|
||||
{(importText || ' ').split('\n').map((_, i) => (
|
||||
<div key={i} style={{ padding: '0 6px' }}>{i + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={importText}
|
||||
onChange={e => setImportText(e.target.value)}
|
||||
rows={10}
|
||||
placeholder={t('packing.importPlaceholder')}
|
||||
style={{
|
||||
flex: 1, border: 'none', padding: '10px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'monospace',
|
||||
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)',
|
||||
background: 'transparent', resize: 'vertical', lineHeight: 1.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<input ref={csvInputRef} type="file" accept={PACKING_IMPORT_ACCEPT} style={{ display: 'none' }} onChange={handleCsvFile} />
|
||||
<button type="button" onClick={() => csvInputRef.current?.click()} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<FileDown size={11} /> {t('packing.importCsv')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={() => setShowImportModal(false)} style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit',
|
||||
}}>{t('common.cancel')}</button>
|
||||
<button type="button" onClick={handleBulkImport} disabled={!importText.trim()} style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600,
|
||||
fontFamily: 'inherit', opacity: importText.trim() ? 1 : 0.5,
|
||||
}}>{t('packing.importAction', { count: parseImportLines(importText).length })}</button>
|
||||
</div>
|
||||
const labelId = useId()
|
||||
const close = () => setShowImportModal(false)
|
||||
const text = fs(13, 'body')
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={close}
|
||||
labelledBy={labelId}
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><Download size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={close}
|
||||
title={t('packing.importTitle')}
|
||||
/>
|
||||
)}
|
||||
footer={(
|
||||
<DialogFooter>
|
||||
<input ref={csvInputRef} type="file" accept={PACKING_IMPORT_ACCEPT} className="hidden" onChange={handleCsvFile} />
|
||||
<DialogButton icon={<FileDown size={14} strokeWidth={2} />} onClick={() => csvInputRef.current?.click()}>{t('packing.importCsv')}</DialogButton>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={close}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={handleBulkImport} disabled={!importText.trim()}>
|
||||
{t('packing.importAction', { count: parseImportLines(importText).length })}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
>
|
||||
<p className="m-0 leading-normal text-content-muted" style={fs(12.5, 'body')}>
|
||||
{t('packing.importHint')}
|
||||
<br />
|
||||
{t('packing.importHintMarkdown')}
|
||||
</p>
|
||||
<div className="flex overflow-hidden rounded-[10px] border border-edge bg-surface-input focus-within:ring-2 focus-within:ring-[color:var(--text-primary)]">
|
||||
<div aria-hidden className="min-w-8 flex-none select-none border-r border-edge-faint bg-surface-tertiary py-2.5 text-right font-mono leading-normal text-content-faint" style={text}>
|
||||
{(importText || ' ').split('\n').map((_, i) => (
|
||||
<div key={i} className="px-1.5">{i + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={importText}
|
||||
onChange={e => setImportText(e.target.value)}
|
||||
rows={10}
|
||||
placeholder={t('packing.importPlaceholder')}
|
||||
aria-label={t('packing.importTitle')}
|
||||
className="min-w-0 flex-1 resize-y border-0 bg-transparent px-3 py-2.5 font-mono leading-normal text-content outline-none placeholder:text-content-faint dark:bg-transparent"
|
||||
style={text}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -169,7 +169,8 @@ describe('ArtikelZeile — basics', () => {
|
||||
fireEvent.mouseLeave(row)
|
||||
expect(screen.getByRole('button', { name: 'Trolley' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(container.querySelector('div[role="presentation"]')!)
|
||||
// The picker and its click catcher open in a portal on the body.
|
||||
fireEvent.click(Array.from(document.body.children).find(el => el.getAttribute('role') === 'presentation')!)
|
||||
expect(screen.queryByRole('button', { name: 'Trolley' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useRef, useState, type CSSProperties } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -55,6 +57,9 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
// that only shows on hover is also there for someone tabbing through.
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [showBagPicker, setShowBagPicker] = useState(false)
|
||||
// The picker opens in a portal: inside the category card it was cut off by the card's edge.
|
||||
const bagBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const bagBox = useAnchoredPosition(bagBtnRef, showBagPicker, { estimatedHeight: 260, offset: 6 })
|
||||
const [showItemMenu, setShowItemMenu] = useState(false)
|
||||
const [showMenuCategories, setShowMenuCategories] = useState(false)
|
||||
const [bagInlineCreate, setBagInlineCreate] = useState(false)
|
||||
@@ -248,6 +253,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button type="button"
|
||||
ref={bagBtnRef}
|
||||
onClick={() => canEdit && setShowBagPicker(p => !p)}
|
||||
style={{
|
||||
width: 22, height: 22, borderRadius: '50%', cursor: canEdit ? 'pointer' : 'default', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -258,10 +264,14 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
>
|
||||
{!item.bag_id && <Package size={9} className="text-content-faint" />}
|
||||
</button>
|
||||
{showBagPicker && (
|
||||
{showBagPicker && bagBox && createPortal(
|
||||
<>
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 49 }} onClick={() => { setShowBagPicker(false); setBagInlineCreate(false); setBagInlineName('') }} />
|
||||
<div className="trek-menu-enter" style={{ ...POPOVER, position: 'absolute', right: 0, top: '100%', marginTop: 6, zIndex: 50, width: 210 }}>
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 'var(--z-toast)' }} onClick={() => { setShowBagPicker(false); setBagInlineCreate(false); setBagInlineName('') }} />
|
||||
<div className="trek-menu-enter" style={{
|
||||
...POPOVER, position: 'fixed', zIndex: 'var(--z-toast)', width: 210, overflowY: 'auto',
|
||||
top: bagBox.top, bottom: bagBox.bottom, maxHeight: bagBox.maxHeight,
|
||||
left: Math.max(8, bagBox.left + bagBox.width - 210),
|
||||
}}>
|
||||
<div style={POPOVER_CAPTION}>{t('packing.bags')}</div>
|
||||
{item.bag_id && (
|
||||
<PopoverItem icon={<span style={{ width: 10, height: 10, borderRadius: '50%', border: '1.5px dashed var(--text-faint)', display: 'inline-block' }} />} label={t('packing.noBag')}
|
||||
@@ -300,7 +310,8 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
<PopoverItem icon={<Plus size={13} />} label={t('packing.addBag')} muted onClick={() => setBagInlineCreate(true)} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -34,15 +34,17 @@ export const composerConfirm = (enabled: boolean): CSSProperties => ({
|
||||
* is mounted but not displayed (the bag sidebar below its breakpoint) has no
|
||||
* layout box, and must not close the visible copy's twin.
|
||||
*/
|
||||
export function useDismissOnOutside(ref: RefObject<HTMLElement | null>, open: boolean, onClose: () => void) {
|
||||
export function useDismissOnOutside(ref: RefObject<HTMLElement | null>, open: boolean, onClose: () => void, panelRef?: RefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const el = ref.current
|
||||
if (!el || el.getClientRects().length === 0 || el.contains(e.target as Node)) return
|
||||
// A panel portalled to the body counts as inside too.
|
||||
if (panelRef?.current?.contains(e.target as Node)) return
|
||||
onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [ref, open, onClose])
|
||||
}, [ref, open, onClose, panelRef])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user