mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
feat(mobile): two calm round controls a side on the map, no extra buttons
The phone map stacked four controls on the right and one on the left, in three different looks. Now each side has two at most, all in one frosted style, 20px off the edge and 8px apart: the whole-trip overview and locate on the right, the base layer and the Dawarich route on the left, with the compass appearing only while the map is turned off north. The map's own places filter button and sheet are gone: the places tab carries the same filter, and the map's pins follow it. The jump-to-today button beside the day rail is gone too, since today's chip is already marked. Also fixes a leftover of the segmented-control change that broke the parse.
This commit is contained in:
@@ -69,14 +69,19 @@ export default function LocationButton({ mode, error, errorCode, onClick, bottom
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: bottomOffset,
|
||||
right: 12,
|
||||
// The map's base-layer switcher sits 20px off the left edge; this mirrors it.
|
||||
right: 20,
|
||||
zIndex: 1000,
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: isActive ? '#3b82f6' : 'var(--bg-card, white)',
|
||||
// Off, it wears the same frosted glass as the map's other round controls;
|
||||
// on, the location blue says it is tracking.
|
||||
background: isActive ? '#3b82f6' : 'var(--sidebar-bg)',
|
||||
backdropFilter: isActive ? undefined : 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: isActive ? undefined : 'blur(20px) saturate(180%)',
|
||||
color: isActive ? 'white' : (error ? '#ef4444' : 'var(--text-muted, #6b7280)'),
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
display: 'flex',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-MAPCOMPASS-001 to FE-COMP-MAPCOMPASS-006
|
||||
// FE-COMP-MAPCOMPASS-001 to FE-COMP-MAPCOMPASS-007
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '../../../tests/helpers/render'
|
||||
@@ -86,3 +86,17 @@ describe('MapCompassPill', () => {
|
||||
expect(button.style.background).toBe('transparent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MapCompassPill on the phone map', () => {
|
||||
it('FE-COMP-MAPCOMPASS-007: appears only while the map is turned off north', () => {
|
||||
const { map, rotateTo } = fakeCompassMap(0)
|
||||
render(<MapCompassPill map={map} onlyWhenRotated />)
|
||||
expect(screen.queryByRole('button', { name: 'Reset north' })).not.toBeInTheDocument()
|
||||
|
||||
rotateTo(30)
|
||||
expect(screen.getByRole('button', { name: 'Reset north' })).toBeInTheDocument()
|
||||
|
||||
rotateTo(0)
|
||||
expect(screen.queryByRole('button', { name: 'Reset north' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,12 @@ export interface CompassMap {
|
||||
* (GL only) and built as the SAME frosted shell (padding 4 around a 34px button)
|
||||
* so its height and transparency match the POI pill exactly.
|
||||
*/
|
||||
export function MapCompassPill({ map }: { map: CompassMap }) {
|
||||
export function MapCompassPill({ map, onlyWhenRotated = false }: {
|
||||
map: CompassMap
|
||||
/** Shown only while the map is turned off north, the way a native map's compass
|
||||
* appears when it has something to undo (the phone map, where room is short). */
|
||||
onlyWhenRotated?: boolean
|
||||
}) {
|
||||
const [bearing, setBearing] = useState(() => map.getBearing())
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,6 +30,8 @@ export function MapCompassPill({ map }: { map: CompassMap }) {
|
||||
return () => { map.off('rotate', update) }
|
||||
}, [map])
|
||||
|
||||
if (onlyWhenRotated && Math.abs(bearing) < 0.5) return null
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
|
||||
|
||||
@@ -29,9 +29,6 @@ export default function MSegmented<T extends string = string>({
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const pill = useActivePill(trackRef, '[data-active="true"]', [value, options.length])
|
||||
|
||||
return () => ro.disconnect()
|
||||
}, [value, options.length])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={trackRef}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState, type ComponentType, type
|
||||
import { findFocusDayId, findTodayDayId } from '../../../components/Planner/today'
|
||||
import { takeResumeDay } from '../../../utils/resumeRoute'
|
||||
import {
|
||||
CalendarCheck, CalendarDays, ChevronDown, ChevronLeft, Download, FileDown, FolderSync, List, Map as MapIcon, MoreHorizontal, Plane, Plus, Route, Rows3, SlidersHorizontal, Trash2, Upload,
|
||||
CalendarDays, ChevronDown, ChevronLeft, Download, FileDown, FolderSync, List, Map as MapIcon, MoreHorizontal, Plane, Plus, Route, Rows3, SlidersHorizontal, Trash2, Upload,
|
||||
} from 'lucide-react'
|
||||
import { useTripPlanner } from '../../../pages/tripPlanner/useTripPlanner'
|
||||
import { pickDockTabs } from './dockTabs'
|
||||
@@ -57,7 +57,7 @@ export type MTripCollabTab = 'chat' | 'notes' | 'links' | 'polls'
|
||||
* { dayId, note? }), 'accommodation' (payload { dayId?, accId?, from? }),
|
||||
* 'transport' (payload { reservationId }), 'bract'
|
||||
* (payload { placeId, dayPicker? }), 'import', 'export', 'members',
|
||||
* 'tripedit', 'bags', 'task', 'placesFilter' (the map's places filter). The place inspector keys off the planner's
|
||||
* 'tripedit', 'bags', 'task'. The place inspector keys off the planner's
|
||||
* place selection instead of a sheet id, and planner-backed editors (place
|
||||
* form, transport, booking, expense) keep using the planner's own modal flags.
|
||||
*/
|
||||
@@ -417,14 +417,10 @@ export default function MTripShell({
|
||||
const dockTabs = pickDockTabs(enabledTabIds)
|
||||
const tabLabel = (id: string) => mobileTabs.find(tab => tab.id === id)?.label ?? id
|
||||
|
||||
// While the trip runs, today's chip carries a ring and a button jumps back to it
|
||||
// from wherever the rail was scrolled (#2392). Null outside the trip's dates.
|
||||
// While the trip runs, today's chip carries a ring and a dot (#2392). That mark
|
||||
// is the whole of it: a second button beside the rail only crowded the chrome.
|
||||
// Null outside the trip's dates.
|
||||
const todayDayId = findTodayDayId(days)
|
||||
const jumpToToday = () => {
|
||||
if (todayDayId == null || todayDayId === planner.selectedDayId) return
|
||||
if (view === 'map') selectDayOnMap(todayDayId)
|
||||
else planner.handleSelectDay(todayDayId, true)
|
||||
}
|
||||
|
||||
const onDayChipTap = (dayId: number) => {
|
||||
if (dayId === planner.selectedDayId) openSheet('day', { dayId })
|
||||
@@ -542,20 +538,6 @@ export default function MTripShell({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Back to today from anywhere on the rail (#2392). Mounted for the whole
|
||||
running trip and dimmed while today is already open, so it never
|
||||
appears under the thumb and shoves the rail aside. */}
|
||||
{todayDayId != null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={jumpToToday}
|
||||
disabled={todayDayId === planner.selectedDayId}
|
||||
aria-label={t('mobileTrip.jumpToToday')}
|
||||
className="flex w-9 flex-none items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] text-[color:var(--m-st-info)] backdrop-blur-[24px] backdrop-saturate-[1.7] transition-opacity disabled:opacity-40"
|
||||
>
|
||||
<CalendarCheck size={15} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{/* Drop the day filter and show the whole trip (#2257). Map only: the
|
||||
plan timeline and the road trip chain are both single-day, so there is
|
||||
nothing to widen there. Stays mounted while a day is active rather than
|
||||
|
||||
@@ -4,7 +4,6 @@ import { MapCompassPill, type CompassMap } from '../../../../components/Map/MapC
|
||||
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../../../components/Map/MapLayerSwitcher'
|
||||
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../../../../components/Map/TripRouteOverview'
|
||||
import { DawarichTrailPill } from '../../../../components/Map/DawarichTrailPill'
|
||||
import { MPlacesFilterPill } from './MPlacesFilterPill'
|
||||
import PoiCategoryPill from '../../../../components/Map/PoiCategoryPill'
|
||||
import { usePoiExplore } from '../../../../components/Map/usePoiExplore'
|
||||
import { useMergedMapPois } from '../../../../components/Map/useMergedMapPois'
|
||||
@@ -453,27 +452,38 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compass, GL maps only (Leaflet cannot rotate). Both engines draw the base-layer
|
||||
switcher in the bottom left corner, so the compass stands on top of it, on the
|
||||
same left edge: beside it the two read as one crowded row next to the dock.
|
||||
The offsets are inline because they are computed from the switcher's own
|
||||
numbers. */}
|
||||
{mapActive && glMap && (
|
||||
<div className="pointer-events-none absolute z-[25]" style={{ left: COMPASS_LEFT, bottom: `calc(var(--bottom-nav-h, 84px) + ${12 + COMPASS_RAISE}px)` }}>
|
||||
<MapCompassPill map={glMap} />
|
||||
{/* The left column, over the base-layer switcher both engines draw in the bottom
|
||||
left corner: the recorded Dawarich route, and the compass, which only shows
|
||||
while the map is turned off north. Two round controls a side at most, one
|
||||
frosted style and one 8px gap, so neither corner crowds the dock. The offsets
|
||||
are inline because they are computed from the switcher's own numbers. */}
|
||||
{mapActive && (glMap || planner.dawarichEnabled) && (
|
||||
<div
|
||||
className="pointer-events-none absolute z-[25] flex flex-col-reverse items-start gap-2"
|
||||
style={{ left: COMPASS_LEFT, bottom: `calc(var(--bottom-nav-h, 84px) + ${12 + COMPASS_RAISE}px)` }}
|
||||
>
|
||||
{/* Kept in road-trip mode: the recorded route beside the planned one is
|
||||
exactly the comparison a drive invites. */}
|
||||
{!onStage && planner.dawarichEnabled && (
|
||||
<DawarichTrailPill
|
||||
active={planner.dawarichTrailShown}
|
||||
status={planner.dawarichTrail.status}
|
||||
onToggle={planner.toggleDawarichTrail}
|
||||
/>
|
||||
)}
|
||||
{glMap && <MapCompassPill map={glMap} onlyWhenRotated />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Whole-trip overview (#1736): the stages stack above their toggle on the right,
|
||||
clear of the round-controls band below it and of the base-layer switcher and
|
||||
the compass, which both sit bottom left. The offset is Tailwind rather than
|
||||
inline because the compass band is identified by being the one element with
|
||||
an inline --bottom-nav-h, and a second would make that ambiguous. The places
|
||||
filter closes the stack, under the toggles, so the overview card stays
|
||||
right above its own toggle: it decides which pins the plan map shows, so it is
|
||||
there whatever else is; the stage draws its own pins and has no use for it. */}
|
||||
{/* The right column: the whole-trip overview (#1736) over the locate button, its
|
||||
stages stacking above their toggle. 20px off the edge and 8px over the locate
|
||||
button, mirroring the left column. The places filter has no button here: it
|
||||
is set in the places tab, whose filter the map's pins follow. The offset is
|
||||
Tailwind rather than inline because the compass band is identified by being
|
||||
the one element with an inline --bottom-nav-h, and a second would make that
|
||||
ambiguous. */}
|
||||
{mapActive && !onStage && (
|
||||
<div className="pointer-events-none absolute inset-x-3 z-[25] flex flex-col items-end gap-2 bottom-[calc(var(--bottom-nav-h,84px)+58px)]">
|
||||
<div className="pointer-events-none absolute inset-x-5 z-[25] flex flex-col items-end gap-2 bottom-[calc(var(--bottom-nav-h,84px)+62px)]">
|
||||
{!planner.roadtripActive && planner.overviewActive && (
|
||||
<TripRouteOverviewPanel
|
||||
overview={planner.tripOverview}
|
||||
@@ -488,16 +498,6 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
|
||||
{!planner.roadtripActive && (
|
||||
<TripRouteOverviewPill active={planner.overviewShown} onToggle={planner.toggleOverview} />
|
||||
)}
|
||||
{/* Kept in road-trip mode: the recorded route beside the planned one is
|
||||
exactly the comparison a drive invites. */}
|
||||
{planner.dawarichEnabled && (
|
||||
<DawarichTrailPill
|
||||
active={planner.dawarichTrailShown}
|
||||
status={planner.dawarichTrail.status}
|
||||
onToggle={planner.toggleDawarichTrail}
|
||||
/>
|
||||
)}
|
||||
<MPlacesFilterPill onOpen={() => shell.openSheet('placesFilter')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { SlidersHorizontal } from 'lucide-react'
|
||||
import { MapTogglePill } from '../../../../components/Map/MapTogglePill'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { countActivePlacesFilters } from '../../../../utils/placesFilter'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
|
||||
/**
|
||||
* The phone map's way into the places filter: a round control in the right-hand
|
||||
* stack, lit and badged with how many filters are narrowing the markers. The sheet
|
||||
* it opens is the shell's 'placesFilter' sheet, so the button announces a dialog
|
||||
* rather than a pressed state.
|
||||
*/
|
||||
export function MPlacesFilterPill({ onOpen }: { onOpen: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const filter = useTripStore(s => s.placesFilter)
|
||||
const categoryFilters = useTripStore(s => s.placesCategoryFilter)
|
||||
const ratingFilter = useTripStore(s => s.placesRatingFilter)
|
||||
const count = countActivePlacesFilters({ filter, categoryFilters, ratingFilter })
|
||||
const label = count > 0 ? `${t('places.filters')} (${count})` : t('places.filters')
|
||||
return (
|
||||
<MapTogglePill
|
||||
active={count > 0}
|
||||
onToggle={onOpen}
|
||||
label={label}
|
||||
badge={count}
|
||||
testId="places-filter-pill"
|
||||
opensDialog
|
||||
icon={<SlidersHorizontal size={17} strokeWidth={2} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { RotateCcw, X } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import MIconBtn from '../../../components/MIconBtn'
|
||||
import MChip from '../../../components/MChip'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { countActivePlacesFilters } from '../../../../utils/placesFilter'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Category, Place } from '../../../../types'
|
||||
import { MCategoryFilterList, MRatingFloorChips } from './MPlacesFilterControls'
|
||||
import { placesFilterTabs, poolHasTracks } from '../../../../components/Planner/usePlacesPool'
|
||||
|
||||
interface MPlacesFilterSheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
places: Place[]
|
||||
categories: Category[]
|
||||
toursEnabled: boolean
|
||||
}
|
||||
|
||||
/** Uppercase eyebrow over each group of the sheet. */
|
||||
function SectionTitle({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="mb-2 font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The places filter, opened from the phone map: which pool to show, a minimum
|
||||
* rating and the trip's categories, with one button that lifts them all. Every
|
||||
* choice lands in the trip store at once, so the markers behind the sheet and the
|
||||
* places list change with it (#1541). Routed as shell sheet 'placesFilter'.
|
||||
*/
|
||||
export default function MPlacesFilterSheet({ open, onClose, places, categories, toursEnabled }: MPlacesFilterSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
const filter = useTripStore(s => s.placesFilter)
|
||||
const categoryFilters = useTripStore(s => s.placesCategoryFilter)
|
||||
const ratingFilter = useTripStore(s => s.placesRatingFilter)
|
||||
const setFilter = useTripStore(s => s.setPlacesFilter)
|
||||
const resetPlacesFilters = useTripStore(s => s.resetPlacesFilters)
|
||||
const activeCount = countActivePlacesFilters({ filter, categoryFilters, ratingFilter })
|
||||
|
||||
const pools = placesFilterTabs(t, poolHasTracks(places, toursEnabled))
|
||||
const hasCategoryChoice = categories.length > 0 || places.some(p => p.category_id == null)
|
||||
|
||||
return (
|
||||
// Opaque: the sheet opens over the map, and the default bar glass let the map's
|
||||
// place names show through the chips and category rows (seen at 390px wide).
|
||||
<MSheet open={open} onClose={onClose} variant="bottom" material="opaque" ariaLabel={t('places.filters')}>
|
||||
<div className="flex flex-none items-center border-b border-[color:var(--m-rowbr)] px-[18px] pb-[11px] pt-4">
|
||||
<div className="min-w-0 flex-1 text-[1.03125rem] font-bold text-m-ink">{t('places.filters')}</div>
|
||||
<MIconBtn variant="neutral" size={34} onClick={onClose} ariaLabel={t('common.close')}>
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-3 pt-[14px]">
|
||||
<section>
|
||||
<SectionTitle>{t('places.filterShow')}</SectionTitle>
|
||||
<div role="group" aria-label={t('places.filterShow')} className="flex flex-wrap gap-[6px]">
|
||||
{pools.map(pool => (
|
||||
<MChip key={pool.id} size="tap" pressable active={filter === pool.id} onClick={() => setFilter(pool.id)}>
|
||||
{pool.label}
|
||||
</MChip>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-[18px]">
|
||||
<SectionTitle>{t('places.filterByRating')}</SectionTitle>
|
||||
<MRatingFloorChips />
|
||||
</section>
|
||||
|
||||
{hasCategoryChoice && (
|
||||
<section className="mt-[18px]">
|
||||
<SectionTitle>{t('categories.title')}</SectionTitle>
|
||||
<div className="overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]">
|
||||
<MCategoryFilterList categories={categories} places={places} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none border-t border-[color:var(--m-rowbr)] px-[18px] py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetPlacesFilters}
|
||||
disabled={activeCount === 0}
|
||||
className="flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-[0.8125rem] font-semibold text-m-ink disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw size={14} strokeWidth={2.2} />
|
||||
{t('common.reset')}
|
||||
</button>
|
||||
</div>
|
||||
</MSheet>
|
||||
)
|
||||
}
|
||||
@@ -27,7 +27,6 @@ import MMehrSheet from './MMehrSheet'
|
||||
import MTripSettingsSheet from './MTripSettingsSheet'
|
||||
import MNewTripSheet from '../../dashboard/MNewTripSheet'
|
||||
import MAssistantScreen from '../assistant/MAssistantScreen'
|
||||
import MPlacesFilterSheet from '../places/MPlacesFilterSheet'
|
||||
import MRtStopSheet from '../roadtrip/MRtStopSheet'
|
||||
import MRtStaySheet from '../roadtrip/MRtStaySheet'
|
||||
import MRtKindSheet from '../roadtrip/MRtKindSheet'
|
||||
@@ -145,13 +144,6 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
onClose={shell.closeSheet}
|
||||
/>
|
||||
<MImportSheet planner={planner} open={sheet?.id === 'import'} onClose={shell.closeSheet} />
|
||||
<MPlacesFilterSheet
|
||||
open={sheet?.id === 'placesFilter'}
|
||||
onClose={shell.closeSheet}
|
||||
places={planner.places}
|
||||
categories={planner.categories}
|
||||
toursEnabled={planner.toursEnabled}
|
||||
/>
|
||||
|
||||
{/* ── Planner-flag editors (also serve ?create= and the import review) ── */}
|
||||
<MPlaceEditSheet planner={planner} onOpenExpense={openBookingExpense} />
|
||||
|
||||
@@ -954,39 +954,9 @@ describe('MMapArea and a booked night at the edge of the stage', () => {
|
||||
})
|
||||
|
||||
describe('MMapArea — places filter', () => {
|
||||
it('FE-MOB-MAPAREA-046: the plan map offers the places filter, and a tap opens its sheet', () => {
|
||||
const { shell } = renderArea({ trTab: 'plan' })
|
||||
const pill = screen.getByTestId('places-filter-pill')
|
||||
expect(pill).toHaveAccessibleName('Filters')
|
||||
// It opens a sheet: a dialog to announce, not an on/off state.
|
||||
expect(pill).toHaveAttribute('aria-haspopup', 'dialog')
|
||||
expect(pill).not.toHaveAttribute('aria-pressed')
|
||||
act(() => { pill.click() })
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('placesFilter')
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-047: the pill lights up and counts the filters narrowing the pins', () => {
|
||||
it('FE-MOB-MAPAREA-046: the map has no filter button of its own; the places tab sets the filter its pins follow', () => {
|
||||
seedStore(useTripStore, { placesFilter: 'unplanned', placesRatingFilter: 4 })
|
||||
renderArea({ trTab: 'plan' })
|
||||
const pill = screen.getByTestId('places-filter-pill')
|
||||
expect(pill).toHaveClass('text-accent')
|
||||
expect(pill).not.toHaveAttribute('aria-pressed')
|
||||
expect(pill).toHaveAttribute('aria-haspopup', 'dialog')
|
||||
expect(pill).toHaveAccessibleName('Filters (2)')
|
||||
expect(screen.getByText('2')).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-049: a road trip keeps the places filter on its plan map, Dawarich or not', () => {
|
||||
// The stack used to stand down in road-trip mode unless Dawarich had a pill in
|
||||
// it; the plan map still shows the trip's places there, so its filter stays.
|
||||
renderArea({ trTab: 'plan' }, { roadtripActive: true, dawarichEnabled: false })
|
||||
expect(screen.getByTestId('places-filter-pill')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-048: the stage draws its own pins, so it has no places filter, and neither has a covered map', () => {
|
||||
renderArea({ trTab: 'roadtrip', mapFront: true })
|
||||
expect(screen.queryByTestId('places-filter-pill')).not.toBeInTheDocument()
|
||||
renderArea({ view: 'plan', mapFront: false })
|
||||
expect(screen.queryByTestId('places-filter-pill')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, within } from '../../../helpers/render'
|
||||
import MPlacesFilterSheet from '../../../../src/mobile/screens/trip/places/MPlacesFilterSheet'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { buildPlace } from '../../../helpers/factories'
|
||||
import type { Category, Place } from '../../../../src/types'
|
||||
|
||||
// FE-MOB-PFSHEET-001 to FE-MOB-PFSHEET-009
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 1, name: 'Sights', color: '#123456', icon: 'landmark' },
|
||||
{ id: 2, name: 'Food', color: '', icon: 'utensils' },
|
||||
] as unknown as Category[]
|
||||
|
||||
const TAGGED = buildPlace({ id: 1, category_id: 1 })
|
||||
const BARE = buildPlace({ id: 2, category_id: null })
|
||||
const TRACK = buildPlace({ id: 3, category_id: 2, route_geometry: '[[1,2],[3,4]]' })
|
||||
|
||||
function renderSheet({ places = [TAGGED, BARE], categories = CATEGORIES, open = true, toursEnabled = false }: {
|
||||
places?: Place[]
|
||||
categories?: Category[]
|
||||
open?: boolean
|
||||
toursEnabled?: boolean
|
||||
} = {}) {
|
||||
const onClose = vi.fn()
|
||||
render(<MPlacesFilterSheet open={open} onClose={onClose} places={places} categories={categories} toursEnabled={toursEnabled} />)
|
||||
return { onClose }
|
||||
}
|
||||
|
||||
const group = (name: string) => screen.getByRole('group', { name })
|
||||
|
||||
describe('MPlacesFilterSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
it('FE-MOB-PFSHEET-001: shows the pool, the rating floors and the categories under one title', async () => {
|
||||
renderSheet()
|
||||
const sheet = await screen.findByRole('dialog', { name: 'Filters' })
|
||||
expect(within(sheet).getByText('Show')).toBeInTheDocument()
|
||||
expect(within(group('Show')).getAllByRole('button').map(b => b.textContent)).toEqual(['All', 'Unplanned', 'Planned'])
|
||||
expect(within(group('Filter by rating')).getAllByRole('button').map(b => b.textContent))
|
||||
.toEqual(['All', '5+', '4+', '3+', '2+', '1+'])
|
||||
expect(screen.getByRole('checkbox', { name: 'Sights' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('checkbox', { name: 'Food' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('checkbox', { name: 'No Category' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PFSHEET-002: the tracks pool only shows once a place carries a track', async () => {
|
||||
renderSheet({ places: [TAGGED, TRACK] })
|
||||
await screen.findByRole('dialog', { name: 'Filters' })
|
||||
expect(within(group('Show')).getByRole('button', { name: 'Tracks' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('checkbox', { name: 'No Category' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides Tracks from the map filter while Tours is enabled', async () => {
|
||||
const tourTrack = buildPlace({ id: 4, route_geometry: '[[5,6],[7,8]]' })
|
||||
renderSheet({ places: [TAGGED, TRACK, tourTrack], toursEnabled: true })
|
||||
await screen.findByRole('dialog', { name: 'Filters' })
|
||||
expect(within(group('Show')).queryByRole('button', { name: 'Tracks' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PFSHEET-003: picking a pool writes it to the trip store', async () => {
|
||||
renderSheet()
|
||||
await screen.findByRole('dialog', { name: 'Filters' })
|
||||
const show = group('Show')
|
||||
expect(within(show).getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(within(show).getByRole('button', { name: 'Unplanned' }))
|
||||
expect(useTripStore.getState().placesFilter).toBe('unplanned')
|
||||
// The chosen chip says so to a screen reader, and only that one.
|
||||
expect(within(show).getByRole('button', { name: 'Unplanned' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(within(show).getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('FE-MOB-PFSHEET-004: picking a floor writes the rating filter to the trip store', async () => {
|
||||
renderSheet()
|
||||
await screen.findByRole('dialog', { name: 'Filters' })
|
||||
const floors = group('Filter by rating')
|
||||
fireEvent.click(within(floors).getByRole('button', { name: '3+' }))
|
||||
expect(useTripStore.getState().placesRatingFilter).toBe(3)
|
||||
expect(within(floors).getByRole('button', { name: '3+' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(within(floors).getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('FE-MOB-PFSHEET-005: categories toggle in and out of the shared set, "no category" included', async () => {
|
||||
renderSheet()
|
||||
await screen.findByRole('dialog', { name: 'Filters' })
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Food' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'No Category' }))
|
||||
expect([...useTripStore.getState().placesCategoryFilter]).toEqual(['2', 'uncategorized'])
|
||||
expect(screen.getByRole('checkbox', { name: 'Food' })).toHaveAttribute('aria-checked', 'true')
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Food' }))
|
||||
expect([...useTripStore.getState().placesCategoryFilter]).toEqual(['uncategorized'])
|
||||
})
|
||||
|
||||
it('FE-MOB-PFSHEET-006: reset lifts every filter, and stays off while nothing filters', async () => {
|
||||
seedStore(useTripStore, { placesFilter: 'planned', placesCategoryFilter: new Set(['1']), placesRatingFilter: 5 })
|
||||
renderSheet()
|
||||
await screen.findByRole('dialog', { name: 'Filters' })
|
||||
const reset = screen.getByRole('button', { name: 'Reset' })
|
||||
expect(reset).toBeEnabled()
|
||||
fireEvent.click(reset)
|
||||
const s = useTripStore.getState()
|
||||
expect(s.placesFilter).toBe('all')
|
||||
expect(s.placesCategoryFilter.size).toBe(0)
|
||||
expect(s.placesRatingFilter).toBe('all')
|
||||
expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PFSHEET-007: without any category to choose, the categories section is left out', async () => {
|
||||
renderSheet({ places: [TAGGED], categories: [] })
|
||||
const sheet = await screen.findByRole('dialog', { name: 'Filters' })
|
||||
expect(within(sheet).queryByText('Categories')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PFSHEET-008: the close button hands back to the caller', async () => {
|
||||
const { onClose } = renderSheet()
|
||||
fireEvent.click(within(await screen.findByRole('dialog', { name: 'Filters' })).getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-PFSHEET-009: nothing is rendered while closed', () => {
|
||||
renderSheet({ open: false })
|
||||
expect(screen.queryByRole('dialog', { name: 'Filters' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@ import { useSettingsStore } from '../../../../src/store/settingsStore'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-SHOST-001 to FE-MOB-SHOST-029 and FE-MOB-SHOST-032 to FE-MOB-SHOST-034 (030 and 031 live in MTripSheets.members.test.tsx)
|
||||
//
|
||||
@@ -240,15 +240,8 @@ describe('MTripSheets', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-033: the map\'s places filter opens for its own id and closes through the shell', async () => {
|
||||
const { shell } = renderHost({}, { sheet: { id: 'placesFilter' } })
|
||||
const sheet = await screen.findByRole('dialog', { name: 'Filters' })
|
||||
fireEvent.click(within(sheet).getByRole('button', { name: 'Close' }))
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-033b: no places filter while another sheet is open', () => {
|
||||
renderHost({}, { sheet: { id: 'day' } })
|
||||
it('FE-MOB-SHOST-033: hosts no places filter of its own any more; the places tab carries it', () => {
|
||||
renderHost({}, { sheet: { id: 'placesFilter' } })
|
||||
expect(screen.queryByRole('dialog', { name: 'Filters' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -668,31 +668,25 @@ describe('MTripShell', () => {
|
||||
{ id: 22, day_number: 2, date: todayIso() },
|
||||
{ id: 23, day_number: 3, date: shift(1) },
|
||||
] as unknown as Day[]
|
||||
const jump = () => screen.getByRole('button', { name: 'mobileTrip.jumpToToday' })
|
||||
const jump = () => screen.queryByRole('button', { name: 'mobileTrip.jumpToToday' })
|
||||
|
||||
it('FE-MOB-SHELL-090: marks today and jumps back to it from another day', () => {
|
||||
const { planner, container } = renderShell({ days: running(), selectedDayId: 21 } as Partial<TripPlanner>)
|
||||
it('FE-MOB-SHELL-090: marks today on its chip, and puts no extra button beside the rail', () => {
|
||||
const { container } = renderShell({ days: running(), selectedDayId: 21 } as Partial<TripPlanner>)
|
||||
const marked = container.querySelectorAll('[data-today]')
|
||||
expect(marked).toHaveLength(1)
|
||||
expect(marked[0].className).toContain('ring-inset')
|
||||
expect(marked[0]).toHaveTextContent('mobileTrip.today')
|
||||
|
||||
expect(jump()).toBeEnabled()
|
||||
fireEvent.click(jump())
|
||||
expect(planner.handleSelectDay).toHaveBeenLastCalledWith(22, true)
|
||||
expect(jump()).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-091: with today open the button stays, dimmed, so the rail does not move', () => {
|
||||
it('FE-MOB-SHELL-091: with today open the active chip keeps its own look, the dot says which day is today', () => {
|
||||
const { container } = renderShell({ days: running(), selectedDayId: 22 } as Partial<TripPlanner>)
|
||||
expect(jump()).toBeDisabled()
|
||||
// The active chip keeps its own look; the dot still says which day is today.
|
||||
expect(container.querySelector('[data-today]')?.className).not.toContain('ring-inset')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-092: outside the trip dates there is no today and no button', () => {
|
||||
it('FE-MOB-SHELL-092: outside the trip dates there is no today', () => {
|
||||
const { container } = renderShell()
|
||||
expect(container.querySelector('[data-today]')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'mobileTrip.jumpToToday' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user