diff --git a/.gitignore b/.gitignore index 3d3c9a9..b69c673 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ [Dd]esktop.ini # Project +node_modules/ package-lock.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4c54f93 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,66 @@ +# Changelog + +## [1.1.0] - 2024-03-19 + +### Added +- Event system implementation + - `on` method for adding event listeners + - `off` method for removing event listeners + - `once` method for one-time event listeners + - `emit` method for triggering events +- IntersectionObserver API integration +- Support for multiple media types + - Images + - Videos + - Iframes + - Audio + - Embed + - Object +- State information for loading process + - `waiting`: Element not yet visible + - `loading`: Element is visible and loading + - `loaded`: Element loaded successfully + - `error`: Loading error occurred + +### Changed +- Switched from Parcel to Rollup build system +- Added ES Modules support +- Improved code organization + - Separated utils and config + - Better file structure +- Updated build configuration + - UMD format support + - Terser plugin for minification +- Enhanced documentation + - Complete README rewrite + - Added usage examples + - Added API documentation + - Added CSS examples +- Updated package.json + - Added dev dependencies + - Updated scripts + - Added module type + - Updated keywords + - Updated description + +### Removed +- Old demo HTML file +- Old lazy.js implementation +- Old build system +- Unnecessary dependencies + +### Fixed +- Event listener management +- Performance optimizations +- Error handling improvements + +### Security +- Updated license year to 2025 + +## [1.0.5] - 2023-03-19 + +### Initial Release +- Basic lazy loading functionality +- Simple implementation +- Limited media support +- Basic documentation \ No newline at end of file diff --git a/LICENSE b/LICENSE index 93c427c..3f00b61 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 - 2023 drementer +Copyright (c) 2022 - 2025 drementer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index baf3eeb..86e97bf 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,140 @@ -# Lazy Load Images +# Lazy-load.js -Lazy Load Images is a JavaScript utility that allows you to lazy load visual content when it approaches the visible area of the screen. This technique helps improve page loading speed by deferring the loading of images until they are actually needed. +A lightweight lazy loading library for modern browsers. -## Usage +## Features -To use Lazy Load Images, include the `lazyLoadImages` function in your JavaScript code. The function takes two optional parameters: +- Simple and lightweight structure +- Configurable options +- Uses IntersectionObserver API +- Support for various media types (img, video, iframe, etc.) +- Event system for better control -- `selector` (string, default: '[lazy]'): CSS selector for lazy load items. -- `options` (object): IntersectionObserver options. +## Installation -If no selector is provided, the default selector '[lazy]' will be used. -If no options are provided, default options will be used. +```bash +# with npm +npm install lazy-load.js +# or with yarn +yarn add lazy-load.js +``` + +## Basic Usage + +HTML: ```html - - +Lazy loaded image + + ``` +JavaScript: ```javascript -lazyLoad(); +import lazyLoad from 'lazy-load.js'; + +// Start with default settings +const loader = lazyLoad(); + +// Listen to events +loader.on('loaded', (element) => { + console.log('Element loaded:', element); +}); + +loader.on('error', (element, error) => { + console.error('Loading error:', error, element); +}); + +// or with custom options +const customLoader = lazyLoad('[lazy]', { + observer: { + rootMargin: '200px 0px', + threshold: 0.1 + } +}); -// Or +// You can also add event listeners this way +customLoader.on('waiting', (element) => { + console.log('Element waiting to be visible:', element); +}); -lazyLoad('[lazy]', { - root: null, - threshold: 1, - rootMargin: '300px 0px', +// One-time event listener +customLoader.once('loaded', (element) => { + console.log('This will only be called once for the first loaded element'); }); + +// Remove specific event listener +const myHandler = (element) => console.log('Element loaded:', element); +customLoader.on('loaded', myHandler); +customLoader.off('loaded', myHandler); +``` + +## Custom Attributes + +- `lazy`: Main lazy loading attribute for src +- `lazy-srcset`: For srcset in img elements +- `lazy-poster`: For poster in video elements + +## State Information + +The library adds a `lazy-state` attribute to elements at each stage of the loading process: + +- `waiting`: Element is not yet visible +- `loading`: Element is visible and loading +- `loaded`: Element has loaded successfully +- `error`: An error occurred during loading + +You can apply CSS styles based on this attribute: + +```css +[lazy-state="loading"] { + filter: blur(5px); + transition: filter 0.3s; +} + +[lazy-state="loaded"] { + filter: blur(0); +} + +[lazy-state="error"] { + opacity: 0.5; + filter: grayscale(100%); +} ``` -The lazy load functionality will be applied to all elements that match the given selector. When an element approaches the visible area of the screen, its 'lazy' attribute will be used as the source for the 'src' attribute, and the element will be marked as loaded by adding the '-loaded' class. +## All Settings + +```javascript +{ + attrs: { + src: 'lazy', + srcset: 'lazy-srcset', + poster: 'lazy-poster', + }, + observer: { + root: null, + threshold: 1, + rootMargin: '100% 0px', + } +} +``` + +## Events + +The library provides an event system for better control over the loading process: + +- `waiting`: Fired when element is not yet visible +- `loading`: Fired when element starts loading +- `loaded`: Fired when element is loaded successfully +- `error`: Fired when loading fails -## Developer +## Browser Support -[@drementer](https://github.com/drementer) +- Chrome 51+ +- Firefox 55+ +- Safari 12.1+ +- Edge 79+ ## License -[MIT](https://choosealicense.com/licenses/mit/) +MIT diff --git a/dist/lazy-load.js b/dist/lazy-load.js new file mode 100644 index 0000000..a470c03 --- /dev/null +++ b/dist/lazy-load.js @@ -0,0 +1,7 @@ +/** + * lazy-load.js + * @version 0.0.8 + * @license MIT + * @link https://github.com/drementer/lazy-load.js + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).lazyLoad=t()}(this,(function(){"use strict";var e={attrs:{src:"lazy",srcset:"lazy-srcset",poster:"lazy-poster"},observer:{root:null,threshold:1,rootMargin:"100% 0px"}};class t{#e=new Map;on(e,t){return this.#e.has(e)||this.#e.set(e,[]),this.#e.get(e).push(t),this}off(e,t){if(!this.#e.has(e))return;const s=this.#e.get(e);return this.#e.set(e,((e,t)=>e.filter((e=>e!==t)))(s,t)),this}once(e,t){const s=(...r)=>{t(...r),this.off(e,s)};return this.on(e,s)}emit(e,...t){const s=this.#e.get(e)||[];return!!s.length&&(s.forEach((e=>e(...t))),!0)}get events(){return this.#e}}const s=["img","video","embed","object","iframe","audio"];class r extends t{#t;#s;constructor(t,s){super(),this.#t=t,this.#s={...e,...s},this.init()}#r(e){Object.entries(this.#s.attrs).forEach((([t,s])=>e.removeAttribute(s)))}#n(e){try{(e=>{const t=e.tagName.toLowerCase();if(!s.includes(t))throw new Error(`${t} Element is not supported!`)})(e),this.emit("waiting",e),((e,t,s)=>{new IntersectionObserver(((e,s)=>{e.forEach((e=>{e.isIntersecting&&(t(e.target),s.unobserve(e.target))}))}),s).observe(e)})(e,this.#o,this.#s.observer)}catch(t){console.warn("Lazy-load error:",e)}}#o(e){var t,s;this.emit("loading",e),t=e,s=this.#s,Object.entries(s.attrs).forEach((([e,s])=>{const r=t.getAttribute(s);r&&t.setAttribute(e,r)})),e.addEventListener("load",(()=>{this.#r(e),this.emit("loaded",e)}),{once:!0}),e.addEventListener("error",(()=>{this.emit("error",e,"Loading media failed"),console.warn("Lazy-load error:",e)}),{once:!0})}init(){return(e=>{if(e instanceof Element)return[e];if(e instanceof NodeList)return e;if(e instanceof Array)return e;const t=document.querySelectorAll(e);if(!t.length)throw new Error("No lazy loadable element found!");return t})(this.#t).forEach((e=>this.#n(e))),this}}return(e="[lazy]",t={})=>new r(e,t)})); diff --git a/index.html b/index.html new file mode 100644 index 0000000..31248f8 --- /dev/null +++ b/index.html @@ -0,0 +1,304 @@ + + + + + + + Lazy-load.js - Simple and Lightweight Lazy Loading Library + + + + + + + + + +
+
+

