feat: VivesPOS landing on Winter CMS 1.2 — theme + plugin + Dockerfile
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run

- Base: wintercms/winter branch 1.2 (full framework)
- Theme vivespos: Canvas 7 + Bootstrap 5 CDN, custom CSS
- Layout: deferred GTM/GA4 tracking, JSON-LD SoftwareApplication
- Partials: hero (offline-first), features, modes (offline/nube toggle),
  screenshots, pricing (3 planes), comparison, FAQ, CTA
- Plugin VivesPOS.Site with ContactForm
- Dockerfile: PHP 8.2 Apache, port 80, healthcheck
- Added winter/wn-pages, blog, sitemap, seo plugins
- Active theme set to vivespos
This commit is contained in:
2026-08-21 19:29:00 -06:00
commit 1f72193a64
3266 changed files with 531480 additions and 0 deletions

View File

@@ -0,0 +1,196 @@
import Singleton from '../abstracts/Singleton';
/**
* Asset Loader.
*
* Provides simple asset loading functionality for Snowboard, making it easy to pre-load images or
* include JavaScript or CSS assets on the fly.
*
* By default, this loader will listen to any assets that have been requested to load in an AJAX
* response, such as responses from a component.
*
* You can also load assets manually by calling the following:
*
* ```js
* Snowboard.addPlugin('assetLoader', AssetLoader);
* Snowboard.assetLoader().processAssets(assets);
* ```
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class AssetLoader extends Singleton {
/**
* Event listeners.
*
* @returns {Object}
*/
listens() {
return {
ajaxLoadAssets: 'load',
};
}
/**
* Dependencies.
*
* @returns {Array}
*/
dependencies() {
return [
'url',
];
}
/**
* Process and load assets.
*
* The `assets` property of this method requires an object with any of the following keys and an
* array of paths:
*
* - `js`: An array of JavaScript URLs to load
* - `css`: An array of CSS stylesheet URLs to load
* - `img`: An array of image URLs to pre-load
*
* Both `js` and `css` files will be automatically injected, however `img` files will not.
*
* This method will return a Promise that resolves when all required assets are loaded. If an
* asset fails to load, this Promise will be rejected.
*
* ESLint *REALLY* doesn't like this code, but ignore it. It's the only way it works.
*
* @param {Object} assets
* @returns {Promise}
*/
async load(assets) {
if (assets.js && assets.js.length > 0) {
for (const script of assets.js) {
try {
await this.loadScript(script);
} catch (error) {
return Promise.reject(error);
}
}
}
if (assets.css && assets.css.length > 0) {
for (const style of assets.css) {
try {
await this.loadStyle(style);
} catch (error) {
return Promise.reject(error);
}
}
}
if (assets.img && assets.img.length > 0) {
for (const image of assets.img) {
try {
await this.loadImage(image);
} catch (error) {
return Promise.reject(error);
}
}
}
return Promise.resolve();
}
/**
* Injects and loads a JavaScript URL into the DOM.
*
* The script will be appended before the closing `</body>` tag.
*
* @param {String} script
* @returns {Promise}
*/
loadScript(script) {
return new Promise((resolve, reject) => {
// Resolve script URL
script = this.snowboard.url().asset(script);
// Check that script is not already loaded
const loaded = document.querySelector(`script[src="${script}"]`);
if (loaded) {
resolve();
return;
}
// Create script
const domScript = document.createElement('script');
domScript.setAttribute('type', 'text/javascript');
domScript.setAttribute('src', script);
domScript.addEventListener('load', () => {
this.snowboard.globalEvent('assetLoader.loaded', 'script', script, domScript);
resolve();
});
domScript.addEventListener('error', () => {
this.snowboard.globalEvent('assetLoader.error', 'script', script, domScript);
reject(new Error(`Unable to load script file: "${script}"`));
});
document.body.append(domScript);
});
}
/**
* Injects and loads a CSS stylesheet into the DOM.
*
* The stylesheet will be appended before the closing `</head>` tag.
*
* @param {String} style
* @returns {Promise}
*/
loadStyle(style) {
return new Promise((resolve, reject) => {
// Resolve style URL
style = this.snowboard.url().asset(style);
// Check that stylesheet is not already loaded
const loaded = document.querySelector(`link[rel="stylesheet"][href="${style}"]`);
if (loaded) {
resolve();
return;
}
// Create stylesheet
const domCss = document.createElement('link');
domCss.setAttribute('rel', 'stylesheet');
domCss.setAttribute('href', style);
domCss.addEventListener('load', () => {
this.snowboard.globalEvent('assetLoader.loaded', 'style', style, domCss);
resolve();
});
domCss.addEventListener('error', () => {
this.snowboard.globalEvent('assetLoader.error', 'style', style, domCss);
reject(new Error(`Unable to load stylesheet file: "${style}"`));
});
document.head.append(domCss);
});
}
/**
* Pre-loads an image.
*
* The image will not be injected into the DOM.
*
* @param {String} image
* @returns {Promise}
*/
loadImage(image) {
return new Promise((resolve, reject) => {
// Resolve script URL
image = this.snowboard.url().asset(image);
const img = new Image();
img.addEventListener('load', () => {
this.snowboard.globalEvent('assetLoader.loaded', 'image', image, img);
resolve();
});
img.addEventListener('error', () => {
this.snowboard.globalEvent('assetLoader.error', 'image', image, img);
reject(new Error(`Unable to load image file: "${image}"`));
});
img.src = image;
});
}
}

