The sign closes in over the road on a gantry truss, arcs into a wider window and hands off to a flat 2D plate that mirrors its face; content steps aside for the interlude and rises after. The bay keeps sound and settings; volume and Pause motion move into the settings popover.
164 lines
6.5 KiB
Vue
164 lines
6.5 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
import type { SectionId } from "@/content/types";
|
|
import type { JourneySnapshot } from "@/journey/types";
|
|
import { SIGN_ACCENTS } from "@/journey/sign-routes";
|
|
|
|
const props = defineProps<{
|
|
items: ReadonlyArray<{ id: SectionId; label: string }>;
|
|
journey: JourneySnapshot;
|
|
/** Exits passed in this browser (client-only memory). */
|
|
visited?: ReadonlySet<SectionId>;
|
|
allowMotion?: boolean;
|
|
/** A returning visitor's last exit: the window offers it from the start. */
|
|
resumeId?: SectionId | undefined;
|
|
onSectionLinkClick: (event: MouseEvent, requestSign?: boolean) => void;
|
|
}>();
|
|
|
|
const navigationLabels: Readonly<Record<SectionId, string>> = {
|
|
start: "Start",
|
|
profile: "Profile",
|
|
experience: "Career",
|
|
infrastructure: "Infra",
|
|
"selected-builds": "Builds",
|
|
"additional-work": "More work",
|
|
toolkit: "Toolkit",
|
|
contact: "Contact",
|
|
};
|
|
|
|
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);
|
|
let compactQuery: MediaQueryList | null = null;
|
|
|
|
// Reuse the journey's existing scroll sample; no second scroll/render loop.
|
|
watch(() => props.journey.scrollY, y => {
|
|
if (y > 24) docked.value = true;
|
|
else if (y <= 6) docked.value = false;
|
|
}, { immediate: true });
|
|
|
|
const measureRestWidth = (): void => {
|
|
const width = document.documentElement.clientWidth;
|
|
if (width > 0) restScale.value = Math.min(1400, Math.max(0, width - 48)) / width;
|
|
};
|
|
|
|
const syncDisclosureToViewport = ({
|
|
matches,
|
|
}: Pick<MediaQueryList, "matches">): void => {
|
|
if (matches) disclosure.value?.removeAttribute("open");
|
|
else disclosure.value?.setAttribute("open", "");
|
|
};
|
|
|
|
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);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener('resize', measureRestWidth);
|
|
window.removeEventListener('keydown', onKeydown);
|
|
compactQuery?.removeEventListener("change", syncDisclosureToViewport);
|
|
compactQuery = null;
|
|
});
|
|
|
|
/** The gantry's sign window drives on to the next exit, resumes a returning
|
|
* visitor's drive from the start, or goes back to the start from the
|
|
* destination, exactly like following that exit's link. */
|
|
const resumeIndex = computed(() =>
|
|
props.resumeId === undefined || activeIndex.value !== 0
|
|
? -1
|
|
: props.items.findIndex((item) => item.id === props.resumeId),
|
|
);
|
|
const nextIndex = computed(() => {
|
|
if (resumeIndex.value > 0) return resumeIndex.value;
|
|
return activeIndex.value >= props.items.length - 1 ? 0 : activeIndex.value + 1;
|
|
});
|
|
const windowLabel = computed(() => {
|
|
const next = props.items[nextIndex.value];
|
|
if (next === undefined) return "Drive on";
|
|
if (resumeIndex.value > 0)
|
|
return `Resume the drive at ${next.label}, exit ${exitNumber(nextIndex.value)}`;
|
|
return nextIndex.value === 0
|
|
? `Drive again from the start: ${next.label}`
|
|
: `Drive to the next exit: ${next.label}`;
|
|
});
|
|
/** Decorative hover line under the window (CSS generated content). */
|
|
const windowHint = computed(() => {
|
|
if (resumeIndex.value > 0) return `RESUME EXIT ${exitNumber(nextIndex.value)} \u2192`;
|
|
return nextIndex.value === 0
|
|
? "DRIVE AGAIN \u21ba"
|
|
: `DRIVE TO EXIT ${exitNumber(nextIndex.value)} \u2192`;
|
|
});
|
|
const driveOn = (): void => {
|
|
list.value?.querySelectorAll<HTMLAnchorElement>("a")[nextIndex.value]?.click();
|
|
};
|
|
|
|
const navigate = (event: MouseEvent): void => {
|
|
props.onSectionLinkClick(event, true);
|
|
if (window.matchMedia("(max-width: 767px)").matches)
|
|
disclosure.value?.removeAttribute("open");
|
|
};
|
|
</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, '--route-index': activeIndex, '--route-count': items.length, '--sign-accent': SIGN_ACCENTS[items[activeIndex]?.id ?? 'start'] }">
|
|
<details ref="disclosure" class="site-navigation__disclosure" open>
|
|
<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="
|
|
props.journey.activeSectionId === item.id ? 'page' : undefined
|
|
"
|
|
@click="navigate"
|
|
>{{ navigationLabels[item.id] }}</a
|
|
>
|
|
</li>
|
|
</ul>
|
|
</details>
|
|
<button
|
|
v-if="enhanced"
|
|
type="button"
|
|
class="gantry-window"
|
|
:aria-label="windowLabel"
|
|
@click="driveOn"
|
|
><span class="gantry-window__cone" aria-hidden="true"></span><span class="gantry-window__hint" aria-hidden="true" :data-hint="windowHint"></span></button>
|
|
</nav>
|
|
</template>
|