presentation/widgets/reference.widget.ts
presentation/widgets/reference.widget.ts is a file in Bane's Lab Site. 188 lines of code and 30 definitions.
import { DESCRIBED_BY_ATTRIBUTE, HREF_ATTRIBUTE, NONE, OPEN_CLASS } from "#configuration/constants/element.constants";
import {
MILLISECONDS,
REFERENCE_CHAIN_MS,
REFERENCE_CHAIN_PROPERTY,
REFERENCE_CLASS,
REFERENCE_HIDE_MS,
REFERENCE_LINK_SELECTOR,
REFERENCE_SHOW_MS,
TOUCH_POINTER,
} from "#configuration/constants/reference.constants";
import { REFERENCE_PANEL_LABEL, REFERENCE_ROLE } from "#configuration/strings/reference.strings";
import type { ReferenceContent, ReferenceTarget } from "#types/reference.types";
import {
armLink,
createChapterPanel,
createRecordPanel,
placePanel,
} from "#presentation/components/reference.component";
import { clearChildren, createElement } from "#core/factories/element.factory";
import type { Disposer } from "#types/base.types";
import { ESCAPE_KEY } from "#configuration/constants/overlay.constants";
import { REFERENCE_PANEL_ID } from "#core/ids/reference.ids";
import { declareStyle } from "#core/registries/style.registry";
import { loadReference } from "#domain/loaders/reference.loader";
import { targetOfHref } from "#domain/converters/reference.converter";
interface Anchored {
readonly anchor: HTMLAnchorElement;
readonly href: string;
readonly target: ReferenceTarget;
}
const linkOf = function linkOf(node: EventTarget | null): HTMLAnchorElement | null {
return node instanceof Element ? node.closest<HTMLAnchorElement>(REFERENCE_LINK_SELECTOR) : null;
};
const anchoredOf = function anchoredOf(node: EventTarget | null): Anchored | null {
const anchor = linkOf(node);
const href = anchor?.getAttribute(HREF_ATTRIBUTE) ?? NONE;
const target = href.length === 0 ? null : targetOfHref(href);
return anchor === null || target === null ? null : { anchor, href, target };
};
const isInside = function isInside(container: Node, node: EventTarget | null): boolean {
return node instanceof Node && container.contains(node);
};
const relatedOf = function relatedOf(event: Event): EventTarget | null {
return event instanceof MouseEvent || event instanceof FocusEvent ? event.relatedTarget : null;
};
const panelsOf = function panelsOf(content: ReferenceContent): readonly HTMLElement[] {
return content.kind === "record" ? createRecordPanel(content.record) : createChapterPanel(content.chapter);
};
export const mountReference = function mountReference(): Disposer {
const panel = createElement("div", {
attributes: { "aria-label": REFERENCE_PANEL_LABEL, id: REFERENCE_PANEL_ID, role: REFERENCE_ROLE },
className: REFERENCE_CLASS,
});
declareStyle(panel, REFERENCE_CHAIN_PROPERTY, String(REFERENCE_CHAIN_MS) + MILLISECONDS);
document.body.append(panel);
let current: Anchored | null = null;
let shown: Anchored | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let touched: HTMLAnchorElement | null = null;
let disarm: Disposer | null = null;
const cancel = function cancel(): void {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
if (disarm !== null) {
disarm();
disarm = null;
}
};
const settle = function settle(): void {
cancel();
current = shown;
};
const hide = function hide(): void {
cancel();
shown?.anchor.removeAttribute(DESCRIBED_BY_ATTRIBUTE);
current = null;
shown = null;
panel.classList.remove(OPEN_CLASS);
clearChildren(panel);
};
const reveal = async function reveal(anchored: Anchored, chained: boolean): Promise<void> {
const content = await loadReference(anchored.target, anchored.href);
if (current !== anchored) {
return;
}
if (content === null) {
hide();
return;
}
cancel();
shown?.anchor.removeAttribute(DESCRIBED_BY_ATTRIBUTE);
shown = anchored;
anchored.anchor.setAttribute(DESCRIBED_BY_ATTRIBUTE, REFERENCE_PANEL_ID);
clearChildren(panel);
panel.append(...panelsOf(content));
panel.classList.add(OPEN_CLASS);
if (!chained) {
placePanel(panel, anchored.anchor);
}
};
const show = function show(anchored: Anchored): void {
cancel();
if (current?.anchor === anchored.anchor) {
return;
}
const chained = isInside(panel, anchored.anchor);
current = anchored;
if (chained) {
disarm = armLink(anchored.anchor);
}
timer = setTimeout(
() => {
void reveal(anchored, chained);
},
chained ? REFERENCE_CHAIN_MS : REFERENCE_SHOW_MS,
);
};
const onEnter = function onEnter(event: Event): void {
const anchored = anchoredOf(event.target);
if (anchored !== null) {
show(anchored);
return;
}
if (isInside(panel, event.target)) {
settle();
}
};
const onLeave = function onLeave(event: Event): void {
const related = relatedOf(event);
if (current === null || isInside(current.anchor, related)) {
return;
}
if (isInside(panel, related)) {
settle();
return;
}
cancel();
timer = setTimeout(hide, REFERENCE_HIDE_MS);
};
const onKey = function onKey(event: KeyboardEvent): void {
if (event.key === ESCAPE_KEY) {
hide();
}
};
const onPointer = function onPointer(event: PointerEvent): void {
if (event.pointerType !== TOUCH_POINTER) {
return;
}
const anchored = anchoredOf(event.target);
if (anchored === null) {
touched = null;
if (!isInside(panel, event.target)) {
hide();
}
return;
}
touched = current?.anchor === anchored.anchor ? null : anchored.anchor;
show(anchored);
};
const onClick = function onClick(event: MouseEvent): void {
if (touched !== null && touched === linkOf(event.target)) {
event.preventDefault();
event.stopImmediatePropagation();
touched = null;
}
};
document.addEventListener("mouseover", onEnter);
document.addEventListener("mouseout", onLeave);
document.addEventListener("focusin", onEnter);
document.addEventListener("focusout", onLeave);
document.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onPointer);
document.addEventListener("click", onClick, true);
return () => {
hide();
document.removeEventListener("mouseover", onEnter);
document.removeEventListener("mouseout", onLeave);
document.removeEventListener("focusin", onEnter);
document.removeEventListener("focusout", onLeave);
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onPointer);
document.removeEventListener("click", onClick, true);
panel.remove();
};
};