View File

@@ -0,0 +1,70 @@
import Singleton from '../abstracts/Singleton';
/**
* Allows attaching a loading class on elements that an AJAX request is targeting.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class AttachLoading extends Singleton {
/**
* Defines dependenices.
*
* @returns {string[]}
*/
dependencies() {
return ['request'];
}
/**
* Defines listeners.
*
* @returns {Object}
*/
listens() {
return {
ajaxStart: 'ajaxStart',
ajaxDone: 'ajaxDone',
};
}
ajaxStart(promise, request) {
if (!request.element) {
return;
}
if (request.element.tagName === 'FORM') {
const loadElements = request.element.querySelectorAll('[data-attach-loading]');
if (loadElements.length > 0) {
loadElements.forEach((element) => {
element.classList.add(this.getLoadingClass(element));
});
}
} else if (request.element.dataset.attachLoading !== undefined) {
request.element.classList.add(this.getLoadingClass(request.element));
}
}
ajaxDone(data, request) {
if (!request.element) {
return;
}
if (request.element.tagName === 'FORM') {
const loadElements = request.element.querySelectorAll('[data-attach-loading]');
if (loadElements.length > 0) {
loadElements.forEach((element) => {
element.classList.remove(this.getLoadingClass(element));
});
}
} else if (request.element.dataset.attachLoading !== undefined) {
request.element.classList.remove(this.getLoadingClass(request.element));
}
}
getLoadingClass(element) {
return (element.dataset.attachLoading !== undefined && element.dataset.attachLoading !== '')
? element.dataset.attachLoading
: 'wn-loading';
}
}

View File

