feat: route-map navigation, persisted progress with a resume point, Alt+arrow exit keys and a trip log

This commit is contained in:
2026-10-10 18:57:03 +03:00
parent e2f2ed6b9b
commit e5c9f01818
5 changed files with 221 additions and 14 deletions

View File

@@ -1,11 +1,13 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import type { SectionId } from "@/content/types";
import type { JourneySnapshot } from "@/journey/types";
const props = defineProps<{
items: ReadonlyArray<{ id: SectionId; label: string }>;
journey: JourneySnapshot;
/** Exits passed in this browser (client-only memory). */
visited?: ReadonlySet<SectionId>;
allowMotion?: boolean;
onSectionLinkClick: (event: MouseEvent, requestSign?: boolean) => void;
}>();
@@ -22,6 +24,35 @@ const navigationLabels: Readonly<Record<SectionId, string>> = {
};
const disclosure = ref<HTMLDetailsElement | null>(null);
const list = ref<HTMLUListElement | null>(null);
// The route map: the road fills up to the current exit. It changes only on
// chapter change, never per scroll sample.
const activeIndex = computed(() =>
Math.max(0, props.items.findIndex((item) => item.id === props.journey.activeSectionId)),
);
const exitNumber = (index: number): string => String(index + 1).padStart(2, "0");
const stateFor = (index: number, id: SectionId): string =>
index === activeIndex.value
? "here"
: index < activeIndex.value || props.visited?.has(id) === true
? "passed"
: "ahead";
/** Alt+ArrowDown / Alt+ArrowUp drive to the next or previous exit. Modified
* arrow keys are not character keys, so they cannot fire by accident while
* typing or with speech input (WCAG 2.1.4), and editable fields are left alone. */
const onKeydown = (event: KeyboardEvent): void => {
if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
const target = event.target as HTMLElement | null;
if (target?.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]')) return;
const step = event.key === "ArrowDown" ? 1 : -1;
const next = Math.min(props.items.length - 1, Math.max(0, activeIndex.value + step));
if (next === activeIndex.value) return;
event.preventDefault();
list.value?.querySelectorAll<HTMLAnchorElement>("a")[next]?.click();
};
const enhanced = ref(false);
const docked = ref(false);
const restScale = ref(1);
@@ -49,6 +80,7 @@ onMounted(() => {
measureRestWidth();
enhanced.value = true;
window.addEventListener('resize', measureRestWidth, { passive: true });
window.addEventListener('keydown', onKeydown);
compactQuery = window.matchMedia("(max-width: 767px)");
syncDisclosureToViewport(compactQuery);
compactQuery.addEventListener("change", syncDisclosureToViewport);
@@ -56,6 +88,7 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('resize', measureRestWidth);
window.removeEventListener('keydown', onKeydown);
compactQuery?.removeEventListener("change", syncDisclosureToViewport);
compactQuery = null;
});
@@ -68,11 +101,11 @@ const navigate = (event: MouseEvent): void => {
</script>
<template>
<nav class="site-navigation" aria-label="Portfolio sections" :data-enhanced="enhanced" :data-docked="docked" :data-motion="allowMotion === true" :style="{ '--nav-rest-scale': restScale }">
<nav class="site-navigation" aria-label="Portfolio sections" :data-enhanced="enhanced" :data-docked="docked" :data-motion="allowMotion === true" :style="{ '--nav-rest-scale': restScale, '--route-index': activeIndex, '--route-count': items.length }">
<details ref="disclosure" class="site-navigation__disclosure" open>
<summary>Navigate</summary>
<ul>
<li v-for="item in items" :key="item.id">
<summary :data-exit="exitNumber(activeIndex)" :data-label="navigationLabels[items[activeIndex]?.id ?? 'start']">Navigate</summary>
<ul ref="list">
<li v-for="(item, index) in items" :key="item.id" :data-route="stateFor(index, item.id)">
<a
:href="`#${item.id}`"
:aria-current="