mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
fix(dayplan): the delete question asks inside the day dialog, and its buttons stay small
Deleting a day no longer stacks a second dialog over the reorder dialog. The dialog swaps its list for the question at the same size and in the same place; Cancel and Escape bring the list back with the focus on the row asked about. The foot is one row of normal sized buttons with the hint line above it, the undated day is labelled "Without date" in every language, and the dialog is a step wider so no label breaks. The list of what goes with a day is quieter: a cancelled stay tints its whole row, a day with nothing on it is one plain line. On the phone the add buttons are two pills under the scrolling list, and the confirm sheet moves a long label to its own row instead of breaking it.
This commit is contained in:
@@ -12,19 +12,22 @@ interface DayAddFooterProps {
|
||||
locale: string
|
||||
}
|
||||
|
||||
const outlineBtn =
|
||||
'flex items-center justify-center gap-1.5 rounded-lg border border-edge py-2 text-body font-medium transition-colors hover:bg-surface-hover focus-visible:bg-surface-hover disabled:cursor-default disabled:opacity-40 disabled:hover:bg-transparent'
|
||||
const primaryBtn =
|
||||
'flex items-center justify-center gap-1.5 rounded-lg bg-accent py-2 text-body font-medium text-accent-text transition-colors hover:bg-accent-hover disabled:cursor-default disabled:opacity-40 disabled:hover:bg-accent'
|
||||
/** One button size for the whole foot of the reorder dialog, the delete question's included. */
|
||||
export const FOOTER_BTN =
|
||||
'inline-flex min-h-9 flex-shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-3 py-1.5 text-body font-medium transition-colors disabled:cursor-default disabled:opacity-40'
|
||||
const SECONDARY_BTN = `${FOOTER_BTN} border border-edge text-content-secondary hover:bg-surface-hover disabled:hover:bg-transparent`
|
||||
const CLOSE_BTN = `${FOOTER_BTN} ml-auto border border-edge text-content-muted hover:bg-surface-hover hover:text-content`
|
||||
/** The add that matters most, tinted from the accent instead of filled with it. */
|
||||
const MAIN_BTN = `${FOOTER_BTN} border border-[color:color-mix(in_srgb,var(--accent)_30%,transparent)] bg-[color:color-mix(in_srgb,var(--accent)_10%,transparent)] font-semibold text-accent-on hover:bg-[color:color-mix(in_srgb,var(--accent)_16%,transparent)] disabled:hover:bg-[color:color-mix(in_srgb,var(--accent)_10%,transparent)]`
|
||||
|
||||
/**
|
||||
* The foot of the reorder dialog: close it, or add a day.
|
||||
*
|
||||
* On a trip with dates there are two ways to add one, side by side. A day
|
||||
* without a date goes to the end and leaves the trip dates alone; the next
|
||||
* calendar day, named on its button, extends the trip by one. The line under
|
||||
* them says what the button in view does, and switches while the pointer or the
|
||||
* focus rests on the other one. When no day can be added it says why instead.
|
||||
* On a trip with dates there are two ways to add one, in one row with Close. The
|
||||
* next calendar day, named on its button, extends the trip by one; a day without
|
||||
* a date goes to the end and leaves the trip dates alone. The line above them
|
||||
* says what the button in view does, and switches while the pointer or the focus
|
||||
* rests on the other one. When no day can be added it says why instead.
|
||||
*/
|
||||
export function DayAddFooter({ dayAdd, onAddDay, onClose, t, locale }: DayAddFooterProps) {
|
||||
const [aboutUndated, setAboutUndated] = useState(false)
|
||||
@@ -34,21 +37,21 @@ export function DayAddFooter({ dayAdd, onAddDay, onClose, t, locale }: DayAddFoo
|
||||
const addOff = busy || !!blocked
|
||||
|
||||
const closeButton = (
|
||||
<button type="button" onClick={onClose} className={`${outlineBtn} flex-shrink-0 px-4 text-content-muted`}>
|
||||
<button type="button" onClick={onClose} className={CLOSE_BTN}>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
)
|
||||
|
||||
if (!dayAdd?.nextDate) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{closeButton}
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{blocked && <span className="min-w-0 text-caption text-content-faint">{blocked}</span>}
|
||||
<button type="button" onClick={onAddDay} disabled={addOff} className={`${primaryBtn} flex-shrink-0 px-4`}>
|
||||
<Plus size={15} strokeWidth={2} />
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{blocked && <p className="m-0 text-caption text-content-faint">{blocked}</p>}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" onClick={onAddDay} disabled={addOff} className={MAIN_BTN}>
|
||||
<Plus size={15} strokeWidth={2} aria-hidden="true" />
|
||||
{t('dayplan.addDay')}
|
||||
</button>
|
||||
{closeButton}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -65,34 +68,33 @@ export function DayAddFooter({ dayAdd, onAddDay, onClose, t, locale }: DayAddFoo
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddDay}
|
||||
disabled={addOff}
|
||||
aria-describedby={hintId}
|
||||
className={`${outlineBtn} min-w-0 px-3 text-center leading-tight text-content-secondary`}
|
||||
{...pointAtUndated}
|
||||
>
|
||||
<Plus size={15} strokeWidth={2} className="flex-shrink-0" />
|
||||
{t('dayplan.addUndatedDay')}
|
||||
</button>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<p id={hintId} aria-live="polite" className="m-0 text-caption text-content-faint">
|
||||
{hint}
|
||||
</p>
|
||||
{/* Should a language run long, the row wraps between buttons, never inside one. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={dayAdd.onAddDated}
|
||||
disabled={addOff || !!dayAdd.datedBlocked}
|
||||
aria-describedby={hintId}
|
||||
className={`${primaryBtn} min-w-0 px-3 text-center leading-tight`}
|
||||
className={MAIN_BTN}
|
||||
>
|
||||
<CalendarPlus size={15} strokeWidth={2} className="flex-shrink-0" />
|
||||
<CalendarPlus size={15} strokeWidth={2} aria-hidden="true" />
|
||||
{t('dayplan.addDatedDay', { date })}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p id={hintId} aria-live="polite" className="m-0 min-w-0 text-caption text-content-faint">
|
||||
{hint}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddDay}
|
||||
disabled={addOff}
|
||||
aria-describedby={hintId}
|
||||
className={SECONDARY_BTN}
|
||||
{...pointAtUndated}
|
||||
>
|
||||
<Plus size={15} strokeWidth={2} aria-hidden="true" />
|
||||
{t('dayplan.addUndatedDay')}
|
||||
</button>
|
||||
{closeButton}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,7 @@ import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDet
|
||||
import { TransitTitle, TransitLegChips, TransitItineraryInline } from './transitDisplay'
|
||||
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
|
||||
import type { DayAddControls } from '../../utils/dayAdd'
|
||||
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
|
||||
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
|
||||
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
|
||||
|
||||
@@ -81,6 +82,8 @@ interface DayPlanSidebarProps {
|
||||
dayAdd?: DayAddControls
|
||||
/** Asks to delete a day from the reorder dialog; the planner owns the question. */
|
||||
onDeleteDay?: (dayId: number) => void
|
||||
/** The open delete question, which the reorder dialog asks in place of its list. */
|
||||
deleteDayQuestion?: DayDeleteQuestion | null
|
||||
/** Renaming lives in the day-detail panel (#1065); the sidebar only forwards the prop. */
|
||||
onUpdateDayTitle: (dayId: number, title: string) => void
|
||||
/** The day route is computed by the planner page itself; kept for the existing call sites. */
|
||||
@@ -152,7 +155,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
trip, days, places, categories, assignments,
|
||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
|
||||
onReorder, onReorderDays, onAddDay, dayAdd, onDeleteDay,
|
||||
onReorder, onReorderDays, onAddDay, dayAdd, onDeleteDay, deleteDayQuestion,
|
||||
onAssignToDay, onMoveToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||
reservations = [],
|
||||
visibleConnectionIds = [],
|
||||
@@ -1137,6 +1140,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onAddDay,
|
||||
dayAdd,
|
||||
onDeleteDay,
|
||||
deleteDayQuestion,
|
||||
onAssignToDay,
|
||||
onRemoveAssignment,
|
||||
onEditPlace,
|
||||
@@ -1341,6 +1345,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onAddDay,
|
||||
dayAdd,
|
||||
onDeleteDay,
|
||||
deleteDayQuestion,
|
||||
onAssignToDay,
|
||||
onRemoveAssignment,
|
||||
onEditPlace,
|
||||
@@ -1602,6 +1607,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onAddDay={onAddDay}
|
||||
dayAdd={dayAdd}
|
||||
onDeleteDay={onDeleteDay}
|
||||
deleteDayQuestion={deleteDayQuestion}
|
||||
/>
|
||||
|
||||
{/* Tagesliste */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-025
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-026
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
@@ -243,6 +243,21 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
expect(onDeleteDay).toHaveBeenCalledWith(11)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-026: the planner question reaches the popup, which asks it in place of its list', async () => {
|
||||
const user = userEvent.setup()
|
||||
const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 }), buildDay({ id: 11, title: 'Day 2', day_number: 2 })]
|
||||
const onCancel = vi.fn()
|
||||
const question = { dayId: 11, title: 'Delete Day 2?', lines: [], onCancel, onConfirm: vi.fn() }
|
||||
const props = makeProps({ days, canEditDays: true, onReorderDays: vi.fn(), onAddDay: vi.fn(), onDeleteDay: vi.fn() })
|
||||
const { rerender } = render(<DayPlanSidebarToolbar {...props} />)
|
||||
await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' }))
|
||||
rerender(<DayPlanSidebarToolbar {...props} deleteDayQuestion={question} />)
|
||||
expect(screen.getByRole('region', { name: 'Delete Day 2?' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('dayplan.reorderHint')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-025: the popup gets the planner add controls, and its dated button adds the next date', async () => {
|
||||
const user = userEvent.setup()
|
||||
const days = [buildDay({ id: 10, title: 'Day 1', day_number: 1 })]
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useToast } from '../shared/Toast'
|
||||
import { TripExportModal } from './TripExportModal'
|
||||
import { isRoutableReservation } from '../../utils/reservationRoutes'
|
||||
import type { DayAddControls } from '../../utils/dayAdd'
|
||||
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
|
||||
interface DayPlanSidebarToolbarProps {
|
||||
@@ -42,6 +43,8 @@ interface DayPlanSidebarToolbarProps {
|
||||
dayAdd?: DayAddControls
|
||||
/** Asks to delete a day from the reorder dialog; without it the dialog has no delete buttons. */
|
||||
onDeleteDay?: (dayId: number) => void
|
||||
/** The open delete question; the dialog asks it in place of its day list. */
|
||||
deleteDayQuestion?: DayDeleteQuestion | null
|
||||
}
|
||||
|
||||
export function DayPlanSidebarToolbar({
|
||||
@@ -49,7 +52,7 @@ export function DayPlanSidebarToolbar({
|
||||
allConnectionsShown = false, onToggleAllConnections,
|
||||
t, locale, toast,
|
||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||
canEditDays, canManageShare = true, onReorderDays, onAddDay, dayAdd, onDeleteDay,
|
||||
canEditDays, canManageShare = true, onReorderDays, onAddDay, dayAdd, onDeleteDay, deleteDayQuestion,
|
||||
}: DayPlanSidebarToolbarProps) {
|
||||
const [reorderOpen, setReorderOpen] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
@@ -204,6 +207,7 @@ export function DayPlanSidebarToolbar({
|
||||
onAddDay={() => onAddDay()}
|
||||
dayAdd={dayAdd}
|
||||
onDeleteDay={onDeleteDay}
|
||||
deleteQuestion={deleteDayQuestion}
|
||||
onClose={() => setReorderOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-021
|
||||
// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-028
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { buildDay } from '../../../tests/helpers/factories'
|
||||
import { DayReorderPopup } from './DayReorderPopup'
|
||||
import { setForcedOffline } from '../../sync/networkMode'
|
||||
import type { DayAddControls } from '../../utils/dayAdd'
|
||||
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
|
||||
import type { Day } from '../../types'
|
||||
|
||||
// The component takes `t` as a prop, so returning the key keeps assertions exact.
|
||||
@@ -123,9 +125,9 @@ describe('DayReorderPopup', () => {
|
||||
fireEvent.dragStart(first)
|
||||
fireEvent.dragOver(second)
|
||||
// The hovered row is highlighted while a drag is in flight.
|
||||
expect(second.style.outline).toContain('dashed')
|
||||
expect(second).toHaveClass('outline-dashed')
|
||||
fireEvent.dragEnd(first)
|
||||
expect(second.style.outline).toBe('none')
|
||||
expect(second).not.toHaveClass('outline-dashed')
|
||||
expect(onReorder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -260,4 +262,104 @@ describe('DayReorderPopup', () => {
|
||||
expect(undated).toHaveAttribute('aria-describedby', hintId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the delete question, asked in place of the list', () => {
|
||||
const question = (overrides: Partial<DayDeleteQuestion> = {}): DayDeleteQuestion => ({
|
||||
dayId: 7,
|
||||
title: 'Delete Lyon?',
|
||||
lines: [{ key: 'places', icon: MapPin, tone: 'neutral', text: 'Planned places: 2', hint: 'They stay in the place list.' }],
|
||||
onCancel: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-022: the question takes the place of the rows and the add buttons, in the same dialog', () => {
|
||||
const dayAdd: DayAddControls = { nextDate: '2026-10-13', blocked: null, datedBlocked: null, busy: false, onAddDated: vi.fn() }
|
||||
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: question(), dayAdd })} />)
|
||||
expect(rows()).toHaveLength(0)
|
||||
expect(screen.queryByRole('button', { name: 'dayplan.addUndatedDay' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.close' })).not.toBeInTheDocument()
|
||||
// Still the reorder dialog: its own title stays, the question sits under it.
|
||||
expect(screen.getByText('dayplan.reorderTitle')).toBeInTheDocument()
|
||||
const section = screen.getByRole('region', { name: 'Delete Lyon?' })
|
||||
expect(section).toHaveAccessibleDescription('dayplan.deleteDayBody')
|
||||
expect(screen.getByRole('list', { name: 'Delete Lyon?' })).toHaveTextContent('Planned places: 2')
|
||||
expect(screen.getByRole('button', { name: 'common.cancel' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'dayplan.deleteDay' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-023: Cancel takes the question back, the delete button answers it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const q = question()
|
||||
const onClose = vi.fn()
|
||||
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: q, onClose })} />)
|
||||
await user.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(q.onCancel).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'dayplan.deleteDay' }))
|
||||
expect(q.onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-024: Escape takes back only the question, not the dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
const q = question()
|
||||
const onClose = vi.fn()
|
||||
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: q, onClose })} />)
|
||||
await user.keyboard('{Escape}')
|
||||
expect(q.onCancel).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-025: closing the dialog also drops an open question', async () => {
|
||||
const user = userEvent.setup()
|
||||
const q = question()
|
||||
const onClose = vi.fn()
|
||||
const { rerender } = render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: q, onClose })} />)
|
||||
const closeX = document.querySelector<HTMLButtonElement>('h2 + button')
|
||||
expect(closeX).not.toBeNull()
|
||||
await user.click(closeX as HTMLButtonElement)
|
||||
expect(q.onCancel).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A dialog closed from outside takes its question with it as well.
|
||||
const again = question()
|
||||
rerender(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: again, onClose })} />)
|
||||
rerender(<DayReorderPopup {...makeProps({ isOpen: false, days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: again, onClose })} />)
|
||||
expect(again.onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-026: a question about a day that is no longer there is not asked', () => {
|
||||
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn(), deleteQuestion: question({ dayId: 99 }) })} />)
|
||||
expect(rows()).toHaveLength(3)
|
||||
expect(screen.queryByRole('region', { name: 'Delete Lyon?' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-027: the focus goes to Cancel, and back to the row asked about when the list returns', async () => {
|
||||
const user = userEvent.setup()
|
||||
const days = threeDays()
|
||||
const onDeleteDay = vi.fn()
|
||||
const { rerender } = render(<DayReorderPopup {...makeProps({ days, onDeleteDay })} />)
|
||||
await user.click(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1])
|
||||
expect(onDeleteDay).toHaveBeenCalledWith(7)
|
||||
|
||||
rerender(<DayReorderPopup {...makeProps({ days, onDeleteDay, deleteQuestion: question() })} />)
|
||||
expect(screen.getByRole('button', { name: 'common.cancel' })).toHaveFocus()
|
||||
|
||||
rerender(<DayReorderPopup {...makeProps({ days, onDeleteDay, deleteQuestion: null })} />)
|
||||
expect(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1]).toHaveFocus()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-028: after the day went, the focus lands on the row that took its place', async () => {
|
||||
const user = userEvent.setup()
|
||||
const days = threeDays()
|
||||
const { rerender } = render(<DayReorderPopup {...makeProps({ days, onDeleteDay: vi.fn() })} />)
|
||||
await user.click(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1])
|
||||
rerender(<DayReorderPopup {...makeProps({ days, onDeleteDay: vi.fn(), deleteQuestion: question() })} />)
|
||||
const left = days.filter(d => d.id !== 7)
|
||||
rerender(<DayReorderPopup {...makeProps({ days: left, onDeleteDay: vi.fn(), deleteQuestion: null })} />)
|
||||
const buttons = screen.getAllByRole('button', { name: 'dayplan.deleteDay' })
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons[1]).toHaveFocus()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { GripVertical, ArrowUp, ArrowDown, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { GripVertical, ArrowUp, ArrowDown, Trash2, AlertTriangle } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { DayAddFooter } from './DayAddFooter'
|
||||
import DayImpactList from '../shared/DayImpactList'
|
||||
import { DayAddFooter, FOOTER_BTN } from './DayAddFooter'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { dayLabel } from '../../utils/dayLabel'
|
||||
import { deleteDayBlockedReason } from '../../utils/dayDeleteImpact'
|
||||
import type { DayAddControls } from '../../utils/dayAdd'
|
||||
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
|
||||
import type { Day } from '../../types'
|
||||
|
||||
interface DayReorderPopupProps {
|
||||
@@ -23,24 +25,48 @@ interface DayReorderPopupProps {
|
||||
dayAdd?: DayAddControls
|
||||
/** Asks to delete a day; the planner opens the question. Without it rows have no delete button. */
|
||||
onDeleteDay?: (dayId: number) => void
|
||||
/** The open delete question, asked in place of the day list until it is answered. */
|
||||
deleteQuestion?: DayDeleteQuestion | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Where the list stood when the question opened, to come back to it. */
|
||||
interface ListSpot {
|
||||
dayId: number
|
||||
index: number
|
||||
scrollTop: number
|
||||
panelHeight: number
|
||||
}
|
||||
|
||||
const ICON_BTN =
|
||||
'grid h-7 w-7 flex-shrink-0 place-items-center rounded-md border border-edge-faint text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-content-muted'
|
||||
const DELETE_BTN =
|
||||
'grid h-7 w-7 place-items-center rounded-md text-content-faint transition-colors hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft focus-visible:text-danger disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-content-faint'
|
||||
|
||||
/**
|
||||
* Modal for moving whole days around: drag a row by its grip or use the up/down
|
||||
* arrows, add a day, or delete one. Day headers stay untouched, so this is the
|
||||
* single surface for ordering. Reorders are applied optimistically by the store,
|
||||
* so the list reflects each move immediately. A delete only asks: the planner
|
||||
* shows what goes with the day before anything happens. Adding a day on a trip
|
||||
* with dates offers both kinds, a day without a date or the next date.
|
||||
* so the list reflects each move immediately.
|
||||
*
|
||||
* A delete only asks, and it asks right here: the dialog swaps its list for the
|
||||
* question, what goes with the day and the two ways out, at the same size and in
|
||||
* the same place. Cancel and Escape bring the list back where it was.
|
||||
*/
|
||||
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, dayAdd, onDeleteDay, onClose }: DayReorderPopupProps) {
|
||||
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, dayAdd, onDeleteDay, deleteQuestion, onClose }: DayReorderPopupProps) {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null)
|
||||
const { offline } = useNetworkMode()
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
const spot = useRef<ListSpot | null>(null)
|
||||
const titleId = useId()
|
||||
const bodyId = useId()
|
||||
|
||||
const ordered = [...days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
const deleteBlocked = deleteDayBlockedReason(ordered.length, offline, t)
|
||||
// A question about a day that is gone (deleted elsewhere) is no question.
|
||||
const asking = isOpen && deleteQuestion && ordered.some(d => d.id === deleteQuestion.dayId) ? deleteQuestion : null
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= ordered.length || from === to) return
|
||||
@@ -50,99 +76,178 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
||||
onReorder(ids)
|
||||
}
|
||||
|
||||
const cellBtn = {
|
||||
display: 'grid', placeItems: 'center', width: 28, height: 28,
|
||||
border: '1px solid var(--border-faint)', borderRadius: 7,
|
||||
background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0,
|
||||
} as const
|
||||
const ask = (dayId: number, index: number) => {
|
||||
const body = bodyRef.current
|
||||
spot.current = {
|
||||
dayId,
|
||||
index,
|
||||
scrollTop: body?.parentElement?.scrollTop ?? 0,
|
||||
panelHeight: body?.closest<HTMLElement>('[role="presentation"]')?.offsetHeight ?? 0,
|
||||
}
|
||||
onDeleteDay?.(dayId)
|
||||
}
|
||||
|
||||
// Closing the dialog also drops a question left open in it; so does the
|
||||
// dialog going away with the sidebar that holds it.
|
||||
const questionRef = useRef(deleteQuestion)
|
||||
questionRef.current = deleteQuestion
|
||||
const close = () => { questionRef.current?.onCancel(); onClose() }
|
||||
useEffect(() => { if (!isOpen) questionRef.current?.onCancel() }, [isOpen])
|
||||
useEffect(() => () => questionRef.current?.onCancel(), [])
|
||||
|
||||
// Escape takes back the question, not the dialog. Captured and stopped
|
||||
// before the dialog's own listener, which would close everything.
|
||||
useEffect(() => {
|
||||
if (!asking) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return
|
||||
e.stopPropagation()
|
||||
asking.onCancel()
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => document.removeEventListener('keydown', onKey, true)
|
||||
}, [asking])
|
||||
|
||||
// The question keeps the dialog at the height the list had, so nothing moves
|
||||
// under the pointer; a longer one grows it and scrolls inside. Back at the
|
||||
// list, the scroll position and the focus return to the row asked about.
|
||||
const askedDayId = asking?.dayId ?? null
|
||||
const lineCount = asking?.lines.length ?? 0
|
||||
const shownDayId = useRef<number | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
const body = bodyRef.current
|
||||
if (!body) return
|
||||
const scroller = body.parentElement
|
||||
const turned = shownDayId.current !== askedDayId
|
||||
shownDayId.current = askedDayId
|
||||
body.style.minHeight = ''
|
||||
if (askedDayId != null) {
|
||||
const target = spot.current?.panelHeight ?? 0
|
||||
const panel = body.closest<HTMLElement>('[role="presentation"]')
|
||||
const short = target - (panel?.offsetHeight ?? target)
|
||||
if (short > 0) body.style.minHeight = `${body.offsetHeight + short}px`
|
||||
if (turned) {
|
||||
if (scroller) scroller.scrollTop = 0
|
||||
cancelRef.current?.focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
const from = spot.current
|
||||
if (!from || !turned) return
|
||||
spot.current = null
|
||||
if (scroller) scroller.scrollTop = from.scrollTop
|
||||
const buttons = body.querySelectorAll<HTMLButtonElement>('[data-delete-day]')
|
||||
const again = body.querySelector<HTMLButtonElement>(`[data-delete-day="${from.dayId}"]`)
|
||||
?? buttons[Math.min(from.index, buttons.length - 1)]
|
||||
again?.focus()
|
||||
}, [askedDayId, lineCount])
|
||||
|
||||
const footer = asking ? (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button ref={cancelRef} type="button" onClick={asking.onCancel} className={`${FOOTER_BTN} border border-edge text-content-secondary hover:bg-surface-hover`}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={asking.onConfirm} className={`${FOOTER_BTN} bg-danger font-semibold text-white hover:opacity-90`}>
|
||||
<Trash2 size={15} strokeWidth={2} aria-hidden="true" />
|
||||
{t('dayplan.deleteDay')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<DayAddFooter dayAdd={dayAdd} onAddDay={onAddDay} onClose={close} t={t} locale={locale} />
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t('dayplan.reorderTitle')}
|
||||
size="md"
|
||||
footer={<DayAddFooter dayAdd={dayAdd} onAddDay={onAddDay} onClose={onClose} t={t} locale={locale} />}
|
||||
>
|
||||
<p style={{ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-faint)', lineHeight: 1.4 }}>
|
||||
{t('dayplan.reorderHint')}
|
||||
</p>
|
||||
<Modal isOpen={isOpen} onClose={close} title={t('dayplan.reorderTitle')} size="lg" footer={footer}>
|
||||
<div ref={bodyRef}>
|
||||
{asking ? (
|
||||
<section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-danger-soft text-danger">
|
||||
<AlertTriangle size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 id={titleId} className="text-body font-semibold leading-snug text-content">{asking.title}</h3>
|
||||
<p id={bodyId} className="mt-0.5 text-caption text-content-muted">{t('dayplan.deleteDayBody')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DayImpactList lines={asking.lines} label={asking.title} />
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-3 text-caption text-content-faint">{t('dayplan.reorderHint')}</p>
|
||||
|
||||
{/* The popup is a modal, so it portals out of the planner and has to opt
|
||||
into the long-press drag itself (#1616). Without this a finger only
|
||||
selects the row text. */}
|
||||
<div data-touch-drag style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{ordered.map((day, index) => (
|
||||
<div
|
||||
key={day.id}
|
||||
draggable
|
||||
onDragStart={() => setDragIndex(index)}
|
||||
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
|
||||
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
|
||||
setDragIndex(null); setOverIndex(null)
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px',
|
||||
borderRadius: 9,
|
||||
border: '1px solid var(--border-faint)',
|
||||
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'var(--bg-card, white)',
|
||||
opacity: dragIndex === index ? 0.5 : 1,
|
||||
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
|
||||
outlineOffset: -2,
|
||||
}}
|
||||
>
|
||||
<GripVertical size={15} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{
|
||||
flexShrink: 0, width: 24, height: 24, borderRadius: '50%',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
display: 'grid', placeItems: 'center', fontSize: 11, fontWeight: 700,
|
||||
}}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 13.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{dayLabel(day, index, t, locale)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
aria-label={t('dayplan.moveUp')}
|
||||
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
|
||||
>
|
||||
<ArrowUp size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, index + 1)}
|
||||
disabled={index === ordered.length - 1}
|
||||
aria-label={t('dayplan.moveDown')}
|
||||
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
|
||||
>
|
||||
<ArrowDown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
{onDeleteDay && (
|
||||
<>
|
||||
<span aria-hidden="true" className="mx-0.5 h-5 w-px flex-shrink-0 bg-edge-faint" />
|
||||
<Tooltip label={deleteBlocked ?? t('dayplan.deleteDay')} placement="left">
|
||||
<span className="inline-flex flex-shrink-0">
|
||||
{/* The popup is a modal, so it portals out of the planner and has to opt
|
||||
into the long-press drag itself (#1616). Without this a finger only
|
||||
selects the row text. */}
|
||||
<div data-touch-drag className="flex flex-col gap-1">
|
||||
{ordered.map((day, index) => {
|
||||
const dropTarget = overIndex === index && dragIndex !== null && dragIndex !== index
|
||||
return (
|
||||
<div
|
||||
key={day.id}
|
||||
draggable
|
||||
onDragStart={() => setDragIndex(index)}
|
||||
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
|
||||
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
|
||||
setDragIndex(null); setOverIndex(null)
|
||||
}}
|
||||
className={`flex items-center gap-2.5 rounded-lg border border-edge-faint px-2.5 py-1.5 ${
|
||||
dropTarget ? 'bg-surface-hover outline-dashed outline-2 -outline-offset-2 outline-edge' : 'bg-surface-card'
|
||||
} ${dragIndex === index ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<GripVertical size={15} strokeWidth={1.8} className="flex-shrink-0 cursor-grab text-content-faint" />
|
||||
<span className="grid h-6 w-6 flex-shrink-0 place-items-center rounded-full bg-surface-hover text-caption font-semibold text-content-muted">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-body font-medium text-content">
|
||||
{dayLabel(day, index, t, locale)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteDay(day.id)}
|
||||
disabled={!!deleteBlocked}
|
||||
aria-label={t('dayplan.deleteDay')}
|
||||
className="grid h-7 w-7 place-items-center rounded-[7px] border border-edge-faint text-content-muted transition-colors hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft focus-visible:text-danger disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-content-muted"
|
||||
onClick={() => move(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
aria-label={t('dayplan.moveUp')}
|
||||
className={ICON_BTN}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
<ArrowUp size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, index + 1)}
|
||||
disabled={index === ordered.length - 1}
|
||||
aria-label={t('dayplan.moveDown')}
|
||||
className={ICON_BTN}
|
||||
>
|
||||
<ArrowDown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
{onDeleteDay && (
|
||||
<>
|
||||
<span aria-hidden="true" className="h-5 w-px flex-shrink-0 bg-edge-faint" />
|
||||
<Tooltip label={deleteBlocked ?? t('dayplan.deleteDay')} placement="left">
|
||||
<span className="inline-flex flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
data-delete-day={day.id}
|
||||
onClick={() => ask(day.id, index)}
|
||||
disabled={!!deleteBlocked}
|
||||
aria-label={t('dayplan.deleteDay')}
|
||||
className={DELETE_BTN}
|
||||
>
|
||||
<Trash2 size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user