├── .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 |
41 | `;
42 |
43 | exports[`OffCanvas renders correctly when open 1`] = `
44 |
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 |     [](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 |
--------------------------------------------------------------------------------