@@ -0,0 +1,222 @@
import PluginBase from '../abstracts/PluginBase';
/**
* Data configuration provider.
*
* Provides a mechanism for passing configuration data through an element's data attributes. This
* is generally used for widgets or UI interactions to configure them.
*
* @copyright 2022 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class DataConfig extends PluginBase {
/**
* Constructor.
*
* @param {PluginBase} instance
* @param {HTMLElement} element
* @param {Object} localConfig
*/
construct(instance, element, localConfig) {
if (instance instanceof PluginBase === false) {
throw new Error('You must provide a Snowboard plugin to enable data configuration');
}
if (element instanceof HTMLElement === false) {
throw new Error('Data configuration can only be extracted from HTML elements');
}
this.instance = instance;
this.element = element;
this.localConfig = localConfig || {};
this.instanceConfig = {};
this.acceptedConfigs = {};
this.refresh();
}
/**
* Gets the config for this instance.
*
* If the `config` parameter is unspecified, returns the entire configuration.
*
* @param {string} config
*/
get(config) {
if (config === undefined) {
return this.instanceConfig;
}
if (this.instanceConfig[config] !== undefined) {
return this.instanceConfig[config];
}
return undefined;
}
/**
* Sets the config for this instance.
*
* This allows you to override, at runtime, any configuration value as necessary.
*
* @param {string} config
* @param {any} value
* @param {boolean} persist
*/
set(config, value, persist) {
if (config === undefined) {
throw new Error('You must provide a configuration key to set');
}
this.instanceConfig[config] = value;
if (persist === true) {
this.element.dataset[config] = value;
this.localConfig[config] = value;
}
}
/**
* Refreshes the configuration from the element.
*
* This will allow you to make changes to the data config on a DOM level and re-apply them
* to the config on the JavaScript side.
*/
refresh() {
this.acceptedConfigs = this.getAcceptedConfigs();
this.instanceConfig = this.processConfig();
}
/**
* Determines the available configurations that can be set through the data config.
*
* If an instance has an `acceptAllDataConfigs` property, set to `true`, then all data
* attributes will be available as configuration values. This can be a security concern, so
* tread carefully.
*
* Otherwise, available configurations will be determined by the keys available in an object
* returned by a `defaults()` method in the instance.
*
* @returns {string[]|boolean}
*/
getAcceptedConfigs() {
if (
this.instance.acceptAllDataConfigs !== undefined
&& this.instance.acceptAllDataConfigs === true
) {
return true;
}
if (
this.instance.defaults !== undefined
&& typeof this.instance.defaults === 'function'
&& typeof this.instance.defaults() === 'object'
) {
return Object.keys(this.instance.defaults());
}
return false;
}
/**
* Returns the default values for the instance.
*
* This will be an empty object if the instance either does not have a `defaults()` method, or
* the method itself does not return an object.
*
* @returns {object}
*/
getDefaults() {
if (
this.instance.defaults !== undefined
&& typeof this.instance.defaults === 'function'
&& typeof this.instance.defaults() === 'object'
) {
return this.instance.defaults();
}
return {};
}
/**
* Processes the configuration.
*
* Loads up the defaults, then populates it with any configuration values provided by the data
* attributes, based on the rules of the accepted configurations.
*
* This configuration object is then cached and available through `config.get()` calls.
*
* @returns {object}
*/
processConfig() {
const config = this.getDefaults();
if (this.acceptedConfigs === false) {
return config;
}
/* eslint-disable */
for (const key in this.element.dataset) {
if (this.acceptedConfigs === true || this.acceptedConfigs.includes(key)) {
config[key] = this.coerceValue(this.element.dataset[key]);
}
}
for (const key in this.localConfig) {
if (this.acceptedConfigs === true || this.acceptedConfigs.includes(key)) {
config[key] = this.localConfig[key];
}
}
/* eslint-enable */
return config;
}
/**
* Coerces configuration values for JavaScript.
*
* Takes the string value returned from the data attribute and coerces it into a more suitable
* type for JavaScript processing.
*
* @param {*} value
* @returns {*}
*/
coerceValue(value) {
const stringValue = String(value);
// Null value
if (stringValue === 'null') {
return null;
}
// Undefined value
if (stringValue === 'undefined') {
return undefined;
}
// Base64 value
if (stringValue.startsWith('base64:')) {
const base64str = stringValue.replace(/^base64:/, '');
const decoded = atob(base64str);
return this.coerceValue(decoded);
}
// Boolean value
if (['true', 'yes'].includes(stringValue.toLowerCase())) {
return true;
}
if (['false', 'no'].includes(stringValue.toLowerCase())) {
return false;
}
// Numeric value
if (/^[-+]?[0-9]+(\.[0-9]+)?$/.test(stringValue)) {
return Number(stringValue);
}
// JSON value
try {
return this.snowboard.jsonParser().parse(stringValue);
} catch (e) {
return (stringValue === '') ? true : stringValue;
}
}
}

View File

