mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(planner): share the left panel tab and the selected place props in the trip planner page
The plan view and the tour planner view each drew the left panel's tab in full, and the place inspector and the tour dialog each listed the same props twice, once beside the panels and once in the phone-width sheet. LeftPanelTab, a small presentational component at the bottom of TripPlannerPage.tsx, now draws the tab for both views. It stays in the page file so the physical left and right offsets keep being counted there. The tour planner's tab passes `announced`, which keeps its aria-expanded and its focus ring; the plan view's tab still has neither. The page body now builds selectedPlaceProps (what the inspector and the tour dialog both take), placeInspectorProps and tourDetailProps once and spreads them. What differs stays at each call site: the desktop inspector's onOpenBooking and panel widths, the sheet's edit and delete closing the sheet, the desktop tour dialog's non-modal focus return and widths, and canEdit and canAssign on every tour dialog.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { render } from '../../tests/helpers/render';
|
||||
import { LeftPanelTab } from './TripPlannerPage';
|
||||
|
||||
// FE-PAGE-PLANNER-LEFTTAB-001 to FE-PAGE-PLANNER-LEFTTAB-004
|
||||
|
||||
describe('LeftPanelTab', () => {
|
||||
it('FE-PAGE-PLANNER-LEFTTAB-001: an open panel shows a flap on its edge that collapses it', () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<LeftPanelTab hidden={false} label="Collapse" onToggle={onToggle} />);
|
||||
const tab = screen.getByRole('button', { name: 'Collapse' });
|
||||
expect(tab).toHaveClass('text-content-faint');
|
||||
expect(tab.style.position).toBe('absolute');
|
||||
expect(tab.style.right).toBe('-28px');
|
||||
expect(tab.style.borderRadius).toBe('0 10px 10px 0');
|
||||
fireEvent.click(tab);
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PAGE-PLANNER-LEFTTAB-002: a hidden panel leaves a raised accent tile', () => {
|
||||
render(<LeftPanelTab hidden label="Plan" onToggle={vi.fn()} />);
|
||||
const tab = screen.getByRole('button', { name: 'Plan' });
|
||||
expect(tab).toHaveClass('bg-accent');
|
||||
expect(tab.style.position).toBe('fixed');
|
||||
expect(tab.style.left).toBe('10px');
|
||||
expect(tab.style.borderRadius).toBe('10px');
|
||||
});
|
||||
|
||||
it('FE-PAGE-PLANNER-LEFTTAB-003: says nothing about its state and has no focus ring by default', () => {
|
||||
render(<LeftPanelTab hidden={false} label="Collapse" onToggle={vi.fn()} />);
|
||||
const tab = screen.getByRole('button', { name: 'Collapse' });
|
||||
expect(tab).not.toHaveAttribute('aria-expanded');
|
||||
expect(tab).not.toHaveClass('focus-visible:outline');
|
||||
});
|
||||
|
||||
it('FE-PAGE-PLANNER-LEFTTAB-004: an announced tab states whether the panel is open and shows a focus ring', () => {
|
||||
const { rerender } = render(<LeftPanelTab hidden={false} label="Collapse" onToggle={vi.fn()} announced />);
|
||||
const tab = screen.getByRole('button', { name: 'Collapse' });
|
||||
expect(tab).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(tab).toHaveClass('text-content-faint', 'focus-visible:outline');
|
||||
rerender(<LeftPanelTab hidden label="Plan" onToggle={vi.fn()} announced />);
|
||||
const hiddenTab = screen.getByRole('button', { name: 'Plan' });
|
||||
expect(hiddenTab).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(hiddenTab).toHaveClass('bg-accent', 'focus-visible:outline');
|
||||
});
|
||||
});
|
||||
@@ -210,6 +210,33 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
const mapInsetLeft = leftPanelPx ? leftPanelPx + 10 : 0
|
||||
const mapInsetRight = rightPanelPx ? rightPanelPx + 10 : 0
|
||||
|
||||
// The place inspector and the tour dialog open on the selected place, beside
|
||||
// the panels or in the phone-width sheet. What every one of them gets; each
|
||||
// call site adds what differs.
|
||||
const selectedPlaceProps = {
|
||||
days, selectedDayId, selectedAssignmentId, assignments, files,
|
||||
onClose: () => setSelectedPlaceId(null),
|
||||
onFileUpload: canUploadFiles ? (fd: FormData) => tripActions.addFile(tripId, fd) : undefined,
|
||||
onAssignToDay: handleAssignToDay,
|
||||
onRemoveAssignment: handleRemoveAssignment,
|
||||
}
|
||||
const placeInspectorProps = {
|
||||
...selectedPlaceProps,
|
||||
roadtripEndDay, roadtripStay, roadtripActive,
|
||||
onEditTransport: openLinkedTransport,
|
||||
onEditReservation: openLinkedReservation,
|
||||
categories, reservations, tripMembers,
|
||||
onSetParticipants: placeActions.setParticipants,
|
||||
onUpdatePlace: placeActions.updatePlace,
|
||||
onUploadImage: async (placeId: number, file: File) => { await tripActions.uploadPlaceImage(tripId, placeId, file) },
|
||||
onImageFromFile: async (placeId: number, fileId: number) => { await tripActions.setPlaceImageFromFile(tripId, placeId, fileId) },
|
||||
onRate: placeActions.ratePlace,
|
||||
}
|
||||
const tourDetailProps = {
|
||||
...selectedPlaceProps,
|
||||
onUpdatePlace: async (placeId: number, data: Partial<Place>) => { await tripActions.updatePlace(tripId, placeId, data); await reloadTourPlaceIds() },
|
||||
}
|
||||
|
||||
// The trip is a family of help screens: the frame, then one per tab, and on
|
||||
// the plan one per overlay that is open. A screen that has no help yet falls
|
||||
// back to the frame.
|
||||
@@ -273,22 +300,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
/>
|
||||
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
{/* The panel's tab: a flap on its edge while open, a raised accent tile once it is tucked away. */}
|
||||
<Tooltip label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')} placement="right">
|
||||
<button type="button" onClick={toggleLeft}
|
||||
aria-label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')}
|
||||
className={leftHidden ? 'bg-accent text-accent-text shadow-md hover:opacity-90' : 'text-content-faint hover:text-content'}
|
||||
style={{
|
||||
position: leftHidden ? 'fixed' : 'absolute', top: leftHidden ? 'calc(var(--nav-h) + 44px + 14px)' : 14, left: leftHidden ? 10 : undefined, right: leftHidden ? undefined : -28, zIndex: -1,
|
||||
width: 36, height: 36, borderRadius: leftHidden ? 10 : '0 10px 10px 0',
|
||||
background: leftHidden ? undefined : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
border: 'none',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'color 0.15s',
|
||||
}}>
|
||||
{leftHidden ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<LeftPanelTab hidden={leftHidden} label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')} onToggle={toggleLeft} />
|
||||
|
||||
<div style={{
|
||||
width: leftHidden ? 0 : leftWidth, height: '100%',
|
||||
@@ -559,31 +571,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
|
||||
{selectedPlace && (!toursEnabled || tourDataReady) && !selectedTour && !isMobile && (
|
||||
<PlaceInspector
|
||||
roadtripEndDay={roadtripEndDay}
|
||||
roadtripStay={roadtripStay} roadtripActive={roadtripActive}
|
||||
onEditTransport={openLinkedTransport}
|
||||
onEditReservation={openLinkedReservation}
|
||||
{...placeInspectorProps}
|
||||
onOpenBooking={openBookingDetail}
|
||||
place={selectedPlace}
|
||||
categories={categories}
|
||||
days={days}
|
||||
selectedDayId={selectedDayId}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
onClose={() => setSelectedPlaceId(null)}
|
||||
onEdit={() => openPlaceEditor(selectedPlace, selectedAssignmentId)}
|
||||
onDelete={() => handleDeletePlace(selectedPlace.id)}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
files={files}
|
||||
onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined}
|
||||
tripMembers={tripMembers}
|
||||
onSetParticipants={placeActions.setParticipants}
|
||||
onUpdatePlace={placeActions.updatePlace}
|
||||
onUploadImage={async (placeId, file) => { await tripActions.uploadPlaceImage(tripId, placeId, file) }}
|
||||
onImageFromFile={async (placeId, fileId) => { await tripActions.setPlaceImageFromFile(tripId, placeId, fileId) }}
|
||||
onRate={placeActions.ratePlace}
|
||||
leftWidth={isMobile ? 0 : leftPanelPx}
|
||||
rightWidth={isMobile ? 0 : rightPanelPx}
|
||||
/>
|
||||
@@ -593,30 +585,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<div className="bg-[rgba(0,0,0,0.3)]" style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: 'var(--bottom-nav-h)' }} role="presentation" onClick={() => setSelectedPlaceId(null)}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh' }} role="presentation" onClick={e => e.stopPropagation()}>
|
||||
<PlaceInspector
|
||||
roadtripEndDay={roadtripEndDay}
|
||||
roadtripStay={roadtripStay} roadtripActive={roadtripActive}
|
||||
onEditTransport={openLinkedTransport}
|
||||
onEditReservation={openLinkedReservation}
|
||||
{...placeInspectorProps}
|
||||
place={selectedPlace}
|
||||
categories={categories}
|
||||
days={days}
|
||||
selectedDayId={selectedDayId}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
onClose={() => setSelectedPlaceId(null)}
|
||||
onEdit={() => { openPlaceEditor(selectedPlace, selectedAssignmentId); setSelectedPlaceId(null) }}
|
||||
onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
files={files}
|
||||
onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined}
|
||||
tripMembers={tripMembers}
|
||||
onSetParticipants={placeActions.setParticipants}
|
||||
onUpdatePlace={placeActions.updatePlace}
|
||||
onUploadImage={async (placeId, file) => { await tripActions.uploadPlaceImage(tripId, placeId, file) }}
|
||||
onImageFromFile={async (placeId, fileId) => { await tripActions.setPlaceImageFromFile(tripId, placeId, fileId) }}
|
||||
onRate={placeActions.ratePlace}
|
||||
leftWidth={0}
|
||||
rightWidth={0}
|
||||
/>
|
||||
@@ -630,19 +602,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
tour={selectedTour}
|
||||
desktopNonModal
|
||||
desktopFocusReturnTarget={tourDetails.openerRef.current?.placeId === selectedTour.place_id ? tourDetails.openerRef.current.element : null}
|
||||
{...tourDetailProps}
|
||||
canEdit={canPlaceEdit}
|
||||
canAssign={canDayEdit}
|
||||
place={selectedPlace}
|
||||
days={days}
|
||||
selectedDayId={selectedDayId}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
assignments={assignments}
|
||||
files={files}
|
||||
onClose={() => setSelectedPlaceId(null)}
|
||||
onUpdatePlace={async (placeId, data) => { await tripActions.updatePlace(tripId, placeId, data); await reloadTourPlaceIds() }}
|
||||
onFileUpload={canUploadFiles ? (formData) => tripActions.addFile(tripId, formData) : undefined}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
leftWidth={leftPanelPx}
|
||||
rightWidth={rightPanelPx}
|
||||
/>
|
||||
@@ -653,19 +616,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<div style={{ width: '100%', maxHeight: '85vh' }} role="presentation" onClick={event => event.stopPropagation()}>
|
||||
<TourDetailDialog
|
||||
tour={selectedTour}
|
||||
{...tourDetailProps}
|
||||
canEdit={canPlaceEdit}
|
||||
canAssign={canDayEdit}
|
||||
place={selectedPlace}
|
||||
days={days}
|
||||
selectedDayId={selectedDayId}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
assignments={assignments}
|
||||
files={files}
|
||||
onClose={() => setSelectedPlaceId(null)}
|
||||
onUpdatePlace={async (placeId, data) => { await tripActions.updatePlace(tripId, placeId, data); await reloadTourPlaceIds() }}
|
||||
onFileUpload={canUploadFiles ? (formData) => tripActions.addFile(tripId, formData) : undefined}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -717,21 +671,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
|
||||
/>
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
<Tooltip label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')} placement="right">
|
||||
<button type="button" onClick={toggleLeft}
|
||||
aria-label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')}
|
||||
aria-expanded={!leftHidden}
|
||||
className={`${leftHidden ? 'bg-accent text-accent-text shadow-md hover:opacity-90' : 'text-content-faint hover:text-content'} focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent`}
|
||||
style={{
|
||||
position: leftHidden ? 'fixed' : 'absolute', top: leftHidden ? 'calc(var(--nav-h) + 44px + 14px)' : 14,
|
||||
left: leftHidden ? 10 : undefined, right: leftHidden ? undefined : -28, zIndex: -1,
|
||||
width: 36, height: 36, borderRadius: leftHidden ? 10 : '0 10px 10px 0',
|
||||
background: leftHidden ? undefined : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'color 0.15s',
|
||||
}}>
|
||||
{leftHidden ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<LeftPanelTab hidden={leftHidden} label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')} onToggle={toggleLeft} announced />
|
||||
<div style={{
|
||||
width: leftHidden ? 0 : leftPanelPx, height: '100%', position: 'relative',
|
||||
background: 'var(--sidebar-bg)', backdropFilter: 'blur(24px) saturate(180%)',
|
||||
@@ -1043,3 +983,33 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LeftPanelTabProps {
|
||||
hidden: boolean
|
||||
label: string
|
||||
onToggle: () => void
|
||||
/** Also states whether the panel is open and shows a focus ring (the tour planner's tab). */
|
||||
announced?: boolean
|
||||
}
|
||||
|
||||
/** The left panel's tab: a flap on its edge while open, a raised accent tile once it is tucked away. */
|
||||
export function LeftPanelTab({ hidden, label, onToggle, announced = false }: LeftPanelTabProps): React.ReactElement {
|
||||
const look = hidden ? 'bg-accent text-accent-text shadow-md hover:opacity-90' : 'text-content-faint hover:text-content'
|
||||
return (
|
||||
<Tooltip label={label} placement="right">
|
||||
<button type="button" onClick={onToggle}
|
||||
aria-label={label}
|
||||
aria-expanded={announced ? !hidden : undefined}
|
||||
className={announced ? `${look} focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent` : look}
|
||||
style={{
|
||||
position: hidden ? 'fixed' : 'absolute', top: hidden ? 'calc(var(--nav-h) + 44px + 14px)' : 14,
|
||||
left: hidden ? 10 : undefined, right: hidden ? undefined : -28, zIndex: -1,
|
||||
width: 36, height: 36, borderRadius: hidden ? 10 : '0 10px 10px 0',
|
||||
background: hidden ? undefined : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'color 0.15s',
|
||||
}}>
|
||||
{hidden ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user