refreshless-8.x-1.x-dev/components/page-transition/page-transition.js
components/page-transition/page-transition.js
/**
* @file
* RefreshLess page transitions.
*/
(function(html, Drupal, $) {
'use strict';
// @todo Remove when SDC additive aggregation is fixed.
//
// @see https://www.drupal.org/project/refreshless/issues/3543409
if (Drupal?.RefreshLess?.pageTransition) {
return;
}
/**
* Property name on the overlay element where class names are stored.
*
* @type {String}
*/
const classesPropName = 'refreshlessPageTransitionOverlayClasses';
/**
* Our event namespace.
*
* @type {String}
*
* @see https://learn.jquery.com/events/event-basics/#namespacing-events
*/
const eventNamespace = 'refreshless-page-transition-overlay';
Drupal.theme.refreshlessPageTransitionOverlay = (
elementType = 'div',
elementClass = 'refreshless-page-transition-overlay',
) => {
const $element = $(
`<${elementType} class="${elementClass}"></${elementType}`,
);
// Save the classes to a known property for the transition class to use.
//
// @todo What about just creating a method on the element to set active or
// inactive so knowledge of these classes isn't required?
$element.prop(classesPropName, {
base: elementClass,
active: `${elementClass}--active`,
});
return $element[0];
}
/**
* The maximum amount of time in milliseconds the transition may take.
*
* This is the failsafe timeout to ensure rendering continues if this time
* has passed without the transition finishing. Defensive programming and all
* that.
*
* @type {Number}
*
* @todo Expose this as a configurable value.
*/
const failsafeTimeout = 500;
/**
* Name of the root element attribute that we write the current state to.
*
* This is primarily intended for styling and is only one way; changing it
* doesn't affect the behaviour of the overlay itself.
*
* @type {String}
*/
const transitionStateAttrName = 'data-refreshless-page-transition-state';
/**
* Represents a RefreshLess page transition sequence.
*/
class TransitionSequence {
/**
* The sequence order for transition states.
*
* @type {Set}
*/
#sequence = new Set([
'revealed',
'hiding',
'hidden',
'revealing',
]);
/**
* The current state.
*
* @type {String}
*/
#current;
/**
* The current iterable from this.#sequence.
*
* @type {Iterator}
*/
#iterable;
/**
* A function to be called if this.assertCurrent() fails.
*
* @type {Function}
*/
#logError;
constructor(logError) {
this.#logError = logError;
this.restart();
}
/**
* Advance the sequence to the next state, restarting if necessary.
*
* @return {this}
* The current instance for chaining.
*/
advance() {
const value = this.#iterable.next().value;
if (typeof value !== 'undefined') {
this.#current = value;
return this;
}
this.#restart();
this.#current = this.#iterable.next().value;
return this;
}
/**
* Restart the sequence.
*
* This is separate from the public this.restart() method to avoid infinite
* recursion when we call it in this.advance().
*
* @return {this}
* The current instance for chaining.
*/
#restart() {
// Note that built-in iterators don't allow for restarting once consumed,
// so we have to create a new one.
this.#iterable = this.#sequence[Symbol.iterator]();
return this;
}
/**
* Restart the sequence.
*
* This is a wrapper around and this.#restart() and this.advance().
*
* @return {this}
* The current instance for chaining.
*/
restart() {
return this.#restart().advance();
}
/**
* Get the current sequence state.
*
* @return {String}
*
* @see this.#sequence
* Lists available state values.
*/
get current() {
return this.#current;
}
/**
* Assert that the current state matches an expected state.
*
* @param {String} expected
* The expected state to assert against.
*
* @return {this}
* The current instance for chaining.
*
* @see this.#sequence
* Lists available state values.
*/
assertCurrent(expected) {
if (this.#current === expected) {
return this;
}
this.#logError({
message: 'Expected state "%s" but found "%s"!',
expected: expected,
current: this.#current,
}
);
return this;
}
/**
* Determine if the current state is in the process of revealing.
*
* This means that the page is currently transitioning into view but has not
* finished transitioning in.
*
* @return {Boolean}
*/
isRevealing() {
return this.#current === 'revealing';
}
/**
* Determione if the current state is fully revealed.
*
* This means that the page is visible and that it's not in the process of
* transitioning in nor out.
*
* @return {Boolean}
*/
isRevealed() {
return this.#current === 'revealed';
}
/**
* Determine if the current state is revealing or revealed.
*
* @return {Boolean}
*/
isRevealingOrRevealed() {
return (this.isRevealing() || this.isRevealed());
}
/**
* Determine if the current state is in the process of hiding.
*
* This means that the page is currently transitioning out of to view but
* has not finished transitioning out.
*
* @return {Boolean}
*/
isHiding() {
return this.#current === 'hiding';
}
/**
* Determione if the current state is fully hidden.
*
* This means that the page is not visible visible and that it's not in
* the process of transitioning in nor out.
*
* @return {Boolean}
*/
isHidden() {
return this.#current === 'hidden';
}
/**
* Determine if the current state is hiding or hidden.
*
* @return {Boolean}
*/
isHidingOrHidden() {
return (this.isHiding() || this.isHidden());
}
}
/**
* Represents the RefreshLess page transition overlay.
*/
class TransitionOverlay {
/**
* The overlay element wrapped in a jQuery collection.
*
* @type {jQuery}
*/
#$overlay;
/**
* The root (<html>) element wrapped in a jQuery collection.
*
* @type {jQuery}
*/
#$root;
/**
* RefreshLess transition sequence instance.
*
* @type {TransitionSequence}
*/
#sequence;
/**
* A resolve function from 'refreshless:before-render' event.detail.delay().
*
* This is set to undefined when the transition out is finished or the
* failsafe is triggered.
*
* @type {undefined|Function}
*/
#resolveTransitionOut;
constructor(root) {
this.#$root = $(root);
this.#$overlay = $(Drupal.theme('refreshlessPageTransitionOverlay'));
this.#bindEventHandlers();
this.#sequence = new TransitionSequence((detail) => {
this.#assertError(detail);
});
// Explicitly set the state to 'initial' so that any CSS page reveal
// animations not interrupted, allowing style rules to differentiate
// between a full page load and a RefreshLess transition.
this.#updateRootAttribute('initial');
}
#assertError(detail) {
const event = new CustomEvent(
'refreshless:page-transition-state-error', {detail: detail},
);
this.#$root[0].dispatchEvent(event);
}
/**
* Destroy this instance.
*/
destroy() {
this.#unbindEventHandlers();
this.#$overlay.remove();
this.#$root.removeAttr(transitionStateAttrName);
}
/**
* Bind all of our event handlers.
*
* @see this~#unbindEventHandlers()
*/
#bindEventHandlers() {
this.#$root.on({
[`refreshless:before-render.${eventNamespace}`]: async (event) => {
await this.#beforeRenderHandler(event);
},
[`refreshless:render.${eventNamespace}`]: async (event) => {
await this.#renderHandler(event);
},
[`refreshless:load.${eventNamespace}`]: async (event) => {
await this.#loadHandler(event);
},
});
this.#$overlay.on({
[`transitionend.${eventNamespace}`]: async (event) => {
await this.#transitionEndHandler(event);
},
[`transitioncancel.${eventNamespace}`]: async (event) => {
await this.#transitionCancelHandler(event);
},
});
}
/**
* Unbind all of our event handlers.
*
* @see this~#bindEventHandlers()
*/
#unbindEventHandlers() {
this.#$root.add(this.#$overlay).off(`.${eventNamespace}`);
}
/**
* Update the root attribute with the current state.
*
* @param {String} state
* The state string to use as the attribute value. If not provided, will
* use the current sequence state. This is only used to set the 'initial'
* state on construct.
*/
#updateRootAttribute(state) {
this.#$root.attr(
transitionStateAttrName, state ?? this.#sequence.current,
);
}
/**
* 'refreshless:before-render' event handler.
*
* @param {jQuery.Event} event
*/
async #beforeRenderHandler(event) {
// If this is a fresh page that replaced a cached preview, do nothing
// because the page will have already been transitioned in when the
// preview was rendered.
if (event.detail.isFreshReplacingPreview === true) {
return;
}
await event.detail.delay(async (resolve, reject) => {
this.#resolveTransitionOut = resolve;
// This acts as a failsafe to resolve the delay if too much time has
// passed if our transitionend event handler does not resolve in a
// reasonable amount of time (or at all), the next page still renders,
// even if a bit less smoothly.
setTimeout(async () => {
if (this.#sequence.isRevealingOrRevealed() === true) {
return;
}
// Resolve here explicitly using the function instead of relying on
// this.#endTransitionOut() in case the reference to resolve() got
// out of sync or there's some other error.
resolve();
// Also ensure that the stored reference is resolved in case it's out
// of sync.
if (typeof this.#resolveTransitionOut === 'function') {
this.#resolveTransitionOut();
this.#resolveTransitionOut = undefined;
}
const event = new CustomEvent(
'refreshless:page-transition-failsafe', {},
);
this.#$root[0].dispatchEvent(event);
await this.#endTransitionOut();
await this.#startTransitionIn();
}, failsafeTimeout);
// Insert the overlay and indicate it's active only at this point, so
// that we don't do it too early and risk it removing a CSS page reveal
// animation.
this.#$overlay.insertBefore(this.#$root.find('body'));
await this.#startTransitionOut();
});
}
/**
* 'refreshless:render' event handler.
*
* @param {jQuery.Event} event
*/
async #renderHandler(event) {
if (event.detail.isPreview === false) {
return;
}
await this.#loadHandler();
}
/**
* 'refreshless:load' event handler.
*
* @param {jQuery.Event} event
*/
async #loadHandler(event) {
if (this.#sequence.isRevealingOrRevealed() === true) {
return;
}
await this.#startTransitionIn();
}
/**
* Overlay 'transitionend' event handler.
*
* @param {jQuery.Event} event
*/
async #transitionEndHandler(event) {
if (event.originalEvent.propertyName !== 'opacity') {
return;
}
const opacity = this.$overlay.css('opacity');
if (opacity === '1') {
await this.#endTransitionOut();
} else {
await this.#endTransitionIn();
}
}
/**
* Overlay 'transitioncancel' event handler.
*
* @param {jQuery.Event} event
*/
async #transitionCancelHandler(event) {
if (event.originalEvent.propertyName !== 'opacity') {
return;
}
if (this.#sequence.isRevealing() === true) {
await this.#endTransitionIn();
}
if (this.#sequence.isHiding() === true) {
await this.#endTransitionOut();
}
}
/**
* Start revealing.
*/
async #startTransitionIn() {
if (this.#sequence.isRevealing() === true) {
await this.#endTransitionIn();
}
// We want to advance the sequence and assert the expected value as soon
// as possible without delaying it. This ensures that any checks for the
// current state are accurate if we get a race condition as can sometimes
// occur with preloading or rapid clicking.
this.#sequence.advance().assertCurrent('revealing');
// Let any rendering/layout/etc. settle for a frame before proceeding.
await new Promise(requestAnimationFrame);
await new Promise(requestAnimationFrame);
this.#$overlay.removeClass(this.#$overlay.prop(classesPropName).active);
await this.#updateRootAttribute();
const event = new CustomEvent(
'refreshless:page-revealing', {},
);
this.#$root[0].dispatchEvent(event);
}
/**
* Finish revealing in if currently in the process of revealing.
*/
async #endTransitionIn() {
if (this.#sequence.isRevealing() !== true) {
return;
}
this.#sequence.advance().assertCurrent('revealed');
await this.#updateRootAttribute();
const event = new CustomEvent(
'refreshless:page-revealed', {},
);
this.#$root[0].dispatchEvent(event);
}
/**
* Start hiding if not already hiding or hidden.
*/
async #startTransitionOut() {
if (this.#sequence.isHidingOrHidden() === true) {
return;
}
if (this.#sequence.isRevealing() === true) {
await this.#endTransitionIn();
}
// Let any rendering/layout/etc. settle for a frame before proceeding.
await new Promise(requestAnimationFrame);
await new Promise(requestAnimationFrame);
this.#$overlay.addClass(this.#$overlay.prop(classesPropName).active);
this.#sequence.restart().advance().assertCurrent('hiding');
await this.#updateRootAttribute();
const event = new CustomEvent(
'refreshless:page-hiding', {},
);
this.#$root[0].dispatchEvent(event);
}
/**
* Finishing hiding.
*/
async #endTransitionOut() {
if (this.#sequence.isHiding() !== true) {
return;
}
this.#sequence.advance().assertCurrent('hidden');
await this.#updateRootAttribute();
const event = new CustomEvent(
'refreshless:page-hidden', {},
);
this.#$root[0].dispatchEvent(event);
if (typeof this.#resolveTransitionOut !== 'function') {
return;
}
this.#resolveTransitionOut();
this.#resolveTransitionOut = undefined;
}
/**
* Get the overlay element jQuery collection.
*
* @return {jQuery}
*/
get $overlay() {
return this.#$overlay;
}
/**
* Get the sequence class instance.
*
* @return {TransitionSequence}
*/
get sequence() {
return this.#sequence;
}
}
// Merge both the class and an instance into the existing Drupal global.
$.extend(true, Drupal, {RefreshLess: {
classes: {
PageTransitionOverlay: TransitionOverlay,
PageTransitionSequence: TransitionSequence,
},
pageTransition: new TransitionOverlay(html),
}});
})(document.documentElement, Drupal, jQuery);