@@ -0,0 +1,150 @@
import PluginBase from '../abstracts/PluginBase';
/**
* Provides flash messages for the CMS.
*
* Flash messages will pop up at the top center of the page and will remain for 7 seconds by default. Hovering over
* the message will reset and pause the timer. Clicking on the flash message will dismiss it.
*
* Arguments:
* - "message": The content of the flash message. HTML is accepted.
* - "type": The type of flash message. This is appended as a class to the flash message itself.
* - "duration": How long the flash message will stay visible for, in seconds. Default: 7 seconds.
*
* Usage:
* Snowboard.flash('This is a flash message', 'info', 8);
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class Flash extends PluginBase {
/**
* Constructor.
*
* @param {string} message
* @param {string} type
* @param {Number} duration
*/
construct(message, type, duration) {
this.message = message;
this.type = type || 'default';
this.duration = Number(duration || 7);
if (this.duration < 0) {
throw new Error('Flash duration must be a positive number, or zero');
}
this.clear();
this.timer = null;
this.flashTimer = null;
this.create();
}
/**
* Defines dependencies.
*
* @returns {string[]}
*/
dependencies() {
return ['transition'];
}
/**
* Destructor.
*
* This will ensure the flash message is removed and timeout is cleared if the module is removed.
*/
destruct() {
if (this.timer !== null) {
window.clearTimeout(this.timer);
}
if (this.flashTimer) {
this.flashTimer.remove();
}
if (this.flash) {
this.flash.remove();
this.flash = null;
this.flashTimer = null;
}
super.destruct();
}
/**
* Creates the flash message.
*/
create() {
this.snowboard.globalEvent('flash.create', this);
this.flash = document.createElement('DIV');
this.flash.innerHTML = this.message;
this.flash.classList.add('flash-message', this.type);
this.flash.removeAttribute('data-control');
this.flash.addEventListener('click', () => this.remove());
this.flash.addEventListener('mouseover', () => this.stopTimer());
this.flash.addEventListener('mouseout', () => this.startTimer());
if (this.duration > 0) {
this.flashTimer = document.createElement('DIV');
this.flashTimer.classList.add('flash-timer');
this.flash.appendChild(this.flashTimer);
} else {
this.flash.classList.add('no-timer');
}
// Add to body
document.body.appendChild(this.flash);
this.snowboard.transition(this.flash, 'show', () => {
this.startTimer();
});
}
/**
* Removes the flash message.
*/
remove() {
this.snowboard.globalEvent('flash.remove', this);
this.stopTimer();
this.snowboard.transition(this.flash, 'hide', () => {
this.flash.remove();
this.flash = null;
this.destruct();
});
}
/**
* Clears all flash messages available on the page.
*/
clear() {
document.querySelectorAll('body > div.flash-message').forEach((element) => element.remove());
}
/**
* Starts the timer for this flash message.
*/
startTimer() {
if (this.duration === 0) {
return;
}
this.timerTrans = this.snowboard.transition(this.flashTimer, 'timeout', null, `${this.duration}.0s`, true);
this.timer = window.setTimeout(() => this.remove(), this.duration * 1000);
}
/**
* Resets the timer for this flash message.
*/
stopTimer() {
if (this.timerTrans) {
this.timerTrans.cancel();
}
if (this.timer) {
window.clearTimeout(this.timer);
}
}
}

View File

@@ -0,0 +1,72 @@
import Singleton from '../abstracts/Singleton';
/**
* Defines a default listener for flash events.
*
* Connects the Flash plugin to various events that use flash messages.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class FlashListener extends Singleton {
/**
* Defines dependenices.
*
* @returns {string[]}
*/
dependencies() {
return ['flash'];
}
/**
* Defines listeners.
*
* @returns {Object}
*/
listens() {
return {
ready: 'ready',
ajaxErrorMessage: 'ajaxErrorMessage',
ajaxFlashMessages: 'ajaxFlashMessages',
};
}
/**
* Do flash messages for PHP flash responses.
*/
ready() {
document.querySelectorAll('[data-control="flash-message"]').forEach((element) => {
this.snowboard.flash(
element.innerHTML,
element.dataset.flashType,
element.dataset.flashDuration,
);
element.remove();
});
}
/**
* Shows a flash message for AJAX errors.
*
* @param {string} message
* @returns {Boolean}
*/
ajaxErrorMessage(message) {
this.snowboard.flash(message, 'error');
return false;
}
/**
* Shows flash messages returned directly from AJAX functionality.
*
* @param {Object} messages
*/
ajaxFlashMessages(messages) {
Object.entries(messages).forEach((entry) => {
const [cssClass, message] = entry;
this.snowboard.flash(message, cssClass);
});
return false;
}
}

View File

