๐Ÿ“Œ 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 ๋‚ด๋ถ€์˜ ์–ด๋””์— ์žˆ๋˜์ง€ ๊ฐ„์— ์ƒ๊ด€์—†๋‹ค.

์‹œ๊ฐ์ ์œผ๋กœ ์ž์‹์„ ํŠ€์–ด๋‚˜์˜ค๋„๋ก ๋ณด์—ฌ์•ผํ•˜๋Š” ๋‹ค์ด์–ผ๋กœ๊ทธ, ํ˜ธ๋ฒ„์นด๋“œ, ํˆดํŒ์— ์‚ฌ์šฉ๋œ๋‹ค. ์ด ๋•Œ, ํ‚ค๋ณด๋“œ ํฌ์ปค์Šค ๊ด€๋ฆฌ์™€ ์ ‘๊ทผ์„ฑ์„ ๊ณ ๋ คํ•ด์ค˜์•ผํ•œ๋‹ค.

๐ŸŒˆ ๋‹ค์ด์–ผ๋กœ๊ทธ ์˜ˆ์‹œ

๋‹ค์ด์–ผ๋กœ๊ทธ ํŠน์ง•

  1. ๋‹ค์ด์–ผ๋กœ๊ทธ๊ฐ€ ๋„์›Œ์ง„ ์ƒํƒœ๋ฉด ๊ทธ ์•„๋ž˜ ์œ„์น˜ํ•œ ๋‚ด์šฉ์€ ๋น„ํ™œ์„ฑํ™” ์ƒํƒœ์—ฌ์•ผํ•œ๋‹ค.
  2. ๋‹ค์ด์–ผ๋กœ๊ทธ ๋ฐ”๊นฅ์œผ๋กœ ์ดˆ์ ์ด๋™๋˜๋ฉด ์•ˆ๋œ๋‹ค.
  3. dialog ์—ญํ• (role)์„ ๋ถ€์—ฌํ•ด์•ผํ•œ๋‹ค.
  4. ๋ชจ๋‹ฌ ๊ธฐ๋Šฅ์ผ ๊ฒฝ์šฐ aria-modal=true ์ด์—ฌ์•ผํ•œ๋‹ค.
  5. ๋‹ค์ด์–ผ๋กœ๊ทธ ์ œ๋ชฉ์€ 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
// Dialog.jsx
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);

// cleanup function
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>
);
};