naja
Version:
Modern AJAX library for Nette Framework
188 lines (148 loc) • 5.94 kB
text/typescript
import {BeforeEvent, Naja, Options, SuccessEvent} from '../Naja';
import {RedirectEvent} from './RedirectHandler';
import {InteractionEvent} from './UIHandler';
import {onDomReady, TypedEventListener} from '../utils';
const originalTitleKey = Symbol();
declare module '../Naja' {
interface Options {
history?: HistoryMode;
href?: string;
[originalTitleKey]?: string;
}
interface Payload {
postGet?: boolean;
url?: string;
}
}
export interface HistoryState extends Record<string, any> {
source: string;
cursor: number;
href: string;
}
export interface HistoryAdapter {
replaceState(state: HistoryState, title: string, url: string): void;
pushState(state: HistoryState, title: string, url: string): void;
}
export type HistoryMode = boolean | 'replace';
export class HistoryHandler extends EventTarget {
private initialized = false;
private cursor = 0;
public popStateHandler = this.handlePopState.bind(this);
public historyAdapter: HistoryAdapter;
public constructor(private readonly naja: Naja) {
super();
naja.addEventListener('init', this.initialize.bind(this));
naja.addEventListener('before', this.saveUrl.bind(this));
naja.addEventListener('before', this.saveOriginalTitle.bind(this));
naja.addEventListener('before', this.replaceInitialState.bind(this));
naja.addEventListener('success', this.pushNewState.bind(this));
naja.redirectHandler.addEventListener('redirect', this.saveRedirectedUrl.bind(this));
naja.uiHandler.addEventListener('interaction', this.configureMode.bind(this));
this.historyAdapter = {
replaceState: (state, title, url) => window.history.replaceState(state, title, url),
pushState: (state, title, url) => window.history.pushState(state, title, url),
};
}
public set uiCache(value: boolean) {
console.warn('Naja: HistoryHandler.uiCache is deprecated, use options.snippetCache instead.');
this.naja.defaultOptions.snippetCache = value;
}
private handlePopState(event: PopStateEvent): void {
const {state} = event;
if (state?.source !== 'naja') {
return;
}
const direction = state.cursor - this.cursor;
this.cursor = state.cursor;
const options = this.naja.prepareOptions();
this.dispatchEvent(new CustomEvent('restoreState', {detail: {state, direction, options}}));
}
private initialize(): void {
window.addEventListener('popstate', this.popStateHandler);
}
private saveOriginalTitle(event: BeforeEvent): void {
const {options} = event.detail;
options[originalTitleKey] = window.document.title;
}
private saveUrl(event: BeforeEvent): void {
const {url, options} = event.detail;
options.href ??= url;
}
private saveRedirectedUrl(event: RedirectEvent): void {
const {url, options} = event.detail;
options.href = url;
}
private replaceInitialState(event: BeforeEvent): void {
const {options} = event.detail;
const mode = HistoryHandler.normalizeMode(options.history);
if (mode !== false && ! this.initialized) {
onDomReady(() => this.historyAdapter.replaceState(
this.buildState(window.location.href, 'replace', this.cursor, options),
window.document.title,
window.location.href,
));
this.initialized = true;
}
}
private configureMode(event: InteractionEvent): void {
const {element, options} = event.detail;
if (element.hasAttribute('data-naja-history') || (element as HTMLInputElement).form?.hasAttribute('data-naja-history')) {
const value = element.getAttribute('data-naja-history') ?? (element as HTMLInputElement).form?.getAttribute('data-naja-history');
options.history = HistoryHandler.normalizeMode(value);
}
}
public static normalizeMode(mode: string | boolean | null | undefined): HistoryMode {
if (mode === 'off' || mode === false) {
return false;
} else if (mode === 'replace') {
return 'replace';
}
return true;
}
private pushNewState(event: SuccessEvent): void {
const {payload, options} = event.detail;
const mode = HistoryHandler.normalizeMode(options.history);
if (mode === false) {
return;
}
if (payload.postGet && payload.url) {
options.href = payload.url;
}
const method = mode === 'replace' ? 'replaceState' : 'pushState';
const cursor = mode === 'replace' ? this.cursor : ++this.cursor;
const state = this.buildState(options.href!, mode, cursor, options);
// before the state is pushed into history, revert to the original title
const newTitle = window.document.title;
window.document.title = options[originalTitleKey]!;
this.historyAdapter[method](
state,
newTitle,
options.href!,
);
// after the state is pushed into history, update back to the new title
window.document.title = newTitle;
}
private buildState(href: string, mode: HistoryMode, cursor: number, options: Options): HistoryState {
const state: HistoryState = {
source: 'naja',
cursor,
href,
};
this.dispatchEvent(new CustomEvent('buildState', {
detail: {
state,
operation: mode === 'replace' ? 'replaceState' : 'pushState',
options,
},
}));
return state;
}
declare public addEventListener: <K extends keyof HistoryHandlerEventMap | string>(type: K, listener: TypedEventListener<HistoryHandler, K extends keyof HistoryHandlerEventMap ? HistoryHandlerEventMap[K] : CustomEvent>, options?: boolean | AddEventListenerOptions) => void;
declare public removeEventListener: <K extends keyof HistoryHandlerEventMap | string>(type: K, listener: TypedEventListener<HistoryHandler, K extends keyof HistoryHandlerEventMap ? HistoryHandlerEventMap[K] : CustomEvent>, options?: boolean | AddEventListenerOptions) => void;
}
export type BuildStateEvent = CustomEvent<{state: HistoryState, operation: 'pushState' | 'replaceState', options: Options}>;
export type RestoreStateEvent = CustomEvent<{state: HistoryState, direction: number, options: Options}>;
interface HistoryHandlerEventMap {
buildState: BuildStateEvent;
restoreState: RestoreStateEvent;
}