Skip to content

Commit de1ad75

Browse files
Improve Modal component to auto focus first available form field element or submit button if there no form fields present and allow Modal to be submitted by Enter key (#100)
1 parent 0235204 commit de1ad75

File tree

10 files changed

+249
-14
lines changed

10 files changed

+249
-14
lines changed

src/lib/components/ui/Button/Button.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,10 @@ Button.propTypes = {
135135
clickHandler: PropTypes.func,
136136
disabled: PropTypes.bool,
137137
endCorner: PropTypes.node,
138-
forwardedRef: PropTypes.func,
138+
forwardedRef: PropTypes.oneOfType([
139+
PropTypes.func,
140+
PropTypes.shape({ current: PropTypes.any }),
141+
]),
139142
grouped: PropTypes.bool,
140143
id: PropTypes.string,
141144
label: PropTypes.string.isRequired,

src/lib/components/ui/CheckboxField/CheckboxField.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,10 @@ CheckboxField.propTypes = {
126126
description: PropTypes.string,
127127
disabled: PropTypes.bool,
128128
error: PropTypes.string,
129-
forwardedRef: PropTypes.func,
129+
forwardedRef: PropTypes.oneOfType([
130+
PropTypes.func,
131+
PropTypes.shape({ current: PropTypes.any }),
132+
]),
130133
id: PropTypes.string.isRequired,
131134
inFormLayout: PropTypes.bool,
132135
isLabelVisible: PropTypes.bool,

src/lib/components/ui/Modal/Modal.jsx

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,41 @@ class Modal extends React.Component {
1818
isContentOverflowing: false,
1919
};
2020

21+
this.childrenWrapperRef = React.createRef();
22+
this.submitButtonRef = React.createRef();
23+
2124
this.setGradient = this.setGradient.bind(this);
22-
this.pressEscapeHandler = this.pressEscapeHandler.bind(this);
25+
this.keyPressHandler = this.keyPressHandler.bind(this);
2326
}
2427

2528
componentDidMount() {
26-
window.document.addEventListener('keydown', this.pressEscapeHandler, false);
29+
const { autoFocus } = this.props;
30+
31+
window.document.addEventListener('keydown', this.keyPressHandler, false);
32+
33+
// If `autoFocus` is set to `true`, following code finds first form field element
34+
// (input, textarea or select) or submit button and auto focuses it. This is necessary
35+
// to have focus on one of those elements to be able to submit form by pressing Enter key.
36+
if (autoFocus && this.childrenWrapperRef && this.childrenWrapperRef.current) {
37+
const childrenWrapperElement = this.childrenWrapperRef.current;
38+
const childrenElements = childrenWrapperElement.querySelectorAll('*');
39+
const formFieldEl = Array.from(childrenElements).find(
40+
(element) => ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName),
41+
);
42+
43+
if (formFieldEl) {
44+
formFieldEl.focus();
45+
return;
46+
}
47+
48+
if (this.submitButtonRef && this.submitButtonRef.current) {
49+
this.submitButtonRef.current.focus();
50+
}
51+
}
2752
}
2853

2954
componentWillUnmount() {
30-
window.document.removeEventListener('keydown', this.pressEscapeHandler, false);
55+
window.document.removeEventListener('keydown', this.keyPressHandler, false);
3156
}
3257

3358
setGradient() {
@@ -36,10 +61,20 @@ class Modal extends React.Component {
3661
}
3762
}
3863

39-
pressEscapeHandler(e) {
64+
keyPressHandler(e) {
65+
const { actions } = this.props;
66+
4067
if (e.keyCode === 27 && this.props.closeHandler) {
4168
this.props.closeHandler();
4269
}
70+
71+
if (e.keyCode === 13 && e.target.nodeName !== 'BUTTON') {
72+
const submitAction = actions.find((action) => action.type === 'submit');
73+
74+
if (submitAction && !submitAction.disabled) {
75+
submitAction.clickHandler(e);
76+
}
77+
}
4378
}
4479

4580
preRender() {
@@ -103,6 +138,7 @@ class Modal extends React.Component {
103138
</div>
104139
<div
105140
className={styles.body}
141+
ref={this.childrenWrapperRef}
106142
{...(this.props.id && { id: `${this.props.id}__content` })}
107143
>
108144
{this.props.children}
@@ -118,6 +154,8 @@ class Modal extends React.Component {
118154
id={action.id || undefined}
119155
label={action.label}
120156
loadingIcon={action.loadingIcon}
157+
ref={this.submitButtonRef}
158+
type="button"
121159
variant={action.variant}
122160
/>
123161
</ToolbarItem>
@@ -152,6 +190,7 @@ class Modal extends React.Component {
152190

153191
Modal.defaultProps = {
154192
actions: [],
193+
autoFocus: true,
155194
closeHandler: null,
156195
id: undefined,
157196
portalId: null,
@@ -167,6 +206,7 @@ Modal.propTypes = {
167206
loadingIcon: PropTypes.node,
168207
variant: PropTypes.string,
169208
})),
209+
autoFocus: PropTypes.bool,
170210
children: PropTypes.node.isRequired,
171211
closeHandler: PropTypes.func,
172212
id: PropTypes.string,

src/lib/components/ui/Modal/__tests__/Modal.test.jsx

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ describe('rendering', () => {
9595
});
9696

9797
describe('functionality', () => {
98-
it('calls closeHandler() on Close button click', () => {
98+
it('call closeHandler() on Close button click', () => {
9999
const spy = sinon.spy();
100100
const component = mount((
101101
<Modal
@@ -110,4 +110,131 @@ describe('functionality', () => {
110110
component.find('Button').at(0).simulate('click');
111111
expect(spy.calledTwice).toEqual(true);
112112
});
113+
114+
it('call closeHandler() when Escape is pressed', () => {
115+
const spy = sinon.spy();
116+
mount((
117+
<Modal
118+
closeHandler={spy}
119+
title="Modal title"
120+
>
121+
Modal content
122+
</Modal>
123+
));
124+
125+
const event = new KeyboardEvent('keydown', { keyCode: 27 });
126+
document.dispatchEvent(event);
127+
128+
expect(spy.calledOnce).toEqual(true);
129+
});
130+
131+
it('call submit action when Enter is pressed', () => {
132+
const submitSpy = sinon.spy();
133+
134+
const component = mount((
135+
<Modal
136+
actions={[
137+
{
138+
clickHandler: submitSpy,
139+
id: 'submitButton',
140+
label: 'submit',
141+
type: 'submit',
142+
},
143+
]}
144+
closeHandler={() => {}}
145+
title="Modal title"
146+
>
147+
Modal content
148+
</Modal>
149+
));
150+
151+
component.find('button').at(1).simulate('click');
152+
153+
expect(submitSpy.calledOnce).toEqual(true);
154+
});
155+
156+
it('call submit action when Enter is pressed', () => {
157+
const submitSpy = sinon.spy();
158+
const otherSpy = sinon.spy();
159+
160+
mount((
161+
<Modal
162+
actions={[
163+
{
164+
clickHandler: otherSpy,
165+
id: 'otherButton',
166+
label: 'other',
167+
},
168+
{
169+
clickHandler: submitSpy,
170+
id: 'submitButton',
171+
label: 'submit',
172+
type: 'submit',
173+
},
174+
]}
175+
closeHandler={() => {}}
176+
title="Modal title"
177+
>
178+
Modal content
179+
</Modal>
180+
));
181+
182+
const event = new KeyboardEvent('keydown', { keyCode: 13 });
183+
document.dispatchEvent(event);
184+
185+
expect(submitSpy.calledOnce).toEqual(true);
186+
expect(otherSpy.notCalled).toEqual(true);
187+
});
188+
189+
it('do not call submit action when submit action is disabled and Enter is clicked', () => {
190+
const submitSpy = sinon.spy();
191+
192+
mount((
193+
<Modal
194+
actions={[
195+
{
196+
clickHandler: submitSpy,
197+
disabled: true,
198+
id: 'submitButton',
199+
label: 'submit',
200+
type: 'submit',
201+
},
202+
]}
203+
closeHandler={() => {}}
204+
title="Modal title"
205+
>
206+
Modal content
207+
</Modal>
208+
));
209+
210+
const event = new KeyboardEvent('keydown', { keyCode: 13 });
211+
document.dispatchEvent(event);
212+
213+
expect(submitSpy.notCalled).toEqual(true);
214+
});
215+
216+
it('do not call submit action when submit action is not present and Enter is clicked', () => {
217+
const otherSpy = sinon.spy();
218+
219+
mount((
220+
<Modal
221+
actions={[
222+
{
223+
clickHandler: otherSpy,
224+
id: 'otherButton',
225+
label: 'other',
226+
},
227+
]}
228+
closeHandler={() => {}}
229+
title="Modal title"
230+
>
231+
Modal content
232+
</Modal>
233+
));
234+
235+
const event = new KeyboardEvent('keydown', { keyCode: 13 });
236+
document.dispatchEvent(event);
237+
238+
expect(otherSpy.notCalled).toEqual(true);
239+
});
113240
});

src/lib/components/ui/Modal/__tests__/__snapshots__/Modal.test.jsx.snap

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ exports[`rendering renders correctly with all props except translations 1`] = `
2525
},
2626
]
2727
}
28+
autoFocus={true}
2829
closeHandler={[Function]}
2930
id="custom-id"
3031
portalId={null}
@@ -95,6 +96,7 @@ exports[`rendering renders correctly with all props except translations 1`] = `
9596
<ForwardRef(withForwardedRef(Button))
9697
clickHandler={[Function]}
9798
label="Action"
99+
type="button"
98100
>
99101
<Button
100102
afterLabel={null}
@@ -103,7 +105,23 @@ exports[`rendering renders correctly with all props except translations 1`] = `
103105
clickHandler={[Function]}
104106
disabled={false}
105107
endCorner={null}
106-
forwardedRef={null}
108+
forwardedRef={
109+
Object {
110+
"current": <button
111+
class="root
112+
priorityDefault
113+
sizeMedium
114+
variantPrimary"
115+
type="button"
116+
>
117+
<span
118+
class="label"
119+
>
120+
Action
121+
</span>
122+
</button>,
123+
}
124+
}
107125
grouped={false}
108126
label="Action"
109127
labelVisibility="all"
@@ -225,6 +243,7 @@ exports[`rendering renders correctly with all props except translations and with
225243
},
226244
]
227245
}
246+
autoFocus={true}
228247
closeHandler={[Function]}
229248
id="custom-id"
230249
portalId={null}
@@ -300,6 +319,7 @@ exports[`rendering renders correctly with all props except translations and with
300319
className="icon"
301320
/>
302321
}
322+
type="button"
303323
>
304324
<Button
305325
afterLabel={null}
@@ -308,7 +328,31 @@ exports[`rendering renders correctly with all props except translations and with
308328
clickHandler={[Function]}
309329
disabled={false}
310330
endCorner={null}
311-
forwardedRef={null}
331+
forwardedRef={
332+
Object {
333+
"current": <button
334+
class="root
335+
priorityDefault
336+
sizeMedium
337+
variantPrimary"
338+
disabled=""
339+
type="button"
340+
>
341+
<span
342+
class="label"
343+
>
344+
Action
345+
</span>
346+
<span
347+
class="loadingIcon"
348+
>
349+
<span
350+
class="icon"
351+
/>
352+
</span>
353+
</button>,
354+
}
355+
}
312356
grouped={false}
313357
label="Action"
314358
labelVisibility="all"
@@ -417,6 +461,7 @@ exports[`rendering renders correctly with mandatory props only 1`] = `
417461
>
418462
<Modal
419463
actions={Array []}
464+
autoFocus={true}
420465
closeHandler={null}
421466
portalId={null}
422467
size="medium"
@@ -481,6 +526,7 @@ exports[`rendering renders correctly with portal id 1`] = `
481526
>
482527
<Modal
483528
actions={Array []}
529+
autoFocus={true}
484530
closeHandler={null}
485531
portalId="app-modal-portal"
486532
size="medium"
@@ -590,6 +636,7 @@ exports[`rendering renders correctly with translations 1`] = `
590636
>
591637
<Modal
592638
actions={Array []}
639+
autoFocus={true}
593640
closeHandler={[Function]}
594641
portalId={null}
595642
size="medium"

src/lib/components/ui/MultipleSelectField/MultipleSelectField.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,10 @@ MultipleSelectField.defaultProps = {
148148
MultipleSelectField.propTypes = {
149149
changeHandler: PropTypes.func,
150150
disabled: PropTypes.bool,
151-
forwardedRef: PropTypes.func,
151+
forwardedRef: PropTypes.oneOfType([
152+
PropTypes.func,
153+
PropTypes.shape({ current: PropTypes.any }),
154+
]),
152155
fullWidth: PropTypes.bool,
153156
helperText: PropTypes.string,
154157
id: PropTypes.string.isRequired,

0 commit comments

Comments
 (0)