fix(mobile): the tour tab's rail heads are rounded bands like the cards under them

This commit is contained in:
Maurice
2026-11-08 11:50:25 +01:00
parent eafb30555c
commit cb41f08199
5 changed files with 92 additions and 38 deletions
@@ -7,7 +7,8 @@ import { useSettingsStore } from '../../../store/settingsStore'
import { useTourFerrataCrossingStore } from '../../../store/tourFerrataCrossingStore'
import { formatDistance } from '../../../utils/units'
import ConfirmDialog from '../../shared/ConfirmDialog'
import { NEUTRAL_TINT, fs } from '../../shared/DialogShell'
import { fs } from '../../shared/DialogShell'
import { useRailHeadProps } from './railHead'
import EmptyState from '../../shared/EmptyState'
import { useToast } from '../../shared/Toast'
import { Tooltip } from '../../shared/Tooltip'
@@ -74,9 +75,6 @@ import { SkiPlannerSummary } from '../ski/SkiRouteSummary'
import { walkTimeFields } from '../walk/walkTimeFields'
/** The tinted head band both rails open with, like every panel head in the planner. */
export const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-faint px-3 py-2.5'
/** The head as a rounded band of its own, inset like the controls above it (the touch shell's tours tab). */
const FLOATING_RAIL_HEAD = 'mx-4 mt-1 flex flex-none items-start gap-2 rounded-xl border border-edge-faint px-3 py-2'
/** The title of a rail's head band, with an optional line under it. */
export function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; aside?: ReactNode }) {
@@ -138,10 +136,11 @@ function NeutralRail({
canEdit: boolean
onOpenWanderer?: () => void
}) {
const railHead = useRailHeadProps()
const { t } = useTranslation()
return (
<section className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.title')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<div {...railHead}>
<button
type="button"
disabled={!canEdit}
@@ -209,6 +208,7 @@ function GpxViewRail({
paddleChecklist?: PaddleChecklist
skiClassification?: SkiClassification
}) {
const railHead = useRailHeadProps()
const { t } = useTranslation()
const distanceUnit = useSettingsStore((state) => state.settings.distance_unit)
const { readOnlyGpxTour } = planner
@@ -228,7 +228,7 @@ function GpxViewRail({
return (
<>
<section className="flex h-full min-h-0 flex-col" aria-label={tour.name}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<div {...railHead}>
<BarButton
label={t('tours.planner.backToTours')}
onClick={() => planner.returnToNeutral({ keepView: true })}
@@ -330,6 +330,7 @@ export function TourPlannerRail({ planner,
/** The trip's tours, never offered as a stop of another tour (PR1-10). */
tourPlaceIds?: ReadonlySet<number>
} & TourPermissionProps) {
const railHead = useRailHeadProps()
const { canEdit } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
const { t } = useTranslation()
const [confirmDiscard, setConfirmDiscard] = useState(false)
@@ -366,7 +367,7 @@ export function TourPlannerRail({ planner,
return (
<>
<section className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.title')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<div {...railHead}>
<BarButton label={t('tours.planner.backToTours')} onClick={() => requestClose()} disabled={isSaving} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
<ArrowLeft size={16} strokeWidth={2} />
</BarButton>
@@ -568,8 +569,6 @@ interface TourPlannerToursRailProps extends TourPermissionProps {
gpxImport?: TourGpxImport
/** LIB-02: takes a tour of another trip in, an entry of "Add tour". */
onTakenOver?: (tour: TourListItem) => Promise<void>
/** The head as a rounded band rather than a ruled one (the touch shell's tours tab). */
floatingHead?: boolean
}
/** The words of the time estimate: walking, riding (BIK-01), running (RUN-01) or paddling, each saying how it is reckoned, and a ride none. */
@@ -676,7 +675,8 @@ function stagesFold(tour: TourListItem, rail: ReturnType<typeof useTourStageRail
}
export function TourPlannerToursRail({ planner, tours, days, loading, onAssignToDay, onViewGpxTour, onDeleteTour,
gpxImport, onTakenOver, canEdit: editPermission, canAssign: assignPermission, floatingHead = false }: TourPlannerToursRailProps) {
gpxImport, onTakenOver, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) {
const railHead = useRailHeadProps()
const { canEdit, canAssign } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
const { t } = useTranslation()
const toast = useToast()
@@ -738,7 +738,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
return (
<aside className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.tripTours')}>
<div className={floatingHead ? FLOATING_RAIL_HEAD : RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<div {...railHead}>
<RailTitle title={t('tours.planner.tripTours')} />
{canEdit && <TourAddMenu planner={planner} gpxImport={gpxImport} onTakenOver={onTakenOver} disabled={isSaving} />}
</div>
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../../tests/helpers/render';
import { FloatingRailHeads, RAIL_HEAD, useRailHeadProps } from './railHead';
function Head() {
return <div data-testid="head" {...useRailHeadProps()} />;
}
describe('rail heads', () => {
it('FE-TOUR-RAILHEAD-001: a ruled, tinted strip on the desktop, a rounded card-like band in the touch tab', () => {
const { unmount } = render(<Head />);
expect(screen.getByTestId('head').className).toBe(RAIL_HEAD);
expect(screen.getByTestId('head').getAttribute('style')).toContain('background');
unmount();
render(
<FloatingRailHeads value>
<Head />
</FloatingRailHeads>
);
const head = screen.getByTestId('head');
expect(head.className).toContain('rounded-2xl');
expect(head.className).not.toContain('border-b');
expect(head.getAttribute('style')).toBeNull();
});
});
@@ -0,0 +1,23 @@
import { createContext, use } from 'react';
import { NEUTRAL_TINT } from '../../shared/DialogShell';
/** The head band of a tours rail: ruled off from the rail under it, as the desktop planner shows it. */
export const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-faint px-3 py-2.5';
/** The head as a rounded band of its own, inset and shaped like the cards under it. */
const FLOATING_RAIL_HEAD =
'mx-3 mt-3 flex flex-none items-start gap-2 rounded-2xl border border-edge-faint bg-surface-secondary px-3 py-2';
/**
* Set by the touch shell's tours tab: there each rail's head (the trip's tours,
* a new or saved tour, a GPX track, the wanderer trails) is a rounded band like
* the cards below it rather than a ruled strip across the pane.
*/
export const FloatingRailHeads = createContext(false);
/** The class and style of a rail's head band, as the surrounding tab wants it. */
export function useRailHeadProps(): { className: string; style?: { background: string } } {
return use(FloatingRailHeads)
? { className: FLOATING_RAIL_HEAD }
: { className: RAIL_HEAD, style: { background: NEUTRAL_TINT } };
}
@@ -24,10 +24,11 @@ import { useSettingsStore } from '../../../store/settingsStore';
import { formatDisplayDate } from '../../../utils/dateFormat';
import { formatDistance, formatElevation } from '../../../utils/units';
import { BarButton, WhiteBadge } from '../../Planner/planParts';
import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell';
import { DialogButton, fs } from '../../shared/DialogShell';
import EmptyState from '../../shared/EmptyState';
import { Tooltip } from '../../shared/Tooltip';
import { RAIL_HEAD, RailTitle } from '../planner/TourPlannerPanels';
import { useRailHeadProps } from '../planner/railHead';
import { RailTitle } from '../planner/TourPlannerPanels';
import { TourDayRowAvatar } from '../TourDayRowFacts';
import { TourListCard, TourMetricFields, TourNotice, TourSection } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
@@ -40,11 +41,12 @@ import WandererMark from './WandererMark';
* The trail itself shows on the map between the rails.
*/
export default function WandererBrowserRail({ browser }: { browser: WandererBrowser }) {
const railHead = useRailHeadProps();
const { t } = useTranslation();
const inPreview = browser.selected !== null;
return (
<section className="flex h-full min-h-0 flex-col" aria-label={t('wanderer.browser.title')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<div {...railHead}>
<BarButton
label={inPreview ? t('wanderer.browser.backToList') : t('tours.planner.backToTours')}
onClick={inPreview ? browser.backToList : browser.close}
@@ -4,6 +4,7 @@ import { createPortal } from 'react-dom';
import TourGpxDropZone from '../../../../components/Tours/TourGpxDropZone';
import TourWorkspaceRail from '../../../../components/Tours/TourWorkspaceRail';
import { TourPlannerToursRail } from '../../../../components/Tours/planner/TourPlannerPanels';
import { FloatingRailHeads } from '../../../../components/Tours/planner/railHead';
import { useIsSplit } from '../../../../hooks/sizeClass';
import { useTouchDragBridge } from '../../../../hooks/useTouchDragBridge';
import { LazyPanel } from '../../../../pages/tripPlanner/LazyPanel';
@@ -105,30 +106,32 @@ export default function MTourWorkspace({ planner }: Pick<MTripTabPanelProps, 'pl
className="flex h-full min-w-0 flex-1 flex-col split:w-[var(--trip-pane-open)] split:flex-none split:border-e split:border-[color:var(--m-rowbr)]"
>
{!headSlot && <div className="flex items-center gap-2 px-4 pb-2 pt-1">{head}</div>}
<div className="relative min-h-0 flex-1 overflow-hidden pb-[env(safe-area-inset-bottom,0px)]">
{pane === 'tours' ? (
<LazyPanel id="m-tour-tours">
<TourPlannerToursRail
planner={tourPlanner}
canEdit={canPlaceEdit}
canAssign={canDayEdit}
tours={planner.tours}
days={planner.days}
loading={planner.toursLoading}
onAssignToDay={planner.handleAssignToDay}
onDeleteTour={planner.handleDeleteTour}
gpxImport={tourGpxImport}
onViewGpxTour={page.viewImportedTour}
onTakenOver={tourDetailActions.onTakenOver}
floatingHead
/>
</LazyPanel>
) : (
<LazyPanel id="m-tour-editor">
<TourWorkspaceRail workspace={page} canEdit={canPlaceEdit} canAssign={canDayEdit} />
</LazyPanel>
)}
</div>
{/* Every rail head here is a rounded band like the cards under it. */}
<FloatingRailHeads value>
<div className="relative min-h-0 flex-1 overflow-hidden pb-[env(safe-area-inset-bottom,0px)]">
{pane === 'tours' ? (
<LazyPanel id="m-tour-tours">
<TourPlannerToursRail
planner={tourPlanner}
canEdit={canPlaceEdit}
canAssign={canDayEdit}
tours={planner.tours}
days={planner.days}
loading={planner.toursLoading}
onAssignToDay={planner.handleAssignToDay}
onDeleteTour={planner.handleDeleteTour}
gpxImport={tourGpxImport}
onViewGpxTour={page.viewImportedTour}
onTakenOver={tourDetailActions.onTakenOver}
/>
</LazyPanel>
) : (
<LazyPanel id="m-tour-editor">
<TourWorkspaceRail workspace={page} canEdit={canPlaceEdit} canAssign={canDayEdit} />
</LazyPanel>
)}
</div>
</FloatingRailHeads>
</section>
)}
{showMap && (