+
+## 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
+
+
+
+```
+
+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.
+ <-- There must be template 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Lorem ipsum.
+
Lorem ipsum.
+
+
+
+
+
+
+
+
+
+
+
+
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: [