Lazy-load.js

+

Lightweight, fast and customizable lazy loading library

+ View on GitHub +
+
+ +
+
+
+

Features

+

What can you do with Lazy-load.js?

+
+ +
+
+

Fast & Lightweight

+

+ Delivers maximum performance with minimal code size. Significantly reduces page loading times. +

+
+ +
+

Easy to Use

+

+ Extremely easy to implement and configure. You can lazy load all elements on your page with just one line of code. +

+
+ +
+

Multiple Media Support

+

+ Provides support for images, videos, iframes and more. Optimize all your media elements with the same library. +

+
+
+
+
+ +
+
+
+

How to Use

+

Start with just a few lines of code

+
+ +
+ + <!-- HTML -->
+ <img lazy="image.jpg" alt="Description">

+ + <!-- JS -->
+ <script src="lazy-load.js"></script>
+ <script>
+   lazyLoad();
+ </script> +
+
+ +

Live Demo

+

Scroll down to trigger lazy loading

+ + Demo Image 1 + Demo Image 1 + Demo Image 2 + Demo Image 3 + Demo Image 4 + Demo Image 5 + Demo Image 6 + Demo Image 7 +
+
+ + + + + diff --git a/package.json b/package.json index 1c4e759..32aa114 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,46 @@ { - "name": "lazy-load.js", - "version": "1.0.5", - "homepage": "https://github.com/drementer/lazy-load.js", - "description": "Loads visual content when it approaches the visible area of ​​the screen to increase page loading speed.", - "scripts": { - "build": "parcel build src/lazy.js --dist-dir dist --no-source-maps" - }, - "keywords": [ - "js", - "lazy", - "lazy load", - "lazy-load.js" - ], - "author": "drementer (https://github.com/drementer)", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/drementer/lazy-load.js.git" - }, - "bugs": { - "url": "https://github.com/drementer/lazy-load.js/issues" - } + "name": "lazy-load.js", + "version": "1.1.0", + "type": "module", + "homepage": "https://github.com/drementer/lazy-load.js", + "description": "Another, another and another Lazy Load Library", + "module": "dist/lazy-load.js", + "files": [ + "dist" + ], + "scripts": { + "dev": "rollup -c -w", + "build": "rollup -c", + "test": "jasmine" + }, + "keywords": [ + "lazy", + "lazy load", + "lazy loader", + "lazy loading", + "lazy-load.js", + "loader", + "lazyloader", + "lazyload", + "performance", + "web performance", + "IntersectionObserver", + "image", + "video", + "iframe" + ], + "author": "drementer (https://github.com/drementer)", + "repository": { + "type": "git", + "url": "git+https://github.com/drementer/lazy-load.js.git" + }, + "bugs": { + "url": "https://github.com/drementer/lazy-load.js/issues" + }, + "license": "MIT", + "devDependencies": { + "@rollup/plugin-terser": "^0.4.4", + "jasmine": "^5.7.1", + "rollup": "^4.39.0" + } } diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..3fb9dd2 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,27 @@ +import terser from '@rollup/plugin-terser'; + +export default { + input: 'src/index.js', + output: { + file: 'dist/lazy-load.js', + format: 'umd', + name: 'lazyLoad', + exports: 'auto', + banner: `/** + * lazy-load.js + * @version 1.1.0 + * @license MIT + * @link https://github.com/drementer/lazy-load.js + */`, + }, + plugins: [ + terser({ + format: { + comments: 'some' + }, + mangle: { + reserved: ['lazyLoad'] + } + }), + ], +}; diff --git a/spec/assetLoader.spec.mjs b/spec/assetLoader.spec.mjs new file mode 100644 index 0000000..b5bca33 --- /dev/null +++ b/spec/assetLoader.spec.mjs @@ -0,0 +1,212 @@ +import assetLoader from '../src/services/assetLoader.js'; + +const createElement = (tagName) => { + return { + tagName: tagName.toUpperCase(), + attributes: new Map(), + getAttribute(name) { + /** + * Simulates real DOM getAttribute behavior: + * - Returns the attribute value if it exists (including empty strings) + * - Returns null if the attribute doesn't exist + */ + return this.attributes.has(name) ? this.attributes.get(name) : null; + }, + setAttribute(name, value) { + this.attributes.set(name, value); + }, + removeAttribute(name) { + this.attributes.delete(name); + }, + }; +}; + +const options = { + attrs: { + src: 'data-src', + srcset: 'data-srcset', + poster: 'data-poster', + }, +}; + +describe('loadAttribute functionality', () => { + let element; + beforeEach(() => (element = createElement('img'))); + + it('should load and remove lazy attribute when value exists', () => { + element.setAttribute('data-src', 'test-image.jpg'); + + assetLoader(element, options); + expect(element.getAttribute('src')).toBe('test-image.jpg'); + expect(element.getAttribute('data-src')).toBeNull(); + }); + + it('should not modify element when lazy attribute is empty', () => { + element.setAttribute('data-src', ''); + + assetLoader(element, options); + expect(element.getAttribute('src')).toBeNull(); + expect(element.getAttribute('data-src')).toBe(''); + }); + + it('should not modify element when lazy attribute does not exist', () => { + assetLoader(element, options); + expect(element.getAttribute('src')).toBeNull(); + expect(element.getAttribute('data-src')).toBeNull(); + }); +}); + +describe('Image loading', () => { + let element; + beforeEach(() => (element = createElement('img'))); + + it('should load src and srcset for image elements', () => { + element.setAttribute('data-src', 'image.jpg'); + element.setAttribute('data-srcset', 'image-2x.jpg 2x'); + + assetLoader(element, options); + + expect(element.getAttribute('src')).toBe('image.jpg'); + expect(element.getAttribute('srcset')).toBe('image-2x.jpg 2x'); + expect(element.getAttribute('data-src')).toBeNull(); + expect(element.getAttribute('data-srcset')).toBeNull(); + }); + + it('should handle image with only src attribute', () => { + element.setAttribute('data-src', 'single-image.jpg'); + + assetLoader(element, options); + + expect(element.getAttribute('src')).toBe('single-image.jpg'); + expect(element.getAttribute('srcset')).toBeNull(); + }); + + it('should handle image with only srcset attribute', () => { + element.setAttribute( + 'data-srcset', + 'responsive.jpg 1x, responsive-2x.jpg 2x' + ); + + assetLoader(element, options); + + expect(element.getAttribute('srcset')).toBe( + 'responsive.jpg 1x, responsive-2x.jpg 2x' + ); + expect(element.getAttribute('src')).toBeNull(); + }); +}); + +describe('Video loading', () => { + let element; + beforeEach(() => (element = createElement('video'))); + + it('should load src and poster for video elements', () => { + element.setAttribute('data-src', 'video.mp4'); + element.setAttribute('data-poster', 'poster.jpg'); + + assetLoader(element, options); + + expect(element.getAttribute('src')).toBe('video.mp4'); + expect(element.getAttribute('poster')).toBe('poster.jpg'); + expect(element.getAttribute('data-src')).toBeNull(); + expect(element.getAttribute('data-poster')).toBeNull(); + }); + + it('should handle video with only src attribute', () => { + element.setAttribute('data-src', 'video-only.mp4'); + + assetLoader(element, options); + + expect(element.getAttribute('src')).toBe('video-only.mp4'); + expect(element.getAttribute('poster')).toBeNull(); + }); +}); + +describe('Iframe loading', () => { + let element; + beforeEach(() => (element = createElement('iframe'))); + + it('should load src for iframe elements', () => { + element.setAttribute('data-src', 'https://example.com'); + + assetLoader(element, options); + + expect(element.getAttribute('src')).toBe('https://example.com'); + expect(element.getAttribute('data-src')).toBeNull(); + }); + + it('should handle iframe without src attribute', () => { + assetLoader(element, options); + expect(element.getAttribute('src')).toBeNull(); + }); +}); + +describe('Default loading', () => { + it('should handle unknown elements with default loader', () => { + const element = createElement('div'); + element.setAttribute('data-src', 'background.jpg'); + + assetLoader(element, options); + + expect(element.getAttribute('src')).toBe('background.jpg'); + expect(element.getAttribute('data-src')).toBeNull(); + }); + + it('should process all attributes for default elements', () => { + const element = createElement('section'); + element.setAttribute('data-src', 'bg.jpg'); + element.setAttribute('data-srcset', 'bg-2x.jpg 2x'); + element.setAttribute('data-poster', 'thumb.jpg'); + + assetLoader(element, options); + + expect(element.getAttribute('src')).toBe('bg.jpg'); + expect(element.getAttribute('srcset')).toBe('bg-2x.jpg 2x'); + expect(element.getAttribute('poster')).toBe('thumb.jpg'); + expect(element.getAttribute('data-src')).toBeNull(); + expect(element.getAttribute('data-srcset')).toBeNull(); + expect(element.getAttribute('data-poster')).toBeNull(); + }); +}); + +describe('Error handling', () => { + it('should log warning and re-throw error when loading fails', () => { + const element = createElement('img'); + spyOn(console, 'warn'); + + const originalGetAttribute = element.getAttribute; + element.getAttribute = jasmine + .createSpy('getAttribute') + .and.throwError('Test error'); + + expect(() => { + assetLoader(element, options); + }).toThrowError('Test error'); + + expect(console.warn).toHaveBeenCalledWith( + 'Failed to load media:', + jasmine.any(Error) + ); + + element.getAttribute = originalGetAttribute; + }); +}); + +describe('Edge cases', () => { + it('should handle elements with uppercase tagName', () => { + const element = createElement('img'); + element.setAttribute('data-src', 'uppercase.jpg'); + + assetLoader(element, options); + expect(element.getAttribute('src')).toBe('uppercase.jpg'); + }); + + it('should handle empty options', () => { + const emptyOptions = { attrs: {} }; + const element = createElement('img'); + + expect(() => { + assetLoader(element, emptyOptions); + }).not.toThrow(); + }); +}); diff --git a/spec/checkSupport.spec.mjs b/spec/checkSupport.spec.mjs new file mode 100644 index 0000000..4150335 --- /dev/null +++ b/spec/checkSupport.spec.mjs @@ -0,0 +1,196 @@ +import checkSupport from '../src/utils/checkSupport.js'; + +const createElement = (tagName) => ({ tagName }); + +describe('Supported Elements', () => { + const supportedElements = [ + 'img', + 'video', + 'embed', + 'object', + 'iframe', + 'audio', + ]; + + const testSupportedElement = (elementType) => { + it(`should support ${elementType} elements`, () => { + const element = createElement(elementType); + expect(() => checkSupport(element)).not.toThrow(); + expect(checkSupport(element)).toBe(true); + }); + }; + + supportedElements.forEach(testSupportedElement); +}); + +describe('Unsupported Elements', () => { + const unsupportedElements = [ + 'div', + 'span', + 'p', + 'h1', + 'h2', + 'h3', + 'section', + 'article', + 'header', + 'footer', + 'nav', + 'main', + 'aside', + ]; + + const testUnsupportedElement = (elementType) => { + it(`should not support ${elementType} elements`, () => { + const element = createElement(elementType); + + expect(() => checkSupport(element)).toThrowError( + `${element.tagName} Element is not supported!` + ); + }); + }; + + unsupportedElements.forEach(testUnsupportedElement); +}); + +describe('Case Sensitivity', () => { + it('should be case insensitive for supported elements', () => { + const testCases = [ + createElement('img'), + createElement('IMG'), + createElement('Img'), + createElement('iMg'), + ]; + + testCases.forEach((element) => { + expect(() => checkSupport(element)).not.toThrow(); + expect(checkSupport(element)).toBe(true); + }); + }); + + it('should be case insensitive for unsupported elements', () => { + const testCases = [ + createElement('div'), + createElement('DIV'), + createElement('Div'), + createElement('dIv'), + ]; + + testCases.forEach(({ tagName }) => { + const element = { tagName }; + expect(() => checkSupport(element)).toThrowError( + `${tagName} Element is not supported!` + ); + }); + }); +}); + +describe('Error Scenarios', () => { + it('should throw error when element is null', () => { + expect(() => checkSupport(null)).toThrow(); + }); + + it('should throw error when element is undefined', () => { + expect(() => checkSupport(undefined)).toThrow(); + }); + + it('should throw error when element has no tagName property', () => { + const element = {}; + + expect(() => checkSupport(element)).toThrow(); + }); + + it('should throw error when tagName is null', () => { + const element = { tagName: null }; + + expect(() => checkSupport(element)).toThrow(); + }); + + it('should throw error when tagName is undefined', () => { + const element = { tagName: undefined }; + + expect(() => checkSupport(element)).toThrow(); + }); + + it('should throw error when tagName is not a string', () => { + const element = { tagName: 123 }; + + expect(() => checkSupport(element)).toThrow(); + }); +}); + +describe('Return Values', () => { + it('should not return anything for unsupported elements (throws before return)', () => { + const element = createElement('div'); + + expect(() => { + const result = checkSupport(element); + expect(result).toBeUndefined(); + }).toThrow(); + }); +}); + +describe('Edge Cases', () => { + it('should handle elements with extra whitespace in tagName', () => { + const element = createElement(' IMG '); + + // Current implementation doesn't trim, so this should fail + expect(() => checkSupport(element)).toThrowError( + `${element.tagName} Element is not supported!` + ); + }); + + it('should handle empty tagName', () => { + const element = createElement(''); + + expect(() => checkSupport(element)).toThrowError( + `${element.tagName} Element is not supported!` + ); + }); + + it('should handle custom elements', () => { + const element = createElement('CUSTOM-ELEMENT'); + + expect(() => checkSupport(element)).toThrowError( + `${element.tagName} Element is not supported!` + ); + }); + + it('should handle web components', () => { + const element = createElement('MY-VIDEO-PLAYER'); + + expect(() => checkSupport(element)).toThrowError( + `${element.tagName} Element is not supported!` + ); + }); +}); + +describe('Performance', () => { + it('should handle multiple calls efficiently', () => { + const element = createElement('img'); + + const startTime = performance.now(); + + for (let i = 0; i < 1000; i++) { + checkSupport(element); + } + + const endTime = performance.now(); + const duration = endTime - startTime; + + expect(duration).toBeLessThan(100); + }); + + it('should handle different elements in sequence', () => { + const elements = [ + createElement('img'), + createElement('video'), + createElement('iframe'), + createElement('audio'), + ]; + + expect(() => { + elements.forEach((element) => checkSupport(element)); + }).not.toThrow(); + }); +}); diff --git a/spec/eventEmitter.spec.mjs b/spec/eventEmitter.spec.mjs new file mode 100644 index 0000000..048fb3c --- /dev/null +++ b/spec/eventEmitter.spec.mjs @@ -0,0 +1,42 @@ +import EventEmitter from '../src/core/eventEmitter.js'; +const { log } = console; + +describe('Event Emitter', () => { + let emitter; + + beforeEach(() => (emitter = new EventEmitter())); + + it('Should add events', () => { + emitter.on('Test Event', () => true); + expect(emitter.events['Test Event']).toBeDefined(); + }); + + it('Should add multiple callbacks to same event', () => { + emitter.on('Test Event', () => true); + emitter.on('Test Event', () => true); + expect(emitter.events['Test Event'].length).toBe(2); + }); + + it('Should remove specific callback from event', () => { + const callback1 = () => 'Callback 1'; + const callback2 = () => 'Callback 2'; + + emitter.on('Test Event', callback1); + emitter.on('Test Event', callback2); + emitter.off('Test Event', callback1); + + expect(emitter.events['Test Event'].length).toBe(1); + expect(emitter.events['Test Event']).toContain(callback2); + }); + + it('Should run once callback only one time', () => { + let count = 0; + emitter.once('Test Event', () => count++); + + emitter.emit('Test Event'); + emitter.emit('Test Event'); + emitter.emit('Test Event'); + + expect(count).toBe(1); + }); +}); diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json new file mode 100644 index 0000000..c442a51 --- /dev/null +++ b/spec/support/jasmine.json @@ -0,0 +1,7 @@ +{ + "spec_dir": "spec", + "spec_files": ["**/*[sS]pec.mjs"], + "helpers": ["helpers/**/*.mjs"], + "stopSpecOnExpectationFailure": false, + "random": true +} diff --git a/src/config/defaultOptions.js b/src/config/defaultOptions.js new file mode 100644 index 0000000..f116a52 --- /dev/null +++ b/src/config/defaultOptions.js @@ -0,0 +1,12 @@ +export default { + attrs: { + src: 'lazy', + srcset: 'lazy-srcset', + poster: 'lazy-poster', + }, + observer: { + root: null, + threshold: 1, + rootMargin: '100% 0px', + }, +}; diff --git a/src/core/eventEmitter.js b/src/core/eventEmitter.js new file mode 100644 index 0000000..16b3050 --- /dev/null +++ b/src/core/eventEmitter.js @@ -0,0 +1,60 @@ +export default class { + #events = new Map(); + + #validateParams(eventName, callback) { + if (typeof eventName !== 'string') { + return console.warn('Event name must be a string'); + } + if (typeof callback !== 'function') { + return console.warn('Callback must be a function'); + } + } + + on(eventName, callback) { + this.#validateParams(eventName, callback); + + if (!this.#events.has(eventName)) { + this.#events.set(eventName, []); + } + this.#events.get(eventName).push(callback); + return this; + } + + off(eventName, callback) { + this.#validateParams(eventName, callback); + + if (!this.#events.has(eventName)) return this; + + const listeners = this.#events.get(eventName); + this.#events.set( + eventName, + listeners.filter((listener) => listener !== callback) + ); + return this; + } + + once(eventName, callback) { + const oneTimeListener = (...args) => { + callback(...args); + this.off(eventName, oneTimeListener); + }; + + return this.on(eventName, oneTimeListener); + } + + emit(eventName, ...args) { + if (typeof eventName !== 'string') { + return console.warn('Event name must be a string'); + } + + const handlers = this.#events.get(eventName); + if (!handlers?.length) return false; + + handlers.forEach((callback) => callback(...args)); + return true; + } + + get events() { + return Object.fromEntries(this.#events); + } +} diff --git a/src/core/lazyLoader.js b/src/core/lazyLoader.js new file mode 100644 index 0000000..49c5708 --- /dev/null +++ b/src/core/lazyLoader.js @@ -0,0 +1,56 @@ +import defaultOptions from '../config/defaultOptions.js'; +import EventEmitter from './eventEmitter.js'; +import assetLoader from '../services/assetLoader.js'; +import checkSupport from '../utils/checkSupport.js'; +import observer from '../utils/observer.js'; +import getElements from '../utils/getElements.js'; + +export default class extends EventEmitter { + #selector; + #options; + + constructor(selector, customOptions) { + super(); + this.#selector = selector; + this.#options = { ...defaultOptions, ...customOptions }; + + this.#init(); + } + + #processItem(item) { + try { + checkSupport(item); + this.emit('waiting', item); + observer(item, this.#handleLoading.bind(this), this.#options.observer); + } catch (error) { + console.warn('Lazy-load error:', item); + } + } + + #handleLoading(target) { + this.emit('loading', target); + assetLoader(target, this.#options); + + target.addEventListener( + 'load', + () => { + this.emit('loaded', target); + }, + { once: true } + ); + target.addEventListener( + 'error', + () => { + this.emit('error', target, 'Loading media failed'); + console.warn('Lazy-load error:', target); + }, + { once: true } + ); + } + + #init() { + const lazyItems = getElements(this.#selector); + lazyItems.forEach((item) => this.#processItem(item)); + return this; + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..0faaa20 --- /dev/null +++ b/src/index.js @@ -0,0 +1,5 @@ +import LazyLoader from './core/lazyLoader.js'; + +export default (selector = '[lazy]', customOptions = {}) => { + return new LazyLoader(selector, customOptions); +}; diff --git a/src/lazy.js b/src/lazy.js deleted file mode 100644 index a915ff2..0000000 --- a/src/lazy.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Lazy loads visual content when it approaches - * the visible area of the screen to increase - * page loading speed. - * - * @param {string} [selector='[lazy]'] - CSS selector for lazy load items. - * @param {Object} [options] - IntersectionObserver options. - */ -const lazyLoad = (selector = '[lazy]', options = {}) => { - const lazyLoadItems = document.querySelectorAll(selector); - - const defaultOptions = { - root: null, - threshold: 1, - rootMargin: '300px 0px', - }; - - const mergedOptions = { ...defaultOptions, ...options }; - - const observer = new IntersectionObserver((entries, observer) => { - entries.forEach((entry) => { - if (!entry.isIntersecting) return; - - const target = entry.target; - const value = target.getAttribute('lazy'); - - target.classList.add('-loaded'); - target.removeAttribute('lazy'); - target.setAttribute('src', value); - - observer.unobserve(target); - }); - }, mergedOptions); - - lazyLoadItems.forEach((item) => observer.observe(item)); -}; diff --git a/src/services/assetLoader.js b/src/services/assetLoader.js new file mode 100644 index 0000000..46ea8e0 --- /dev/null +++ b/src/services/assetLoader.js @@ -0,0 +1,51 @@ +export default (element, options) => { + const { attrs } = options; + const elementType = element.tagName.toLowerCase(); + + const loadAttribute = (element, attr, lazyAttr) => { + const value = element.getAttribute(lazyAttr); + + if (!value) return; + element.setAttribute(attr, value); + element.removeAttribute(lazyAttr); + }; + + const loadImage = () => { + loadAttribute(element, 'src', attrs.src); + loadAttribute(element, 'srcset', attrs.srcset); + }; + + const loadVideo = () => { + loadAttribute(element, 'src', attrs.src); + loadAttribute(element, 'poster', attrs.poster); + }; + + const loadIframe = () => { + loadAttribute(element, 'src', attrs.src); + }; + + const loadDefault = () => { + Object.entries(attrs).forEach(([attr, lazyAttr]) => { + loadAttribute(element, attr, lazyAttr); + }); + }; + + const loaders = { + img: loadImage, + video: loadVideo, + iframe: loadIframe, + default: loadDefault, + }; + + const loadMedia = () => { + const loader = loaders[elementType] || loaders.default; + return loader(); + }; + + try { + loadMedia(); + } catch (error) { + console.warn('Failed to load media:', error); + throw error; + } +}; diff --git a/src/utils/checkSupport.js b/src/utils/checkSupport.js new file mode 100644 index 0000000..81288cc --- /dev/null +++ b/src/utils/checkSupport.js @@ -0,0 +1,16 @@ +const supportedElements = [ + 'img', + 'video', + 'embed', + 'object', + 'iframe', + 'audio', +]; + +export default (element) => { + const elementType = element.tagName; + const isSupported = supportedElements.includes(elementType.toLowerCase()); + + if (!isSupported) throw new Error(`${elementType} Element is not supported!`); + return true; +}; diff --git a/src/utils/getElements.js b/src/utils/getElements.js new file mode 100644 index 0000000..831812c --- /dev/null +++ b/src/utils/getElements.js @@ -0,0 +1,9 @@ +export default (selector) => { + if (selector instanceof Element) return [selector]; + if (selector instanceof NodeList) return selector; + if (selector instanceof Array) return selector; + + const elements = document.querySelectorAll(selector); + if (!elements.length) throw new Error('No lazy loadable element found!'); + return elements; +}; diff --git a/src/utils/observer.js b/src/utils/observer.js new file mode 100644 index 0000000..5830da0 --- /dev/null +++ b/src/utils/observer.js @@ -0,0 +1,13 @@ +export default (item, callback, settings) => { + const handleIntersection = (entries, observer) => { + const handleEntry = (entry) => { + if (!entry.isIntersecting) return; + callback(entry.target); + observer.unobserve(entry.target); + }; + entries.forEach(handleEntry); + }; + + const observer = new IntersectionObserver(handleIntersection, settings); + observer.observe(item); +}; diff --git a/test/index.html b/test/index.html deleted file mode 100644 index bc53a52..0000000 --- a/test/index.html +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - Lazy-load.js - - - - Random Image - Random Image - Random Image - Random Image - Random Image - Random Image - Random Image - Random Image - Random Image - Random Image - - - - - - - - - \ No newline at end of file