diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..e7dc3c1 Binary files /dev/null and b/bun.lockb differ diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ab579c1 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "tooltip.js", + "module": "tooltip.js", + "type": "module", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } +} \ No newline at end of file diff --git a/tooltip.js b/tooltip.js new file mode 100644 index 0000000..cef80df --- /dev/null +++ b/tooltip.js @@ -0,0 +1,1086 @@ +'use strict' + +module.exports = { + /** + * Setup global options + * These can and will be overwriteen if a config object is passed into this.init() + */ + options: { + html: false, + placement: 'top', + container: 'body', + scrollContainer: window, + template: '
', + removalDelay: 200, + tooltipOffset: 10, + windowPadding: { + top: 10, + right: 10, + bottom: 10, + left: 10, + }, + }, + + /** + * initializeTooltips - Initialize function to bind events and set any global data + * @example + * tooltips.init() + * @param {object} (config) - Congifuration object that is used to overwrite the defaults in this.options + * @return {void} + */ + init: function initializeTooltips(config) { + var self = this + + // Check to see if we should use document.body or document.documentElement + document.documentElement.scrollTop = 1 + this.documentElement = + document.documentElement.scrollTop === 1 + ? document.documentElement + : document.body + + // Test for browser support + var div = document.createElement('div') + + div.innerHTML = + '' + title + '
' + ) + + tooltip.appendChild(tooltipTitle) + } + + // If the supplied string should be interpreted as html, make an element for it... + if ((this.options.html || html) && content) { + if (this.isElement(content)) { + tooltipContent = content + } else if (this.getTagName(content)) { + tooltipContent = this.createDOMElement(content) + } else { + tooltipContent = this.createDOMElement( + '' + content + '
' + ) + } + // ...or create a default p instead + } else { + tooltipContent = this.createDOMElement( + '' + content + '
' + ) + } + + tooltip.appendChild(tooltipContent) + + // If the a container was supplied and it's not also the body element, store that element + if (container && container !== this.options.container) { + if (typeof container === 'string') { + this.currentContainer = document.querySelector(container) + } + // If they initialized tooltips and set a different global container, store that element + } else { + this.currentContainer = document.querySelector( + this.options.container + ) + } + + // If a scrollContainer was supplied and it's also not the window element, store that element + if ( + scrollContainer && + scrollContainer !== this.options.scrollContainer + ) { + if (typeof scrollContainer === 'string') { + this.currentScrollContainer = + document.querySelector(scrollContainer) + } + // If they initialized tooltips and incase they set a different global container, store that element + } else { + this.currentScrollContainer = this.options.scrollContainer + } + + // If autoClose is set to false, add an attribute for the event handler to look for + if ( + options.autoClose === false || + trigger.getAttribute('data-tooltip-autoclose') === 'false' + ) { + tooltip.setAttribute('data-autoclose', 'false') + } + + if (preExistingTooltip) { + this.currentTooltip = preExistingTooltip.parentNode.insertBefore( + tooltip, + preExistingTooltip + ) + } else { + this.currentTooltip = this.currentContainer.appendChild(tooltip) + } + + this.currentTrigger = trigger + + // Position the tooltip on the page + this.positionTooltip( + this.currentTooltip, + this.currentTrigger, + placement + ) + + // If a tooltip is open and the user scrolls, isotip needs to keep up with the trigger + if (this.currentScrollContainer !== window) { + this.addEventListener( + this.currentScrollContainer, + 'scroll', + this.windowChangeHandler + ) + } + + return this.currentTooltip + }, + + /** + * closeTooltip - Main close function to close a specific tooltip + * @example + * tooltip.close( document.body.querySelector( '.tooltip' )) + * @param {string|element} tooltip - The tooltip that needs to be closed + * @return {void} + */ + close: function closeTooltip(tooltip) { + // We need a DOM element, so make it one if it isn't already + if (typeof tooltip === 'string') { + tooltip = document.body.querySelector(tooltip) + } + + if (this.currentScrollContainer !== window) { + this.removeEventListener( + this.currentScrollContainer, + 'scroll', + this.windowChangeHandler + ) + } + + this.removeClass(tooltip, 'visible') + + this.currentTooltip = null + this.currentTrigger = null + this.currentScrollContainer = null + + // We should assume that there will be some sort of tooltip animation with CSS or JS + // So we can only remove the element after a certain period of time + window.setTimeout(function removeElementFromDOM() { + if ( + tooltip && + tooltip instanceof window.Element && + tooltip.parentNode + ) { + tooltip.parentNode.removeChild(tooltip) + } else { + tooltip = document.body.querySelector('.tooltip') + + if (tooltip && tooltip.parentNode) { + tooltip.parentNode.removeChild(tooltip) + } + } + }, this.options.removalDelay) + }, + + /** + * positionTooltip - Logic for positioning the tooltip on the page + * @example + * this.positionTooltip( this.currentTooltip, this.currentTrigger, 'top' ) + * @param {string|element} tooltip - The tooltip that needs to be positioned + * @param {string|element} trigger - The element that triggered the tooltip + * @param {string} (placement) - The position that the tooltip needs to be placed in in relation to the trigger + * @return {element} - Returns the tooltip that was positioned + * @api private + */ + positionTooltip: function positionTooltip(tooltip, trigger, placement) { + if (typeof tooltip === 'string') { + tooltip = document.body.querySelector(tooltip) + } + + if (typeof trigger === 'string') { + trigger = document.body.querySelector(trigger) + } + + // Since we support this being done on scroll, we need a way to get the placement if it isn't specified + if (!placement) { + placement = + trigger.getAttribute('data-tooltip-placement') || + this.options.placement + } + + var self = this + var tooltipAccent = tooltip.querySelector('.tooltip-accent') + var triggerPosition = trigger.getBoundingClientRect() + var triggerWidth = Math.floor(triggerPosition.width) + var triggerHeight = Math.floor(triggerPosition.height) + var triggerX = triggerPosition.left + var triggerY = triggerPosition.top + var windowTop = this.options.windowPadding.top + var windowRight = + (window.innerWidth || + document.documentElement.clientWidth || + document.body.clientWidth) - this.options.windowPadding.right + var windowBottom = + (window.innerHeight || + document.documentElement.clientHeight || + document.body.clientHeight) - this.options.windowPadding.bottom + var windowLeft = this.options.windowPadding.left + var containerTop + var containerRight + var containerBottom + var containerLeft + var tooltipX + var tooltipY + var tooltipWidth + var tooltipHeight + var tooltipRight + var tooltipBottom + var tooltipAccentWidth + var tooltipAccentHeight + + if (this.currentScrollContainer.getBoundingClientRect) { + var scrollContainerPosition = + this.currentScrollContainer.getBoundingClientRect() + + if (scrollContainerPosition.top >= 0) { + containerTop = + scrollContainerPosition.top + this.options.windowPadding.top + } + + if (scrollContainerPosition.right <= windowRight) { + containerRight = + scrollContainerPosition.right - + this.options.windowPadding.right + } + + if (scrollContainerPosition.bottom <= windowBottom) { + containerBottom = + scrollContainerPosition.bottom - + this.options.windowPadding.bottom + } + + if (scrollContainerPosition.left >= windowLeft) { + containerLeft = + scrollContainerPosition.left + + this.options.windowPadding.left + } + } + + /** + * We sometimes need to re-position the tooltip (I.E. switch from top to bottom) + * Which is why these are separate functions + */ + function positionTop() { + tooltipX = triggerX - tooltipWidth / 2 + triggerWidth / 2 + tooltipY = triggerY - tooltipHeight - self.options.tooltipOffset + tooltipRight = tooltipX + tooltipWidth + + self.addClass(tooltip, 'tooltip-top') + + if (!tooltipAccentWidth) { + tooltipAccentWidth = parseInt(tooltipAccent.offsetWidth, 10) + } + + if (!tooltipAccentHeight) { + tooltipAccentHeight = parseInt(tooltipAccent.offsetHeight, 10) + } + + if (triggerY + triggerHeight <= containerTop) { + if (self.hasClass(tooltip, 'visible')) { + self.removeClass(tooltip, 'visible') + } + } + + // If the tooltip extends beyond the right edge of the window... + if (tooltipRight > windowRight || tooltipRight > containerRight) { + tooltip.style.top = 'auto' + tooltip.style.bottom = + windowBottom + + self.options.windowPadding.bottom - + triggerY + + self.options.tooltipOffset + + 'px' + tooltip.style.right = self.options.windowPadding.right + 'px' + tooltipAccent.style.left = 'auto' + tooltipAccent.style.right = + triggerWidth / 2 - + tooltipAccentWidth / 2 + + (windowRight - triggerX - triggerWidth) + + 'px' + // ...or if the tooltip extends beyond the top of the window... + } else if (tooltipY < windowTop || tooltipY < containerTop) { + self.removeClass(tooltip, 'tooltip-top') + + return positionBottom() + // ...or if the tooltip extends beyond the left edge of the window... + } else if (tooltipX < windowLeft || tooltipX < containerLeft) { + tooltip.style.top = 'auto' + tooltip.style.bottom = + windowBottom + + self.options.windowPadding.bottom - + triggerY + + self.options.tooltipOffset + + 'px' + tooltip.style.left = self.options.windowPadding.left + 'px' + tooltipAccent.style.right = 'auto' + tooltipAccent.style.left = + triggerWidth / 2 - + tooltipAccentWidth / 2 + + (triggerX - windowLeft) + + 'px' + // ...or it fits inside the window + } else { + tooltip.style.top = 'auto' + tooltip.style.bottom = + windowBottom + + self.options.windowPadding.bottom - + triggerY + + self.options.tooltipOffset + + 'px' + tooltip.style.left = tooltipX + 'px' + tooltipAccent.style.top = '' + tooltipAccent.style.bottom = '' + tooltipAccent.style.right = '' + tooltipAccent.style.left = + tooltipWidth / 2 - tooltipAccentWidth / 2 + 'px' + } + } + + function positionRight() { + tooltipX = triggerX + triggerWidth + self.options.tooltipOffset + tooltipY = triggerY - tooltipHeight / 2 + triggerHeight / 2 + tooltipRight = tooltipX + tooltipWidth + + self.addClass(tooltip, 'tooltip-right') + + if (!tooltipAccentHeight) { + tooltipAccentHeight = parseInt(tooltipAccent.offsetHeight, 10) + } + + // If the tooltip extends beyond the right edge of the screen... + if (tooltipRight > windowRight || tooltipRight > containerRight) { + self.removeClass(tooltip, 'tooltip-right') + + return positionTop() + // ...or if it fits to the right of the trigger element + } else { + tooltip.style.top = tooltipY + 'px' + tooltip.style.left = tooltipX + 'px' + tooltipAccent.style.right = '' + tooltipAccent.style.bottom = '' + tooltipAccent.style.left = '' + tooltipAccent.style.top = + tooltipHeight / 2 - tooltipAccentHeight / 2 + 'px' + } + } + + function positionBottom() { + tooltipX = triggerX - tooltipWidth / 2 + triggerWidth / 2 + tooltipY = triggerY + triggerHeight + self.options.tooltipOffset + tooltipRight = tooltipX + tooltipWidth + tooltipBottom = tooltipY + tooltipHeight + + self.addClass(tooltip, 'tooltip-bottom') + + if (!tooltipAccentWidth) { + tooltipAccentWidth = parseInt(tooltipAccent.offsetWidth, 10) + } + + if (triggerY >= containerBottom) { + if (self.hasClass(tooltip, 'visible')) { + self.removeClass(tooltip, 'visible') + } + } + + // If the tooltip extends beyond the right edge of the window... + if (tooltipRight > windowRight || tooltipRight > containerRight) { + tooltip.style.top = tooltipY + 'px' + tooltip.style.right = self.options.windowPadding.right + 'px' + tooltipAccent.style.left = 'auto' + tooltipAccent.style.right = + triggerWidth / 2 - + tooltipAccentWidth / 2 + + (windowRight - triggerX - triggerWidth) + + 'px' + // ...or if the tooltip extends beyond the top of the window... + } else if ( + tooltipBottom > windowBottom || + tooltipBottom > containerBottom + ) { + self.removeClass(tooltip, 'tooltip-bottom') + + return positionTop() + // ...or if the tooltip extends beyond the left edge of the window... + } else if (tooltipX < windowLeft || tooltipX < windowLeft) { + tooltip.style.top = tooltipY + 'px' + tooltip.style.left = self.options.windowPadding.left + 'px' + tooltipAccent.style.right = 'auto' + tooltipAccent.style.left = + triggerWidth / 2 - + tooltipAccentWidth / 2 + + (triggerX - windowLeft) + + 'px' + // ...or it fits inside the window + } else { + tooltip.style.top = tooltipY + 'px' + tooltip.style.bottom = 'auto' + tooltip.style.left = tooltipX + 'px' + tooltipAccent.style.top = '' + tooltipAccent.style.bottom = '' + tooltipAccent.style.right = '' + tooltipAccent.style.left = + tooltipWidth / 2 - tooltipAccentWidth / 2 + 'px' + } + } + + function positionLeft() { + tooltipX = triggerX - tooltipWidth - self.options.tooltipOffset + tooltipY = triggerY - tooltipHeight / 2 + triggerHeight / 2 + + self.addClass(tooltip, 'tooltip-left') + + if (!tooltipAccentHeight) { + tooltipAccentHeight = parseInt(tooltipAccent.offsetHeight, 10) + } + + // If the tooltip extends beyond the right edge of the screen... + if (tooltipX < windowLeft || tooltipX < containerLeft) { + self.removeClass(tooltip, 'tooltip-left') + + return positionTop() + // ...or if it fits to the left of the trigger element + } else { + tooltip.style.top = tooltipY + 'px' + tooltip.style.left = tooltipX + 'px' + tooltipAccent.style.right = '' + tooltipAccent.style.bottom = '' + tooltipAccent.style.left = '' + tooltipAccent.style.top = + tooltipHeight / 2 - tooltipAccentHeight / 2 + 'px' + } + } + + tooltip.style.position = 'fixed' + + tooltipWidth = parseInt(tooltip.offsetWidth, 10) + tooltipHeight = parseInt(tooltip.offsetHeight, 10) + tooltipAccentWidth = parseInt(tooltipAccent.offsetWidth, 10) + tooltipAccentHeight = parseInt(tooltipAccent.offsetHeight, 10) + + // clear any classes set before hand + self.removeClass(tooltip, 'tooltip-top') + self.removeClass(tooltip, 'tooltip-right') + self.removeClass(tooltip, 'tooltip-bottom') + self.removeClass(tooltip, 'tooltip-left') + + // position the tooltip + if (placement === 'top') { + positionTop() + } else if (placement === 'right') { + positionRight() + } else if (placement === 'bottom') { + positionBottom() + } else if (placement === 'left') { + positionLeft() + } + + // try and give the tooltip enough time to position itself + if ( + !self.hasClass(tooltip, 'visible') && + (containerTop === undefined || + (triggerY + triggerHeight > containerTop && + triggerY < containerBottom)) + ) { + window.setTimeout(function () { + self.addClass(tooltip, 'visible') + }, 50) + } else if ( + triggerY + triggerHeight <= containerTop || + triggerY >= containerBottom + ) { + self.removeClass(tooltip, 'visible') + } + + return tooltip + }, + + /** + * addEventListener - Small function to add an event listener. Should be compatible with IE8+ + * @example + * this.addEventListener( document.body, 'click', this.open( this.currentTooltip )) + * @param {element} el - The element node that needs to have the event listener added + * @param {string} eventName - The event name (sans the "on") + * @param {function} handler - The function to be run when the event is triggered + * @return {element} - The element that had an event bound + * @api private + */ + addEventListener: function addEventListener( + el, + eventName, + handler, + useCapture + ) { + if (!useCapture) { + useCapture = false + } + + if (el.addEventListener) { + el.addEventListener(eventName, handler, useCapture) + + return el + } else { + if (eventName === 'focus') { + eventName = 'focusin' + } + + el.attachEvent('on' + eventName, function () { + handler.call(el) + }) + + return el + } + }, + + /** + * removeEventListener - Small function to remove and event listener. Should be compatible with IE8+ + * @example + * this.removeEventListener( document.body, 'click', this.open( this.currentTooltip )) + * @param {element} el - The element node that needs to have the event listener removed + * @param {string} eventName - The event name (sans the "on") + * @param {function} handler - The function that was to be run when the event is triggered + * @return {element} - The element that had an event removed + * @api private + */ + removeEventListener: function removeEventListener( + el, + eventName, + handler, + useCapture + ) { + if (!useCapture) { + useCapture = false + } + + if (!el) { + return + } + + if (el.removeEventListener) { + el.removeEventListener(eventName, handler, useCapture) + } else { + if (eventName === 'focus') { + eventName = 'focusin' + } + + el.detachEvent('on' + eventName, function () { + handler.call(el) + }) + } + + return el + }, + + /** + * hasClass - Small function to see if an element has a specific class. Should be compatible with IE8+ + * @example + * this.hasClass( this.currentTooltip, 'visible' ) + * @param {element} el - The element to check the class existence on + * @param {string} className - The class to check for + * @return {boolean} - True or false depending on if the element has the class + * @api private + */ + hasClass: function hasClass(el, className) { + if (el.classList) { + return el.classList.contains(className) + } else { + return new RegExp('(^| )' + className + '( |$)', 'gi').test( + el.className + ) + } + }, + + /** + * addClass - Small function to add a class to an element. Should be compatible with IE8+ + * @example + * this.addClass( this.currentTooltip, 'visible' ) + * @param {element} el - The element to add the class to + * @param {string} className - The class name to add to the element + * @return {element} - The element that had the class added to it + * @api private + */ + addClass: function addClass(el, className) { + if (el.classList) { + el.classList.add(className) + } else { + el.className += ' ' + className + } + + return el + }, + + /** + * removeClass - Small function to remove a class from an element. Should be compatible with IE8+ + * @example + * this.removeClass( this.currentTooltip, 'visible' ) + * @param {element} el - The element to remove the class from + * @param {string} className - The class name to remove from the element + * @return {element} - The element that had the class removed from it + * @api private + */ + removeClass: function removeClass(el, className) { + if (el) { + if (el.classList) { + el.classList.remove(className) + } else { + el.className = el.className.replace( + new RegExp( + '(^|\\b)' + className.split(' ').join('|') + '(\\b|$)', + 'gi' + ), + ' ' + ) + } + } + + return el + }, + + /** + * setInnerText - Small function to set the inner text of an element. Should be compatible with IE8+ + * @example + * this.setInnerText( this.currentTooltip, 'Hello world' ) + * @param {element} el - The element to have the text inserted into + * @param {string} text - The text to insert into the element + * @return {element} - The element with the new inner text + * @api private + */ + setInnerText: function setInnerText(el, text) { + if (el.textContent !== undefined) { + el.textcontent = text + } else { + el.innerText = text + } + + return el + }, + + /** + * getTagName - Small function to get the tag name of an html string. + * @example + * this.getTagName( '' ) + * @param {string} html - The string of html to check for the tag + * @return {object} - The object containing the tag of the root element in the html string as well as some other basic info + * @api private + */ + getTagName: function getTagName(html) { + return /<([\w:]+)/.exec(html) + }, + + /** + * isElement - Small function to determine if object is a DOM element. + * @example + * this.isElement( '' ); # false + * @example + * this.isElement( this.createElement('div') ); # true + * @param {string} obj - The object to check + * @return {boolean} - Whether or not the object is a DOM element. + * @api private + */ + isElement: function getTagName(obj) { + return !!(obj && obj.nodeType === 1) + }, + + /** + * createDOMElement - Small function to transform an html string into an element + * @example + * this.createDOMElement( '