├── .nvmrc ├── .gitignore ├── eslint.config.js ├── src ├── js │ ├── browser.js │ ├── types │ │ ├── youtube.js │ │ ├── html.js │ │ ├── iframe.js │ │ └── image.js │ └── index.js └── scss │ ├── _variables.scss │ └── tobii.scss ├── add-banner.js ├── .github ├── FUNDING.yml └── workflows │ └── npm-publish.yml ├── demo ├── styles.css └── index.html ├── LICENSE.md ├── .stylelintrc ├── package.json ├── CHANGELOG.md ├── dist ├── tobii.min.css ├── tobii.min.js └── tobii.js └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/hydrogen 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .idea 3 | node_modules 4 | test 5 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import neostandard from 'neostandard' 2 | 3 | export default neostandard({ 4 | env: ['browser'] 5 | }) 6 | -------------------------------------------------------------------------------- /src/js/browser.js: -------------------------------------------------------------------------------- 1 | import '../scss/tobii.scss' 2 | import Tobii from './index' 3 | 4 | if (typeof module < 'u') { 5 | module.exports = Tobii 6 | } else { 7 | self.Tobii = Tobii 8 | } 9 | -------------------------------------------------------------------------------- /add-banner.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import pkg from './package.json' with { type: 'json' }; 4 | 5 | const banner = `/*! 6 | * ${pkg.name} ${pkg.version} 7 | * Licensed under the ${pkg.license} license. 8 | * ${pkg.homepage} 9 | */ 10 | `; 11 | 12 | const filePath = path.join(process.env.PWD, 'dist/tobii.min.js'); 13 | const content = fs.readFileSync(filePath, 'utf8'); 14 | const output = banner + '\n' + content; 15 | fs.writeFileSync(filePath, output); 16 | 17 | console.log('Banner prepended to ' + filePath); 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: midzer 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /demo/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | background-color: #f5f5f7; 3 | } 4 | 5 | h1 { 6 | margin-top: 0; 7 | text-decoration: underline; 8 | text-align: center; 9 | } 10 | 11 | body { 12 | max-width: 1024px; 13 | margin: 32px auto; 14 | padding: 1em 1em; 15 | border-radius: 8px; 16 | background-color: #bfbfbf; 17 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; 18 | font-size: 16px; 19 | line-height: 1.5; 20 | box-shadow: #555 0 1px 3px 0, #555 0 3px 8px 3px; 21 | } 22 | 23 | table { 24 | border-collapse: collapse; 25 | border-spacing: 0; 26 | } 27 | 28 | th { 29 | text-align: left; 30 | } 31 | 32 | th, td { 33 | padding: 5px 1em; 34 | } 35 | 36 | img { 37 | max-width: 200px; 38 | height: auto; 39 | border: 1px solid #555; 40 | box-shadow: 1px 2px 2px 0 #555; 41 | } 42 | -------------------------------------------------------------------------------- /src/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --tobii-base-font-size: 1rem; /* also update --tobii-slide-max-height */ 3 | 4 | --tobii-transition-duration: 0.3s; 5 | --tobii-transition-timing-function: cubic-bezier(0.19, 1, 0.22, 1); 6 | 7 | --tobii-zoom-icon-background: hsla(210, 38%, 16%, 0.94); 8 | --tobii-zoom-icon-color: #ffffff; 9 | 10 | --tobii-lightbox-background: rgba(0,0,0,0.85); 11 | --tobii-lightbox-z-index: 1337; 12 | 13 | --tobii-caption-background: rgba(0,0,0,0.8); 14 | --tobii-caption-color: #eeeeee; 15 | 16 | --tobii-counter-background: transparent; 17 | --tobii-counter-color: #ffffff; 18 | 19 | --tobii-button-background: transparent; 20 | --tobii-button-navigation-background: rgba(0,0,0,0.5); 21 | --tobii-button-color: #ffffff; 22 | 23 | --tobii-loader-color: #ffffff; 24 | 25 | --tobii-slide-max-height: calc(100vh - 3.125em); 26 | --tobii-slide-max-width: 100vw; 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2020 rqrauhvmra, 2021 midzer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/guides/publishing-nodejs-packages#publishing-packages-to-npm-and-github-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: read 15 | packages: write 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | # Setup .npmrc file to publish to npm 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 18 24 | registry-url: 'https://registry.npmjs.org' 25 | 26 | - run: npm ci 27 | 28 | # Publish to npm 29 | - run: npm publish --access public 30 | env: 31 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 32 | 33 | # Setup .npmrc file to publish to GitHub Packages 34 | - uses: actions/setup-node@v4 35 | with: 36 | node-version: 18 37 | registry-url: 'https://npm.pkg.github.com' 38 | scope: '@midzer' 39 | 40 | # Publish to GitHub Packages 41 | - run: npm publish 42 | env: 43 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indentation": 2, 4 | "string-quotes": "single", 5 | "no-duplicate-selectors": true, 6 | "color-hex-case": "lower", 7 | "color-hex-length": "long", 8 | "color-named": "never", 9 | "selector-no-qualifying-type": true, 10 | "selector-max-id": 0, 11 | "selector-combinator-space-after": "always", 12 | "selector-attribute-quotes": "always", 13 | "selector-attribute-operator-space-before": "never", 14 | "selector-attribute-operator-space-after": "never", 15 | "selector-attribute-brackets-space-inside": "never", 16 | "declaration-block-trailing-semicolon": "always", 17 | "declaration-no-important": true, 18 | "declaration-colon-space-before": "never", 19 | "declaration-colon-space-after": "always", 20 | "number-leading-zero": "always", 21 | "function-url-quotes": "always", 22 | "font-weight-notation": "ignore", 23 | "font-family-name-quotes": "always-where-recommended", 24 | "comment-whitespace-inside": "always", 25 | "comment-empty-line-before": "always", 26 | "at-rule-no-vendor-prefix": true, 27 | "rule-empty-line-before": "always", 28 | "selector-pseudo-element-colon-notation": "double", 29 | "selector-pseudo-class-parentheses-space-inside": "never", 30 | "media-feature-range-operator-space-before": "always", 31 | "media-feature-range-operator-space-after": "always", 32 | "media-feature-parentheses-space-inside": "never", 33 | "media-feature-colon-space-before": "never", 34 | "media-feature-colon-space-after": "always" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/js/types/youtube.js: -------------------------------------------------------------------------------- 1 | class YoutubeType { 2 | constructor () { 3 | this.playerId = 0 4 | this.PLAYER = [] 5 | this.userSettings = null 6 | } 7 | 8 | init (el, container, userSettings) { 9 | this.userSettings = userSettings 10 | 11 | const IFRAME_PLACEHOLDER = document.createElement('div') 12 | 13 | // Add iframePlaceholder to container 14 | container.appendChild(IFRAME_PLACEHOLDER) 15 | 16 | this.PLAYER[this.playerId] = new window.YT.Player(IFRAME_PLACEHOLDER, { 17 | host: 'https://www.youtube-nocookie.com', 18 | height: el.getAttribute('data-height') || '360', 19 | width: el.getAttribute('data-width') || '640', 20 | videoId: el.getAttribute('data-id'), 21 | playerVars: { 22 | controls: el.getAttribute('data-controls') || 1, 23 | rel: 0, 24 | playsinline: 1 25 | } 26 | }) 27 | 28 | // Set player ID 29 | container.setAttribute('data-player', this.playerId) 30 | 31 | // Register type 32 | container.setAttribute('data-type', 'youtube') 33 | container.classList.add('tobii-youtube') 34 | 35 | this.playerId++ 36 | } 37 | 38 | onPreload (container) { 39 | // Nothing 40 | } 41 | 42 | onLoad (container) { 43 | this.PLAYER[container.getAttribute('data-player')].playVideo() 44 | } 45 | 46 | onLeave (container) { 47 | if (this.PLAYER[container.getAttribute('data-player')].getPlayerState() === 1) { 48 | this.PLAYER[container.getAttribute('data-player')].pauseVideo() 49 | } 50 | } 51 | 52 | onCleanup (container) { 53 | if (this.PLAYER[container.getAttribute('data-player')].getPlayerState() === 1) { 54 | this.PLAYER[container.getAttribute('data-player')].pauseVideo() 55 | } 56 | } 57 | 58 | onReset () { 59 | // Nothing 60 | } 61 | } 62 | 63 | export default YoutubeType 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@midzer/tobii", 3 | "version": "3.1.0", 4 | "description": "An accessible, open-source lightbox with no dependencies.", 5 | "main": "./dist/tobii.js", 6 | "module": "./dist/tobii.module.js", 7 | "umd:main": "./dist/tobii.umd.js", 8 | "unpkg": "./dist/tobii.umd.js", 9 | "source": "./src/js/index.js", 10 | "exports": { 11 | ".": { 12 | "browser": "./dist/tobii.module.js", 13 | "umd": "./dist/tobii.umd.js", 14 | "import": "./dist/tobii.modern.js", 15 | "require": "./dist/tobii.js" 16 | }, 17 | "./package.json": "./package.json", 18 | "./": "./" 19 | }, 20 | "devDependencies": { 21 | "cross-env": "^10.1.0", 22 | "eslint": "9.39.1", 23 | "microbundle": "^0.15.1", 24 | "neostandard": "^0.12.2", 25 | "rimraf": "4.4.1", 26 | "sass": "^1.94.2", 27 | "stylelint": "^16.26.0" 28 | }, 29 | "browserslist": { 30 | "browser": [ 31 | "last 2 versions", 32 | "not <= 1%" 33 | ], 34 | "main": [ 35 | "last 2 versions", 36 | "not <= 1%" 37 | ] 38 | }, 39 | "scripts": { 40 | "build": "npm run distclean && npm run build:main && npm run build:browser", 41 | "build:main": "cross-env BROWSERSLIST_ENV=main microbundle build --raw --no-compress --no-sourcemap --name Tobii", 42 | "build:browser": "cross-env BROWSERSLIST_ENV=browser microbundle build --raw -f iife src/js/browser.js -o dist/tobii.min.js --no-sourcemap --name Tobii", 43 | "postbuild:browser": "node add-banner.js", 44 | "distclean": "rimraf dist", 45 | "clean": "rimraf dist && rimraf node_modules", 46 | "dev": "microbundle watch --raw --format cjs", 47 | "dev-modern": "microbundle watch --raw --format esm", 48 | "lint": "eslint src", 49 | "test": "npm run lint" 50 | }, 51 | "repository": { 52 | "type": "git", 53 | "url": "git://github.com/midzer/tobii.git" 54 | }, 55 | "files": [ 56 | "src", 57 | "dist" 58 | ], 59 | "engines": { 60 | "node": ">=18" 61 | }, 62 | "keywords": [ 63 | "lightbox", 64 | "accessible", 65 | "a11y", 66 | "javascript", 67 | "vanilla", 68 | "scss", 69 | "css" 70 | ], 71 | "author": "midzer", 72 | "license": "MIT", 73 | "bugs": { 74 | "url": "https://github.com/midzer/tobii/issues" 75 | }, 76 | "homepage": "https://midzer.github.io/tobii/demo/" 77 | } 78 | -------------------------------------------------------------------------------- /src/js/types/html.js: -------------------------------------------------------------------------------- 1 | class HtmlType { 2 | constructor () { 3 | this.userSettings = null 4 | } 5 | 6 | init (el, container, userSettings) { 7 | this.userSettings = userSettings 8 | 9 | const TARGET_SELECTOR = el.hasAttribute('data-target') ? el.getAttribute('data-target') : el.getAttribute('href') 10 | const TARGET = document.querySelector(TARGET_SELECTOR) 11 | 12 | if (!TARGET) { 13 | throw new Error(`Ups, I can't find the target ${TARGET_SELECTOR}.`) 14 | } 15 | 16 | // Add content to container 17 | container.appendChild(TARGET) 18 | 19 | // Register type 20 | container.setAttribute('data-type', 'html') 21 | container.classList.add('tobii-html') 22 | } 23 | 24 | onPreload (container) { 25 | // Nothing 26 | } 27 | 28 | onLoad (container, group) { 29 | const VIDEO = container.querySelector('video') 30 | 31 | if (VIDEO) { 32 | if (VIDEO.hasAttribute('data-time') && VIDEO.readyState > 0) { 33 | // Continue where video was stopped 34 | VIDEO.currentTime = VIDEO.getAttribute('data-time') 35 | } 36 | 37 | // Start playback (and loading if necessary) 38 | VIDEO.play() 39 | } 40 | 41 | const audio = container.querySelector('audio') 42 | if (audio) { 43 | // Start playback (and loading if necessary) 44 | audio.play() 45 | } 46 | 47 | container.classList.add('tobii-group-' + group) 48 | } 49 | 50 | onLeave (container) { 51 | const VIDEO = container.querySelector('video') 52 | 53 | if (VIDEO) { 54 | if (!VIDEO.paused) { 55 | // Stop if video is playing 56 | VIDEO.pause() 57 | } 58 | 59 | // Backup currentTime (needed for revisit) 60 | if (VIDEO.readyState > 0) { 61 | VIDEO.setAttribute('data-time', VIDEO.currentTime) 62 | } 63 | } 64 | 65 | const audio = container.querySelector('audio') 66 | 67 | if (audio) { 68 | if (!audio.paused) { 69 | // Stop if is playing 70 | audio.pause() 71 | } 72 | } 73 | } 74 | 75 | onCleanup (container) { 76 | const VIDEO = container.querySelector('video') 77 | 78 | if (VIDEO) { 79 | if (VIDEO.readyState > 0 && VIDEO.readyState < 3 && VIDEO.duration !== VIDEO.currentTime) { 80 | // Some data has been loaded but not the whole package. 81 | // In order to save bandwidth, stop downloading as soon as possible. 82 | const VIDEO_CLONE = VIDEO.cloneNode(true) 83 | 84 | this._removeSources(VIDEO) 85 | VIDEO.load() 86 | 87 | VIDEO.parentNode.removeChild(VIDEO) 88 | 89 | container.appendChild(VIDEO_CLONE) 90 | } 91 | } 92 | } 93 | 94 | onReset () { 95 | // Nothing 96 | } 97 | 98 | /** 99 | * Remove all `src` attributes 100 | * 101 | * @param {HTMLElement} el - Element to remove all `src` attributes 102 | */ 103 | _removeSources (el) { 104 | const SOURCES = el.querySelectorAll('src') 105 | 106 | if (SOURCES) { 107 | SOURCES.forEach((source) => { 108 | source.setAttribute('src', '') 109 | }) 110 | } 111 | } 112 | } 113 | 114 | export default HtmlType 115 | -------------------------------------------------------------------------------- /src/js/types/iframe.js: -------------------------------------------------------------------------------- 1 | class IframeType { 2 | constructor () { 3 | this.userSettings = null 4 | } 5 | 6 | init (el, container, userSettings) { 7 | this.userSettings = userSettings 8 | 9 | const HREF = el.hasAttribute('data-target') ? el.getAttribute('data-target') : el.getAttribute('href') 10 | 11 | container.setAttribute('data-HREF', HREF) 12 | if (el.hasAttribute('data-allow')) { 13 | container.setAttribute('data-allow', el.getAttribute('data-allow')) 14 | } 15 | if (el.hasAttribute('data-width')) { 16 | container.setAttribute('data-width', `${el.getAttribute('data-width')}`) 17 | } 18 | if (el.hasAttribute('data-height')) { 19 | container.setAttribute('data-height', `${el.getAttribute('data-height')}`) 20 | } 21 | 22 | // dont create empty iframes here - very slow 23 | 24 | // Register type 25 | container.setAttribute('data-type', 'iframe') 26 | container.classList.add('tobii-iframe') 27 | } 28 | 29 | onPreload (container) { 30 | // Nothing 31 | } 32 | 33 | onLoad (container) { 34 | let IFRAME = container.querySelector('iframe') 35 | 36 | // Create loading indicator 37 | const LOADING_INDICATOR = document.createElement('div') 38 | LOADING_INDICATOR.className = 'tobii__loader' 39 | LOADING_INDICATOR.setAttribute('role', 'progressbar') 40 | LOADING_INDICATOR.setAttribute('aria-label', this.userSettings.loadingIndicatorLabel) 41 | container.appendChild(LOADING_INDICATOR) 42 | 43 | if (IFRAME == null) { 44 | // create iframe 45 | IFRAME = document.createElement('iframe') 46 | const HREF = container.getAttribute('data-href') 47 | 48 | IFRAME.setAttribute('frameborder', '0') 49 | IFRAME.setAttribute('src', HREF) 50 | IFRAME.setAttribute('allowfullscreen', '') 51 | 52 | // set allow parameters 53 | if (HREF.indexOf('youtube.com') > -1) { 54 | IFRAME.setAttribute('allow', 55 | 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture') 56 | } else if (HREF.indexOf('vimeo.com') > -1) { 57 | IFRAME.setAttribute('allow', 'autoplay; picture-in-picture') 58 | } else if (container.hasAttribute('data-allow')) { 59 | IFRAME.setAttribute('allow', container.getAttribute('data-allow')) 60 | } 61 | 62 | if (container.hasAttribute('data-width')) { 63 | IFRAME.style.maxWidth = `${container.getAttribute('data-width')}` 64 | } 65 | 66 | if (container.hasAttribute('data-height')) { 67 | IFRAME.style.maxHeight = `${container.getAttribute('data-height')}` 68 | } 69 | 70 | // Hide until loaded 71 | IFRAME.style.opacity = '0' 72 | 73 | // Add iframe to container 74 | container.appendChild(IFRAME) 75 | 76 | IFRAME.addEventListener('load', () => { 77 | IFRAME.style.opacity = '1' 78 | const LOADING_INDICATOR = container.querySelector('.tobii__loader') 79 | if (LOADING_INDICATOR) { 80 | container.removeChild(LOADING_INDICATOR) 81 | } 82 | }) 83 | IFRAME.addEventListener('error', () => { 84 | IFRAME.style.opacity = '1' 85 | const LOADING_INDICATOR = container.querySelector('.tobii__loader') 86 | if (LOADING_INDICATOR) { 87 | container.removeChild(LOADING_INDICATOR) 88 | } 89 | }) 90 | } else { 91 | // was already created 92 | IFRAME.setAttribute('src', container.getAttribute('data-href')) 93 | } 94 | } 95 | 96 | onLeave (container) { 97 | // Nothing 98 | } 99 | 100 | onCleanup (container) { 101 | const IFRAME = container.querySelector('iframe') 102 | IFRAME.setAttribute('src', '') 103 | IFRAME.style.opacity = '0' 104 | } 105 | 106 | onReset () { 107 | // Nothing 108 | } 109 | } 110 | 111 | export default IframeType 112 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v3.1.0 4 | 5 | ### New 6 | 7 | - empty or false `selector` option does only init Tobii (to `add()` elements later) 8 | 9 | ### Fixed 10 | 11 | - do not clone html-type target 12 | 13 | ## v3.0.0 14 | 15 | ### Breaking Changes 16 | 17 | - remove legacy prefixes 18 | - zoom icon default to false 19 | - remove autoplay settings, Media elements like YouTube `