@@ -0,0 +1,215 @@
import Singleton from '../abstracts/Singleton';
/**
* Adds AJAX-driven form validation to Snowboard requests.
*
* Documentation for this feature can be found here:
* https://wintercms.com/docs/snowboard/extras#ajax-validation
*
* @copyright 2022 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class FormValidation extends Singleton {
/**
* Constructor.
*/
construct() {
this.errorBags = [];
}
/**
* Defines listeners.
*
* @returns {Object}
*/
listens() {
return {
ready: 'ready',
ajaxStart: 'clearValidation',
ajaxValidationErrors: 'doValidation',
};
}
/**
* Ready event handler.
*/
ready() {
this.collectErrorBags(document);
}
/**
* Retrieves validation errors from an AJAX response and passes them through to the error bags.
*
* This handler returns false to cancel any further validation handling, and prevents the flash
* message that is displayed by default for field errors in AJAX requests from showing.
*
* @param {HTMLFormElement} form
* @param {Object} invalidFields
* @param {Request} request
* @returns {Boolean}
*/
doValidation(form, invalidFields, request) {
if (request.element && request.element.dataset.requestValidate === undefined) {
return null;
}
if (!form) {
return null;
}
const errorBags = this.errorBags.filter((errorBag) => errorBag.form === form);
errorBags.forEach((errorBag) => {
this.showErrorBag(errorBag, invalidFields);
});
return false;
}
/**
* Clears any validation errors in the given form.
*
* @param {Promise} promise
* @param {Request} request
* @returns {void}
*/
clearValidation(promise, request) {
if (request.element && request.element.dataset.requestValidate === undefined) {
return;
}
if (!request.form) {
return;
}
const errorBags = this.errorBags.filter((errorBag) => errorBag.form === request.form);
errorBags.forEach((errorBag) => {
this.hideErrorBag(errorBag);
});
}
/**
* Collects error bags (elements with "data-validate-error" attribute) and links them to a
* placeholder and form.
*
* The error bags will be initially hidden, and will only show when validation errors occur.
*
* @param {HTMLElement} rootNode
*/
collectErrorBags(rootNode) {
rootNode.querySelectorAll('[data-validate-error], [data-validate-for]').forEach((errorBag) => {
const form = errorBag.closest('form[data-request-validate]');
// If this error bag does not reside within a validating form, remove it
if (!form) {
errorBag.parentNode.removeChild(errorBag);
return;
}
// Find message list node, if available
let messageListElement = null;
if (errorBag.matches('[data-validate-error]')) {
messageListElement = errorBag.querySelector('[data-message]');
}
// Create a placeholder node
const placeholder = document.createComment('');
// Register error bag and replace with placeholder
const errorBagData = {
element: errorBag,
form,
validateFor: (errorBag.dataset.validateFor)
? errorBag.dataset.validateFor.split(/\s*,\s*/)
: '*',
placeholder,
messageListElement: (messageListElement)
? messageListElement.cloneNode(true)
: null,
messageListAnchor: null,
customMessage: (errorBag.dataset.validateFor)
? (errorBag.textContent !== '' || errorBag.childNodes.length > 0)
: false,
};
// If an message list element exists, create another placeholder to act as an anchor point
if (messageListElement) {
const messageListAnchor = document.createComment('');
messageListElement.parentNode.replaceChild(messageListAnchor, messageListElement);
errorBagData.messageListAnchor = messageListAnchor;
}
errorBag.parentNode.replaceChild(placeholder, errorBag);
this.errorBags.push(errorBagData);
});
}
/**
* Hides an error bag, replacing the error messages with a placeholder node.
*
* @param {Object} errorBag
*/
hideErrorBag(errorBag) {
if (errorBag.element.isConnected) {
errorBag.element.parentNode.replaceChild(errorBag.placeholder, errorBag.element);
}
}
/**
* Shows an error bag with the given invalid fields.
*
* @param {Object} errorBag
* @param {Object} invalidFields
*/
showErrorBag(errorBag, invalidFields) {
if (!this.errorBagValidatesField(errorBag, invalidFields)) {
return;
}
if (!errorBag.element.isConnected) {
errorBag.placeholder.parentNode.replaceChild(errorBag.element, errorBag.placeholder);
}
if (errorBag.validateFor !== '*') {
if (!errorBag.customMessage) {
const firstField = Object.keys(invalidFields)
.filter((field) => errorBag.validateFor.includes(field))
.shift();
[errorBag.element.innerHTML] = invalidFields[firstField];
}
} else if (errorBag.messageListElement) {
// Remove previous error messages
errorBag.element.querySelectorAll('[data-validation-message]').forEach((message) => {
message.parentNode.removeChild(message);
});
Object.entries(invalidFields).forEach((entry) => {
const [, errors] = entry;
errors.forEach((error) => {
const messageElement = errorBag.messageListElement.cloneNode(true);
messageElement.dataset.validationMessage = '';
messageElement.innerHTML = error;
errorBag.messageListAnchor.after(messageElement);
});
});
} else {
[errorBag.element.innerHTML] = invalidFields[Object.keys(invalidFields).shift()];
}
}
/**
* Determines if a given error bag applies for the given invalid fields.
*
* @param {Object} errorBag
* @param {Object} invalidFields
* @returns {Boolean}
*/
errorBagValidatesField(errorBag, invalidFields) {
if (errorBag.validateFor === '*') {
return true;
}
return Object.keys(invalidFields)
.filter((field) => errorBag.validateFor.includes(field))
.length > 0;
}
}

