Repository: Q42/floating-focus-a11y Branch: develop Commit: 07961f2bbc36 Files: 25 Total size: 37.6 KB Directory structure: gitextract_sek1uzv1/ ├── .browserslistrc ├── .editorconfig ├── .eslintrc ├── .github/ │ └── workflows/ │ └── node.js.yml ├── .gitignore ├── .nvmrc ├── .prettierrc ├── .vscode/ │ └── settings.json ├── LICENSE ├── README.md ├── __mocks__/ │ └── generalMocks.js ├── babel.config.js ├── example/ │ └── index.html ├── index.d.ts ├── jest.config.js ├── package.json ├── postcss.config.js ├── src/ │ ├── floating-focus.js │ ├── floating-focus.scss │ └── floating-focus.spec.js ├── webpack.common.js ├── webpack.dev.js ├── webpack.prod.js ├── webpack.styled.js └── webpack.unstyled.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .browserslistrc ================================================ Chrome >= 49 Safari >= 8 ios_saf >= 8 ie >= 11 Edge >= 12 Firefox >= 45 Samsung >= 2 ================================================ FILE: .editorconfig ================================================ # This file is for unifying the coding style for different editors and IDEs # editorconfig.org root = true [*] charset = utf-8 indent_style = space indent_size = 2 end_of_line = lf trim_trailing_whitespace = true insert_final_newline = true ================================================ FILE: .eslintrc ================================================ { "parser": "@babel/eslint-parser", "extends": [ "eslint:recommended", "plugin:prettier/recommended" ], "env": { "browser": true, "es6": true, "jest": true }, "globals": { "Promise": true, "require": true, "module": true }, "rules": { "no-console": 0, "indent": "off" } } ================================================ FILE: .github/workflows/node.js.yml ================================================ # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions name: Node.js CI on: push: branches: [ develop ] pull_request: branches: [ develop ] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version-file: package.json - run: npm run ci - run: npm run test - run: npm run build ================================================ FILE: .gitignore ================================================ # IntelliJ folders .idea_modules/ .idea/ # Node modules node_modules/ # Jest code coverage coverage/ # Built code dist/ # macOS .DS_Store ================================================ FILE: .nvmrc ================================================ 20.0.0 ================================================ FILE: .prettierrc ================================================ { "trailingComma": "es5", "useTabs": false, "tabWidth": 2, "semi": false, "singleQuote": true, "printWidth": 120 } ================================================ FILE: .vscode/settings.json ================================================ { "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.organizeImports": true }, "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" } } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019 Q42 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

Floating Focus

Build status Package version Package license

A clear, beautiful and easy to implement focus-state solution that improves accessibility and usability.

https://engineering.q42.nl/floating-focus/

