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
A clear, beautiful and easy to implement focus-state solution that improves accessibility and usability.
https://engineering.q42.nl/floating-focus/

---
## 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
Please upload a file
```
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
Test 1
Test 2
Test 3
Please upload a file
================================================
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'],
}),
],
})