feat(ui): Costs bar like the other tabs, desktop packing list in the phone's look

The Costs tab opens with the same bar as Transports, Bookings, Lists and
Files: the tab's name, the trip's dates and days in one pill, the
travelers, and Settle up and Add expense on the right.

The desktop packing list takes the phone's look without losing anything
it shows. Category cards get a tinted head band, rows get hairlines and
filled checkboxes, owner, quantity and weight read as small badges, and
the bin and a menu with Move to List, Sharing, Rename and Delete replace
the row of icons. What a row does not use stays dimmed until hover or
focus, so nothing leaves a gap. A click on the name renames, ticked
items too. Add item opens a composer shaped like the next row, Add list
moved into the Lists bar, Add list and Save as template ask for the name
in a dialog, and Remove checked sits beside the progress bar. The Bags
panel becomes a card, and the item menu, the bag picker, the list menu
and the bag member picker share one popover look and close on a click
beside them.

Help center pictures for Costs and Lists are re-rendered, the Lists
guide texts follow the new controls in all 27 languages, and the wiki
pages are updated. The phone screens are unchanged.
This commit is contained in:
Maurice
2026-09-25 12:57:15 +02:00
parent 5c312a8497
commit dc60f39554
141 changed files with 1574 additions and 960 deletions
@@ -248,8 +248,8 @@ describe('PackingListPanel', () => {
);
render(<PackingListPanel tripId={1} items={[item]} />);
// Click the rename (pencil) button
await user.click(screen.getByTitle('Rename'));
// A click on the name opens it for renaming
await user.click(screen.getByText('Sunscreen'));
// Input appears pre-filled with 'Sunscreen'
const input = screen.getByDisplayValue('Sunscreen');
@@ -275,8 +275,8 @@ describe('PackingListPanel', () => {
);
const { container } = render(<PackingListPanel tripId={1} items={[item]} />);
// The toggle button contains the Square icon for unchecked items
const toggleBtn = container.querySelector('svg.lucide-square')?.closest('button');
// The checkbox is the button marked packing-check
const toggleBtn = container.querySelector<HTMLButtonElement>('.packing-check');
expect(toggleBtn).toBeTruthy();
await user.click(toggleBtn!);
@@ -861,9 +861,9 @@ describe('PackingListPanel', () => {
// In jsdom, CSS classes don't apply so the buttons are accessible
// The dot button has a circle span inside with category color
// Find all buttons with the 'Move to List' title
const catChangeBtn = screen.getAllByTitle('Move to List');
expect(catChangeBtn.length).toBeGreaterThan(0);
await user.click(catChangeBtn[0]);
// Moving an item goes through its menu: the three dots, then Move to List.
await user.click(container.querySelector<HTMLButtonElement>('.packing-row-overflow button')!);
await user.click(screen.getByText('Move to List'));
// Category picker shows both category names
await waitFor(() => {
@@ -1059,11 +1059,11 @@ describe('PackingListPanel', () => {
return HttpResponse.json({ item: buildPackingItem({ id: 74, category: 'Documents' }) });
})
);
render(<PackingListPanel tripId={1} items={[itemA, itemB]} />);
const { container } = render(<PackingListPanel tripId={1} items={[itemA, itemB]} />);
// Use fireEvent (no pointer events) to open the category picker — avoids mouseLeave closing picker
const catChangeBtns = screen.getAllByTitle('Move to List');
fireEvent.click(catChangeBtns[0]);
// Open the first item's menu and its Move to List entry
fireEvent.click(container.querySelector<HTMLButtonElement>('.packing-row-overflow button')!);
fireEvent.click(screen.getByText('Move to List'));
// Picker shows available categories — find and click the 'Documents' button (role=button, text=Documents)
const docBtn = await screen.findByRole('button', { name: 'Documents' });
@@ -1248,9 +1248,10 @@ describe('PackingListPanel', () => {
const items = [buildPackingItem({ name: 'Boots', category: 'Clothing' })];
render(<PackingListPanel tripId={1} items={items} />);
// Wait for sidebar "Add bag" button (sidebar renders when bags.length > 0)
await waitFor(() => expect(screen.getAllByText('Add bag').length).toBeGreaterThan(0));
const addBagBtns = screen.getAllByText('Add bag');
// Wait for the sidebar's add button (sidebar renders when bags.length > 0); it
// reads just "Bags" and carries "Add bag" as its accessible name.
await waitFor(() => expect(screen.getAllByRole('button', { name: 'Add bag' }).length).toBeGreaterThan(0));
const addBagBtns = screen.getAllByRole('button', { name: 'Add bag' });
await user.click(addBagBtns[0]);
// Bag name input appears
@@ -1637,7 +1638,55 @@ describe('PackingListPanel', () => {
render(<PackingListPanel tripId={1} items={items} />);
await userEvent.click(screen.getByText('My list'));
await screen.findByText('Power bank');
// "by Bob" — taken care of by the bringer.
expect(screen.getByText('by Bob')).toBeInTheDocument();
// "by Bob" — taken care of by the bringer, as the chip's tooltip and label.
expect(screen.getByLabelText('by Bob')).toBeInTheDocument();
});
});
describe('PackingListPanel — under the Lists bar', () => {
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildAdmin({ id: 1 }), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
});
it('FE-COMP-PACKING-090: the clean-up sits in the progress card and clears what is ticked', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
let deleted = false;
server.use(http.delete('/api/trips/1/packing/91', () => { deleted = true; return HttpResponse.json({ success: true }); }));
render(<PackingListPanel tripId={1} inlineHeader={false} items={[buildPackingItem({ id: 91, name: 'Towel', checked: 1 }), buildPackingItem({ id: 92, name: 'Soap' })]} />);
fireEvent.click(screen.getByRole('button', { name: 'Remove 1 checked' }));
await waitFor(() => expect(deleted).toBe(true));
// No dashed Add list under the bar: the Lists bar carries it.
expect(screen.queryByRole('button', { name: 'Add list' })).toBeNull();
});
it('FE-COMP-PACKING-091: a raised add-list signal opens the name dialog, which creates the list', async () => {
let body: Record<string, unknown> | null = null;
server.use(http.post('/api/trips/1/packing', async ({ request }) => {
body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 93, name: '...', category: 'Rain kit' }) });
}));
const { rerender } = render(<PackingListPanel tripId={1} inlineHeader={false} items={[buildPackingItem({ id: 92, name: 'Soap' })]} addCategorySignal={0} />);
rerender(<PackingListPanel tripId={1} inlineHeader={false} items={[buildPackingItem({ id: 92, name: 'Soap' })]} addCategorySignal={1} />);
fireEvent.change(await screen.findByPlaceholderText('List name (e.g. Clothing)'), { target: { value: 'Rain kit' } });
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
await waitFor(() => expect(body).toMatchObject({ category: 'Rain kit' }));
});
it('FE-COMP-PACKING-092: Save as template asks for the name in a dialog', async () => {
let body: Record<string, unknown> | null = null;
server.use(http.post('/api/trips/1/packing/save-as-template', async ({ request }) => {
body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ template: { id: 1, name: 'Beach' } });
}));
const { rerender } = render(<PackingListPanel tripId={1} inlineHeader={false} items={[buildPackingItem({ id: 92, name: 'Soap' })]} saveTemplateSignal={0} />);
rerender(<PackingListPanel tripId={1} inlineHeader={false} items={[buildPackingItem({ id: 92, name: 'Soap' })]} saveTemplateSignal={1} />);
fireEvent.change(await screen.findByPlaceholderText('Template name'), { target: { value: 'Beach' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(body).toMatchObject({ name: 'Beach' }));
});
});
@@ -1,7 +1,9 @@
import { useState, useEffect } from 'react'
import { X, Plus, Check } from 'lucide-react'
import { useState, useEffect, useRef, useCallback } from 'react'
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'
interface BagCardProps {
bag: PackingBag; bagItems: PackingItem[]; totalWeight: number; pct: number; tripId: number
@@ -14,6 +16,9 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
const [editingName, setEditingName] = useState(false)
const [nameVal, setNameVal] = useState(bag.name)
const [showUserPicker, setShowUserPicker] = useState(false)
const membersRef = useRef<HTMLDivElement>(null)
const closePicker = useCallback(() => setShowUserPicker(false), [])
useDismissOnOutside(membersRef, showUserPicker, closePicker)
useEffect(() => setNameVal(bag.name), [bag.name])
const saveName = () => {
@@ -50,9 +55,11 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
const sz = compact ? { dot: 10, name: 12, weight: 11, bar: 6, count: 10, gap: 6, mb: 14, icon: 11, avatar: 18 } : { dot: 12, name: 14, weight: 13, bar: 8, count: 11, gap: 8, mb: 16, icon: 13, avatar: 22 }
// Three lines, each with one job: who the bag is (name, who carries it, delete),
// how full it is, and the numbers under the bar (items left, weight and limit right).
return (
<div style={{ marginBottom: sz.mb }}>
<div style={{ display: 'flex', alignItems: 'center', gap: sz.gap, marginBottom: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: sz.gap }}>
<span style={{ width: sz.dot, height: sz.dot, borderRadius: '50%', background: bag.color, flexShrink: 0 }} />
{editingName && canEdit ? (
<input autoFocus value={nameVal} onChange={e => setNameVal(e.target.value)}
@@ -60,9 +67,56 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
style={{ flex: 1, fontSize: sz.name, fontWeight: 600, padding: '1px 4px', borderRadius: 4, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', color: 'var(--text-primary)', background: 'transparent' }} />
) : (
<button type="button" disabled={!canEdit} onClick={() => setEditingName(true)}
style={{ flex: 1, fontSize: sz.name, fontWeight: 600, color: compact ? 'var(--text-secondary)' : 'var(--text-primary)', cursor: canEdit ? 'text' : 'default', background: 'none', border: 'none', padding: 0, textAlign: 'left', fontFamily: 'inherit' }}>{bag.name}</button>
style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: sz.name, fontWeight: 700, color: 'var(--text-primary)', cursor: canEdit ? 'text' : 'default', background: 'none', border: 'none', padding: 0, textAlign: 'left', fontFamily: 'inherit' }}>{bag.name}</button>
)}
<span style={{ fontSize: sz.weight, color: 'var(--text-faint)', fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 3 }}>
{/* Members */}
<div ref={membersRef} style={{ display: 'flex', alignItems: 'center', gap: 3, flexShrink: 0, position: 'relative' }}>
{(bag.members || []).map(m => {
const face = m.avatar ? (
<img src={m.avatar} alt={m.username} style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', objectFit: 'cover', border: `1.5px solid ${bag.color}`, boxSizing: 'border-box' }} />
) : (
<span style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', background: bag.color + '25', color: bag.color, fontSize: sz.avatar * 0.45, fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', border: `1.5px solid ${bag.color}`, boxSizing: 'border-box' }}>
{m.username[0].toUpperCase()}
</span>
)
if (!canEdit) return <span key={m.user_id} title={m.username} style={{ display: 'inline-flex' }}>{face}</span>
return (
<button type="button" key={m.user_id} title={m.username} aria-label={m.username} onClick={() => toggleMember(m.user_id)}
style={{ cursor: 'pointer', display: 'inline-flex', background: 'none', border: 'none', padding: 0 }}>
{face}
</button>
)
})}
{canEdit && (
<button type="button" onClick={() => setShowUserPicker(v => !v)} style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', border: '1.5px dashed var(--border-primary)', background: 'none', color: 'var(--text-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, boxSizing: 'border-box' }}>
<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 }}>
<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)}
icon={m.avatar ? (
<img src={m.avatar} alt="" style={{ width: 18, height: 18, borderRadius: '50%', objectFit: 'cover' }} />
) : (
<span style={{ width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-faint)' }}>
{m.username[0].toUpperCase()}
</span>
)} />
))}
{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>
{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>}
</div>
<div style={{ height: sz.bar, marginTop: 9, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 5 }}>
<span style={{ fontSize: sz.count, color: 'var(--text-faint)' }}>{bagItems.length} {t('admin.packingTemplates.items')}</span>
<span style={{ fontSize: sz.weight, color: 'var(--text-muted)', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 3, fontVariantNumeric: 'tabular-nums' }}>
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
{editingLimit && canEdit ? (
<>
@@ -86,65 +140,7 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
</button>
) : null}
</span>
{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>}
</div>
{/* Members */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, flexWrap: 'wrap', position: 'relative' }}>
{(bag.members || []).map(m => {
const face = m.avatar ? (
<img src={m.avatar} alt={m.username} style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', objectFit: 'cover', border: `1.5px solid ${bag.color}`, boxSizing: 'border-box' }} />
) : (
<span style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', background: bag.color + '25', color: bag.color, fontSize: sz.avatar * 0.45, fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', border: `1.5px solid ${bag.color}`, boxSizing: 'border-box' }}>
{m.username[0].toUpperCase()}
</span>
)
if (!canEdit) return <span key={m.user_id} title={m.username} style={{ display: 'inline-flex' }}>{face}</span>
return (
<button type="button" key={m.user_id} title={m.username} aria-label={m.username} onClick={() => toggleMember(m.user_id)}
style={{ cursor: 'pointer', display: 'inline-flex', background: 'none', border: 'none', padding: 0 }}>
{face}
</button>
)
})}
{canEdit && (
<button type="button" onClick={() => setShowUserPicker(v => !v)} style={{ width: sz.avatar, height: sz.avatar, borderRadius: '50%', border: '1.5px dashed var(--border-primary)', background: 'none', color: 'var(--text-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, boxSizing: 'border-box' }}>
<Plus size={sz.avatar * 0.5} />
</button>
)}
{showUserPicker && (
<div style={{ position: 'absolute', left: 0, top: '100%', marginTop: 4, zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.1)', padding: 4, minWidth: 160 }}>
{tripMembers.map(m => {
const isSelected = memberIds.includes(m.id)
return (
<button type="button" key={m.id} onClick={() => { toggleMember(m.id); }}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', background: isSelected ? 'var(--bg-tertiary)' : 'transparent', cursor: 'pointer', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-primary)', fontFamily: 'inherit' }}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-secondary)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}>
{m.avatar ? (
<img src={m.avatar} alt="" style={{ width: 20, height: 20, borderRadius: '50%', objectFit: 'cover' }} />
) : (
<span style={{ width: 20, height: 20, borderRadius: '50%', background: 'var(--bg-tertiary)', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-faint)' }}>
{m.username[0].toUpperCase()}
</span>
)}
<span style={{ flex: 1, fontWeight: isSelected ? 600 : 400 }}>{m.username}</span>
{isSelected && <Check size={12} style={{ color: '#10b981' }} />}
</button>
)
})}
{tripMembers.length === 0 && <div style={{ padding: '8px 10px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{t('packing.noMembers')}</div>}
<div style={{ borderTop: '1px solid var(--border-secondary)', marginTop: 4, paddingTop: 4 }}>
<button type="button" onClick={() => setShowUserPicker(false)} style={{ width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', background: 'transparent', cursor: 'pointer', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontFamily: 'inherit', textAlign: 'center' }}>
{t('common.close')}
</button>
</div>
</div>
)}
</div>
<div style={{ height: sz.bar, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
</div>
<div style={{ fontSize: sz.count, color: 'var(--text-faint)', marginTop: 2 }}>{bagItems.length} {t('admin.packingTemplates.items')}</div>
</div>
)
}
@@ -0,0 +1,89 @@
// FE-BAGSIDE-001 to FE-BAGSIDE-006
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { BagSidebar } from './PackingListPanelBagSidebar'
import type { PackingState } from './usePackingListPanel'
const ITEMS = [
{ id: 1, name: 'Stove', weight_grams: 300, quantity: 1, bag_id: 10, is_private: 0, owner_id: 1, checked: 0, category: 'Kitchen' },
{ id: 2, name: 'Sandwich', weight_grams: 150, quantity: 1, bag_id: null, is_private: 0, owner_id: 1, checked: 0, category: 'Food' },
{ id: 3, name: 'Apple', weight_grams: 100, quantity: 1, bag_id: null, is_private: 0, owner_id: 1, checked: 0, category: 'Food' },
]
function buildState(over: Partial<PackingState> = {}): PackingState {
return {
t: (k: string) => k,
bags: [{ id: 10, trip_id: 1, name: 'Duffel', color: '#6366f1', sort_order: 0, weight_limit_grams: null, members: [], total_weight_grams: 300 }],
items: ITEMS,
tripId: 1,
tripMembers: [],
canEdit: true,
currentUserId: 1,
unassignedWeightGrams: 250,
serverWeightsFresh: true,
handleDeleteBag: vi.fn(),
handleUpdateBag: vi.fn(),
handleSetBagMembers: vi.fn(),
handleCreateBag: vi.fn(),
showAddBag: false,
setShowAddBag: vi.fn(),
newBagName: '',
setNewBagName: vi.fn(),
...over,
} as unknown as PackingState
}
describe('BagSidebar', () => {
it('FE-BAGSIDE-001: the head band carries the title and a short add button', () => {
const S = buildState()
render(<BagSidebar {...S} />)
const add = screen.getByRole('button', { name: 'packing.addBag' })
expect(add).toHaveTextContent('packing.bags')
fireEvent.click(add)
expect(S.setShowAddBag).toHaveBeenCalledWith(true)
})
it('FE-BAGSIDE-002: what sits in no bag is one row with its count as a badge', () => {
render(<BagSidebar {...buildState()} />)
const badge = screen.getByTitle('2 admin.packingTemplates.items')
expect(badge).toHaveTextContent(/^2$/)
expect(screen.getByText('packing.noBag')).toBeInTheDocument()
})
it('FE-BAGSIDE-003: the composer adds on Enter and on its button, once there is a name', () => {
const S = buildState({ showAddBag: true, newBagName: 'Daypack' } as Partial<PackingState>)
render(<BagSidebar {...S} />)
expect(screen.queryByRole('button', { name: 'packing.addBag' })).toBeNull()
fireEvent.keyDown(screen.getByPlaceholderText('packing.bagName'), { key: 'Enter' })
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
expect(S.handleCreateBag).toHaveBeenCalledTimes(2)
})
it('FE-BAGSIDE-004: Escape and the cross both close the composer and drop the name', () => {
const S = buildState({ showAddBag: true, newBagName: 'Daypack' } as Partial<PackingState>)
render(<BagSidebar {...S} />)
fireEvent.keyDown(screen.getByPlaceholderText('packing.bagName'), { key: 'Escape' })
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(S.setShowAddBag).toHaveBeenCalledWith(false)
expect(S.setNewBagName).toHaveBeenCalledWith('')
expect(S.setShowAddBag).toHaveBeenCalledTimes(2)
})
it('FE-BAGSIDE-005: the confirm waits for a name', () => {
render(<BagSidebar {...buildState({ showAddBag: true, newBagName: ' ' } as Partial<PackingState>)} />)
expect(screen.getByRole('button', { name: 'common.add' })).toBeDisabled()
})
it('FE-BAGSIDE-006: the dashed plus beside a bag opens who carries it, or says there is nobody', () => {
render(<BagSidebar {...buildState()} />)
const plus = document.querySelector<HTMLButtonElement>('button[style*="dashed"]')!
fireEvent.click(plus)
expect(screen.getByText('packing.assignMembers')).toBeInTheDocument()
expect(screen.getByText('packing.noMembers')).toBeInTheDocument()
})
})
@@ -1,8 +1,17 @@
import { Plus } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { Briefcase, Plus, X } from 'lucide-react'
import type { PackingState } from './usePackingListPanel'
import { bagFillPct, bagTotalWeight, countsTowardsMyLoad, unassignedTotalWeight } from './packingListPanel.helpers'
import { BagCard } from './PackingListPanelBagCard'
import { COMPOSER, composerConfirm, useDismissOnOutside } from './packingPopoverStyles'
const kgOrG = (grams: number) => (grams >= 1000 ? `${(grams / 1000).toFixed(1)} kg` : `${grams} g`)
/**
* The bags beside the list, as a card like the categories: a tinted head band
* with the add action, one block per bag between hairlines, what sits in no bag
* as a quiet last row, and the total in a matching foot band.
*/
export function BagSidebar(S: PackingState) {
const {
t, bags, items, tripId, tripMembers, canEdit, currentUserId, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
@@ -16,72 +25,77 @@ export function BagSidebar(S: PackingState) {
bagTotalWeight(bag, myItems.filter(i => i.bag_id === bag.id), serverWeightsFresh)
// Reference for bags without a limit of their own — computed once instead of per bag.
const heaviestBagWeight = Math.max(...bags.map(bagWeightOf), 1)
const unassigned = myItems.filter(i => !i.bag_id)
const unassignedWeight = unassignedTotalWeight(unassignedWeightGrams, unassigned, serverWeightsFresh)
// Same rule as the rows above it (#2191).
const totalWeight = bags.reduce((s, b) => s + bagWeightOf(b), 0) + unassignedWeight
const hairline = (first: boolean): React.CSSProperties => ({ borderTop: first ? 'none' : '1px solid var(--border-faint)' })
const composerRef = useRef<HTMLDivElement>(null)
const closeComposer = useCallback(() => { setShowAddBag(false); setNewBagName('') }, [setShowAddBag, setNewBagName])
useDismissOnOutside(composerRef, showAddBag, closeComposer)
return (
<div className="hidden xl:block" style={{ width: 260, marginLeft: 16, borderLeft: '1px solid var(--border-secondary)', overflowY: 'auto', padding: 16, flexShrink: 0 }}>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-faint)', marginBottom: 12 }}>
{t('packing.bags')}
</div>
<div className="hidden xl:block" style={{ width: 272, marginLeft: 16, overflowY: 'auto', padding: '10px 0 16px', flexShrink: 0 }}>
<div style={{ background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '9px 10px 9px 14px', background: 'var(--bg-tertiary)', borderRadius: '15px 15px 0 0' }}>
<Briefcase size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', letterSpacing: '-0.01em' }}>
{t('packing.bags')}
</span>
{canEdit && !showAddBag && (
<button type="button" onClick={() => setShowAddBag(true)} aria-label={t('packing.addBag')} title={t('packing.addBag')}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 9px', borderRadius: 99, border: '1px solid var(--border-secondary)', background: 'var(--bg-card)', cursor: 'pointer', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', fontFamily: 'inherit' }}>
<Plus size={12} /> {t('packing.bags')}
</button>
)}
</div>
{bags.map(bag => {
const bagItems = myItems.filter(i => i.bag_id === bag.id)
const totalWeight = bagWeightOf(bag)
const pct = bagFillPct(totalWeight, bag.weight_limit_grams, heaviestBagWeight)
return (
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
)
})}
{/* Unassigned */}
{(() => {
const unassigned = myItems.filter(i => !i.bag_id)
const unassignedWeight = unassignedTotalWeight(unassignedWeightGrams, unassigned, serverWeightsFresh)
// Shown whenever there is weight to account for, even with no visible
// items — the grand total counts it either way (#2191).
if (unassigned.length === 0 && unassignedWeight === 0) return null
return (
<div style={{ marginBottom: 14, opacity: 0.6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-faint)' }}>{t('packing.noBag')}</span>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>
{unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
</span>
</div>
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div>
{canEdit && showAddBag && (
<div ref={composerRef} style={{ ...COMPOSER, margin: '10px 10px 0', padding: '4px 4px 4px 10px' }}>
<span aria-hidden style={{ width: 10, height: 10, borderRadius: '50%', border: '1.5px dashed var(--text-faint)', flexShrink: 0 }} />
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') closeComposer() }}
placeholder={t('packing.bagName')}
style={{ flex: 1, minWidth: 0, padding: '5px 0', border: 'none', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'transparent' }} />
<button type="button" onClick={handleCreateBag} disabled={!newBagName.trim()} aria-label={t('common.add')} style={composerConfirm(!!newBagName.trim())}>
<Plus size={14} strokeWidth={2.5} />
</button>
<button type="button" onClick={closeComposer} aria-label={t('common.cancel')} style={{ width: 26, height: 28, border: 'none', background: 'none', cursor: 'pointer', display: 'grid', placeItems: 'center', flexShrink: 0, color: 'var(--text-faint)' }}>
<X size={13} />
</button>
</div>
)
})()}
)}
{/* Total */}
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>
<div style={{ padding: '0 14px' }}>
{bags.map((bag, index) => {
const bagItems = myItems.filter(i => i.bag_id === bag.id)
const weight = bagWeightOf(bag)
const pct = bagFillPct(weight, bag.weight_limit_grams, heaviestBagWeight)
return (
<div key={bag.id} style={{ ...hairline(index === 0), paddingTop: 12 }}>
<BagCard bag={bag} bagItems={bagItems} totalWeight={weight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
</div>
)
})}
{/* Whatever sits in no bag. Shown whenever there is weight to account
for, even with no visible items: the total counts it either way (#2191). */}
{(unassigned.length > 0 || unassignedWeight > 0) && (
<div style={{ ...hairline(bags.length === 0), display: 'flex', alignItems: 'center', gap: 6, padding: '11px 0' }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '1.5px dashed var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)' }}>{t('packing.noBag')}</span>
<span title={`${unassigned.length} ${t('admin.packingTemplates.items')}`} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, padding: '1px 6px', borderRadius: 99, minWidth: 18, textAlign: 'center', background: 'var(--bg-tertiary)', color: 'var(--text-faint)', fontVariantNumeric: 'tabular-nums' }}>{unassigned.length}</span>
<span style={{ flex: 1 }} />
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}>{kgOrG(unassignedWeight)}</span>
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg-tertiary)', borderRadius: '0 0 15px 15px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span>
<span>{(() => {
// Same rule as the rows above it (#2191).
const w = bags.reduce((s, b) => s + bagWeightOf(b), 0)
+ unassignedTotalWeight(unassignedWeightGrams, myItems.filter(i => !i.bag_id), serverWeightsFresh)
return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g`
})()}</span>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{kgOrG(totalWeight)}</span>
</div>
</div>
{/* Add bag */}
{canEdit && (showAddBag ? (
<div style={{ display: 'flex', gap: 4, marginTop: 12 }}>
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
placeholder={t('packing.bagName')}
style={{ flex: 1, padding: '5px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontFamily: 'inherit', outline: 'none' }} />
<button type="button" onClick={handleCreateBag} style={{ padding: '4px 8px', borderRadius: 8, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Plus size={12} />
</button>
</div>
) : (
<button type="button" onClick={() => setShowAddBag(true)}
style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}>
<Plus size={11} /> {t('packing.addBag')}
</button>
))}
</div>
)
}
@@ -323,7 +323,7 @@ describe('KategorieGruppe — bulk actions', () => {
expect(checkAll.style.background).toBe('none')
fireEvent.mouseEnter(deleteAll)
expect(deleteAll.style.background).toBe('rgb(254, 242, 242)')
expect(deleteAll.style.background).toBe('rgba(239, 68, 68, 0.1)')
})
})
@@ -10,6 +10,8 @@ import type { PackingItem, PackingBag } from '../../types'
import { katColor } from './packingListPanel.helpers'
import type { TripMember, CategoryAssignee } from './usePackingListPanel'
import { ArtikelZeile } from './PackingListPanelItemRow'
import { PopoverItem } from './PackingPopover'
import { COMPOSER, POPOVER, POPOVER_DIVIDER, composerConfirm } from './packingPopoverStyles'
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
import GuestBadge from '../shared/GuestBadge'
@@ -113,12 +115,10 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
}
return (
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', borderBottom: offen ? '1px solid var(--border-secondary)' : 'none' }}>
<button type="button" onClick={() => setOffen(o => !o)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }}>
{offen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
</button>
// Shaped after the phone's category card: a tinted head band carrying the
// name as written, the count, and the fold arrow on the far right.
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '11px 12px 11px 14px', background: 'var(--bg-tertiary)', borderRadius: offen ? '15px 15px 0 0' : 15 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
{editingName && canEdit ? (
@@ -127,10 +127,10 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
onChange={e => setEditKatName(e.target.value)}
onBlur={handleSaveKatName}
onKeyDown={e => { if (e.key === 'Enter') handleSaveKatName(); if (e.key === 'Escape') { setEditingName(false); setEditKatName(kategorie) } }}
style={{ flex: 1, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', borderBottom: '2px solid var(--text-primary)', outline: 'none', background: 'transparent', fontFamily: 'inherit', color: 'var(--text-primary)', padding: '0 2px' }}
style={{ flex: 1, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, border: 'none', borderBottom: '2px solid var(--text-primary)', outline: 'none', background: 'transparent', fontFamily: 'inherit', color: 'var(--text-primary)', padding: '0 2px' }}
/>
) : (
<span style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
<span style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', letterSpacing: '-0.01em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}>
{kategorie}
</span>
)}
@@ -188,11 +188,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
<UserPlus size={10} />
</button>
{showAssigneeDropdown && (
<div style={{
position: 'absolute', left: 0, top: '100%', marginTop: 4, zIndex: 50,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 160,
}}>
<div className="trek-menu-enter" style={{ ...POPOVER, position: 'absolute', left: 0, top: '100%', marginTop: 6, zIndex: 50, minWidth: 190 }}>
{tripMembers.map(m => {
const isAssigned = assignees.some(a => a.user_id === m.id)
return (
@@ -239,8 +235,9 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
</div>
<span style={{
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, padding: '1px 8px', borderRadius: 99,
background: alleAbgehakt ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, padding: '2px 8px', borderRadius: 99,
fontVariantNumeric: 'tabular-nums',
background: alleAbgehakt ? 'rgba(22,163,74,0.12)' : 'var(--bg-card)',
color: alleAbgehakt ? '#16a34a' : 'var(--text-muted)',
}}>
{abgehakt}/{items.length}
@@ -256,28 +253,32 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
return (
<>
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 99 }} onClick={() => setShowMenu(false)} />
<div style={{ position: 'fixed', right: rect ? window.innerWidth - rect.right : 0, top: rect ? rect.bottom + 4 : 0, zIndex: 100, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}>
{canEdit && <MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
<div className="trek-menu-enter" style={{ ...POPOVER, position: 'fixed', right: rect ? window.innerWidth - rect.right : 0, top: rect ? rect.bottom + 6 : 0, zIndex: 100, minWidth: 200 }}>
{canEdit && <PopoverItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
<PopoverItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
<PopoverItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
{canEdit && <>
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
<div style={POPOVER_DIVIDER} />
<PopoverItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
</>}
</div>
</>
);
})()}
</div>
<button type="button" onClick={() => setOffen(o => !o)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }}>
{offen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
</button>
</div>
{offen && (
<div style={{ padding: '4px 4px 6px' }}>
{items.map(item => {
{items.map((item, index) => {
const contributions = contribFor(item.id)
return (
<React.Fragment key={item.id}>
<ArtikelZeile item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit}
<ArtikelZeile item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} divider={index > 0}
tripMembers={tripMembers} currentUserId={currentUserId} onSetSharing={onSetSharing} onClone={onClone} onJoin={onJoin} onLeave={onLeave}
drag={canEdit ? {
isDragging: dragId === item.id,
@@ -293,7 +294,10 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
})}
{/* Inline add item */}
{canEdit && (showAddItem ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}>
// The new item already looks like a row: an empty box in the checkbox
// column, the name typed where names sit, and the add as the accent.
<div style={{ ...COMPOSER, gap: 9, margin: '6px 4px 0', padding: '4px 4px 4px 27px' }}>
<span aria-hidden style={{ width: 20, height: 20, borderRadius: 6, border: '1.5px solid var(--text-faint)', opacity: 0.6, flexShrink: 0 }} />
<input
ref={addItemRef}
autoFocus
@@ -308,25 +312,31 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
if (e.key === 'Escape') { setShowAddItem(false); setNewItemName('') }
}}
placeholder={t('packing.addItemPlaceholder')}
style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'var(--bg-input)' }}
style={{ flex: 1, minWidth: 0, padding: '5px 0', border: 'none', fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 500, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'transparent' }}
/>
{/* disabled while the field is empty, so no extra guard here */}
<button type="button" onClick={() => { onAddItem(kategorie, newItemName.trim()); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
disabled={!newItemName.trim()}
style={{ padding: '5px 8px', borderRadius: 8, border: 'none', background: newItemName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newItemName.trim() ? 'pointer' : 'default', display: 'flex' }}>
<Plus size={14} />
disabled={!newItemName.trim()} aria-label={t('common.add')}
style={composerConfirm(!!newItemName.trim())}>
<Plus size={15} strokeWidth={2.5} />
</button>
<button type="button" onClick={() => { setShowAddItem(false); setNewItemName('') }}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: 'var(--text-faint)' }}>
<button type="button" onClick={() => { setShowAddItem(false); setNewItemName('') }} aria-label={t('common.cancel')}
style={{ width: 28, height: 28, borderRadius: 8, border: 'none', background: 'none', cursor: 'pointer', display: 'grid', placeItems: 'center', flexShrink: 0, color: 'var(--text-faint)' }}>
<X size={14} />
</button>
</div>
) : (
// A row of its own on a faint tint, so it closes the card instead
// of trailing off: a dashed box in the checkbox column (past the drag
// grip's width) and the label where an item's name sits.
<button type="button" onClick={() => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }}
style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px', margin: '2px 4px', borderRadius: 8, border: 'none', background: 'none', cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontFamily: 'inherit' }}
style={{ display: 'flex', alignItems: 'center', gap: 9, width: 'calc(100% - 8px)', margin: '6px 4px 0', padding: '8px 10px 8px 28px', borderRadius: 10, border: 'none', background: 'var(--bg-secondary)', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, color: 'var(--text-faint)', fontFamily: 'inherit', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Plus size={12} /> {t('packing.addItem')}
<span style={{ width: 20, height: 20, borderRadius: 6, border: '1.5px dashed currentColor', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
<Plus size={12} strokeWidth={2.5} />
</span>
{t('packing.addItem')}
</button>
))}
</div>
@@ -334,26 +344,3 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
</div>
)
}
interface MenuItemProps {
icon: React.ReactNode
label: string
onClick: () => void
danger?: boolean
}
function MenuItem({ icon, label, onClick, danger = false }: MenuItemProps) {
return (
<button type="button" onClick={onClick} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '7px 10px', background: 'none', border: 'none', cursor: 'pointer',
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontFamily: 'inherit', borderRadius: 7, textAlign: 'left',
color: danger ? '#ef4444' : 'var(--text-secondary)',
}}
onMouseEnter={e => e.currentTarget.style.background = danger ? '#fef2f2' : 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>
{icon}{label}
</button>
)
}
@@ -1,7 +1,8 @@
import {
X, Check, CheckCheck, Luggage, Package, FolderPlus, Download,
CheckCheck, Luggage, Package, FolderPlus, Download, Trash2,
} from 'lucide-react'
import type { PackingState } from './usePackingListPanel'
import PackingNameDialog from './PackingNameDialog'
export function PackingHeader(S: PackingState) {
const {
@@ -26,20 +27,6 @@ export function PackingHeader(S: PackingState) {
</div>
) : <span />}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{canEdit && isAdmin && items.length > 0 && showSaveTemplate && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
type="text" autoFocus
value={saveTemplateName}
onChange={e => setSaveTemplateName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }}
placeholder={t('packing.templateName')}
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/>
<button type="button" onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
<button type="button" onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
</div>
)}
{inlineHeader && canEdit && (
<button type="button" onClick={() => setShowImportModal(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
@@ -122,8 +109,9 @@ export function PackingHeader(S: PackingState) {
</div>
{items.length > 0 && (
<div className="hidden sm:block" style={{ marginTop: 14, marginBottom: 14 }}>
<div className="flex items-center" style={{ gap: 14 }}>
// The phone's progress card: count and share on the left, the bar filling the rest.
<div className="hidden sm:block" style={{ marginTop: 14, marginBottom: 2, padding: '14px 18px', borderRadius: 16, background: 'var(--bg-card)', border: '1px solid var(--border-secondary)' }}>
<div className="flex items-center" style={{ gap: 16 }}>
{fortschritt === 100 ? (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -158,7 +146,7 @@ export function PackingHeader(S: PackingState) {
<div style={{
flex: 1,
height: 8,
height: 7,
background: 'var(--bg-tertiary)',
borderRadius: 99,
overflow: 'hidden',
@@ -184,36 +172,53 @@ export function PackingHeader(S: PackingState) {
}} />
</div>
</div>
{/* Under the Lists bar the clean-up sits with the count it clears. */}
{!inlineHeader && canEdit && abgehakt > 0 && (
<button type="button" onClick={handleClearChecked} style={{
display: 'inline-flex', alignItems: 'center', gap: 5, flexShrink: 0, padding: '5px 11px', borderRadius: 99,
border: 'none', background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600,
}}>
<Trash2 size={13} strokeWidth={2.2} />
{t('packing.clearChecked', { count: abgehakt })}
</button>
)}
</div>
</div>
)}
{canEdit && (addingCategory ? (
<div style={{ display: 'flex', gap: 6 }}>
<input
autoFocus
type="text" value={newCatName} onChange={e => setNewCatName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddNewCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
placeholder={t('packing.newCategoryPlaceholder')}
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }}
/>
<button type="button" onClick={handleAddNewCategory} disabled={!newCatName.trim()}
style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: newCatName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newCatName.trim() ? 'pointer' : 'default', display: 'flex', alignItems: 'center' }}>
<Check size={16} />
</button>
<button type="button" onClick={() => { setAddingCategory(false); setNewCatName('') }}
style={{ padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}>
<X size={16} />
</button>
</div>
) : (
{canEdit && inlineHeader && (
<button type="button" onClick={() => setAddingCategory(true)}
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '9px 14px', borderRadius: 10, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontFamily: 'inherit', transition: 'all 0.15s' }}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%', padding: '10px 14px', borderRadius: 99, border: '1.5px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-faint)', fontFamily: 'inherit', transition: 'all 0.15s' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-secondary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
<FolderPlus size={14} /> {t('packing.addCategory')}
</button>
))}
)}
{/* Naming a new list or a template happens in a dialog of its own, not in a
field pushed into the page. */}
<PackingNameDialog
open={canEdit && addingCategory}
title={t('packing.addCategory')}
placeholder={t('packing.newCategoryPlaceholder')}
confirmLabel={t('common.add')}
value={newCatName}
onChange={setNewCatName}
onConfirm={handleAddNewCategory}
onClose={() => { setAddingCategory(false); setNewCatName('') }}
/>
<PackingNameDialog
open={canEdit && isAdmin && items.length > 0 && showSaveTemplate}
title={t('packing.saveAsTemplate')}
placeholder={t('packing.templateName')}
confirmLabel={t('common.save')}
value={saveTemplateName}
onChange={setSaveTemplateName}
onConfirm={handleSaveAsTemplate}
onClose={() => { setShowSaveTemplate(false); setSaveTemplateName('') }}
/>
</div>
)
}
@@ -62,6 +62,12 @@ function fieldOf(container: HTMLElement, label: string) {
return within(menu(container).getByText(label).parentElement!).getByRole('textbox', { hidden: true })
}
/** Opens the item menu and its Move to List entry. */
function openMoveToList(container: HTMLElement) {
fireEvent.click(overflowTrigger(container))
fireEvent.click(menuButton(container, 'Move to List'))
}
function bagButton(container: HTMLElement) {
// The bag picker trigger is the round button right after the weight field.
return container.querySelectorAll<HTMLButtonElement>('button[style*="border-radius: 50%"]')[0]
@@ -92,19 +98,21 @@ describe('ArtikelZeile — basics', () => {
const { container } = setup()
expect(screen.getByText('Tent')).toBeInTheDocument()
fireEvent.click(container.querySelector('svg.lucide-square')!.closest('button')!)
const box = container.querySelector<HTMLButtonElement>('.packing-check')!
expect(box).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(box)
await waitFor(() => expect(body).toMatchObject({ checked: true }))
})
it('FE-W5ROW-002: a checked item is struck through and not editable by click', () => {
it('FE-W5ROW-002: a checked item is struck through and still renames on a click', () => {
setup({ item: buildPackingItem({ id: 1, name: 'Tent', checked: 1 }) })
const label = screen.getByText('Tent')
expect(label).toHaveStyle({ textDecoration: 'line-through' })
fireEvent.click(label)
expect(screen.queryByDisplayValue('Tent')).toBeNull()
expect(screen.getByDisplayValue('Tent')).toBeInTheDocument()
})
it('FE-W5ROW-003: the placeholder row edits from an empty field', () => {
@@ -126,19 +134,43 @@ describe('ArtikelZeile — basics', () => {
expect(screen.queryByDisplayValue('Tent')).toBeNull()
})
it('FE-W5ROW-005: hovering the row lifts its background and dismisses open pickers', () => {
it('FE-W5ROW-005: hovering the row lifts its background and brings its controls up', () => {
const { container } = setup()
const row = container.querySelector<HTMLElement>('.packing-item-row')!
const actions = container.querySelector<HTMLElement>('.packing-row-overflow')!
expect(actions.style.opacity).toBe('0.4')
fireEvent.mouseEnter(row)
expect(row.style.background).toBe('var(--bg-secondary)')
fireEvent.click(screen.getByTitle('Move to List'))
expect(screen.getByRole('button', { name: 'Docs' })).toBeInTheDocument()
expect(actions.style.opacity).toBe('1')
fireEvent.mouseLeave(row)
expect(row.style.background).toBe('transparent')
expect(screen.queryByRole('button', { name: 'Docs' })).toBeNull()
expect(actions.style.opacity).toBe('0.4')
})
it('FE-W5ROW-005b: keyboard focus inside the row counts as hovering it', () => {
const { container } = setup()
const actions = container.querySelector<HTMLElement>('.packing-row-overflow')!
fireEvent.focus(overflowTrigger(container))
expect(actions.style.opacity).toBe('1')
fireEvent.blur(overflowTrigger(container), { relatedTarget: document.body })
expect(actions.style.opacity).toBe('0.4')
})
it('FE-W5ROW-005c: the bag picker stays open when the pointer leaves and closes on a click beside it', () => {
const { container } = setup({ bagTrackingEnabled: true, bags: BAGS })
const row = container.querySelector<HTMLElement>('.packing-item-row')!
fireEvent.click(bagButton(container))
expect(screen.getByRole('button', { name: 'Trolley' })).toBeInTheDocument()
fireEvent.mouseLeave(row)
expect(screen.getByRole('button', { name: 'Trolley' })).toBeInTheDocument()
fireEvent.click(container.querySelector('div[role="presentation"]')!)
expect(screen.queryByRole('button', { name: 'Trolley' })).toBeNull()
})
})
@@ -172,7 +204,7 @@ describe('ArtikelZeile — renaming', () => {
}),
)
setup()
fireEvent.click(screen.getByTitle('Rename'))
fireEvent.click(screen.getByText('Tent'))
const input = screen.getByDisplayValue('Tent')
fireEvent.change(input, { target: { value: ' Tarp ' } })
@@ -185,7 +217,7 @@ describe('ArtikelZeile — renaming', () => {
let called = false
server.use(http.put('/api/trips/1/packing/1', () => { called = true; return HttpResponse.json({ item: buildPackingItem() }) }))
setup()
fireEvent.click(screen.getByTitle('Rename'))
fireEvent.click(screen.getByText('Tent'))
const input = screen.getByDisplayValue('Tent')
fireEvent.change(input, { target: { value: ' ' } })
@@ -227,7 +259,7 @@ describe('ArtikelZeile — renaming', () => {
it('FE-W5ROW-009: a failing rename surfaces a save error', async () => {
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
setup()
fireEvent.click(screen.getByTitle('Rename'))
fireEvent.click(screen.getByText('Tent'))
fireEvent.change(screen.getByDisplayValue('Tent'), { target: { value: 'Tarp' } })
fireEvent.keyDown(screen.getByDisplayValue('Tarp'), { key: 'Enter' })
@@ -265,16 +297,10 @@ describe('ArtikelZeile — deleting', () => {
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to delete', 'error', undefined))
})
it('FE-W5ROW-013: the rename and delete buttons highlight on hover', () => {
it('FE-W5ROW-013: the delete button turns red on hover', () => {
setup()
const rename = screen.getByTitle('Rename')
const remove = screen.getByTitle('Delete')
fireEvent.mouseEnter(rename)
expect(rename.style.color).toBe('var(--text-secondary)')
fireEvent.mouseLeave(rename)
expect(rename.style.color).toBe('var(--text-faint)')
fireEvent.mouseEnter(remove)
expect(remove.style.color).toBe('rgb(239, 68, 68)')
fireEvent.mouseLeave(remove)
@@ -291,8 +317,8 @@ describe('ArtikelZeile — category picker', () => {
return HttpResponse.json({ item: buildPackingItem({ id: 1, category: 'Docs' }) })
}),
)
setup()
fireEvent.click(screen.getByTitle('Move to List'))
const { container } = setup()
openMoveToList(container)
fireEvent.click(screen.getByRole('button', { name: 'Docs' }))
@@ -303,8 +329,8 @@ describe('ArtikelZeile — category picker', () => {
it('FE-W5ROW-015: picking the current list closes the picker without a request', async () => {
let called = false
server.use(http.put('/api/trips/1/packing/1', () => { called = true; return HttpResponse.json({ item: buildPackingItem() }) }))
setup()
fireEvent.click(screen.getByTitle('Move to List'))
const { container } = setup()
openMoveToList(container)
fireEvent.click(screen.getByRole('button', { name: 'Gear' }))
@@ -314,8 +340,8 @@ describe('ArtikelZeile — category picker', () => {
it('FE-W5ROW-016: a failing move surfaces a generic error', async () => {
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
setup()
fireEvent.click(screen.getByTitle('Move to List'))
const { container } = setup()
openMoveToList(container)
fireEvent.click(screen.getByRole('button', { name: 'Docs' }))
@@ -323,11 +349,11 @@ describe('ArtikelZeile — category picker', () => {
})
it('FE-W5ROW-017: an uncategorized item marks the default list as current', () => {
setup({
const { container } = setup({
item: buildPackingItem({ id: 1, name: 'Soap', category: null }),
categories: ['Other', 'Gear'],
})
fireEvent.click(screen.getByTitle('Move to List'))
openMoveToList(container)
expect(screen.getByRole('button', { name: 'Other' })).toHaveStyle({ background: 'var(--bg-tertiary)' })
expect(screen.getByRole('button', { name: 'Gear' })).not.toHaveStyle({ background: 'var(--bg-tertiary)' })
@@ -341,7 +367,7 @@ describe('ArtikelZeile — sharing badges', () => {
currentUserId: 1,
})
expect(screen.getByText('by Bob')).toBeInTheDocument()
expect(screen.getByLabelText('by Bob')).toHaveAttribute('title', 'by Bob')
})
it('FE-W5ROW-019: a nameless bringer degrades to an empty name', () => {
@@ -362,8 +388,8 @@ describe('ArtikelZeile — sharing badges', () => {
currentUserId: 1,
})
expect(screen.getByText('shared with 2')).toBeInTheDocument()
expect(screen.getByTitle('alice, bob')).toBeInTheDocument()
expect(screen.getByLabelText('shared with 2')).toHaveTextContent('2')
expect(screen.getByTitle('shared with 2: alice, bob')).toBeInTheDocument()
})
it('FE-W5ROW-021: a common item names its bringer and counts co-bringers', () => {
@@ -375,7 +401,8 @@ describe('ArtikelZeile — sharing badges', () => {
currentUserId: 1,
})
expect(screen.getByTitle('brought by Bob')).toHaveTextContent('Bob +1')
// The bringer's initial on an avatar, the co-bringers as a count.
expect(screen.getByTitle('brought by Bob')).toHaveTextContent('B+1')
})
it('FE-W5ROW-022: a bringer without co-bringers shows no counter', () => {
@@ -384,7 +411,7 @@ describe('ArtikelZeile — sharing badges', () => {
currentUserId: 1,
})
expect(screen.getByTitle('brought by Bob')).toHaveTextContent(/^Bob$/)
expect(screen.getByTitle('brought by Bob')).toHaveTextContent(/^B$/)
})
it('FE-W5ROW-023: the placeholder row carries no badges and no share control', () => {
@@ -403,7 +430,7 @@ describe('ArtikelZeile — sharing badges', () => {
it('FE-W5ROW-024: the share control is wired up for an editable item', () => {
const onSetSharing = vi.fn()
setup({
const { container } = setup({
currentUserId: 1,
tripMembers: MEMBERS,
onSetSharing,
@@ -412,7 +439,9 @@ describe('ArtikelZeile — sharing badges', () => {
onLeave: () => {},
})
fireEvent.click(screen.getByTitle('Sharing'))
// Sharing lives in the item menu, as a whole line to click.
fireEvent.click(overflowTrigger(container))
fireEvent.click(menu(container).getByText('Sharing').closest('button')!)
fireEvent.click(screen.getByText(/^Personal$/))
expect(onSetSharing).toHaveBeenCalledWith(1, 'personal', [])
@@ -799,9 +828,9 @@ describe('ArtikelZeile — weight and bag', () => {
const add = screen.getByText('Add bag').closest('button')!
fireEvent.mouseEnter(add)
expect(add.style.color).toBe('var(--text-secondary)')
expect(add.style.background).toBe('var(--bg-tertiary)')
fireEvent.mouseLeave(add)
expect(add.style.color).toBe('var(--text-faint)')
expect(add.style.background).toBe('none')
})
})
@@ -988,9 +1017,7 @@ describe('ArtikelZeile — overflow menu', () => {
})
fireEvent.click(overflowTrigger(container))
expect(screen.getByText('Sharing')).toBeInTheDocument()
const triggers = screen.getAllByTitle('Sharing')
fireEvent.click(triggers[triggers.length - 1])
fireEvent.click(menu(container).getByText('Sharing').closest('button')!)
fireEvent.click(screen.getByText(/^Personal$/))
expect(onSetSharing).toHaveBeenCalledWith(1, 'personal', [])
@@ -1015,6 +1042,6 @@ describe('ArtikelZeile — overflow menu', () => {
expect(active.style.background).toBe('var(--bg-tertiary)')
fireEvent.mouseEnter(danger)
expect(danger.style.background).toBe('rgb(254, 242, 242)')
expect(danger.style.background).toBe('rgba(239, 68, 68, 0.1)')
})
})
@@ -1,16 +1,18 @@
import { useRef, useState, type CSSProperties, type ReactNode } from 'react'
import { useRef, useState, type CSSProperties } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import {
CheckSquare, Square, Trash2, Plus, Pencil, Package, GripVertical, UserRound, Users, HandHelping,
MoreHorizontal,
Check, Trash2, Plus, Pencil, Package, GripVertical, UserRound, HandHelping,
MoreHorizontal, ChevronDown, ChevronRight,
} from 'lucide-react'
import type { PackingItem, PackingBag } from '../../types'
import { katColor } from './packingListPanel.helpers'
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
import { QuantityInput } from './PackingListPanelQuantityInput'
import PackingShareControl from './PackingShareControl'
import { PopoverItem } from './PackingPopover'
import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER } from './packingPopoverStyles'
import type { TripMember } from './usePackingListPanel'
import { NumericInput } from '../shared/NumericInput'
@@ -31,6 +33,8 @@ interface ArtikelZeileProps {
onClone?: (id: number) => void
onJoin?: (id: number) => void
onLeave?: (id: number, userId: number) => void
/** Draws the hairline above the row; the category group leaves it off its first row. */
divider?: boolean
// Drag-to-reorder (#969) — wired by the category group, which owns the order.
drag?: {
isDragging: boolean
@@ -42,12 +46,14 @@ interface ArtikelZeileProps {
}
}
export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, drag }: ArtikelZeileProps) {
export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, divider = false, drag }: ArtikelZeileProps) {
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
const [hovered, setHovered] = useState(false)
const [showCatPicker, setShowCatPicker] = useState(false)
// Keyboard focus anywhere in the row counts as hovering it, so every control
// that only shows on hover is also there for someone tabbing through.
const [focused, setFocused] = useState(false)
const [showBagPicker, setShowBagPicker] = useState(false)
const [showItemMenu, setShowItemMenu] = useState(false)
const [showMenuCategories, setShowMenuCategories] = useState(false)
@@ -83,7 +89,6 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
}
const handleCatChange = async (cat: string) => {
setShowCatPicker(false)
setShowMenuCategories(false)
setShowItemMenu(false)
if (cat === item.category) return
@@ -93,13 +98,28 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
const canDrag = canEdit && !isPlaceholder && !!drag
const selectedBag = bags.find(b => b.id === item.bag_id)
const quantity = item.quantity || 1
const owner = tripMembers.find(m => m.id === item.owner_id)
const ownerAvatar = owner?.avatar_url ?? owner?.avatar ?? null
// At rest a row reads as what it holds (owner, a quantity above one, a
// weight, the bag) and everything else stays in place but dimmed: nothing
// leaves a hole and nothing jumps. Pointer or focus brings it all up.
const active = hovered || focused || showItemMenu || showBagPicker
const reveal = (visible: boolean): CSSProperties => ({ opacity: visible ? 1 : 0.4, transition: 'opacity 120ms ease' })
// Quantity and weight read as quiet pills; under the pointer an outline
// shows they can be typed into.
const badge = (visible: boolean): CSSProperties => ({
...reveal(visible), background: 'var(--bg-tertiary)', borderRadius: 99, padding: '2px 8px',
borderColor: active ? 'var(--border-primary)' : 'transparent',
})
// Shared by both shells of the name: a renameable name is a real button so
// the rename can be reached with the keyboard, everything else is plain text.
const nameStyle: CSSProperties = {
flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontSize: 'calc(13.5px * var(--fs-scale-body, 1))',
cursor: !canEdit || item.checked ? 'default' : 'text',
fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 500,
cursor: canEdit ? 'text' : 'default',
color: isPlaceholder ? 'var(--text-faint)' : (item.checked ? 'var(--text-faint)' : 'var(--text-primary)'),
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
textDecoration: item.checked ? 'line-through' : 'none',
@@ -109,19 +129,22 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
<div
className="group packing-item-row"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => { setHovered(false); setShowCatPicker(false); setShowBagPicker(false) }}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false) }}
onDragOver={canDrag ? (e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag!.onOver(item.id) }) : undefined}
onDragLeave={canDrag ? (e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) drag!.onOver(-1) }) : undefined}
onDrop={canDrag ? (e => { e.preventDefault(); e.stopPropagation(); drag!.onDrop(item.id) }) : undefined}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 10px', borderRadius: 10, position: 'relative',
display: 'flex', alignItems: 'center', gap: 9,
padding: '8px 10px', borderRadius: 10, position: 'relative',
background: hovered ? 'var(--bg-secondary)' : 'transparent',
opacity: drag?.isDragging ? 0.4 : 1,
boxShadow: drag?.isOver ? 'inset 3px 0 0 0 var(--accent)' : 'none',
transition: 'background 0.1s, opacity 0.15s',
}}
>
{divider && !hovered && <span aria-hidden className="pointer-events-none absolute inset-x-2.5 top-0 h-px bg-edge-faint" />}
{canDrag && (
<div
draggable
@@ -133,20 +156,16 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
<GripVertical size={13} />
</div>
)}
<button type="button" onClick={handleToggle} style={{
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, position: 'relative',
width: 18, height: 18,
color: item.checked ? '#10b981' : 'var(--text-faint)',
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
{/* The phone's box: filled with the accent once packed, an outline until then. */}
<button type="button" onClick={handleToggle} className="packing-check" aria-pressed={!!item.checked} style={{
flexShrink: 0, cursor: 'pointer', padding: 0, width: 20, height: 20, borderRadius: 6,
display: 'grid', placeItems: 'center',
border: `1.5px solid ${item.checked ? 'var(--accent)' : 'var(--text-faint)'}`,
background: item.checked ? 'var(--accent)' : 'transparent',
color: 'var(--accent-text)',
transition: 'background 180ms cubic-bezier(0.23,1,0.32,1), border-color 180ms cubic-bezier(0.23,1,0.32,1)',
}}>
<Square size={18} style={{
position: 'absolute', inset: 0,
opacity: item.checked ? 0 : 1,
transform: item.checked ? 'scale(0.7)' : 'scale(1)',
transition: 'opacity 180ms cubic-bezier(0.23,1,0.32,1), transform 180ms cubic-bezier(0.23,1,0.32,1)',
}} />
<CheckSquare size={18} style={{
position: 'absolute', inset: 0,
<Check size={12} strokeWidth={3} style={{
opacity: item.checked ? 1 : 0,
transform: item.checked ? 'scale(1)' : 'scale(0.5)',
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 220ms cubic-bezier(0.34,1.56,0.64,1)',
@@ -162,7 +181,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
style={{ flex: 1, minWidth: 0, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
/>
) : canEdit && !item.checked ? (
) : canEdit ? (
<button
type="button"
onClick={() => setEditing(true)}
@@ -176,34 +195,44 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
</span>
)}
{/* Sharing badges (#858 three-tier) */}
{/* Sharing badges (#858 three-tier), as compact chips like the phone's
(#1525): the full sentence is the tooltip and the accessible name. */}
{!isPlaceholder && sharedToMe && (
<span className="packing-row-badge" title={t('packing.takenCareOf', { name: item.owner_username || '' })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 12%, transparent)', padding: '1px 7px', borderRadius: 99 }}>
<HandHelping size={10} /> {t('packing.takenCareOf', { name: item.owner_username || '' })}
<span className="packing-row-badge" title={t('packing.takenCareOf', { name: item.owner_username || '' })} aria-label={t('packing.takenCareOf', { name: item.owner_username || '' })}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, width: 22, height: 22, borderRadius: 99, color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 12%, transparent)' }}>
<HandHelping size={12} />
</span>
)}
{!isPlaceholder && sharedByMe && (
<span className="packing-row-badge" title={recipients.map(r => r.username).join(', ')}
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 7px', borderRadius: 99 }}>
<UserRound size={10} /> {t('packing.sharedWithCount', { count: recipients.length })}
<span className="packing-row-badge" title={`${t('packing.sharedWithCount', { count: recipients.length })}: ${recipients.map(r => r.username).join(', ')}`} aria-label={t('packing.sharedWithCount', { count: recipients.length })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, height: 22, padding: '0 7px', borderRadius: 99, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', background: 'var(--bg-tertiary)' }}>
<UserRound size={11} /> {recipients.length}
</span>
)}
{!isPlaceholder && broughtBy && (
<span className="packing-row-badge" title={t('packing.broughtBy', { name: broughtBy })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '1px 4px' }}>
<Users size={10} /> {broughtBy}{contributors.length > 0 ? ` +${contributors.length}` : ''}
<span className="packing-row-badge" title={t('packing.broughtBy', { name: broughtBy })} aria-label={t('packing.broughtBy', { name: broughtBy })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0 }}>
{ownerAvatar
? <img src={ownerAvatar} alt="" style={{ width: 20, height: 20, borderRadius: '50%', objectFit: 'cover', display: 'block' }} />
: (
<span aria-hidden style={{
width: 20, height: 20, borderRadius: '50%', display: 'grid', placeItems: 'center',
background: `hsl(${(broughtBy.codePointAt(0) ?? 0) * 37 % 360}, 55%, 55%)`, // theme-lint-disable: the colour comes from the bringer's name
color: 'white', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase',
}}>{broughtBy[0]}</span>
)}
{contributors.length > 0 && <span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)' }}>+{contributors.length}</span>}
</span>
)}
<div className="packing-row-inline-actions" style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
{/* Quantity */}
{canEdit && <QuantityInput value={item.quantity || 1} onSave={qty => updatePackingItem(tripId, item.id, { quantity: qty })} />}
{canEdit && <QuantityInput value={quantity} onSave={qty => updatePackingItem(tripId, item.id, { quantity: qty })} style={badge(active || quantity > 1)} />}
{/* Weight + Bag (when enabled) */}
{bagTrackingEnabled && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent', ...badge(active || item.weight_grams != null) }}>
<NumericInput
value={item.weight_grams ?? ''}
readOnly={!canEdit}
@@ -224,127 +253,65 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
width: 22, height: 22, borderRadius: '50%', cursor: canEdit ? 'pointer' : 'default', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
border: item.bag_id ? `2.5px solid ${selectedBag?.color || 'var(--border-primary)'}` : '2px dashed var(--border-primary)',
background: item.bag_id ? `${selectedBag?.color || 'var(--border-primary)'}30` : 'transparent',
opacity: item.bag_id || active ? 1 : 0.45, transition: 'opacity 120ms ease',
}}
>
{!item.bag_id && <Package size={9} className="text-content-faint" />}
</button>
{showBagPicker && (
<div style={{
position: 'absolute', right: 0, top: '100%', marginTop: 4, zIndex: 50,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 160,
}}>
<>
<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 style={POPOVER_CAPTION}>{t('packing.bags')}</div>
{item.bag_id && (
<button type="button" onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: null }) } catch { toast.error(t('packing.toast.saveError')) } }}
style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '6px 10px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontFamily: 'inherit', color: 'var(--text-faint)', borderRadius: 7 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)' }} />
{t('packing.noBag')}
</button>
<PopoverItem icon={<span style={{ width: 10, height: 10, borderRadius: '50%', border: '1.5px dashed var(--text-faint)', display: 'inline-block' }} />} label={t('packing.noBag')}
onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: null }) } catch { toast.error(t('packing.toast.saveError')) } }} />
)}
{bags.map(b => (
<button type="button" key={b.id} onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: b.id }) } catch { toast.error(t('packing.toast.saveError')) } }}
style={{
display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '6px 10px',
background: item.bag_id === b.id ? 'var(--bg-tertiary)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontFamily: 'inherit', color: 'var(--text-secondary)', borderRadius: 7,
}}
onMouseEnter={e => { if (item.bag_id !== b.id) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
onMouseLeave={e => { if (item.bag_id !== b.id) e.currentTarget.style.background = 'none' }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: b.color, flexShrink: 0 }} />
{b.name}
</button>
<PopoverItem key={b.id} icon={<span style={{ width: 10, height: 10, borderRadius: '50%', background: b.color, display: 'inline-block' }} />} label={b.name} active={item.bag_id === b.id}
onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: b.id }) } catch { toast.error(t('packing.toast.saveError')) } }} />
))}
{bags.length > 0 && <div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />}
<div style={{ padding: '4px 6px' }}>
{bagInlineCreate ? (
<div style={{ display: 'flex', gap: 4 }}>
<input autoFocus value={bagInlineName} onChange={e => setBagInlineName(e.target.value)}
onKeyDown={async e => {
if (e.key === 'Enter' && bagInlineName.trim()) {
const newBag = await onCreateBag(bagInlineName.trim())
if (newBag) { try { await updatePackingItem(tripId, item.id, { bag_id: newBag.id }) } catch { toast.error(t('packing.toast.saveError')) } }
setBagInlineName(''); setBagInlineCreate(false); setShowBagPicker(false)
}
if (e.key === 'Escape') { setBagInlineCreate(false); setBagInlineName('') }
}}
placeholder={t('packing.bagName')}
style={{ flex: 1, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontFamily: 'inherit', outline: 'none' }} />
<button type="button" onClick={async () => {
if (bagInlineName.trim()) {
{bags.length > 0 && <div style={POPOVER_DIVIDER} />}
{bagInlineCreate ? (
<div style={{ display: 'flex', gap: 6, padding: 4 }}>
<input autoFocus value={bagInlineName} onChange={e => setBagInlineName(e.target.value)}
onKeyDown={async e => {
if (e.key === 'Enter' && bagInlineName.trim()) {
const newBag = await onCreateBag(bagInlineName.trim())
if (newBag) { try { await updatePackingItem(tripId, item.id, { bag_id: newBag.id }) } catch { toast.error(t('packing.toast.saveError')) } }
setBagInlineName(''); setBagInlineCreate(false); setShowBagPicker(false)
}
if (e.key === 'Escape') { setBagInlineCreate(false); setBagInlineName('') }
}}
style={{ padding: '3px 6px', borderRadius: 6, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Plus size={11} />
</button>
</div>
) : (
<button type="button" onClick={() => setBagInlineCreate(true)}
style={{ display: 'flex', alignItems: 'center', gap: 5, width: '100%', padding: '5px 6px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontFamily: 'inherit', color: 'var(--text-faint)', borderRadius: 7 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Plus size={11} /> {t('packing.addBag')}
placeholder={t('packing.bagName')}
style={{ flex: 1, minWidth: 0, padding: '6px 9px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'var(--bg-input)' }} />
<button type="button" aria-label={t('common.add')} onClick={async () => {
if (bagInlineName.trim()) {
const newBag = await onCreateBag(bagInlineName.trim())
if (newBag) { try { await updatePackingItem(tripId, item.id, { bag_id: newBag.id }) } catch { toast.error(t('packing.toast.saveError')) } }
setBagInlineName(''); setBagInlineCreate(false); setShowBagPicker(false)
}
}}
style={{ width: 30, borderRadius: 8, border: 'none', background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer', display: 'grid', placeItems: 'center', flexShrink: 0, opacity: bagInlineName.trim() ? 1 : 0.35 }}>
<Plus size={13} strokeWidth={2.5} />
</button>
)}
</div>
</div>
) : (
<PopoverItem icon={<Plus size={13} />} label={t('packing.addBag')} muted onClick={() => setBagInlineCreate(true)} />
)}
</div>
</>
)}
</div>
</>
)}
</div>
{/* Deleting stays one click away; moving, sharing and renaming live in the
menu next to it, and a click on the name renames as well. */}
{canEdit && (
<div className="packing-row-inline-actions" style={{ display: 'flex', gap: 2, alignItems: 'center', flexShrink: 0 }}>
<div style={{ position: 'relative' }}>
<button type="button"
onClick={() => setShowCatPicker(p => !p)}
title={t('packing.changeCategory')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 5px', borderRadius: 6, display: 'flex', alignItems: 'center', color: 'var(--text-faint)', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', gap: 2 }}
>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: katColor(item.category || t('packing.defaultCategory'), categories), display: 'inline-block' }} />
</button>
{showCatPicker && (
<div style={{
position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)',
border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
padding: 4, minWidth: 140,
}}>
{categories.map(cat => (
<button type="button" key={cat} onClick={() => handleCatChange(cat)} style={{
display: 'flex', alignItems: 'center', gap: 7, width: '100%',
padding: '6px 10px', background: cat === (item.category || t('packing.defaultCategory')) ? 'var(--bg-tertiary)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontFamily: 'inherit',
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
}}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(cat, categories), flexShrink: 0 }} />
{cat}
</button>
))}
</div>
)}
</div>
{canShare && onClone && onJoin && onLeave && (
<PackingShareControl
item={item}
tripMembers={tripMembers}
currentUserId={currentUserId}
onSetSharing={onSetSharing!}
onClone={onClone}
onJoin={onJoin}
onLeave={onLeave}
/>
)}
<button type="button" onClick={() => setEditing(true)} title={t('common.rename')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={13} />
</button>
<button type="button" onClick={handleDelete} title={t('common.delete')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
<div className="packing-row-inline-actions" style={{ display: 'flex', gap: 2, alignItems: 'center', flexShrink: 0, ...reveal(active) }}>
<button type="button" onClick={handleDelete} title={t('common.delete')} aria-label={t('common.delete')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={13} />
</button>
@@ -352,12 +319,13 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
)}
{canEdit && (
<div className="packing-row-overflow" style={{ display: 'none', flexShrink: 0, position: 'relative' }}>
<div className="packing-row-overflow" style={{ display: 'flex', flexShrink: 0, position: 'relative', ...reveal(active) }}>
<button type="button"
ref={itemMenuBtnRef}
onClick={() => setShowItemMenu(m => !m)}
title={t('common.showMore')}
style={{ width: 30, height: 30, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-faint)', padding: 0 }}
aria-label={t('common.showMore')} aria-expanded={showItemMenu}
style={{ width: 26, height: 26, borderRadius: 7, border: 'none', background: showItemMenu ? 'var(--bg-tertiary)' : 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-faint)', padding: 0 }}
>
<MoreHorizontal size={16} />
</button>
@@ -367,19 +335,17 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
<>
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 1098 }} onClick={() => { setShowItemMenu(false); setShowMenuCategories(false) }} />
<div className="trek-menu-enter" style={{
...POPOVER,
position: 'fixed',
right: rect ? Math.max(8, window.innerWidth - rect.right) : 8,
top: rect ? rect.bottom + 4 : 0,
top: rect ? rect.bottom + 6 : 0,
zIndex: 1099,
width: 'min(260px, calc(100vw - 16px))',
width: 'min(240px, calc(100vw - 16px))',
maxHeight: '70vh',
overflowY: 'auto',
background: 'var(--bg-card)',
border: '1px solid var(--border-primary)',
borderRadius: 10,
boxShadow: '0 8px 28px rgba(0,0,0,0.18)',
padding: 6,
}}>
{/* Quantity, weight and bag sit in the row itself on a wide screen. */}
<div className="packing-menu-phone-only">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '6px 8px' }}>
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0 }}>{t('packing.quantity')}</span>
<QuantityInput value={item.quantity || 1} onSave={qty => updatePackingItem(tripId, item.id, { quantity: qty })} />
@@ -405,13 +371,13 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
</div>
</div>
<div style={{ padding: '2px 0' }}>
<OverflowMenuItem icon={<Package size={13} />} label={selectedBag?.name || t('packing.noBag')} onClick={async () => {
<PopoverItem icon={<Package size={13} />} label={selectedBag?.name || t('packing.noBag')} onClick={async () => {
if (item.bag_id) {
try { await updatePackingItem(tripId, item.id, { bag_id: null }) } catch { toast.error(t('packing.toast.saveError')) }
}
}} />
{bags.map(b => (
<OverflowMenuItem key={b.id} icon={<span style={{ width: 10, height: 10, borderRadius: '50%', background: b.color, display: 'inline-block' }} />} label={b.name} active={item.bag_id === b.id} onClick={async () => {
<PopoverItem key={b.id} icon={<span style={{ width: 10, height: 10, borderRadius: '50%', background: b.color, display: 'inline-block' }} />} label={b.name} active={item.bag_id === b.id} onClick={async () => {
setShowItemMenu(false)
try { await updatePackingItem(tripId, item.id, { bag_id: b.id }) } catch { toast.error(t('packing.toast.saveError')) }
}} />
@@ -420,34 +386,35 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
</>
)}
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
<OverflowMenuItem icon={<span style={{ width: 9, height: 9, borderRadius: '50%', background: katColor(item.category || t('packing.defaultCategory'), categories), display: 'inline-block' }} />} label={t('packing.changeCategory')} onClick={() => setShowMenuCategories(v => !v)} />
<div style={POPOVER_DIVIDER} />
</div>
<PopoverItem icon={<span style={{ width: 9, height: 9, borderRadius: '50%', background: katColor(item.category || t('packing.defaultCategory'), categories), display: 'inline-block' }} />} label={t('packing.changeCategory')} onClick={() => setShowMenuCategories(v => !v)}
trailing={showMenuCategories ? <ChevronDown size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} /> : <ChevronRight size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />} />
{showMenuCategories && (
<div style={{ padding: '2px 0 4px 18px' }}>
<div style={{ margin: '2px 0 4px 18px', paddingLeft: 6, borderLeft: '1px solid var(--border-faint)' }}>
{categories.map(cat => (
<OverflowMenuItem key={cat} icon={<span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(cat, categories), display: 'inline-block' }} />} label={cat} active={cat === (item.category || t('packing.defaultCategory'))} onClick={() => handleCatChange(cat)} />
<PopoverItem key={cat} icon={<span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(cat, categories), display: 'inline-block' }} />} label={cat} active={cat === (item.category || t('packing.defaultCategory'))} onClick={() => handleCatChange(cat)} />
))}
</div>
)}
{canShare && onClone && onJoin && onLeave && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '6px 8px' }}>
<span style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', color: 'var(--text-secondary)' }}>{t('packing.share')}</span>
<PackingShareControl
item={item}
tripMembers={tripMembers}
currentUserId={currentUserId}
onSetSharing={onSetSharing!}
onClone={onClone}
onJoin={onJoin}
onLeave={onLeave}
/>
</div>
<PackingShareControl
variant="menu"
item={item}
tripMembers={tripMembers}
currentUserId={currentUserId}
onSetSharing={onSetSharing!}
onClone={onClone}
onJoin={onJoin}
onLeave={onLeave}
onAction={() => setShowItemMenu(false)}
/>
)}
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
<OverflowMenuItem icon={<Pencil size={13} />} label={t('common.rename')} onClick={() => { setEditing(true); setShowItemMenu(false) }} />
<OverflowMenuItem icon={<Trash2 size={13} />} label={t('common.delete')} danger onClick={() => { setShowItemMenu(false); handleDelete() }} />
<div style={POPOVER_DIVIDER} />
<PopoverItem icon={<Pencil size={13} />} label={t('common.rename')} onClick={() => { setEditing(true); setShowItemMenu(false) }} />
<PopoverItem icon={<Trash2 size={13} />} label={t('common.delete')} danger onClick={() => { setShowItemMenu(false); handleDelete() }} />
</div>
</>
)
@@ -457,31 +424,3 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
</div>
)
}
interface OverflowMenuItemProps {
icon: ReactNode
label: string
onClick: () => void
active?: boolean
danger?: boolean
}
function OverflowMenuItem({ icon, label, onClick, active = false, danger = false }: OverflowMenuItemProps) {
return (
<button type="button"
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '7px 9px', borderRadius: 7, border: 'none', cursor: 'pointer',
background: active ? 'var(--bg-tertiary)' : 'none',
color: danger ? '#ef4444' : 'var(--text-secondary)',
fontFamily: 'inherit', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', textAlign: 'left',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.background = danger ? '#fef2f2' : 'var(--bg-tertiary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'none' }}
>
<span style={{ width: 14, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{icon}</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</button>
)
}
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, type CSSProperties } from 'react'
import { NumericInput } from '../shared/NumericInput'
export function QuantityInput({ value, onSave }: { value: number; onSave: (qty: number) => void }) {
/** `style` lays over the frame, for a row that wants the field quieter at rest. */
export function QuantityInput({ value, onSave, style }: { value: number; onSave: (qty: number) => void; style?: CSSProperties }) {
const [local, setLocal] = useState(String(value))
useEffect(() => setLocal(String(value)), [value])
@@ -12,7 +13,7 @@ export function QuantityInput({ value, onSave }: { value: number; onSave: (qty:
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent', flexShrink: 0, ...style }}>
<NumericInput
value={local}
onValueChange={setLocal}
@@ -3,61 +3,57 @@ import type { PackingState } from './usePackingListPanel'
/**
* One tab row: the three-tier view switch (Gemeinsam / Meine Liste, #858) on the
* left, and the all/open/done filter on the right, separated by a vertical rule
* and sharing the same height. Left-aligned with the list content.
* left, and the all/open/done filter on the right. Each sits in its own tinted
* track, the way the phone shows them, so the row reads as two questions
* (whose list, which state) rather than five pills of equal weight.
*/
export function PackingViewTabs(S: PackingState) {
const { view, setView, filter, setFilter, t, items } = S
const commonCount = items.filter(i => !i.is_private).length
const personalCount = items.filter(i => !!i.is_private).length
const pillBase: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', gap: 6, height: 30, padding: '0 12px', borderRadius: 999,
border: '1px solid', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, transition: 'all 0.12s',
const track: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', gap: 2, padding: 3, borderRadius: 999, background: 'var(--bg-tertiary)',
}
const segment = (active: boolean): React.CSSProperties => ({
display: 'inline-flex', alignItems: 'center', gap: 6, height: 28, padding: '0 13px', borderRadius: 999,
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, transition: 'all 0.12s',
background: active ? 'var(--text-primary)' : 'transparent',
color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.12)' : 'none',
})
const viewPill = (id: 'common' | 'personal', icon: React.ReactNode, label: string, count: number) => {
const active = view === id
return (
<button type="button" onClick={() => setView(id)} style={{
...pillBase,
background: active ? 'var(--text-primary)' : 'transparent',
borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
color: active ? 'var(--bg-primary)' : 'var(--text-secondary)',
}}>
<button type="button" onClick={() => setView(id)} style={segment(active)}>
{icon}{label}
<span style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, borderRadius: 99, padding: '0 6px',
background: active ? 'var(--bg-primary)' : 'var(--bg-tertiary)',
background: active ? 'var(--bg-primary)' : 'var(--bg-card)',
color: active ? 'var(--text-primary)' : 'var(--text-faint)',
}}>{count}</span>
</button>
)
}
const filterPill = (id: string, label: string) => {
const active = filter === id
return (
<button type="button" key={id} onClick={() => setFilter(id)} style={{
...pillBase, gap: 0, border: '1px solid transparent', fontWeight: active ? 600 : 400,
background: active ? 'var(--text-primary)' : 'transparent',
color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>{label}</button>
)
}
const filterPill = (id: string, label: string) => (
<button type="button" key={id} onClick={() => setFilter(id)} style={segment(filter === id)}>{label}</button>
)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '10px 0 0', flexShrink: 0, flexWrap: 'wrap' }}>
{viewPill('common', <Users size={14} />, t('packing.viewCommon'), commonCount)}
{viewPill('personal', <UserRound size={14} />, t('packing.viewPersonal'), personalCount)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 0 0', flexShrink: 0, flexWrap: 'wrap' }}>
<div style={track}>
{viewPill('common', <Users size={14} />, t('packing.viewCommon'), commonCount)}
{viewPill('personal', <UserRound size={14} />, t('packing.viewPersonal'), personalCount)}
</div>
{items.length > 0 && (
<>
<span style={{ alignSelf: 'stretch', width: 1, background: 'var(--border-primary)', margin: '3px 4px' }} />
<div style={track}>
{filterPill('alle', t('packing.filterAll'))}
{filterPill('offen', t('packing.filterOpen'))}
{filterPill('erledigt', t('packing.filterDone'))}
</>
</div>
)}
</div>
)
@@ -0,0 +1,47 @@
// FE-PACKNAME-001 to FE-PACKNAME-004
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import PackingNameDialog from './PackingNameDialog'
function setup(value: string, open = true) {
const onChange = vi.fn()
const onConfirm = vi.fn()
const onClose = vi.fn()
render(
<PackingNameDialog open={open} title="Add list" placeholder="List name" confirmLabel="Add"
value={value} onChange={onChange} onConfirm={onConfirm} onClose={onClose} />,
)
return { onChange, onConfirm, onClose }
}
describe('PackingNameDialog', () => {
it('FE-PACKNAME-001: asks for the name under its title, confirm waiting for one', () => {
const { onChange } = setup('')
expect(screen.getByText('Add list')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled()
fireEvent.change(screen.getByPlaceholderText('List name'), { target: { value: 'Rain kit' } })
expect(onChange).toHaveBeenCalledWith('Rain kit')
})
it('FE-PACKNAME-002: Enter and the button confirm a typed name', () => {
const { onConfirm } = setup('Rain kit')
fireEvent.keyDown(screen.getByPlaceholderText('List name'), { key: 'Enter' })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onConfirm).toHaveBeenCalledTimes(2)
})
it('FE-PACKNAME-003: Enter on a blank name does nothing, Cancel closes', () => {
const { onConfirm, onClose } = setup(' ')
fireEvent.keyDown(screen.getByPlaceholderText('List name'), { key: 'Enter' })
expect(onConfirm).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-PACKNAME-004: stays out of the page while closed', () => {
setup('', false)
expect(screen.queryByPlaceholderText('List name')).toBeNull()
})
})
@@ -0,0 +1,53 @@
import Modal from '../shared/Modal'
import { useTranslation } from '../../i18n'
interface PackingNameDialogProps {
open: boolean
title: string
placeholder: string
confirmLabel: string
value: string
onChange: (value: string) => void
onConfirm: () => void
onClose: () => void
}
/**
* The small dialog that asks for one name: a new list, a template to save.
* It takes the name in the middle of the screen instead of pushing a field
* into the page; Enter confirms and Esc (from the modal) cancels.
*/
export default function PackingNameDialog({ open, title, placeholder, confirmLabel, value, onChange, onConfirm, onClose }: PackingNameDialogProps) {
const { t } = useTranslation()
const ready = value.trim().length > 0
return (
<Modal
isOpen={open}
onClose={onClose}
title={title}
size="sm"
footer={(
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="rounded-lg border border-edge px-4 py-2 text-body text-content-muted hover:bg-surface-hover">
{t('common.cancel')}
</button>
<button type="button" onClick={onConfirm} disabled={!ready}
className="rounded-lg bg-accent px-4 py-2 text-body font-medium text-accent-text hover:bg-accent-hover disabled:cursor-default disabled:opacity-40">
{confirmLabel}
</button>
</div>
)}
>
<input
autoFocus
type="text"
value={value}
onChange={e => onChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && ready) onConfirm() }}
placeholder={placeholder}
aria-label={title}
className="w-full rounded-lg border border-edge bg-surface-input px-3 py-2.5 text-body text-content outline-none focus:border-content-muted"
/>
</Modal>
)
}
@@ -0,0 +1,97 @@
// FE-PACKPOP-001 to FE-PACKPOP-008
import { describe, it, expect, vi } from 'vitest'
import { useRef } from 'react'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { PopoverItem } from './PackingPopover'
import { composerConfirm, useDismissOnOutside } from './packingPopoverStyles'
function Harness({ open, onClose, laidOut = true }: { open: boolean; onClose: () => void; laidOut?: boolean }) {
const ref = useRef<HTMLDivElement>(null)
useDismissOnOutside(ref, open, onClose)
return (
<div>
<div ref={el => {
ref.current = el
// jsdom lays nothing out; a displayed element has at least one box.
if (el) el.getClientRects = () => ({ length: laidOut ? 1 : 0 }) as DOMRectList
}} data-testid="inside"><button type="button">inside</button></div>
<button type="button">outside</button>
</div>
)
}
describe('PopoverItem', () => {
it('FE-PACKPOP-001: the current choice carries a tick and keeps its tint on hover', () => {
const onClick = vi.fn()
const { container } = render(<PopoverItem icon={<span />} label="Trolley" active onClick={onClick} />)
const btn = screen.getByRole('button', { name: 'Trolley' })
expect(container.querySelector('svg.lucide-check')).not.toBeNull()
fireEvent.mouseEnter(btn)
fireEvent.mouseLeave(btn)
expect(btn.style.background).toBe('var(--bg-tertiary)')
fireEvent.click(btn)
expect(onClick).toHaveBeenCalledTimes(1)
})
it('FE-PACKPOP-002: a trailing element replaces the tick', () => {
const { container } = render(<PopoverItem icon={<span />} label="Move" active trailing={<i data-testid="arrow" />} onClick={() => {}} />)
expect(screen.getByTestId('arrow')).toBeInTheDocument()
expect(container.querySelector('svg.lucide-check')).toBeNull()
})
it('FE-PACKPOP-003: a muted entry reads faint and a danger entry red, with its own hover tint', () => {
render(<>
<PopoverItem icon={<span />} label="Add bag" muted onClick={() => {}} />
<PopoverItem icon={<span />} label="Delete" danger onClick={() => {}} />
</>)
const muted = screen.getByRole('button', { name: 'Add bag' })
const danger = screen.getByRole('button', { name: 'Delete' })
expect(muted.style.color).toBe('var(--text-faint)')
expect(danger.style.color).toBe('rgb(239, 68, 68)')
fireEvent.mouseEnter(danger)
expect(danger.style.background).toBe('rgba(239, 68, 68, 0.1)')
fireEvent.mouseLeave(danger)
expect(danger.style.background).toBe('none')
})
it('FE-PACKPOP-004: the composer button fades while there is nothing to add', () => {
expect(composerConfirm(false)).toMatchObject({ opacity: 0.35, cursor: 'default' })
expect(composerConfirm(true)).toMatchObject({ opacity: 1, cursor: 'pointer' })
})
})
describe('useDismissOnOutside', () => {
it('FE-PACKPOP-005: a press outside closes', () => {
const onClose = vi.fn()
render(<Harness open onClose={onClose} />)
fireEvent.mouseDown(screen.getByRole('button', { name: 'outside' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-PACKPOP-006: a press inside leaves it open', () => {
const onClose = vi.fn()
render(<Harness open onClose={onClose} />)
fireEvent.mouseDown(screen.getByRole('button', { name: 'inside' }))
expect(onClose).not.toHaveBeenCalled()
})
it('FE-PACKPOP-007: a copy that is mounted but not displayed never closes anything', () => {
const onClose = vi.fn()
render(<Harness open onClose={onClose} laidOut={false} />)
fireEvent.mouseDown(screen.getByRole('button', { name: 'outside' }))
expect(onClose).not.toHaveBeenCalled()
})
it('FE-PACKPOP-008: nothing listens while closed', () => {
const onClose = vi.fn()
render(<Harness open={false} onClose={onClose} />)
fireEvent.mouseDown(screen.getByRole('button', { name: 'outside' }))
expect(onClose).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,37 @@
import type { ReactNode } from 'react'
import { Check } from 'lucide-react'
/** An entry in a packing popover: icon column, label, a tick on the current choice (the shared look is in packingPopoverStyles.ts). */
interface PopoverItemProps {
icon: ReactNode
label: string
onClick: () => void
active?: boolean
danger?: boolean
/** A secondary line, such as Add bag, in the faint text colour. */
muted?: boolean
/** What sits at the right end; the current choice gets a tick by default. */
trailing?: ReactNode
}
export function PopoverItem({ icon, label, onClick, active = false, danger = false, muted = false, trailing }: PopoverItemProps) {
const tone = danger ? '#ef4444' : muted ? 'var(--text-faint)' : 'var(--text-primary)'
return (
<button type="button"
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 9, width: '100%',
padding: '8px 10px', borderRadius: 9, border: 'none', cursor: 'pointer',
background: active ? 'var(--bg-tertiary)' : 'none',
color: tone,
fontFamily: 'inherit', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, textAlign: 'left',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.background = danger ? 'rgba(239,68,68,0.1)' : 'var(--bg-tertiary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'none' }}
>
<span style={{ width: 16, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, color: danger ? '#ef4444' : 'var(--text-muted)' }}>{icon}</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
{trailing ?? (active ? <Check size={13} style={{ color: 'var(--text-muted)', flexShrink: 0 }} /> : null)}
</button>
)
}
@@ -282,3 +282,37 @@ describe('PackingShareControl — optional fields', () => {
expect(onJoin).toHaveBeenCalledWith(8)
})
})
describe('PackingShareControl — menu variant', () => {
function setupMenu(it_: PackingItem, currentUserId = 1) {
const handlers = { onSetSharing: vi.fn(), onClone: vi.fn(), onJoin: vi.fn(), onLeave: vi.fn(), onAction: vi.fn() }
render(<PackingShareControl variant="menu" item={it_} tripMembers={MEMBERS} currentUserId={currentUserId} {...handlers} />)
return handlers
}
it('FE-W6PSC-001: the owner gets one whole line naming the current tier', () => {
setupMenu(item())
const line = screen.getByText('Sharing').closest('button')!
expect(line).toHaveTextContent('Shared')
fireEvent.click(line)
expect(screen.getByText(/^Personal$/)).toBeInTheDocument()
})
it('FE-W6PSC-002: someone else pledges or copies from full lines, and the menu closes after', () => {
const h = setupMenu(item({ owner_id: 2 }))
fireEvent.click(screen.getByRole('button', { name: 'I can bring that too' }))
fireEvent.click(screen.getByRole('button', { name: 'Copy to my list' }))
expect(h.onJoin).toHaveBeenCalledWith(5)
expect(h.onClone).toHaveBeenCalledWith(5)
expect(h.onAction).toHaveBeenCalledTimes(2)
})
it('FE-W6PSC-003: a co-bringer takes the pledge back from the same line', () => {
const h = setupMenu(item({ owner_id: 2, contributors: [{ user_id: 1, username: 'ada' }] } as Partial<PackingItem>))
fireEvent.click(screen.getByRole('button', { name: "I'm not bringing it" }))
expect(h.onLeave).toHaveBeenCalledWith(5, 1)
})
})
@@ -13,6 +13,13 @@ interface Props {
onClone: (id: number) => void
onJoin: (id: number) => void
onLeave: (id: number, userId: number) => void
/**
* 'menu' draws the control as full-width rows for an overflow menu, where the
* whole line is the target instead of a lone icon. `onAction` then closes that
* menu after a pledge or a copy.
*/
variant?: 'icon' | 'menu'
onAction?: () => void
}
/**
@@ -21,7 +28,7 @@ interface Props {
* a dropdown; everyone else can pledge to co-bring a Common item ("I can bring
* that too") or clone it onto their own list.
*/
export default function PackingShareControl({ item, tripMembers, currentUserId, onSetSharing, onClone, onJoin, onLeave }: Props) {
export default function PackingShareControl({ item, tripMembers, currentUserId, onSetSharing, onClone, onJoin, onLeave, variant = 'icon', onAction }: Props) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [pos, setPos] = useState<{ top: number; right: number } | null>(null)
@@ -57,12 +64,20 @@ export default function PackingShareControl({ item, tripMembers, currentUserId,
</button>
)
const pledge = () => (iAmContributor ? onLeave(item.id, currentUserId!) : onJoin(item.id))
const pledgeLabel = iAmContributor ? t('packing.alsoBringingStop') : t('packing.alsoBring')
// Non-owner on a Common item: pledge to co-bring + clone to personal list.
if (!isOwner && isCommon) {
if (variant === 'menu') return (
<>
<MenuRow icon={<HandHelping size={13} />} label={pledgeLabel} active={iAmContributor} onClick={() => { pledge(); onAction?.() }} />
<MenuRow icon={<Copy size={13} />} label={t('packing.cloneToMine')} onClick={() => { onClone(item.id); onAction?.() }} />
</>
)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{btn(() => (iAmContributor ? onLeave(item.id, currentUserId!) : onJoin(item.id)),
iAmContributor ? t('packing.alsoBringingStop') : t('packing.alsoBring'), iAmContributor, <HandHelping size={14} />)}
{btn(pledge, pledgeLabel, iAmContributor, <HandHelping size={14} />)}
{btn(() => onClone(item.id), t('packing.cloneToMine'), false, <Copy size={13} />)}
</div>
)
@@ -70,14 +85,20 @@ export default function PackingShareControl({ item, tripMembers, currentUserId,
// A recipient of a shared item has no controls (it's the owner's responsibility).
if (!isOwner) return null
const tierLabel = visibility === 'common' ? t('packing.viewCommon') : visibility === 'personal' ? t('packing.tierPersonal') : t('packing.tierShared')
return (
<div style={{ display: 'flex' }}>
{variant === 'menu' ? (
<MenuRow buttonRef={btnRef} icon={<Share2 size={13} />} label={t('packing.share')} hint={tierLabel} active={open} onClick={toggle} />
) : (
<button type="button" ref={btnRef} onClick={toggle} title={t('packing.share')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: visibility !== 'common' ? 'var(--accent)' : 'var(--text-faint)' }}
onMouseEnter={e => { if (visibility === 'common') e.currentTarget.style.color = 'var(--text-secondary)' }}
onMouseLeave={e => { if (visibility === 'common') e.currentTarget.style.color = 'var(--text-faint)' }}>
<Share2 size={14} />
</button>
)}
{open && pos && createPortal(
<>
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 1099 }} onClick={() => setOpen(false)} />
@@ -130,3 +151,20 @@ function Row({ icon, label, sub, active, onClick }: { icon: React.ReactNode; lab
</button>
)
}
/** A full-width line in an overflow menu, shaped like the item menu's own entries. */
function MenuRow({ icon, label, hint, active = false, onClick, buttonRef }: {
icon: React.ReactNode; label: string; hint?: string; active?: boolean; onClick: () => void
buttonRef?: React.Ref<HTMLButtonElement>
}) {
return (
<button type="button" ref={buttonRef} onClick={onClick}
style={{ display: 'flex', alignItems: 'center', gap: 9, width: '100%', padding: '8px 10px', borderRadius: 9, border: 'none', cursor: 'pointer', background: active ? 'var(--bg-tertiary)' : 'none', color: 'var(--text-primary)', fontFamily: 'inherit', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, textAlign: 'left' }}
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'none' }}>
<span style={{ width: 16, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, color: 'var(--text-muted)' }}>{icon}</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
{hint && <span style={{ flexShrink: 0, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{hint}</span>}
</button>
)
}
@@ -0,0 +1,48 @@
import { useEffect, type CSSProperties, type RefObject } from 'react'
/**
* The one look every packing popover shares (the item menu, the bag picker, a
* list's menu): a soft card, entries with an icon column, a tick on the
* current choice.
*/
export const POPOVER: CSSProperties = {
background: 'var(--bg-card)', border: '1px solid var(--border-secondary)', borderRadius: 14,
boxShadow: '0 12px 32px -8px rgba(0,0,0,0.25), 0 2px 6px rgba(0,0,0,0.06)', padding: 6,
}
export const POPOVER_DIVIDER: CSSProperties = { height: 1, background: 'var(--border-faint)', margin: '5px 6px' }
export const POPOVER_CAPTION: CSSProperties = {
padding: '5px 10px 4px', fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700,
color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em',
}
/** An inline field that adds something (an item, a bag): framed, with a soft accent ring. */
export const COMPOSER: CSSProperties = {
display: 'flex', alignItems: 'center', gap: 8, borderRadius: 10,
border: '1px solid var(--border-primary)', background: 'var(--bg-card)',
boxShadow: '0 0 0 3px color-mix(in srgb, var(--accent) 10%, transparent)',
}
/** The square accent button that confirms a composer; faded while there is nothing to add. */
export const composerConfirm = (enabled: boolean): CSSProperties => ({
width: 28, height: 28, borderRadius: 8, border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
cursor: enabled ? 'pointer' : 'default', opacity: enabled ? 1 : 0.35, display: 'grid', placeItems: 'center',
flexShrink: 0, transition: 'opacity 120ms ease',
})
/**
* Closes a popover or composer on a press anywhere outside `ref`. A copy that
* 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) {
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
onClose()
}
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [ref, open, onClose])
}
@@ -784,17 +784,14 @@ describe('usePackingList — templates, import and signals', () => {
expect(result.current.showSaveTemplate).toBe(true)
})
it('FE-W5HOOK-054: a raised clear-checked signal runs the bulk delete', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
const items = [buildPackingItem({ id: 81, checked: 1 })]
let deleted = false
server.use(http.delete('/api/trips/1/packing/81', () => { deleted = true; return HttpResponse.json({ success: true }) }))
const { rerender } = renderPanel({ items, clearCheckedSignal: 0 })
it('FE-W5HOOK-054: a raised add-list signal opens the list name field', async () => {
const { result, rerender } = renderPanel({ items: [], addCategorySignal: 0 })
await settled()
expect(result.current.addingCategory).toBe(false)
await act(async () => { rerender({ tripId: 1, items, clearCheckedSignal: 1 }) })
rerender({ tripId: 1, items: [], addCategorySignal: 1 })
await waitFor(() => expect(deleted).toBe(true))
expect(result.current.addingCategory).toBe(true)
})
it('FE-W5HOOK-055: a mousedown outside the template dropdown closes it', async () => {
@@ -32,7 +32,8 @@ export interface PackingListPanelProps {
tripId: number
items: PackingItem[]
openImportSignal?: number
clearCheckedSignal?: number
// Raised by the Lists bar's Add list button, which opens the name field here.
addCategorySignal?: number
saveTemplateSignal?: number
inlineHeader?: boolean
// Lifted so an out-of-panel Apply Template button knows the active view (#1565).
@@ -47,7 +48,7 @@ export interface PackingListPanelProps {
* sections below render header, filters, the grouped list, the bag sidebar/
* modal and the import dialog.
*/
export function usePackingList({ tripId, items, openImportSignal = 0, clearCheckedSignal = 0, saveTemplateSignal = 0, inlineHeader = true, view: viewProp, onViewChange }: PackingListPanelProps) {
export function usePackingList({ tripId, items, openImportSignal = 0, addCategorySignal = 0, saveTemplateSignal = 0, inlineHeader = true, view: viewProp, onViewChange }: PackingListPanelProps) {
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
// Three-tier sharing (#858): 'common' = the group pool (where existing items
// live — non-breaking), 'personal' = my own list (private + shared-to-me).
@@ -298,7 +299,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const [showImportModal, setShowImportModal] = useState(false)
const [importText, setImportText] = useState('')
const lastHandledImportSignal = useRef(openImportSignal)
const lastHandledClearSignal = useRef(clearCheckedSignal)
const lastHandledAddCategorySignal = useRef(addCategorySignal)
const lastHandledSaveSignal = useRef(saveTemplateSignal)
useEffect(() => {
@@ -309,12 +310,11 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
}, [openImportSignal])
useEffect(() => {
if (clearCheckedSignal !== lastHandledClearSignal.current && clearCheckedSignal > 0) {
handleClearChecked()
if (addCategorySignal !== lastHandledAddCategorySignal.current && addCategorySignal > 0) {
setAddingCategory(true)
}
lastHandledClearSignal.current = clearCheckedSignal
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clearCheckedSignal])
lastHandledAddCategorySignal.current = addCategorySignal
}, [addCategorySignal])
useEffect(() => {
if (saveTemplateSignal !== lastHandledSaveSignal.current && saveTemplateSignal > 0) {