๐ Portal์ด๋?
ํฌํธ์ ์ฌ์ฉํ๋ฉด ์ ํ๋ฆฌ์ผ์ด์
์์ญ์ ๋ฒ์ด๋ ํน์ ์์น์ ์ปดํฌ๋ํธ๋ฅผ ๋ ๋๋งํ ์ ์๋ค. ์ฆ, ์ง๊ธ๊ป root ์ปจํ
์ด๋์๋ง ๋ ๋๋ง์ ํด์๋ค๋ฉด ํฌํธ์ ์ฌ์ฉํ์ฌ root ์ปจํ
์ด๋ ์ธ๋ถ์๋ค๊ฐ๋ ์ปดํฌ๋ํธ๋ฅผ ๋ ๋๋งํ ์ ์๊ฒ๋๋ค.
ํฌํธ์ ํตํด ๋ ๋๋ง๋ ์ปดํฌ๋ํธ๋ DOM ํธ๋ฆฌ ์์น์ ์๊ด์์ด React ์ปดํฌ๋ํธ ํธ๋ฆฌ์ ํฌํจ๋๊ธฐ ๋๋ฌธ์ด๋ค.
๐จ ์ฌ์ฉ๋ฐฉ๋ฒ
1
| ReactDOM.createPortal(child, container);
|
- child๋ ๋ ๋๋งํ ์ ์๋ ์์
- container๋ DOM ์์์ด๋ค.
1 2 3 4 5 6
| render() { return ReactDOM.createPortal( this.props.children, domNode ) }
|
- ์ ๊ฒฝ์ฐ React๋ ์๋ก์ด div๋ฅผ ์์ฑํ์ง ์๊ณ domNode ์์ ์์์ ๋ ๋๋งํ๋ค.
- domNode๋ DOM ๋ด๋ถ์ ์ด๋์ ์๋์ง ๊ฐ์ ์๊ด์๋ค.
์๊ฐ์ ์ผ๋ก ์์์ ํ์ด๋์ค๋๋ก ๋ณด์ฌ์ผํ๋ ๋ค์ด์ผ๋ก๊ทธ, ํธ๋ฒ์นด๋, ํดํ์ ์ฌ์ฉ๋๋ค. ์ด ๋, ํค๋ณด๋ ํฌ์ปค์ค ๊ด๋ฆฌ์ ์ ๊ทผ์ฑ์ ๊ณ ๋ คํด์ค์ผํ๋ค.
๐ ๋ค์ด์ผ๋ก๊ทธ ์์
๋ค์ด์ผ๋ก๊ทธ ํน์ง
- ๋ค์ด์ผ๋ก๊ทธ๊ฐ ๋์์ง ์ํ๋ฉด ๊ทธ ์๋ ์์นํ ๋ด์ฉ์ ๋นํ์ฑํ ์ํ์ฌ์ผํ๋ค.
- ๋ค์ด์ผ๋ก๊ทธ ๋ฐ๊นฅ์ผ๋ก ์ด์ ์ด๋๋๋ฉด ์๋๋ค.
- dialog ์ญํ (role)์ ๋ถ์ฌํด์ผํ๋ค.
- ๋ชจ๋ฌ ๊ธฐ๋ฅ์ผ ๊ฒฝ์ฐ
aria-modal=true
์ด์ฌ์ผํ๋ค.
- ๋ค์ด์ผ๋ก๊ทธ ์ ๋ชฉ์
aria-label
, aria-labelledby
๋ก ์ค์ ํ๋ค.
1. ๋ถ๋ชจ ์ปดํฌ๋ํธ
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| export class DemoDialog extends React.Component { #opennerRef = React.createRef(null);
state = { show: false, };
handleShowDialog = () => { this.setState({ show: true }); };
handleHideDialog = () => { this.setState({ show: false }); };
render() { return ( <div className={styles.box}> <button ref={this.#opennerRef} type="button" className={styles.openDialogButton} aria-haspopup="dialog" aria-label="๋ชจ๋ฌ ๋ค์ด์ผ๋ก๊ทธ ์ด๊ธฐ" title="๋ชจ๋ฌ ๋ค์ด์ผ๋ก๊ทธ ์ด๊ธฐ" onClick={this.handleShowDialog} > ๋ชจ๋ฌ์ด๊ธฐ </button> {this.state.show && ( <Dialog modal onClose={this.handleHideDialog} openner={this.#opennerRef.current} > <Dialog.Header> <h3>๋ถ๊ธ ๋ค์ด์ผ๋ก๊ทธ</h3> </Dialog.Header> <Dialog.Body> <ul>...</ul> </Dialog.Body> </Dialog> )} </div> ); } }
|
- ๋ถ๋ชจ ์ปดํฌ๋ํธ์์๋ ๋ฒํผ ํด๋ฆญ์ด๋ฒคํธ๋ก ๋ชจ๋ฌ์ ์กฐ๊ฑด๋ถ ๋ ๋๋งํด์ฃผ๊ณ ์๋ค.
2. ๋ชจ๋ฌ ์ปดํฌ๋ํธ(์์)
root ์์๊ฐ ์๋ ๊ณณ์ ์์ ์ปดํฌ๋ํธ๋ฅผ ๊ทธ๋ ค์ฃผ๊ธฐ ์ํด์ public ํด๋์ index.html์ ๋ค์๊ณผ ๊ฐ์ด div ์์๋ฅผ ์ถ๊ฐํด์ค์ผํ๋ค.
1 2 3 4 5 6 7
| // public/index.html <body> <div id="root"></div>
<div id="dialogZone"></div> </boyd>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| const { documentElement: htmlElement } = document; const reactDomContainer = document.getElementById("root");
export class Dialog extends React.Component { #containerRef = React.createRef(null);
handleClose = () => { this.props.onClose?.(); this.props.openner.focus(); };
render() { const { modal = false } = this.props;
return createPortal( <> <article ref={this.#containerRef} tabIndex={-1} role="dialog" aria-modal={modal} className={styles.container} > {this.props.children} <Dialog.Footer onClose={this.handleClose} /> </article> {modal && <div className={styles.dim} onClick={this.handleClose} />} </>, document.getElementById("dialogZone") ); } }
|
- ์ฐ์ ํด๋น ๋ชจ๋ฌ ์ปดํฌ๋ํธ๋ฅผ ๊ทธ๋ฆด domNode๋ฅผ htmlElement๋ก ์ง์ ํด์ฃผ์๋ค.
- ์ปจํ
์ด๋ DOM ์์ ๊ฐ์ ธ์ค๊ธฐ ์ํด์ ref๋ฅผ ์์ฑํด์ฃผ์๋ค.
createPortal()
์ ๋ ๋๋งํ ์์ ์ปดํฌ๋ํธ์ ์์ ์ปดํฌ๋ํธ๋ฅผ ๋ ๋๋งํ ์ปจํ
์ด๋๋ฅผ ์ ๋ฌํ์๋ค.
- ๋ชจ๋ฌ์ด ์ผ์ ธ์์ ๋,
Dialog.footer
์์ ์์์๊ฒ onClose๋ฅผ props๋ก ์ ๋ฌํ์๋ค.
3. ์์ ์ปดํฌ๋ํธ ์ ๊ทผ์ฑ ๊ณ ๋ ค
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
| export class Dialog extends React.Component { ...
#tabbableElements = [];
#bindEscKeyEvents() { const handler = (e) => { if (e.key.toLowerCase().includes("escape")) { console.log("pressed esc key"); this.handleClose(); } };
document.addEventListener("keyup", handler);
return () => document.removeEventListener("keyup", handler); }
#unbindEscKeyEvents = null;
componentDidMount() { this.#containerRef.current.focus(); this.#tabbableElements = getTabbableElements(this.#containerRef.current); this.settingKeyboardTrap();
htmlElement.style.overflowY = "hidden"; reactDomContainer.setAttribute("aria-hidden", true);
this.#unbindEscKeyEvents = this.#bindEscKeyEvents(); }
componentWillUnmount() { htmlElement.style.overflowY = "visible"; reactDomContainer.setAttribute("aria-hidden", false);
this.#unbindEscKeyEvents?.(); }
settingKeyboardTrap() { const tabbles = this.#tabbableElements; const firstElement = tabbles[0]; const lastElement = tabbles[tabbles.length - 1];
firstElement.addEventListener("keydown", (e) => { if (e.shiftKey && e.key.toLowerCase().includes("tab")) { e.preventDefault(); lastElement.focus(); } });
lastElement.addEventListener("keydown", (e) => { if (!e.shiftKey && e.key.toLowerCase().includes("tab")) { e.preventDefault(); firstElement.focus(); } }); } }
|
- key ์ด๋ฒคํธ๋ฅผ ๋ฑ๋กํด์ฃผ๊ณ ์ ๊ฑฐํด์ค ๋, ํด๋ก์ ๋ฅผ ์ฌ์ฉํ์ฌ ์ด๋ฒคํธ ํธ๋ค๋ฌ๋ฅผ ๋์ผํ ์ฐธ์กฐ๊ฐ์ผ๋ก ์ผ์น์์ผ์ฃผ๋ฉด ํธํ๋ค.
- keydown ์ด๋ฒคํธ๋ฅผ ์ฌ์ฉํด์ผ์ง๋ง
e.preventDefault()
๋ก ๊ธฐ๋ณธ๋์์ ๋ง์ ์ ์๋ค.
- ๋ชจ๋ฌ ์ปดํฌ๋ํธ๊ฐ ์์ฑ๋์์ ๋, ํด๋น ์ปจํ
์ด๋(article)์ ์ด์ ์ ๊ฐ๊ฒํ๊ธฐ ์ํด ref๋ฅผ ์ ๋ฌํด์ค ๊ฒ์ด๋ค.
htmlElement.style.overflowY = "hidden"
๋ก ๋ชจ๋ฌ ์ปดํฌ๋ํธ๊ฐ ๋์์ ธ์์ ๋, ๋ค๋ฅธ ์์๋ ์คํฌ๋กค์ด ๋นํ์ฑํ์์ผ์ฃผ์๋ค.
- ๋ชจ๋ฌ์ด ์ผ์ ธ์์ผ๋ฉด root ์ปจํ
์ด๋๋ aria-hidden์ ์คํฌ๋ฆฐ ๋ฆฌ๋๊ธฐ์์๋ ๋ชจ๋ฌ๋ง ์ฝํ๋๋ก ํด์ค์ผํ๋ค.
- ์ปดํฌ๋ํธ๊ฐ ์๋ฉธ๋๊ธฐ ์ง์ ์ ๋ฑ๋กํ๋ key ์ด๋ฒคํธ๋ฅผ ์ ๊ฑฐํด์ค์ผํ๋ ๊ฒ์ ์์ง ๋ง์. (๊ณ ๋ คํ์ง ์๋๋ค๋ฉด ์ฑ๋ฅ์ ๋ฌธ์ ๊ฐ ์๊ธธ ๊ฒ์ด๋ค.)
4. ๋ชจ๋ฌ slot ๊ตฌ๋ถ
์ด๋ค ์ปดํฌ๋ํธ๋ค์ ์ด๋ค ์์ ์์๊ฐ ๋ค์ด์ฌ์ง ์์ํ ์ ์๋ ๊ฒฝ์ฐ๊ฐ ์๋ค. ์ด๋ด ๊ฒฝ์ฐ children prop์ ์ฌ์ฉํ์ฌ ์์ ์์๋ฅผ ์ถ๋ ฅ์ ๊ทธ๋๋ก ์ ๋ฌํ๋ ๊ฒ์ด ์ข๋ค.
ํฉ์ฑ(composition)์ ์ฌ์ฉํ์ฌ ์ปดํฌ๋ํธ ๊ฐ์ ์ฝ๋๋ฅผ ์ฌ์ฌ์ฉํ๋๋ก ํ์.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| Dialog.Header = function DialogHeader({ children }) { return <header className={styles.header}>{children}</header>; };
Dialog.Header.defaultProps = { children: <h2>React ํฌํธ๋ก ์ฐ ๋ค์ด์ผ๋ก๊ทธ(with ๋ชจ๋ฌ)</h2>, };
Dialog.Body = function DialogBody({ children }) { return <div className={styles.body}>{children}</div>; };
Dialog.Footer = function DialogFooter({ children, onClose }) { return ( <footer className={styles.footer}> <button type="button" className={styles.closeButton} aria-label="๋ชจ๋ฌ ๋ค์ด์ผ๋ก๊ทธ ๋ซ๊ธฐ" title="๋ชจ๋ฌ ๋ค์ด์ผ๋ก๊ทธ ๋ซ๊ธฐ" onClick={onClose} > ๋ชจ๋ฌ๋ซ๊ธฐ </button> {children} </footer> ); };
|