View File

@@ -0,0 +1,94 @@
import Singleton from '../abstracts/Singleton';
/**
* Displays a stripe at the top of the page that indicates loading.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class StripeLoader extends Singleton {
/**
* Defines dependenices.
*
* @returns {string[]}
*/
dependencies() {
return ['request'];
}
/**
* Defines listeners.
*
* @returns {Object}
*/
listens() {
return {
ready: 'ready',
ajaxStart: 'ajaxStart',
};
}
ready() {
this.counter = 0;
this.createStripe();
}
ajaxStart(promise, request) {
if (request.loading === false || request.options.stripe === false) {
return;
}
this.show();
promise.then(() => {
this.hide();
}).catch(() => {
this.hide();
});
}
createStripe() {
this.indicator = document.createElement('DIV');
this.stripe = document.createElement('DIV');
this.stripeLoaded = document.createElement('DIV');
this.indicator.classList.add('stripe-loading-indicator', 'loaded');
this.stripe.classList.add('stripe');
this.stripeLoaded.classList.add('stripe-loaded');
this.indicator.appendChild(this.stripe);
this.indicator.appendChild(this.stripeLoaded);
document.body.appendChild(this.indicator);
}
show() {
this.counter += 1;
const newStripe = this.stripe.cloneNode(true);
this.indicator.appendChild(newStripe);
this.stripe.remove();
this.stripe = newStripe;
if (this.counter > 1) {
return;
}
this.indicator.classList.remove('loaded');
document.body.classList.add('wn-loading');
}
hide(force) {
this.counter -= 1;
if (force === true) {
this.counter = 0;
}
if (this.counter <= 0) {
this.indicator.classList.add('loaded');
document.body.classList.remove('wn-loading');
}
}
}

View File

@@ -0,0 +1,38 @@
import Singleton from '../abstracts/Singleton';
/**
* Embeds the "extras" stylesheet into the page, if it is not loaded through the theme.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class StylesheetLoader extends Singleton {
/**
* Defines listeners.
*
* @returns {Object}
*/
listens() {
return {
ready: 'ready',
};
}
ready() {
let stylesLoaded = false;
// Determine if stylesheet is already loaded
document.querySelectorAll('link[rel="stylesheet"]').forEach((css) => {
if (css.href.endsWith('/modules/system/assets/css/snowboard.extras.css')) {
stylesLoaded = true;
}
});
if (!stylesLoaded) {
const stylesheet = document.createElement('link');
stylesheet.setAttribute('rel', 'stylesheet');
stylesheet.setAttribute('href', this.snowboard.url().asset('/modules/system/assets/css/snowboard.extras.css'));
document.head.appendChild(stylesheet);
}
}
}

View File

