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' }));
});
});