feat: coordinate sections signs and native navigation
This commit is contained in:
142
src/composables/useKeyboardNavigation.ts
Normal file
142
src/composables/useKeyboardNavigation.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user