diff --git a/src/modal/README.md b/src/modal/README.md index 5626827..1b39da8 100644 --- a/src/modal/README.md +++ b/src/modal/README.md @@ -24,12 +24,17 @@ import '@travelopia/web-components/dist/modal/style.css'; // TypeScript usage: import { TPModalElement, TPModalCloseElement } from '@travelopia/web-components'; + +... + +const modal: TPModalElement = document.querySelector( 'tp-modal' ); +modal.open(); ``` ```html - <-- There must be a button inside inside this component. + <-- There must be a button inside this component.

Any modal content here.

@@ -42,3 +47,20 @@ import { TPModalElement, TPModalCloseElement } from '@travelopia/web-components' | Attribute | Required | Values | Notes | |----------------------|----------|--------|----------------------------------------------| | overlay-click-close | No | `yes` | Closes the modal when the overlay is clicked | + +## Events + +| Event | Notes | +|-------|--------------------------| +| open | When the modal is opened | +| close | When the modal is closed | + +## Methods + +### `open` + +Open the modal. + +### `close` + +Close the modal. diff --git a/src/slider/README.md b/src/slider/README.md new file mode 100644 index 0000000..52bd0b5 --- /dev/null +++ b/src/slider/README.md @@ -0,0 +1,85 @@ +# Slider + + + + + + +
+

Built by the super talented team at Travelopia.

+
+ +
+ +## Sample Usage + +This is a highly customizable slider component. Pick and choose subcomponents to use, and style as needed! + +Example: + +```js +// Import the component as needed: +import '@travelopia/web-components/dist/slider'; +import '@travelopia/web-components/dist/slider/style.css'; + +// TypeScript usage: +import { TPSliderElement } from '@travelopia/web-components'; + +... + +const slider: TPSliderElement = document.querySelector( 'tp-slider' ); +slider.setCurrentSlide( 2 ); +``` + +```html + + <-- There must be a button inside this component + <-- There must be a button inside this component + + + + +

Any content you want here.

