Files
Portfolio/src/composables/useKeyboardNavigation.ts

143 lines
4.2 KiB
TypeScript

import { onMounted, onUnmounted, readonly, shallowRef, type Ref } from "vue";
import type { SectionId } from "@/content/types";
import {
canonicalSectionIdFromHash,
createNavigationController,
validateSectionAnchor,
type NavigationController,
type NavigationState,
} from "@/journey/navigation-controller";
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
const SCROLL_KEYS = new Set([
"PageUp",
"PageDown",
"Home",
"End",
" ",
"Spacebar",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
]);
export const isScrollIntentKey = (key: string): boolean => SCROLL_KEYS.has(key);
const isEditableTarget = (target: EventTarget | null): boolean =>
target instanceof HTMLElement &&
(target.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement);
export interface KeyboardNavigation {
state: Readonly<Ref<NavigationState>>;
onSectionLinkClick(event: MouseEvent): void;
notePassiveSection(id: SectionId): void;
}
export function useKeyboardNavigation(
canonicalIds: readonly SectionId[],
): KeyboardNavigation {
const state = shallowRef<NavigationState>({
epoch: 0,
source: "initial",
destinationId: null,
});
let controller: NavigationController | undefined;
let teardown = (): void => {};
const restoreCurrentFragment = (
source: "initial" | "history-restoration",
): void => {
if (controller === undefined) return;
const id = canonicalSectionIdFromHash(window.location.hash, canonicalIds);
if (id === null) return;
const target = document.getElementById(id);
if (!(target instanceof HTMLElement)) return;
if (source === "initial") controller.initialize(id, target);
else controller.restore(id, target);
};
const onSectionLinkClick = (event: MouseEvent): void => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
controller === undefined ||
!(event.currentTarget instanceof HTMLAnchorElement)
) {
return;
}
const validated = validateSectionAnchor(event.currentTarget, canonicalIds);
if (validated === null) return;
event.preventDefault();
controller.navigate(validated.id, validated.target);
};
onMounted(() => {
controller = createNavigationController(canonicalIds);
const unsubscribe = controller.subscribe((nextState) => {
state.value = nextState;
});
const noteScrollIntent = (): void => {
controller?.noteUserScrollIntent();
};
const handleKeydown = (event: KeyboardEvent): void => {
if (
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
isEditableTarget(event.target) ||
!isScrollIntentKey(event.key)
) {
return;
}
noteScrollIntent();
};
const handlePopstate = (): void =>
restoreCurrentFragment("history-restoration");
const handlePageshow = (event: PageTransitionEvent): void => {
if (event.persisted) restoreCurrentFragment("history-restoration");
};
window.addEventListener("wheel", noteScrollIntent, PASSIVE_LISTENER);
window.addEventListener("touchmove", noteScrollIntent, PASSIVE_LISTENER);
window.addEventListener("keydown", handleKeydown);
window.addEventListener("popstate", handlePopstate);
window.addEventListener("pageshow", handlePageshow);
if (window.location.hash !== "") restoreCurrentFragment("initial");
teardown = (): void => {
window.removeEventListener("wheel", noteScrollIntent, PASSIVE_LISTENER);
window.removeEventListener(
"touchmove",
noteScrollIntent,
PASSIVE_LISTENER,
);
window.removeEventListener("keydown", handleKeydown);
window.removeEventListener("popstate", handlePopstate);
window.removeEventListener("pageshow", handlePageshow);
unsubscribe();
controller?.dispose();
controller = undefined;
};
});
onUnmounted(() => teardown());
return {
state: readonly(state),
onSectionLinkClick,
notePassiveSection(id) {
controller?.notePassiveSection(id);
},
};
}