From 72ec319a8fc295b62552f924059043aab3aec057 Mon Sep 17 00:00:00 2001 From: johnzanussi Date: Wed, 13 Dec 2023 21:20:01 -0500 Subject: [PATCH] Full conversion to TypeScript #39 --- demo/dist/simple-scrollspy.min.js | 2 +- package.json | 6 +- src/index.d.ts | 35 ------- src/index.js | 9 -- src/index.ts | 19 ++++ src/scrollspy.js | 122 ------------------------ src/scrollspy.ts | 151 ++++++++++++++++++++++++++++++ tsconfig.json | 22 +++++ webpack.config.js | 22 ++++- 9 files changed, 216 insertions(+), 172 deletions(-) delete mode 100644 src/index.d.ts delete mode 100644 src/index.js create mode 100644 src/index.ts delete mode 100644 src/scrollspy.js create mode 100644 src/scrollspy.ts create mode 100644 tsconfig.json diff --git a/demo/dist/simple-scrollspy.min.js b/demo/dist/simple-scrollspy.min.js index b8ec50f..0752890 100644 --- a/demo/dist/simple-scrollspy.min.js +++ b/demo/dist/simple-scrollspy.min.js @@ -1 +1 @@ -!function(t,o){"object"==typeof exports&&"object"==typeof module?module.exports=o():"function"==typeof define&&define.amd?define([],o):"object"==typeof exports?exports.scrollSpy=o():t.scrollSpy=o()}(self,(()=>(()=>{var t={138:(t,o,e)=>{t.exports=(t,o={})=>{const{ScrollSpy:s}=e(218),i=new s(t,o);return window.onload=i.onScroll(),window.addEventListener("scroll",(()=>i.onScroll())),i}},218:(t,o,e)=>{"use strict";e.r(o),e.d(o,{ScrollSpy:()=>s});class s{constructor(t,o={}){if(!t)throw new Error("Your navigation query selector is the first argument.");if("object"!=typeof o)throw new Error("The second argument must be an instance of an Object.");o.smoothScroll=!0===o.smoothScroll&&{}||o.smoothScroll,this.menuList=t instanceof HTMLElement?t:document.querySelector(t),this.options=Object.assign({},{sectionClass:".scrollspy",menuActiveTarget:"li > a",offset:0,hrefAttribute:"href",activeClass:"active",scrollContainer:"",smoothScroll:{}},o),this.options.scrollContainer?this.scroller=this.options.scrollContainer instanceof HTMLElement?this.options.scrollContainer:document.querySelector(this.options.scrollContainer):this.scroller=window,this.sections=document.querySelectorAll(this.options.sectionClass),this.attachEventListeners()}attachEventListeners(){if(this.scroller&&(this.scroller.addEventListener("scroll",(()=>this.onScroll())),this.options.smoothScroll)){this.menuList.querySelectorAll(this.options.menuActiveTarget).forEach((t=>t.addEventListener("click",this.onClick.bind(this))))}}onClick(t){const o=t.target.getAttribute(this.options.hrefAttribute),e=document.querySelector(o);e&&this.options.smoothScroll&&(t.preventDefault(),this.scrollTo(e))}onScroll(){const t=this.getSectionInView(),o=this.getMenuItemBySection(t);o&&(this.removeCurrentActive({ignore:o}),this.setActive(o))}scrollTo(t){const o="function"==typeof this.options.smoothScrollBehavior&&this.options.smoothScrollBehavior;o?o(t,this.options.smoothScroll):t.scrollIntoView({...this.options.smoothScroll,behavior:"smooth"})}getMenuItemBySection(t){if(!t)return;const o=t.getAttribute("id");return this.menuList.querySelector(`[${this.options.hrefAttribute}="#${o}"]`)}getSectionInView(){for(let t=0;to&&s<=e)return this.sections[t]}}setActive(t){t.classList.contains(this.options.activeClass)||(t.classList.add(this.options.activeClass),"function"==typeof this.options.onActive&&this.options.onActive(t))}removeCurrentActive({ignore:t}){const{hrefAttribute:o,menuActiveTarget:e,activeClass:s}=this.options,i=`${e}.${s}:not([${o}="${t.getAttribute(o)}"])`;this.menuList.querySelectorAll(i).forEach((t=>t.classList.remove(this.options.activeClass)))}}}},o={};function e(s){var i=o[s];if(void 0!==i)return i.exports;var r=o[s]={exports:{}};return t[s](r,r.exports,e),r.exports}return e.d=(t,o)=>{for(var s in o)e.o(o,s)&&!e.o(t,s)&&Object.defineProperty(t,s,{enumerable:!0,get:o[s]})},e.o=(t,o)=>Object.prototype.hasOwnProperty.call(t,o),e.r=t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},e(138)})())); \ No newline at end of file +!function(t,o){"object"==typeof exports&&"object"==typeof module?module.exports=o():"function"==typeof define&&define.amd?define([],o):"object"==typeof exports?exports.scrollSpy=o():t.scrollSpy=o()}(self,(()=>(()=>{"use strict";var t={185:(t,o)=>{Object.defineProperty(o,"__esModule",{value:!0}),o.ScrollSpy=void 0;o.ScrollSpy=class{menuList;options;scroller;sections;constructor(t,o={}){if(!t)throw new Error("Your navigation query selector is the first argument.");if("object"!=typeof o)throw new Error("The second argument must be an instance of an Object.");o.smoothScroll=!0===o.smoothScroll&&{}||o.smoothScroll,this.menuList=t instanceof HTMLElement?t:document.querySelector(t),this.options=Object.assign({},{sectionClass:".scrollspy",menuActiveTarget:"li > a",offset:0,hrefAttribute:"href",activeClass:"active",scrollContainer:"",smoothScroll:{}},o),this.options.scrollContainer?this.scroller=this.options.scrollContainer instanceof HTMLElement?this.options.scrollContainer:document.querySelector(this.options.scrollContainer):this.scroller=window,this.sections=document.querySelectorAll(this.options.sectionClass),this.attachEventListeners()}attachEventListeners(){if(this.scroller&&(this.scroller.addEventListener("scroll",(()=>this.onScroll())),this.options.smoothScroll&&this.menuList)){this.menuList.querySelectorAll(this.options.menuActiveTarget).forEach((t=>t.addEventListener("click",this.onClick.bind(this))))}}onClick(t){if(t.target){const o=t.target.getAttribute(this.options.hrefAttribute);if(o){const e=document.querySelector(o);e&&this.options.smoothScroll&&(t.preventDefault(),this.scrollTo(e))}}}onScroll(){const t=this.getSectionInView();if(t){const o=this.getMenuItemBySection(t);o&&(this.removeCurrentActive({ignore:o}),this.setActive(o))}}scrollTo(t){const o="function"==typeof this.options.smoothScrollBehavior&&this.options.smoothScrollBehavior;o?o(t,this.options.smoothScroll):t.scrollIntoView({...!0===this.options.smoothScroll?{}:this.options.smoothScroll,behavior:"smooth"})}getMenuItemBySection(t){if(!t||!this.menuList)return;const o=t.getAttribute("id");return this.menuList.querySelector(`[${this.options.hrefAttribute}="#${o}"]`)}getSectionInView(){for(let t=0;to&&s<=e)return this.sections[t]}}setActive(t){t.classList.contains(this.options.activeClass)||(t.classList.add(this.options.activeClass),"function"==typeof this.options.onActive&&this.options.onActive(t))}removeCurrentActive({ignore:t}){if(this.menuList){const{hrefAttribute:o,menuActiveTarget:e,activeClass:s}=this.options,i=`${e}.${s}:not([${o}="${t.getAttribute(o)}"])`;this.menuList.querySelectorAll(i).forEach((t=>t.classList.remove(this.options.activeClass)))}}}}},o={};function e(s){var i=o[s];if(void 0!==i)return i.exports;var n=o[s]={exports:{}};return t[s](n,n.exports,e),n.exports}var s={};return(()=>{var t=s;const o=e(185);t.default=(t,e={})=>{const s=new o.ScrollSpy(t,e);return window.onload=s.onScroll,window.addEventListener("scroll",(()=>s.onScroll())),s}})(),s=s.default})())); \ No newline at end of file diff --git a/package.json b/package.json index c284887..5ac4a6c 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,10 @@ "name": "simple-scrollspy", "version": "2.4.2", "description": "Simple scrollspy javascript without jQuery, no dependencies.", - "main": "src/index.js", + "main": "dist/index.js", "scripts": { "dev": "webpack --mode production --watch --progress", - "build": "webpack --mode production --progress", + "build": "tsc && webpack --mode production --progress", "prerelease": "npm run build && git add . && git commit -m 'release: Update /dist folder' || true", "release": "standard-version" }, @@ -26,6 +26,8 @@ "devDependencies": { "standard-version": "^9.1.1", "terser-webpack-plugin": "^5.3.6", + "ts-loader": "^9.5.1", + "typescript": "^5.3.3", "webpack": "^5.76.0", "webpack-cli": "^5.0.0" }, diff --git a/src/index.d.ts b/src/index.d.ts deleted file mode 100644 index 2bc44c1..0000000 --- a/src/index.d.ts +++ /dev/null @@ -1,35 +0,0 @@ -declare module 'simple-scrollspy' { - - type MenuElement = string | HTMLElement; - type SmoothScroll = boolean | ScrollIntoViewOptions; - - type Options = { - sectionClass: string; - menuActiveTarget: string; - offset: number; - hrefAttribute: string; - activeClass: string; - scrollContainer: string | HTMLElement; - smoothScroll: SmoothScroll, - smoothScrollBehavior?: (targetElement: HTMLElement, smoothScrollOptions: SmoothScroll) => void; - onActive?: (activeItem: HTMLElement) => void; - } - - class ScrollSpy { - menuList: HTMLElement; - options: Options; - scroller: HTMLElement; - sections: NodeList; - - attachEventListeners(): void; - onClick(event: Event): void; - onScroll(): void; - scrollTo(targetElement: HTMLElement): void; - getMenuItemBySection(section: HTMLElement): HTMLElement; - getSectionInView(): HTMLElement | void; - setActive(activeItem: HTMLElement): void; - removeCurrentActive({ ignore: HTMLElement }): void; - } - - export default function scrollSpy(menuElement: MenuElement, options: Partial): ScrollSpy; -} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 0e872cd..0000000 --- a/src/index.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = (el, options = {}) => { - const { ScrollSpy } = require('./scrollspy') - const instance = new ScrollSpy(el, options) - - window.onload = instance.onScroll() - window.addEventListener('scroll', () => instance.onScroll()) - - return instance -} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..eca59b2 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,19 @@ +import { ScrollSpy, type MenuElement, type Options } from './scrollspy' + +const scrollSpy = (el: MenuElement, options: Partial = {}) => { + const instance = new ScrollSpy(el, options) + + window.onload = instance.onScroll; + window.addEventListener('scroll', () => instance.onScroll()) + + return instance +} + +export type { + MenuElement, + Options, + ScrollSpy +}; + +export default scrollSpy; + diff --git a/src/scrollspy.js b/src/scrollspy.js deleted file mode 100644 index 4698e4d..0000000 --- a/src/scrollspy.js +++ /dev/null @@ -1,122 +0,0 @@ -export class ScrollSpy { - constructor(menu, options = {}) { - if (!menu) { - throw new Error('Your navigation query selector is the first argument.') - } - - if (typeof options !== 'object') { - throw new Error('The second argument must be an instance of an Object.') - } - - let defaultOptions = { - sectionClass: '.scrollspy', - menuActiveTarget: 'li > a', - offset: 0, - hrefAttribute: 'href', - activeClass: 'active', - scrollContainer: '', - smoothScroll: {}, - } - - options.smoothScroll = options.smoothScroll === true && {} || options.smoothScroll - - this.menuList = menu instanceof HTMLElement ? menu : document.querySelector(menu) - this.options = Object.assign({}, defaultOptions, options) - - if(this.options.scrollContainer) { - this.scroller = this.options.scrollContainer instanceof HTMLElement ? this.options.scrollContainer : document.querySelector(this.options.scrollContainer) - } else { - this.scroller = window - } - - this.sections = document.querySelectorAll(this.options.sectionClass) - - this.attachEventListeners() - } - - attachEventListeners() { - if (this.scroller) { - this.scroller.addEventListener('scroll', () => this.onScroll()) - - // smooth scroll - if (this.options.smoothScroll) { - const menuItems = this.menuList.querySelectorAll(this.options.menuActiveTarget) - menuItems.forEach((item) => item.addEventListener('click', this.onClick.bind(this))) - } - } - } - - onClick(event) { - const targetSelector = event.target.getAttribute(this.options.hrefAttribute) - const targetElement = document.querySelector(targetSelector) - - if (targetElement && this.options.smoothScroll) { - // prevent click event - event.preventDefault() - // handle smooth scrolling to 'targetElement' - this.scrollTo(targetElement) - } - } - - onScroll() { - const section = this.getSectionInView() - const menuItem = this.getMenuItemBySection(section) - - if (menuItem) { - this.removeCurrentActive({ ignore: menuItem }) - this.setActive(menuItem) - } - } - - scrollTo(targetElement) { - const smoothScrollBehavior = typeof this.options.smoothScrollBehavior === 'function' && this.options.smoothScrollBehavior - - if (smoothScrollBehavior) { - smoothScrollBehavior(targetElement, this.options.smoothScroll) - } else { - targetElement.scrollIntoView({ - ...this.options.smoothScroll, - behavior: 'smooth', - }) - } - } - - getMenuItemBySection(section) { - if (!section) return - const sectionId = section.getAttribute('id') - return this.menuList.querySelector(`[${this.options.hrefAttribute}="#${sectionId}"]`) - } - - getSectionInView() { - for (let i = 0; i < this.sections.length; i++) { - const startAt = this.sections[i].offsetTop - const endAt = startAt + this.sections[i].offsetHeight - let currentPosition = (document.documentElement.scrollTop || document.body.scrollTop) + this.options.offset - - if(this.options.scrollContainer && this.scroller) { - currentPosition = (this.scroller.scrollTop) + this.options.offset - } - - const isInView = currentPosition > startAt && currentPosition <= endAt - if (isInView) return this.sections[i] - } - } - - setActive(activeItem) { - const isActive = activeItem.classList.contains(this.options.activeClass) - if (!isActive) { - activeItem.classList.add(this.options.activeClass) - if (typeof this.options.onActive === 'function') { - this.options.onActive(activeItem) - } - } - } - - removeCurrentActive({ ignore }) { - const { hrefAttribute, menuActiveTarget, activeClass } = this.options - const items = `${menuActiveTarget}.${activeClass}:not([${hrefAttribute}="${ignore.getAttribute(hrefAttribute)}"])` - const menuItems = this.menuList.querySelectorAll(items) - - menuItems.forEach((item) => item.classList.remove(this.options.activeClass)) - } -} diff --git a/src/scrollspy.ts b/src/scrollspy.ts new file mode 100644 index 0000000..d2e92b6 --- /dev/null +++ b/src/scrollspy.ts @@ -0,0 +1,151 @@ +export type MenuElement = string | HTMLElement; +export type SmoothScroll = boolean | ScrollIntoViewOptions; + +export type Options = { + sectionClass: string; + menuActiveTarget: string; + offset: number; + hrefAttribute: string; + activeClass: string; + scrollContainer: string | HTMLElement; + smoothScroll: SmoothScroll, + smoothScrollBehavior?: (targetElement: HTMLElement, smoothScrollOptions: SmoothScroll) => void; + onActive?: (activeItem: HTMLElement) => void; +}; + +export class ScrollSpy { + public menuList: HTMLElement | null; + public options: Options; + public scroller: HTMLElement | Window | null; + public sections: NodeListOf; + + constructor(menu: MenuElement, options: Partial = {}) { + if (!menu) { + throw new Error('Your navigation query selector is the first argument.') + } + + if (typeof options !== 'object') { + throw new Error('The second argument must be an instance of an Object.') + } + + let defaultOptions = { + sectionClass: '.scrollspy', + menuActiveTarget: 'li > a', + offset: 0, + hrefAttribute: 'href', + activeClass: 'active', + scrollContainer: '', + smoothScroll: {}, + } + + options.smoothScroll = options.smoothScroll === true && {} || options.smoothScroll + + this.menuList = menu instanceof HTMLElement ? menu : document.querySelector(menu) + this.options = Object.assign({}, defaultOptions, options) + + if(this.options.scrollContainer) { + this.scroller = this.options.scrollContainer instanceof HTMLElement ? this.options.scrollContainer : document.querySelector(this.options.scrollContainer) + } else { + this.scroller = window + } + + this.sections = document.querySelectorAll(this.options.sectionClass) + + this.attachEventListeners() + } + + attachEventListeners() { + if (this.scroller) { + this.scroller.addEventListener('scroll', () => this.onScroll()) + + // smooth scroll + if (this.options.smoothScroll && this.menuList) { + const menuItems = this.menuList.querySelectorAll(this.options.menuActiveTarget) + menuItems.forEach((item) => item.addEventListener('click', this.onClick.bind(this))) + } + } + } + + onClick(event: MouseEvent) { + if (event.target) { + const targetSelector = (event.target as HTMLElement).getAttribute(this.options.hrefAttribute) + if (targetSelector) { + const targetElement = document.querySelector(targetSelector) + + if (targetElement && this.options.smoothScroll) { + // prevent click event + event.preventDefault() + // handle smooth scrolling to 'targetElement' + this.scrollTo(targetElement) + } + } + } + } + + onScroll() { + const section = this.getSectionInView() + if (section) { + const menuItem = this.getMenuItemBySection(section) + + if (menuItem) { + this.removeCurrentActive({ ignore: menuItem }) + this.setActive(menuItem) + } + } + } + + scrollTo(targetElement: HTMLElement) { + const smoothScrollBehavior = typeof this.options.smoothScrollBehavior === 'function' && this.options.smoothScrollBehavior + + if (smoothScrollBehavior) { + smoothScrollBehavior(targetElement, this.options.smoothScroll) + } else { + targetElement.scrollIntoView({ + ...(this.options.smoothScroll === true ? {} : this.options.smoothScroll), + behavior: 'smooth', + }) + } + } + + getMenuItemBySection(section: HTMLElement) { + if (!section || !this.menuList) return + const sectionId = section.getAttribute('id') + return this.menuList.querySelector(`[${this.options.hrefAttribute}="#${sectionId}"]`) + } + + getSectionInView() { + for (let i = 0; i < this.sections.length; i++) { + const startAt = this.sections[i]!.offsetTop + const endAt = startAt + this.sections[i]!.offsetHeight + let currentPosition = (document.documentElement.scrollTop || document.body.scrollTop) + this.options.offset + + if(this.options.scrollContainer && this.scroller) { + const scrollTop = this.scroller instanceof Window ? this.scroller.scrollY : this.scroller.scrollTop; + currentPosition = (scrollTop) + this.options.offset + } + + const isInView = currentPosition > startAt && currentPosition <= endAt + if (isInView) return this.sections[i] + } + } + + setActive(activeItem: HTMLElement) { + const isActive = activeItem.classList.contains(this.options.activeClass) + if (!isActive) { + activeItem.classList.add(this.options.activeClass) + if (typeof this.options.onActive === 'function') { + this.options.onActive(activeItem) + } + } + } + + removeCurrentActive({ ignore }: { ignore: HTMLElement }) { + if (this.menuList) { + const { hrefAttribute, menuActiveTarget, activeClass } = this.options + const items = `${menuActiveTarget}.${activeClass}:not([${hrefAttribute}="${ignore.getAttribute(hrefAttribute)}"])` + const menuItems = this.menuList.querySelectorAll(items) + + menuItems.forEach((item) => item.classList.remove(this.options.activeClass)) + } + } + } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d3239e8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es2022", + "allowJs": true, + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + "strict": true, + "strictNullChecks": true, + "noUncheckedIndexedAccess": true, + "moduleResolution": "NodeNext", + "module": "NodeNext", + "outDir": "dist", + "sourceMap": true, + "declaration": true, + "lib": ["es2022", "dom", "dom.iterable"], + }, + "include": ["src/*.ts"], + "exclude": ["node_modules", "dist", "demo"] + } \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index aff3052..bda5365 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -2,14 +2,30 @@ const { resolve } = require('path') const TerserPlugin = require('terser-webpack-plugin') module.exports = { - entry: resolve(__dirname, 'src/index.js'), + entry: resolve(__dirname, 'src/index.ts'), + + module: { + rules: [ + { + test: /\.tsx?$/, + use: 'ts-loader', + exclude: /node_modules/, + }, + ], + }, + resolve: { + extensions: ['.ts', '.js'], + }, output: { path: resolve(__dirname, 'demo/dist'), filename: 'simple-scrollspy.min.js', chunkFilename: '[name].simple-scrollspy.min.js', - library: 'scrollSpy', - libraryTarget: 'umd' + library: { + name: 'scrollSpy', + type: 'umd', + export: 'default', + } }, optimization: {