+
+
+
+ + <-- There must be a button inside this component + <-- There must be a button inside this component + + 1 / 2 +
+``` + +## Attributes + +| Attribute | Required | Values | Notes | +|-----------------|----------|--------|--------------------------------------------------------------------------------------------------------| +| flexible-height | No | `yes` | Whether the height of the slider changes depending on the content inside the slides | +| infinite | No | `yes` | Go back to the first slide at the end of all slides, and open the last slide when navigating backwards | +| swipe | No | `yes` | Whether to add support for swiping gestures on touch devices | + +## Events + +| Event | Notes | +|----------------|---------------------------------------------------| +| slide-set | When the current slide is set, but before sliding | +| slide-complete | After sliding is complete | + +## Methods + +### `next` + +Navigate to the next slide. + +### `previous` + +Navigate to the previous slide. + +### `getCurrentSlide` + +Gets the current slide's index. + +### `setCurrentSlide` + +Sets the current slide based on its index. diff --git a/src/slider/index.html b/src/slider/index.html new file mode 100644 index 0000000..06d144c --- /dev/null +++ b/src/slider/index.html @@ -0,0 +1,62 @@ + + + + + + + Web Component: Slider + + + + + + + +
+ + + + + + + + + +

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+
+
+
+ + + + + + + 1 / 4 +
+
+ + diff --git a/src/slider/index.ts b/src/slider/index.ts new file mode 100644 index 0000000..0d43bb6 --- /dev/null +++ b/src/slider/index.ts @@ -0,0 +1,26 @@ +/** + * Styles. + */ +import './style.scss'; + +/** + * Components. + */ +import { TPSliderElement } from './tp-slider'; +import { TPSliderSlidesElement } from './tp-slider-slides'; +import { TPSliderSlideElement } from './tp-slider-slide'; +import { TPSliderArrowElement } from './tp-slider-arrow'; +import { TPSliderNavElement } from './tp-slider-nav'; +import { TPSliderNavItemElement } from './tp-slider-nav-item'; +import { TPSliderCountElement } from './tp-slider-count'; + +/** + * Register Components. + */ +customElements.define( 'tp-slider', TPSliderElement ); +customElements.define( 'tp-slider-slides', TPSliderSlidesElement ); +customElements.define( 'tp-slider-slide', TPSliderSlideElement ); +customElements.define( 'tp-slider-arrow', TPSliderArrowElement ); +customElements.define( 'tp-slider-nav', TPSliderNavElement ); +customElements.define( 'tp-slider-nav-item', TPSliderNavItemElement ); +customElements.define( 'tp-slider-count', TPSliderCountElement ); diff --git a/src/slider/style.scss b/src/slider/style.scss new file mode 100644 index 0000000..db83214 --- /dev/null +++ b/src/slider/style.scss @@ -0,0 +1,35 @@ +tp-slider { + display: block; +} + +tp-slider-track { + display: block; + overflow-y: visible; + overflow-x: clip; + position: relative; +} + +tp-slider-slides { + position: relative; + display: flex; + align-items: flex-start; + + tp-slider:not([resizing="yes"]) & { + transition-duration: 0.6s; + transition-timing-function: cubic-bezier(0.42, 0, 0.58, 1); + } +} + +tp-slider-slide { + flex: 0 0 100%; + scroll-snap-align: start; + + tp-slider[flexible-height="yes"]:not([initialized]) &:not(:first-child) { + display: none; + } +} + +tp-slider-nav { + display: flex; + gap: 10px; +} diff --git a/src/slider/tp-slider-arrow.ts b/src/slider/tp-slider-arrow.ts new file mode 100644 index 0000000..0a805ea --- /dev/null +++ b/src/slider/tp-slider-arrow.ts @@ -0,0 +1,36 @@ +/** + * Internal dependencies. + */ +import { TPSliderElement } from './tp-slider'; + +/** + * TP Slider Arrow. + */ +export class TPSliderArrowElement extends HTMLElement { + /** + * Connected callback. + */ + connectedCallback(): void { + this.querySelector( 'button' )?.addEventListener( 'click', this.handleClick.bind( this ) ); + } + + /** + * Handle when the button is clicked. + */ + handleClick(): void { + if ( 'yes' === this.getAttribute( 'disabled' ) ) { + return; + } + + const slider: TPSliderElement | null = this.closest( 'tp-slider' ); + if ( ! slider ) { + return; + } + + if ( 'previous' === this.getAttribute( 'direction' ) ) { + slider.previous(); + } else if ( 'next' === this.getAttribute( 'direction' ) ) { + slider.next(); + } + } +} diff --git a/src/slider/tp-slider-count.ts b/src/slider/tp-slider-count.ts new file mode 100644 index 0000000..63ba72d --- /dev/null +++ b/src/slider/tp-slider-count.ts @@ -0,0 +1,48 @@ +/** + * TP Slider Count. + */ +export class TPSliderCountElement extends HTMLElement { + /** + * Get observed attributes. + * + * @return {Array} Observed attributes. + */ + static get observedAttributes(): string[] { + return [ 'current', 'total', '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 { + this.innerHTML = + this.format + .replace( '$current', this.getAttribute( 'current' ) ?? '' ) + .replace( '$total', this.getAttribute( 'total' ) ?? '' ); + } +} diff --git a/src/slider/tp-slider-nav-item.ts b/src/slider/tp-slider-nav-item.ts new file mode 100644 index 0000000..457a51f --- /dev/null +++ b/src/slider/tp-slider-nav-item.ts @@ -0,0 +1,43 @@ +/** + * Internal dependencies. + */ +import { TPSliderElement } from './tp-slider'; +import { TPSliderNavElement } from './tp-slider-nav'; + +/** + * TP Slider Nav Item. + */ +export class TPSliderNavItemElement extends HTMLElement { + /** + * Connected callback. + */ + connectedCallback(): void { + this.querySelector( 'button' )?.addEventListener( 'click', this.handleClick.bind( this ) ); + } + + /** + * Handle when the button is clicked. + */ + handleClick(): void { + const slider: TPSliderElement | null = this.closest( 'tp-slider' ); + if ( ! slider ) { + return; + } + + slider.setCurrentSlide( this.getIndex() ); + } + + /** + * Get index of this item inside the navigation. + * + * @return {number} Index. + */ + getIndex(): number { + if ( this.getAttribute( 'index' ) ) { + return parseInt( this.getAttribute( 'index' ) ?? '0' ); + } + + const slideNav: TPSliderNavElement | null = this.closest( 'tp-slider-nav' ); + return Array.from( slideNav?.children ?? [] ).indexOf( this ) + 1; + } +} diff --git a/src/slider/tp-slider-nav.ts b/src/slider/tp-slider-nav.ts new file mode 100644 index 0000000..5aae6c0 --- /dev/null +++ b/src/slider/tp-slider-nav.ts @@ -0,0 +1,5 @@ +/** + * TP Slider Nav. + */ +export class TPSliderNavElement extends HTMLElement { +} diff --git a/src/slider/tp-slider-slide.ts b/src/slider/tp-slider-slide.ts new file mode 100644 index 0000000..96df59b --- /dev/null +++ b/src/slider/tp-slider-slide.ts @@ -0,0 +1,5 @@ +/** + * TP Slider Slide. + */ +export class TPSliderSlideElement extends HTMLElement { +} diff --git a/src/slider/tp-slider-slides.ts b/src/slider/tp-slider-slides.ts new file mode 100644 index 0000000..3f8c6d5 --- /dev/null +++ b/src/slider/tp-slider-slides.ts @@ -0,0 +1,5 @@ +/** + * TP Slider Slides. + */ +export class TPSliderSlidesElement extends HTMLElement { +} diff --git a/src/slider/tp-slider.ts b/src/slider/tp-slider.ts new file mode 100644 index 0000000..263e277 --- /dev/null +++ b/src/slider/tp-slider.ts @@ -0,0 +1,316 @@ +/** + * Internal dependencies. + */ +import { TPSliderSlidesElement } from './tp-slider-slides'; +import { TPSliderSlideElement } from './tp-slider-slide'; +import { TPSliderCountElement } from './tp-slider-count'; +import { TPSliderNavItemElement } from './tp-slider-nav-item'; +import { TPSliderArrowElement } from './tp-slider-arrow'; + +/** + * TP Slider. + */ +export class TPSliderElement extends HTMLElement { + /** + * Properties. + */ + protected touchStartX: number = 0; + + /** + * Constructor. + */ + constructor() { + super(); + + // Set current slide. + if ( ! this.getAttribute( 'current-slide' ) ) { + this.setAttribute( 'current-slide', '1' ); + } + + // Initialize slider. + this.slide(); + this.setAttribute( 'initialized', 'yes' ); + + // Event listeners. + window.addEventListener( 'resize', this.handleResize.bind( this ) ); + this.addEventListener( 'touchstart', this.handleTouchStart.bind( this ) ); + this.addEventListener( 'touchend', this.handleTouchEnd.bind( this ) ); + } + + /** + * Get observed attributes. + * + * @return {Array} List of observed attributes. + */ + static get observedAttributes(): string[] { + return [ 'current-slide', 'flexible-height', 'infinite' ]; + } + + /** + * 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 { + if ( 'current-slide' === name && oldValue !== newValue ) { + this.slide(); + this.dispatchEvent( new CustomEvent( 'slide-complete', { bubbles: true } ) ); + } + + this.update(); + } + + /** + * Get current slide index. + * + * @return {number} Current slide index. + */ + get currentSlideIndex(): number { + return parseInt( this.getAttribute( 'current-slide' ) ?? '1' ); + } + + /** + * Set current slide index. + * + * @param {number} index Slide index. + */ + set currentSlideIndex( index: number ) { + this.setCurrentSlide( index ); + } + + /** + * Get total number of slides. + * + * @return {number} Total slides. + */ + getTotalSlides(): number { + const slides: NodeListOf | null = this.querySelectorAll( 'tp-slider-slide' ); + if ( slides ) { + return slides.length; + } + + return 0; + } + + /** + * Navigate to the next slide. + */ + next(): void { + const totalSlides: number = this.getTotalSlides(); + + if ( this.currentSlideIndex >= totalSlides ) { + if ( 'yes' === this.getAttribute( 'infinite' ) ) { + this.setCurrentSlide( 1 ); + } + + return; + } + + this.setCurrentSlide( this.currentSlideIndex + 1 ); + } + + /** + * Navigate to the previous slide. + */ + previous(): void { + if ( this.currentSlideIndex <= 1 ) { + if ( 'yes' === this.getAttribute( 'infinite' ) ) { + this.setCurrentSlide( this.getTotalSlides() ); + } + + return; + } + + this.setCurrentSlide( this.currentSlideIndex - 1 ); + } + + /** + * Get current slide index. + * + * @return {number} Current slide index. + */ + getCurrentSlide(): number { + return this.currentSlideIndex; + } + + /** + * Set the current slide index. + * + * @param {number} index Slide index. + */ + setCurrentSlide( index: number ): void { + if ( index > this.getTotalSlides() || index <= 0 ) { + return; + } + + this.dispatchEvent( new CustomEvent( 'slide-set', { bubbles: true } ) ); + this.setAttribute( 'current-slide', index.toString() ); + } + + /** + * Slide to the current slide. + * + * @protected + */ + protected slide(): void { + // Check if slider is disabled. + if ( 'yes' === this.getAttribute( 'disabled' ) ) { + return; + } + + // Get slides. + const slidesContainer: TPSliderSlidesElement | null = this.querySelector( 'tp-slider-slides' ); + const slides: NodeListOf | null = this.querySelectorAll( 'tp-slider-slide' ); + if ( ! slidesContainer || ! slides ) { + return; + } + + // First, update the height. + this.updateHeight(); + + // Now lets slide! + slidesContainer.style.left = `-${ this.offsetWidth * ( this.currentSlideIndex - 1 ) }px`; + } + + /** + * Update stuff when any attribute has changed. + * Example: Update subcomponents. + */ + update(): void { + // Get subcomponents. + const sliderNavItems: NodeListOf | null = this.querySelectorAll( 'tp-slider-nav-item' ); + const sliderCount: TPSliderCountElement | null = this.querySelector( 'tp-slider-count' ); + const leftArrow: TPSliderArrowElement | null = this.querySelector( 'tp-slider-arrow[direction="previous"]' ); + const rightArrow: TPSliderArrowElement | null = this.querySelector( 'tp-slider-arrow[direction="next"]' ); + + // Set active slide. + const slides: NodeListOf | null = this.querySelectorAll( 'tp-slider-slide' ); + slides?.forEach( ( slide: TPSliderSlideElement, index: number ): void => { + if ( this.currentSlideIndex - 1 === index ) { + slide.setAttribute( 'active', 'yes' ); + } else { + slide.removeAttribute( 'active' ); + } + } ); + + // Set current slider nav item. + if ( sliderNavItems ) { + sliderNavItems.forEach( ( navItem: TPSliderNavItemElement, index: number ): void => { + if ( this.currentSlideIndex - 1 === index ) { + navItem.setAttribute( 'current', 'yes' ); + } else { + navItem.removeAttribute( 'current' ); + } + } ); + } + + // Update slider count. + if ( sliderCount ) { + sliderCount.setAttribute( 'current', this.getCurrentSlide().toString() ); + sliderCount.setAttribute( 'total', this.getTotalSlides().toString() ); + } + + // Enable / disable arrows. + if ( 'yes' !== this.getAttribute( 'infinite' ) ) { + if ( this.getCurrentSlide() === this.getTotalSlides() ) { + rightArrow?.setAttribute( 'disabled', 'yes' ); + } else { + rightArrow?.removeAttribute( 'disabled' ); + } + + if ( 1 === this.getCurrentSlide() ) { + leftArrow?.setAttribute( 'disabled', 'yes' ); + } else { + leftArrow?.removeAttribute( 'disabled' ); + } + } else { + rightArrow?.removeAttribute( 'disabled' ); + leftArrow?.removeAttribute( 'disabled' ); + } + } + + /** + * Update the height of the slider based on current slide. + */ + updateHeight(): void { + // Get slides container to resize. + const slidesContainer: TPSliderSlidesElement | null = this.querySelector( 'tp-slider-slides' ); + if ( ! slidesContainer ) { + return; + } + + // Bail early if we don't want it to be flexible height. + if ( 'yes' !== this.getAttribute( 'flexible-height' ) ) { + // Remove height property for good measure! + slidesContainer.style.removeProperty( 'height' ); + return; + } + + // Get slides. + const slides: NodeListOf | null = this.querySelectorAll( 'tp-slider-slide' ); + if ( ! slides ) { + return; + } + + // Set the height of the container to be the height of the current slide. + const height: number = slides[ this.currentSlideIndex - 1 ].scrollHeight; + slidesContainer.style.height = `${ height }px`; + } + + /** + * Resize the slider when the window is resized. + * + * @protected + */ + protected handleResize(): void { + // First, lets flag this component as resizing. + this.setAttribute( 'resizing', 'yes' ); + + // Run the slide (so height can be resized). + this.slide(); + + // Done, let's remove the flag. + // We need to do this on a timeout to avoid a race condition with transitions. + const _this = this; + setTimeout( function() { + _this.removeAttribute( 'resizing' ); + }, 10 ); + } + + /** + * Detect touch start event, and store the starting location. + * + * @param {Event} e Touch event. + * + * @protected + */ + protected handleTouchStart( e: TouchEvent ): void { + if ( 'yes' === this.getAttribute( 'swipe' ) ) { + this.touchStartX = e.touches[ 0 ].clientX; + } + } + + /** + * Detect touch end event, and check if it was a left or right swipe. + * + * @param {Event} e Touch event. + * + * @protected + */ + protected handleTouchEnd( e: TouchEvent ): void { + if ( 'yes' !== this.getAttribute( 'swipe' ) ) { + return; + } + + const touchEndX: number = e.changedTouches[ 0 ].clientX; + const swipeDistance: number = touchEndX - this.touchStartX; + + if ( swipeDistance > 0 ) { + this.previous(); + } else if ( swipeDistance < 0 ) { + this.next(); + } + } +} diff --git a/webpack.config.js b/webpack.config.js index e770487..53e0545 100755 --- a/webpack.config.js +++ b/webpack.config.js @@ -77,6 +77,7 @@ module.exports = ( env ) => { const buildConfig = { entry: { modal: './src/modal/index.ts', + slider: './src/slider/index.ts', }, module: { rules: [