mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user