feat(tours): bike tours in the planner, CyclOSM, and the planned cards' last points (BIK-01 to BIK-06, RTE-01, IO-01, LIB-02)

The tour type is a dropdown; a bike tour picks its profile, routes for it,
says when a point lies off every way, and shows what it runs on: surfaces,
kinds of way and, for a mountain bike, the trail grades, each pointed at on
the map. A type's own map (OpenHikingMap for a hike, CyclOSM for a bike tour)
is offered while a tour of that type is open, with an admin switch each.

From the planned cards: a hike avoids steep climbs, steps or ferries, nothing
else; a saved tour can be shared where the platform takes GPX files; and the
tours rail adds a tour from one menu: plan new, import GPX, or take one over
from another trip.
This commit is contained in:
Maurice
2026-10-19 21:33:49 +02:00
parent adcaca3c5f
commit 73237ffedb
85 changed files with 3728 additions and 647 deletions
+34 -75
View File
@@ -1,10 +1,10 @@
import { useEffect, useId, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown, Plus } from 'lucide-react'
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
import { POPOVER } from '../Packing/packingPopoverStyles'
import { PopoverItem } from '../Packing/PackingPopover'
import { PILL, fs } from './DialogShell'
import { usePopoverList } from './usePopoverList'
// The editors' fields in the cards' language: framed white boxes under small
// Geist eyebrows. The sizes are classes, not fs() styles, so components that
@@ -94,8 +94,6 @@ export interface PillOption<T extends string> {
hint?: string
}
const itemsOf = (list: HTMLElement | null) => Array.from(list?.querySelectorAll('button') ?? [])
/**
* A head band pill that opens a list to pick from, such as the booking type.
* Opening moves the focus onto the current choice; the arrow keys walk the
@@ -116,71 +114,14 @@ export function PillSelect<T extends string>({ value, options, onChange, label,
/** In the body of a dialog instead of on its head band. */
quiet?: boolean
}) {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const startAt = useRef(0)
const listId = useId()
const box = useAnchoredPosition(triggerRef, open, { matchWidth: false, estimatedHeight: 320 })
const list = usePopoverList(options.findIndex(o => o.value === value))
const { open, toggle, triggerRef, listId } = list
const current = options.find(o => o.value === value)
const shown = current ?? fallback
const listShown = open && !!box
const toggle = () => {
if (!open) startAt.current = Math.max(0, options.findIndex(o => o.value === value))
setOpen(o => !o)
}
useEffect(() => {
if (listShown) itemsOf(menuRef.current)[startAt.current]?.focus({ preventScroll: true })
}, [listShown])
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => {
const target = e.target as Node
if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return
setOpen(false)
}
// Captured and stopped, so the key takes back the list and not the dialog under it.
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.stopPropagation()
setOpen(false)
triggerRef.current?.focus()
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey, true)
}
}, [open])
const pick = (next: T) => {
setOpen(false)
list.close()
onChange(next)
triggerRef.current?.focus()
}
const onListKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Tab') {
// Back on the pill before the browser moves on, so Tab carries on from there.
setOpen(false)
triggerRef.current?.focus()
return
}
const items = itemsOf(menuRef.current)
const at = items.indexOf(document.activeElement as HTMLButtonElement)
const last = items.length - 1
let next: number
if (e.key === 'ArrowDown') next = at < 0 || at === last ? 0 : at + 1
else if (e.key === 'ArrowUp') next = at <= 0 ? last : at - 1
else if (e.key === 'Home') next = 0
else if (e.key === 'End') next = last
else return
e.preventDefault()
items[next]?.focus()
}
return (
@@ -193,21 +134,39 @@ export function PillSelect<T extends string>({ value, options, onChange, label,
{shown?.hint && <span className="font-medium text-content-muted">{shown.hint}</span>}
<ChevronDown size={12} strokeWidth={2.2} className={`-me-0.5 flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && box && createPortal(
<div ref={menuRef} id={listId} role="group" aria-label={label} onKeyDown={onListKey}
className="fixed z-[var(--z-toast)] min-w-[200px] overflow-y-auto"
style={{ ...POPOVER, top: box.top, bottom: box.bottom, left: box.left, maxHeight: box.maxHeight }}>
{options.map(o => (
<PopoverItem key={o.value} icon={o.icon ?? null} label={o.label} active={o.value === value} onClick={() => pick(o.value)}
trailing={o.hint ? <span className="font-geist text-content-faint" style={fs(11)}>{o.hint}</span> : undefined} />
))}
</div>,
document.body,
)}
<PopoverListPanel list={list} label={label} className="min-w-[200px]">
{options.map(o => (
<PopoverItem key={o.value} icon={o.icon ?? null} label={o.label} active={o.value === value} onClick={() => pick(o.value)}
trailing={o.hint ? <span className="font-geist text-content-faint" style={fs(11)}>{o.hint}</span> : undefined} />
))}
</PopoverListPanel>
</>
)
}
/**
* The open list of a `usePopoverList` button, in the popover look, laid over
* the page beside its button: where the button sits decides where it opens.
*/
export function PopoverListPanel({ list, label, className = '', children }: {
list: ReturnType<typeof usePopoverList>
/** The list's name for a screen reader. */
label: string
className?: string
children: ReactNode
}) {
const { open, box, menuRef, listId, onListKey } = list
if (!open || !box) return null
return createPortal(
<div ref={menuRef} id={listId} role="group" aria-label={label} onKeyDown={onListKey}
className={`fixed z-[var(--z-toast)] overflow-y-auto ${className}`}
style={{ ...POPOVER, top: box.top, bottom: box.bottom, left: box.left, maxHeight: box.maxHeight }}>
{children}
</div>,
document.body,
)
}
/** The dashed full-width button that adds a row: a stop, a leg. */
export function AddRowButton({ onClick, disabled, children }: { onClick: () => void; disabled?: boolean; children: ReactNode }) {
return (
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
import { PopoverListPanel } from './dialogParts';
import { usePopoverList } from './usePopoverList';
// FE-POPOVER-LIST-001 to FE-POPOVER-LIST-006
function Picker({ current = 1, onPick = vi.fn() }: { current?: number; onPick?: (index: number) => void }) {
const list = usePopoverList(current);
return (
<>
<button type="button" ref={list.triggerRef} onClick={list.toggle} aria-expanded={list.open}>
trigger
</button>
<PopoverListPanel list={list} label="choices">
{['one', 'two', 'three'].map((name, index) => (
<button
key={name}
type="button"
onClick={() => {
onPick(index);
list.close();
}}
>
{name}
</button>
))}
</PopoverListPanel>
</>
);
}
const trigger = () => screen.getByRole('button', { name: 'trigger' });
afterEach(() => vi.restoreAllMocks());
describe('usePopoverList', () => {
it('FE-POPOVER-LIST-001: opening puts the focus on the current choice', () => {
render(<Picker current={1} />);
fireEvent.click(trigger());
expect(trigger()).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('group', { name: 'choices' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'two' })).toHaveFocus();
});
it('FE-POPOVER-LIST-002: the arrows walk round the list, Home and End go to its ends', () => {
render(<Picker current={0} />);
fireEvent.click(trigger());
const group = screen.getByRole('group');
fireEvent.keyDown(group, { key: 'ArrowUp' });
expect(screen.getByRole('button', { name: 'three' })).toHaveFocus();
fireEvent.keyDown(group, { key: 'ArrowDown' });
expect(screen.getByRole('button', { name: 'one' })).toHaveFocus();
fireEvent.keyDown(group, { key: 'End' });
expect(screen.getByRole('button', { name: 'three' })).toHaveFocus();
fireEvent.keyDown(group, { key: 'Home' });
expect(screen.getByRole('button', { name: 'one' })).toHaveFocus();
fireEvent.keyDown(group, { key: 'x' });
expect(screen.getByRole('button', { name: 'one' })).toHaveFocus();
});
it('FE-POPOVER-LIST-003: Escape closes the list only, and gives the focus back to the button', () => {
const outer = vi.fn();
document.addEventListener('keydown', outer);
render(<Picker />);
fireEvent.click(trigger());
fireEvent.keyDown(screen.getByRole('button', { name: 'two' }), { key: 'Escape' });
expect(screen.queryByRole('group')).toBeNull();
expect(trigger()).toHaveFocus();
expect(outer).not.toHaveBeenCalled();
document.removeEventListener('keydown', outer);
});
it('FE-POPOVER-LIST-004: Tab closes it and goes on from the button; a click beside it closes it', () => {
render(<Picker />);
fireEvent.click(trigger());
fireEvent.keyDown(screen.getByRole('group'), { key: 'Tab' });
expect(screen.queryByRole('group')).toBeNull();
expect(trigger()).toHaveFocus();
fireEvent.click(trigger());
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('group')).toBeNull();
});
it('FE-POPOVER-LIST-005: a choice closes the list and hands the focus back', () => {
const onPick = vi.fn();
render(<Picker onPick={onPick} />);
fireEvent.click(trigger());
fireEvent.click(screen.getByRole('button', { name: 'three' }));
expect(onPick).toHaveBeenCalledWith(2);
expect(screen.queryByRole('group')).toBeNull();
expect(trigger()).toHaveFocus();
});
it('FE-POPOVER-LIST-006: a list that would run past the window moves back inside it, and stays where it fits', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
const isList = this.getAttribute('role') === 'group';
const left = isList ? window.innerWidth - 100 : window.innerWidth - 40;
return { left, right: left + 240, top: 10, bottom: 40, width: 240, height: 30, x: left, y: 10 } as DOMRect;
});
render(<Picker />);
act(() => fireEvent.click(trigger()));
// 240 wide from 100 short of the edge: 140 past it, and the 8 kept clear of it.
expect(screen.getByRole('group').style.translate).toBe('-148px 0');
});
});
@@ -0,0 +1,98 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition';
const itemsOf = (list: HTMLElement | null) => Array.from(list?.querySelectorAll('button') ?? []);
/** Room kept between an open list and the window's sides. */
const EDGE = 8;
/**
* How a button that opens a list to pick from behaves, whatever it looks like:
* opening moves the focus onto the current choice; the arrow keys, Home and End
* walk the list; Escape closes it and leaves a dialog under it open; Tab closes
* it and goes on from the button; a click beside it closes it. The caller draws
* the button and the list and hands `currentIndex`, the choice held now.
*/
export function usePopoverList(currentIndex: number, options: { estimatedHeight?: number } = {}) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const startAt = useRef(0);
const listId = useId();
const box = useAnchoredPosition(triggerRef, open, {
matchWidth: false,
estimatedHeight: options.estimatedHeight ?? 320,
});
const listShown = open && !!box;
const toggle = () => {
if (!open) startAt.current = Math.max(0, currentIndex);
setOpen((o) => !o);
};
useEffect(() => {
if (listShown) itemsOf(menuRef.current)[startAt.current]?.focus({ preventScroll: true });
}, [listShown]);
// The list opens from its button's start; one wider than the room beside a button near the
// window's side moves back inside, as far as it must, before it is painted.
useLayoutEffect(() => {
const menu = menuRef.current;
if (!listShown || !menu) return;
menu.style.translate = '';
const rect = menu.getBoundingClientRect();
let shift = Math.min(0, window.innerWidth - EDGE - rect.right);
if (rect.left + shift < EDGE) shift = EDGE - rect.left;
if (shift) menu.style.translate = `${Math.round(shift)}px 0`;
}, [listShown, box]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Node;
if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return;
setOpen(false);
};
// Captured and stopped, so the key takes back the list and not the dialog under it.
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.stopPropagation();
setOpen(false);
triggerRef.current?.focus();
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey, true);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey, true);
};
}, [open]);
/** Closes the list after a choice and hands the focus back to the button. */
const close = () => {
setOpen(false);
triggerRef.current?.focus();
};
const onListKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Tab') {
// Back on the button before the browser moves on, so Tab carries on from there.
setOpen(false);
triggerRef.current?.focus();
return;
}
const items = itemsOf(menuRef.current);
const at = items.indexOf(document.activeElement as HTMLButtonElement);
const last = items.length - 1;
let next: number;
if (e.key === 'ArrowDown') next = at < 0 || at === last ? 0 : at + 1;
else if (e.key === 'ArrowUp') next = at <= 0 ? last : at - 1;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = last;
else return;
e.preventDefault();
items[next]?.focus();
};
return { open, box, toggle, close, onListKey, triggerRef, menuRef, listId };
}