diff --git a/src/lightbox/README.md b/src/lightbox/README.md new file mode 100644 index 0000000..33d25d2 --- /dev/null +++ b/src/lightbox/README.md @@ -0,0 +1,73 @@ +# Lightbox + + + + + + +
+

Built by the super talented team at Travelopia.

+
+ +
+ +## Sample Usage + +This is a super minimal modal that is designed to be highly extendable. + +Example: + +First, create the lightbox and give it an ID. Style as needed: + +```html + + + + <-- There must be a button inside this component. + + + <-- There must be a button inside this component. + + + <-- There must be a button inside this component. + + + + + +``` + +Next, we need to trigger the lightbox with and give it some content. Any content added inside the `template` will be added to the lightbox, so you have full control over it: + +```html + <-- Group multiple lightboxes together with a unique name. + <-- There must be a button inside this component. + + +``` + +## Attributes + +| Attribute | Required | Values | Notes | +|------------------------|-----------|----------|----------------------------------------------| +| close-on-overlay-click | No | `yes` | Closes the modal when the overlay is clicked | + +## Events + +| Event | Notes | +|----------------|-------------------------------------------------------------| +| change | When any attribute has changed | +| template-set | When a template is set, before content has actually updated | +| content-update | When the content has updated inside the lightbox | + +## Methods + +### `open` + +Open the lightbox. + +### `close` + +Close the lightbox. diff --git a/src/lightbox/index.html b/src/lightbox/index.html new file mode 100644 index 0000000..a6da0cd --- /dev/null +++ b/src/lightbox/index.html @@ -0,0 +1,62 @@ + + + + + + + Web Component: Lightbox + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + + + + + + + + + +
+ + diff --git a/src/lightbox/index.ts b/src/lightbox/index.ts new file mode 100644 index 0000000..e2c359d --- /dev/null +++ b/src/lightbox/index.ts @@ -0,0 +1,26 @@ +/** + * Styles. + */ +import './style.scss'; + +/** + * Components. + */ +import { TPLightboxElement } from './tp-lightbox'; +import { TPLightboxContentElement } from './tp-lightbox-content'; +import { TPLightboxCloseElement } from './tp-lightbox-close'; +import { TPLightboxPreviousElement } from './tp-lightbox-previous'; +import { TPLightboxNextElement } from './tp-lightbox-next'; +import { TPLightboxCountElement } from './tp-lightbox-count'; +import { TPLightboxTriggerElement } from './tp-lightbox-trigger'; + +/** + * Register Components. + */ +customElements.define( 'tp-lightbox', TPLightboxElement ); +customElements.define( 'tp-lightbox-content', TPLightboxContentElement ); +customElements.define( 'tp-lightbox-close', TPLightboxCloseElement ); +customElements.define( 'tp-lightbox-previous', TPLightboxPreviousElement ); +customElements.define( 'tp-lightbox-next', TPLightboxNextElement ); +customElements.define( 'tp-lightbox-count', TPLightboxCountElement ); +customElements.define( 'tp-lightbox-trigger', TPLightboxTriggerElement ); diff --git a/src/lightbox/style.scss b/src/lightbox/style.scss new file mode 100644 index 0000000..0a13562 --- /dev/null +++ b/src/lightbox/style.scss @@ -0,0 +1,44 @@ +// Prevent scrolling when lightbox is open. +:root:has(tp-lightbox dialog[open]) { + overflow: clip; +} + +@keyframes show-tp-lightbox { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +tp-lightbox { + + dialog { + border: 0; + padding: 0; + + &[open] { + animation-name: show-tp-lightbox; + animation-duration: 0.5s; + animation-timing-function: ease-in-out; + } + + &::backdrop { + background: rgba(0, 0, 0, 0.6); + } + } +} + +tp-lightbox-content { + display: block; + position: relative; +} + +tp-lightbox[loading] tp-lightbox-content::after { + position: absolute; + content: "Loading..."; + z-index: 5; + top: 50px; + left: 50px; +} diff --git a/src/lightbox/tp-lightbox-close.ts b/src/lightbox/tp-lightbox-close.ts new file mode 100644 index 0000000..7c4b9b2 --- /dev/null +++ b/src/lightbox/tp-lightbox-close.ts @@ -0,0 +1,31 @@ +/** + * Internal dependencies. + */ +import { TPLightboxElement } from './tp-lightbox'; + +/** + * TP Lightbox Close. + */ +export class TPLightboxCloseElement extends HTMLElement { + /** + * Constructor. + */ + constructor() { + super(); + + // Events. + this.querySelector( 'button' )?.addEventListener( 'click', this.close.bind( this ) ); + } + + /** + * Close the lightbox. + */ + close(): void { + const lightbox: TPLightboxElement | null = this.closest( 'tp-lightbox' ); + if ( lightbox ) { + setTimeout( (): void => { + lightbox.close(); + }, 0 ); + } + } +} diff --git a/src/lightbox/tp-lightbox-content.ts b/src/lightbox/tp-lightbox-content.ts new file mode 100644 index 0000000..d57f0d7 --- /dev/null +++ b/src/lightbox/tp-lightbox-content.ts @@ -0,0 +1,5 @@ +/** + * TP Lightbox Content. + */ +export class TPLightboxContentElement extends HTMLElement { +} diff --git a/src/lightbox/tp-lightbox-count.ts b/src/lightbox/tp-lightbox-count.ts new file mode 100644 index 0000000..89957cb --- /dev/null +++ b/src/lightbox/tp-lightbox-count.ts @@ -0,0 +1,64 @@ +/** + * Internal dependencies. + */ +import { TPLightboxElement } from './tp-lightbox'; + +/** + * TP Slider Count. + */ +export class TPLightboxCountElement extends HTMLElement { + /** + * Get observed attributes. + * + * @return {Array} Observed attributes. + */ + static get observedAttributes(): string[] { + return [ 'format' ]; + } + + /** + * Get format. + * + * @return {string} Format. + */ + get format(): string { + return this.getAttribute( 'format' ) ?? '$current / $total'; + } + + /** + * Set format. + * + * @param {string} format Format. + */ + set format( format: string ) { + this.setAttribute( 'format', format ); + } + + /** + * Attribute changed callback. + */ + attributeChangedCallback(): void { + this.update(); + } + + /** + * Update component. + */ + update(): void { + const lightbox: TPLightboxElement | null = this.closest( 'tp-lightbox' ); + if ( ! lightbox ) { + return; + } + + const current: string = lightbox.currentIndex.toString(); + const total: string = lightbox.getAttribute( 'total' ) ?? ''; + + this.innerHTML = + this.format + .replace( '$current', current ) + .replace( '$total', total ); + + this.setAttribute( 'current', current ); + this.setAttribute( 'total', total ); + } +} diff --git a/src/lightbox/tp-lightbox-next.ts b/src/lightbox/tp-lightbox-next.ts new file mode 100644 index 0000000..126f4b0 --- /dev/null +++ b/src/lightbox/tp-lightbox-next.ts @@ -0,0 +1,35 @@ +/** + * Internal dependencies. + */ +import { TPLightboxElement } from './tp-lightbox'; + +/** + * TP Lightbox Close. + */ +export class TPLightboxNextElement extends HTMLElement { + /** + * Constructor. + */ + constructor() { + super(); + + // Events. + this.querySelector( 'button' )?.addEventListener( 'click', this.next.bind( this ) ); + } + + /** + * Navigate next. + */ + next(): void { + if ( 'yes' === this.getAttribute( 'disabled' ) ) { + return; + } + + const lightbox: TPLightboxElement | null = this.closest( 'tp-lightbox' ); + if ( lightbox ) { + setTimeout( (): void => { + lightbox.next(); + }, 0 ); + } + } +} diff --git a/src/lightbox/tp-lightbox-previous.ts b/src/lightbox/tp-lightbox-previous.ts new file mode 100644 index 0000000..d303186 --- /dev/null +++ b/src/lightbox/tp-lightbox-previous.ts @@ -0,0 +1,35 @@ +/** + * Internal dependencies. + */ +import { TPLightboxElement } from './tp-lightbox'; + +/** + * TP Lightbox Close. + */ +export class TPLightboxPreviousElement extends HTMLElement { + /** + * Constructor. + */ + constructor() { + super(); + + // Events. + this.querySelector( 'button' )?.addEventListener( 'click', this.previous.bind( this ) ); + } + + /** + * Navigate previous. + */ + previous(): void { + if ( 'yes' === this.getAttribute( 'disabled' ) ) { + return; + } + + const lightbox: TPLightboxElement | null = this.closest( 'tp-lightbox' ); + if ( lightbox ) { + setTimeout( (): void => { + lightbox.previous(); + }, 0 ); + } + } +} diff --git a/src/lightbox/tp-lightbox-trigger.ts b/src/lightbox/tp-lightbox-trigger.ts new file mode 100644 index 0000000..1700e88 --- /dev/null +++ b/src/lightbox/tp-lightbox-trigger.ts @@ -0,0 +1,70 @@ +/** + * Internal dependencies. + */ +import { TPLightboxElement } from './tp-lightbox'; + +/** + * TP Lightbox Trigger. + */ +export class TPLightboxTriggerElement extends HTMLElement { + /** + * Constructor. + */ + constructor() { + super(); + + // Events. + this.querySelector( 'button' )?.addEventListener( 'click', this.trigger.bind( this ) ); + } + + /** + * Trigger the lightbox. + */ + trigger(): void { + // Get lightbox ID and template. + const lightboxId: string | null = this.getAttribute( 'lightbox' ); + const template: HTMLTemplateElement | null = this.querySelector( 'template' ); + + // We can't proceed without them. + if ( ! lightboxId || ! template ) { + return; + } + + // Get the lightbox. + const lightbox: TPLightboxElement | null = document.querySelector( `#${ lightboxId.toString() }` ); + if ( ! lightbox ) { + return; + } + + // Check to see if we have a group. + const group: string = this.getAttribute( 'group' ) ?? ''; + + // Yield to main thread. + setTimeout( (): void => { + // Prepare lightbox. + lightbox.template = template; + lightbox.group = group; + + // Set index and group if we have them. + if ( '' !== group ) { + const allGroups: NodeListOf = document.querySelectorAll( `tp-lightbox-trigger[group="${ group }"]` ); + if ( allGroups.length ) { + // Update all groups. + // We do this when we're opening a lightbox, or navigating. + // This allows consumers to inject elements at any point. + lightbox.updateAllGroups( allGroups ); + + // Get current trigger's index within the group. + allGroups.forEach( ( triggerElement: TPLightboxTriggerElement, index: number ): void => { + if ( this === triggerElement ) { + lightbox.currentIndex = index + 1; + } + } ); + } + } + + // All done, lets open the lightbox. + lightbox.open(); + }, 0 ); + } +} diff --git a/src/lightbox/tp-lightbox.ts b/src/lightbox/tp-lightbox.ts new file mode 100644 index 0000000..f56b0cc --- /dev/null +++ b/src/lightbox/tp-lightbox.ts @@ -0,0 +1,363 @@ +/** + * Internal dependencies. + */ +import { TPLightboxContentElement } from './tp-lightbox-content'; +import { TPLightboxPreviousElement } from './tp-lightbox-previous'; +import { TPLightboxNextElement } from './tp-lightbox-next'; +import { TPLightboxTriggerElement } from './tp-lightbox-trigger'; +import { TPLightboxCountElement } from './tp-lightbox-count'; + +/** + * TP Lightbox. + */ +export class TPLightboxElement extends HTMLElement { + /** + * Properties. + */ + protected currentTemplate: HTMLTemplateElement | null = null; + protected currentGroup: string = ''; + protected allGroups: NodeListOf | null = null; + + /** + * Constructor. + */ + constructor() { + super(); + + // Event listeners. + this.querySelector( 'dialog' )?.addEventListener( 'click', this.handleDialogClick.bind( this ) ); + } + + /** + * Get observed attributes. + * + * @return {Array} List of observed attributes. + */ + static get observedAttributes(): string[] { + return [ 'open', 'index', 'total', 'close-on-overlay-click', 'loading' ]; + } + + /** + * Attribute changed callback. + * + * @param {string} name Attribute name. + * @param {string} oldValue Old value. + * @param {string} newValue New value. + */ + attributeChangedCallback( name: string = '', oldValue: string = '', newValue: string = '' ): void { + // Prevent redundant updates. + if ( oldValue === newValue ) { + return; + } + + this.dispatchEvent( new CustomEvent( 'change' ) ); + + // Trigger current index target if index has changed. + if ( 'index' === name ) { + this.triggerCurrentIndexTarget(); + } + } + + /** + * Get template. + */ + get template(): HTMLTemplateElement | null { + return this.currentTemplate; + } + + /** + * Set template. + * + * @param {HTMLTemplateElement} template The template. + */ + set template( template: HTMLTemplateElement | null ) { + // Set the template. + this.currentTemplate = template; + this.dispatchEvent( new CustomEvent( 'template-set' ) ); + + // Get lightbox content element. + const content: TPLightboxContentElement | null = this.querySelector( 'tp-lightbox-content' ); + if ( ! content ) { + return; + } + + // Check if we have a template. + if ( this.currentTemplate ) { + // We do, update content with template's content. + // We do this rather than a string to avoid script injection. + const templateContent: Node = this.currentTemplate.content.cloneNode( true ); + content.replaceChildren( templateContent ); + this.dispatchEvent( new CustomEvent( 'content-change' ) ); + + setTimeout( (): void => { + this.prepareImageLoading(); + this.prepareNavigation(); + }, 0 ); + } else { + // We don't, set content as empty. + content.innerHTML = ''; + } + } + + /** + * Get current group. + */ + get group(): string { + return this.currentGroup; + } + + /** + * Set current group. + * + * @param {string} group Group name. + */ + set group( group: string ) { + this.currentGroup = group; + } + + /** + * Get current index. + */ + get currentIndex(): number { + return parseInt( this.getAttribute( 'index' ) ?? '1' ); + } + + /** + * Set current index. + * + * @param {number} index Current index. + */ + set currentIndex( index: number ) { + if ( index < 1 ) { + index = 1; + } + + // Setting this attributes triggers a re-trigger. + this.setAttribute( 'index', index.toString() ); + } + + /** + * Trigger the target that matches the current index within current group. + */ + triggerCurrentIndexTarget(): void { + // Get all groups and check if current index exists within group. + const allGroups: NodeListOf | null = this.getAllGroups(); + if ( ! allGroups || ! allGroups[ this.currentIndex - 1 ] ) { + return; + } + + // Trigger element within group. + allGroups[ this.currentIndex - 1 ].trigger(); + } + + /** + * Open lightbox. + */ + open(): void { + // Get the dialog element. + const dialog: HTMLDialogElement | null = this.querySelector( 'dialog' ); + + // Check if dialog exists or is already open. + if ( ! dialog || dialog.open ) { + return; + } + + // First, take this opportunity to update all groups (if it wasn't set from the trigger). + if ( '' !== this.group && ! this.allGroups ) { + this.updateAllGroups(); + } + + // Now, show the modal. + dialog.showModal(); + this.setAttribute( 'open', 'yes' ); + } + + /** + * Close lightbox. + */ + close(): void { + // Find and close the dialog. + const dialog: HTMLDialogElement | null = this.querySelector( 'dialog' ); + dialog?.close(); + this.removeAttribute( 'open' ); + + // Clear groups from memory. + this.allGroups = null; + } + + /** + * Navigate previous. + */ + previous(): void { + // Check if we even have a group. + if ( '' === this.group ) { + return; + } + + // Check if we have elements within group. + const allGroups: NodeListOf | null = this.getAllGroups(); + if ( ! allGroups ) { + return; + } + + // Decrement the current index. + if ( this.currentIndex > 1 ) { + this.currentIndex--; + } + } + + /** + * Navigate next. + */ + next(): void { + // Check if we even have a group. + if ( '' === this.group ) { + return; + } + + // Check if we have elements within group. + const allGroups: NodeListOf | null = this.getAllGroups(); + if ( ! allGroups ) { + return; + } + + // Increment the current index. + if ( this.currentIndex < allGroups.length ) { + this.currentIndex++; + } + } + + /** + * Update all groups and save it to memory. + * + * @param {NodeList} allGroups All groups. + */ + updateAllGroups( allGroups: NodeListOf | null = null ): void { + if ( allGroups && allGroups.length ) { + this.allGroups = allGroups; + this.setAttribute( 'total', this.allGroups.length.toString() ); + return; + } + + this.allGroups = document.querySelectorAll( `tp-lightbox-trigger[group="${ this.group }"]` ); + if ( ! this.allGroups.length ) { + this.allGroups = null; + } else { + this.setAttribute( 'total', this.allGroups.length.toString() ); + } + } + + /** + * Get all groups from memory. + */ + getAllGroups(): NodeListOf | null { + return this.allGroups; + } + + /** + * Prepare navigation. + */ + prepareNavigation(): void { + // Update counter. + const count: TPLightboxCountElement | null = this.querySelector( 'tp-lightbox-count' ); + count?.update(); + + // Get previous and next elements. + const previous: TPLightboxPreviousElement | null = this.querySelector( 'tp-lightbox-previous' ); + const next: TPLightboxNextElement | null = this.querySelector( 'tp-lightbox-next' ); + + // Bail early if we don't have either. + if ( ! previous && ! next ) { + return; + } + + // Check if we have a group. + if ( '' === this.group ) { + previous?.setAttribute( 'disabled', 'yes' ); + next?.setAttribute( 'disabled', 'yes' ); + return; + } + + // Check if we have elements within the group. + const allGroups: NodeListOf | null = this.getAllGroups(); + if ( ! allGroups ) { + previous?.setAttribute( 'disabled', 'yes' ); + next?.setAttribute( 'disabled', 'yes' ); + return; + } + + // Enable / disable previous navigation. + if ( this.currentIndex <= 1 ) { + previous?.setAttribute( 'disabled', 'yes' ); + } else { + previous?.removeAttribute( 'disabled' ); + } + + // Enable / disable next navigation. + if ( this.currentIndex < allGroups.length ) { + next?.removeAttribute( 'disabled' ); + } else { + next?.setAttribute( 'disabled', 'yes' ); + } + } + + /** + * Prepare image loading. + */ + prepareImageLoading(): void { + // Get lightbox content element. + const content: TPLightboxContentElement | null = this.querySelector( 'tp-lightbox-content' ); + if ( ! content ) { + return; + } + + // Bail if there are no images within current content. + const images: NodeListOf = content.querySelectorAll( 'img' ); + if ( ! images.length ) { + this.removeAttribute( 'loading' ); + return; + } + + // Start off by setting the state as loading. + this.setAttribute( 'loading', 'yes' ); + + // Prepare increment variables. + let counter: number = 0; + const totalImages: number = images.length; + + /** + * Increment counter. + */ + const incrementLoadingCounter = (): void => { + counter++; + + // Remove loading attribute once all images have loaded. + if ( counter === totalImages ) { + this.removeAttribute( 'loading' ); + } + }; + + // Check if images have loaded, else add an event listener. + images.forEach( ( image: HTMLImageElement ): void => { + if ( image.complete ) { + incrementLoadingCounter(); + } else { + image.addEventListener( 'load', incrementLoadingCounter, { once: true } ); + } + } ); + } + + /** + * Handle when the dialog is clicked. + * + * @param {Event} e Click event. + */ + handleDialogClick( e: MouseEvent ): void { + if ( + 'yes' === this.getAttribute( 'close-on-overlay-click' ) && + this.querySelector( 'dialog' ) === e.target + ) { + this.close(); + } + } +} diff --git a/webpack.config.js b/webpack.config.js index e75ea95..14f1bb9 100755 --- a/webpack.config.js +++ b/webpack.config.js @@ -82,6 +82,7 @@ module.exports = ( env ) => { form: './src/form/index.ts', accordion: './src/accordion/index.ts', 'multi-select': './src/multi-select/index.ts', + lightbox: './src/lightbox/index.ts', }, module: { rules: [