1 | import React, {Component} from 'react';
|
2 | import PropTypes from 'prop-types';
|
3 |
|
4 | import styles from './tab-trap.css';
|
5 |
|
6 | export const FOCUSABLE_ELEMENTS = 'input, button:not([data-trap-button]), select, textarea, a[href], *[tabindex]';
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 | export default class TabTrap extends Component {
|
13 | static propTypes = {
|
14 | children: PropTypes.node.isRequired,
|
15 | trapDisabled: PropTypes.bool,
|
16 | autoFocusFirst: PropTypes.bool,
|
17 | focusBackOnClose: PropTypes.bool
|
18 | };
|
19 |
|
20 | static defaultProps = {
|
21 | trapDisabled: false,
|
22 | autoFocusFirst: true,
|
23 | focusBackOnClose: true
|
24 | };
|
25 |
|
26 | componentDidMount() {
|
27 | this.previousFocusedNode = document.activeElement;
|
28 |
|
29 | if (this.props.autoFocusFirst) {
|
30 | this.focusFirst();
|
31 | } else {
|
32 | this.trapWithoutFocus = true;
|
33 | this.trapButtonNode.focus();
|
34 | }
|
35 | }
|
36 |
|
37 | componentWillUnmount() {
|
38 | this.restoreFocus();
|
39 | }
|
40 |
|
41 | restoreFocus() {
|
42 | if (!this.props.focusBackOnClose) {
|
43 | return;
|
44 | }
|
45 | const {previousFocusedNode} = this;
|
46 | if (previousFocusedNode && previousFocusedNode.focus) {
|
47 | previousFocusedNode.focus();
|
48 | }
|
49 | }
|
50 |
|
51 | containerRef = node => {
|
52 | if (!node) {
|
53 | return;
|
54 | }
|
55 | this.node = node;
|
56 | };
|
57 |
|
58 | focusElement = (first = true) => {
|
59 | const {node} = this;
|
60 | if (!node) {
|
61 | return;
|
62 | }
|
63 |
|
64 | const tabables = [...node.querySelectorAll(FOCUSABLE_ELEMENTS)].
|
65 | filter(item => item.tabIndex >= 0);
|
66 |
|
67 | const toBeFocused = first ? tabables[0] : tabables[tabables.length - 1];
|
68 |
|
69 | if (toBeFocused) {
|
70 | toBeFocused.focus();
|
71 | }
|
72 | };
|
73 |
|
74 | focusFirst = () => this.focusElement(true);
|
75 |
|
76 | focusLast = () => this.focusElement(false);
|
77 |
|
78 | focusLastIfEnabled = () => {
|
79 | if (this.trapWithoutFocus) {
|
80 | return;
|
81 | }
|
82 | this.focusLast();
|
83 | };
|
84 |
|
85 | handleBlurIfWithoutFocus = event => {
|
86 | if (!this.trapWithoutFocus) {
|
87 | return;
|
88 | }
|
89 | this.trapWithoutFocus = false;
|
90 |
|
91 | const newFocused = event.nativeEvent.relatedTarget;
|
92 | if (!newFocused) {
|
93 | return;
|
94 | }
|
95 |
|
96 | if (this.node.contains(newFocused)) {
|
97 | return;
|
98 | }
|
99 |
|
100 | this.focusLast();
|
101 | };
|
102 |
|
103 | trapButtonRef = node => {
|
104 | if (!node) {
|
105 | return;
|
106 | }
|
107 |
|
108 | this.trapButtonNode = node;
|
109 | };
|
110 |
|
111 | render() {
|
112 |
|
113 | const {children, trapDisabled, autoFocusFirst, focusBackOnClose, ...restProps} = this.props;
|
114 |
|
115 | if (trapDisabled) {
|
116 | return (
|
117 | <div
|
118 | ref={this.containerRef}
|
119 | {...restProps}
|
120 | >
|
121 | {children}
|
122 | </div>
|
123 | );
|
124 | }
|
125 |
|
126 | return (
|
127 | <div
|
128 | ref={this.containerRef}
|
129 | {...restProps}
|
130 | >
|
131 | <button
|
132 | type="button"
|
133 | ref={this.trapButtonRef}
|
134 | className={styles.trapButton}
|
135 | onFocus={this.focusLastIfEnabled}
|
136 | onBlur={this.handleBlurIfWithoutFocus}
|
137 | data-trap-button
|
138 | />
|
139 | {children}
|
140 | <button
|
141 | type="button"
|
142 | className={styles.trapButton}
|
143 | onFocus={this.focusFirst}
|
144 | data-trap-button
|
145 | />
|
146 | </div>
|
147 | );
|
148 | }
|
149 | }
|