├── .gitignore ├── src ├── index.js ├── helpers │ ├── static.js │ ├── tabbable.js │ ├── focusManager.js │ ├── focusTrap.js │ └── styles.js └── OffCanvas.js ├── .travis.yml ├── prettier.config.js ├── babel.config.js ├── .npmignore ├── examples ├── index.js ├── index.html └── App.js ├── tests ├── helpers │ ├── tests.js │ └── components.js ├── OffCanvas.snaps.spec.js ├── __snapshots__ │ └── OffCanvas.snaps.spec.js.snap ├── OffCanvas.styles.spec.js ├── OffCanvas.events.spec.js └── OffCanvas.spec.js ├── .eslintrc.json ├── webpack.config.js ├── LICENSE ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | examples/__build__ -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import OffCanvas from './OffCanvas' 2 | 3 | export default OffCanvas 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - 'node' 5 | install: npm install 6 | script: npm run test 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | semi: false, 6 | } 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-env', '@babel/preset-react'], 3 | plugins: ['@babel/plugin-proposal-class-properties'], 4 | } 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tests 3 | examples 4 | .babelrc 5 | .eslintrc.json 6 | .gitignore 7 | .travis.yml 8 | prettier.config.js 9 | webpack.config.js 10 | babel.config.js -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | 5 | ReactDOM.render(, document.getElementById('app')) 6 | -------------------------------------------------------------------------------- /src/helpers/static.js: -------------------------------------------------------------------------------- 1 | export const canUseDOM = !!( 2 | typeof window !== 'undefined' && 3 | window.document && 4 | window.document.createElement 5 | ) 6 | 7 | export const canUseRoot = root => !!root 8 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Accessible Off-Canvas component for React.js 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/helpers/tests.js: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | 3 | export const getContent = element => { 4 | const { getByTestId } = render(element) 5 | const content = getByTestId('content') 6 | return content 7 | } 8 | 9 | export const getOverlay = element => { 10 | const { getByTestId } = render(element) 11 | const overlay = getByTestId('overlay') 12 | return overlay 13 | } 14 | 15 | export const extractNumber = property => parseInt(property.match(/[-]*\d+/)) 16 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "jest": true 5 | }, 6 | "extends": [ 7 | "plugin:prettier/recommended", 8 | "plugin:react/recommended", 9 | "plugin:jsx-a11y/recommended" 10 | ], 11 | "parser": "babel-eslint", 12 | "plugins": ["react", "prettier", "jsx-a11y"], 13 | "rules": { 14 | "prettier/prettier": "error", 15 | "no-unused-vars": ["error", { "args": "none" }], 16 | "jsx-a11y/no-static-element-interactions": 0, 17 | "jsx-a11y/click-events-have-key-events": 0 18 | }, 19 | "settings": { 20 | "react": { 21 | "version": "detect" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/OffCanvas.snaps.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import renderer from 'react-test-renderer' 4 | import OffCanvas from '../src/index' 5 | 6 | // Mock ReactDOM.createPortal 7 | ReactDOM.createPortal = jest.fn((element, node) => element) 8 | 9 | describe('OffCanvas', () => { 10 | it('renders correctly when open', () => { 11 | const tree = renderer.create().toJSON() 12 | expect(tree).toMatchSnapshot() 13 | }) 14 | 15 | it('renders correctly when closed', () => { 16 | const tree = renderer.create().toJSON() 17 | expect(tree).toMatchSnapshot() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/helpers/tabbable.js: -------------------------------------------------------------------------------- 1 | const tabbable = [ 2 | 'a[href]', 3 | 'area[href]', 4 | 'input:not([disabled])', 5 | 'select:not([disabled])', 6 | 'textarea:not([disabled])', 7 | 'button:not([disabled])', 8 | '[contenteditable]', 9 | '[tabindex]:not([tabindex="-1"])', 10 | 'audio[controls]', 11 | 'video[controls]', 12 | ] 13 | 14 | const findTabbable = element => { 15 | if (element) { 16 | const tabbableElements = element.querySelectorAll(tabbable.join(',')) 17 | return tabbableElements 18 | } 19 | } 20 | 21 | export const setTabbable = element => { 22 | if (element) { 23 | const tabbableElements = findTabbable(element) 24 | 25 | return { 26 | first: tabbableElements[0], 27 | last: tabbableElements[tabbableElements.length - 1], 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/helpers/focusManager.js: -------------------------------------------------------------------------------- 1 | import { setTabbable } from './tabbable' 2 | 3 | let lastFocusedElement = undefined 4 | 5 | export const focusLater = () => { 6 | lastFocusedElement = document.activeElement 7 | } 8 | 9 | export const returnFocus = () => { 10 | if (lastFocusedElement) { 11 | lastFocusedElement.focus() 12 | lastFocusedElement = null 13 | } 14 | } 15 | 16 | export const focusFirstChild = element => { 17 | const tabbable = setTabbable(element) 18 | tabbable.first.focus() 19 | } 20 | 21 | export const focusChild = (parent, selector) => { 22 | const child = document.querySelector(selector) 23 | 24 | if (!parent.contains(child)) { 25 | console.error(`${selector} is not a child of the specified parent.`) // eslint-disable-line 26 | return 27 | } 28 | 29 | child.focus() 30 | } 31 | -------------------------------------------------------------------------------- /src/helpers/focusTrap.js: -------------------------------------------------------------------------------- 1 | import { setTabbable } from './tabbable' 2 | 3 | const TAB_KEY = 9 4 | 5 | const isTabKey = event => event.keyCode === TAB_KEY 6 | 7 | // Traps focus in an element. 8 | const focusTrap = (event, element) => { 9 | const tabbable = setTabbable(element) 10 | 11 | if (!isTabKey(event)) return 12 | 13 | if (event.shiftKey) { 14 | // If focus is on the first element, move focus to the last element 15 | if (event.target === tabbable.first) { 16 | event.preventDefault() 17 | tabbable.last.focus() 18 | } 19 | } else { 20 | // If focus is on the last element, move focus to the first element 21 | if (event.target === tabbable.last) { 22 | event.preventDefault() 23 | tabbable.first.focus() 24 | } 25 | } 26 | } 27 | 28 | export default focusTrap 29 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HtmlWebPackPlugin = require('html-webpack-plugin') 3 | 4 | module.exports = { 5 | entry: { 6 | example: './examples/index.js', 7 | }, 8 | output: { 9 | path: path.resolve(__dirname, './examples/__build__'), 10 | filename: 'bundle.js', 11 | }, 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.js$/, 16 | exclude: /node_modules/, 17 | loader: 'babel-loader', 18 | options: { 19 | presets: ['@babel/react'], 20 | }, 21 | }, 22 | ], 23 | }, 24 | plugins: [ 25 | new HtmlWebPackPlugin({ 26 | template: './examples/index.html', 27 | filename: './index.html', 28 | }), 29 | ], 30 | devServer: { 31 | contentBase: path.join(__dirname, 'examples'), 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Denis Kotlica 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 | -------------------------------------------------------------------------------- /tests/helpers/components.js: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react' 2 | import OffCanvas from '../../src/index' 3 | 4 | /* eslint react/prop-types: 0 */ 5 | export default class App extends Component { 6 | state = { 7 | isOpen: false, 8 | } 9 | 10 | open = () => { 11 | this.setState({ isOpen: true }) 12 | } 13 | 14 | close = () => { 15 | this.setState({ isOpen: false }) 16 | } 17 | 18 | render() { 19 | const { 20 | isOpen, 21 | mainContainerSelector, 22 | returnFocusAfterClose, 23 | style, 24 | containerClassName, 25 | } = this.props 26 | 27 | return ( 28 | 29 |
30 | 31 |
32 | 39 |

Testing

40 |
41 |
42 | ) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/__snapshots__/OffCanvas.snaps.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`OffCanvas renders correctly when closed 1`] = ` 4 |
19 |
40 |
41 | `; 42 | 43 | exports[`OffCanvas renders correctly when open 1`] = ` 44 |
59 |
80 |
81 | `; 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-aria-offcanvas", 3 | "version": "1.4.3", 4 | "description": "Accessible Off-Canvas component for React.js", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --mode development", 8 | "build": "webpack --mode production", 9 | "deploy": "gh-pages -d examples/__build__", 10 | "publish:gh": "npm run build && npm run deploy", 11 | "transpile": "npx babel src -d dist", 12 | "prepare": "npm run transpile", 13 | "lint": "eslint src tests", 14 | "jest": "npx jest", 15 | "jest:w": "npx jest --watch", 16 | "test": "npm run lint && npm run jest" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/neosiae/react-aria-offcanvas.git" 21 | }, 22 | "keywords": [ 23 | "react", 24 | "react-component", 25 | "aria", 26 | "offcanvas", 27 | "accessible" 28 | ], 29 | "author": "Denis Kotlica", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/neosiae/react-aria-offcanvas/issues" 33 | }, 34 | "homepage": "https://neosiae.github.io/react-aria-offcanvas", 35 | "dependencies": { 36 | "prop-types": "^15.7.2", 37 | "run-event-handler-once": "^1.0.2" 38 | }, 39 | "peerDependencies": { 40 | "react": "^16.4.2 || ^17.0.0", 41 | "react-dom": "^16.4.2 || ^17.0.0" 42 | }, 43 | "devDependencies": { 44 | "@babel/cli": "^7.8.4", 45 | "@babel/core": "^7.8.7", 46 | "@babel/plugin-proposal-class-properties": "^7.8.3", 47 | "@babel/preset-env": "^7.10.2", 48 | "@babel/preset-react": "^7.8.3", 49 | "@testing-library/dom": "^5.6.1", 50 | "@testing-library/react": "^8.0.9", 51 | "babel-eslint": "^10.1.0", 52 | "babel-jest": "^24.9.0", 53 | "babel-loader": "^8.0.6", 54 | "eslint": "^5.16.0", 55 | "eslint-config-prettier": "^4.3.0", 56 | "eslint-plugin-jsx-a11y": "^6.2.3", 57 | "eslint-plugin-prettier": "^3.1.2", 58 | "eslint-plugin-react": "^7.19.0", 59 | "gh-pages": "^2.2.0", 60 | "html-webpack-plugin": "^3.2.0", 61 | "jest": "^24.9.0", 62 | "jest-dom": "^3.5.0", 63 | "prettier": "^1.19.1", 64 | "react": "^16.8.6", 65 | "react-dom": "^16.8.6", 66 | "react-test-renderer": "^16.8.6", 67 | "webpack": "^4.44.1", 68 | "webpack-cli": "^3.3.11", 69 | "webpack-dev-server": "^3.11.0" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /examples/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react' 2 | import OffCanvas from '../src/index' 3 | 4 | const Navigation = () => ( 5 | 30 | ) 31 | 32 | const styles = { 33 | container: { 34 | textAlign: 'center', 35 | marginTop: '2.5rem', 36 | fontFamily: ' -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif' 37 | }, 38 | subtitle: { 39 | fontSize: '1.5rem', 40 | }, 41 | github: { 42 | marginTop: '2.5rem', 43 | }, 44 | } 45 | 46 | export default class App extends Component { 47 | state = { 48 | isOpen: false, 49 | } 50 | 51 | open = () => { 52 | this.setState({ isOpen: true }) 53 | } 54 | 55 | close = () => { 56 | this.setState({ isOpen: false }) 57 | } 58 | 59 | render() { 60 | return ( 61 | 62 |
63 |

react-aria-offcanvas

64 |

65 | Accessible Off-Canvas component for React.js 66 |

67 | 76 |

77 | 81 | View source on GitHub 82 | 83 |

84 |
85 | 92 | 93 | 94 | 95 |
96 | ) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/helpers/styles.js: -------------------------------------------------------------------------------- 1 | // Sets the vertical or horizontal position of an element. 2 | const setPosition = position => { 3 | let obj = {} 4 | obj[position] = '0' 5 | return obj 6 | } 7 | 8 | const setContentTransformValue = position => { 9 | if (position === 'left') { 10 | return `translateX(-100%)` 11 | } else if (position === 'right') { 12 | return `translateX(100%)` 13 | } else if (position === 'top') { 14 | return `translateY(-100%)` 15 | } else { 16 | return `translateY(100%)` 17 | } 18 | } 19 | 20 | const setPushTransformValue = (width, height, position) => { 21 | if (position === 'left') { 22 | return `translateX(${width})` 23 | } else if (position === 'right') { 24 | return `translateX(-${width})` 25 | } else if (position === 'top') { 26 | return `translateY(${height})` 27 | } else { 28 | return `translateY(-${height})` 29 | } 30 | } 31 | 32 | // Checks whether an element has a className. 33 | export const hasClassName = element => element && element.classList.length > 0 34 | 35 | export const shouldShowContent = (content, isOpen) => { 36 | if (content) { 37 | content.style.visibility = isOpen ? 'visible' : 'hidden' 38 | } 39 | } 40 | 41 | export const createStyles = ( 42 | defaultStyles, 43 | extraStyles, 44 | isOpen, 45 | width, 46 | height, 47 | position, 48 | customStyles, 49 | ) => { 50 | const positionProperty = setPosition(position) 51 | 52 | return { 53 | overlay: { 54 | ...defaultStyles.overlay, 55 | ...extraStyles.overlay, 56 | width: isOpen ? '100%' : '', 57 | ...customStyles.overlay, 58 | }, 59 | content: { 60 | ...defaultStyles.content, 61 | ...extraStyles.content, 62 | ...positionProperty, 63 | width: width, 64 | height: height, 65 | transform: isOpen ? '' : setContentTransformValue(position), 66 | ...customStyles.content, 67 | // !important 68 | // Off-Canvas element should be able to receive focus immediately. 69 | // https://allyjs.io/tutorials/focusing-in-animated-ui.html#remedy-2-caveat 70 | transitionProperty: isOpen ? 'transform' : '', 71 | }, 72 | } 73 | } 74 | 75 | export const createPushStyles = ( 76 | extraStyles, 77 | isOpen, 78 | width, 79 | height, 80 | position, 81 | ) => { 82 | const styles = { 83 | ...extraStyles, 84 | transform: isOpen ? setPushTransformValue(width, height, position) : '', 85 | } 86 | 87 | return function(element) { 88 | if (element) { 89 | // Apply the push styles 90 | for (const property of Object.keys(styles)) { 91 | element.style[property] = styles[property] 92 | } 93 | } 94 | } 95 | } 96 | 97 | export const applyInitialPushStyles = (element, width, height, position) => { 98 | if (element) { 99 | element.style.transform = setPushTransformValue(width, height, position) 100 | } 101 | } 102 | 103 | // Shows/hides the horizontal scrollbar. 104 | export const shouldHideHorizontalScrollbar = isOpen => { 105 | const body = document.querySelector('body') 106 | body.style.overflowX = isOpen ? 'hidden' : '' 107 | } 108 | 109 | // should lock the body scroll when open 110 | export const shouldLockBodyScroll = isOpen => { 111 | const body = document.querySelector('body') 112 | body.style.overflowY = isOpen ? 'hidden' : '' 113 | } 114 | -------------------------------------------------------------------------------- /tests/OffCanvas.styles.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, cleanup, fireEvent } from '@testing-library/react' 3 | import { getOverlay, getContent } from './helpers/tests' 4 | import OffCanvas from '../src/index' 5 | import App from './helpers/components' 6 | 7 | describe('OffCanvas', () => { 8 | afterEach(cleanup) 9 | 10 | describe('container', () => { 11 | it('removes extra styles when a custom className is passed', () => { 12 | const { getByTestId, getByText } = render( 13 | , 17 | ) 18 | const mainContainer = getByTestId('main') 19 | const button = getByText('Open') 20 | 21 | fireEvent.click(button) 22 | 23 | expect(mainContainer.style.transition).toBe('') 24 | }) 25 | }) 26 | 27 | describe('overlay', () => { 28 | it('supports inline styling', () => { 29 | const style = { overlay: { background: 'rgba(0, 0, 0, 0.5)' } } 30 | const overlay = getOverlay() 31 | 32 | expect(overlay.style.background).toBe('rgba(0, 0, 0, 0.5)') 33 | }) 34 | 35 | it('accepts a custom overlayClassName', () => { 36 | const overlay = getOverlay( 37 | , 38 | ) 39 | 40 | expect(overlay.classList.contains('customOverlayClassName')).toBe(true) 41 | }) 42 | 43 | it('removes extra styles when a custom overlayClassName is passed', () => { 44 | const overlay = getOverlay( 45 | , 46 | ) 47 | 48 | expect(overlay.style.background).toBe('') 49 | }) 50 | }) 51 | 52 | describe('content', () => { 53 | it('supports inline styling', () => { 54 | const style = { content: { width: '100%' } } 55 | const content = getContent() 56 | 57 | expect(content.style.width).toBe('100%') 58 | }) 59 | 60 | it('accepts a custom className', () => { 61 | const content = getContent() 62 | 63 | expect(content.classList.contains('customClassName')).toBe(true) 64 | }) 65 | 66 | it('removes extra styles when a custom className is passed', () => { 67 | const content = getContent() 68 | 69 | expect(content.style.background).toBe('') 70 | }) 71 | 72 | it('has important styles when closed', () => { 73 | const content = getContent() 74 | 75 | expect(content.style.visibility).toBe('hidden') 76 | expect(content.style.transitionProperty).toBeUndefined 77 | }) 78 | 79 | it('has important styles when open', () => { 80 | const content = getContent() 81 | 82 | expect(content.style.visibility).toBe('visible') 83 | expect(content.style.transitionProperty).toBe('transform') 84 | }) 85 | 86 | it('visibility can not be overwritten', () => { 87 | const style = { content: { visibility: 'visible' } } 88 | const content = getContent() 89 | 90 | expect(content.style.visibility).toBe('hidden') 91 | }) 92 | 93 | it('transitionProperty can not be overwritten', () => { 94 | const style = { content: { transitionProperty: 'all' } } 95 | const content = getContent() 96 | 97 | expect(content.style.transitionProperty).toBeUndefined 98 | }) 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /tests/OffCanvas.events.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, fireEvent, cleanup } from '@testing-library/react' 3 | import OffCanvas from '../src/index' 4 | import App from './helpers/components' 5 | 6 | describe('OffCanvas', () => { 7 | afterEach(cleanup) 8 | 9 | describe('returnFocusAfterClose', () => { 10 | it('returns focus to the last focused element by default', () => { 11 | const { getByText, getByTestId } = render() 12 | const button = getByText('Open') 13 | const content = getByTestId('content') 14 | 15 | button.focus() 16 | fireEvent.click(button) 17 | button.blur() 18 | fireEvent.keyDown(content, { key: 'ESC', keyCode: 27 }) 19 | fireEvent.transitionEnd(content) 20 | 21 | expect(document.activeElement).toBe(button) 22 | }) 23 | 24 | it('does not return focus to the last focused element if set to false', () => { 25 | const { getByText, getByTestId } = render( 26 | , 27 | ) 28 | const button = getByText('Open') 29 | const content = getByTestId('content') 30 | 31 | button.focus() 32 | fireEvent.click(button) 33 | button.blur() 34 | fireEvent.keyDown(content, { key: 'ESC', keyCode: 27 }) 35 | 36 | expect(document.activeElement).not.toBe(button) 37 | }) 38 | }) 39 | 40 | describe('trapFocusAfterOpen', () => { 41 | it('traps focus inside the OffCanvas content by default', () => { 42 | const Buttons = () => ( 43 |
44 | 45 | 46 |
47 | ) 48 | 49 | const { getByText } = render( 50 | 51 | 52 | , 53 | ) 54 | 55 | const first = getByText('First') 56 | const second = getByText('Second') 57 | 58 | fireEvent.keyDown(second, { key: 'TAB', keyCode: 9 }) 59 | 60 | expect(document.activeElement).toBe(first) 61 | }) 62 | 63 | it('does not trap focus inside the OffCanvas content if set to false', () => { 64 | const Buttons = () => ( 65 |
66 | 67 | 68 |
69 | ) 70 | 71 | const { getByText } = render( 72 | 73 | 74 | , 75 | ) 76 | 77 | const first = getByText('First') 78 | const second = getByText('Second') 79 | 80 | fireEvent.keyDown(second, { key: 'TAB', keyCode: 9 }) 81 | 82 | expect(document.activeElement).not.toBe(first) 83 | }) 84 | }) 85 | 86 | describe('closeOnOverlayClick', () => { 87 | it('should close on overlay click by default', () => { 88 | const handleClose = jest.fn() 89 | const { getByTestId } = render( 90 | , 91 | ) 92 | const overlay = getByTestId('overlay') 93 | 94 | fireEvent.click(overlay) 95 | 96 | expect(handleClose).toHaveBeenCalled() 97 | }) 98 | 99 | it('should not close on overlay click if set to false', () => { 100 | const handleClose = jest.fn() 101 | const { getByTestId } = render( 102 | , 107 | ) 108 | const overlay = getByTestId('overlay') 109 | 110 | fireEvent.click(overlay) 111 | 112 | expect(handleClose).not.toHaveBeenCalled() 113 | }) 114 | }) 115 | 116 | describe('closeOnEsc', () => { 117 | it('should close on ESC key by default', () => { 118 | const handleClose = jest.fn() 119 | const { getByTestId } = render( 120 | , 121 | ) 122 | const content = getByTestId('content') 123 | 124 | fireEvent.keyDown(content, { key: 'ESC', keyCode: 27 }) 125 | 126 | expect(handleClose).toHaveBeenCalled() 127 | }) 128 | 129 | it('should not close on ESC key if set to false', () => { 130 | const handleClose = jest.fn() 131 | const { getByTestId } = render( 132 | , 133 | ) 134 | const content = getByTestId('content') 135 | 136 | fireEvent.keyDown(content, { key: 'ESC', keyCode: 27 }) 137 | 138 | expect(handleClose).not.toHaveBeenCalled() 139 | }) 140 | }) 141 | }) 142 | -------------------------------------------------------------------------------- /tests/OffCanvas.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import OffCanvas from '../src/index' 3 | import { render, fireEvent, cleanup } from '@testing-library/react' 4 | import { bindElementToQueries } from '@testing-library/dom' 5 | import { getContent, extractNumber } from './helpers/tests' 6 | import 'jest-dom/extend-expect' 7 | import App from './helpers/components' 8 | 9 | const bodyUtils = bindElementToQueries(document.body) 10 | 11 | function renderPortal() { 12 | const renderUtils = render() 13 | const portalNode = bodyUtils.getByTestId('offcanvas-portal') 14 | return { 15 | portalNode, 16 | ...renderUtils, 17 | ...bindElementToQueries(portalNode), 18 | } 19 | } 20 | 21 | describe('OffCanvas', () => { 22 | afterEach(cleanup) 23 | 24 | it('renders a text in a portal', () => { 25 | const { getByText } = renderPortal() 26 | expect(getByText('Testing')).toBeInTheDocument() 27 | }) 28 | 29 | it('removes the portal from the document on unmount', () => { 30 | const { unmount, portalNode } = renderPortal() 31 | expect(document.body.contains(portalNode)).toBe(true) 32 | unmount() 33 | expect(document.body.contains(portalNode)).toBe(false) 34 | }) 35 | 36 | it('focuses the OffCanvas content when open', () => { 37 | const content = getContent() 38 | 39 | expect(document.activeElement).toBe(content) 40 | }) 41 | 42 | it('focuses the first child when open if focusFirstChildAfterOpen is true', () => { 43 | const Buttons = () => ( 44 |
45 | 46 | 47 |
48 | ) 49 | 50 | const { getByText } = render( 51 | 52 | 53 | , 54 | ) 55 | 56 | const first = getByText('First') 57 | 58 | expect(document.activeElement).toBe(first) 59 | }) 60 | 61 | it('focuses a specific child when open if focusThisChildAfterOpen is defined', () => { 62 | const Buttons = () => ( 63 |
64 | 65 | 66 |
67 | ) 68 | 69 | const { getByText } = render( 70 | 71 | 72 | , 73 | ) 74 | 75 | const second = getByText('Second') 76 | 77 | expect(document.activeElement).toBe(second) 78 | }) 79 | 80 | it('accepts a custom width', () => { 81 | const content = getContent() 82 | 83 | expect(content.style.width).toBe('50%') 84 | }) 85 | 86 | describe('position', () => { 87 | it('opens from the left by default', () => { 88 | const content = getContent() 89 | const value = extractNumber(content.style.transform) 90 | 91 | expect(value).toBeLessThan(0) 92 | }) 93 | 94 | it('opens from the right if set to right', () => { 95 | const content = getContent() 96 | const value = extractNumber(content.style.transform) 97 | 98 | expect(value).toBeGreaterThan(0) 99 | }) 100 | 101 | it('opens from the top if set to top', () => { 102 | const content = getContent() 103 | const value = extractNumber(content.style.transform) 104 | 105 | expect(value).toBeLessThan(0) 106 | }) 107 | 108 | it('opens from the bottom if set to bottom', () => { 109 | const content = getContent() 110 | const value = extractNumber(content.style.transform) 111 | 112 | expect(value).toBeGreaterThan(0) 113 | }) 114 | }) 115 | 116 | it('pushes the main container if mainContainerSelector is defined', () => { 117 | const { getByText, getByTestId } = render( 118 | , 119 | ) 120 | const button = getByText('Open') 121 | const mainContainer = getByTestId('main') 122 | 123 | fireEvent.click(button) 124 | 125 | expect(mainContainer.style.transform).toBe('translateX(300px)') 126 | }) 127 | 128 | it('accepts a custom role', () => { 129 | const content = getContent() 130 | 131 | expect(content.getAttribute('role')).toBe('dialog') 132 | }) 133 | 134 | it('accepts a custom aria-label', () => { 135 | const content = getContent() 136 | 137 | expect(content.getAttribute('aria-label')).toBe('OffCanvas') 138 | }) 139 | 140 | it('accepts a custom aria-labelledby', () => { 141 | const content = getContent() 142 | 143 | expect(content.getAttribute('aria-labelledby')).toBe('button') 144 | }) 145 | 146 | it('sets aria-hidden to false when open', () => { 147 | const content = getContent() 148 | 149 | expect(content.getAttribute('aria-hidden')).toBe('false') 150 | }) 151 | 152 | it('sets aria-hidden to true when closed', () => { 153 | const content = getContent() 154 | 155 | expect(content.getAttribute('aria-hidden')).toBe('true') 156 | }) 157 | }) 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-aria-offcanvas 2 | 3 | ![npm](https://img.shields.io/npm/v/react-aria-offcanvas.svg?style=flat-square) ![Travis (.org) branch](https://img.shields.io/travis/neosiae/react-aria-offcanvas/master?style=flat-square) ![npm](https://img.shields.io/npm/dw/react-aria-offcanvas.svg?style=flat-square) ![npm bundle size](https://img.shields.io/bundlephobia/min/react-aria-offcanvas.svg?style=flat-square) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) 4 | 5 | Accessible Off-Canvas component for React.js 6 | 7 | ## Demo 8 | 9 | https://neosiae.github.io/react-aria-offcanvas/ 10 | 11 | ## Installation 12 | 13 | Install `react-aria-offcanvas` using npm: 14 | 15 | > npm install --save react-aria-offcanvas 16 | 17 | Or via yarn: 18 | 19 | > yarn add react-aria-offcanvas 20 | 21 | ## Usage 22 | 23 | ```javascript 24 | import React, { Component, Fragment } from 'react' 25 | import OffCanvas from 'react-aria-offcanvas' 26 | 27 | const Navigation = () => ( 28 | 47 | ) 48 | 49 | export default class App extends Component { 50 | state = { 51 | isOpen: false, 52 | } 53 | 54 | open = () => { 55 | this.setState({ isOpen: true }) 56 | } 57 | 58 | close = () => { 59 | this.setState({ isOpen: false }) 60 | } 61 | 62 | render() { 63 | return ( 64 | 65 | 74 | 79 | 80 | 81 | 82 | 83 | ) 84 | } 85 | } 86 | ``` 87 | 88 | ## Props 89 | 90 | The only required property for the component is `isOpen`, which controls whether the component is displayed or not. 91 | 92 | | Prop | Type | Default | Description | 93 | | -------------------------- | -------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | 94 | | `isOpen` | `bool` | `false` | Open or close OffCanvas. | 95 | | `width` | `string` | `300px` | The width of OffCanvas. | 96 | | `height` | `string` | `300px` | The height of OffCanvas. | 97 | | `position` | `string` | `left` | Position OffCanvas to the `left`, `right`, `top` or `bottom`. | 98 | | `mainContainerSelector` | `string` | | Allow `OffCanvas` to push your page. Pass a valid CSS selector of an element that should be pushed. | 99 | | `onClose` | `func` | | Callback fired when the overlay is clicked or esc key is pressed. | 100 | | `closeOnEsc` | `bool` | `true` | Close OffCanvas on esc key. | 101 | | `closeOnOverlayClick` | `bool` | `true` | Close OffCanvas on overlay click. | 102 | | `lockBodyAfterOpen` | `bool` | `true` | Lock body overflow on menu open | 103 | | `trapFocusAfterOpen` | `bool` | `true` | Trap focus when OffCanvas is open. | 104 | | `returnFocusAfterClose` | `bool` | `true` | Return focus to the element that had focus before opening OffCanvas. | 105 | | `focusFirstChildAfterOpen` | `bool` | | Set initial focus on the first focusable child inside OffCanvas. | 106 | | `focusThisChildAfterOpen` | `string` | | Set initial focus on a specific child inside OffCanvas. Pass a valid CSS selector of an element that should receive initial focus. | 107 | | `style` | `object` | `{ overlay: {}, content: {} }` | Inline styles object. It has two keys: `overlay` - overlay styles and `content` - OffCanvas styles. | 108 | | `className` | `string` | | Custom className for OffCanvas. | 109 | | `overlayClassName` | `string` | | Custom className for the overlay. | 110 | | `role` | `string` | | Custom role for OffCanvas. | 111 | | `label` | `string` | | Custom aria-label for OffCanvas. | 112 | | `labelledby` | `string` | | Custom aria-labelledby for OffCanvas. | 113 | 114 | ## License 115 | 116 | MIT 117 | -------------------------------------------------------------------------------- /src/OffCanvas.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ReactDOM from 'react-dom' 3 | import PropTypes from 'prop-types' 4 | import runEventHandlerOnce from 'run-event-handler-once' 5 | import focusTrap from './helpers/focusTrap' 6 | import { canUseDOM, canUseRoot } from './helpers/static' 7 | import { 8 | focusLater, 9 | returnFocus, 10 | focusFirstChild, 11 | focusChild, 12 | } from './helpers/focusManager' 13 | import { 14 | hasClassName, 15 | createStyles, 16 | createPushStyles, 17 | shouldShowContent, 18 | applyInitialPushStyles, 19 | shouldHideHorizontalScrollbar, 20 | shouldLockBodyScroll, 21 | } from './helpers/styles' 22 | 23 | const TAB_KEY = 9 24 | const ESC_KEY = 27 25 | const EVENT_LISTENER_OPTIONS = { 26 | add: { capture: false }, 27 | remove: { capture: false }, 28 | } 29 | 30 | export default class OffCanvas extends Component { 31 | constructor(props) { 32 | super(props) 33 | this.createOffCanvasRoot() 34 | } 35 | 36 | static defaultProps = { 37 | isOpen: false, 38 | width: '300px', 39 | height: '300px', 40 | position: 'left', 41 | closeOnEsc: true, 42 | closeOnOverlayClick: true, 43 | trapFocusAfterOpen: true, 44 | lockBodyAfterOpen: true, 45 | returnFocusAfterClose: true, 46 | style: { 47 | overlay: {}, 48 | content: {}, 49 | }, 50 | } 51 | 52 | static propTypes = { 53 | isOpen: PropTypes.bool.isRequired, 54 | width: PropTypes.string, 55 | height: PropTypes.string, 56 | position: PropTypes.oneOf(['left', 'right', 'top', 'bottom']), 57 | mainContainerSelector: PropTypes.string, 58 | onClose: PropTypes.func, 59 | closeOnEsc: PropTypes.bool, 60 | closeOnOverlayClick: PropTypes.bool, 61 | trapFocusAfterOpen: PropTypes.bool, 62 | returnFocusAfterClose: PropTypes.bool, 63 | lockBodyAfterOpen: PropTypes.bool, 64 | focusFirstChildAfterOpen: PropTypes.bool, 65 | focusThisChildAfterOpen: PropTypes.string, 66 | style: PropTypes.shape({ 67 | container: PropTypes.object, 68 | overlay: PropTypes.object, 69 | content: PropTypes.object, 70 | }), 71 | className: PropTypes.string, 72 | overlayClassName: PropTypes.string, 73 | role: PropTypes.string, 74 | label: PropTypes.string, 75 | labelledby: PropTypes.string, 76 | children: PropTypes.node, 77 | } 78 | 79 | static defaultStyles = { 80 | overlay: { 81 | position: 'fixed', 82 | top: 0, 83 | left: 0, 84 | width: '100%', 85 | height: '100%', 86 | zIndex: '900', 87 | }, 88 | content: { 89 | position: 'fixed', 90 | zIndex: '1000', 91 | overflowY: 'auto', 92 | outline: 0, 93 | }, 94 | } 95 | 96 | static extraStyles = { 97 | container: { 98 | transition: 'transform 0.25s ease-out', 99 | }, 100 | overlay: { 101 | background: 'rgba(255, 255, 255, 0.5)', 102 | }, 103 | content: { 104 | background: 'rgba(0, 0, 0, 0.1)', 105 | transition: 'transform 0.25s ease-out', 106 | }, 107 | } 108 | 109 | componentDidMount() { 110 | const { 111 | isOpen, 112 | width, 113 | height, 114 | position, 115 | mainContainerSelector, 116 | lockBodyAfterOpen, 117 | } = this.props 118 | 119 | shouldShowContent(this.content, isOpen) 120 | 121 | if (mainContainerSelector) { 122 | // Get the element that should be pushed 123 | this.mainContainer = document.querySelector(mainContainerSelector) 124 | 125 | // Remove the extra styles when the main container has a className 126 | if (hasClassName(this.mainContainer)) { 127 | OffCanvas.extraStyles.container = {} 128 | } 129 | } 130 | 131 | if (isOpen) { 132 | this.setInitialFocus() 133 | if (mainContainerSelector) { 134 | // If the initial state is set to true, this is the right time to apply 135 | // some of the push styles to the main container. 136 | applyInitialPushStyles(this.mainContainer, width, height, position) 137 | shouldHideHorizontalScrollbar(true) 138 | lockBodyAfterOpen && shouldLockBodyScroll(true) 139 | } 140 | } 141 | } 142 | 143 | componentDidUpdate(prevProps, prevState) { 144 | if (this.props.isOpen && !prevProps.isOpen) { 145 | this.open() 146 | } else if (!this.props.isOpen && prevProps.isOpen) { 147 | this.close() 148 | } 149 | } 150 | 151 | open = () => { 152 | const { returnFocusAfterClose, lockBodyAfterOpen } = this.props 153 | 154 | shouldShowContent(this.content, true) 155 | 156 | if (returnFocusAfterClose) { 157 | focusLater() 158 | } 159 | 160 | runEventHandlerOnce( 161 | this.content, 162 | 'transitionend', 163 | () => { 164 | this.setInitialFocus() 165 | }, 166 | EVENT_LISTENER_OPTIONS, 167 | ) 168 | 169 | shouldHideHorizontalScrollbar(true) 170 | // Lock Body scroll on component update 171 | lockBodyAfterOpen && shouldLockBodyScroll(true) 172 | } 173 | 174 | close = () => { 175 | const { mainContainerSelector, returnFocusAfterClose } = this.props 176 | 177 | if (returnFocusAfterClose) { 178 | if (mainContainerSelector) { 179 | runEventHandlerOnce( 180 | this.mainContainer, 181 | 'transitionend', 182 | () => { 183 | // If the Open button is off the screen, returning focus 184 | // immediately breaks the transition. Transitionend event ensures 185 | // that the animation has enough time to finish. 186 | // then use the lock body scroll method to lock body scroll 187 | returnFocus() 188 | shouldShowContent(this.content, false) 189 | shouldHideHorizontalScrollbar(false) 190 | shouldLockBodyScroll(false) 191 | }, 192 | EVENT_LISTENER_OPTIONS, 193 | ) 194 | } else { 195 | runEventHandlerOnce( 196 | this.content, 197 | 'transitionend', 198 | () => { 199 | returnFocus() 200 | shouldShowContent(this.content, false) 201 | shouldHideHorizontalScrollbar(false) 202 | shouldLockBodyScroll(false) 203 | }, 204 | EVENT_LISTENER_OPTIONS, 205 | ) 206 | } 207 | } 208 | } 209 | 210 | setInitialFocus = () => { 211 | const { focusFirstChildAfterOpen, focusThisChildAfterOpen } = this.props 212 | 213 | if (focusFirstChildAfterOpen) { 214 | focusFirstChild(this.content) 215 | } else if (focusThisChildAfterOpen) { 216 | focusChild(this.content, focusThisChildAfterOpen) 217 | } else { 218 | this.focusContent() 219 | } 220 | } 221 | 222 | parentHandlesClose = event => { 223 | if (this.props.onClose) { 224 | this.props.onClose(event) 225 | } 226 | } 227 | 228 | handleOverlayClick = event => { 229 | if (this.props.closeOnOverlayClick && event.target === this.overlay) { 230 | this.parentHandlesClose(event) 231 | } 232 | } 233 | 234 | handleKeyDown = event => { 235 | if (this.props.trapFocusAfterOpen && event.keyCode === TAB_KEY) { 236 | focusTrap(event, this.content) 237 | } 238 | 239 | if (this.props.closeOnEsc && event.keyCode === ESC_KEY) { 240 | event.stopPropagation() 241 | this.parentHandlesClose(event) 242 | } 243 | } 244 | 245 | setOverlayRef = overlay => { 246 | this.overlay = overlay 247 | } 248 | 249 | setContentRef = content => { 250 | this.content = content 251 | } 252 | 253 | focusContent = () => this.content && this.content.focus() 254 | 255 | buildStyles = () => { 256 | const { 257 | isOpen, 258 | width, 259 | height, 260 | position, 261 | mainContainerSelector, 262 | style, 263 | className, 264 | overlayClassName, 265 | } = this.props 266 | 267 | const extra = { 268 | container: OffCanvas.extraStyles.container, 269 | // Remove the extra styles when classNames are passed 270 | overlay: overlayClassName ? {} : OffCanvas.extraStyles.overlay, 271 | content: className ? {} : OffCanvas.extraStyles.content, 272 | } 273 | 274 | const main = createStyles( 275 | OffCanvas.defaultStyles, 276 | extra, 277 | isOpen, 278 | width, 279 | height, 280 | position, 281 | style, 282 | ) 283 | 284 | const applyPushStyles = mainContainerSelector 285 | ? createPushStyles( 286 | OffCanvas.extraStyles.container, 287 | isOpen, 288 | width, 289 | height, 290 | position, 291 | ) 292 | : null 293 | 294 | return { main, applyPushStyles } 295 | } 296 | 297 | createOffCanvasRoot = () => { 298 | if (canUseDOM) { 299 | this.offCanvasRoot = document.createElement('div') 300 | this.offCanvasRoot.setAttribute('id', 'offcanvas-root') 301 | this.offCanvasRoot.dataset.testid = 'offcanvas-portal' 302 | document.body.appendChild(this.offCanvasRoot) 303 | } 304 | } 305 | 306 | removeOffCanvasRoot = () => 307 | this.offCanvasRoot && document.body.removeChild(this.offCanvasRoot) 308 | 309 | componentWillUnmount() { 310 | this.removeOffCanvasRoot() 311 | shouldHideHorizontalScrollbar(false) 312 | shouldLockBodyScroll(false) 313 | } 314 | 315 | render() { 316 | const { 317 | isOpen, 318 | role, 319 | label, 320 | labelledby, 321 | className, 322 | overlayClassName, 323 | } = this.props 324 | 325 | const styles = this.buildStyles() 326 | 327 | if (styles.applyPushStyles) { 328 | styles.applyPushStyles(this.mainContainer) 329 | } 330 | 331 | return ( 332 | canUseRoot(this.offCanvasRoot) && 333 | ReactDOM.createPortal( 334 |
341 |
353 | {this.props.children} 354 |
355 |
, 356 | this.offCanvasRoot, 357 | ) 358 | ) 359 | } 360 | } 361 | --------------------------------------------------------------------------------