diff --git a/client/e2e/help/trip-lists.guide.ts b/client/e2e/help/trip-lists.guide.ts index 92f3e80b6..f9fd641e7 100644 --- a/client/e2e/help/trip-lists.guide.ts +++ b/client/e2e/help/trip-lists.guide.ts @@ -686,7 +686,8 @@ const SCRIPTS: Record = { target: p => todoPane(p).getByRole('button', { name: 'Save changes' }), act: async p => { await todoPane(p).getByRole('button', { name: 'Save changes' }).click() - await expect(task(p, EDITED_TASK)).toContainText(ASSIGNEE, { timeout: 20_000 }) + // The row names its assignee as an avatar, labelled with the name. + await expect(task(p, EDITED_TASK).getByLabel(ASSIGNEE)).toBeVisible({ timeout: 20_000 }) await settle(p) }, }, diff --git a/client/public/help-media/create-todo/result.webp b/client/public/help-media/create-todo/result.webp index f266e74f9..1643234ef 100644 Binary files a/client/public/help-media/create-todo/result.webp and b/client/public/help-media/create-todo/result.webp differ diff --git a/client/public/help-media/create-todo/step-1.webp b/client/public/help-media/create-todo/step-1.webp index fa59ca8da..fd3318f20 100644 Binary files a/client/public/help-media/create-todo/step-1.webp and b/client/public/help-media/create-todo/step-1.webp differ diff --git a/client/public/help-media/create-todo/step-2.webp b/client/public/help-media/create-todo/step-2.webp index 5f3c81941..6327b37d6 100644 Binary files a/client/public/help-media/create-todo/step-2.webp and b/client/public/help-media/create-todo/step-2.webp differ diff --git a/client/public/help-media/create-todo/step-3.webp b/client/public/help-media/create-todo/step-3.webp index 536e28a12..22e745525 100644 Binary files a/client/public/help-media/create-todo/step-3.webp and b/client/public/help-media/create-todo/step-3.webp differ diff --git a/client/public/help-media/create-todo/step-4.webp b/client/public/help-media/create-todo/step-4.webp index 64604ea7e..32c916e4a 100644 Binary files a/client/public/help-media/create-todo/step-4.webp and b/client/public/help-media/create-todo/step-4.webp differ diff --git a/client/public/help-media/create-todo/step-5.webp b/client/public/help-media/create-todo/step-5.webp index c66f18db5..17c56dab5 100644 Binary files a/client/public/help-media/create-todo/step-5.webp and b/client/public/help-media/create-todo/step-5.webp differ diff --git a/client/public/help-media/create-todo/step-6.webp b/client/public/help-media/create-todo/step-6.webp index fe63d1e3c..7356213b9 100644 Binary files a/client/public/help-media/create-todo/step-6.webp and b/client/public/help-media/create-todo/step-6.webp differ diff --git a/client/public/help-media/todo-filters/step-1.webp b/client/public/help-media/todo-filters/step-1.webp index d02cdb747..dac37a449 100644 Binary files a/client/public/help-media/todo-filters/step-1.webp and b/client/public/help-media/todo-filters/step-1.webp differ diff --git a/client/public/help-media/todo-filters/step-2.webp b/client/public/help-media/todo-filters/step-2.webp index 8cbc75b14..5983d4837 100644 Binary files a/client/public/help-media/todo-filters/step-2.webp and b/client/public/help-media/todo-filters/step-2.webp differ diff --git a/client/public/help-media/todo-filters/step-3.webp b/client/public/help-media/todo-filters/step-3.webp index dc3782813..302b96371 100644 Binary files a/client/public/help-media/todo-filters/step-3.webp and b/client/public/help-media/todo-filters/step-3.webp differ diff --git a/client/public/help-media/todo-filters/step-4.webp b/client/public/help-media/todo-filters/step-4.webp index dd58907d5..766ae7e64 100644 Binary files a/client/public/help-media/todo-filters/step-4.webp and b/client/public/help-media/todo-filters/step-4.webp differ diff --git a/client/public/help-media/todo-filters/step-5.webp b/client/public/help-media/todo-filters/step-5.webp index 18a466150..0174eb848 100644 Binary files a/client/public/help-media/todo-filters/step-5.webp and b/client/public/help-media/todo-filters/step-5.webp differ diff --git a/client/src/components/Packing/PackingListPanelHeader.tsx b/client/src/components/Packing/PackingListPanelHeader.tsx index 9bd9547e4..cfd9a6299 100644 --- a/client/src/components/Packing/PackingListPanelHeader.tsx +++ b/client/src/components/Packing/PackingListPanelHeader.tsx @@ -2,7 +2,7 @@ import { CheckCheck, Luggage, Package, FolderPlus, Download, Trash2, } from 'lucide-react' import type { PackingState } from './usePackingListPanel' -import PackingNameDialog from './PackingNameDialog' +import NameDialog from '../shared/NameDialog' export function PackingHeader(S: PackingState) { const { @@ -199,7 +199,7 @@ export function PackingHeader(S: PackingState) { {/* Naming a new list or a template happens in a dialog of its own, not in a field pushed into the page. */} - { setAddingCategory(false); setNewCatName('') }} /> - 0 && showSaveTemplate} title={t('packing.saveAsTemplate')} placeholder={t('packing.templateName')} diff --git a/client/src/components/Todo/TodoListPanel.test.tsx b/client/src/components/Todo/TodoListPanel.test.tsx index 7b21dd020..8c70d7c3e 100644 --- a/client/src/components/Todo/TodoListPanel.test.tsx +++ b/client/src/components/Todo/TodoListPanel.test.tsx @@ -502,7 +502,7 @@ describe('TodoListPanel — sidebar', () => { const done = screen.getByRole('button', { name: /^Done/ }); fireEvent.mouseEnter(done); - expect(done.style.background).toBe('var(--bg-hover)'); + expect(done.style.background).toBe('var(--bg-secondary)'); fireEvent.mouseLeave(done); expect(done.style.background).toBe('transparent'); }); @@ -513,23 +513,27 @@ describe('TodoListPanel — sidebar', () => { fireEvent.mouseLeave(all); // 'all' is the active filter, so the hover handlers must leave it alone. - expect(all.style.background).toBe('var(--bg-hover)'); + expect(all.style.background).toBe('var(--bg-tertiary)'); }); - it('FE-COMP-TODO-032: hovering the priority sort toggles its background only while off', async () => { + it('FE-COMP-TODO-032: the sort track in the list head switches between priority, due date and the manual order', async () => { const user = userEvent.setup(); render(); - const sort = screen.getByRole('button', { name: 'Priority' }); + const prio = screen.getByRole('button', { name: 'Priority' }); + const due = screen.getByRole('button', { name: 'Due date' }); - fireEvent.mouseEnter(sort); - expect(sort.style.background).toBe('var(--bg-hover)'); - fireEvent.mouseLeave(sort); - expect(sort.style.background).toBe('transparent'); + await user.click(prio); + expect(prio).toHaveAttribute('aria-pressed', 'true'); + expect(prio.style.background).toBe('var(--text-primary)'); - await user.click(sort); - fireEvent.mouseEnter(sort); - // Active sort keeps its amber tint instead of the neutral hover. - expect(sort.style.background).toBe('rgba(245, 158, 11, 0.07)'); + await user.click(due); + expect(prio).toHaveAttribute('aria-pressed', 'false'); + expect(due).toHaveAttribute('aria-pressed', 'true'); + + // A second click on the active one goes back to the manual order. + await user.click(due); + expect(due).toHaveAttribute('aria-pressed', 'false'); + expect(due.style.background).toBe('transparent'); }); it('FE-COMP-TODO-033: Escape abandons the new-list input', async () => { @@ -554,7 +558,7 @@ describe('TodoListPanel — sidebar', () => { await user.click(screen.getByRole('button', { name: 'Add list' })); const input = await screen.findByPlaceholderText('List name'); await user.type(input, 'Errands'); - fireEvent.click(input.nextElementSibling as HTMLElement); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); expect(posted).toBe(false); expect(screen.queryByPlaceholderText('List name')).not.toBeInTheDocument(); @@ -687,11 +691,11 @@ describe('TodoListPanel — detail pane', () => { ); await openDetail(user); - await user.click(screen.getByRole('button', { name: 'List name' })); + await user.click(within(screen.getByRole('region', { name: 'Task' })).getByRole('button', { name: 'Add list' })); await user.type(await screen.findByPlaceholderText('List name'), 'Logistics'); await user.keyboard('{Enter}'); - // Enter closes the inline editor and the typed list is offered as a new option. + // Enter confirms the name dialog and the typed list is offered as a new option. expect(screen.queryByPlaceholderText('List name')).not.toBeInTheDocument(); expect(screen.getByText(/^Logistics/)).toBeInTheDocument(); @@ -705,7 +709,7 @@ describe('TodoListPanel — detail pane', () => { withMembers(); await openDetail(user); - await user.click(screen.getByRole('button', { name: 'List name' })); + await user.click(within(screen.getByRole('region', { name: 'Task' })).getByRole('button', { name: 'Add list' })); await user.type(await screen.findByPlaceholderText('List name'), 'Logistics'); await user.keyboard('{Escape}'); @@ -725,7 +729,7 @@ describe('TodoListPanel — detail pane', () => { await user.click(screen.getByText('Plan route')); await screen.findByText('Task'); - const pane = screen.getByText('Task').closest('div[style*="border-left"]') as HTMLElement; + const pane = screen.getByRole('region', { name: 'Task' }); await user.click(within(pane).getByRole('button', { name: 'No list' })); pickOption('Errands'); @@ -864,7 +868,7 @@ describe('TodoListPanel — new task pane', () => { await user.type(screen.getByPlaceholderText('Task name'), 'Pack bags'); await user.type(screen.getByPlaceholderText('Description (optional)'), 'rain gear'); - await user.click(screen.getByRole('button', { name: 'List name' })); + await user.click(within(screen.getByRole('region', { name: 'New task' })).getByRole('button', { name: 'Add list' })); await user.type(await screen.findByPlaceholderText('List name'), 'Prep '); await user.keyboard('{Enter}'); await user.click(screen.getByRole('button', { name: 'P1' })); @@ -886,7 +890,7 @@ describe('TodoListPanel — new task pane', () => { const user = userEvent.setup(); await openNew(); - await user.click(screen.getByRole('button', { name: 'List name' })); + await user.click(within(screen.getByRole('region', { name: 'New task' })).getByRole('button', { name: 'Add list' })); await user.type(await screen.findByPlaceholderText('List name'), 'Prep'); await user.keyboard('{Escape}'); @@ -999,7 +1003,7 @@ describe('TodoListPanel — mobile layout', () => { await user.click(header); expect(screen.getByText('Save changes')).toBeInTheDocument(); - await user.click(header.parentElement!.querySelectorAll('button')[0]); + await user.click(within(screen.getByRole('region', { name: 'Task' })).getByRole('button', { name: 'Close' })); await waitFor(() => expect(screen.queryByText('Save changes')).not.toBeInTheDocument()); }); @@ -1083,12 +1087,12 @@ describe('TodoListPanel — remaining paths', () => { const all = screen.getByRole('button', { name: /^All/ }); fireEvent.mouseEnter(all); - expect(all.style.background).toBe('var(--bg-hover)'); + expect(all.style.background).toBe('var(--bg-tertiary)'); const sort = screen.getByRole('button', { name: 'Priority' }); await user.click(sort); fireEvent.mouseLeave(sort); - expect(sort.style.background).toBe('rgba(245, 158, 11, 0.07)'); + expect(sort.style.background).toBe('var(--text-primary)'); }); it('FE-COMP-TODO-065: clearing the task name blocks the save', async () => { @@ -1104,24 +1108,24 @@ describe('TodoListPanel — remaining paths', () => { expect(put).toBe(false); }); - it('FE-COMP-TODO-066: the check button closes the inline list editor in both panes', async () => { + it('FE-COMP-TODO-066: the name dialog adds a new list in both panes', async () => { const user = userEvent.setup(); const { rerender } = render(); await user.click(screen.getByText('Plan route')); - await user.click(await screen.findByRole('button', { name: 'List name' })); + await user.click(within(await screen.findByRole('region', { name: 'Task' })).getByRole('button', { name: 'Add list' })); let input = await screen.findByPlaceholderText('List name'); await user.type(input, 'Logistics'); - fireEvent.click(input.nextElementSibling as HTMLElement); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); expect(screen.queryByPlaceholderText('List name')).not.toBeInTheDocument(); expect(screen.getByText(/^Logistics/)).toBeInTheDocument(); rerender(); await screen.findByText('Create task'); - await user.click(screen.getByRole('button', { name: 'List name' })); + await user.click(within(screen.getByRole('region', { name: 'New task' })).getByRole('button', { name: 'Add list' })); input = await screen.findByPlaceholderText('List name'); await user.type(input, 'Prep'); - fireEvent.click(input.nextElementSibling as HTMLElement); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); expect(screen.queryByPlaceholderText('List name')).not.toBeInTheDocument(); expect(screen.getByText(/^Prep/)).toBeInTheDocument(); }); @@ -1289,3 +1293,23 @@ describe('TodoListPanel — plugin contributions', () => { expect(screen.getByText('Rainy')).toBeInTheDocument(); }); }); + +describe('TodoListPanel — detail pane head', () => { + it('FE-COMP-TODO-090: the box in the pane head ticks the task off', async () => { + const user = userEvent.setup(); + let body: Record | null = null; + server.use(http.put('/api/trips/1/todo/60', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({ item: buildTodoItem({ id: 60, name: 'Buy SIM', checked: 1 }) }); + })); + render(); + + await user.click(screen.getByText('Buy SIM')); + const box = within(await screen.findByRole('region', { name: 'Task' })).getByRole('button', { name: 'Done' }); + expect(box).toHaveAttribute('aria-pressed', 'false'); + await user.click(box); + + await waitFor(() => expect(body).toMatchObject({ checked: true })); + }); +}); + diff --git a/client/src/components/Todo/TodoListPanel.tsx b/client/src/components/Todo/TodoListPanel.tsx index 4eb57bd71..fe361b862 100644 --- a/client/src/components/Todo/TodoListPanel.tsx +++ b/client/src/components/Todo/TodoListPanel.tsx @@ -1,33 +1,35 @@ -import { Fragment, useState, useMemo, useEffect, useRef } from 'react' -import { avatarSrc } from '../../utils/avatarSrc' +import { Fragment, useState, useEffect, type CSSProperties } from 'react' import { createPortal } from 'react-dom' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' -import { tripsApi } from '../../api/client' -import apiClient from '../../api/client' -import CustomSelect from '../shared/CustomSelect' -import { CustomDatePicker } from '../shared/CustomDateTimePicker' -import { formatDate as fmtDate } from '../../utils/formatters' -import { - CheckSquare, Square, Plus, ChevronRight, Flag, - X, Check, Calendar, User, FolderPlus, AlertCircle, ListChecks, Inbox, CheckCheck, Trash2, -} from 'lucide-react' +import { Check, Plus, Flag, X, Calendar, User, AlertCircle, Inbox, CheckCheck, Trash2 } from 'lucide-react' import type { TodoItem } from '../../types' -import { KAT_COLORS, PRIO_CONFIG, katColor, type FilterType, type Member } from './todoListModel' +import { katColor, taskInputStyle, type FilterType, type Member, type TaskFieldValues } from './todoListModel' import { useTodoList } from './useTodoList' import TodoRow from './TodoRow' +import TodoTaskFields from './TodoTaskFields' import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions' import EmptyState from '../shared/EmptyState' +import NameDialog from '../shared/NameDialog' + +/** The card the list and the detail pane sit in, the packing list's category card. */ +const CARD: CSSProperties = { + background: 'var(--bg-card)', border: '1px solid var(--border-secondary)', borderRadius: 16, + display: 'flex', flexDirection: 'column', overflow: 'hidden', +} +const HEAD_BAND: CSSProperties = { + display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px 10px 16px', background: 'var(--bg-tertiary)', flexShrink: 0, +} // Sidebar filter row. Declared at module level so React keeps the same component // type across renders — inline it and every re-render remounts the buttons, // which drops clicks that are already in flight. function SidebarItem({ id, icon: Icon, label, count, color, active, compact, onSelect }: { id: string - icon: any + icon: React.ComponentType<{ size?: number; style?: CSSProperties }> | null label: string count: number color?: string @@ -40,23 +42,23 @@ function SidebarItem({ id, icon: Icon, label, count, color, active, compact, onS title={compact ? label : undefined} style={{ display: 'flex', alignItems: 'center', justifyContent: compact ? 'center' : 'flex-start', - gap: compact ? 0 : 8, width: '100%', padding: compact ? '8px 0' : '7px 12px', - border: 'none', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))', - background: active ? 'var(--bg-hover)' : 'transparent', + gap: compact ? 0 : 9, width: '100%', padding: compact ? '8px 0' : '8px 12px', + border: 'none', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))', + background: active ? 'var(--bg-tertiary)' : 'transparent', color: active ? 'var(--text-primary)' : 'var(--text-secondary)', - fontWeight: active ? 600 : 400, transition: 'all 0.1s', + fontWeight: active ? 600 : 500, transition: 'all 0.1s', position: 'relative', }} - onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--bg-hover)' }} + onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--bg-secondary)' }} onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent' }}> {color ? ( - ) : ( + ) : Icon ? ( - )} + ) : null} {!compact && {label}} {!compact && count > 0 && ( - + {count} )} @@ -69,6 +71,14 @@ function SidebarItem({ id, icon: Icon, label, count, color, active, compact, onS ) } +function SidebarCaption({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tripId: number; items: TodoItem[]; addItemSignal?: number }) { // Layout component: state/effects/derived/handlers live in useTodoList. const { @@ -110,6 +120,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr } const selectFilter = (id: string) => setFilter(id as FilterType) + const pct = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0 // Filter title const filterTitle = (() => { @@ -120,125 +131,95 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr return filter })() + // One segment of the sort track in the list's head band; a second click on the + // active one goes back to the manual order. + const sortSegment = (active: boolean): CSSProperties => ({ + display: 'inline-flex', alignItems: 'center', gap: 5, height: 26, padding: isMobile ? '0 9px' : '0 11px', borderRadius: 99, + border: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, + background: active ? 'var(--text-primary)' : 'transparent', color: active ? 'var(--bg-primary)' : 'var(--text-muted)', + transition: 'all 0.12s', + }) + return ( -
+
{/* ── Left Sidebar ── */}
- {/* Progress Card */} - {!isMobile &&
-
- - {totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0}% - + {/* Progress card, the packing list's: done of all, the share, the bar */} + {!isMobile && ( +
+
+ + {doneCount} + /{totalCount} + + {pct}% +
+
+
+
+
+ {doneCount} / {totalCount} {t('todo.completed')} +
-
-
0 ? `${Math.round((doneCount / totalCount) * 100)}%` : '0%', background: '#22c55e', borderRadius: 2, transition: 'width 0.3s' }} /> -
-
- {doneCount} / {totalCount} {t('todo.completed')} -
-
} + )} {/* Smart filters */} - {!isMobile &&
- {t('todo.sidebar.tasks')} -
} + {!isMobile && {t('todo.sidebar.tasks')}} !i.checked).length} active={filter === 'all'} compact={isMobile} onSelect={selectFilter} /> - {/* Sort by */} - {!isMobile &&
- {t('todo.sidebar.sortBy')} -
} - - - - {/* Categories */} - {!isMobile &&
- {t('todo.sidebar.categories')} -
} + {/* Lists */} + {!isMobile && {t('todo.sidebar.categories')}} {isMobile &&
} {categories.map(cat => ( ))} {canEdit && ( - addingCategory && !isMobile ? ( -
- setNewCategoryName(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') addCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCategoryName('') } }} - placeholder={t('todo.newCategory')} - style={{ flex: 1, fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '4px 6px', border: '1px solid var(--border-primary)', borderRadius: 5, background: 'var(--bg-hover)', color: 'var(--text-primary)', fontFamily: 'inherit', minWidth: 0 }} /> - -
- ) : ( - - ) + )}
- {/* ── Middle: Task List ── */} -
- {/* Header */} -
-
-

- {filterTitle} -

- - {filtered.length} - + {/* ── Middle: the task list, as a card ── */} +
+
+

+ {filterTitle} +

+ + {filtered.length} + + + {/* Sort order: priority or due date, neither means the manual order */} +
+ +
{/* Task list */} -
+
{filtered.length === 0 ? : ( - filtered.map(item => { + filtered.map((item, index) => { const contributions = contribFor(item.id) return ( @@ -250,6 +231,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr isSelected={selectedId === item.id} canEdit={canEdit} formatDate={formatDate} + divider={index > 0} onSelect={(id) => { setSelectedId(id); setIsAddingNew(false) }} onToggle={(id, checked) => toggleTodoItem(tripId, id, checked)} drag={canReorder ? { @@ -262,7 +244,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr } : undefined} /> {contributions.length > 0 && ( -
+
)}
) @@ -273,54 +255,27 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr {/* ── Right: Detail Pane ── */} {selectedItem && !isAddingNew && !isMobile && ( - setSelectedId(null)} - /> + setSelectedId(null)} /> )} {selectedItem && !isAddingNew && isMobile && (
{ if (e.target === e.currentTarget) setSelectedId(null) }} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}> -
{ if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}> - setSelectedId(null)} - /> +
+ setSelectedId(null)} />
)} - {isAddingNew && !selectedItem && !isMobile && createPortal( + {isAddingNew && !selectedItem && createPortal(
{ if (e.target === e.currentTarget) setIsAddingNew(false) }} className="trek-modal-backdrop" - style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(15,23,42,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'flex-start', paddingTop: 'calc(var(--nav-h) + 60px)', paddingBottom: 40 }}> -
{ if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px' } } }}> - { setIsAddingNew(false); setSelectedId(id) }} - onClose={() => setIsAddingNew(false)} - /> -
-
, - document.body - )} - {isAddingNew && !selectedItem && isMobile && createPortal( -
{ if (e.target === e.currentTarget) setIsAddingNew(false) }} - className="trek-modal-backdrop" - style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}> -
{ if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}> + style={isMobile + ? { position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' } + : { position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(15,23,42,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'flex-start', paddingTop: 'calc(var(--nav-h) + 60px)', paddingBottom: 40 }}> +
, document.body )} + + { setAddingCategory(false); setNewCategoryName('') }} + />
) } +type PaneVariant = 'side' | 'dialog' | 'sheet' + +/** The pane's frame: a card beside the list, a dialog, or a sheet from the bottom on a phone. */ +function paneFrame(variant: PaneVariant): CSSProperties { + if (variant === 'side') return { ...CARD, width: 340, flexShrink: 0 } + return { ...CARD, width: '100%', border: 'none', borderRadius: variant === 'sheet' ? '16px 16px 0 0' : 16 } +} + +function PaneHead({ title, onClose, lead }: { title: string; onClose: () => void; lead?: React.ReactNode }) { + const { t } = useTranslation() + return ( +
+ {lead} + {title} + +
+ ) +} + +const nameFieldStyle: CSSProperties = { + width: '100%', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 600, border: 'none', padding: '2px 0', + background: 'transparent', color: 'var(--text-primary)', outline: 'none', fontFamily: 'inherit', +} +const FORM_BODY: CSSProperties = { flex: 1, minHeight: 0, overflowY: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: 16 } +const FOOTER: CSSProperties = { padding: '12px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', gap: 8, flexShrink: 0, background: 'var(--bg-card)' } +const primaryButton = (enabled: boolean): CSSProperties => ({ + flex: 1, padding: '9px 16px', borderRadius: 10, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, + cursor: enabled ? 'pointer' : 'default', fontFamily: 'inherit', border: 'none', + background: 'var(--accent)', color: 'var(--accent-text)', opacity: enabled ? 1 : 0.4, transition: 'opacity 0.15s', +}) + // ── Detail Pane (right side) ────────────────────────────────────────────── -function DetailPane({ item, tripId, categories, members, onClose }: { +/** A task's editable fields as the form holds them: empty strings where the task has nothing. */ +function fieldsOf(item: TodoItem): TaskFieldValues { + return { + desc: item.description || '', priority: item.priority || 0, category: item.category || '', + dueDate: item.due_date || '', assignedUserId: item.assigned_user_id, + } +} + +function DetailPane({ item, tripId, categories, members, onClose, variant = 'side' }: { item: TodoItem; tripId: number; categories: string[]; members: Member[]; - onClose: () => void; + onClose: () => void; variant?: PaneVariant; }) { - const { updateTodoItem, deleteTodoItem } = useTripStore() + const { updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore() const trip = useTripStore((s) => s.trip) const can = useCanDo() const canEdit = can('packing_edit', trip) @@ -350,36 +357,30 @@ function DetailPane({ item, tripId, categories, members, onClose }: { const { t } = useTranslation() const [name, setName] = useState(item.name) - const [desc, setDesc] = useState(item.description || '') - const [dueDate, setDueDate] = useState(item.due_date || '') - const [category, setCategory] = useState(item.category || '') - const [addingCategory, setAddingCategoryInline] = useState(false) - const [assignedUserId, setAssignedUserId] = useState(item.assigned_user_id) - const [priority, setPriority] = useState(item.priority || 0) + const [fields, setFields] = useState(() => fieldsOf(item)) const [saving, setSaving] = useState(false) // Sync when selected item changes useEffect(() => { setName(item.name) - setDesc(item.description || '') - setDueDate(item.due_date || '') - setCategory(item.category || '') - setAssignedUserId(item.assigned_user_id) - setPriority(item.priority || 0) + setFields({ + desc: item.description || '', priority: item.priority || 0, category: item.category || '', + dueDate: item.due_date || '', assignedUserId: item.assigned_user_id, + }) }, [item.id, item.name, item.description, item.due_date, item.category, item.assigned_user_id, item.priority]) - const hasChanges = name !== item.name || desc !== (item.description || '') || - dueDate !== (item.due_date || '') || category !== (item.category || '') || - assignedUserId !== item.assigned_user_id || priority !== (item.priority || 0) + const hasChanges = name !== item.name || fields.desc !== (item.description || '') || + fields.dueDate !== (item.due_date || '') || fields.category !== (item.category || '') || + fields.assignedUserId !== item.assigned_user_id || fields.priority !== (item.priority || 0) const save = async () => { if (!name.trim() || !hasChanges) return setSaving(true) try { await updateTodoItem(tripId, item.id, { - name: name.trim(), description: desc || null, - due_date: dueDate || null, category: category || null, - assigned_user_id: assignedUserId, priority, + name: name.trim(), description: fields.desc || null, + due_date: fields.dueDate || null, category: fields.category || null, + assigned_user_id: fields.assignedUserId, priority: fields.priority, }) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) } setSaving(false) @@ -392,172 +393,41 @@ function DetailPane({ item, tripId, categories, members, onClose }: { } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) } } - const labelClass = 'block text-xs font-medium text-content-secondary mb-1' - const inputStyle: React.CSSProperties = { - width: '100%', fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '8px 10px', border: '1px solid var(--border-primary)', - borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', - } + const done = !!item.checked + const doneBox = ( + + ) return ( -
- {/* Header */} -
- {t('todo.detail.title')} - +
+ + +
+ setName(e.target.value)} disabled={!canEdit} + style={{ ...nameFieldStyle, textDecoration: done ? 'line-through' : 'none', color: done ? 'var(--text-faint)' : 'var(--text-primary)' }} + placeholder={t('todo.namePlaceholder')} /> + setFields(f => ({ ...f, ...patch }))} categories={categories} members={members} canEdit={canEdit} />
- {/* Form */} -
- {/* Name */} -
- setName(e.target.value)} disabled={!canEdit} - style={{ ...inputStyle, fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, border: 'none', padding: '4px 0', background: 'transparent' }} - placeholder={t('todo.namePlaceholder')} /> -
- - {/* Description */} -
- -