Skip to content

Commit 42ff318

Browse files
committed
Add offcanvas
1 parent f38a9e1 commit 42ff318

File tree

10 files changed

+287
-76
lines changed

10 files changed

+287
-76
lines changed

src/components/modal/Modal.js

Lines changed: 58 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import ModalHeader from './ModalHeader';
1010
import ModalTitle from './ModalTitle';
1111
import { MODAL_SIZES } from '../../utils/constants';
1212
import BaseView from '../../utils/rnw-compat/BaseView';
13-
import useModal from './useModal';
13+
import useModal from '../../hooks/useModal';
1414

1515
const propTypes = {
1616
children: PropTypes.node.isRequired,
@@ -33,7 +33,11 @@ const Modal = React.forwardRef((props, ref) => {
3333
...elementProps
3434
} = props;
3535

36-
const modal = useModal(visible, onToggle);
36+
const modal = useModal(visible, onToggle, {
37+
keepBodyScroll: false,
38+
bodyClass: 'modal-open',
39+
centered: true,
40+
});
3741
const waitingForMouseUp = useRef(false);
3842
const ignoreBackdropClick = useRef(false);
3943

@@ -54,69 +58,67 @@ const Modal = React.forwardRef((props, ref) => {
5458
);
5559

5660
const modalElement = (
57-
<BaseView
58-
key="modal"
59-
ref={(element) => {
60-
modal.ref.current = findNodeHandle(element);
61-
}}
62-
accessible
63-
accessibilityRole="dialog"
64-
aria-labelledby={modal.identifier}
65-
aria-modal="true"
66-
// For now we need onClick here, because onMouseDown would also toggle the modal when the user clicks on a scrollbar.
67-
onClick={(event) => {
68-
if (backdrop === 'static') {
69-
return;
70-
}
71-
72-
if (
73-
ignoreBackdropClick.current ||
74-
event.target !== event.currentTarget
75-
) {
76-
ignoreBackdropClick.current = false;
77-
return;
78-
}
79-
80-
modal.setVisible(false);
81-
}}
82-
onMouseUp={(event) => {
83-
if (waitingForMouseUp.current && event.target === modal.ref.current) {
84-
ignoreBackdropClick.current = true;
85-
}
86-
87-
waitingForMouseUp.current = false;
88-
}}
89-
onKeyUp={(event) => {
90-
if (event.key !== 'Escape') {
91-
return;
92-
}
93-
94-
event.preventDefault();
95-
96-
if (backdrop === 'static') {
97-
return;
98-
}
99-
100-
modal.setVisible(false);
101-
}}
102-
essentials={{ className: 'modal show' }}
103-
>
61+
<ModalContext.Provider value={modal} key="modal">
10462
<BaseView
105-
accessibilityRole="document"
106-
onMouseDown={() => {
107-
waitingForMouseUp.current = true;
63+
ref={(element) => {
64+
modal.ref.current = findNodeHandle(element);
65+
}}
66+
accessible
67+
accessibilityRole="dialog"
68+
aria-labelledby={modal.identifier}
69+
aria-modal="true"
70+
// For now we need onClick here, because onMouseDown would also toggle the modal when the user clicks on a scrollbar.
71+
onClick={(event) => {
72+
if (backdrop === 'static') {
73+
return;
74+
}
75+
76+
if (
77+
ignoreBackdropClick.current ||
78+
event.target !== event.currentTarget
79+
) {
80+
ignoreBackdropClick.current = false;
81+
return;
82+
}
83+
84+
modal.setVisible(false);
85+
}}
86+
onMouseUp={(event) => {
87+
if (waitingForMouseUp.current && event.target === modal.ref.current) {
88+
ignoreBackdropClick.current = true;
89+
}
90+
91+
waitingForMouseUp.current = false;
92+
}}
93+
onKeyUp={(event) => {
94+
if (event.key !== 'Escape') {
95+
return;
96+
}
97+
98+
event.preventDefault();
99+
100+
if (backdrop === 'static') {
101+
return;
102+
}
103+
104+
modal.setVisible(false);
108105
}}
109-
essentials={{ className: dialogClasses }}
106+
essentials={{ className: 'modal show' }}
110107
>
111-
<ModalContext.Provider value={modal}>
108+
<BaseView
109+
onMouseDown={() => {
110+
waitingForMouseUp.current = true;
111+
}}
112+
essentials={{ className: dialogClasses }}
113+
>
112114
<BaseView
113115
{...elementProps}
114116
ref={ref}
115117
essentials={{ className: 'modal-content' }}
116118
/>
117-
</ModalContext.Provider>
119+
</BaseView>
118120
</BaseView>
119-
</BaseView>
121+
</ModalContext.Provider>
120122
);
121123

122124
if (!backdrop) {
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import ReactDOM from 'react-dom';
4+
import cx from 'classnames';
5+
import findNodeHandle from 'react-native-web/dist/cjs/exports/findNodeHandle';
6+
import OffcanvasContext from './OffcanvasContext';
7+
import OffcanvasBody from './OffcanvasBody';
8+
import OffcanvasHeader from './OffcanvasHeader';
9+
import OffcanvasTitle from './OffcanvasTitle';
10+
import { PLACEMENTS } from '../../utils/constants';
11+
import BaseView from '../../utils/rnw-compat/BaseView';
12+
import useModal from '../../hooks/useModal';
13+
import concatRefs from '../../utils/concatRefs';
14+
15+
const propTypes = {
16+
children: PropTypes.node.isRequired,
17+
placement: PropTypes.oneOf(PLACEMENTS),
18+
visible: PropTypes.bool.isRequired,
19+
backdrop: PropTypes.bool,
20+
scroll: PropTypes.bool,
21+
onToggle: PropTypes.func.isRequired,
22+
};
23+
24+
const Offcanvas = React.forwardRef((props, ref) => {
25+
const {
26+
placement = 'top',
27+
visible,
28+
backdrop = true,
29+
scroll = false,
30+
onToggle,
31+
...elementProps
32+
} = props;
33+
34+
const modal = useModal(visible, onToggle, { keepBodyScroll: scroll });
35+
36+
// Return null if not mounted.
37+
if (!modal.mounted || !modal.visible) {
38+
return null;
39+
}
40+
41+
const classes = cx(
42+
// constant classes
43+
'offcanvas',
44+
`offcanvas-${placement}`,
45+
'show',
46+
);
47+
48+
const offcanvasElement = (
49+
<OffcanvasContext.Provider value={modal} key="offcanvas">
50+
<BaseView
51+
{...elementProps}
52+
ref={concatRefs((element) => {
53+
modal.ref.current = findNodeHandle(element);
54+
}, ref)}
55+
accessible
56+
accessibilityRole="dialog"
57+
aria-labelledby={modal.identifier}
58+
aria-modal="true"
59+
onKeyUp={(event) => {
60+
if (event.key !== 'Escape') {
61+
return;
62+
}
63+
64+
event.preventDefault();
65+
66+
modal.setVisible(false);
67+
}}
68+
essentials={{ className: classes }}
69+
/>
70+
</OffcanvasContext.Provider>
71+
);
72+
73+
if (!backdrop) {
74+
return ReactDOM.createPortal(offcanvasElement, document.body);
75+
}
76+
77+
const backdropElement = (
78+
<BaseView
79+
key="offcanvas-backdrop"
80+
essentials={{ className: 'offcanvas-backdrop show' }}
81+
// For now we need onClick here, because onMouseDown would also toggle the offcanvas when the user clicks on a scrollbar.
82+
onClick={() => {
83+
modal.setVisible(false);
84+
}}
85+
/>
86+
);
87+
88+
return ReactDOM.createPortal(
89+
[offcanvasElement, backdropElement],
90+
document.body,
91+
);
92+
});
93+
94+
Offcanvas.displayName = 'Offcanvas';
95+
Offcanvas.propTypes = propTypes;
96+
97+
Offcanvas.Context = OffcanvasContext;
98+
Offcanvas.Body = OffcanvasBody;
99+
Offcanvas.Header = OffcanvasHeader;
100+
Offcanvas.Title = OffcanvasTitle;
101+
102+
export default Offcanvas;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import BaseView from '../../utils/rnw-compat/BaseView';
4+
5+
const propTypes = {
6+
children: PropTypes.node.isRequired,
7+
};
8+
9+
const OffcanvasBody = React.forwardRef((props, ref) => (
10+
<BaseView {...props} ref={ref} essentials={{ className: 'offcanvas-body' }} />
11+
));
12+
13+
OffcanvasBody.displayName = 'OffcanvasBody';
14+
OffcanvasBody.propTypes = propTypes;
15+
16+
export default OffcanvasBody;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import React from 'react';
2+
3+
const OffcanvasContext = React.createContext();
4+
5+
OffcanvasContext.displayName = 'OffcanvasContext';
6+
7+
export default OffcanvasContext;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import BaseView from '../../utils/rnw-compat/BaseView';
4+
5+
const propTypes = {
6+
children: PropTypes.node.isRequired,
7+
titleId: PropTypes.string,
8+
};
9+
10+
const OffcanvasHeader = React.forwardRef((props, ref) => (
11+
<BaseView
12+
{...props}
13+
ref={ref}
14+
essentials={{ className: 'offcanvas-header' }}
15+
/>
16+
));
17+
18+
OffcanvasHeader.displayName = 'OffcanvasHeader';
19+
OffcanvasHeader.propTypes = propTypes;
20+
21+
export default OffcanvasHeader;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import React, { useContext } from 'react';
2+
import PropTypes from 'prop-types';
3+
import invariant from 'fbjs/lib/invariant';
4+
import BaseView from '../../utils/rnw-compat/BaseView';
5+
import OffcanvasContext from './OffcanvasContext';
6+
7+
const propTypes = {
8+
children: PropTypes.node.isRequired,
9+
};
10+
11+
const OffcanvasTitle = React.forwardRef((props, ref) => {
12+
const offcanvas = useContext(OffcanvasContext);
13+
14+
invariant(
15+
offcanvas,
16+
'OffcanvasTitle can only be used inside an Offcanvas component.',
17+
);
18+
19+
return (
20+
<BaseView
21+
{...props}
22+
ref={ref}
23+
nativeID={offcanvas.identifier}
24+
accessibilityRole="heading"
25+
aria-level={5}
26+
essentials={{ className: 'offcanvas-title' }}
27+
/>
28+
);
29+
});
30+
31+
OffcanvasTitle.displayName = 'OffcanvasTitle';
32+
OffcanvasTitle.propTypes = propTypes;
33+
34+
export default OffcanvasTitle;

src/components/modal/useModal.js renamed to src/hooks/useModal.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { useState, useRef, useEffect, useMemo } from 'react';
22
import invariant from 'fbjs/lib/invariant';
3-
import useIdentifier from '../../hooks/useIdentifier';
3+
import useIdentifier from './useIdentifier';
44
import useScrollbarEffects from './useScrollbarEffects';
55

6-
export default function useModal(visible, setVisible) {
6+
export default function useModal(
7+
visible,
8+
setVisible,
9+
{ keepBodyScroll, bodyClass, centered },
10+
) {
711
const identifier = useIdentifier('modal');
812

913
const [mounted, setMounted] = useState(false);
@@ -17,6 +21,9 @@ export default function useModal(visible, setVisible) {
1721
useScrollbarEffects({
1822
modalRef: ref,
1923
active: mounted && visible,
24+
keepBodyScroll,
25+
bodyClass,
26+
centered,
2027
});
2128

2229
return useMemo(
@@ -29,7 +36,7 @@ export default function useModal(visible, setVisible) {
2936
trigger: ({ dismiss }) => {
3037
invariant(
3138
dismiss,
32-
"Modal/ModalContext cannot be used with prop 'toggle'. Please use prop 'dismiss' instead.",
39+
"Overlay cannot be used with prop 'toggle'. Please use prop 'dismiss' instead.",
3340
);
3441

3542
return {

0 commit comments

Comments
 (0)