![Drag Racing](https://thumbs.gfycat.com/GrimLoneKakapo-size_restricted.gif) --- ## Installation With [npm](https://www.npmjs.com/) installed, run ```bash $ npm install @q42/floating-focus-a11y --save ``` ## Usage Import the package and instantiate the class on page load: ```javascript import FloatingFocus from '@q42/floating-focus-a11y'; new FloatingFocus(containerElement); // Element is an optional parameter which defaults to `document.body` ``` Define a default outline and outline-offset. Either of these values can be overruled per component: ```css /* Hide all default focus states if a mouse is used, this is completely optional ofcourse */ *:focus { outline: none; } /* Default outline value, which will be applied to all elements receiving focus, this is a required step. */ /* The .focus class is used by the focus target, more below. */ .floating-focus-enabled :focus, .floating-focus-enabled .focus { outline: dodgerblue solid 2px; outline-offset: 8px; } /* Give all buttons a green focus state instead of dodgerblue, this is optional in case it's needed. */ .floating-focus-enabled [type="button"]:focus { outline-color: green; outline-offset: 4px; } ``` ### Focus target Sometimes the actual element that receives focus is hidden from view, as is common with a custom input field. In this case it's possible to define a `focus-target` attribute on the focusable element. ```html ``` This will append the `focus` class to the target element and make the visual focus box appear around the target element, instead of the element that actually has the native focus. ### Separate stylesheet For convenience, the styles are included in the script by default. There is also an option to include the stylesheet separately. This is particularly useful with strict `style-src 'self'` CORS headers. Import unstyled dist file: ```javascript import FloatingFocus from '@q42/floating-focus-a11y/dist/unstyled'; ``` The stylesheet can then be separately imported with your favorite CSS preprocessor: ```css @import '@q42/floating-focus-a11y/dist/unstyled'; ``` ### Extra cautions - Watch out with CSS transitions: if an element that will be focused has a `transition` for `outline-color` / `outline-width` / `outline-style` (including `all` !), the floating focus will not display correctly on that element. ## Develop ```bash $ npm run build $ npm run watch $ npm run test ``` ## Deploy ```bash # bump version $ npm version [major | minor | patch | premajor | preminor | prepatch | prerelease] # publish $ npm publish ``` ## License [MIT](https://opensource.org/licenses/MIT) ================================================ FILE: __mocks__/generalMocks.js ================================================ // This mocks general things like browser APIs or scripts we include in our html. // Runs once after jest setup. // Modules can be mocked by using jest.mock('module') in your test or, if you // (always) need specific behaviour, by putting a mock in this folder. window.addEventListener = document.addEventListener = jest.fn((type, listener, options) => { }); window.removeEventListener = document.removeEventListener = jest.fn((type, listener, options) => { }); ================================================ FILE: babel.config.js ================================================ module.exports = (api) => { const isTest = api.env('test') // Cache the returned result api.cache(true) return { presets: [ [ '@babel/preset-env', { modules: isTest ? 'commonjs' : false, loose: true, corejs: 3, useBuiltIns: 'entry', exclude: ['es.regexp.to-string'], }, ], ], } } ================================================ FILE: example/index.html ================================================ Document

Please input 3 or more characters!

================================================ FILE: index.d.ts ================================================ declare class FloatingFocus { constructor(container?: Element) } export default FloatingFocus ================================================ FILE: jest.config.js ================================================ module.exports = { testEnvironment: 'jsdom', clearMocks: true, moduleFileExtensions: ['js', 'jsx', 'json'], setupFilesAfterEnv: ['/__mocks__/generalMocks'], transform: { // Stub all styling & assets '.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub', '^.+\\.jsx?$': 'babel-jest', }, transformIgnorePatterns: ['/node_modules/'], testEnvironmentOptions: { url: 'http://localhost/', }, } ================================================ FILE: package.json ================================================ { "name": "@q42/floating-focus-a11y", "version": "1.4.0", "description": "An a11y focus solution that is clear, beautiful and easy to implement.", "keywords": [ "a11y", "focus", "floating" ], "author": { "name": "Ricardo Snoek", "email": "ricardo@q42.nl", "url": "https://q42.nl" }, "homepage": "https://github.com/Q42/FloatingFocus", "repository": { "type": "git", "url": "https://github.com/Q42/FloatingFocus.git" }, "bugs": { "url": "https://github.com/Q42/FloatingFocus/issues" }, "main": "dist/styled/index.js", "types": "index.d.ts", "scripts": { "ci": "npm ci --omit=optional", "build": "webpack --config webpack.prod.js", "watch": "webpack --config webpack.dev.js", "lint:check": "prettier --check ./src/**/*.{scss,js}", "lint:fix": "prettier --write ./src/**/*.{scss,js}", "test": "jest --verbose --coverage", "prepublishOnly": "npm run lint:check && npm run test && npm run build" }, "devDependencies": { "@babel/core": "^7.21.4", "@babel/eslint-parser": "^7.21.3", "@babel/preset-env": "^7.21.4", "@jest/globals": "^29.5.0", "autoprefixer": "^10.4.14", "babel-jest": "^29.5.0", "babel-loader": "^9.1.2", "clean-webpack-plugin": "^4.0.0", "copy-webpack-plugin": "^11.0.0", "core-js": "^3.30.1", "css-loader": "^6.7.3", "eslint": "^8.38.0", "eslint-config-prettier": "^8.8.0", "eslint-plugin-prettier": "^4.2.1", "eslint-webpack-plugin": "^4.0.1", "jest": "^29.5.0", "jest-cli": "^29.5.0", "jest-environment-jsdom": "^29.5.0", "jest-transform-stub": "^2.0.0", "mini-css-extract-plugin": "^2.7.5", "postcss-loader": "^7.2.4", "prettier": "^2.8.7", "regenerator-runtime": "^0.13.11", "sass": "^1.62.0", "sass-loader": "^13.2.2", "style-loader": "^3.3.2", "webpack": "^5.79.0", "webpack-cli": "^5.0.1", "webpack-merge": "^5.8.0" }, "license": "MIT", "files": [ "/dist", "index.d.ts" ], "volta": { "node": "20.0.0" } } ================================================ FILE: postcss.config.js ================================================ module.exports = { plugins: [require('autoprefixer')({ flexbox: 'no-2009' })], } ================================================ FILE: src/floating-focus.js ================================================ import './floating-focus.scss' export const HELPER_FADE_TIME = 800 export const MONITOR_INTERVAL = 250 export default class FloatingFocus { constructor(container = document.body) { this.container = container this.previousTargetRect = null this.floaterIsMoving = false this.addEventListeners() } addEventListeners() { this.handleKeyDown = this.handleKeyDown.bind(this) this.handleMouseDown = this.handleMouseDown.bind(this) this.handleFocus = this.handleFocus.bind(this) this.handleBlur = this.handleBlur.bind(this) this.handleScrollResize = this.handleScrollResize.bind(this) this.monitorElementPosition = this.monitorElementPosition.bind(this) document.addEventListener('keydown', this.handleKeyDown, false) document.addEventListener('mousedown', this.handleMouseDown, false) document.addEventListener('focus', this.handleFocus, true) document.addEventListener('blur', this.handleBlur, true) document.addEventListener('scroll', this.handleScrollResize, true) window.addEventListener('resize', this.handleScrollResize, true) } handleKeyDown(e) { // Show animation only upon Tab or Arrow keys press. if (e.keyCode !== 9 && !(e.keyCode > 36 && e.keyCode < 41)) { return } if (!this.floater) { this.floater = this.constructFloatingElement() } this.enableFloatingFocus() } handleMouseDown() { if (!this.floater) { return } this.disableFloatingFocus() } handleScrollResize() { if (!this.floater || !this.target) { return } requestAnimationFrame(() => this.repositionElement(this.target, this.floater)) } constructFloatingElement() { const element = document.createElement('div') element.classList.add('floating-focus') this.container.appendChild(element) return element } handleFocus(e) { let target = e.target if (!this.floater || !this.container) { return } if (target === this.floater) { this.handleBlur() return } if (!this.container.contains(target)) { this.handleBlur() return } this.floater.classList.add('visible') this.floater.classList.add('helper') this.floater.classList.add('moving') const focusTargetAttribute = target.getAttribute('focus-target') if (focusTargetAttribute) { target = document.querySelector(`#${focusTargetAttribute}`) || target } this.target = target // Make sure we can read the target style (even when refocussing the viewport) this.target.classList.remove('floating-focused') this.target.classList.add('focus') this.resolveTargetOutlineStyle(this.target, this.floater) this.repositionElement(this.target, this.floater) this.target.classList.add('floating-focused') this.handleFloaterMove() clearTimeout(this.helperFadeTimeout) this.helperFadeTimeout = setTimeout(() => this.floater.classList.remove('helper'), HELPER_FADE_TIME) } handleBlur() { if (!this.floater) { return } this.floater.classList.remove('visible') this.floater.classList.remove('helper') this.floater.classList.remove('moving') if (!this.target) { return } this.target.classList.remove('floating-focused') this.target.classList.remove('focus') } enableFloatingFocus() { this.container.classList.add('floating-focus-enabled') this.floater.classList.add('enabled') clearInterval(this.monitorElementPositionInterval) this.monitorElementPositionInterval = setInterval(this.monitorElementPosition, MONITOR_INTERVAL) } disableFloatingFocus() { this.container.classList.remove('floating-focus-enabled') this.floater.classList.remove('enabled') clearInterval(this.monitorElementPositionInterval) } handleFloaterMove() { if (this.floaterIsMoving) { return } this.floaterIsMoving = true const removeMovingClass = () => { this.floater.classList.remove('moving') this.floater.removeEventListener('transitionend', removeMovingClass) this.floaterIsMoving = false } this.floater.addEventListener('transitionend', removeMovingClass.bind(this)) } addPixels(pixels1, pixels2) { const result = parseFloat(pixels1) + parseFloat(pixels2) return !isNaN(result) ? `${result}px` : null } getOffsetBorderRadius(baseRadius, offset) { if (!baseRadius || parseFloat(baseRadius) === 0) { return '0px' } if (!offset) { return baseRadius } offset = Math.max(parseFloat(offset), 0) return this.addPixels(baseRadius, offset) || '0px' } resolveTargetOutlineStyle(target, floater) { const targetStyle = window.getComputedStyle(target) const padding = targetStyle.outlineOffset || null Object.assign(floater.style, { color: targetStyle.outlineColor, borderWidth: targetStyle.outlineWidth, borderStyle: targetStyle.outlineStyle, borderBottomLeftRadius: this.getOffsetBorderRadius(targetStyle.borderBottomLeftRadius, padding), borderBottomRightRadius: this.getOffsetBorderRadius(targetStyle.borderBottomRightRadius, padding), borderTopLeftRadius: this.getOffsetBorderRadius(targetStyle.borderTopLeftRadius, padding), borderTopRightRadius: this.getOffsetBorderRadius(targetStyle.borderTopRightRadius, padding), }) } getFloaterPosition(target) { const targetStyle = window.getComputedStyle(target) const padding = parseFloat(targetStyle.outlineOffset || '0px') const rect = target.getBoundingClientRect() this.previousTargetRect = rect const width = rect.width + padding * 2 const height = rect.height + padding * 2 const left = window.scrollX + rect.left - padding + width / 2 const top = window.scrollY + rect.top - padding + height / 2 return { left: `${left}px`, top: `${top}px`, width: `${width}px`, height: `${height}px`, } } monitorElementPosition() { if (!this.target || !this.previousTargetRect || this.floaterIsMoving) { return } const { left, top, width, height } = this.target.getBoundingClientRect() const { left: leftPrev, top: topPrev, width: widthPrev, height: heightPrev } = this.previousTargetRect if (left === leftPrev && top === topPrev && width === widthPrev && height === heightPrev) { return } this.floater.classList.add('moving') this.repositionElement(this.target, this.floater) this.handleFloaterMove() } repositionElement(target, floater) { Object.assign(floater.style, this.getFloaterPosition(target)) } } ================================================ FILE: src/floating-focus.scss ================================================ .floating-focus { border: 0 solid currentColor; position: absolute; transform: translate(-50%, -50%); opacity: 0; will-change: top, left, width, height; box-sizing: content-box; pointer-events: none; overflow: hidden; z-index: 9999999999; // It should always be on top of everything, no matter what. &.moving { transition-property: opacity, left, top, width, height, border-width, border-radius; transition-duration: 0.2s, 0.1s, 0.1s, 0.1s, 0.1s, 0.1s, 0.1s; transition-timing-function: linear, ease, ease, ease, ease, ease, ease; } @media (prefers-reduced-motion: reduce) { &.moving { transition: none; } } &.enabled.visible { opacity: 1; } &::after { content: ''; background: currentColor; position: absolute; top: 0; right: 0; bottom: 0; left: 0; opacity: 0; transition: opacity 0.2s linear; } &.helper::after { transition-duration: 0.1s; opacity: 0.3; } } .floating-focused { outline-style: none !important; &::-moz-focus-inner { border: 0 !important; } } ================================================ FILE: src/floating-focus.spec.js ================================================ import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals' import FloatingFocus, { HELPER_FADE_TIME, MONITOR_INTERVAL } from './floating-focus' describe('Floating focus', () => { beforeEach(() => { jest.useFakeTimers() jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb()) }) afterEach(() => { document.body.className = '' document.body.innerHTML = '' jest.clearAllTimers() window.requestAnimationFrame.mockRestore() }) it('Should bind all required event listeners on construction', () => { expect(document.addEventListener).not.toHaveBeenCalled() const floatingFocus = new FloatingFocus() expect(document.addEventListener).toHaveBeenNthCalledWith(1, 'keydown', floatingFocus.handleKeyDown, false) expect(document.addEventListener).toHaveBeenNthCalledWith(2, 'mousedown', floatingFocus.handleMouseDown, false) expect(document.addEventListener).toHaveBeenNthCalledWith(3, 'focus', floatingFocus.handleFocus, true) expect(document.addEventListener).toHaveBeenNthCalledWith(4, 'blur', floatingFocus.handleBlur, true) expect(document.addEventListener).toHaveBeenNthCalledWith(5, 'scroll', floatingFocus.handleScrollResize, true) expect(window.addEventListener).toHaveBeenNthCalledWith(6, 'resize', floatingFocus.handleScrollResize, true) }) it('Should not do anything if the keyboard input is not Tab or Arrow keys', () => { const floatingFocus = new FloatingFocus() floatingFocus.enableFloatingFocus = jest.fn() floatingFocus.handleKeyDown({ keyCode: 21 }) expect(floatingFocus.enableFloatingFocus).not.toHaveBeenCalled() floatingFocus.handleKeyDown({ keyCode: 42 }) expect(floatingFocus.enableFloatingFocus).not.toHaveBeenCalled() floatingFocus.handleKeyDown({ keyCode: 9 }) expect(floatingFocus.enableFloatingFocus).toHaveBeenCalled() }) it('Should construct, append and return a floating element', () => { const floatingFocus = new FloatingFocus() const floatingElement = floatingFocus.constructFloatingElement() expect(floatingElement instanceof Element).toBe(true) expect(floatingElement.classList.contains('floating-focus')).toBe(true) expect(floatingElement.tagName).toBe('DIV') expect(document.body.contains(floatingElement)).toBe(true) }) it("Should create the 'floater' element when it is not present yet", () => { const floatingFocus = new FloatingFocus() floatingFocus.constructFloatingElement = jest.fn().mockImplementation(() => document.createElement('div')) expect(floatingFocus.constructFloatingElement).not.toHaveBeenCalled() floatingFocus.handleKeyDown({ keyCode: 9 }) expect(floatingFocus.constructFloatingElement).toHaveBeenCalled() }) it("Should not recreate the 'floater' element when it's already present created", () => { const floatingFocus = new FloatingFocus() floatingFocus.constructFloatingElement = jest.fn().mockImplementation(() => document.createElement('div')) expect(floatingFocus.constructFloatingElement).not.toHaveBeenCalled() floatingFocus.handleKeyDown({ keyCode: 9 }) expect(floatingFocus.constructFloatingElement).toHaveBeenCalled() floatingFocus.handleKeyDown({ keyCode: 9 }) expect(floatingFocus.constructFloatingElement).toHaveBeenCalledTimes(1) }) it("Should only try to disable focus it the 'element' was created before", () => { const floatingFocus = new FloatingFocus() floatingFocus.disableFloatingFocus = jest.fn() expect(floatingFocus.disableFloatingFocus).not.toHaveBeenCalled() floatingFocus.handleMouseDown() expect(floatingFocus.disableFloatingFocus).not.toHaveBeenCalled() floatingFocus.handleKeyDown({ keyCode: 9 }) floatingFocus.handleMouseDown() expect(floatingFocus.disableFloatingFocus).toHaveBeenCalled() }) it("Should only reposition if a target and 'floater' was set, when scrolling or resizing", async () => { const floatingFocus = new FloatingFocus() floatingFocus.repositionElement = jest.fn() floatingFocus.handleScrollResize() expect(floatingFocus.repositionElement).not.toHaveBeenCalled() floatingFocus.floater = document.createElement('div') floatingFocus.target = document.createElement('div') floatingFocus.handleScrollResize() await new Promise((resolve) => requestAnimationFrame(resolve)) expect(floatingFocus.repositionElement).toHaveBeenCalled() }) it('Should enable the floating element by setting the appropriate classes', () => { const floatingFocus = new FloatingFocus() floatingFocus.floater = document.createElement('div') expect(document.body.classList.contains('floating-focus-enabled')).toBe(false) expect(floatingFocus.floater.classList.contains('enabled')).toBe(false) floatingFocus.enableFloatingFocus() expect(document.body.classList.contains('floating-focus-enabled')).toBe(true) expect(floatingFocus.floater.classList.contains('enabled')).toBe(true) }) it('Should disable the floating element by removing the appropriate classes', () => { const floatingFocus = new FloatingFocus() floatingFocus.floater = document.createElement('div') floatingFocus.enableFloatingFocus() expect(document.body.classList.contains('floating-focus-enabled')).toBe(true) expect(floatingFocus.floater.classList.contains('enabled')).toBe(true) floatingFocus.disableFloatingFocus() expect(document.body.classList.contains('floating-focus-enabled')).toBe(false) expect(floatingFocus.floater.classList.contains('enabled')).toBe(false) }) it('Should early return if not meeting requirements yet, when calling for focus handling', () => { const floatingFocus = new FloatingFocus() floatingFocus.resolveTargetOutlineStyle = jest.fn() const target = document.createElement('div') floatingFocus.handleFocus({ target }) // Missing 'floater' element expect(floatingFocus.resolveTargetOutlineStyle).not.toHaveBeenCalled() floatingFocus.floater = floatingFocus.constructFloatingElement() floatingFocus.handleFocus({ target: floatingFocus.floater }) // Target is the same as 'floater' element floatingFocus.handleFocus({ target }) // Target is not inside the body expect(floatingFocus.resolveTargetOutlineStyle).not.toHaveBeenCalled() document.body.appendChild(target) floatingFocus.handleFocus({ target }) // Successful handleFocus call expect(floatingFocus.resolveTargetOutlineStyle).toHaveBeenCalled() }) it('Should set all appropriate classes when handling focus', async () => { const floatingFocus = new FloatingFocus() floatingFocus.floater = floatingFocus.constructFloatingElement() const target = document.createElement('div') document.body.appendChild(target) floatingFocus.handleFocus({ target }) expect(floatingFocus.floater.classList.contains('visible')).toBe(true) expect(floatingFocus.floater.classList.contains('helper')).toBe(true) expect(floatingFocus.floater.classList.contains('moving')).toBe(true) expect(floatingFocus.target).toBe(target) expect(floatingFocus.target.classList.contains('floating-focused')).toBe(true) floatingFocus.floater.dispatchEvent(new Event('transitionend')) expect(floatingFocus.floater.classList.contains('moving')).toBe(false) jest.advanceTimersByTime(HELPER_FADE_TIME) expect(floatingFocus.floater.classList.contains('helper')).toBe(false) }) it('Should change the target to a different element when the focused element has a focus-target attribute', async () => { const floatingFocus = new FloatingFocus() floatingFocus.floater = floatingFocus.constructFloatingElement() const target = document.createElement('div') const focusTarget = document.createElement('div') target.setAttribute('focus-target', 'element123') focusTarget.id = 'element123' document.body.appendChild(target) document.body.appendChild(focusTarget) floatingFocus.handleFocus({ target }) expect(floatingFocus.target).toEqual(focusTarget) expect(focusTarget.classList.contains('focus')).toBe(true) }) it('Should use the existing target if its focus-target cannot be found', () => { const floatingFocus = new FloatingFocus() floatingFocus.floater = floatingFocus.constructFloatingElement() const target = document.createElement('div') target.setAttribute('focus-target', 'element123') document.body.appendChild(target) floatingFocus.handleFocus({ target }) expect(floatingFocus.target).toEqual(target) expect(target.classList.contains('focus')).toBe(true) }) it('Should resolve the target outline style and reposition the element when handling focus', () => { const floatingFocus = new FloatingFocus() floatingFocus.resolveTargetOutlineStyle = jest.fn() floatingFocus.repositionElement = jest.fn() floatingFocus.floater = floatingFocus.constructFloatingElement() const target = document.createElement('div') document.body.appendChild(target) expect(floatingFocus.resolveTargetOutlineStyle).not.toHaveBeenCalled() expect(floatingFocus.resolveTargetOutlineStyle).not.toHaveBeenCalled() floatingFocus.handleFocus({ target }) expect(floatingFocus.resolveTargetOutlineStyle).toHaveBeenCalled() expect(floatingFocus.resolveTargetOutlineStyle).toHaveBeenCalled() }) it("Should early return when 'floater' is not present when handling blur", () => { const floatingFocus = new FloatingFocus() expect(floatingFocus.handleBlur()).toBe(undefined) }) it("Should remove all visibility classes from the 'floater' when handleBlur is called", () => { const floatingFocus = new FloatingFocus() floatingFocus.target = document.createElement('div') floatingFocus.floater = floatingFocus.constructFloatingElement() floatingFocus.floater.classList.add('visible') floatingFocus.floater.classList.add('helper') floatingFocus.floater.classList.add('moving') expect(floatingFocus.floater.classList.contains('visible')).toBe(true) expect(floatingFocus.floater.classList.contains('helper')).toBe(true) expect(floatingFocus.floater.classList.contains('moving')).toBe(true) floatingFocus.handleBlur() expect(floatingFocus.floater.classList.contains('visible')).toBe(false) expect(floatingFocus.floater.classList.contains('helper')).toBe(false) expect(floatingFocus.floater.classList.contains('moving')).toBe(false) }) it('Should resolve and append the outline styling from the target element', () => { const floatingFocus = new FloatingFocus() const target = document.createElement('div') const floater = floatingFocus.constructFloatingElement() const targetStyle = { outlineOffset: '8px', outlineColor: 'dodgerblue', outlineStyle: 'dashed', outlineWidth: '2px', borderBottomLeftRadius: '0px', borderBottomRightRadius: '0px', borderTopLeftRadius: '0px', borderTopRightRadius: '0px', } window.getComputedStyle = jest.fn().mockImplementation(() => targetStyle) floatingFocus.resolveTargetOutlineStyle(target, floater) expect(floater.style.color).toBe(targetStyle.outlineColor) expect(floater.style.borderWidth).toBe(targetStyle.outlineWidth) expect(floater.style.borderStyle).toBe(targetStyle.outlineStyle) expect(floater.style.borderBottomLeftRadius).toBe(targetStyle.borderBottomLeftRadius) expect(floater.style.borderBottomRightRadius).toBe(targetStyle.borderBottomRightRadius) expect(floater.style.borderTopLeftRadius).toBe(targetStyle.borderTopLeftRadius) expect(floater.style.borderTopRightRadius).toBe(targetStyle.borderTopRightRadius) }) it("Should correctly offset the target element's border radii by its outline offset", () => { const floatingFocus = new FloatingFocus() const target = document.createElement('div') const floater = floatingFocus.constructFloatingElement() const targetStyle = { outlineOffset: '8px', borderBottomLeftRadius: '6px', borderBottomRightRadius: '0px', borderTopLeftRadius: null, } window.getComputedStyle = jest.fn().mockImplementation(() => targetStyle) floatingFocus.resolveTargetOutlineStyle(target, floater) expect(floater.style.borderBottomLeftRadius).toBe('14px') expect(floater.style.borderBottomRightRadius).toBe('0px') expect(floater.style.borderTopLeftRadius).toBe('0px') expect(floater.style.borderTopRightRadius).toBe('0px') targetStyle.outlineOffset = null floatingFocus.resolveTargetOutlineStyle(target, floater) expect(floater.style.borderBottomLeftRadius).toBe(targetStyle.borderBottomLeftRadius) }) it.each([4, 0])("Should reposition 'floater' based on target position -- outline offset %d", (outlineOffset) => { const floatingFocus = new FloatingFocus() const target = document.createElement('div') const floater = floatingFocus.constructFloatingElement() const targetStyle = window.getComputedStyle(target) targetStyle.outlineOffset = outlineOffset const rect = { left: 42, top: 84, width: 42, height: 128, } target.getBoundingClientRect = jest.fn().mockImplementation(() => rect) floatingFocus.repositionElement(target, floater) expect(floater.style.left).toBe(`${rect.left + rect.width / 2}px`) expect(floater.style.top).toBe(`${rect.top + rect.height / 2}px`) expect(floater.style.width).toBe(`${rect.width + outlineOffset * 2}px`) expect(floater.style.height).toBe(`${rect.height + outlineOffset * 2}px`) }) it("Should automatically reposition the 'floater' when the target element's position changes", async () => { const floatingFocus = new FloatingFocus() const target = document.createElement('div') document.body.appendChild(target) const rect = { left: 42, top: 84, width: 42, height: 128, } target.getBoundingClientRect = jest.fn().mockImplementation(() => ({ ...rect })) floatingFocus.handleKeyDown({ keyCode: 9 }) floatingFocus.enableFloatingFocus() floatingFocus.handleFocus({ target }, true) // Cleanup because transitionend is not called in this setup of jsdom floatingFocus.floater.classList.remove('moving') floatingFocus.floaterIsMoving = false expect(floatingFocus.floater.style.left).toBe(`${rect.left + rect.width / 2}px`) expect(floatingFocus.floater.style.top).toBe(`${rect.top + rect.height / 2}px`) jest.advanceTimersByTime(MONITOR_INTERVAL) expect(floatingFocus.floater.classList.contains('moving')).toBe(false) jest.advanceTimersByTime(MONITOR_INTERVAL) expect(floatingFocus.floater.classList.contains('moving')).toBe(false) rect.left += 42 rect.top += 42 expect(floatingFocus.floater.classList.contains('moving')).toBe(false) expect(floatingFocus.floater.style.left).not.toBe(`${rect.left + rect.width / 2}px`) expect(floatingFocus.floater.style.top).not.toBe(`${rect.top + rect.height / 2}px`) jest.advanceTimersByTime(MONITOR_INTERVAL) expect(floatingFocus.floater.classList.contains('moving')).toBe(true) expect(floatingFocus.floater.style.left).toBe(`${rect.left + rect.width / 2}px`) expect(floatingFocus.floater.style.top).toBe(`${rect.top + rect.height / 2}px`) }) describe('addPixels', () => { it("Should correctly add up pixel amounts as if it's a normal calculation", () => { const floatingFocus = new FloatingFocus() const number1 = Math.random() * 10 const number2 = Math.random() * 10 expect(floatingFocus.addPixels(`${number1}px`, `${number2}px`)).toBe(`${number1 + number2}px`) }) it('Should return null in case of invalid input', () => { const floatingFocus = new FloatingFocus() const number1 = '10px' const number2 = 'apx' expect(floatingFocus.addPixels(number1, number2)).toBeNull() }) }) }) ================================================ FILE: webpack.common.js ================================================ const ESLintPlugin = require('eslint-webpack-plugin') module.exports = { entry: { 'floating-focus': './src/floating-focus.js', }, output: { filename: 'index.js', library: 'floating-focus', libraryTarget: 'umd', }, module: { rules: [ { test: /\.js$/, exclude: /node_modules|dist/, loader: 'babel-loader', }, ], }, plugins: [ new ESLintPlugin({ files: 'src/**/*.js', failOnWarning: true, }), ], } ================================================ FILE: webpack.dev.js ================================================ const merge = require('webpack-merge') const styled = require('./webpack.styled.js') module.exports = merge(styled, { mode: 'development', watch: true, }) ================================================ FILE: webpack.prod.js ================================================ const { merge } = require('webpack-merge') const styled = require('./webpack.styled.js') const unstyled = require('./webpack.unstyled.js') const { CleanWebpackPlugin } = require('clean-webpack-plugin') const prodConfig = { mode: 'production', plugins: [new CleanWebpackPlugin()], } module.exports = [merge(styled, prodConfig), merge(unstyled, prodConfig)] ================================================ FILE: webpack.styled.js ================================================ /* global __dirname */ const path = require('path') const { merge } = require('webpack-merge') const common = require('./webpack.common.js') module.exports = merge(common, { output: { path: path.resolve(__dirname, 'dist/styled'), }, module: { rules: [ { test: /\.(css|scss)$/, use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'], }, ], }, }) ================================================ FILE: webpack.unstyled.js ================================================ /* global __dirname */ const path = require('path') const { merge } = require('webpack-merge') const common = require('./webpack.common.js') const MiniCssExtractPlugin = require('mini-css-extract-plugin') const CopyWebpackPlugin = require('copy-webpack-plugin') module.exports = merge(common, { output: { path: path.resolve(__dirname, 'dist/unstyled'), }, module: { rules: [ { test: /\.(css|scss)$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader', 'sass-loader'], }, ], }, plugins: [ new MiniCssExtractPlugin({ filename: 'index.css', }), new CopyWebpackPlugin({ patterns: ['index.d.ts'], }), ], })