@@ -0,0 +1,206 @@
import PluginBase from '../abstracts/PluginBase';
/**
* Provides transition support for elements.
*
* Transition allows CSS transitions to be controlled and callbacks to be run once completed. It works similar to Vue
* transitions with 3 stages of transition, and classes assigned to the element with the transition name suffixed with
* the stage of transition:
*
* - `in`: A class assigned to the element for the first frame of the transition, removed afterwards. This should be
* used to define the initial state of the transition.
* - `active`: A class assigned to the element for the duration of the transition. This should be used to define the
* transition itself.
* - `out`: A class assigned to the element after the first frame of the transition and kept to the end of the
* transition. This should define the end state of the transition.
*
* Usage:
* Snowboard.transition(document.element, 'transition', () => {
* console.log('Remove element after 7 seconds');
* this.remove();
* }, '7s');
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class Transition extends PluginBase {
/**
* Constructor.
*
* @param {HTMLElement} element The element to transition
* @param {string} transition The name of the transition, this prefixes the stages of transition.
* @param {Function} callback An optional callback to call when the transition ends.
* @param {Number} duration An optional override on the transition duration. Must be specified as 's' (secs) or 'ms' (msecs).
* @param {Boolean} trailTo If true, the "out" class will remain after the end of the transition.
*/
construct(element, transition, callback, duration, trailTo) {
if (element instanceof HTMLElement === false) {
throw new Error('A HTMLElement must be provided for transitioning');
}
this.element = element;
if (typeof transition !== 'string') {
throw new Error('Transition name must be specified as a string');
}
this.transition = transition;
if (callback && typeof callback !== 'function') {
throw new Error('Callback must be a valid function');
}
this.callback = callback;
if (duration) {
this.duration = this.parseDuration(duration);
} else {
this.duration = null;
}
this.trailTo = (trailTo === true);
this.doTransition();
}
/**
* Maps event classes to the given transition state.
*
* @param {...any} args
* @returns {Array}
*/
eventClasses(...args) {
const eventClasses = {
in: `${this.transition}-in`,
active: `${this.transition}-active`,
out: `${this.transition}-out`,
};
if (args.length === 0) {
return Object.values(eventClasses);
}
const returnClasses = [];
Object.entries(eventClasses).forEach((entry) => {
const [key, value] = entry;
if (args.indexOf(key) !== -1) {
returnClasses.push(value);
}
});
return returnClasses;
}
/**
* Executes the transition.
*
* @returns {void}
*/
doTransition() {
// Add duration override
if (this.duration !== null) {
this.element.style.transitionDuration = this.duration;
}
this.resetClasses();
// Start transition - show "in" and "active" classes
this.eventClasses('in', 'active').forEach((eventClass) => {
this.element.classList.add(eventClass);
});
window.requestAnimationFrame(() => {
// Ensure a transition exists
if (window.getComputedStyle(this.element)['transition-duration'] !== '0s') {
// Listen for the transition to end
this.element.addEventListener('transitionend', () => this.onTransitionEnd(), {
once: true,
});
window.requestAnimationFrame(() => {
this.element.classList.remove(this.eventClasses('in')[0]);
this.element.classList.add(this.eventClasses('out')[0]);
});
} else {
this.resetClasses();
if (this.callback) {
this.callback.apply(this.element);
}
this.destruct();
}
});
}
/**
* Callback function when the transition ends.
*
* When a transition ends, the instance of the transition is automatically destructed.
*
* @returns {void}
*/
onTransitionEnd() {
this.eventClasses('active', (!this.trailTo) ? 'out' : '').forEach((eventClass) => {
this.element.classList.remove(eventClass);
});
if (this.callback) {
this.callback.apply(this.element);
}
// Remove duration override
if (this.duration !== null) {
this.element.style.transitionDuration = null;
}
this.destruct();
}
/**
* Cancels a transition.
*
* @returns {void}
*/
cancel() {
this.element.removeEventListener('transitionend', () => this.onTransitionEnd, {
once: true,
});
this.resetClasses();
// Remove duration override
if (this.duration !== null) {
this.element.style.transitionDuration = null;
}
// Call destructor
this.destruct();
}
/**
* Resets the classes, removing any transition classes.
*
* @returns {void}
*/
resetClasses() {
this.eventClasses().forEach((eventClass) => {
this.element.classList.remove(eventClass);
});
}
/**
* Parses a given duration and converts it to a "ms" value.
*
* @param {String} duration
* @returns {String}
*/
parseDuration(duration) {
const parsed = /^([0-9]+(\.[0-9]+)?)(m?s)?$/.exec(duration);
const amount = Number(parsed[1]);
const unit = (parsed[3] === 's')
? 'sec'
: 'msec';
return (unit === 'sec')
? `${amount * 1000}ms`
: `${Math.floor(amount)}ms`;
}
}