mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
feat(dayplan): a day can be deleted from the reorder dialog, after a warning that says what goes with it
Every row of the reorder dialog, and of the days sheet on a phone, now has a delete button. It first asks, and the question lists what goes with the day: planned places stay in the place list, notes and day texts are deleted, bookings stay without a day, a stay that checks in or out on the day is cancelled with its booking and expense (shown in the danger tone), and on a dated trip the later days and their bookings move one date earlier or the trip ends a day earlier. The server side moved from a bare DELETE into DayRemovalService, which REST, the delete_day tool and the plugin RPC all use. It closes the numbering, keeps the dates on their positions like a reorder, re-stamps the moved bookings, shifts the road trip boundaries, cancels touching stays through the accommodations domain, refuses the last day of a trip, and fans out day:deleted, day:reordered and trip:updated so collaborators renumber and see the new day count and end date. The trip query moved into a leaf file so the days domain can read it without a module cycle. On the client the delete is online only, runs optimistically through the days slice with the same reducer collaborators apply, and the ConfirmDialog gained a slot for extra content, an Escape that only closes itself, and the danger and accent tokens.
This commit is contained in:
@@ -76,6 +76,8 @@ interface DayPlanSidebarProps {
|
||||
onReorder: (dayId: number, orderedIds: number[]) => void
|
||||
onReorderDays?: (orderedIds: number[]) => void
|
||||
onAddDay?: (position?: number) => void
|
||||
/** Asks to delete a day from the reorder dialog; the planner owns the question. */
|
||||
onDeleteDay?: (dayId: number) => void
|
||||
/** 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. */
|
||||
@@ -146,7 +148,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
trip, days, places, categories, assignments,
|
||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
|
||||
onReorder, onReorderDays, onAddDay,
|
||||
onReorder, onReorderDays, onAddDay, onDeleteDay,
|
||||
onAssignToDay, onMoveToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||
reservations = [],
|
||||
visibleConnectionIds = [],
|
||||
@@ -1129,6 +1131,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onReorder,
|
||||
onReorderDays,
|
||||
onAddDay,
|
||||
onDeleteDay,
|
||||
onAssignToDay,
|
||||
onRemoveAssignment,
|
||||
onEditPlace,
|
||||
@@ -1331,6 +1334,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onReorder,
|
||||
onReorderDays,
|
||||
onAddDay,
|
||||
onDeleteDay,
|
||||
onAssignToDay,
|
||||
onRemoveAssignment,
|
||||
onEditPlace,
|
||||
@@ -1590,6 +1594,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
canManageShare={canManageShare}
|
||||
onReorderDays={onReorderDays}
|
||||
onAddDay={onAddDay}
|
||||
onDeleteDay={onDeleteDay}
|
||||
/>
|
||||
|
||||
{/* Tagesliste */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-023
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-024
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
@@ -233,6 +233,16 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
expect(onAddDay).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-024: the popup hands a delete request for its day to the planner', 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 onDeleteDay = vi.fn((_id: number) => {})
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: true, onReorderDays: vi.fn(), onAddDay: vi.fn(), onDeleteDay })} />)
|
||||
await user.click(screen.getByRole('button', { name: 'dayplan.reorderDays' }))
|
||||
await user.click(screen.getAllByRole('button', { name: 'dayplan.deleteDay' })[1])
|
||||
expect(onDeleteDay).toHaveBeenCalledWith(11)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-021: the reorder button is hidden without edit rights or without days', () => {
|
||||
const days = [buildDay({ id: 10 })]
|
||||
const { rerender } = render(<DayPlanSidebarToolbar {...makeProps({ days, canEditDays: false, onReorderDays: vi.fn(), onAddDay: vi.fn() })} />)
|
||||
|
||||
@@ -37,6 +37,8 @@ interface DayPlanSidebarToolbarProps {
|
||||
canManageShare?: boolean
|
||||
onReorderDays?: (orderedIds: number[]) => void
|
||||
onAddDay?: (position?: number) => void
|
||||
/** Asks to delete a day from the reorder dialog; without it the dialog has no delete buttons. */
|
||||
onDeleteDay?: (dayId: number) => void
|
||||
}
|
||||
|
||||
export function DayPlanSidebarToolbar({
|
||||
@@ -44,7 +46,7 @@ export function DayPlanSidebarToolbar({
|
||||
allConnectionsShown = false, onToggleAllConnections,
|
||||
t, locale, toast,
|
||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||
canEditDays, canManageShare = true, onReorderDays, onAddDay,
|
||||
canEditDays, canManageShare = true, onReorderDays, onAddDay, onDeleteDay,
|
||||
}: DayPlanSidebarToolbarProps) {
|
||||
const [reorderOpen, setReorderOpen] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
@@ -197,6 +199,7 @@ export function DayPlanSidebarToolbar({
|
||||
locale={locale}
|
||||
onReorder={onReorderDays}
|
||||
onAddDay={() => onAddDay()}
|
||||
onDeleteDay={onDeleteDay}
|
||||
onClose={() => setReorderOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-012
|
||||
// FE-PLANNER-DAYREORDER-001 to FE-PLANNER-DAYREORDER-016
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { buildDay } from '../../../tests/helpers/factories'
|
||||
import { DayReorderPopup } from './DayReorderPopup'
|
||||
import { setForcedOffline } from '../../sync/networkMode'
|
||||
import type { Day } from '../../types'
|
||||
|
||||
// The component takes `t` as a prop, so returning the key keeps assertions exact.
|
||||
@@ -137,4 +138,47 @@ describe('DayReorderPopup', () => {
|
||||
await user.click(screen.getByText('common.close'))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('deleting a day', () => {
|
||||
afterEach(() => setForcedOffline(false))
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-013: each row asks to delete its own day, and only asks', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDeleteDay = vi.fn()
|
||||
const onReorder = vi.fn()
|
||||
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay, onReorder })} />)
|
||||
const buttons = screen.getAllByRole('button', { name: 'dayplan.deleteDay' })
|
||||
expect(buttons).toHaveLength(3)
|
||||
await user.click(buttons[1])
|
||||
expect(onDeleteDay).toHaveBeenCalledWith(7)
|
||||
expect(onReorder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-014: without a delete handler the rows carry no delete button', () => {
|
||||
render(<DayReorderPopup {...makeProps({ days: threeDays() })} />)
|
||||
expect(screen.queryByRole('button', { name: 'dayplan.deleteDay' })).not.toBeInTheDocument()
|
||||
// The label is still the row's second span, next to its position badge.
|
||||
expect(rows()[0].querySelectorAll('span')[1].textContent).toBe('Paris')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-015: the only day of a trip cannot be deleted', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDeleteDay = vi.fn()
|
||||
render(<DayReorderPopup {...makeProps({ days: [buildDay({ id: 3, title: 'Paris', day_number: 1 })], onDeleteDay })} />)
|
||||
const button = screen.getByRole('button', { name: 'dayplan.deleteDay' })
|
||||
expect(button).toBeDisabled()
|
||||
await user.click(button)
|
||||
expect(onDeleteDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYREORDER-016: offline every delete button is off, and hovering says why', async () => {
|
||||
setForcedOffline(true)
|
||||
const user = userEvent.setup()
|
||||
render(<DayReorderPopup {...makeProps({ days: threeDays(), onDeleteDay: vi.fn() })} />)
|
||||
const buttons = screen.getAllByRole('button', { name: 'dayplan.deleteDay' })
|
||||
expect(buttons.every(b => (b as HTMLButtonElement).disabled)).toBe(true)
|
||||
await user.hover(buttons[0].parentElement as HTMLElement)
|
||||
expect(await screen.findByText('dayplan.daysOffline')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react'
|
||||
import { GripVertical, ArrowUp, ArrowDown, Plus, Trash2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { dayLabel } from '../../utils/dayLabel'
|
||||
import { deleteDayBlockedReason } from '../../utils/dayDeleteImpact'
|
||||
import type { Day } from '../../types'
|
||||
|
||||
interface DayReorderPopupProps {
|
||||
@@ -10,29 +14,25 @@ interface DayReorderPopupProps {
|
||||
locale: string
|
||||
onReorder: (orderedIds: number[]) => void
|
||||
onAddDay: () => void
|
||||
/** Asks to delete a day; the planner opens the question. Without it rows have no delete button. */
|
||||
onDeleteDay?: (dayId: number) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for moving whole days around: drag a row by its grip or use the up/down
|
||||
* arrows, and add a day at the end. Day headers stay untouched — this is the
|
||||
* single surface for ordering. Reorders are applied optimistically by the store,
|
||||
* so the list reflects each move immediately.
|
||||
* arrows, add a day at the end, 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.
|
||||
*/
|
||||
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
|
||||
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onDeleteDay, onClose }: DayReorderPopupProps) {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null)
|
||||
const { offline } = useNetworkMode()
|
||||
|
||||
const ordered = [...days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
|
||||
const label = (day: Day, index: number) => {
|
||||
if (day.title) return day.title
|
||||
if (day.date) {
|
||||
const d = new Date(day.date + 'T00:00:00')
|
||||
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
return t('dayplan.dayN', { n: index + 1 })
|
||||
}
|
||||
const deleteBlocked = deleteDayBlockedReason(ordered.length, offline, t)
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= ordered.length || from === to) return
|
||||
@@ -120,7 +120,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
||||
{index + 1}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 13.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{label(day, index)}
|
||||
{dayLabel(day, index, t, locale)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -140,6 +140,24 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
||||
>
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import Modal from './Modal';
|
||||
|
||||
describe('ConfirmDialog', () => {
|
||||
const onClose = vi.fn();
|
||||
@@ -91,6 +92,44 @@ describe('ConfirmDialog', () => {
|
||||
process.off('unhandledRejection', unhandled);
|
||||
});
|
||||
|
||||
it('FE-COMP-CONFIRM-010: extra content renders under the message, and the card widens for it', () => {
|
||||
render(
|
||||
<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} message="Delete it?">
|
||||
<ul aria-label="consequences"><li>Stay at Harbour Hotel</li></ul>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
const list = screen.getByRole('list', { name: 'consequences' });
|
||||
expect(list).toHaveTextContent('Stay at Harbour Hotel');
|
||||
expect(screen.getByText('Delete it?').compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(document.querySelector('.max-w-md')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-CONFIRM-011: Escape takes back the question only, not the dialog it was asked from', () => {
|
||||
const closeModal = vi.fn();
|
||||
render(
|
||||
<>
|
||||
<Modal isOpen={true} onClose={closeModal} title="Reorder days">rows</Modal>
|
||||
<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} message="Delete it?" />
|
||||
</>
|
||||
);
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
expect(closeModal).not.toHaveBeenCalled();
|
||||
// Other keys pass through untouched.
|
||||
fireEvent.keyDown(document.body, { key: 'Enter' });
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('FE-COMP-CONFIRM-012: the danger look comes from the danger tokens, the plain one from the accent', () => {
|
||||
const { unmount } = render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} />);
|
||||
expect(screen.getByRole('button', { name: /delete/i })).toHaveClass('bg-danger');
|
||||
expect(document.querySelector('.bg-danger-soft')).not.toBeNull();
|
||||
unmount();
|
||||
render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} danger={false} confirmLabel="Keep" />);
|
||||
expect(screen.getByRole('button', { name: 'Keep' })).toHaveClass('bg-accent', 'text-accent-text');
|
||||
expect(document.querySelector('.bg-danger-soft')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-CONFIRM-008: clicking backdrop calls onClose', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ConfirmDialog isOpen={true} onClose={onClose} onConfirm={onConfirm} message="msg" />);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useCallback } from 'react'
|
||||
import React, { useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -12,6 +12,8 @@ interface ConfirmDialogProps {
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
danger?: boolean
|
||||
/** Extra content under the message, such as a list of what the action takes with it. */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
// Callers commonly pass an async handler that reports its own failure and then
|
||||
@@ -33,18 +35,23 @@ export default function ConfirmDialog({
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
danger = true,
|
||||
children,
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Captured and stopped here: the question often opens over another dialog
|
||||
// (a Modal) that closes on Escape too, and one key press should only take
|
||||
// back the question, not the dialog it was asked from.
|
||||
const handleEsc = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key !== 'Escape') return
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
}
|
||||
return () => document.removeEventListener('keydown', handleEsc)
|
||||
if (!isOpen) return
|
||||
document.addEventListener('keydown', handleEsc, true)
|
||||
return () => document.removeEventListener('keydown', handleEsc, true)
|
||||
}, [isOpen, handleEsc])
|
||||
|
||||
if (!isOpen) return null
|
||||
@@ -60,22 +67,23 @@ export default function ConfirmDialog({
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm p-6 bg-surface-card"
|
||||
className={`trek-modal-enter rounded-2xl shadow-2xl w-full ${children ? 'max-w-md' : 'max-w-sm'} p-6 bg-surface-card`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{danger && (
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-danger-soft flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-danger" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-content">
|
||||
{title || t('common.confirm')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-content-secondary">
|
||||
{message}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,8 +96,8 @@ export default function ConfirmDialog({
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => { runConfirm(onConfirm); onClose() }}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors text-white ${
|
||||
danger ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-opacity hover:opacity-90 ${
|
||||
danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel || t('common.delete')}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// FE-COMP-DAYIMPACT-001 to FE-COMP-DAYIMPACT-003
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { BedDouble, MapPin } from 'lucide-react'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import DayImpactList from './DayImpactList'
|
||||
import MDayImpactList from '../../mobile/components/MDayImpactList'
|
||||
import type { ImpactLine } from '../../utils/dayImpactLines'
|
||||
|
||||
const lines: ImpactLine[] = [
|
||||
{ key: 'stay-9', icon: BedDouble, tone: 'danger', text: 'Stay at Harbour Hotel', hint: 'Cancelled with its booking.' },
|
||||
{ key: 'places', icon: MapPin, tone: 'neutral', text: 'Planned places: 3' },
|
||||
]
|
||||
|
||||
describe('DayImpactList', () => {
|
||||
it('FE-COMP-DAYIMPACT-001: one row per line, text and hint, named for assistive tech', () => {
|
||||
render(<DayImpactList lines={lines} label="Delete Tue, Oct 13?" />)
|
||||
const list = screen.getByRole('list', { name: 'Delete Tue, Oct 13?' })
|
||||
const rows = screen.getAllByRole('listitem')
|
||||
expect(list).toContainElement(rows[0])
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]).toHaveTextContent('Stay at Harbour Hotel')
|
||||
expect(rows[0]).toHaveTextContent('Cancelled with its booking.')
|
||||
expect(rows[1]).toHaveTextContent('Planned places: 3')
|
||||
})
|
||||
|
||||
it('FE-COMP-DAYIMPACT-002: the tone reaches the row: money in the danger tokens, the rest in the content tokens', () => {
|
||||
render(<DayImpactList lines={lines} />)
|
||||
const [stay, places] = screen.getAllByRole('listitem')
|
||||
expect(stay).toHaveAttribute('data-tone', 'danger')
|
||||
expect(screen.getByText('Stay at Harbour Hotel')).toHaveClass('text-danger')
|
||||
expect(stay.querySelector('span')).toHaveClass('bg-danger-soft')
|
||||
expect(screen.getByText('Planned places: 3')).toHaveClass('text-content')
|
||||
expect(places.querySelectorAll('p')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-DAYIMPACT-003: renders nothing without lines, and the phone skin draws the same rows in its own tokens', () => {
|
||||
const { container } = render(<DayImpactList lines={[]} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
|
||||
render(<MDayImpactList lines={lines} label="phone" />)
|
||||
const phone = screen.getByRole('list', { name: 'phone' })
|
||||
expect(phone.className).toContain('var(--m-inner)')
|
||||
const rows = phone.querySelectorAll('li')
|
||||
expect(rows).toHaveLength(2)
|
||||
// Hairlines between rows, not above the first.
|
||||
expect(rows[0].className).not.toContain('border-t')
|
||||
expect(rows[1].className).toContain('border-t')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ImpactLine, ImpactTone } from '../../utils/dayImpactLines'
|
||||
|
||||
/** The class names one shell draws the list with. */
|
||||
export interface ImpactListSkin {
|
||||
list: string
|
||||
row: string
|
||||
/** Between two rows; the desktop card divides them itself. */
|
||||
divider: string
|
||||
chip: string
|
||||
iconSize: number
|
||||
text: string
|
||||
hint: string
|
||||
chipTone: Record<ImpactTone, string>
|
||||
textTone: Record<ImpactTone, string>
|
||||
}
|
||||
|
||||
const DESKTOP: ImpactListSkin = {
|
||||
list: 'mt-4 overflow-hidden rounded-xl border border-edge-faint bg-surface-secondary divide-y divide-edge-faint',
|
||||
row: 'flex items-start gap-3 px-3 py-2.5',
|
||||
divider: '',
|
||||
chip: 'mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg',
|
||||
iconSize: 15,
|
||||
text: 'text-body font-medium leading-snug',
|
||||
hint: 'mt-0.5 text-caption text-content-muted',
|
||||
chipTone: {
|
||||
neutral: 'bg-surface text-content-muted',
|
||||
muted: 'bg-surface text-content-faint',
|
||||
warning: 'bg-warning-soft text-warning',
|
||||
danger: 'bg-danger-soft text-danger',
|
||||
},
|
||||
textTone: { neutral: 'text-content', muted: 'text-content-muted', warning: 'text-content', danger: 'text-danger' },
|
||||
}
|
||||
|
||||
interface DayImpactListProps {
|
||||
lines: ImpactLine[]
|
||||
/** Names the list for assistive tech; the dialog title usually says it already. */
|
||||
label?: string
|
||||
/** The phone passes its own; the default is the desktop card. */
|
||||
skin?: ImpactListSkin
|
||||
}
|
||||
|
||||
/**
|
||||
* What goes with the days being removed, one row per kind of content: an icon
|
||||
* chip in the row's tone, the count, and a caption saying what happens to it.
|
||||
* The rows come ready made from utils/dayImpactLines, and the look comes from a
|
||||
* skin, so the desktop dialog and the phone sheet share this markup and differ
|
||||
* only in their tokens.
|
||||
*/
|
||||
export default function DayImpactList({ lines, label, skin = DESKTOP }: DayImpactListProps) {
|
||||
if (lines.length === 0) return null
|
||||
return (
|
||||
<ul aria-label={label} className={skin.list}>
|
||||
{lines.map(({ key, icon: Icon, text, hint, tone }, i) => (
|
||||
<li key={key} data-tone={tone} className={`${skin.row} ${i > 0 ? skin.divider : ''}`.trim()}>
|
||||
<span className={`${skin.chip} ${skin.chipTone[tone]}`}>
|
||||
<Icon size={skin.iconSize} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`${skin.text} ${skin.textTone[tone]}`}>{text}</p>
|
||||
{hint && <p className={skin.hint}>{hint}</p>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user